Skip to content

Tags: mcpp-community/mcpp

Tags

v2026.8.18.2

Toggle v2026.8.18.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat(windows): MSVC DLLs export, packages link with cl.exe, and modul…

…e_extensions is followed everywhere

Closes the three questions left open by 2026.8.18.1, from
`.agents/docs/2026-08-18-windows-shared-library-and-module-extensions.md`. They
shared a shape: mcpp treated "the toolchain does not do this for us" as "this
cannot be done", and each had an industry answer older than a decade.

## `kind = "shared"` works on the MSVC ABI

MSVC exports nothing from a DLL without `__declspec(dllexport)` or a `.def`, so
the import library came out empty and consumers failed with unresolved externals
for symbols visibly in the objects. The **reason** was right; the **conclusion**
was not. CMake has shipped `WINDOWS_EXPORT_ALL_SYMBOLS` since 3.4, and its
`bindexplib` reads COFF directly — decisive here, because `dumpbin` lives in a
Visual Studio developer environment and mcpp's default Windows toolchain is
clang, so a plain `mcpp build` is not inside one.

`mcpp.build.coff_exports` is that reader, **a pure function over bytes** so it
can be tested on hosts that cannot produce COFF at all. 16 unit tests build
objects byte by byte — the only way to vary a storage class on demand — plus two
committed real mingw objects, because a reader fed only its own test's output
agrees with itself and nothing else. Past 65535 exports it **refuses rather than
truncates**: a truncated export table links cleanly and fails at whichever
consumer needed the symbol that fell off the end.

**Annotation wins.** An object already carrying `/EXPORT:` directives — what
`__declspec(dllexport)` emits — makes mcpp stand down. Adding a list on top would
export the same names twice (`LNK4197`) and export everything else besides,
replacing a chosen public surface with all of it. Detected, not configured: a key
for "I annotated my exports" is a second place to say what the objects say.

Two limits survive that no tool removes, and they are CMake's documented ones for
the same mechanism — exported **data** still needs `dllimport` on the consumer,
and a class whose **vtable** is referenced must be marked whole. Both are in
docs/12 rather than left to be discovered.

## A package can be consumed by native `cl.exe`

The generated manifest now also carries the dialect-neutral
`[target.<pred>.runtime]` pair, which mcpp renders as `/LIBPATH:` + `<n>.lib` or
`-L` + `-l<n>`. Not new vocabulary — the same two keys `[runtime]` has had at top
level, made per-target. Both spellings ship: an older mcpp reads only the
`ldflags` and silently ignores the new block, so dropping them would leave every
older client with no link line.

⚠️ **One leg is deliberately excluded, and e2e 257 is why.** A PE/MinGW shared
leg links with `-L… -Wl,-Bdynamic -lmathkit`, and `-Wl,-Bdynamic` only works
immediately before the `-l` it enables. Two attempts to route it through the
neutral channel both failed with `have you installed the static version of the
mathkit library?` — first by clearing the ldflags, then by rendering the library
reference into a different command-line slot. Both were the same mistake:
treating a hand-tuned link line as a two-field record.

## `module_extensions` is the knob, and everything follows it

`.ixx` is **not** built in — the extension set is configuration, not a list mcpp
grows one entry at a time. What that owes in return is that one declaration is
enough, and three places were not holding up their end.

**The scanner accepted what it could not classify.** An undeclared `.ixx`
produced a compile edge whose object nothing links:

```
build obj/mathkit.ixx.o | gcm.cache/mathkit.gcm : cxx_object …
  bmi_out = gcm.cache/mathkit.gcm      ← the BMI was produced
build bin/app : cxx_link obj/main.o    ← the object is not here
ld: undefined reference to `mk::answer@mathkit()'
```

Two answers to "is this a module interface" and only one of them read.

**The lib-root convention hard-coded `.cppm`**, so packing an `.ixx` library
started its closure at a file that does not exist — and the result was silently
wrong twice over:

```
$ mcpp pack mathkit
     Interface (headers only)      ← the module interface, gone
      Withheld (nothing)
  Packed …-x86_64-linux-gnu        ← the C-SURFACE tag
