Skip to content

Target rows become executable, and a machine-scoped scan stops being paid per target (2026.9.11.3) - #610

Merged
Sunrisepeak merged 13 commits into
mainfrom
feat/target-rows-executable-2026.9.11.3
Sep 11, 2026
Merged

Target rows become executable, and a machine-scoped scan stops being paid per target (2026.9.11.3)#610
Sunrisepeak merged 13 commits into
mainfrom
feat/target-rows-executable-2026.9.11.3

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

One PR, one version, the whole mcpp-side capability. Commits are the
checkpoints.

Two design records drive it:
.agents/docs/2026-09-11-platform-targets-design-review.md (the ownership
question for iOS/Android/Web, answered against cargo, Zig, CMake, Bazel and the
platform tools) and section 11 of the distribution record.

wasm32-emscripten is verified

$ mcpp run --target wasm32-emscripten
   Compiling wrow2 v0.1.0 (.)
    Finished dev [unoptimized + debuginfo] in 1.53s
     Running `target/wasm32-emscripten/.../bin/wrow2`
1-2-3

From the row alone -- no [target.…] override -- on a source that writes
import std;. And the project side needed no new vocabulary: a project
that builds for Linux builds for the web by changing one flag. That is the
first real measurement of what this design costs, and it is seven engine
changes.

Each was found by the previous one's failure, and each was a predicate that was
correct about the rows its author had in mind:

# change what it stopped
1 the payload chosen by the TARGET in to_xim_package xim:llvm answering for a wasm target
2 XimToolchainPackage::frontendSubdir looking for em++ in bin/
3 has_own_sysroot() in the shared producer this host's stdint.h in a wasm compile
4 the same predicate in resolve_link_model --dynamic-linker=…ld-linux-x86-64.so.2 reaching wasm-ld
5 the same in discover_link_runtime_dirs the compiler's libatomic on the artefact's link line
6 host_can_serve "no toolchain payload here produces it", with the payload pinned one column away
7 Format::Wasm and its mechanism a warning about a libc++.so that cannot exist for the target

Three of those are worth reading as one finding. #3 was first put at one of
its three callers
, so ordinary compiles stopped taking the host's headers and
the std module precompile went on taking them -- which is where it had been
failing. #4 is the model and not its two channels, and the comment at the
second channel already recorded that a reader who fixed only the first "saw the
identical error and could reasonably conclude the fix had not worked". #5 is
#4's finding a second time
: for every row predating these two, "the
compiler's own runtime" and "the artefact's runtime" are the same directory.

#7 the module had predicted and deferred in its own comment: "Adding
Format::Wasm is deferred to whoever gives this module a mechanism for it."

#6 was caught by the target matrix, not by reasoning: the first version
returned true for any SDK target, which is the same over-broad shape as the
branches above it. xim:emsdk publishes Linux only, so macOS and Windows must
answer host-cannot-serve.

A machine-scoped scan was paid per target: 17948ms to 4ms

mcpp build   loader-tags stage    170ms     one 21 MB binary
mcpp test    loader-tags stage  17948ms     108 binaries, 2.4 GB

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 discovering the surface was empty. Its
own record said members=0, walked=0 every time, and the artefacts are
executables, which cannot carry DT_SONAME at all.

Both halves are 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. Measured after: 3051ms once, then 4ms per target; the suite runs in
5.77s.

min_api_level, reusing what macos_deployment_target already does

This recommendation was written one way, reversed on a measurement, and
reversed back when the measurement turned out to answer a different question.
The record keeps all three, because the mistake in the middle is the
instructive part.

clang -target aarch64-linux-android21 -print-effective-triple answers
aarch64-unknown-linux-android21, so LLVM carries the level in the env
field. That says where LLVM writes it and nothing about where mcpp should store
it -- mcpp keeps its own vocabulary and maps to a compiler target, and
prepare.cppm says so in as many words: "The triple is mcpp's vocabulary
(aarch64-macos); the flag carries the spelling a compiler takes
(arm64-apple-macos14.0)."

macOS already solved this, and llvm_triple() already takes the version
parameter
-- so the objection that a manifest key would give it a second
input was false when I raised it.

[target.aarch64-linux-android]
min_api_level = 24
canonical triple aarch64-linux-android -- names the output dir, cfg(env=), the ABI tag
what clang receives aarch64-unknown-linux-android24
fingerprint carries the level, so 21 and 24 are two build directories

The fingerprint slot is renamed minPlatformVersion: a target is either Apple
or Android, so one slot cannot hold both, and both answer one question. The
rename costs no extra rebuild -- the mcpp version is already in that key.

The field name is Android's own: 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 (it is level), and ndk_ names the toolchain while one NDK
serves a range, which would reintroduce exactly the confusion this
resolves.

The four-field spelling is accepted

em++ -v passes -target wasm32-unknown-emscripten; 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.

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 rather than of this workflow. 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.

Verification

109 unit tests pass (5.77s, from a projected 33 minutes). tests/e2e/639
(eight scanner criteria) and tests/e2e/233 pass. 12 wasm matrix cells
rewritten -- ok on the two Linux hosts, host-cannot-serve on macOS and
Windows, which is where xim:emsdk is published. mcpp run --target wasm32-emscripten re-verified after every change in this branch.

Deliberately not in this PR

R4 (generalising @system) is withdrawn, not deferred: its motivating
second instance dissolved once xim:iphoneos-sdk covered iOS as a package, and
relaxing a refusal without a case is how gcc@system gets in -- the exact
spelling that refusal names.

The .wasm sibling as an explicit implicit output is not here. It is
already produced (em++ writes it beside -o), so this is a graph-bookkeeping
improvement rather than a capability, and it is recorded as E11.

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.
… 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.
… 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.
…ffers 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.
…s 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.
…s 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.
… 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.
…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.
… 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.
… 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.
… 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.
…rning 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.
…ve 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants