Skip to content

Latest commit

 

History

History
1018 lines (784 loc) · 47.9 KB

File metadata and controls

1018 lines (784 loc) · 47.9 KB

Cross-repo fix plan: #532, #533, #534

Status: plan. No code changed yet. Branch: fix/action-ordering-and-package-identity, cut from mcpp-community/mcpp @ ab1da5d (origin/main). Evidence: .agents/docs/2026-08-30-issues-532-533-534-analysis.md — every claim below rests on a reproduction recorded there, not on reading alone. Repos touched: mcpp (mcpp-community/mcpp), xlings (d2learn/xlings, released as openxlings/xlings), mcpp-index (mcpplibs/mcpp-index).


0. What this plan is

Seven changes in mcpp, one in xlings, and a policy decision in mcpp-index. They are grouped into four tracks that can be reviewed, merged and released independently, plus one ordering constraint between repos that cannot be reordered.

The one thing to get right before anything else is §1. Everything after it is ordinary work.

track repo issue depends on
A — the misleading error mcpp #533 L3 nothing
B — action ordering mcpp #534 nothing
C — install marker, lib root mcpp #533 L2/L4 nothing
X — store identity xlings #533 L1 nothing
F — floor bump mcpp X released
I — index policy mcpp-index #533 impact F released and adopted

A, B, C and X are mutually independent and can land in parallel. F and I are not, and §1 is why.


1. The cross-repo ordering constraint

Fixing xlings does not make it safe to publish a colliding version.

This is the part that cannot be undone once it goes wrong, so it is stated first.

After X lands, a machine running the fixed xlings will install compat:libdrm@2.4.123 correctly even though xim:libdrm@2.4.123 is in the store. A machine running an older xlings will still silently skip install() and produce the -shared: not found failure. The index is data consumed by every installed client, not just the newest — so the moment mcpp-index publishes a descriptor whose <shortname>@<version> collides with an xim package, every user below the fixed xlings breaks, and breaks in the way #533 documents: silently, with an error naming the linker.

mcpp already has the mechanism for this. src/xlings/xlings.cppm:65:

inline constexpr std::string_view kXlingsVersion = "2026.8.27.5";

The comment above it is explicit that this is a floor, not a current pick, and .github/tools/check_version_pins.sh enforces that the seven copies under .github/ agree with it. acquire_xlings_binary (src/fallback/xlings_binary.cppm:53) replaces a vendored xlings that is strictly older than the floor.

So the ordering is:

X merged in xlings
  → xlings release V
    → mcpp Track F: kXlingsVersion = V           (one edit; CI enforces the 7 copies)
      → mcpp release carrying that floor
        → the installed base moves
          → ONLY THEN may mcpp-index publish a colliding <shortname>@<version>

The last arrow is not a build step and has no green checkmark. It is a judgement about the installed base. §6 proposes not taking it at all.

Nothing in tracks A, B, C or X requires this. Track X is worth merging on its own merit — it removes a silent failure for anyone who hits the collision by accident, which per §6.1 is already 20 short names wide.


2. Track A — mcpp: stop reporting a package-identity bug as a shell error

Three changes. A2 is the load-bearing one — see the measurement immediately below. A1 is hygiene; A3 is what stops the class.

A0 — the measurement that sets the priority

The self-review (§11) asked what -shared: not found would become if only A1 landed, and whether the shared-library case is the whole story. Both were measured:

$ gcc -shared @empty.rsp -o out.so
gcc: fatal error: no input files          # exit 1 — bad message, but it FAILS

$ ar rcs libempty.a
$ echo $?; stat -c %s libempty.a
0                                          # exit 0
8                                          # an empty archive, silently

So the reported symptom has a silent sibling. A kind = "lib" package whose install() was skipped produces an empty 8-byte .a through cxx_archive (ninja_backend.cppm:1915, $ar from dial.archiveCmd) and the build reports success. Consumers then fail at link time with undefined symbols, three layers further away than #533's shell error.

Two consequences for this plan:

  1. A2 is the fix; A1 is not. A1 alone converts a shell error into gcc: fatal error: no input files, and does nothing at all for the static case, which never reaches a compiler driver.
  2. A1 must not land without A2. On its own it makes the shared case quieter without making it correct, and quieter is the direction #533 was already suffering from.

A1 — emit cc unconditionally

src/build/ninja_backend.cppm:594

if (need_c_rule || need_asm_rule || need_ios_init_shim) {
    append(std::format("cc        = {}\n", escape_ninja_path(flags.ccBinary)));
}

c_link and c_shared reference $cc, and a link unit with zero compile units selects c_shared while need_c_rule is false. $cc expands to nothing and the shell executes -shared.

Three emission sites across two dialect branches — c_link at ninja_backend.cppm:1171 (the ldDriver branch, which emits no c_shared), and c_link/c_shared at :1188 and :1190 (the default branch). Reasoning about which branch is reachable with which variables defined is exactly the work A3 removes.