```

An empty published set is precisely how the packer recognises a C surface, so
losing the interface **also** stopped the package constraining the C++ ABI, and
the compatibility gate stopped checking compiler and stdlib.

**The generated manifest listed `.ixx` sources without saying what an `.ixx` is.**
It now declares the extension — computed from the published files, so it cannot
disagree with `sources`, and absent for a `.cppm` package whose manifest is
unchanged.

## Notes for review

- ⚠️ **Behaviour change**: a `sources` entry mcpp cannot classify is now a hard
  error. A project with a `.md` in `sources` starts failing.
- ⚠️ The probing lib-root resolver lives in `mcpp.manifest.toml`, not beside its
  sibling in `types` — measured: adding the extension-table import to `types`,
  which nearly everything depends on, made **GCC 16.1 ICE while compiling an
  unrelated `src/main.cpp`**, with a cleared gcm.cache. The edge is not added;
  the function moved to where the edge already is.
- Local: **239 passed, 1 failed, 14 skipped**. The failure is
  `22_doctor_cache_publish`, which the previous RELEASE binary fails identically
  on this machine.
- Still unverified: the `cl.exe` consumption path end to end (needs a consuming
  case in the msvc job), and the data-symbol `dllimport` limit is documented but
  has no reproducing test.

v2026.8.18.1

Toggle v2026.8.18.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat(pack): ship a library as interface + prebuilt binaries (#433)

Closes #433.

`mcpp pack <target>` now ships a library as **interface + prebuilt binaries**,
and a consumer builds against it through the ordinary dependency path.

**No new manifest section and no new key.** `[targets.<n>].kind` decides what is
packed (so there is no `--lib`, no `--artifact`), the published interface is the
module closure of the lib root, the public headers are `include_dirs` in full,
and each leg's ABI tag, digest and provenance ride on the existing
`[[runtime.artifacts]]`. The result is an ordinary mcpp package, which is why an
older mcpp can still build against it — it simply does not run the gates.

**Two gates, both for failures that are otherwise silent:** an ABI tag mismatch
(measured: swapping two struct fields in a shipped interface compiled, linked,
ran, printed wrong data, and produced no diagnostic at all) and an interface
digest that ties the published sources to the binaries they were built from.

**`kind = "shared"` beyond ELF.** Mach-O and PE/MinGW are supported now — the PE
side needed the import library modelled (a PE shared library is two files, and
mingw's tolerance for linking the `.dll` directly was hiding that), Mach-O needed
`@rpath` install names or every relocated `.dylib` would report `image not
found`. MSVC stays refused, for the real reason: it exports nothing from a DLL
without `__declspec(dllexport)`, so the import library would be empty.

**Three pre-existing defects fixed on the way**, each with its own regression
test: `[target.'<triple>'.build]` never matched on a native build; `sources = []`
was byte-identical to omitting the key; an implementation partition (`module
M:part;`) was recorded as requiring its own name, which left the graph with no
ordering edge and failed on Windows clang with `failed to read compiled module`.
And `--target` no longer accepts a target this host cannot produce — it used to
resolve the native compiler and deliver an ELF as a Windows build.

Docs: `docs/12-binary-distribution.md` (+ zh), `examples/05-lib-distribution`.
Design, measurements and the four designs my own experiments disproved:
`.agents/docs/2026-08-17-library-distribution-design.md`.

v2026.8.17.1

Toggle v2026.8.17.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat(windows): the SDK and the runtime get the axes the compiler alre…

…ady had (#448)

* docs: the three Windows toolchain axes — a design, and two corrections

The architecture review found where the problems are. This is what to do
about them, written after a round of questions that overturned two shapes in
my own proposal.

Both corrections are recorded in §0.2, because the wrong version looked
equally reasonable and will otherwise be proposed again:

  - generalising `@system` to gcc/llvm is backwards. xlings is a user-space
    OS and mcpp minimises host dependence; `msvc@system` is a Windows
    concession, not a capability three families are missing.
  - a `windows_sdk = "..."` manifest key should not exist. The model already
    reserves the slot (`runtime_binding.cppm:26` documents `ucrt@...` and
    nothing populates it), and a version key would sit next to `_WIN32_WINNT`
    looking interchangeable while controlling a different thing — which is
    worse than not having it.

The design separates three axes that are currently entangled: where the
compiler came from, which SDK is used, and what the artifact ships. They take
values independently — a managed toolset with the machine's SDK is what
xrgui's CI does today — so any design that fuses them into one switch is
wrong.

It also records the finding that makes `mcpp pack` more than a missing
feature: `ldd_parse` computes the dependency closure by RUNNING the binary
(LD_TRACE_LOADED_OBJECTS), so it cannot cross an OS or an architecture by
construction. Reading imports statically is what makes cross-packaging fall
out rather than be added.

The acceptance criteria are written so that none of them can be met by CI
going green — two of them explicitly require a machine POORER than the CI
runner, because "the verification environment is richer than the target" is
the shape that produced most of this round's eleven defect layers.

* feat(windows): give the SDK and the runtime the axes the compiler already had

Implements §1 / §2 / §3 of
.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md.

Three questions were entangled because only one of them had ever been
modelled. The compiler got a version axis in the last round; the headers it
compiles against and the runtime the artifact loads did not.

§2 THE SDK IS BOUND, NOT SEARCHED.

`find_windows_sdk()` scanned — WindowsSdkDir, then the sibling store, then
the conventional roots — for BOTH origins. So a pinned `msvc@<toolset>` was a
pin the environment could overwrite, and two machines could build one
manifest against two SDKs with nothing in the log naming either. It is now
resolved by origin: a managed toolset takes the SDK payload from its own
store and ignores WindowsSdkDir/WindowsSdkVersion *out loud*; `msvc@system`
keeps today's chain, because a machine's things can only be found by looking.

A managed toolset with no SDK payload beside it still falls back to the
machine's — working beats failing — and says so, because that build is no
longer reproducible and only that line records it.

§2.3 `ucrt@<version>` FILLS A SLOT THAT HAS BEEN RESERVED SINCE THE FIELD
EXISTED. `RuntimeBinding::runtimeId`'s comment has documented it from the
start and nothing ever wrote one, so the SDK version never reached
`runtimeContractHash` and two SDKs shared one build cache.

It is NOT isomorphic to `glibc@`, and the comment says so where it will be
read: glibc@ binds a payload (headers + .so, patchelf makes the artifact run
on that copy), ucrt@ declares a floor (ucrtbase.dll is an OS component from
Win10 on; mcpp's windows-sdk payload deliberately carries only half of ucrt
and no redistributable). It is therefore not projected into `libc`. The
`glibc@`-prefix gates become `runtime_provider()` dispatch, so another
provider reads as "no rules here" rather than "no identity".

§3.3 `toolchain-coupled` NOW MEANS SOMETHING ON PE. The refusal said the MSVC
runtime "ships with the OS/redistributable, not with the toolchain" — true of
ucrtbase.dll, false of vcruntime140.dll/msvcp140.dll, which sit in
VC\Redist\MSVC\ inside every toolset. That is the relationship gcc has to
libstdc++.so, so it takes the same contract; PE has no rpath, so the
mechanism is a copy beside the artifact rather than a search path. /MT stays
a degradation, and a genuine one: a static CRT leaves no DLL to couple to.

The DLL set comes from `vc_redist_dir()` — the single criterion that excludes
`debug_nonredist\`, which may not be redistributed. A second, name-shaped
rule here could disagree with it, and disagreeing about that is a licensing
defect rather than a bug.

§1 THE ORIGIN AXIS IS CONTAINED, NOT GENERALISED.

- `gcc@system` / `llvm@system` are refused where they are read, naming both
  things the user might have meant. They used to parse and then fail
  elsewhere as `xim:gcc@system` → "no such package", sending the reader after
  a version that was never going to exist. `msvc@system` is a concession to
  one platform, not a capability the other families lack; the family-less
  `system` escape hatch is untouched.
- `resolve_managed_msvc()` replaces two hand-written copies of "where does a
  managed toolset live, and why is the fetcher's `root` wrong for it" — the
  reason existed in only one of them.
- `needs_linux_sysroot_payloads()` replaces two spellings of one rule whose
  comment claimed they mirrored each other. They did not: the PE term was
  missing from one. Unreachable today, which is how it survived.
- The toolchain resolution order was documented twice, as "3 steps" and "4
  steps", naming five of the nine inputs and disagreeing about two. One table
  now, keyed to `TcOrigin` enumerators so it cannot quietly stop matching.

`dist::Format` is derived from the target triple before falling back to the
host, which only ADDS answers — and makes a Windows contract assertable on
the Linux runner that reviews most of this.

Tests: 22 new. The SDK-override criterion is the design doc's §6 acceptance
test as a unit test (point WindowsSdkDir elsewhere; the payload SDK must
still win, and the note must say the variable was ignored); the deploy tests
assert reachability twice over, since a copy edge nothing asks for never runs
under explicit ninja goals.

* feat(pack): read the import table instead of running the binary (§4)

`mcpp pack` refused Windows with `#if defined(_WIN32)`, and the reason given
was that the tools were POSIX-only. That was the symptom. The cause is one
layer down: the dependency closure comes from

    LD_TRACE_LOADED_OBJECTS=1 '<binary>'

