Status: plan. No code changed yet. Branch:
fix/action-ordering-and-package-identity, cut frommcpp-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).
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.
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.
Three changes. A2 is the load-bearing one — see the measurement immediately below. A1 is hygiene; A3 is what stops the class.
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:
- 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. - 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.
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_linkreferences$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.
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 kinds — Binary, 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.
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.
The issue asks for exactly the right fix. Four changes deliver it.
modules/manifest/src/types.cppm:299 — add:
std::string packageName; // the package whose build.mcpp declared thisSet 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.
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 phonyEmit 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.
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.
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.
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.
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.
- Before writing the marker (
package_fetcher.cppm:1095): if!payload_is_substantive(verdir), do not callmark_install_complete, and warn naming the package and the directory contents. Not a hard error — see the scoping note below. - On the fast path (
package_fetcher.cppm:1049,is_install_complete): re-check there too.is_install_completeis marker-only by design (src/fallback/install_integrity.cppm:135-150), so a store already poisoned by this bug never heals — the bad.mcpp_okis 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.
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.
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.
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:
-
node.nameis the bare short name. The samePlanNodecarriesnamespaceNameandcanonicalName(src/core/xim/libxpkg/types/type.cppm:34-44). The qualified identity is at the call site and discarded. -
xvm::match_versiondoesdb.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. -
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.
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 namespaces — libpng, 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.
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.
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:
-
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;-lgbmon a machine that also has Mesa in the SubOS builds silently, exit 0, and the artifact takes its ABI from/usr/includewhile its private loader resolveslibgbm.so.1fromregistry/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/libis 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. -
Re-measure the closure in a sandbox. The 11 entries were counted on the development machine, where
registry/subos/default/libis a shared, accumulating view — the same directory that silently suppliedlibgbm.so.1above. That reading cannot distinguish "satisfied by the four declared packages" from "satisfied by whatever else this machine has installed". -
Decide the CI posture and write it in the README. Examples are covered individually (
tests/e2e/312_build_rules_example.shbuilds 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.
Criteria are stated so that a false reading and a did-not-run reading are different, and so that every count carries a denominator.
| 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 |
- Denominator, Track B. For a plan with one package declaring ≥1 Source
action:
count(compile edges carrying "|| mcpp-actions-<pkg>")equalscount(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
SharedLibrarylink unit emits acc =line. Today it does not; the repro is in the analysis document. - Track A3. A manifest referencing an undefined
$varinside a rule is rejected. Add one deliberately-broken fixture so the checker itself is tested.
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.
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.
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.
- 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 atprepare.cppm:8370— hence placing the check after that attachment and after the PE.defattachment 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
kInstallMarkerinsrc/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 envreports 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.
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.
.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.
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.
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.
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.
| 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.
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.
- A1/A2/B/C are platform-neutral;
||is ninja syntax, not shell. - On the MSVC dialect
separateLinkeris true, socxx_sharedis used and the$ccgap cannot arise there — A1 is a no-op on Windows, A2 is not. - The asymmetry:
need_ios_init_shim(macOS, static libc++) definesccwhere 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: gccis the token with 55 existing tests and it runs on both Linux shards.run_all.sh:153already warns that declaring a token is part of adding it.
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.
§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.
- §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
Sourceoutputs out ofactionDefaults(§3): reaching them two ways is how the soname aliases went missing in 0.0.104. - §7's three amendments to #532.
Written after implementation, against the code as merged. Everything here was measured, not inferred.
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.
| 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 |
§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.
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.
§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.
| version | evidence | |
|---|---|---|
| xlings | 2026.8.30.2 |
openxlings/xlings#576 → f5a0775; release run all-green; 8 assets on GitHub, xlings-res and GitCode; xim-pkgindex#729 moved latest |
| mcpp | 2026.8.30.1 |
#536 → 0117a9f; 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.
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.
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.
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.
xim-pkgindexlatestnow 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
defaultSubOS on the development machine moved from mcpp2026.8.28.2to the released2026.8.30.1. - The CN mirror is configured for both mcpp and xlings, as the verification required.