The file already states the rule that prevents this, 26 lines below, for the other variable of the same two rules (ninja_backend.cppm:615-620, mcpp#426):

ALWAYS emitted, even when identical — c_link references $c_ldflags, and a conditional definition would make an empty link line the failure mode on exactly the toolchains where the two happen to agree.

Apply it to $cc. Leave cflags conditional: it is referenced only by c_object, which is itself gated on need_c_rule.

A2 — refuse a link unit with no inputs, at plan time

LinkUnit::objects (src/build/plan.cppm:88) is empty and nothing checks it. There is no guard anywhere in plan.cppm, prepare.cppm or ninja_backend.cppm.

Add the check where link units are finalised, after the role = "object" action outputs are attached (prepare.cppm:8370) and after the PE .def attachment (prepare.cppm:8608-8610) — those are the two places objects arrive from somewhere other than the compile set, and checking before them would fire on a legitimate unit.

Scope, measured. mcpp gives every library target of a package the package's whole inferred source set — a two-target project where one target "has no sources of its own" still links the same objects, so it is not affected. The only route to an empty unit is a package with zero compile units, which is exactly the state a skipped install() leaves behind. The check is therefore narrow, and it must cover all three kindsBinary, SharedLibrary and StaticLibrary — because per A0 only the static one is currently silent.

Message must name the target and what matched nothing:

error: target 'libdrm' (kind = shared) has no inputs to link
       inferred sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}] matched 0 files under
       <pkg root>
       a library target must have at least one translation unit, one `role =
       "object"` action output, or an explicit [lib].path

Not a mcpp::build::refusal::Code. That enum (src/build/refusal.cppm) classifies target refusals — which triple, which toolchain — and every one of its 14 codes answers "why can this platform not be built". A link unit with no inputs is a project error, not a platform verdict. Adding a code here would widen the enum's meaning to something its consumers (tests/matrix/scan.sh) do not read it for.

A3 — the durable guard: no rule may reference an undefined variable

A1 fixes one variable. A3 makes the class impossible.

ninja_backend.cppm already has the pattern, and its comment says why (check_inline_command_lengths):

Scanning the emitted manifest rather than instrumenting each emit site is deliberate — a new edge kind is then covered the day it is added, which is precisely how the previous seven slipped through.

Add a sibling, check_undefined_ninja_variables(manifest), run at the same point: collect every $name referenced inside an emitted rule block, collect every top-level name = definition, and fail the build if a rule references a name that is never defined. Same shape, same call site, same test file.

This is the change that would have caught #533 L3 before it shipped, and it costs one function.

Review question. A3 turns any future conditional-variable slip into a hard build failure at manifest-emit time. That is the right strength for a defect whose alternative failure mode is /bin/sh: 1: -shared: not found — but it means a rule referencing a genuinely optional variable (none today) would have to define it empty. Confirm that is acceptable before implementing A3.