which RUNS the artifact — so it can cross neither an OS (a Linux box cannot
execute a PE) nor an ARCHITECTURE (an x86_64 box cannot execute an aarch64
ELF, same OS or not). Porting `tar` would not have helped, and every tool the
2026-05-19 design proposed — dumpbin, ImageNtHeader, Compress-Archive — would
have reintroduced the obstacle one layer down, because each exists only on the
platform where the problem had already gone away.

mcpp.pack.binfmt reads it out of the file instead: ELF DT_NEEDED through the
segment table, PE imports AND delay-imports (a missing delay-load does not
fail at startup — it fails at the first call through it, which is strictly
worse to debug). Cross-OS packaging is then not a feature that had to be
added; it is what remains once nothing has to be executed.

mcpp.pack.zip writes the archive, for the same reason: no zip tool exists on
every host (GNU tar cannot write zip, `zip(1)` is often absent,
Compress-Archive is Windows-only). Entries are STORED, which is a real size
cost and the honest trade — a DEFLATE encoder is the one part that could
produce an archive that unpacks WRONG rather than failing loudly, and mcpp has
no zlib to borrow one from. Deterministic by construction: no timestamps are
read, so a published checksum means something.

THE CONTRACT NOW REACHES PACKAGING (§4.3). `cxx_runtime` used to stop at the
compile and link flags, so the step that decides which files actually travel
could not see what had been promised — on ELF the `ldd` closure agreed with it
by luck, on PE nothing did. It is now an input:

  toolchain-coupled  the toolchain's runtime directory joins the search set
  host-coupled       it stays OUT, so a vcruntime140.dll in the toolset is not
                     silently swept into a package that promised the host
                     would provide it
  --mode system/static + toolchain-coupled → refused, naming the way out

PE layout is flat, and that is the relocation mechanism rather than a style:
the Win32 loader resolves a DLL from the directory of the executable, and
there is no rpath to point elsewhere. Windows' own DLLs are never bundled —
two of something that must be unique is a broken program, not a heavier one —
but `force_bundle` overrides that, as it always did on ELF.

Verified on a Linux host against a real cross-built PE, not only synthesised
fixtures: e2e 240 builds a mingw target, drops a stand-in for a DLL the EXE
imports, packs, and has PYTHON verify the archive. The msvcrt.dll assertion is
the positive half (a parser that read nothing could not have produced it) and
the kernel32.dll assertion the negative half; together they are decisive. It
runs in the mingw-cross job because running it on Windows would prove nothing.

Docs: the Windows layout, the cross-host story and both size/determinism
consequences in 02-pack-and-release (en+zh); the MSVC half of `cxx_runtime` in
05-mcpp-toml (en+zh), replacing a claim about /MT that stopped being true;
SDK-by-origin and the `@system` rule in 03-toolchains (en; the zh MSVC section
was rewritten — it still described msvc as a system-only toolchain and
`msvc@19.44` as a pin-verify). 2026-05-19-pack-windows-design.md is marked
superseded with what it got wrong and why, since the mistake is instructive.

* chore: bump version to 2026.8.17.1, and record what §1–§4 actually became

The design doc gains a status section per item, including the two places the
PLAN was corrected by the implementation and the one half of §4 that was
deliberately not done:

- `dist::Format` now reads the target triple before falling back to the host.
  Everything but MinGW used to ask the host, which made a Windows contract
  unassertable on the Linux runner where most of this gets reviewed. It only
  ADDS answers, so no existing build changes.
- `force_bundle` had to reach the PE system list too. ELF always worked that
  way; making the PE exclusion unconditional would have turned an explicitly
  written decision into decoration.
