Skip to content

Commit 0117a9f

Browse files
fix(build): a package's generated inputs come before its compiles; an empty link is refused (2026.8.30.1) (#536)
* fix(build): a package's generated inputs come before its compiles; an empty link is refused (2026.8.30.1) Three defects, one change of mind about evidence. Each of them was a place where something answered "is this ready?" from a proxy instead of from the artifact. ── mcpp#534: `role = "source"` did not order a generated header ───────────── The engine's own comment claimed ordering needed no special handling because "a Source action's outputs ARE the compile edge's inputs". That is true of a generated `.cpp` and false of a generated `.h`: a header is reached through `-I`, never appears as an edge input, and the depfile that would record it does not exist until a compile has already succeeded. So an action whose outputs were all headers had a node in build.ninja that nothing could reach — not `default` (Source outputs are excluded on purpose), not the goal phony (objects and link outputs only), and no consuming edge. It never ran. The issue was filed as an intermittent race; it is deterministic, and five consecutive builds reproduce it identically. What made it look like a race is that `prepare_actions` wrote a zero-byte placeholder for every declared Source output, headers included — so the file was on disk whether or not the generator had run. * `BuildAction` records the package that declared it, spelled the same way `CompileUnit::packageName` is (`qualified_package_name`, now exported so the two cannot drift). * Each package that declares a gating action gets one phony over its outputs, and every compile edge of that package takes it as an order-only prerequisite. Per package, not per build: `include_dir` colours only the declaring package's own TUs, and a build-wide edge would express a dependency that does not exist while landing on the critical path. * `check_action_ordering` scans the emitted manifest and fails the build if any such edge is missing one — including the denominator, because "every edge that should carry it does" is vacuously true when none does, which was exactly the previous state. Seven call sites append the string today; being careful at seven sites is not a mechanism. * `blocking` on a `check` now does what it has been documented to do since it was introduced. It was typed, emitted over the build-program protocol, parsed, documented in two languages and demonstrated in a shipped example — and read by nothing. * Placeholders are no longer written for outputs that are not translation units. The scan never reads a header, and the empty file only ever turned "the generator did not run" into "the header is empty". ── mcpp#533: an empty link unit, reported as a shell error ────────────────── A dependency whose `install()` was skipped over a package-identity collision left a version directory with no sources. mcpp planned its shared library anyway and the user was shown `/bin/sh: 1: -shared: not found`. * A link unit with no inputs is refused at plan time, naming the target. The static case is why this is an error rather than a better linker message: `ar rcs` with no members exits 0 and writes an 8-byte archive, so the build REPORTED SUCCESS and every consumer failed later with undefined symbols. * `cc` is emitted unconditionally, for the reason `c_ldflags` twenty-six lines below already carried (mcpp#426). The rule had been written down for one variable of `c_link`/`c_shared` and not for the other. * `check_rule_commands_name_a_program` scans the manifest for the class: a rule's command must begin with a program. Deliberately not the more obvious "no undefined variables" — ninja's empty expansion is a feature several rules rely on (`$soname_flag`, `$unit_ldflags`), and that check would have needed an allowlist of exceptions. * `.mcpp_ok` is no longer written from the installer's exit code plus the existence of a directory the installer creates before doing any work. It now requires one entry that neither mcpp nor xlings wrote. Withheld rather than fatal, because a package may legitimately install no payload. * The same predicate runs on the fast path, so a store already poisoned by this bug heals on the next build instead of requiring the user to know which directory to delete. * The lib-root warning asked `has_lib_target` — "does this produce a library" — when the property it wants is "is this a C++ module library". A source-built C package warned that `src/<name>.cppm` was missing in every consumer's build. ── Tests ─────────────────────────────────────────────────────────────────── e2e 314 (a dependency generating its only header), 315 (blocking gates the compile, non-blocking does not), 316 (empty shared AND static targets refused; a populated one still builds). All three were run against the pre-fix binary and all three fail there, so they discriminate rather than describe. Unit: 14 new cases over the two emitter guards, the ordering denominator, and the install-marker evidence — including the poisoned-store heal. Analysis and cross-repo plan: .agents/docs/2026-08-30-*.md Refs #533, #534 * chore(xlings): raise the floor to 2026.8.30.2 — the store-identity fix openxlings/xlings#576, released as v2026.8.30.2. Below this version xlings answers "is this package already installed" from the xvm version database keyed on the bare short name, so a package skips its own `install()` whenever any other namespace holds the same `<name>@<version>`. This is a FLOOR and not a preference, which is the whole reason the pin has the shape it does. The mcpp side of #533 makes the resulting failure legible on any client — the link unit is refused by name, and `.mcpp_ok` is withheld from a directory holding nothing the package installed — but only a client at or above this version INSTALLS correctly. ⚠️ This does NOT authorise an index change. Publishing a deliberately colliding `<name>@<version>` stays unsafe until the floor is adopted, not merely released, and .agents/docs/2026-08-30-cross-repo-fix-plan-532-533-534.md §6.2 recommends not doing it at all: the fix is for the collisions people hit by accident, which is already 20 short names wide on a real store. `src/xlings/xlings.cppm` is the source of truth; the seven copies under .github/ follow it and `check_version_pins.sh` enforces the agreement. Also records what landed against the plan (§12), including two things the plan had wrong: the xlings anchor was 167 commits stale and the call site had moved, and `⚠` is CI-governed in xlings while it is merely conventional here. --------- Co-authored-by: speak-agent <x.d2learn.org@gmail.com>
1 parent ab1da5d commit 0117a9f