3. Track B — mcpp: action ordering (#534, all three defects)

The issue asks for exactly the right fix. Four changes deliver it.

B1 — BuildAction learns which package declared it

modules/manifest/src/types.cppm:299 — add:

std::string packageName;   // the package whose build.mcpp declared this

Set it in collect() at src/build/prepare.cppm:8298-8305, which already walks *m (root) and packages[i].manifest (dependencies) and knows which is which. CompileUnit already carries packageName (plan.cppm:44), so after B1 both sides of the edge speak the same key.

This field is not serialised across the build-program protocol — the protocol carries what the program declared, and the package is known to the engine, not to the program.

B2 — a per-package action phony, order-only on that package's compile edges

Model it on kStagedCachePhony, which is the same shape already working in this file:

// today, ninja_backend.cppm:1534 — one phony, one global string
stagedOrderOnly = " || " + std::string(kStagedCachePhony);

Replace the scalar with a per-unit lookup:

std::string order_only_for(const CompileUnit& cu);   // staged phony + this package's action phony

Emit build mcpp-actions-<sanitised-package> : phony <outputs…> for each package that declares ≥1 Source action, or ≥1 Check action with blocking = true (B3).

The seven call sites that must all be converted — this is the enumeration, not an example:

line edge
ninja_backend.cppm:1558 cxx_scan (the .ddi edge)
:1683 dyndep-mode object edge
:1754 split-BMI object edge
:1778 object edge
:1786 object edge
:1789 object edge
:1841 object/asm edge

Object and Artifact roles stay out of the phony: an Object action's outputs are link inputs (prepare.cppm:8370) and an Artifact action's inputs are link outputs, so both are already sequenced by file dependency — which is what ninja_backend.cppm:2090 claims for all four roles and is true for these two.

Source role also stops being excluded from actionDefaults (ninja_backend.cppm:2173)? No — leave that exclusion. After B2 the phony makes Source outputs reachable through the compile edges, which is the architecturally correct route and keeps explicit-goal builds (#274) working without a default entry. Reaching them two ways would resurrect the soname-alias problem in mirror image.

Per-package, not one global phony. The simpler design — a single phony over every action output in the build, order-only on every compile edge — needs no B1 and mirrors stagedOrderOnly exactly. It is rejected for two reasons that point the same way. Semantically, include_dir "colours only this package's own TUs" (docs/07-build-mcpp.md, line 67), so a generated header is visible to one package by construction and cross-package ordering would express a dependency that does not exist. Practically, a modular mcpp build is latency-bound — the critical path is effectively 100% of wall clock — so a false edge from package A's compile to package B's generator lands directly on that path. B1 costs one string field; the global variant costs correctness of meaning and wall clock.

B5 — a structural self-check, not seven trusted edits

B2 edits seven call sites. An eighth added later without the order-only string reintroduces #534 for that edge kind, silently — and §10 originally left this as a risk to be careful about. Being careful is not a mechanism.

The emitter has everything needed to check itself: after the manifest is built, it knows each package's action phony and each package's object paths (CompileUnit::packageName + CompileUnit::object). Assert, over the emitted text, that every build line producing an object of package P carries || mcpp-actions-P whenever P declares a qualifying action.

Same shape and same call site as check_inline_command_lengths, whose comment states the principle this reuses:

Scanning the emitted manifest rather than instrumenting each emit site is deliberate — a new edge kind is then covered the day it is added, which is precisely how the previous seven slipped through.

Seven, again. B5 is what makes B2's enumeration hold.

B3 — wire blocking

action.blocking is typed (types.cppm:331), emitted (src/build/hostprogram.cppm:126), parsed (modules/buildmcpp/src/directives.cppm:721), documented in both languages (docs/07-build-mcpp.md:315, docs/zh/07-build-mcpp.md:281) and demonstrated (examples/08-build-rules/rules-tidy) — and read nowhere. The only || in the backend is stagedOrderOnly.

B2 supplies the mechanism. A Check action with blocking = true puts its stamp in the package's phony; a non-blocking one does not. One if, once the phony exists. blocking = true stops being a documented no-op.

B4 — stop materialising placeholders for outputs that are not translation units

modules/buildmcpp/src/directives.cppm:788-800 writes a zero-byte file for every Source output with no extension filter. The scanner only ever reads translation units, and adoptActionOutputs already refuses to adopt anything else (prepare.cppm:4255).

The placeholder's only present effect on a generated header is to turn "the generator did not run" into "the header is empty". That substitution is what cost #534 its diagnosis: the reporter saw the file on disk and concluded the action had run.

Gate the placeholder on is_compilable_output (directives.cppm:770), the same predicate adoptActionOutputs uses.

Review question. B4 is behaviour-visible: a project that today compiles against an empty generated header (because the action never ran, and nothing in it was used yet) will start failing with file not found instead. That is the better error, but it is a change. B4 is separable from B1–B3 — it can be dropped without weakening the fix, at the cost of leaving the misleading artefact in place.


4. Track C — mcpp: the marker, and the warning

C1 — do not stamp .mcpp_ok on the callee's word

src/pm/package_fetcher.cppm:1095

if (inst->exitCode == 0 && std::filesystem::exists(verdir)) {
    mcpp::fallback::mark_install_complete(verdir);

mcpp asks the right question — verdir is namespace-qualified at package_fetcher.cppm:986 — and accepts the wrong proof. Because step 1 of the resolution chain (is_install_complete, :1049) short-circuits on this marker, the wrong state survives every subsequent build.

The reported directory contained .mcpp_ok, .xpkg-install.json and mcpp_generated/nothing except what mcpp and xlings wrote themselves. That is a detectable signature that needs no knowledge of the descriptor:

payload_is_substantive(verdir) :=
     ∃ entry in verdir not in { .mcpp_ok, .xpkg-install.json, mcpp_generated/ }
  OR verdir has any of bin/ include/ lib/            (the payload shape mcpp consumes)

Deliberately weak. It cannot verify a descriptor produced the right tree, and it should not try.

Two call sites, not one — this is what makes the upgrade seamless.

  1. Before writing the marker (package_fetcher.cppm:1095): if !payload_is_substantive(verdir), do not call mark_install_complete, and warn naming the package and the directory contents. Not a hard error — see the scoping note below.
  2. On the fast path (package_fetcher.cppm:1049, is_install_complete): re-check there too. is_install_complete is marker-only by design (src/fallback/install_integrity.cppm:135-150), so a store already poisoned by this bug never heals — the bad .mcpp_ok is already on disk and short-circuits forever. Without (2) the fix helps only users who have not hit the bug yet, which is the wrong half.

Scoping — why a warning and not a hard error. xlings supports type-only packages that legitimately install no payload (installer.cppm: "type-only packages like auto-config don't need a payload"), and #531 now provisions [xlings] deps on first build, so mcpp resolves packages it does not consume a tree from. A hard error would fail those. Withholding the marker is the honest action — the marker means verified complete, and mcpp could not verify it — and the cost of being wrong is one cheap re-check per build (xlings short-circuits its own install), not a failure. The build then fails, if it fails, at A2 with a message that names the target.

This is the third recurrence of ".mcpp_ok proves a process exited 0, not that an artifact is correct", so the guard belongs beside kInstallMarker in src/fallback/install_integrity.cppm — one predicate, both call sites — not open-coded at either.

C2 — the lib-root warning asks the wrong question

src/modgraph/validate.cppm:157, gated on has_lib_target (modules/manifest/src/types.cppm:1317), true for any Library or SharedLibrary. The convention candidate is src/<tail>.cppm; a pure-C library has none and can never have one. Reproduced verbatim in a two-line mcpp.toml.

The predicate is "does this target produce a library"; the property wanted is "is this a C++ module library". Narrow the gate: skip the lib-root check when the target's source set contains no file classifying as a module interface under the package's own extension table (mcpp::extension_table_for, the same table adoptActionOutputs uses at prepare.cppm:4253).

Per #533: this warning propagates to every consumer of a C package, so it is not cosmetic.


4b. Track D — mcpp: the documentation is part of the defect

Not cosmetic. docs/07-build-mcpp.md:315 and its Chinese counterpart carry the table that says what each role guarantees, and two of its cells are the claims #534 disproves:

cell today after B
source / Ordering "the compile edge consumes them" true only for a generated TU; a generated header is never an edge input. Must say the package's compile edges wait for the action.
check / Ordering "runs alongside compilation (set blocking = true to gate it)" the parenthesis describes a mechanism that does not exist until B3

Also ninja_backend.cppm:2088-2095, the comment that states the false assumption in the engine itself:

Ordering needs no special handling: a Source action's outputs ARE the compile edge's inputs …

That comment is why the defect was not found earlier, and leaving it in place after B would leave the next reader with the same wrong model. It must be rewritten to say what is actually true: Object and Artifact are sequenced by file dependency; Source and blocking Check are sequenced by the package's action phony, because their outputs may be headers, which are reached through -I and never appear as edge inputs.

Both language versions of the docs change together — CI enforces the pair. The examples/08-build-rules/README.md line about blocking = true becomes true for the first time and needs no change.


5. Track X — xlings: key the store lookup on the qualified identity

src/core/xim/installer.cppm:903-911:

// Default: check xvm version database when no installed hook
else if (!payloadInstalled) {
    auto db = Config::versions();
    auto resolved = xvm::match_version(db, node.name, node.version);   // ← short name
    if (!resolved.empty()) {
        log::debug("{} already installed in xvm (version {})", node.name, resolved);
        payloadInstalled = true;
    }
}

Three facts:

  1. node.name is the bare short name. The same PlanNode carries namespaceName and canonicalName (src/core/xim/libxpkg/types/type.cppm:34-44). The qualified identity is at the call site and discarded.

  2. xvm::match_version does db.find(target) (src/core/xvm/db.cppm:99); the VersionDB is keyed by program name. That is the correct key for xvm's own question — which version of the program named X is active — and the wrong key for is package <ns>:<name>@<ver>'s payload installed.

  3. The primary path is already correct. src/core/xim/catalog.cppm:302:

    auto installDir = match.storeRoot / package_store_name(match.namespaceName, match.name) / match.version;
    match.installed = exists(installDir) && is_directory(installDir) && !is_empty(installDir);

Proposed fix. The fallback should ask the store the way catalog.cppm:302 already does, not the xvm program table. Extract that expression into one function and call it from both places — two answers to one question should be one function. The xvm lookup then either goes away, or is kept strictly for what it is about (shim activation) and no longer gates install().

X2 — the diagnostic. Whatever the key becomes, a lookup that matched a different namespace must not be log::debug. Either refuse, or say at default verbosity which store entry was matched:

compat:libdrm@2.4.123 — skipping install(): matched xim-x-libdrm/2.4.123

#533's closing sentence is the requirement: "现在的表现是把一个包管理器的身份 问题伪装成了一条链接器命令行错误。" The engine fix removes the substitution; X2 is what makes the next identity question legible instead of silent.

Related, not the same: xlings#381 is this defect on the index side (keying by (namespace, name)). X is the store side. Fixing one does not fix the other.


6. mcpp-index: what becomes possible, and the recommendation not to use it

6.1 Current state, measured

Nothing in the index is broken today. The four packages routed around the collision by choosing versions that do not collide — origin/main @ 89cfee7:

package version on index main #533 said it wanted
compat.libdrm 2.4.134 2.4.123
compat.wayland 2026.08.30 1.23.1
compat.libffi 3.4.8 3.4.4
compat.expat 2.7.1 2.6.2

The store on the development machine already holds 20 short names occupying two or more namespaceslibpng, expat, zlib, cairo, fontconfig, linux-headers, nasm, ncurses, mcpp, xlings and more. The constraint #533 identifies is already load-bearing across the installed base; it holds only because the versions happen to differ.

6.2 Recommendation: do not re-pin

Track X removes the defect for anyone who hits the collision by accident — which, given 6.1, is the common case and the one worth fixing. Deliberately publishing a colliding version is a different act, and per §1 it breaks every client below the floor, silently.

Concretely:

  • Do merge and release X. It makes accidental collisions install correctly and (via X2) legible.
  • Do not change the four packages' versions. They work, and a bare version pin is exact — every consumer would have to be re-pinned individually, and the version a consumer pinned is not visible in the index descriptor.
  • Do allow new packages to use the upstream version once the floor has shipped and been adopted, as a per-package judgement rather than a blanket policy change.

An index-side min_mcpp on colliding descriptors is not proposed: a floor in the index turns old clients into bricks, and the index is data while mcpp is a program — published data must not invalidate the program.

Review question. §6.2 says the four packages keep their current versions permanently. The alternative is to schedule a re-pin after the floor ships, which buys "the consumer and Mesa name the same libdrm version" at the cost of an enumerated re-pin of every consumer and a window where older clients fail silently. This is a judgement call, not a technical one — flagging it rather than deciding it.


7. #532 — the graphics-stack example

Not part of any track above; listed so the three issues close together.

The technical answer is sound and worth landing: four declarations resolve an 11-entry closure including three transitive libraries no manifest names, which is what #527 §1.4 said was impossible. Three amendments before merge:

  1. Reword the rationale. The sentence "mcpp 在构建期就明说了" is true only when the closure would be unsatisfiable. Measured: -lcap (host-only) is refused with a precise message; -lgbm on a machine that also has Mesa in the SubOS builds silently, exit 0, and the artifact takes its ABI from /usr/include while its private loader resolves libgbm.so.1 from registry/subos/default/lib. On the exact class of machine this example targets, #527's reporter gets silence, not a refusal.

    The correction strengthens the argument: the reason not to write -L/usr/lib is not that mcpp refuses it — it is that mcpp cannot refuse it when the soname resolves on both sides, and you are then shipping an artifact compiled against one library and loaded against another.

  2. Re-measure the closure in a sandbox. The 11 entries were counted on the development machine, where registry/subos/default/lib is a shared, accumulating view — the same directory that silently supplied libgbm.so.1 above. That reading cannot distinguish "satisfied by the four declared packages" from "satisfied by whatever else this machine has installed".

  3. Decide the CI posture and write it in the README. Examples are covered individually (tests/e2e/312_build_rules_example.sh builds example 08). Example 09 opens /dev/dri/renderD128, which no runner has, and its four dependencies are index packages needing network and a current index. Either build-only in CI with the device work behind a runtime guard, or documented as uncovered — an example with neither rots, and this one carries four external version pins that will drift.

Separate follow-up to file: the silent link-A/load-B case in (1) is a defect in its own right, and it is the part of #527 §1 that is actually about mcpp rather than about usage.


8. Tests and criteria

Criteria are stated so that a false reading and a did-not-run reading are different, and so that every count carries a denominator.

New e2e (next free number is 314; all # requires: gcc, which both shards have)

id asserts why this shape
314 a dependency package whose Source action emits only a header builds, and the header is non-empty with expected content #534 D1 in the reported shape. Assert content, not existence — existence passes today against a 0-byte placeholder
315 a companion header (p.cc + p.h) consumed by a different TU compiles, with a sleep in the generator and at -j > 1 #534 D2. 188_build_actions.sh §2c has this action but its main.cpp does not include p.h, so ordering is never exercised
316 blocking = true on a failing check prevents the object from being produced; blocking = false does not #534 D3. Assert on the artifact, not on a log line
317 a kind = "shared" target matching zero sources fails with a message naming the target, and the message does not contain -shared: not found Track A2. The negative half is the criterion that catches a regression to the shell error
318 after a reported-successful install, a version dir containing only mcpp/xlings-written entries is refused and named Track C1

Structural criteria on the emitted manifest (unit tests on emit_ninja_string)

  • Denominator, Track B. For a plan with one package declaring ≥1 Source action: count(compile edges carrying "|| mcpp-actions-<pkg>") equals count(compile edges of that package), and both counts are asserted non-zero separately. A single equality passes when both are zero — which is exactly today's state.
  • Track A1. A plan with zero C compile units and one SharedLibrary link unit emits a cc = line. Today it does not; the repro is in the analysis document.
  • Track A3. A manifest referencing an undefined $var inside a rule is rejected. Add one deliberately-broken fixture so the checker itself is tested.

Amend, do not replace

tests/e2e/188_build_actions.sh §2c stays as is — it tests that a companion header is not adopted as a TU, which is a real invariant. 315 is a new case, not a rewrite. Line 178's comment (// companion: produced, NOT compiled) should gain a pointer to 315 so the ordering gap is not re-derived.

Existing coverage that must not regress

tests/e2e/313_check_stamp_on_every_platform.sh covers the check role's stamp wrapper. B3 changes when a blocking check's stamp is demanded, not how it is written; 313 must stay green unmodified.


9. PR sequence

Chosen shape: xlings first and released, then one mcpp PR carrying everything including the floor pin. The alternative — four small mcpp PRs — was rejected because Track F (the floor bump) can only be written once V exists, and splitting would either strand F in a fifth PR or pin a version that is not yet released. One PR also means one CI matrix proves the combination, which matters here: A2 and B2 both change what a failing build prints, and their interaction is only observable together.

# repo contents gate
1 xlings X1 + X2 + a test that two namespaces × same (name, version) both install
2 xlings merge, then release V 1 green on xlings main
3 mcpp one PR: A1+A2+A3, B1–B5, C1+C2, Track D docs (EN+ZH), e2e 314–318, unit tests with denominators, version bump, and kXlingsVersion = V 2 released
4 mcpp CI green → second self-review of the diff → merge → verify the run on origin/main HEAD SHA 3
5 mcpp release; backfill GitCode assets with gtc 4
6 sandbox verification of the released artifacts (§8 "released-form criteria") 5
7 mcpp #532 example with the three §7 amendments; file the link-A/load-B follow-up independent

Within PR 3 the tracks are independent and can be implemented in parallel; only the floor pin is gated on step 2.

Per §1, step 5 does not authorise an index change. §6.2 stands.

House rules: everything except pure documentation goes through a PR; a green PR is not a green main — the criterion is the CI run on origin/main's HEAD SHA after merge; and for the release, the criterion is the index latest pointing at it, not the tag existing.


10. What could still go wrong

  • B2's phony membership is a new enumeration. Seven call sites today; an eighth added later without the order-only string reintroduces #534 for that edge kind, silently. Resolved during self-review: this is B5, not a risk to be careful about. The denominator test in §8 remains necessary — a plain grep -c … || … test reads zero as success — but B5 is what makes the enumeration hold.
  • A2 could refuse a legitimate unit. A link unit whose objects all arrive from role = "object" actions is legitimate and non-empty at prepare.cppm:8370 — hence placing the check after that attachment and after the PE .def attachment at :8608. If a third source of link inputs is added later, it must land before the check. Worth a comment at the check site naming both existing sources.
  • C1's exclusion list is a hand-maintained list, which is the shape this codebase has been burned by before. Keep it to the three names that exist, put it next to kInstallMarker in src/fallback/install_integrity.cppm, and state in the comment that a new mcpp-written entry must be added to it.
  • Track F's floor bump is measured on the released artifact, not the merge. The criterion is that a fresh install pulls xlings V and that mcpp self env reports it — not that PR 7 is green.
  • X1 changes install-skip behaviour for every xlings user, not just mcpp's. A package that was being skipped and is now installed will run an install() hook that may not have run in a long time. Worth checking on xlings' side whether any descriptor depends on being skipped.

11. Self-review log

The plan above was reviewed against nine axes before any code was written. Six axes produced changes; three confirmed the plan as drafted. Everything recorded here is a change already folded into §1–§10 — this section exists so a reviewer can see what moved and why, not to hold pending work.

11.1 Architecture — one principle, found three times

.mcpp_ok, the ninja graph, and the runtime closure validator all answer some form of "is this thing ready?", and this investigation found each of them trusting a proxy instead of the artifact:

answerer trusted should trust
.mcpp_ok the callee's exit code + directory existence something the package installed (C1)
ninja graph "a Source action's outputs ARE the compile edge's inputs" an explicit order-only edge, because headers never are (B2)
link edge that a rule's variables are defined because its inputs exist a manifest-level check (A3)

Stated once: a completeness marker must be derived from the artifact, not from the producer's report. Each of A3, B2 and C1 is that principle applied where it was missing. This is why they belong in one PR — they are one change of mind about evidence, in three places.

Second architectural note: after B, mcpp::action's documented contract ("role only decides where the edge's outputs attach") becomes true for all four roles for the first time. Today it is true for two.

11.2 Stability — the priority inside Track A was wrong

Drafted as "A1 is three lines; A2 is the one that says something true", which read as though A1 were the safe first step. Measurement (§A0) inverted it: ar rcs with no members exits 0, so the static-library form of this defect is silent today, and A1 does not touch it. A1 alone would also make the shared form quieter without making it correct. §2 now leads with A0 and states that A1 must not land without A2.

11.3 Elegance — the simpler B was considered and rejected on merit

A single global action phony needs no BuildAction::packageName and mirrors stagedOrderOnly exactly. Rejected: include_dir colours only the declaring package's TUs, so cross-package ordering would encode a dependency that does not exist, and mcpp's builds are latency-bound so the false edge lands on the critical path. Recorded in §3 rather than left implicit, because "why not the obvious simpler thing" is the question a reviewer will ask.

Conversely, elegance added B5: seven hand-edited sites guarded by a manifest-scanning check is a smaller thing to maintain than seven sites guarded by care.

11.4 User experience — the diagnostics are the deliverable

Every one of these defects is, from a user's seat, a bad message. So the messages are specified in the plan rather than left to implementation: A2 names the target and what matched nothing; C1 names the package and the directory contents; X2 names the store entry that was matched instead. All three follow mcpp's existing shape — name the thing, name the reason, name the way out — which the runtime closure validator already demonstrates and which §7 quotes in full as the standard to meet.

11.5 Compatibility — every behaviour change is now enumerated

change today after who notices
A2 empty .a built, build succeeds error naming the target a package with zero sources — broken already, silently
A2 /bin/sh: 1: -shared: not found error naming the target same
B2 header-only action never runs runs, ordered anyone who worked around it by running the generator eagerly — their build still works, one extra edge
B4 empty generated header file not found a project relying on the placeholder; better error, still a change
C1 .mcpp_ok written unconditionally withheld when nothing was installed type-only packages re-check each build (cheap); see §4 scoping
X1 collision skips install() installs correctly every xlings user, not just mcpp's — see §10

The one with the widest blast radius is X1, and it is in the other repo. §10's last bullet stands: xlings should check whether any descriptor depends on being skipped.

11.6 Seamless upgrade — the original plan fixed only half the population

is_install_complete is marker-only (install_integrity.cppm:135-150), so a store already poisoned by #533 carries a .mcpp_ok that short-circuits forever. The drafted C1 guarded only the write site, which helps users who have not yet hit the bug and does nothing for those who have — the wrong half, since the people who have hit it are the ones who filed the issue. C1 now specifies the fast-path re-check as well. Nobody has to know to delete a directory.

The xlings floor upgrade needs no user action: acquire_xlings_binary replaces a vendored xlings strictly older than the pin, and looks before it leaps.

11.7 Cross-platform — verified, one asymmetry found

  • A1/A2/B/C are platform-neutral; || is ninja syntax, not shell.
  • On the MSVC dialect separateLinker is true, so cxx_shared is used and the $cc gap cannot arise there — A1 is a no-op on Windows, A2 is not.
  • The asymmetry: need_ios_init_shim (macOS, static libc++) defines cc where Linux would not, so the same defective input can take different paths per host. A2 removes the divergence by refusing before either path is chosen — another reason to treat A2, not A1, as the fix.
  • New e2e must be reachable: # requires: gcc is the token with 55 existing tests and it runs on both Linux shards. run_all.sh:153 already warns that declaring a token is part of adding it.

11.8 Consistency — one mechanism, not two

stagedOrderOnly and the action phony are the same idea (aggregate into a phony, attach order-only, one word per edge — mcpp#274's reason). They must share one code path — a single order_only_for(cu) returning both — rather than two parallel strings appended at the same seven sites. Folded into §3.

Likewise C1's predicate lives beside kInstallMarker, used by both call sites, rather than open-coded twice; and X1 extracts catalog.cppm:302's expression so the store is asked the same way in both places. In all three cases the defect being fixed was a second derivation of a decision that already existed elsewhere, so a fix that adds a third would be self-defeating.

11.9 Test coverage — the plan was under-specified in one place

§8's criteria were adequate for B and A, and thin for C. Added: e2e 318 must assert the fast-path case too — a store seeded with a poisoned .mcpp_ok must heal on the next build — because that is the half of C1 that §11.6 added and an assertion on the write path alone would pass without it.

Confirmed as drafted: the denominator requirement (a bare equality passes when both sides are zero, which is today's state), asserting header content rather than existence (existence passes against the 0-byte placeholder), and asserting B3 on the produced artifact rather than a log line.

11.10 What the review did not change

  • §1's ordering constraint and §6.2's recommendation not to re-pin the index. Re-examined; the reasoning holds and is the most consequential judgement here.
  • The decision to keep Source outputs out of actionDefaults (§3): reaching them two ways is how the soname aliases went missing in 0.0.104.
  • §7's three amendments to #532.

12. What landed, and what the plan got wrong

Written after implementation, against the code as merged. Everything here was measured, not inferred.

12.1 Two corrections to the plan itself

The xlings anchor was 167 commits stale. §5 located Track X at installer.cpp:903-911 and described xvm::match_version(db, node.name, …) as a fallback beside a namespace-aware primary path. That reading came from a local checkout dated 8 July. Upstream main had since consolidated four separate "is this installed" answerers into one install_state module whose predicate takes a namespace — and the defective call site had moved to installer.cpp:2628, survived the consolidation, and become a fifth answerer to the question that module exists to answer alone. The fix is unchanged in shape and better motivated than the plan knew.

The general rule this is the second instance of: what the implementation is can only be read from the tracked upstream branch. A worktree at the fetched ref costs one command and is the only thing that makes an anchor trustworthy.

is CI-enforced in xlings and merely conventional in mcpp. tests/e2e/tui_output_contract_test.sh §S6 greps all of src/**/*.{cppm,cpp} for U+26A0 and U+24D8 and fails if either appears outside src/core/glyph.cppm — they are label glyphs the renderer owns, and a second spelling is how a dead icon table in src/platform/ once drifted from the real one. Two comment headers turned an otherwise-green xlings PR red (101 passed, 1 failed) for comment decoration alone. is not governed and is already used in that tree.

12.2 What shipped

where note
A1 ninja_backend.cppm cc unconditional
A2 prepare.cppm, before return ctx after all three object sources
A3 check_rule_commands_name_a_program exported, so hand-written manifests can test it
B1 BuildAction::packageName set in collect(); qualified_package_name exported so the two spellings cannot drift
B2 per-package phony, 7 call sites via order_only_for(cu)
B3 blocking reaches the graph for the first time
B4 prepare_actions placeholder gated on is_compilable_output
B5 check_action_ordering with the denominator
C1 payload_is_substantive both the write site and the fast path
C2 validate.cppm gated on the graph containing a module interface
D docs/07-build-mcpp.md + zh the role table and the engine comment
X installer.cpp:2628, owner.cppm payload_path_names_another_package

12.3 The measurement that changed the plan's priorities

§A0 was added mid-review and is the most consequential single fact found:

$ ar rcs libempty.a ; echo $? ; stat -c %s libempty.a
0
8

A static library target with no sources built successfully — confirmed against the released binary, which printed Finished dev and left an 8-byte .a. The reported symptom (/bin/sh: 1: -shared: not found) was the loud half of a defect whose quiet half reported success. That is why A2 is the fix and A1 is hygiene, and why the check is at plan time rather than in the linker diagnostic.

12.4 Evidence that the tests discriminate

Every new e2e was run against the pre-fix binary (2026.8.28.2) and every one fails there:

314  'PROTO_ANSWER' was not declared in this scope
315  a failing BLOCKING check did not stop the compile — the object exists
316  /bin/sh: 1: -shared: not found

The xlings unit tests were checked the other way — by reverting payload_path_names_another_package to its previous answer and re-running. PayloadOwnership.SameShortNameInAnotherNamespaceIsAnotherPackage fails.

Neither check is optional here. Three of these tests assert a negative (nothing was produced, the marker was withheld, the phony is absent), and a negative assertion against absent machinery passes by describing nothing.

12.5 Unchanged

§1's ordering constraint and §6.2's recommendation not to re-pin the index both survived implementation. Track X removes the accidental collision, which is the common case; publishing a deliberate one stays gated on the floor being adopted, and that gate has no green checkmark.


13. Released, and verified against the released form

13.1 What shipped

version evidence
xlings 2026.8.30.2 openxlings/xlings#576f5a0775; release run all-green; 8 assets on GitHub, xlings-res and GitCode; xim-pkgindex#729 moved latest
mcpp 2026.8.30.1 #5360117a9f; 36 PR checks green, and main green on the merge commit; release run all six jobs green; xim-pkgindex#730 moved latest

The GitCode leg needed the documented local step. publish-ecosystem had already uploaded, but that is not something a status line can be trusted for: raw.gitcode.com answered 200 for every asset while serving a 3576-byte HTML page. The check that settles it is the bytes —

downloaded: 5917911 bytes            (GitHub says 5917911)
type:       gzip compressed data
sidecar:    12d75edb…39e6fa4
actual:     12d75edb…39e6fa4
contains:   mcpp-2026.8.30.1-linux-x86_64/bin/mcpp

— and .github/tools/mirror_res.sh mcpp 2026.8.30.1 then verified all 16 URLs across both hosts.

⚠️ One trap worth writing down: the first mirror run reported no GITCODE_TOKEN/gtc; skipping gitcode mirror and exited 0. The cause was that the export GITCODE_TOKEN=$(python3 …) ran with the working directory inside the repository, where .xlings.json pins a project SubOS that has no python3 — so the command substitution produced an empty string and the whole GitCode leg was silently skipped. A skipped leg and a successful one are the same exit code. Use an absolute interpreter for anything that computes a credential.

13.2 Closure, measured on the published artefacts

Run inside xlings subos use … --sandbox, installing xim:mcpp@2026.8.30.1 from the index rather than pointing at a local build:

── resolve and install the published package ──
  ok  xim:mcpp@2026.8.30.1 resolved and installed from the index
  ok  the binary in PATH is the released 2026.8.30.1
  ok  xlings pinned = 2026.8.30.2

── #534 ──
  ok  built and ran: ANSWER=42
  ok  the generated header carries the generator's content (24 bytes)
  ok  2/2 of the dependency's compile edges wait for the generator

── #533 ──
  ok  kind=shared refused, and the message names the target
  ok  kind=shared never reaches the shell
  ok  kind=lib   refused, and the message names the target
  ok  kind=lib   never reaches the shell

── ecosystem ──
  ok  a project with an index dependency builds

  passed: 11   failed: 0        ECOSYSTEM CLOSURE: OK

The install has to happen inside the sandbox. A sandbox does not inherit the version bindings written outside it: the same SubOS reported mcpp 2026.8.30.1 outside and resolved 2026.8.28.2 inside. Activating a package outside and then "verifying" inside measures the wrong binary and reads as a pass. Resolving from the index in there is also the only step in this whole sequence that no CI job performs.

13.3 Local e2e disagreed with CI, and CI was right

The full suite on this machine reported 271 passed, 6 failed; both CI shards reported 266 passed, 0 failed. All six were checked two ways: CI ran each of them (not skipped — the distinction that matters), and each fails identically on the pre-fix binary on this machine. Accumulated SubOS state, not a regression.

13.4 Left as it stands

  • xim-pkgindex latest now names both new versions. §6.2 is unchanged: no index package was re-pinned onto a colliding <name>@<version>, and the recommendation is still not to.
  • The default SubOS on the development machine moved from mcpp 2026.8.28.2 to the released 2026.8.30.1.
  • The CN mirror is configured for both mcpp and xlings, as the verification required.