- The ELF closure still runs the artifact. `ldd` hands back RESOLVED PATHS
  while `DT_NEEDED` gives only names, and turning names into paths means
  reimplementing the loader's search order ($ORIGIN, DT_RPATH before
  LD_LIBRARY_PATH before DT_RUNPATH before ld.so.cache, hwcaps). Rewriting
  that under a correct, e2e-covered path is more risk than it buys — and the
  cost is stated rather than left to be discovered: cross-ARCHITECTURE ELF
  packing is still unsupported, which is the second limit §4.1 names.

`mcpp self doctor` reports the Windows SDK PER ORIGIN. One unlabelled line was
the same "one question, two answerers" shape this axis exists to close: a user
reading it would believe it applied to their pinned build, and it did not.

* fix(pack): stop using the module-boundary shapes clang miscompiles

Every Windows job and the macOS job went red on the same new code, in two
different ways, while gcc was green everywhere:

  Windows  clang 20.1.7 (MSVC ABI) segfaulted COMPILING mcpp.pack —
           0xC0000005, no diagnostic, five jobs at once
  macOS    test_pack_binfmt died with SIGSEGV at RUN time, inside the PE
           import-table test

The parser is not the problem, and that was measured rather than assumed: the
same code is clean under ASan+UBSan with clang 22.1.8 + libc++, and correct
when compiled AS A CLANG MODULE on x86_64 Linux at both -O0 and -O2. What is
left is the compiler, on the two targets neither of those probes covers.

This codebase has been here before. hostflags.cppm exists because adding an
UNUSED helper to a module's anonymous namespace miscompiled a NEIGHBOURING
function under clang + C++20 modules + -O2, and its verdict was "mechanism
unknown, reproduction solid; the cheap response is to not grow that
namespace". Same response here — remove the shapes, keep the behaviour:

  mcpp.pack       a scoped enum from another module as a defaulted member of
                  an EXPORTED struct (`dist::Contract` in `Options`) → a plain
                  bool, since only one of the three values changes anything
                  here; a ranges projection over an imported type's member →
                  std::sort with a comparator
  mcpp.pack.zip   `std::span<const Entry>` across the boundary → the vector by
                  const reference (one caller)
  mcpp.pack.binfmt  `template <typename T> le(...)` in the module purview →
                  four concrete le8/le16/le32/le64; `constexpr std::array` via
                  `std::to_array<>` → plain arrays; ranges algorithms over
                  them → loops

WHICH ONE IT WAS IS NOT ESTABLISHED, and the comments say so rather than
inventing a finding — they were removed together because each CI round costs
minutes and none of the replacements is worse than what it replaced. Every
one is also simpler, so nothing is being paid for the avoidance.

Behaviour is unchanged: the same 10 unit tests and e2e 240 pass.

* test(pack): split the PE case so it can localise its own crash

The combined test died with SIGSEGV on the macOS ARM64 runner and nowhere
else — not under ASan+UBSan with clang 22 + libc++, not as a clang module on
x86_64 Linux, not under gcc. A single test that builds a fixture, identifies
it and parses it cannot say which of the three it was, so each hypothesis
costs a CI round. Three tests now: the fixture is well-formed, identify(),
and needed_names().

* docs: 03-toolchains contradicted itself about `[build] linkage`

The MinGW section says the key does not exist and is silently ignored; the
MSVC section two hundred lines down showed exactly that form as the way to
select `/MT`. Found by writing it out and watching mcpp print "unsupported
key 'linkage' (ignored)" — which is also what a user following the page would
have got, without the page telling them why nothing changed.

`linkage` is exact-triple only (`[target.<triple>]`, or `--static`). Both
language editions now show that, and say which section each key lives in —
the zh page had it right before this round and lost it when the MSVC section
was rewritten from the English one.

* test(pack): rewrite the PE fixture plainly, and make it say where it dies

Splitting the test localised the macOS ARM64 SIGSEGV to
ThePeFixtureItselfIsWellFormed — fixture code that calls no module at all. So
it is not mcpp.pack.binfmt, and the three probes that came back clean (ASan +
UBSan under clang 22 + libc++, the same module compiled by clang on x86_64
Linux, gcc everywhere) were looking in the right place for the wrong thing.

The fixture used two `std::span` parameters and two lambdas that mutated a
captured string through a captured cursor. It now takes vectors, indexes
explicitly, and captures nothing — and traces each phase to stderr, so if it
moves again the log names the step instead of costing another CI round.

`#include <cstdio>` is not redundant next to `import std;`: `stderr` is a
macro, and a module cannot export one.

* fix(pack): `--mode static` must not override a target the user asked for

`--mode static` on its own has always meant "the musl-static ELF", and that
is unchanged. But the re-prepare that enforces it ignored `opts.targetTriple`,
so `--mode static --target x86_64-windows-gnu` silently produced a LINUX
build. That was invisible while PE packaging did not exist — there was no
Windows package to notice was missing — and it is a wrong answer now that
there is.

Measured both ways: with an explicit target the output is
`…-x86_64-w64-mingw32-static.zip`; without one it is still
`…-x86_64-linux-musl-static.tar.gz`.

* test(pack): the PE fixture trace is opt-in

It did its job — the plain rewrite is green on macOS ARM64 — and 24 lines of
stderr in every CI run forever is a poor trade for a crash that is currently
fixed. `MCPP_TEST_TRACE=1` brings it back, which keeps a recurrence one CI
round to localise instead of the four this one cost.

---------

Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>

v2026.8.16.3

Toggle v2026.8.16.3's commit message
mcpp 2026.8.16.3

The release where msvc@<toolset> completes its whole lifecycle on a real
Windows runner:

    PASS: msvc@14.44.35207 installs, builds, stays distinct from
          msvc@system, and removes

