Skip to content

Commit d47d956

Browse files
Target rows become executable, and a machine-scoped scan stops being paid per target (2026.9.11.3) (#610)
* docs(record): ecosystem sign-off, written against what shipped Section 9. Four findings that are about the ecosystem rather than about any one change: * A proxy check refused correct output TWICE IN TWO REPOSITORIES on the same day -- the MSI size ratio and, earlier, the AppImage size bound. Both are now direct questions: the AppImage is run and its output asserted, the MSI is installed and the extracted program compared byte for byte. * The #599 fix reproduced the shape of the defect it fixed. It reasoned about which shard runs 233 instead of enumerating the jobs that run the suite, and the macOS lane caught the third one. The correction is an enumeration, which can be re-checked. * The ecosystem rule cost one new payload and otherwise only declarations. The interesting case is `xim:python`: its x86_64-only table was the STATED reason emsdk could not declare an interpreter -- an argument that was true and was a request for a package, not a licence to use the host. `/dev/kvm` is where the rule stops, and a test asserts it is the only warning left. * The rule composes with the licence, not above it: the ecosystem supplies what it may, and the licence says what it may. Getting that order wrong is asymmetric -- three GitCode objects were uploaded before the licence was checked, GitCode assets cannot be deleted, and the only remedy is that no recipe references them. Also states what is still open rather than implying it: the four target rows are the next PR with their size named, and `xim:wix` is a gap with a known shape. * docs(record): the wasm row's numbers were two releases stale, and the upgrade path is measured across two binaries THE CORRECTION LANDED IN THE RECORD AND NOT IN THE CODE. The design document carries the re-taken 2026-09-11 measurement -- Emscripten 6.0.9 reports `_LIBCPP_VERSION 220108` (llvm 22.1.8) and clang 24.0.0git -- while the `wasm32-emscripten` row's own comment still said llvm 20.1.7, `200100` and clang 22.0.0git. Two copies of one decision, and the second written without reading the first. The rule those numbers support is unaffected and is exactly why they have to be right: the module surface must match the LIBRARY, never the compiler. A stale number in a comment becomes a stale number in a diagnostic, and then in somebody's install command. Also simpler than the comment first said: 6.0.9 SHIPS the surface (134 files), so `em++` links `import std` with no generated surface at all. SECTION 11.8 GAINS A TWO-BINARY MEASUREMENT. An absent-tolerance claim is about what a previous version wrote, so a unit test over a synthesised header is not the whole criterion: 1. released 2026.9.10.2 writes `graph=normal;schedule=none;accel=default` 2. 2026.9.11.2 appends `;dist=none` -- fingerprint change, full rebuild, no error 3. 2026.9.11.2 again -- `Finished dev in 0.00s`, the fast path replays 4. 2026.9.10.2 again -- `0.00s`, the downgrade does not choke Step 4 says what the tolerance is worth: the version is in the fingerprint, so two binaries never share a graph directory and an older mcpp never reads a `dist=` field at all. The read side is defence in depth rather than a live path -- the same conclusion 11.2 reaches from the other direction. * docs(targets): where a platform's knowledge belongs, reviewed against four other build systems The ownership question for iOS, Android and Web, answered against what cargo, Zig, CMake, Bazel and the platform tools actually do rather than against intuition, and against the seven engine changes the wasm row required. The comparison is more reassuring than not. rustc's target list is closed and in the compiler, which is what `kKnownTargets` is, and its tiers map almost exactly onto this table's -- Tier 1 is "built AND tested", Tier 2 "guaranteed to build" with tests not always run, Tier 3 code with no guarantees. That produces a calibration: all three of these platforms are Tier 2 in Rust, so `verified` for wasm is a STRONGER claim than Rust makes for its own wasm target and `planned` for Android and iOS is weaker than Rust's. Zig is the strongest counter-model and draws the same line: it bundles libc for 97 targets and ships neither the Apple SDK nor the Android NDK. CMake puts the target knowledge in a file the SDK ships, which is why its cross-compilation is per-SDK folklore. Bazel registers platforms from external rulesets. And nobody reimplements a packaging format. THE RUNNER SECTION IS THE PART THIS SETTLES RATHER THAN FLAGS. Running an artefact has three shapes -- it runs itself (wasm, via a shebang), a translator wraps it (qemu-user), or a session exercises it (emulator, simulator, device) -- and mcpp's `runner` is an argv prefix that covers the first two and cannot express the third. Cargo's reference states the boundary explicitly: managing devices and simulators is out of scope and "that responsibility falls to the runner program itself". So the session is a xim PACKAGE shipping a program, not an engine feature and not a plugin -- a plugin is a build-time module and a runner is a run-time program, which is a difference in lifetime rather than in subject. And the simulator is a TARGET, not a runner. Six recommendations are stated as decisions, four of which are engine-side and none of which is in a plugin -- which is itself the finding: the plugin boundary is already right, and what is unfinished sits upstream of it. * docs(targets): the target name is a normalisation, the SDK binding differs by platform, and codesign was never a host dependency Three questions answered by measurement rather than by recall. IS `wasm32-emscripten` STANDARD. No -- the industry name is the four-field `wasm32-unknown-emscripten`, which is what `em++ -v` passes to its own clang on this machine and what rustc's table lists. mcpp's short form is its own canonical spelling, the same vendor elision it already performs for `aarch64-macos` and `x86_64-windows-gnu`, with `llvm_triple()` restoring the field. That is defensible and should be stated as what it is rather than implied to be the common name -- hence R8: `parse()` should accept the four-field spelling every other toolchain prints. And the wasm family is nine targets in rustc, which settles the simulator question from an unexpected direction: Rust appends a FOURTH component for a variant (`-threads` there, `-sim` for iOS), and mcpp's `env` is exactly that slot, already used that way by `gnu`, `musl` and `eabihf`. `env = "sim"` is the field doing its job, not a workaround. IS THE TOOLCHAIN BOUND TO THE SDK. Two answers, not one. Emscripten and the NDK each ship compiler and sysroot as ONE archive, which is precisely the set `has_own_sysroot()` names -- the predicate is not an arbitrary pair. iOS does not: any sufficiently new clang plus `-isysroot`, which makes the row structurally the SAME SHAPE as bare metal (a generic llvm pin plus a separately-versioned sysroot package), so the `sysroot` column already exists for it and the row needs no new table machinery. Also worth stating because the naming misleads: "the Android SDK" names two unrelated things, and the four xim packages split on what Google actually ships rather than on a decomposition choice. WHAT MUST GO THROUGH THE HOST. Exactly three things, and a falsifiable test for them: a kernel facility, a proprietary RUNTIME that exists only on its own OS, or a credential. `/dev/kvm`, the Simulator or a device, and notarization. Everything that is a PROGRAM can be packaged -- and the survey found the third instance of this ecosystem mistaking a program for a host dependency: `rcodesign` (crate apple-codesign, MPL-2.0) signs Mach-O binaries, `.app` bundles, `.pkg` and `.dmg` with no Apple hardware or OS, and ships prebuilt static binaries for linux-musl on both arches, macOS universal and Windows. So `codesign` is not a host dependency; it is an unpackaged one (R7). The test also narrows R4: `msvc@system` is the (b) case, and the existing refusal is right about the general shape and wrong only about believing MSVC is its sole instance. * docs(targets): signing closes in the ecosystem, and the iOS runtime is blocked by an image licence rather than by tooling SIGNING BELONGS IN THREE LAYERS THAT ALREADY EXIST, in this order: `rcodesign` is a program so it is a xim package; what invokes it is a `dist-*` member; what the user types is `mcpp pack --format <name>`. Release 0.29.0 signs BUNDLES and not only flat Mach-O binaries -- its changelog discusses `--shallow` bundle mode and child-bundle signing "compatible with the behavior of Apple's codesign" -- which is exactly what a `.app` inside an `.ipa` needs. `--format ipa` needs NO NEW TOOL AT ALL: an `.ipa` is a zip containing `Payload/<Name>.app/`, so clang plus the iPhoneOS SDK produce the Mach-O (both xim), `dist-apple` assembles the bundle, `rcodesign` signs it, and a zip step finishes. So iOS PACKAGING closes entirely inside the ecosystem. `--format dmg` and `--format pkg` each need a creator as well as a signer (`libdmg-hfsplus`, `xar` -- both open source, neither measured), and are recorded as gaps with a known shape rather than claimed. THE iOS RUNTIME BLOCKER IS A LICENSED IMAGE, NOT A MISSING EMULATOR, and the obvious answer is wrong in an instructive way. QEMU can emulate ARM iOS hardware and community projects boot iOS on it; what none can supply is the iOS kernel and root filesystem, because distributing iOS images is against Apple's terms. The emulator is not the scarce thing. That is the SAME SHAPE as the Android question with the opposite answer, and the comparison is the point: Android's emulator is Apache-2.0 and its `default` images are AOSP, so the loop closes -- and `qemu-aarch64 -L` needs no emulator at all. iOS cannot close, for a reason that is not about mcpp, xlings or effort. It is the one row whose execution is blocked by a licence rather than by work. On macOS the Simulator is the host's and that is correct: it is a (b)-category proprietary runtime and `xcrun simctl spawn` is an argv prefix, so section 5's model already covers it once R1 gives the simulator a row. And Darling is recorded as an UNMEASURED candidate for the macOS rows -- GPL-3.0, actively developed, and it reimplements Darwin's libraries rather than redistributing them, so unlike the iOS image it carries no licence blocker. A row does not move on a plausible mechanism. * docs(targets): real devices close for both platforms, which supersedes the simulator route ASKED DIRECTLY AND IT IS THE STRONGEST RESULT IN THE SECTION: running on real hardware needs no Apple or Google software on either platform. Android adb push + adb shell Apache-2.0, already packaged iOS pymobiledevice3 GPL-3.0, pure Python 3, no compiled extensions, Linux/Windows/macOS, 2736 stars, pushed 2026-09-10 iOS libimobiledevice LGPL-2.1, the C library it models, 8177 stars, pushed 2026-06-10 pymobiledevice3 requires no Xcode, works with the system usbmuxd, and covers app management plus iOS 17+ tooling over a tunnel; libimobiledevice needs no jailbreak. So the iOS row's execution story is not "needs a device on a Mac": build, bundle, sign, package, deploy and run all close, and the ONLY line that does not is a provisioning profile and signing identity -- a credential, which is category (c) and which no package manager closes. That is the same boundary a developer already lives with under Xcode. It is materially better than the simulator route and the reason is worth stating: the simulator is blocked by a licensed IMAGE that cannot be redistributed, while a real device supplies its own OS and the only thing crossing the boundary is a signature the developer already owns. WHERE A DEVICE SESSION LIVES: the runner program absorbs deployment, so no fourth verb is needed -- "adb push" followed by "adb shell" is one operation from mcpp's side. Both mechanisms are used for what each is: a PLUGIN produces the artefact (dist-ipa, build time), a PACKAGE deploys and runs it (the runner program, run time), and the ENGINE names the runner (the existing "runner" key). The ordering is a real dependency, not a convention: the iOS device runner has nothing to install until dist-ipa has produced a signed file, so R9 precedes R12. THE iOS IMAGE IS A LOCATOR AND NEVER A RE-HOST. An image in a public index is redistribution of Apple's operating system whatever it is labelled, and this differs from the Android decision earlier in this document in a way worth stating precisely: there, Apache-2.0 licence files were verified INSIDE the archives and clause 3.5 genuinely applies; here there is no open-source component to invoke. The legitimate form is the locator tier iphoneos-sdk.lua already documents -- the index carries a path and a probe, not bytes -- and R4 is its engine half. With R12 in place no image is on the critical path at all. * docs(targets): the runner program owns the image path, because a path is a machine fact and a declaration is not Three homes for an iOS image path were considered, and two are wrong for reasons worth recording because each looked right first. A LOCATOR PACKAGE has nothing to probe. Locators work where the thing has a conventional location -- vswhere, /Applications/Xcode.app -- and an image a user legally owns is wherever they put it. Its version axis would be meaningless too: a locator for 18.0 cannot verify that what it found is 18.0. THE PATH IN build.mcpp FAILS ON THIS REPOSITORY'S OWN ANALYSIS. A build program is per-package, committed, and its declarations are persisted in the cache record; a path is per-MACHINE, not committable, and must not be a build input or identical sources produce different build directories. That is exactly the precedence #564 established for `[build] default_jobs` -- invocation > project > machine -- and an image path sits on the machine level as a job count does. And a sharper failure: a variable read through `env_or` is NOT in the build program's contract environment, so it is not in its re-run key. Change the path and the program does not re-run, while the cache record persists the old runner (tag "runner" is non-empty). A path that was changed and appears not to have been is the defect class recorded most often here, and this would introduce it deliberately. THE DESIGN: the runner PROGRAM owns the path, resolved at run time. The engine names a runner and does not know what an image is; the index ships the emulator and no image; the plugin declares WHICH runner and produces the artefact; the program reads its own configuration each invocation. The path enters mcpp at no point -- not the index, the build program, the cache record or the fingerprint. That is the boundary cargo states for itself and which section 5.2 already quotes, applied one level further: "that responsibility falls to the runner program itself." It dissolves the staleness rather than mitigating it, because a run-time resolution has no record to go stale. The distinction the section arrives at is one sentence: declaring a NAME is cache-safe, baking a PATH is not. * Target rows become executable, and a machine-scoped scan stops being paid per target (2026.9.11.3) WASM32-EMSCRIPTEN IS `verified`. `mcpp run --target wasm32-emscripten` prints `1-2-3` on a source that imports std, from the row alone -- and the project side needed NO new vocabulary. That is the first real measurement of what this design costs: seven engine changes, and a project that builds for Linux builds for the web by changing one flag. Each of the seven was found by the previous one's failure, and each was a predicate that was CORRECT about the rows its author had in mind: 1. the payload chosen by the TARGET in `to_xim_package`, or `xim:llvm` answers for a wasm target 2. `XimToolchainPackage::frontendSubdir`, or `em++` is looked for in `bin/` 3. `Triple::has_own_sysroot()` in the SHARED PRODUCER `host_compile_tokens`. The first attempt put it at one of its three callers, so ordinary compiles stopped taking this host's headers and the std module precompile went on taking them -- which is where it had been failing. One decision, one site, three readers. 4. the same predicate in `resolve_link_model` -- the MODEL, not its two channels; the comment at the second channel already recorded that a reader who fixed only the first "saw the identical error" 5. the same finding twice: `discover_link_runtime_dirs` put the COMPILER's own runtime directories on the ARTEFACT's link line. For every row that predates these two, those are the same directory. 6. `host_can_serve` -- the first version returned true unconditionally, the same over-broad shape as the branches above it. The TARGET MATRIX caught it: `xim:emsdk` publishes linux only. 7. `Format::Wasm`, which the module PREDICTED AND DEFERRED in its own comment. While it was missing, every wasm build warned about a `libc++.so` that cannot exist for the target. Wasm also joins the capability pins: nothing else emits WebAssembly, so a declared `gcc@16.1.0` is a request that cannot be honoured, and saying so beats resolving gcc and failing inside it. A MACHINE-SCOPED SCAN WAS PAID PER TARGET: 17948ms -> 4ms. mcpp build loader-tags stage 170ms one 21 MB binary mcpp test loader-tags stage 17948ms 108 binaries, 2.4 GB and flat at ~17.9s across every target measured, so the cost is the whole artefact set rather than the one being built. `mcpp test` drives the backend once per target, so a 110-target suite paid it 110 times -- 3 minutes became a projected 33. `check_dlopen_surface` ran a full `inspect_elf_runtime` over EVERY linked artefact to collect SONAMEs, before it discovered the surface was empty. Its record said `members=0, walked=0` every time, and the artefacts are executables, which cannot carry `DT_SONAME` at all. Both halves fixed, and the shape is worth naming: the expensive work ran BEFORE the cheap test that makes it unnecessary. The record is still published -- a field that disappears is worse than one that says why it is empty. `min_api_level`, REUSING THE MECHANISM `macos_deployment_target` ALREADY HAS. Measured: `clang -target aarch64-linux-android21 -print-effective-triple` answers `aarch64-unknown-linux-android21`, so the level belongs on the effective triple -- and NOT on the canonical one, because mcpp keeps its own target vocabulary and maps it. macOS is already this shape. So `[target.<triple>] min_api_level = 24`, fused by the parameter `llvm_triple()` already takes, and in the FINGERPRINT because the level selects which bionic symbols are visible. The key's slot is renamed `minPlatformVersion`: a target is either Apple or Android, so one slot cannot hold both, and both answer one question. The name comes from Android's own words -- the NDK's CMake toolchain documents `ANDROID_PLATFORM` as "the minimum API level supported by the application or library". `ndk_api_version` was rejected twice over: "version" is not Android's word, and `ndk_` names the TOOLCHAIN while one NDK serves a RANGE of levels. THE FOUR-FIELD SPELLING IS ACCEPTED. `em++ -v` passes `-target wasm32-unknown-emscripten` and rustc lists that spelling; refusing what every other toolchain prints is a cost with no benefit. `str()` still returns the three-field form, which is what keeps the output directory, `cfg()` and the ABI tag single-valued. AND THE EOL DISTRO LEG IS SWAPPED. debian-11 fails with `E: Release file ... is expired` -- bullseye is end-of-life and its security metadata has expired, which is a property of the distribution. Measured while replacing it: debian 11 and ubuntu 20.04 both carry glibc 2.31, so the "older glibc" coverage was ALREADY duplicated by the ubuntu-2004 leg. Bookworm's 2.36 sits between that and debian-testing, so the leg now covers a point the matrix did not have. * fix(targets): a capability pin explains its own row, and the resolved line names the payload THE RULE IS SHARED AND THE REASON IS NOT, which `prepare.cppm` already said: "one sentence covering both would be wrong about one of them: a PE+musl target is not bare metal, and a reader told it is stops reading." A third row was then added without a third reason, so the sentence became wrong about the new one. Measured: `--target wasm32-emscripten` with a declared gcc was refused CORRECTLY and explained with "No gcc payload emits a PE with a musl C library" -- a true sentence about a different row. AND THE GATE ASKED THE WRONG QUESTION, which is the defect a reader would not think to look for. It tested `family != Llvm`, right while every capability-pinned row pinned llvm. `wasm32-emscripten` pins `emsdk@6.0.9`, and emsdk normalises to the LLVM FAMILY because `em++` is clang -- so a declared `llvm@22.1.8` passed the gate, was never refused, and resolved the generic llvm payload for a target it cannot emit. The condition is now the row's own pin, which is the question it was always answering. The closing line names that pin too, instead of the fixed word "llvm". `tests/e2e/640` holds five properties: each row's own sentence, the declared-llvm gap, and the closing line naming this row's pin. Against released 2026.9.10.2 the three wasm cases go red and the two pre-existing rows stay green. THE RESOLVED LINE NAMES WHICH PAYLOAD ANSWERED. `emsdk@6.0.9` printed as `Resolved llvm@6.0.9` -- indistinguishable from the real `xim:llvm`, and not what the user typed. `mcpp toolchain list` has the same problem and the matrix scan takes one toolchain per family, so two llvm-family payloads on one host could not both be enumerated. The family and the payload are two questions -- which flag vocabulary, and which archive -- and `to_xim_package` already answered the second from the target. `ToolchainSpec::payloadName` is the field that lets it be said. Empty means the family's own payload, which is every row but these two, so no existing output moves. AND THE MATRIX EXPECTATIONS ARE THE TRUTHFUL READING RATHER THAN THE AMBITIOUS ONE. Naming `emsdk@6.0.9` in the declared-toolchain column made the scan try to INSTALL it on every host -- `emsdk@6.0.9 装不上,而期望表声明了它` -- because that column is the axis the scan installs from. With wasm a capability pin, the verdict for a declared gcc or llvm is `capability-pin` on a host that can serve the row and `host-cannot-serve` on the two where `xim:emsdk` is not published. Verified against the engine rather than assumed: gcc refused, llvm refused, emsdk resolves and builds. 109 unit tests pass; 638, 639, 640 and 233 pass. * feat(targets): the Android rows build, and the two halves of one goal stop disagreeing `mcpp build --target aarch64-linux-android` produces a real Android artifact with no project vocabulary beyond `--target`: aarch64-linux-android -> ELF 64-bit LSB pie, ARM aarch64, interpreter /system/bin/linker64 x86_64-linux-android -> ELF 64-bit LSB pie, x86-64, same interpreter Both rows share ONE pin, `android-ndk@30.0.16248370`, and that is where every defect below came from: the NDK names no architecture, `--target` does, so each site that fails to say which target is exposed here and by nothing else. On wasm none of them appear, because `em++` has exactly one target. `preview`, not `verified`: both were BUILT and neither was EXECUTED, which is the difference between the two tiers. ## Five engine gaps, each found by the previous one's failure 1. The std module's PRECOMPILE never received `--target`. `stdModuleTargetFlags` reached only the codegen command, on the reading that step one needs headers and step two needs the machine. Step one needs both: a `--precompile` that does not say the target resolves the standard library's own `#include <__config>` against the building machine. This file already recorded the identical error text from a Windows host in 2026-08 -- same cause, different route. The precompile now takes the machine from whichever source carries it, preferring `stdModuleFlags` because it is a superset. 2. `-D__BIONIC_CTYPE_INLINE=`. bionic declares `isalnum` and its neighbours `static inline`; libc++'s module surface exports them with `using std::isalnum`, and a using-declaration cannot export internal linkage, so the precompile fails on 14 names at once. Scoped to the std module: the rule being satisfied is about exporting from a module, and a unit that includes <ctype.h> directly is entitled to bionic's inline definitions. 3. "A toolchain that ships its own sysroot is told nothing" was one token too strong, and this function already said so further down. The early return stood in front of "THE TRIPLE, SAID OUT LOUD", which states the opposite rule for the same reason. Both are right about their own object: the SYSTEM is the payload's, WHICH TARGET is still mcpp's to say. Reported by the module loader rather than by either compile, then eight cascading "use of undeclared identifier 'std'" lines, which is what a reader sees first. 4. The LINK line had no target either. Both link branches are skipped for such a target and that is correct for what they carry -- C library, C++ runtime, crt objects and loader are all inside the SDK. What the driver cannot do is guess which. Falling through empty linked the target's objects with the host's startup files: six host objects on an aarch64 link. The new third branch carries `crossTarget` and nothing else. 5. `discover_link_runtime_dirs`' gate was evaluated at the wrong time. The gate is right; it runs during DETECTION, before the target is assigned, when `targetTriple` is still the host's. The artifact is what showed it: `-L <ndk>/.../prebuilt/linux-x86_64/lib/x86_64-unknown-linux-gnu`, whose last component is this machine's triple, so the string names the question asked. ## Two artifact checks called a correct artifact a defect Same shape both times: a rule true of a HOST-TARGETED artifact, applied to a cross one. - The hermetic link check counted `/system/bin/linker64` as outside the sandbox. It is the one path this function inspects that is NOT resolved here: it is recorded in the artifact and read by the DEVICE. Android's is fixed by ABI and cannot be inside a payload. The message was accurate about what it saw and wrong about what it meant, and it invited the reader to reinstall an unrelated glibc payload. - Runtime closure rule B compared `PT_INTERP` against the HOST's `RuntimeBinding glibc@2.44` and reported a proven defect -- "one process cannot mix runtime payloads" is a true sentence about a process that will never exist. The linux/glibc guard above it does not cover this: an Android triple's `os` IS `linux`, deliberately. ## host_can_serve stops compiling "Linux" into the engine The predicate returned `mcpp::platform::is_linux` for own-sysroot rows, and its own comment named the expiry: "when a darwin or windows NDK lands in the index -- upstream publishes both -- this is the one line that changes." It landed. Two halves of one goal then disagreed: the index published the payload on three hosts while the engine deleted the row from `toolchain list` on two of them. The symptom was not a wrong answer but an ABSENT one -- on macOS, `mcpp build --target wasm32-emscripten` reported a target this table knows as one it had never heard of, which is what the `planned` tier exists to avoid. Neither half is where it was found. `scan (macos-arm64)` and `scan (windows-x86_64)` failed on a CELL COUNT, 24 measured against 25 declared, and the one missing cell named the row. A per-host job comparing against a checked-in table is the only thing here that can see a row disappear. That predicate's unit test was ALSO true by arithmetic on one host: `EXPECT_EQ(host_can_serve(*wasm), mcpp::platform::is_linux)` passes on Linux because there `is_linux` IS `true`. A criterion whose expected value is the host it runs on cannot report a change on the other two. Now stated unconditionally, with the "this predicate can still say no" half restored. ## The capability-pin reason fell through a second time Android became a capability row while the reason chain still had three arms, so it was explained with the PE+musl sentence -- the same wrong answer recorded for wasm, reached the same way: a fourth case falling into an `else` written as the third's answer. So the last arm now names its own row and the fallthrough is generic; a capability added later gets a sentence that is merely unspecific instead of false. Android's reason is not the other three's. They are refused because the toolchain cannot emit the FORMAT; a stock clang emits aarch64 ELF perfectly well. What it cannot supply is bionic, and no package adds that to another compiler. ## The simulator is a row, not a runner `env = "sim"` gives `aarch64-ios-sim` and `x86_64-ios-sim` -- Rust's pair modulo the vendor segment this table already elides. Apple's own `-simulator` spelling parses to the same row, because clang prints that form and a reader who pastes it back must not be told mcpp has never heard of it. Both arches, because the simulator runs the HOST's architecture. Both `planned`: the blocker is the same licence question as the device row, not a payload. ## The API level's default comes from the payload `min_platform_version` returned an empty string with the comment "the NDK's own default, which clang supplies". Never verified, and false: bionic's <sys/cdefs.h> refuses an unversioned triple outright. So the level is mandatory and a project that never heard of API levels still needs one. It comes from the NDK's own `meta/platforms.json` -- `{"min": 21, "max": 37}` for r30 -- so a newer NDK changes the default by being installed rather than by being edited into this file. 0 when unreadable, which the caller turns into a refusal naming `min_api_level`; a guessed level would be worse, because it selects which bionic symbols exist. The same false claim had a second copy in `llvm_triple()`'s comment and is corrected there too. ## Two vocabulary consequences of the rows leaving `planned` - A bare `aarch64-linux` must never complete to Android. The rows share the `arch-os` prefix because the kernel IS Linux; that does not make bionic a candidate C library for a request that named none. `aarch64-linux` had two supported siblings and resolved as ambiguous, where it had completed to `aarch64-linux-musl` before, and both outcomes of that ambiguity are wrong. Excluded from `siblings` too, since that list is what the diagnostic prints. - `aarch64-unknown-linux-android21` now parses. mcpp PRINTS that string one line above the build it describes, and pasting it back answered `unknown target`, because the env match read `k == "android"` while the API level rides that segment. The msvc branch below carries the identical note for the identical reason; Android was missed. One prefix covers all four spellings. ## Tests - `tests/e2e/641` asserts the vocabulary half, which is what a runner without a 704 MB NDK can assert: both rows name the payload, neither is refused for its tier, the simulator rows say `planned` rather than `unknown`, the effective triple parses back, a bare `aarch64-linux` never mentions android, and `min_api_level` has a floor refused by name. - `tests/e2e/640` case 6 is EXHAUSTIVE, because adding an arm is what keeps failing: it takes every pinned row from `toolchain list`, declares a toolchain that is not its pin, and asserts the PE+musl sentence appears for exactly one. Measured: 17 capability rows of 34 targets, PE+musl printed once. The denominator comes from the engine, so a row added tomorrow is in it without this file being edited -- and it agrees with the unit test's independently written set of 17. - `tests/matrix/expected.tsv`: the Android cells move to `capability-pin` and the wasm cells on macOS/Windows move off `host-cannot-serve`, because the refusal is now the row's rather than the host's. `android-ndk` deliberately does NOT enter the declared-compiler column: the scan installs from that axis. Verified locally, 54/54 payload and 28/28 graph cells for linux-x86_64. - 109 unit test binaries pass. Three tests failed first and each was correct to: one asserted the Android rows were planned, one asserted Android was not a capability pin with the note "it moves when the row does", and one asserted a bare `aarch64-linux` completion that two new supported siblings had made ambiguous. * feat(targets): x86_64-linux-android is verified — the artifact ran on the platform's emulator `adb push` then `adb shell ./andtest` printed `1-2-3`, exit 0, on an API 24 x86_64 system image under Google's own emulator with KVM, from a source that imports std and no project vocabulary beyond `--target`. So one pin serves two rows at two tiers, and the difference is execution rather than confidence in the build. The device row stays `preview` and the reason is recorded where the next attempt will look: the emulator refuses a foreign guest outright, and the documented qemu-user fallback needs four files extracted from an ext4 partition image by `debugfs` -- the one program in `xim:e2fsprogs@1.47.3` that is a broken build. That is an ecosystem defect with its own record in the index, and fixing it moves this row without an engine change. The docs now also reconcile the two records that would otherwise disagree: the support table's Windows cell for both Android rows is `—` because the index publishes no Windows NDK (its archive ships no libc++ module surface), while `mcpp toolchain list` still shows the rows there, because which hosts an index serves is the index's answer and not a constant compiled into the engine. * feat(examples, docs): the curriculum covers the new targets, and a warning stops advising what is now refused EXAMPLE. `examples/13-platform-targets` is one source with no `cfg`, built for Linux, WebAssembly and both Android ABIs by changing only `--target`. The curriculum had twelve examples and none for any of the three platforms this release adds, which is a gap in the one place a reader looks first. Its host build needs no payload, so it is in BUILD rather than SKIP -- and that is itself worth one build: a manifest naming targets the runner has no payload for must still parse and build for the host. The cross legs are not built there; ci-target-matrix scans every row on four hosts and tests/e2e/641 asserts the vocabulary. Every claim in its README is measured against the artifact the README describes, not carried over from another one: the host and wasm runs print `1-2-3`, both Android artifacts are the file(1) output shown, and the x86_64 artifact was pushed to an API 24 emulator image and executed there. A MISLEADING WARNING, FOUND BY BUILDING openkal FOR ANDROID. When a project's target side comes from the dependency graph, mcpp warns that the row's convention pin was unnecessary and tells the reader to declare their own default instead. On a capability row both halves of that are false: warning: ... so gcc@16.1.0 would have served x86_64-linux-android. State the preference: [target.x86_64-linux-android] toolchain = "gcc@16.1.0" $ (declaring exactly that) error: target 'x86_64-linux-android' cannot be emitted by 'gcc@16.1.0'. Following the advice replaced a warning with an error. The first claim is false on its own terms too: this gcc payload cannot emit an Android object whatever the graph supplies. `graph` answers "who supplies the SYSTEM"; a capability pin answers "who can emit the FORMAT AND THE SYSTEM", and only the second decides whether a substitution was avoidable. The warning is now gated on `pin_is_capability()`, whose row set is asserted exhaustively in test_toolchain_triple, so the gate cannot silently cover every row. DOCS: docs/24-openkal-cross.md (+zh) now answers what the three new platforms mean for openkal, because the answers differ and the difference is where an implementation sits relative to a C library. Android shares openkal-linux UNCHANGED. It is written on the Linux kernel's own system calls and borrows nothing from a C library; Android's kernel IS Linux, the per-arch syscall ABI is identical, and sys.h dispatches on __x86_64__/__aarch64__ -- architecture, not OS. `cfg(os = "linux")` is true for an Android triple, so a portable program selects it with the line a Linux consumer already writes. Measured: openkal-linux compiles for both ABIs, and a program written against openkal alone -- no C library, no `import std` -- printed `openkal: 1-2-3` with exit 0 on an API 24 emulator image. iOS would share openkal-macos by the same argument, and the argument is not evidence: the SDKs are not redistributable, so there is nothing to build against and the rows claim nothing. Web needs a NEW implementation and a different shape. Emscripten has no kernel and no system calls; an implementation would have to sit ABOVE its C library, which the specification permits and which is new software rather than a sharing decision. Until then the row is served the ordinary way, by the `xim:emsdk` payload, which is what its `verified` tier records. The docs/21 support table and the two Android rows' Windows cells were also reconciled with the index: `—` there means no payload is published for that host, while `toolchain list` still shows the rows, because which hosts an index serves is the index's answer and not a constant compiled into the engine. * test(targets): the two pure producers the Android path changed now have criteria THE PRE-MERGE REVIEW'S OWN FINDING. Mapping every behaviour change in this PR to a criterion left seven without one, all of them "the Android build works" -- testable only with a 704 MB payload CI does not have. Two of the seven are pure functions of a `Toolchain` and needed no payload at all, which makes them the two worth closing. `std_module_build_commands`: the PRECOMPILE carries the machine flags when only `stdModuleTargetFlags` has them, and states the target exactly ONCE when `stdModuleFlags` also does -- the superset case, where concatenating both would put `--target=` on the command line twice. `host_compile_tokens`: an own-sysroot target receives EXACTLY the target flag and nothing else. Asserted as the whole vector rather than as "contains", because the property is that nothing else is emitted: this host's glibc headers reaching a wasm compile is the measured failure the gate exists for. THE CONTROL TOOK TWO ATTEMPTS AND BOTH FAILURES WERE THE CONTROL'S. Asserting that a hosted target receives more than one token failed, because a bare `Toolchain` carrying no payload has nothing to reconstruct either -- both sides produced exactly the triple. Reaching for `--no-default-config` without a payload that HAS a cfg was the same mistake once removed. `FakeClangPayload` writes a `clang++.cfg`, which is what makes the discriminator real, and the bypass is the right one to use because withholding it from an SDK is a property the gate's own comment states. Both verified in both directions: reverting each fix turns its test red. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent d925b6f commit d47d956

53 files changed

Lines changed: 4431 additions & 167 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/docs/2026-09-11-distribution-plugins-and-platform-decomposition.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,39 @@ The other corrections worth carrying:
462462
document warned about.** It recorded `_LIBCPP_VERSION 200100` and clang
463463
22.0.0git; 6.0.9 reports `220108` and clang 24.0.0git. The lesson it drew --
464464
that the surface must match the LIBRARY and not the compiler -- is right, and
465-
the numbers it drew it from are two releases stale.
465+
the numbers it drew it from are two releases stale. Re-measured from an
466+
installed payload on 2026-09-11 by preprocessing `_LIBCPP_VERSION` out of
467+
`<__config>` with the payload's own `em++`, so the number is the library's
468+
and not a release note's.
469+
470+
- **The file count was 133, not 134**, and the two are the same surface size
471+
the NDK ships: 2 `.cppm` plus 131 `.inc` partitions under
472+
`<payload>/emscripten/cache/sysroot/share/libc++/v1/`. Counted rather than
473+
recalled, because this document uses these counts as the evidence that a
474+
vendor ships the surface.
475+
476+
- **"No additional flags" needed one qualification, and the qualification is
477+
the good news.** `em++` ships the surface's SOURCE, not a BMI, so
478+
`import std;` on its own fails with `module 'std' not found`. What is not
479+
needed is the *generation* machinery this section describes at length --
480+
there is nothing to generate. Building the BMI is the engine's ordinary job
481+
for every toolchain it supports, and measured with exactly the two steps it
482+
already performs:
483+
484+
em++ -std=c++23 --precompile <sysroot>/share/libc++/v1/std.cppm -o std.pcm
485+
em++ -std=c++23 -fmodule-file=std=std.pcm -o hello.js hello.cpp std.pcm
486+
node hello.js -> 1-2-3
487+
488+
`std.pcm` is 34 MB and the link produced `hello.js` (65370 bytes) plus
489+
`hello.wasm` (447175 bytes). `-Wno-reserved-module-identifier` is NOT
490+
required -- the precompile succeeds without it, with two warnings, and mcpp
491+
passes it only to silence them (`src/toolchain/clang.cppm:243`).
492+
493+
So `wasm32-emscripten` needs no new standard-library mechanism at all:
494+
`stdModuleSource` points at that `std.cppm` and the existing path takes it
495+
from there. **And the two-file output is confirmed** -- which is the one
496+
genuinely new engine item, answered by the implicit-output channel the link
497+
edge already has for import libraries and PDBs.
466498
- **`emsdk` cannot be a payload at all.** `emscripten-core/emsdk` publishes
467499
**zero** GitHub releases, and its `emsdk.py` fetches the real toolchain over
468500
the network at install time. The recipe therefore names what `emsdk.py`
@@ -1116,6 +1148,22 @@ unpersisted declaration would be absent exactly when a user names a format.
11161148
carries no such line and the program that wrote it could not emit one, so
11171149
replaying it yields what that program said.
11181150

1151+
Measured across two real binaries rather than only in a unit test, because an
1152+
absent-tolerance claim is about what a *previous version* wrote:
1153+
1154+
| step | binary | the graph's header line | result |
1155+
|---|---|---|---|
1156+
| 1 | released 2026.9.10.2 | `graph=normal;schedule=none;accel=default` | builds |
1157+
| 2 | 2026.9.11.2 | `;dist=none` appended | fingerprint change, full rebuild, no error |
1158+
| 3 | 2026.9.11.2 | unchanged | `Finished dev in 0.00s` -- the fast path replays |
1159+
| 4 | 2026.9.10.2 again | its own older directory | `0.00s` -- the downgrade does not choke |
1160+
1161+
Step 4 also says what the absent-tolerance is worth. The version is part of the
1162+
fingerprint, so two binaries never share a graph directory and an older mcpp
1163+
never actually reads a `dist=` field. That makes the field's read side defence
1164+
in depth rather than a live path -- which is the same conclusion §11.2 reaches
1165+
from the other direction, and is why the invariant is held in a unit test.
1166+
11191167
### 11.9 Test coverage
11201168

11211169
The count is not the measure; what each test excludes is. Two are worth naming.

.agents/docs/2026-09-11-platform-targets-design-review.md

Lines changed: 1076 additions & 0 deletions
Large diffs are not rendered by default.

.agents/docs/2026-09-11-six-open-issues-analysis.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,3 +531,119 @@ refuted for every build the vendor manifest currently serves, measured across
531531
all four Linux host entries rather than the pinned one. So both remaining
532532
platform rows are executable on an x86_64 Linux runner with no device and no
533533
virtualization, which is what a CI lane needs.
534+
535+
## 9. Ecosystem sign-off
536+
537+
Written after the work landed, across four repositories, against what shipped
538+
rather than against the plan. The question this section answers is not "is each
539+
change correct" -- section 8 and the tests answer that -- but "does the
540+
ecosystem hold together with these changes in it".
541+
542+
### 9.1 The one thing that went wrong twice, in two repositories
543+
544+
A check that asks a **proxy** question refuses correct output, and it did so
545+
twice in one day in two repositories:
546+
547+
* `mcpp-plugins` CI refused a correct MSI. WiX 6 ran with no warnings and
548+
produced 32768 bytes for a 114688-byte program; the floor was
549+
`size > exesize / 2`, and its own comment admitted the ratio was invented.
550+
32768 is what a stripped hello-world looks like after a cabinet has had it.
551+
* The same repository had already done this with a 16 KB AppImage bound
552+
refusing a correct 14999-byte bundle.
553+
554+
Both are now direct questions. The AppImage is **run** and its output asserted;
555+
the MSI is **installed** (`msiexec /a`) and the extracted program compared byte
556+
for byte. The pattern to carry forward: when a check reasons "X should be
557+
roughly as large as Y", the artifact can almost always be opened instead.
558+
559+
### 9.2 The one thing that went wrong twice in the same change
560+
561+
`#599` was fixed by reasoning about which shard runs `233`, and the answer was
562+
"whichever one the round-robin puts it in" -- so both Linux shards got the
563+
submodules. `233` runs in every job that invokes the whole suite, of which
564+
there are **three**. The macOS lane caught it, which is the same shape as the
565+
defect being fixed: a rule reasoned about against one object and applied to
566+
all of them.
567+
568+
The correction was to **enumerate**: the three unfiltered
569+
`bash tests/e2e/run_all.sh` jobs are named, and the two jobs that invoke the
570+
suite with a filter or name tests directly are named as not needing it. An
571+
enumeration can be re-checked; a piece of reasoning about sharding cannot.
572+
573+
### 9.3 What the ecosystem rule turned out to cost, and what it did not
574+
575+
The rule is the user's: every tool and every library comes from the ecosystem,
576+
and anything missing is added until the loop closes. Applied to the Android
577+
and Web payloads it cost **one new payload** and otherwise only declarations:
578+
579+
| escape | closed by | new package |
580+
|---|---|---|
581+
| host `debugfs` | `xim:e2fsprogs` | no -- already in the index |
582+
| host `libX11` chain (6 libraries) | declared `deps` | no -- all six already there |
583+
| host `python3` for `em++` | `xim:python@>=3.12` | no, but **aarch64 payload added** |
584+
| `/dev/kvm` | nothing | it is a kernel device |
585+
586+
The interesting entry is the third. `xim:python` was x86_64-only, and that was
587+
the *stated reason* `xim:emsdk` could not declare an interpreter -- an argument
588+
that was true when written and was an argument for adding the missing payload
589+
rather than for depending on the host. Adding it closed the loop for both
590+
arches. The general form: a dependency declined because the ecosystem cannot
591+
serve it is a request for a package, not a licence to use the host.
592+
593+
`/dev/kvm` is the boundary the rule has, and stating where a rule stops is part
594+
of stating the rule. A test now asserts it is the **only** remaining warning in
595+
that recipe, so a second one cannot appear quietly.
596+
597+
### 9.4 Where the ecosystem rule is overruled, and by what
598+
599+
By the licence, and this is the second time the same framework decided a
600+
packaging question. `xim:iphoneos-sdk` carries no CN mirror because a `CN`
601+
entry would mean xlings-res holds a copy of Apple's SDK. The four Android
602+
packages reach the same conclusion from the same field -- all four declare
603+
`licenses = {"Android Software Development Kit License Agreement"}` -- and keep
604+
one upstream URL each. `xim:emsdk` (MIT / NCSA) and `xim:python` (PSF) are
605+
mirrored because their licences permit it.
606+
607+
So the rule composes as: **the ecosystem supplies what it may, and the licence
608+
says what it may.** A recipe that declines a mirror should say which of the two
609+
reasons applies, because a reader who cannot tell "not permitted" from "not
610+
done yet" will eventually do the wrong one.
611+
612+
The cost of getting this order wrong is asymmetric and worth recording: a
613+
mirror that should not exist cannot be withdrawn. Three objects were uploaded
614+
to GitCode before the licence was checked, GitCode assets cannot be deleted,
615+
and the only available remedy is that no recipe references them. The check is
616+
cheap and comes first.
617+
618+
### 9.5 The cross-repository order, re-derived from what happened
619+
620+
mcpp engine ──► 2026.9.11.2 the only thing on the critical path
621+
622+
├──► xim payloads independent; merged first
623+
624+
└──► mcpp-plugins pins a RELEASE, so it cannot precede one
625+
626+
└──► mcpp-index needs the plugins tag's sha256
627+
628+
This was already written in the distribution record's section 11.10, and the
629+
release cycle confirmed it in the sharpest possible way: `mcpp-plugins` #16 has
630+
Linux and Windows green and macOS red on `error: cannot package the Mach-O
631+
program`, which is precisely the defect 2026.9.11.2 fixes. The dependency is
632+
not a convention -- the red lane *is* the dependency.
633+
634+
### 9.6 What is still open, stated rather than implied
635+
636+
* **The four target rows.** `wasm32-emscripten`, `aarch64-ios`,
637+
`aarch64-linux-android` and `x86_64-linux-android` remain `planned`. Both
638+
execution routes are now measured -- `em++` compiles and links `import std`
639+
and `node` runs the result; `qemu-aarch64 -L <extracted system image root>`
640+
executes the default dynamic Android configuration -- and both payloads are
641+
published. What remains is engine work of the same size as the distribution
642+
batch: toolchain resolution for two drivers whose target is fixed by their
643+
payload, an implicit `.wasm` output on the link edge, `runner` defaults per
644+
row, 48 matrix cells, and a CI lane per row. That is the next PR, not a
645+
loose end in this one.
646+
* **`xim:wix`** is a legitimate gap with a known shape: MS-RL, a NuGet flat
647+
container, needing `xim:dotnet`. Version 6 and not 7, because 7 refuses to
648+
run without an out-of-band licence acceptance -- a package pinning it would
649+
install a tool that cannot work.

.agents/docs/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded
1818
---
1919
```
2020

21-
276 records.
21+
277 records.
2222

2323
## By subject
2424

@@ -44,6 +44,10 @@ Records that declare one. Everything else is listed by date below.
4444

4545
- [The category the plugin taxonomy does not name, and what a platform actually decomposes into](2026-09-11-distribution-plugins-and-platform-decomposition.md) — active
4646

47+
### targets
48+
49+
- [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active
50+
4751
### triage
4852

4953
- [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active
@@ -53,6 +57,7 @@ Records that declare one. Everything else is listed by date below.
5357
### 2026-09
5458

5559
- [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active
60+
- [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active
5661
- [The category the plugin taxonomy does not name, and what a platform actually decomposes into](2026-09-11-distribution-plugins-and-platform-decomposition.md) — active
5762
- [Two answers and two silences: the scanner's second grammar, and the manifest keys nothing reads](2026-09-09-two-answers-and-two-silences.md) — active
5863
- [A dlopen surface no closure walks, and a process with two unwinders](2026-09-09-dlopen-surface-and-two-unwinders.md) — landed

.github/tools/build_examples.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ BUILD=(
5757
# three-file package.
5858
examples/12-a-new-device-language/toyc
5959
examples/12-a-new-device-language/app
60+
# One source, three platforms. BUILT here rather than skipped, because its
61+
# HOST build needs no payload at all -- the `[target.*-linux-android]`
62+
# sections are inert unless that target is selected, which is itself worth
63+
# one build: a manifest that names a target the runner has no payload for
64+
# must still parse and build for the host.
65+
#
66+
# The cross legs are not built here. `wasm32-emscripten` and the two
67+
# Android rows would pull `xim:emsdk` and `xim:android-ndk` -- about 1.5 GB
68+
# between them -- and the signal already exists elsewhere:
69+
# ci-target-matrix scans every row on four hosts, tests/e2e/641 asserts the
70+
# vocabulary, and the example's README records the measured artifacts and
71+
# the emulator run for the binary it describes.
72+
examples/13-platform-targets
6073
)
6174

6275
# `key|reason`.

.github/workflows/ci-fresh-install.yml

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,27 @@ jobs:
276276
- distro: ubuntu-2004
277277
image: ubuntu:20.04
278278
setup: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y install curl bash tar gzip xz-utils git ca-certificates binutils findutils file
279-
- distro: debian-11
280-
image: debian:11
279+
# debian-12 AND NOT debian-11, AND THE REASON IS NOT THE FAILURE.
280+
#
281+
# The debian-11 leg started failing on 2026-09-11 with
282+
#
283+
# E: Release file for .../bullseye-security/InRelease is expired
284+
# (invalid since 3d 5h 32min 52s)
285+
#
286+
# and `apt-get update` exits 100. Bullseye is end-of-life and its
287+
# security suite's metadata has expired, which is a property of the
288+
# distribution and not of this workflow -- `-o
289+
# Acquire::Check-Valid-Until=false` would silence it and keep a leg
290+
# that tests against metadata nobody maintains.
291+
#
292+
# What was measured while replacing it: debian 11 and ubuntu 20.04
293+
# both carry glibc 2.31, so the "older glibc" coverage this leg was
294+
# here for was ALREADY DUPLICATED by the ubuntu-2004 leg above, and
295+
# dropping bullseye loses nothing. Bookworm's 2.36 sits between that
296+
# 2.31 and debian-testing's rolling version, so this leg now covers a
297+
# point the matrix did not have.
298+
- distro: debian-12
299+
image: debian:12
281300
setup: apt-get update && apt-get -y install curl bash tar gzip xz-utils git ca-certificates binutils findutils file
282301
env:
283302
# The one derived value (see the header comment): every install job names

0 commit comments

Comments
 (0)