diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0ab1923..046dcdc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,6 +204,15 @@ jobs: # (ubuntu-latest ships >=3.11). - name: DW-code catalog matches the compiler reference (both directions) run: python3 tools/check-dw-codes.py + # The bot tier's dsl_version allowlist (harness/src/critical-path.ts) must + # never trail the compiler's own SUPPORTED_DSL_VERSION ceiling + # (crates/dsl/src/envelope.rs) — nothing else in the repo relates the two + # files, so a compiler version bump landed with zero signal to the harness + # and the bot tier refused every campaign at the newest dsl_version, after + # the server booted and the bot connected (task #157). Deterministic, + # offline, stdlib-only python3. + - name: harness dsl_version allowlist covers the compiler ceiling + run: python3 tools/check-harness-dsl-version.py # Merge artifacts in the docs tree: a table row duplicated under one key, a # doubled heading, a stray conflict marker. Two branches appending rows at # the same table anchor merge cleanly and silently, so this class is diff --git a/docs/reference/tools.md b/docs/reference/tools.md index d4c94e59..2382877b 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -133,6 +133,7 @@ Never shipped inside a delve. | `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-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 | | `tools/check-worker-override.py` | CI | `python3 tools/check-worker-override.py` — worker-isolation coverage gate: every service in `validation/compose.yaml` that pins a `container_name` or publishes `ports` must be reset (`!reset`) in `validation/worker-override.yaml`. Container names and host ports are Docker-GLOBAL, so `-p dw-worker-` does not isolate them; the omission cost a run twice (`server` #190, then `bot`) | +| `tools/check-harness-dsl-version.py` | CI | `python3 tools/check-harness-dsl-version.py` — sync gate: the compiler's `SUPPORTED_DSL_VERSION` (`crates/dsl/src/envelope.rs`) must be a member of the harness's `SUPPORTED_DSL_VERSIONS` allowlist (`harness/src/critical-path.ts`). Nothing else relates the two files; spec-0026 moved the compiler to `0.9.0` while the harness allowlist still ended at `0.8.0`, and the bot tier refused every campaign at the version gate after the server booted and the bot connected (task #157) | | `tools/extract-sound-registry.py` | maintenance | `python3 tools/extract-sound-registry.py ` — regenerates the compiler's sound registry for a new MC pin (positional args only, no `--help`) | | `tools/extract-item-stack-sizes.py` | maintenance | `python3 tools/extract-item-stack-sizes.py ` — regenerates `crates/compiler/data/item-stack-sizes-1.21.11.json`, the item→`max_stack_size` table `DW0436` reads, for a new MC pin (positional args only). Pins and checks the source SHA-256; refuses to default a missing component rather than assuming 64 | | `tools/extract-item-combat-stats.py` | maintenance | `python3 tools/extract-item-combat-stats.py ` — regenerates `crates/compiler/data/item-combat-1.21.11.json`, the item→`attack_damage`/`attack_speed`/`armor`/`armor_toughness`/`nutrition` table the spec-0023 winnability arithmetic reads (`DW0472`, `DW0474`), for a new MC pin (positional args only). Pins the source SHA-256 and refuses any non-`add_value` modifier rather than mis-summing it | diff --git a/harness/src/critical-path.ts b/harness/src/critical-path.ts index d5a0ca36..35a3559a 100644 --- a/harness/src/critical-path.ts +++ b/harness/src/critical-path.ts @@ -31,7 +31,15 @@ * VALIDATION surface — `branch-plan.json`, the chronicles, the per-branch paths — * and change no step the bot walks, so a v0.8 path is walked exactly as a v0.6 * one is. Without them here, the first campaign to declare a branch could not be - * run at all, branch tier or not. + * run at all, branch tier or not. v0.9 (spec-0026) adds the stage-1 + * horizon-library surface (`horizon` object form, new base/shorthand names) — + * world-generation input the compiler consumes to build the map, not a change + * to the step contract, so a v0.9 path is walked exactly as a v0.8 one is. + * + * This allowlist must never trail the compiler's own `SUPPORTED_DSL_VERSION` + * ceiling (`crates/dsl/src/envelope.rs`) — `tools/check-harness-dsl-version.py` + * enforces that in CI (task #157: a v0.9.0 campaign was refused at this gate, + * server booted and bot connected, because this list still ended at 0.8.0). */ export const SUPPORTED_DSL_VERSIONS = [ "0.2.0", @@ -41,6 +49,7 @@ export const SUPPORTED_DSL_VERSIONS = [ "0.6.0", "0.7.0", "0.8.0", + "0.9.0", ] as const; /** diff --git a/harness/test/critical-path.test.ts b/harness/test/critical-path.test.ts index 1149c0ae..2f3971e6 100644 --- a/harness/test/critical-path.test.ts +++ b/harness/test/critical-path.test.ts @@ -410,6 +410,20 @@ test("accepts the 0.5.0 and 0.6.0 dsl versions (additive; same path contract)", } }); +test("accepts the 0.7.0, 0.8.0, and 0.9.0 dsl versions (additive; same path contract)", () => { + // v0.9 (spec-0026) adds the stage-1 horizon-library surface (object-form + // `horizon`, new base/shorthand names) — world-generation input the compiler + // consumes to build the map, not a change to the critical-path step contract + // the bot walks. task #157: the allowlist lagged the compiler's dsl_version + // ceiling and refused every 0.9.0 campaign at the gate before the bot took a + // single step. + for (const v of ["0.7.0", "0.8.0", "0.9.0"]) { + const raw = validRaw(); + raw["version"] = v; + assert.equal(parseCriticalPath(raw).version, v); + } +}); + test("parses an optional ending_tail_ticks on assert-complete (task #125)", () => { const raw = validRaw(); (raw["steps"] as Array>)[3]!["ending_tail_ticks"] = 250; diff --git a/tools/check-harness-dsl-version.py b/tools/check-harness-dsl-version.py new file mode 100755 index 00000000..ac445089 --- /dev/null +++ b/tools/check-harness-dsl-version.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Harness/compiler `dsl_version` sync gate (task #157). + +The compiler's DSL surface and the harness's critical-path allowlist are two +independent files that must agree on one number: the newest `dsl_version` a +campaign may declare. `crates/dsl/src/envelope.rs` names it once +(`SUPPORTED_DSL_VERSION`, the crate's own "latest version" identity constant); +`harness/src/critical-path.ts` names the harness's whole accepted set +(`SUPPORTED_DSL_VERSIONS`), which every artifact the bot tier parses +(critical-path, waypoints, combat plan) gates on. + +Nothing in the repo ties these together, so a compiler version bump lands with +zero signal to the harness. That happened for real: spec-0026 raised +`SUPPORTED_DSL_VERSION` to `0.9.0` while `harness/src/critical-path.ts` still +listed `0.2.0 … 0.8.0` — the server booted, the bot connected, and the gate +refused the campaign before it took a single step (hollow-vigil ladder run, +task #157). Every other CI job was green. + +The rule: the compiler's latest `dsl_version` must be a member of the +harness's `SUPPORTED_DSL_VERSIONS`. The harness is allowed to lag behind on +older-but-still-supported versions (this checks membership of the ceiling, not +set equality) but must never fall behind the compiler's own idea of "current". + +Deterministic, offline, no dependencies (Python 3 stdlib). Run from the repo +root: + python3 tools/check-harness-dsl-version.py +Exit 0 = in sync, 1 = the harness lags the compiler (see stderr), 2 = usage/IO +error (missing file or the source no longer matches the expected shape — fix +the regex, don't loosen the check). +""" + +import pathlib +import re +import sys + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +ENVELOPE_RS = REPO_ROOT / "crates" / "dsl" / "src" / "envelope.rs" +CRITICAL_PATH_TS = REPO_ROOT / "harness" / "src" / "critical-path.ts" + +# `pub const SUPPORTED_DSL_VERSION: &str = "0.9.0";` — the compiler's single +# "latest dsl_version this crate implements" identity constant. +COMPILER_VERSION_RE = re.compile( + r'pub\s+const\s+SUPPORTED_DSL_VERSION\s*:\s*&str\s*=\s*"([^"]+)"\s*;' +) + +# `export const SUPPORTED_DSL_VERSIONS = [ "0.2.0", ..., "0.8.0" ] as const;` +HARNESS_ARRAY_RE = re.compile( + r"export\s+const\s+SUPPORTED_DSL_VERSIONS\s*=\s*\[(.*?)\]\s*as\s+const\s*;", + re.DOTALL, +) +VERSION_LITERAL_RE = re.compile(r'"([^"]+)"') + + +def compiler_version(text: str) -> str | None: + m = COMPILER_VERSION_RE.search(text) + return m.group(1) if m else None + + +def harness_versions(text: str) -> list[str]: + m = HARNESS_ARRAY_RE.search(text) + if not m: + return [] + return VERSION_LITERAL_RE.findall(m.group(1)) + + +def main() -> int: + for p in (ENVELOPE_RS, CRITICAL_PATH_TS): + if not p.is_file(): + sys.stderr.write(f"error: {p} not found (run from the repo root)\n") + return 2 + + compiler_max = compiler_version(ENVELOPE_RS.read_text(encoding="utf-8")) + if compiler_max is None: + sys.stderr.write( + 'error: could not find `pub const SUPPORTED_DSL_VERSION: &str = "...";` ' + f"in {ENVELOPE_RS} — the constant was renamed/reshaped; update " + "COMPILER_VERSION_RE in tools/check-harness-dsl-version.py\n" + ) + return 2 + + harness = harness_versions(CRITICAL_PATH_TS.read_text(encoding="utf-8")) + if not harness: + sys.stderr.write( + "error: could not find `export const SUPPORTED_DSL_VERSIONS = [...] as " + f"const;` (or it parsed empty) in {CRITICAL_PATH_TS} — the declaration was " + "reshaped; update HARNESS_ARRAY_RE in tools/check-harness-dsl-version.py\n" + ) + return 2 + + if compiler_max not in harness: + sys.stderr.write( + "harness dsl_version allowlist lags the compiler:\n" + f" {ENVELOPE_RS}: SUPPORTED_DSL_VERSION = {compiler_max!r}\n" + f" {CRITICAL_PATH_TS}: SUPPORTED_DSL_VERSIONS = {harness!r}\n" + f" {compiler_max!r} is not in the harness allowlist.\n\n" + "Every campaign at the compiler's current dsl_version would be refused " + "at the bot-tier version gate before it takes a single step (task #157). " + "Add the compiler's SUPPORTED_DSL_VERSION to harness/src/critical-path.ts's " + "SUPPORTED_DSL_VERSIONS (additive addition only — do not remove older " + "entries still in use), and confirm no version-conditional harness " + "behavior needs updating for the new version's artifacts.\n" + ) + return 1 + + print( + f"harness dsl_version allowlist covers the compiler ceiling " + f"({compiler_max!r} in {harness!r})" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/tests/test_check_harness_dsl_version.py b/tools/tests/test_check_harness_dsl_version.py new file mode 100644 index 00000000..1a969393 --- /dev/null +++ b/tools/tests/test_check_harness_dsl_version.py @@ -0,0 +1,94 @@ +"""The harness/compiler `dsl_version` sync gate (`tools/check-harness-dsl-version.py`). + +The defect this pins, from the field (task #157): the compiler's +`SUPPORTED_DSL_VERSION` moved to `0.9.0` (spec-0026) while +`harness/src/critical-path.ts`'s `SUPPORTED_DSL_VERSIONS` allowlist still ended +at `0.8.0`. Every other CI job was green — nothing else in the repo relates the +two files — and the bot tier refused every 0.9.0 campaign at the version gate, +after the server booted and the bot connected. + +These tests drive the detector over synthetic source pairs so it keeps failing +for the right reason as the real files grow. The live pair is checked by the +CI step itself. +""" + +import importlib.util +import pathlib + +import pytest + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "check-harness-dsl-version.py" + +ENVELOPE_TEMPLATE = """\ +//! doc +pub const SUPPORTED_DSL_VERSION: &str = "{version}"; +pub const SUPPORTED_DSL_VERSIONS: &[&str] = &["0.2.0", "{version}"]; +""" + +CRITICAL_PATH_TEMPLATE = """\ +// doc +export const SUPPORTED_DSL_VERSIONS = [ +{entries} +] as const; +""" + + +@pytest.fixture +def gate(tmp_path, monkeypatch): + """The script loaded as a module, re-rooted at synthetic source files.""" + spec = importlib.util.spec_from_file_location("check_harness_dsl_version", SCRIPT) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + monkeypatch.setattr(module, "ENVELOPE_RS", tmp_path / "envelope.rs") + monkeypatch.setattr(module, "CRITICAL_PATH_TS", tmp_path / "critical-path.ts") + return module + + +def pair(gate, compiler_version: str, harness_versions: list[str]) -> int: + gate.ENVELOPE_RS.write_text( + ENVELOPE_TEMPLATE.format(version=compiler_version), encoding="utf-8" + ) + entries = "\n".join(f' "{v}",' for v in harness_versions) + gate.CRITICAL_PATH_TS.write_text( + CRITICAL_PATH_TEMPLATE.format(entries=entries), encoding="utf-8" + ) + return gate.main() + + +def test_harness_covering_the_compiler_ceiling_passes(gate): + assert pair(gate, "0.9.0", ["0.2.0", "0.8.0", "0.9.0"]) == 0 + + +def test_harness_lagging_the_compiler_ceiling_fails(gate, capsys): + # The task #157 reproduction: compiler moved to 0.9.0, harness allowlist + # still ends at 0.8.0. + assert pair(gate, "0.9.0", ["0.2.0", "0.7.0", "0.8.0"]) == 1 + err = capsys.readouterr().err + assert "0.9.0" in err + assert "0.8.0" in err + + +def test_harness_may_carry_older_versions_the_compiler_dropped(gate): + # Membership of the ceiling, not set equality — the harness is allowed to + # keep accepting versions the compiler still emits/documents even if this + # synthetic pair only names the current one. + assert pair(gate, "0.5.0", ["0.2.0", "0.3.0", "0.4.0", "0.5.0"]) == 0 + + +def test_missing_compiler_constant_is_a_usage_error(gate, capsys): + gate.ENVELOPE_RS.write_text("// no constant here\n", encoding="utf-8") + gate.CRITICAL_PATH_TS.write_text( + CRITICAL_PATH_TEMPLATE.format(entries=' "0.2.0",'), encoding="utf-8" + ) + assert gate.main() == 2 + assert "SUPPORTED_DSL_VERSION" in capsys.readouterr().err + + +def test_missing_harness_declaration_is_a_usage_error(gate, capsys): + gate.ENVELOPE_RS.write_text( + ENVELOPE_TEMPLATE.format(version="0.9.0"), encoding="utf-8" + ) + gate.CRITICAL_PATH_TS.write_text("// no allowlist here\n", encoding="utf-8") + assert gate.main() == 2 + assert "SUPPORTED_DSL_VERSIONS" in capsys.readouterr().err