Eleven layers of defect got there, each reachable only once the one before
it was fixed, and every one of them had been green in CI. They share one
shape: an acceptance criterion weaker than "usable".

  - installed() checked cl.exe, then directories, then a sample
  - the SDK subset had no kernel32.lib, then no um/shared headers, no rc/mt
  - find_windows_sdk() accepted headers with no import libraries
  - remove failed on Windows, and would not say which file
  - vctip.exe held the payload open; not installing it did nothing for
    machines that already had it
  - the payload directory cannot be renamed while a file in it is open —
    the files have to move instead
  - and an empty directory skeleton still would not delete, so "removed"
    had to mean "no files remain"

Also: macOS could not build any project carrying a build.mcpp (#437), the
default /MD build could not start on a clean Windows box, and mcpp doctor
could not see a toolset that mcpp toolchain list showed.

v2026.8.16.2

Toggle v2026.8.16.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
chore: bump version to 2026.8.16.2 (#439)

Ships #436: an installed msvc toolset was invisible in `toolchain list`.
The v2026.8.16.1 tag was cut before that fix, so `latest` currently carries
it -- everything else in .1 is fine (install, build, default and remove all
work; only the listing row was missing).

Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>

v2026.8.16.1

Toggle v2026.8.16.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat(toolchain): MSVC gets a version axis — `msvc@<toolset>` alongsid…

…e `msvc@system` (#434)

* feat(toolchain): MSVC gets a version axis -- `msvc@<toolset>` alongside `msvc@system` (2026.8.16.1)

gcc and llvm are installed by mcpp and resolved from what the manifest
declares. MSVC was the one exception: EVERY msvc spec was a system spec, so a
manifest could name a toolset and have that name discarded.

The consequence is not inelegance. It is that the same source compiles with
different compilers on different machines, and nothing says so. Measured on
xrgui#3: in ONE CI run mcpp used 14.51 and xmake used 14.52, and it stayed
invisible until 14.51 hit an ICE. Exporting a complete vcvars environment did
not help -- the only way out was to move `vswhere.exe` aside so mcpp would
fall through to VSINSTALLDIR, and that workaround is still in xrgui's
workflow.

## The version axis decides the origin

  msvc@system  (or bare msvc)  the machine's own Visual Studio -- UNCHANGED
  msvc@<toolset>               an xlings payload mcpp installs and pins

`msvc@14.44.35207` is isomorphic to `gcc@16.1.0` in every respect:
coexisting versions, `toolchain remove msvc@<toolset>`, auto-install from a
manifest. The payload brings the compiler, the STL, and -- through its
`xim:windows-sdk` dependency -- the ucrt/um headers and libs, so nothing has
to be preinstalled.

Structurally, ACQUISITION and RESOLUTION are separated: acquisition is shared
with gcc (the xim install), resolution is shared with `msvc@system`
(`installation_from_tools_dir`). A pinned toolset is therefore not a second
code path, and cannot grow its own bugs. What it does not share is the
bin/-shaped frontend lookup (cl.exe is four levels deeper) and the ELF
post-install fixup (there is nothing to patchelf on a PE toolchain).

BREAKING: `msvc@19.44` was a pin-verify against the system install's cl
banner -- checked by `toolchain default` and silently ignored by builds. The
version axis now names a toolset everywhere. A `19.x` spelling errors out
with this machine's actual cl version and both replacements.

## VSINSTALLDIR now outranks vswhere

vswhere returns something on nearly every developer machine, which made
VSINSTALLDIR effectively unreachable -- a build that had exported a complete
vcvars environment still compiled with whatever vswhere ranked first. A guess
must not silently override an answer.

vswhere also gains `-prerelease`: without it a machine with only an Insiders
VS is reported as "MSVC not found" while a perfectly good cl.exe sits on
disk.

VS*COMNTOOLS stays BELOW vswhere. Those are machine-wide leftovers -- a 2017
VS150COMNTOOLS must not outrank a current install -- whereas VSINSTALLDIR is
someone setting it for this shell. The old `find_vs_via_env()` conflated the
two, so promoting it would have promoted the leftovers too.

## The Windows SDK stops being two absolute paths

Order: WindowsSdkDir (+ WindowsSdkVersion, both exported by vcvars) -> the
`xim:windows-sdk` payload beside a pinned toolset in mcpp's own store -> the
hardcoded roots, now a fallback.

The second source needs no configuration and hardcodes no version: the
COMPILER'S OWN PATH says which store it came from, and the SDK is its
neighbour there. `sibling_sdk_roots()` returns empty for a system cl, so the
two origins stay separate.

## cxx_runtime = "self-contained" now actually does something on MSVC

Two knobs, one working: `linkage = "static"` really emitted /MT while
`cxx_runtime = "self-contained"` reported "not implemented" -- for the same
physical switch. Two comments contradicted each other about it
(flags.cppm:605 vs distribution.cppm:202).

On the MSVC ABI these are not alternatives: /MT links the C runtime and the
C++ runtime out of the same library. Both spellings now select it through one
`msvc_wants_static_crt()`, which the project's TUs and the std module both
ask -- rather than each spelling out `linkage == "static"`, which is exactly
how they diverged in #422.

The default stays /MD: the predicate reads the WRITTEN manifest scalar, not
the resolved contract, because most roles default to self-contained and
keying off that would flip every Windows build to /MT.

The CRT model is a whole-PROJECT property (one std module per project, and cl
bakes _MSVC_MT/_MSVC_MD into it), so a per-role override is now refused with
a message that says why, instead of failing later inside the ucrt headers.

## Tests

MSVC discovery is testable off Windows for the first time: `installation_at()`
takes a directory instead of probing, `find_windows_sdk()` takes a list of
roots, and neither is behind a platform macro. Six new unit tests drive real
fixture trees on Linux CI -- including "both toolsets present, ask for the
OLDER one", which a latest-wins implementation fails and a real machine might
pass by accident.

New e2e `239_msvc_managed_toolset.sh`, written so that the SYSTEM compiler
answering would FAIL rather than pass quietly: cl.exe must be inside mcpp's
store, the toolset directory must be the one named, and switching the same
project back to `msvc@system` must resolve to the system cl again.

83/83 unit targets pass; 95/96/103/183 e2e pass on Linux.

Closes #432

* test(e2e): 239 asked for a flag that does not exist

`toolchain list --available` is not a thing -- `toolchain list` already
prints an "Available toolchains" section. Caught by ci-windows on the first
run, which is the right place for it: this test only ever executes there.

Also reordered: the install now runs BEFORE the discoverability check, so a
runner that cannot reach the index skips cleanly instead of failing an
assertion about a list the index would have filled in. And the check became
stronger than the one it replaces -- it asserts the INSTALLED toolset shows
up, not merely that some msvc row exists, because a toolset that installs and
then never appears is indistinguishable from one that did not install.

* docs: the cross-repo plan, its one hard dependency, and which claims can self-certify

Four repos, five changes, ONE real dependency edge: the packages must be
published before mcpp can install them and before xrgui can use them.
Everything else is parallel, and stringing it into a line is the usual waste
in work shaped like this.

The part worth reading is §4: which acceptance criteria a person could
satisfy by adjusting a test, and which they could not. The mirror is the
example that earned the distinction -- the criterion is not "the upload
succeeded" but "the bytes came back with Microsoft's sha256", and that
caught a real failure where the tool reported 16 files as failed while the
release listing showed them present and they were in fact absent.

* fix(toolchain): a managed toolset without its SDK reported success

Self-review catch. `installation_at()` succeeding means cl.exe is where the
declared version says it should be -- it says nothing about the ucrt/um
headers, which arrive as a separate package dependency and can therefore fail
on their own. The install printed "Installed", and the build died inside the
ucrt headers much later.

That is the half-installed state `has_usable_msvc()` was written for; this
applies the same judgement to the managed origin, and names the dependency
that must have failed rather than leaving the reader to work it out.

Also: `msvc_print_detected` now takes the label. "Detected" is a claim about
probing the machine, and printing it after unpacking a payload the caller
NAMED describes the wrong thing -- quietly, and in exactly the direction this
whole change is about.

---------

Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>

v2026.8.15.3

Toggle v2026.8.15.3's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(diagnostics): stop promising a remedy that does not work (2026.8.…

…15.3)

沙箱真实验证抓到的:mcpp 在 SubOS 缺 `subos_info` 时建议 `xlings self update`,而实测它与 `self doctor --fix` 都不回填已存在的 SubOS,`xlings subos` 也无 repair 子命令。已开 openxlings/xlings#547;三处措辞改为陈述事实并指向它。这句话出自 xlings 的原始措辞,mcpp 一直在转述 —— 把「上游这么说」当成「这是对的」转述给用户,和自己写错一样。

v2026.8.15.2

Toggle v2026.8.15.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(toolchain,build): absent SubOS description must not stop the buil…

…d; C-only units link with the C driver (2026.8.15.2)

Closes #427, closes #426. Also un-reds `main`.

三条互不相关的缺陷,共同点是**一个事实的缺失被当成了矛盾**。

**#427** — `ensure_post_install_fixup` 在调用方没给出运行时身份时自己去读硬编码的
`<xlings home>/subos/default`,并把「读不到」变成致命错误。触发条件与沙箱无关:默认
SubOS 由早于 `subos_info` 块的 xlings 创建即可。`allow_host_libs` 救不了(判定在
hermeticity 策略之前),`mcpp toolchain install` 同样死。已发布三周。

真因是没做完的迁移 —— 调用点注释写着「fixup 是 RuntimeBinding 的消费者」,`runtimeId`
早已从四处传入,旧推导没删;而被它保护的函数本来就正确处理空值,那个降级分支从未执行过。
删掉第二处推导;未知降级、矛盾仍然失败;严重程度归调用方;降级不写 marker。
`toolchain_install` 自己解析一次 RuntimeBinding —— 缺这一步,单删兜底会让它永远跳过
fixup,把硬失败换成静默的坏安装。

**#426** — 所有链接一律走 `$cxx`。同一对象同一 ldflags 只换驱动:g++ 给出
`libstdc++.so.6` `libm.so.6` `libgcc_s.so.1` `libc.so.6`,gcc 只给出 `libc.so.6`。
按内容选驱动,查不到的对象保守判为 C++。只换驱动不够:`-lstdc++exp` 是显式命名的,
故 `CompileFlags` 增加 `ldC`(与 `ld` 同一表达式产生),契约表增加 `unitFlagsC`
(`-static` / `-static-libgcc` 属于 libc,保留)。顺带补上 `std.compat.o` 缺失的收窄。

**main 的 bench pin 守卫** — 断言本身是错的。它要求 `reference_mcpp` 等于 bootstrap
pin,而标准集不在任何 workflow 里、bootstrap pin 可合理滞后;它声称防止的危险已由
`run-standard.sh` 自己防住。删掉跨文件断言,报告表头直接写出实测版本。

**文档** — `edit-body` 改写为三种情形的表(`.cppm` 移动行号 / `.cppm` 原地等长 /
独立 `.cpp`);订正 SPEC.md 中已被实测证否的解释(决定因素是行号移动,不是成员函数体
进 BMI);记录「原地等长修改」这个具名缺口。

**测试** — 单测 +4 钉住门的四个分支(必须是单测:低成本 e2e 都继承载荷,fixup 对继承
载荷提前返回);e2e 237 / 238 两个方向都钉。

CI 18/18,本地 e2e 219 passed / 0 failed。
分析与两轮自我 review:`.agents/docs/2026-08-15-issues-426-427-analysis.md`

v2026.8.15.1

Toggle v2026.8.15.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat(bench): cross-platform benchmark suite, and the defects it expos…

…ed (2026.8.15.1)

一个跨平台、可扩展的构建引擎基准设施,以及**用它跑出来的、和 review 它时发现的**
一批缺陷修复。版本 2026.8.15.1。

## 1. bench/ —— 把一次性脚本变成测量设施

C++23 写、由 mcpp 构建,所以三个平台跑法一致(它替换掉的 shell 脚本只能在 Linux 跑)。

* **可断续**:测量单元 = `工程·variant·场景·引擎·轮次`,测完即 append+flush;
  整份配置一个指纹,落 `.mbench/<指纹>/`。同配置命中续跑,改配置换目录。
  实测杀掉后记录 12 个点,重跑跳过这 12 个、补完剩下 12 个。
* **接口/实现分离**:19 个模块单元全部拆成 `.cppm` 声明 + `.cpp` 定义。
  判据是行为不变:拿重构前的二进制对照,`--list` 逐字节相同、**42 个生成文件
  逐字节相同**、一次真实测量的 cell 结构完全相同。
* **Linux 标准数据集**:696 个测量点、每格 3 轮。未跑的格子、macOS、Windows
  在表里一律标 `-`(未测),不留给读者当成结果。
* **CI 里的 bench matrix 已删**:它 10 格 32 条外部引擎臂里**豁免了 12 条**
  (xmake 被豁免的比在测的还多)而 job 是绿的。改为本地 `run-standard.sh`。

## 2. 数据本身推翻了一个已发布的说法

新表有 old-vs-new 列,于是最显眼的 200x **不再是「一直如此的默认行为」**:

    touch-hub     已发布 2026.8.11.3: 81.72s   本分支: 0.42s   cmake: 83.21s
    edit-comment  已发布 2026.8.11.3: 79.11s   本分支: 0.40s   cmake: 83.21s

级联抑制以前并没有生效,是这个分支让它真正工作的。根 README 中英两份的表格现在
由 `report.py --headline` 从**同一份报告**生成,守卫核 50 个中位数。

## 3. bmi_schedule:开着它跑 CI,修到全绿

`[build] bmi_schedule = "on"` 打开后被 CI 在一个周期内否掉两次,两次都是真缺陷:

* **默认配置下并发完全没有上限** —— 模块自己写着「上限是信号量」,而没给
  `--jobs` 时 `sched_cap = 0`,信号量被禁用,ninja 有多快就起多少个编译器
* **`.mcpp-sched` 令牌没有任何人回收** —— 对构建按一次 Ctrl-C 就永久少一个槽位,
  攒够 cap 个之后下一次构建**无输出卡死**(e2e 实测卡满 600s)
* **phase 1 等 BMI 无上限**(phase 2 反而有界)、**supervisor 有一条退出路径不写 `.rc`**
* **`cmd.exe /c` 不用 CreateProcess 的引号规则** —— Windows 宿主交叉构建时编译器
  少了 `-I`,报成「找不到头文件」

## 4. 七个 issue 的核实与修复

核实过程推翻了 issue 自己的三处说法(详见
`.agents/docs/2026-08-15-issues-412-422-analysis.md`):

* **#422**:归因给 `cxx_runtime` 是错的 —— CRT 模型来自 `linkage`,而构建 std 的
  命令里**一个 `/M` 开关都没有**。所以**默认配置就已经不匹配**,不是 host-coupled
  独有。修法与 `macos_deployment_target` 同形,且 CRT flag 进入
  `std_build_commands` ⇒ 缓存键自动分叉。
* **#416**:归因给 `std.o` 是错的 —— 实测 `std.o` 有 **0 个未定义符号**,拖不动
  任何库;纯 C 库的 `libstdc++.so.6` 来自链接一律用 g++。已拆出 #426。
  本 PR 只做 std.o 按需链接(带传递可达性)。
* **#418**:`cxxRuntimeTests` 有**两个**同名字段,只有 `TargetEntry` 那个是死的。
* **#415**:`$ORIGIN` 进闭包,e2e 219 现在能**逐项**比对
  (`closure == DT_RPATH, 4 entries, item by item`),不需要任何例外。
* **#417**:binding 无法求值是**一个事实**,不是每个产物一条(26 行 → 1 行)。
  真因未定位,不动时序。
* **#421**:文档承诺了一个不存在的能力(宏保护的 `import` 其实被前置扫描直接拒),
  中英两份都改对。
* **#412**:删掉一句劝退用户的假 note、一条与自身断言矛盾的注释,并补上
  `module_extensions` 在**非 gcc 平台**的覆盖(此前只在 Linux 测过)。

## 5. 新增测试

`234`(bmi_schedule 端到端 + 陈旧令牌)、`235`(std.o 按需链接,含传递性)、
`236`(module_extensions 走各平台默认工具链)、`233` 的多处守卫加固,
以及单测若干(MSVC CRT 单一真源、`Origin::Artifact` 的 rank 与
`is_machine_local`、per-target 未知标量键)。

上游缺陷开了 #424(clang 两条)、#425(已修)、#426

v2026.8.11.3

Toggle v2026.8.11.3's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: 产物必须加载它链接的那一份库 —— 链接行顺序成为声明,共享库不再劫持运行时 (2026.8.11.3) (#414)

`mcpp run` 一个 imgui/GLFW 工程,链接 rc=0,运行即死:

    undefined symbol: _ZNKSt13runtime_error4whatEv

## 缺陷一:`$ORIGIN` 被 SubOS 库视图遮蔽

2026.8.11.2(#413)首次把 SubOS 库视图(farm)写进产物 DT_RPATH,但它落在
`$ORIGIN` **之前**。而这两个目录在本生态里天然装着同名 SONAME —— mcpp 从
`compat.x11` 源码构建 `libX11.so` 部署到产物目录,xlings 又在 farm 里有
`xim:libX11`。于是**链接期用 A,运行期加载 B**。

真因不是"放错位置",而是**一条链接命令行的顺序由两个互不知情的生产者用 `+=`
决定**:`flags.cppm` 把 farm 拼进全局 ldflags(注释还写着 "so it is LAST"),
`plan.cppm` 把 `$ORIGIN` 拼进 per-unit,而链接规则渲染的是
`$ldflags $unit_ldflags`。三处各自都对,合起来是错的。

新增 `mcpp.build.link_line`:把 per-unit 尾部声明成**具名槽位**
(dependencies → cxxRuntime → runtimeFallback → loaderTag),相对顺序写在类型
里、由单测钉死。新增一个生产者必须先选一个槽 —— "选"正是"在产物自己的目录之前
还是之后"这个问题被提出来的地方。格式中立:槽位按职责命名,PE 的两个槽天然为空,
Mach-O 的 dependencies 装 `@loader_path`,没有任何 `if (platform)`。

## 缺陷二:共享库把自己的 C++ 运行时导出给了别人(ELF)

`SharedLibrary` 与可执行文件共用 `Distributable` 角色,于是拿到同一份
self-contained 契约:`-static-libstdc++`。ELF 上这不是"私有一份" —— 只有一个全局
符号命名空间,共享对象导出它定义的每一个全局符号。一个**纯 C** 的 compat 包因此
导出了 777 个 GLOBAL 标准库定义(`libXau.so`:39KB 的 Xau + 9.5MB 的 libstdc++)。

可执行文件链接时 `-lX11` 排在驱动的 `-lstdc++` 之前,ld 用它满足了
`runtime_error::what()`,归档成员从不拉入 —— **exe 的 `-static-libstdc++` 变成
空操作,它的 C++ 运行时事实上是那个 `.so`**。缺陷一之所以致命,根源在这里。

共享库默认契约改为**按目标格式分档**:

  ELF     toolchain-coupled   一个全局命名空间,先加载的定义胜出
  Mach-O  self-contained      机制本就是 -load_hidden,dyld 不归一;
                              且 toolchain-coupled 在 macOS 是死路(#202)
  PE      self-contained      没有全局命名空间,导入按 DLL 逐个按名解析

**只有 ELF 的行为变了,而它正是有缺陷的那个**;Mach-O/PE 产物字节不变。
显式 `cxx_runtime = { shared = "self-contained" }` 仍可选回自包含,此时自动补
`-Wl,--exclude-libs`(实测:按归档基名匹配,与 `-l` 还是完整路径无关),
让内嵌的运行时留在动态符号表之外 —— 逃生舱不会重新打开这个洞。

## 顺带修掉的架构债

`dist::default_contract` 自称"the role -> contract policy, in one place",实际
**没有任何生产调用方** —— 真正的策略在 `flags.cppm` 被第二次推导。这正是
`distribution.cppm` 开篇声讨的那类债("derived independently in five places")
换个位置复发。现在它是唯一真源。

`cxx_runtime` 补进 `[build]` 已知键白名单:它此前会打印 "unsupported key
(ignored)" —— 而 "ignored" 是假的,`--strict` 还会直接拒绝 manifest。整个特性的
唯一入口不能一边工作一边说自己被忽略了。

## 测试

- 新增 `test_link_line`(6):槽位顺序、空槽不产生多余分隔、顺序与赋值序无关
- 新增 `NinjaBackend.SubosFarmRpathFollowsTheArtifactsOwnDirectory`:断言在
  **合成后的**链接行上 `$ORIGIN` 早于 farm(只看其中一个变量,正是原缺陷隐形的原因)
- `test_distribution` +8:(Role × Format) 全表、`--exclude-libs` 只给共享库
- `test_manifest` +3:`shared` 键、未知角色键报错、标量拼写仍覆盖所有角色
- e2e 219 断言由「farm 是最后一个**绝对路径**条目」收紧为「**字面**最后一项」,
  并补一条**行为**不变量(`LD_DEBUG=libs` 实测同名 SONAME 解析到 `$ORIGIN`)。
  旧断言把 `$ORIGIN` 过滤掉了,对坏顺序与好顺序给出同一个结论;测试工程也从
  `int main()` 换成消费依赖共享库 —— 否则它连 `$ORIGIN` 都不产生
- 新增 e2e 222:共享库不得导出 GLOBAL 标准库符号、必须 NEEDED libstdc++.so.6、
  exe 无未定义 std 符号、显式自包含时护栏仍在

**红测**(两条新 e2e 对已发布 2026.8.11.2):219 报「farm is not the last entry」,
222 报「exports 713 GLOBAL standard-library symbols」—— 均以正确理由失败。

**⚠️ 行为不变量不得依赖崩溃**:缺陷二修好后崩溃会消失,依赖崩溃的断言会立刻假绿。
219 的不变量 4 因此测的是加载器的搜索过程,不是程序的退出码。

## 实测(helloegui:imgui + GLFW + X11)

| 判据 | 修复前 | 修复后 |
|---|---|---|
| DT_RPATH 尾部 | `… : <subos>/lib : $ORIGIN` | `… : $ORIGIN : <subos>/lib` |
| `libX11.so.6` 解析到 | farm 里的 xim:libX11 1.8.10 | `$ORIGIN`(farm 未被试到) |
| `bin/libX11.so` 导出 std 符号 | 2931(777 GLOBAL) | 0 |
| `bin/libXau.so` | 9 557 936 B | 39 368 B |
| exe 未定义 `runtime_error::what` | 有 | 无 |
| 运行 | symbol lookup error | GUI 正常启动 |

分析:`.agents/docs/2026-08-11-runtime-search-origin-precedence-analysis.md`
计划:`.agents/docs/2026-08-11-origin-precedence-implementation-plan.md`

Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>