27 files changed

Lines changed: 2683 additions & 73 deletions

.agents/docs/2026-08-30-cross-repo-fix-plan-532-533-534.md

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

.agents/docs/2026-08-30-issues-532-533-534-analysis.md

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

.github/actions/bootstrap-mcpp/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ inputs:
2525
# `package.name`, so one of the two was simply unreachable — and which one
2626
# depended on the machine, which is why CI failed on `compat:lua` on
2727
# Windows and `mcpplibs.capi:lua` on Linux. Never pin below that.
28-
default: '2026.8.27.5'
28+
default: '2026.8.30.2'
2929
cache-target:
3030
description: also restore/save target/ (build artifacts + BMIs)
3131
required: false

.github/actions/setup-macos-llvm/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ inputs:
1515
# Floor imposed by the index, not a routine bump — see
1616
# .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required
1717
# (two packages named `lua` in one repo need openxlings/xlings#381).
18-
default: '2026.8.27.5'
18+
default: '2026.8.30.2'
1919

2020
runs:
2121
using: composite

.github/workflows/bootstrap-macos.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
# Dormant (workflow_dispatch only), but kept in step with the rest —
1818
# check_version_pins.sh holds it there. Floor: 0.4.69, below which the
1919
# index cannot resolve two packages that share a short name.
20-
XLINGS_VERSION: '2026.8.27.5'
20+
XLINGS_VERSION: '2026.8.30.2'
2121
steps:
2222
- uses: actions/checkout@v4
2323

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ jobs:
152152
env:
153153
XLINGS_NON_INTERACTIVE: '1'
154154
run: |
155-
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.5
155+
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.30.2
156156
echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH"
157157
158158
- name: Install mcpp and config mirror
@@ -293,7 +293,7 @@ jobs:
293293

294294
- name: Install xlings + mcpp
295295
run: |
296-
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.5
296+
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.30.2
297297
# Deliberately NOT writing to $GITHUB_PATH here. On container
298298
# images that declare no PATH in their config (opensuse/
299299
# tumbleweed), appending a single dir to GITHUB_PATH makes the
@@ -364,7 +364,7 @@ jobs:
364364
# (older ones carry minos=15 and refuse to start).
365365
# v0.4.51+: in-process sha256 — this image has no sha256sum
366366
# binary, so pinned fetches failed before it.
367-
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.5
367+
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.30.2
368368
echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH"
369369
370370
- name: Install mcpp and config mirror

.github/workflows/ci-linux-e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ jobs:
237237
238238
- name: Bootstrap xlings + released mcpp
239239
run: |
240-
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.5
240+
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.30.2
241241
export PATH="$HOME/.xlings/subos/current/bin:$PATH"
242242
xlings update
243243
xlings install mcpp -y -g

.github/workflows/cross-build-test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ jobs:
122122
# release assets were uploaded in a broken state (records present,
123123
# blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX
124124
# half is handled by the marker-clear below.
125-
XLINGS_VERSION: '2026.8.27.5'
125+
XLINGS_VERSION: '2026.8.30.2'
126126
run: |
127127
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
128128
bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \
@@ -263,7 +263,7 @@ jobs:
263263
- name: Bootstrap mcpp via xlings
264264
env:
265265
XLINGS_NON_INTERACTIVE: '1'
266-
XLINGS_VERSION: '2026.8.27.5'
266+
XLINGS_VERSION: '2026.8.30.2'
267267
run: |
268268
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
269269
bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \

