diff --git a/.github/campaign-build-exclusions.toml b/.github/campaign-build-exclusions.toml new file mode 100644 index 0000000..f9d5a3d --- /dev/null +++ b/.github/campaign-build-exclusions.toml @@ -0,0 +1,39 @@ +# Campaigns in the pinned content repo that `tools/build-every-campaign.py` +# does NOT require to build, each with the reason it may not. +# +# WHY THIS FILE IS NOT A SKIP LIST +# +# A silently skipped campaign is a vacuous green (CLAUDE.md: a green gate that +# binds to nothing is VACUOUS, not a pass) and is worse than no gate at all. So +# an entry here does not switch a campaign off — it INVERTS the assertion and +# pins it to a named failure: +# +# * the campaign is still built, on every run; +# * it must still fail, and fail with EXACTLY the codes in `expect_codes` — +# any other error code is a NEW break that would otherwise have hidden +# behind the exclusion, and reds the job; +# * if it starts BUILDING, that reds the job too. The exclusion has expired +# and must be deleted in the PR that fixed the campaign, or the next +# regression it suffers would be excused by a stale line in this file. +# +# So the honest states are two, both visible in the job's own output: "built" +# and "known-red for this reason, still exactly this reason". +# +# Fields +# id campaign directory name under /campaigns/ +# task the tracking item that will retire this entry +# reason why it cannot build today, in one sentence +# expect_codes the complete set of DW error codes the build must emit + +[[exclusion]] +id = "hollow-vigil" +task = "#34" +reason = """ +Two dialogue option labels are authored over the 146 px a vanilla dialog button \ +fits (DW0331); vanilla scrolls an over-wide caption back and forth rather than \ +wrapping it, so the fix is a content rewrite of the labels, not an engine change. \ +Out of scope for the PR that introduced this gate — the gate exists to stop \ +ENGINE changes from breaking campaigns, and hollow-vigil was already red before \ +it.\ +""" +expect_codes = ["DW0331"] diff --git a/.github/required-status-checks.txt b/.github/required-status-checks.txt index b23cfee..2dcb204 100644 --- a/.github/required-status-checks.txt +++ b/.github/required-status-checks.txt @@ -1,6 +1,7 @@ # The exact `name:` of every CI job that is a REQUIRED status check on `main` # (owner decision, 2026-08-05: all ten, previously three; eleven since -# ADR-0017 added the cross-build shelf). +# ADR-0017 added the cross-build shelf, twelve since task #44 added the +# campaign-build gate). # # THIS FILE IS A DEADLOCK TRIPWIRE. Branch protection matches a required context # by its NAME STRING. Rename a job in ci.yml and its required context stops @@ -30,4 +31,5 @@ skin toolchain (pytest) i18n translation tool (pytest) mecha cross-check (PR only) campaign storybooks (engine-version marker) +campaign builds (every campaign in the content repo) tier 2 (datapack load + PackTest) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9218098..b314e19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,7 +290,7 @@ jobs: # Deterministic, offline, stdlib-only python3. - name: no early-exit consumer on the right of a pipe (SIGPIPE + pipefail) run: python3 tools/check-shell-pipe-shortcircuit.py - # All ten jobs are required status checks (owner decision 2026-08-05; it was + # EVERY job here is a required status check (owner decision 2026-08-05; it was # three, so `tier 2` — datapack load plus the whole PackTest suite — never # blocked a merge). Branch protection matches a required context by its NAME # STRING: rename a job and its context stops reporting forever, blocking @@ -362,6 +362,52 @@ jobs: - name: every campaign storybook carries a TRUE engine-version marker run: python3 tools/check-storybook-version.py + # Every gate above this line builds a FIXTURE. Nothing in CI had ever built a + # real campaign, so the product could stop compiling with the whole board + # green: PR #260 reached 10/10 while stopping the flagship released campaign + # `nobodys-cave-island` from building at all (26 × `DW0364` on standable cells + # at the ocean line). Fixtures exercise one verb each; a campaign is where the + # verbs meet a real prefab library, a real layout solve and a real translation + # sidecar, and that is the surface an engine change breaks. + # + # Campaigns are DISCOVERED in the pinned content checkout, never listed here, + # so a new campaign on content `main` is gated by the next re-pin with nobody + # remembering. A campaign that cannot build today is recorded in + # `.github/campaign-build-exclusions.toml`, which INVERTS the assertion instead + # of removing it — the campaign is still built and must still fail with exactly + # the codes recorded there, so neither a new break hiding behind an exclusion + # nor an exclusion that has expired can pass. Every run states its binding + # count (CLAUDE.md: a green gate that binds to nothing is VACUOUS). + # + # Its own job, not a step in `rust`: a campaign build is minutes of layout + # solving, and burying it inside the fmt/clippy/test job would make one status + # answer two unrelated questions. On every push, not PR-only — a change that + # breaks a released campaign must not be able to sit on a green board for the + # window between push and PR. + campaign-builds: + # Name must stay exactly "campaign builds (every campaign in the content + # repo)" — it is a required status context in branch protection; renaming it + # deadlocks every PR (see .github/required-status-checks.txt). + name: campaign builds (every campaign in the content repo) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # LFS on (the default): a campaign build copies the prefab `.nbt` BODIES + # into the datapack, so pointers would fail here for a reason that has + # nothing to do with the engine change under test. + - uses: ./.github/actions/checkout-content + - uses: Swatinem/rust-cache@v2 + with: { key: campaign-builds } + # Built once and handed to the driver, rather than a `cargo run` per + # campaign per language: the gate's subject is WHICH engine built the + # campaign, so the binary is named explicitly and the script never infers + # one. Dev profile — the workspace sets `opt-level = 1` for it (PR #313), + # which is what makes a real layout solve affordable on every push. + - name: build the engine under test + run: cargo build -p delvec --bin delvec + - name: every campaign in the pinned content repo still builds + run: python3 tools/build-every-campaign.py --delvec target/debug/delvec + # ------------------------------------------------------------------ tier 2 -- # spec-0004 tier 2 (every PR): build the delve, prove it loads with zero errors # on the pinned vanilla server, and run the generated PackTest suite headless. diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 6a28700..2c46ac9 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -240,6 +240,7 @@ Never shipped inside a delve. |---|---|---| | `tools/i18n-translate.py` | agent | `python3 tools/i18n-translate.py --lang [--config f] [--delvec cmd] [--batch-size n] [--dry-run] [--force] [--no-validate] [--reflect\|--no-reflect]` — external OpenAI-compatible API, generation-time only; `--reflect` runs the three-step translate → critique → revise pass; see [`i18n.md`](i18n.md) | | `tools/skin/` (`delve_skin`) | agent | `python -m delve_skin all --skins-dir D --catalog-dir D --preview-dir D [--id ID] [--scale N]`, or the `build` / `preview` / `catalog` stages individually. Needs its own venv (`pip install -r tools/skin/requirements.txt`); see [`../../tools/skin/README.md`](../../tools/skin/README.md) | +| `tools/build-every-campaign.py` | CI + agent (run it before proposing any engine change that touches emission, layout or validation) | `python3 tools/build-every-campaign.py --delvec [--content ]` — builds **every campaign** the pinned content repo carries, in **every language its `world.json` declares**, and reds if one stops building. Closes the gap that let PR #260 reach 10/10 green while stopping the flagship released campaign `nobodys-cave-island` from building at all (26 × `DW0364`): every other gate builds a FIXTURE, and a fixture exercises one verb, where a campaign is the only place the verbs meet a real prefab library, a real layout solve and a real translation sidecar. Campaigns are **discovered** (any dir under `/campaigns/` with a `world.json`), never listed, so the next content re-pin gates a new campaign with nobody remembering. `--delvec` is required and never inferred — the gate's whole subject is *which engine* built the campaign. A campaign that cannot build today goes in `.github/campaign-build-exclusions.toml`, which **inverts** the assertion rather than removing it: still built, must still fail, and must fail with **exactly** the recorded `expect_codes` — an extra code is a new break that was hiding behind the exclusion, and a SUCCESS is an expired exclusion, both red. Currently one entry: `hollow-vigil`, `DW0331` (task #34). States its binding count every run (discovered / built green / known-red, each named); discovering zero campaigns, building zero campaigns, or an exclusion naming a campaign that no longer exists are each a red. Runs in CI as `campaign builds (every campaign in the content repo)`, on every push | | `tools/check-dw-codes.py` | CI | `python3 tools/check-dw-codes.py` — asserts the DW catalog in `compiler.md` matches `crates/**/*.rs` both ways, and that every code has a test | | `tools/check-effect-roots.py` | CI | `python3 tools/check-effect-roots.py` — no source file may enumerate the campaign's **effect roots** by hand. An effect root is a `Vec` emission can lower; there are five, four hang off the quests stage and the fifth off dialogue, and nothing about the DSL's shape makes them findable by inspection — so every walk that needed "every effect" was written by someone enumerating the roots they knew about. Six were found and fixed independently; a sweep found thirteen more; this gate, on the run that introduced it, found three the sweep had missed (`continuity::excluded_npcs`, `emit::first_damage_players`, `emit_v04_packtests`' despawn scan). **None was ever red** — a walk that visits four of five roots looks correct over any campaign that does not use the fifth. `delvewright_dsl::effects` is now the one enumeration and every walk inherits it, which closes the thirteen; this gate is what stops a fourteenth, since the root fields are ordinary public fields and no type can forbid the loop. Flags a window of 40 source lines naming **3+ distinct** roots, outside an allowlist that carries a REASON per entry (the enumeration itself; `validate::reserved_v06_world`, sound by construction; `plan::required_anchors_for_area`, an open finding printed on every run). **Known non-proof, stated in its docstring**: a proximity heuristic over text — roots spread across a hundred lines, or reached through a helper taking the list as an argument, are invisible. States its binding count every run (currently 128 files, 92 markers); **examining zero files or finding zero markers is a red**, because a renamed field would otherwise leave it quietly green forever. Runs as a step of `docs (local link check)` — a step, not a job, since every job name is a required status context | | `tools/check-doc-dupes.py` | CI | `python3 tools/check-doc-dupes.py [path …]` — merge-artifact gate over `docs/**/*.md` + `README.md`: no two body rows in one markdown table share a first-cell key, no heading repeats within a file, no git conflict markers. Kills the class that put `shortcuts[]` in the stage-5 table twice (owner finding 2026-08-03). Same-key rows in *different* tables are fine; a genuine same-table collision means restructure the table, not allowlist it | diff --git a/tools/build-every-campaign.py b/tools/build-every-campaign.py new file mode 100755 index 0000000..5b7a1fb --- /dev/null +++ b/tools/build-every-campaign.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Build EVERY campaign in the pinned content repo. A break in one is a red here. + +WHY THIS EXISTS (task #44) + +Nothing in CI ever built a real campaign. Every compiler gate ran against +fixtures under `crates/**/tests/fixtures` and `crates/dsl/fixtures/valid` — +small, hand-shaped trees that exercise one verb each. The shipped campaigns, the +only artifacts a player ever sees, were built by hand on somebody's laptop when +somebody remembered. + +The cost of that gap was found three separate times in one day; the most +expensive instance is that PR #260 reached 10/10 green while stopping the +flagship released campaign `nobodys-cave-island` from building at all — 26 +`DW0364` errors on cells at the ocean line. Ten required status checks, all +green, and the product did not compile. + +This closes it: on every push, every campaign the pinned content repo carries is +built, in every language it declares, and a campaign that stops building reds a +required status check. + +WHAT IT IS NOT + +It is not a sampler and it is not a skip list. Campaigns are DISCOVERED (any +directory under `/campaigns/` holding a `world.json`), never enumerated +here, so a campaign added to content `main` is gated by the next content re-pin +without anyone remembering to add a line. A campaign that legitimately cannot +build today goes in `.github/campaign-build-exclusions.toml`, which inverts the +assertion rather than removing it: the campaign is still built, must still fail, +and must fail with exactly the codes recorded there — a different code, or a +success, is a finding. See that file's header for why. + +BINDING (CLAUDE.md: a green gate that binds to nothing is VACUOUS, not a pass). +Every run states how many campaigns it discovered, built, and held known-red, +each by name. Discovering zero campaigns is a red; building zero campaigns is a +red. Both are the shapes in which this gate could go green having proven +nothing — a content checkout that silently landed empty, or an exclusion file +that grew to cover everything. + +Deterministic and offline apart from the compiler itself; stdlib-only python3. +Exit 0 clean, 1 with one finding per line. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +EXCLUSIONS = REPO / ".github" / "campaign-build-exclusions.toml" +VERSIONS = REPO / "versions.toml" + + +class Exclusion: + def __init__(self, raw: dict, index: int) -> None: + missing = [k for k in ("id", "task", "reason", "expect_codes") if k not in raw] + if missing: + raise SystemExit( + f"build-every-campaign: FAIL — exclusion #{index} in {EXCLUSIONS.name} " + f"is missing {', '.join(missing)}. An exclusion without a reason and an " + f"expected failure is a skip, and a skip is a vacuous green." + ) + self.id: str = raw["id"] + self.task: str = raw["task"] + self.reason: str = " ".join(raw["reason"].split()) + self.expect_codes: set[str] = set(raw["expect_codes"]) + if not self.expect_codes: + raise SystemExit( + f"build-every-campaign: FAIL — exclusion {self.id!r} names no " + f"expect_codes, so any failure at all would satisfy it." + ) + + +def read_exclusions() -> dict[str, Exclusion]: + if not EXCLUSIONS.is_file(): + return {} + raw = tomllib.load(EXCLUSIONS.open("rb")) + out: dict[str, Exclusion] = {} + for i, entry in enumerate(raw.get("exclusion", [])): + ex = Exclusion(entry, i) + out[ex.id] = ex + return out + + +def content_pin() -> str: + try: + c = tomllib.load(VERSIONS.open("rb"))["content"] + return f"{c['repo']}@{c['sha']}" + except Exception: + return "unpinned" + + +def declared_languages(world: Path) -> list[str]: + """`en` (the canonical build) plus every language `world.json` declares. + + A campaign ships its localized output too (`out-zh/`), so a translation + sidecar that stops satisfying the compiler's coverage checks breaks a + released artifact exactly the way an English break does. + """ + doc = json.loads(world.read_text(encoding="utf-8")) + langs = doc.get("languages") or doc.get("content", {}).get("languages") or [] + return ["en"] + [str(l) for l in langs] + + +def build(delvec: Path, campaign: Path, prefabs: Path, lang: str) -> tuple[int, list[dict]]: + """Run one `delvec build`. Returns (exit code, parsed JSONL diagnostics).""" + out = Path(tempfile.mkdtemp(prefix=f"dw-{campaign.name}-{lang}-")) + try: + proc = subprocess.run( + [ + str(delvec), "build", str(campaign), + "-o", str(out), + "--prefabs", str(prefabs), + "--lang", lang, + "--json", + ], + capture_output=True, + text=True, + ) + finally: + shutil.rmtree(out, ignore_errors=True) + + diags: list[dict] = [] + for line in (proc.stdout + "\n" + proc.stderr).splitlines(): + line = line.strip() + if not line.startswith("{"): + continue + try: + d = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(d, dict) and "code" in d and "severity" in d: + diags.append(d) + # A build that dies without emitting a single parsable diagnostic (a panic, + # a missing prefab library, an OOM) must not read as "no errors": record the + # raw tail so the finding says something a reader can act on. + if proc.returncode != 0 and not any(d["severity"] == "error" for d in diags): + diags.append( + { + "code": "«no diagnostic»", + "severity": "error", + "stage": "process", + "path": "", + "message": ( + f"delvec exited {proc.returncode} without emitting an error " + f"diagnostic. Last output:\n" + + "\n".join((proc.stderr or proc.stdout).splitlines()[-15:]) + ), + } + ) + return proc.returncode, diags + + +def errors(diags: list[dict]) -> list[dict]: + return [d for d in diags if d.get("severity") == "error"] + + +def show(diags: list[dict], limit: int = 40) -> None: + for d in diags[:limit]: + print(f" {d['code']} {d.get('stage', '')} {d.get('path', '')}") + head = (d.get("message") or "").splitlines() + print(f" {(head[0] if head else '(no message)')[:500]}") + if len(diags) > limit: + print(f" … and {len(diags) - limit} more") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--content", + type=Path, + default=REPO / "campaigns", + help="content-repo checkout (holds `campaigns/` and `prefabs/`). " + "Default `campaigns`, the CI checkout path and the local dev symlink.", + ) + ap.add_argument( + "--delvec", + type=Path, + required=True, + help="the `delvec` binary to build with. Required and never inferred: " + "the whole point of this gate is WHICH engine built the campaign, so " + "the caller names it.", + ) + args = ap.parse_args() + + delvec: Path = args.delvec.resolve() + if not delvec.is_file(): + print(f"build-every-campaign: FAIL — no delvec binary at {delvec}", file=sys.stderr) + return 1 + + sources = args.content / "campaigns" + prefabs = args.content / "prefabs" + for p, what in ((sources, "campaign sources"), (prefabs, "prefab library")): + if not p.is_dir(): + print( + f"build-every-campaign: FAIL — {what} not found at {p}. The content " + f"checkout did not land; every campaign below would be 'skipped'.", + file=sys.stderr, + ) + return 1 + + exclusions = read_exclusions() + discovered = sorted(d for d in sources.iterdir() if (d / "world.json").is_file()) + + version = subprocess.run( + [str(delvec), "--version"], capture_output=True, text=True + ).stdout.strip() + print(f"engine : {version or delvec}") + # The pin is what CI checks out; an explicit --content is a LOCAL override + # (a control run against another branch's pin), and saying "content: " + # over a tree that is not that pin would be the gate lying in its own output. + overridden = args.content.resolve() != (REPO / "campaigns").resolve() + print(f"content : {content_pin()} (versions.toml [content])") + print(f"checkout : {args.content}" + (" ← --content OVERRIDE, not the pin above" if overridden else "")) + print(f"discovered {len(discovered)} campaign(s): " + f"{', '.join(d.name for d in discovered) or '(none)'}") + print() + + findings: list[str] = [] + + # Vacuity guard 1: a content checkout that landed but carried no campaign. + if not discovered: + print( + f"build-every-campaign: FAIL — discovered 0 campaigns under {sources}. " + f"A gate over nothing is not a pass.", + file=sys.stderr, + ) + return 1 + + # Vacuity guard 2: an exclusion for a campaign that is not there any more. + # Left alone it would silently pre-excuse a campaign of that name added later. + known = {d.name for d in discovered} + for eid, ex in sorted(exclusions.items()): + if eid not in known: + findings.append( + f"exclusion {eid!r} ({ex.task}) names no campaign in the pinned " + f"content repo. Delete the entry — a stale exclusion pre-excuses " + f"any future campaign that takes the name." + ) + + built: list[str] = [] + known_red: list[str] = [] + + for campaign in discovered: + name = campaign.name + ex = exclusions.get(name) + langs = declared_languages(campaign / "world.json") + head = f"::group::{name} ({'+'.join(langs)})" + print(head + (" [EXCLUDED — expected red]" if ex else "")) + + lang_ok = True + for lang in langs: + code, diags = build(delvec, campaign, prefabs, lang) + errs = errors(diags) + warns = [d for d in diags if d.get("severity") == "warning"] + status = "ok" if code == 0 else f"exit {code}" + print(f" --lang {lang}: {status}, {len(errs)} error(s), {len(warns)} warning(s)") + + if ex is None: + if code != 0: + lang_ok = False + print(f" ::error::{name} (--lang {lang}) NO LONGER BUILDS") + show(errs) + seen = sorted({d["code"] for d in errs}) + findings.append( + f"{name} (--lang {lang}) no longer builds on this engine: " + f"{len(errs)} error(s), codes {', '.join(seen)}. This is a " + f"released campaign; the engine change that caused it is the " + f"thing to fix, not this gate." + ) + continue + + # Excluded: the assertion is INVERTED, not removed. + seen = {d["code"] for d in errs} + if code == 0: + lang_ok = False + findings.append( + f"{name} (--lang {lang}) BUILDS now, but is still listed in " + f"{EXCLUSIONS.name} ({ex.task}). Delete the exclusion in the PR " + f"that fixed it — until then the next regression it suffers is " + f"excused by a stale line." + ) + elif not seen <= ex.expect_codes: + lang_ok = False + unexpected = sorted(seen - ex.expect_codes) + print(f" ::error::{name} (--lang {lang}) failed for a NEW reason") + show([d for d in errs if d["code"] in unexpected]) + findings.append( + f"{name} (--lang {lang}) is excluded for {sorted(ex.expect_codes)} " + f"({ex.task}) but also failed with {unexpected}. A new break was " + f"hiding behind the exclusion." + ) + else: + print(f" known-red as recorded: {sorted(seen)} — {ex.task}") + + print("::endgroup::") + if ex is None: + if lang_ok: + built.append(name) + else: + known_red.append(name) + + print() + print("---- binding -------------------------------------------------------") + print(f"discovered : {len(discovered)} ({', '.join(sorted(known))})") + print(f"built green: {len(built)} ({', '.join(built) or 'NONE'})") + print(f"known-red : {len(known_red)} ({', '.join(known_red) or 'none'})") + for name in known_red: + ex = exclusions[name] + print(f" - {name}: {ex.task} — {ex.reason}") + print("--------------------------------------------------------------------") + + # Vacuity guard 3: everything excluded. The gate would be green having built + # nothing at all, which is the state this whole job exists to make impossible. + if not built and not findings: + findings.append( + f"0 of {len(discovered)} campaigns were built — every one is excluded. " + f"This job would be green having compiled no product at all." + ) + + if findings: + print(f"\nbuild-every-campaign: {len(findings)} finding(s)\n", file=sys.stderr) + for f in findings: + print(f" {f}\n", file=sys.stderr) + return 1 + + print(f"\nbuild-every-campaign: OK — {len(built)} campaign(s) build on this engine.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())