.github/workflows/release.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ jobs:
9696
# Pin xlings to a known-good version. The upstream install
9797
# script always grabs `latest` (no version override), so we
9898
# download + self-install manually to avoid broken releases.
99-
XLINGS_VERSION: '2026.8.27.5'
99+
XLINGS_VERSION: '2026.8.30.2'
100100
run: |
101101
if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then
102102
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
@@ -289,7 +289,7 @@ jobs:
289289
- name: Bootstrap mcpp via xlings
290290
env:
291291
XLINGS_NON_INTERACTIVE: '1'
292-
XLINGS_VERSION: '2026.8.27.5'
292+
XLINGS_VERSION: '2026.8.30.2'
293293
run: |
294294
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
295295
bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \
@@ -360,7 +360,7 @@ jobs:
360360
# below are pinned to the same version as XLINGS_VERSION; they are
361361
# NOT interpolated from it, so check_version_pins.sh scans for them
362362
# explicitly (they were absent from the old lock-step comment).
363-
XLA="xlings-2026.8.27.5-linux-aarch64.tar.gz"
363+
XLA="xlings-2026.8.30.2-linux-aarch64.tar.gz"
364364
# NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the
365365
# point — an arch with no prebuilt xlings must fall through quietly,
366366
# while the helper retries a 404 five times before giving up. The one
@@ -369,9 +369,9 @@ jobs:
369369
# cover it.
370370
if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \
371371
--connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \
372-
"https://github.com/openxlings/xlings/releases/download/v2026.8.27.5/$XLA"; then
372+
"https://github.com/openxlings/xlings/releases/download/v2026.8.30.2/$XLA"; then
373373
tar -xzf "/tmp/$XLA" -C /tmp
374-
XLBIN=$(find /tmp/xlings-2026.8.27.5-linux-aarch64 -path '*/bin/xlings' -type f | head -1)
374+
XLBIN=$(find /tmp/xlings-2026.8.30.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1)
375375
if [ -n "$XLBIN" ]; then
376376
mkdir -p "$STAGING/$WRAPPER/registry/bin"
377377
cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings"
@@ -449,7 +449,7 @@ jobs:
449449
- name: Bootstrap mcpp via xlings
450450
env:
451451
XLINGS_NON_INTERACTIVE: '1'
452-
XLINGS_VERSION: '2026.8.27.5'
452+
XLINGS_VERSION: '2026.8.30.2'
453453
run: |
454454
if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then
455455
WORK=$(mktemp -d)
@@ -632,7 +632,7 @@ jobs:
632632
shell: bash
633633
env:
634634
XLINGS_NON_INTERACTIVE: '1'
635-
XLINGS_VERSION: '2026.8.27.5'
635+
XLINGS_VERSION: '2026.8.30.2'
636636
run: |
637637
# Captured before the `cd` below, in POSIX form: this step never
638638
# returns to the workspace, and GITHUB_WORKSPACE is a backslash

docs/07-build-mcpp.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,14 +311,27 @@ attach:
311311

312312
| `role` | Outputs | Ordering | Typical |
313313
|---|---|---|---|
314-
| `source` | join the compile set | the compile edge consumes them | protoc, a transpiler |
315-
| `check` | a stamp file, written by mcpp | runs **alongside** compilation (set `blocking = true` to gate it) | clang-tidy, a format or ABI check |
314+
| `source` | compilable ones join the compile set; the rest are produced but not compiled | **every compile edge of the declaring package waits for them** | protoc, a transpiler, a protocol/IDL generator |
315+
| `check` | a stamp file, written by mcpp | runs **alongside** compilation; `blocking = true` makes the package's compile edges wait for it | clang-tidy, a format or ABI check |
316316
| `object` | join the **link** set | the link edge consumes them | a resource compiler, `objcopy` embedding a blob, a generated `.def`, a pre-built `.o` |
317317
| `artifact` | a new file | its *inputs* are link outputs, so it runs after the link | codesign, packaging, size budgets |
318318

319-
No phase machinery is involved: ninja's own file dependencies do the
320-
sequencing, which is also why an `artifact` action cannot double-apply itself
321-
the way a naive "post-build hook" would.
319+
No phase machinery is involved. `object` and `artifact` are sequenced by
320+
ninja's own file dependencies — which is also why an `artifact` action cannot
321+
double-apply itself the way a naive "post-build hook" would. `source` and a
322+
blocking `check` are sequenced by an order-only edge from the declaring
323+
package's compile edges to that package's action outputs.
324+
325+
> **Why `source` needs the edge (mcpp 2026.8.30.2+).** A generated `.cpp`
326+
> becomes an input of the edge that compiles it, so it was ordered for free. A
327+
> generated **header** never does: it is reached through `-I`, and the depfile
328+
> that would record it does not exist until a compile has already succeeded.
329+
> Before this, an action whose outputs were all headers had a node in
330+
> `build.ninja` that nothing could reach — not `default`, not the goal set, no
331+
> consuming edge — so it never ran, and what the compiler read was the empty
332+
> placeholder mcpp writes for a declared output. The ordering is **per
333+
> package**, because `include_dir` colours only the declaring package's own
334+
> translation units.
322335
323336
**A check's command does not have to write its stamp** (mcpp 2026.8.29.1+).
324337
The verdict is the exit code; the stamp is bookkeeping the graph needs, and

0 commit comments

Comments
 (0)