Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
547 lines
24 KiB
Python
547 lines
24 KiB
Python
"""`scripts/build_decisions_catalog.py`'s `--check` path — the one CI actually runs.
|
|
|
|
`decisions-guard` (`.gitea/workflows/pr-checks.yml`, the "Active catalog in sync" step) runs
|
|
`python3 scripts/build_decisions_catalog.py --check`. `scripts/tests/test_build_catalog.py` covers
|
|
`render_catalog()` directly and never calls `main()` at all, so nothing there proves:
|
|
|
|
* that `main()`'s stale-detection comparison (`want.strip() != have.strip()`) is load-bearing —
|
|
a version that always agreed would pass every existing test;
|
|
* that the `if __name__ == "__main__": raise SystemExit(main())` wiring actually turns a stale
|
|
catalog into a non-zero process exit code, which is the only thing CI's `run:` step reads.
|
|
|
|
`testing.guard-ships-with-mutation-proof` (#775) is explicit that a guard is not tested because a
|
|
test *involving* it passes: it ships with a mutation proof — disarm the guard's own clause, alone,
|
|
and a named test must go red. This file is that proof for the catalog-guard, plus the subprocess
|
|
proof that the `__main__` wiring is connected (the #751/#719 shape the decision record names: a
|
|
green `main()` behind dead wiring).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
import scripts.build_decisions_catalog as bc
|
|
import scripts.decisions_lib as dl
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
CATALOG_SCRIPT = REPO_ROOT / "scripts" / "build_decisions_catalog.py"
|
|
DECISIONS_LIB = REPO_ROOT / "scripts" / "decisions_lib.py"
|
|
SCRIPTS_INIT = REPO_ROOT / "scripts" / "__init__.py"
|
|
|
|
|
|
def _current_catalog_text() -> str:
|
|
return bc.render_catalog(dl.all_active_records())
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# ANTI-VACUITY — if the real corpus is empty, every assertion below passes for nothing.
|
|
# ------------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_the_real_corpus_is_non_empty_and_renders_a_real_catalog():
|
|
records = dl.all_active_records()
|
|
active = [r for r in records if r.status == "active" and r.key]
|
|
assert active, "no active decision records were parsed — every test below would be vacuous"
|
|
|
|
text = _current_catalog_text()
|
|
assert bc.BANNER in text, "render_catalog produced no banner — not a real catalog document"
|
|
assert f"`{active[0].key}`" in text, (
|
|
"render_catalog produced no row for a known active record — not a real catalog document"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# `main(["--check"])` — the comparison CI reads
|
|
# ------------------------------------------------------------------------------------------------
|
|
|
|
|
|
def test_check_returns_0_when_OUTPUT_matches_render_catalog(tmp_path, monkeypatch):
|
|
fresh = _current_catalog_text()
|
|
output = tmp_path / "README.md"
|
|
output.write_text(fresh.rstrip("\n") + "\n", encoding="utf-8")
|
|
monkeypatch.setattr(bc, "OUTPUT", output)
|
|
|
|
assert bc.main(["--check"]) == 0
|
|
|
|
|
|
def test_check_returns_1_when_OUTPUT_is_stale(tmp_path, monkeypatch):
|
|
fresh = _current_catalog_text()
|
|
output = tmp_path / "README.md"
|
|
# Append a line: the on-disk file no longer matches what render_catalog would produce.
|
|
output.write_text(fresh.rstrip("\n") + "\nEXTRA STALE LINE\n", encoding="utf-8")
|
|
monkeypatch.setattr(bc, "OUTPUT", output)
|
|
|
|
assert bc.main(["--check"]) == 1
|
|
|
|
|
|
def test_generate_then_check_round_trips(tmp_path, monkeypatch):
|
|
"""The no-argument path WRITES the catalog, and a subsequent --check must then pass.
|
|
|
|
This pins the property CI depends on: generate and check agree. If they ever diverged, `main([])`
|
|
would produce a file that `main(["--check"])` immediately rejects — a self-contradiction that
|
|
would make the generator useless for fixing the exact problem `--check` reports.
|
|
"""
|
|
output = tmp_path / "README.md"
|
|
assert not output.exists()
|
|
monkeypatch.setattr(bc, "OUTPUT", output)
|
|
|
|
assert bc.main([]) == 0
|
|
assert output.exists(), "main([]) with no --check must write OUTPUT"
|
|
written = output.read_text(encoding="utf-8")
|
|
assert written.strip() == _current_catalog_text().strip()
|
|
|
|
assert bc.main(["--check"]) == 0, "the file main([]) just wrote must satisfy main(['--check'])"
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# THE `__main__` WIRING — proof CI's subprocess invocation actually surfaces staleness
|
|
# ------------------------------------------------------------------------------------------------
|
|
|
|
|
|
def _seed_decisions_copy(root: Path) -> None:
|
|
"""Copy only what build_decisions_catalog.py + decisions_lib.py need to resolve a real corpus."""
|
|
(root / "scripts").mkdir(parents=True)
|
|
(root / "scripts" / "__init__.py").write_bytes(SCRIPTS_INIT.read_bytes())
|
|
(root / "scripts" / "build_decisions_catalog.py").write_bytes(CATALOG_SCRIPT.read_bytes())
|
|
(root / "scripts" / "decisions_lib.py").write_bytes(DECISIONS_LIB.read_bytes())
|
|
|
|
docs = root / "docs"
|
|
docs.mkdir()
|
|
(docs / "decisions.md").write_bytes((REPO_ROOT / "docs" / "decisions.md").read_bytes())
|
|
|
|
dst_decisions = docs / "decisions"
|
|
src_decisions = REPO_ROOT / "docs" / "decisions"
|
|
dst_decisions.mkdir()
|
|
for item in src_decisions.iterdir():
|
|
if item.is_dir():
|
|
_copy_tree(item, dst_decisions / item.name)
|
|
else:
|
|
(dst_decisions / item.name).write_bytes(item.read_bytes())
|
|
|
|
|
|
def _copy_tree(src: Path, dst: Path) -> None:
|
|
dst.mkdir(parents=True, exist_ok=True)
|
|
for item in src.rglob("*"):
|
|
rel = item.relative_to(src)
|
|
target = dst / rel
|
|
if item.is_dir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
else:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(item.read_bytes())
|
|
|
|
|
|
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "pr-checks.yml"
|
|
|
|
|
|
def _active_runs(workflow: Path | None = None) -> list[str]:
|
|
"""The `run` script of every step the `decisions-guard` job would ACTUALLY execute.
|
|
|
|
Parsed with `yaml.safe_load`, and returned WHOLE — not split into lines. Both choices are
|
|
scar tissue.
|
|
|
|
Text-scanning for `run:` was round one, and it broke three ways: a `run: |` block
|
|
scalar was invisible; a job or step switched off still read as wired; and `run:` inside
|
|
block-scalar *text* was extracted and executed. Round two parsed the YAML and matched a LINE
|
|
beginning with `PYTHONPATH=.` — and that broke too, with a heredoc:
|
|
|
|
run: |
|
|
cat <<'EOF' > /dev/null
|
|
PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
|
EOF
|
|
|
|
The matched line is heredoc DATA. The extractor reported the guard as running, and the proof
|
|
executed a command CI does not. Deciding which lines of a shell script are executed requires
|
|
parsing shell, and `fixing-a-parser-bug-introduces-the-next-one` is explicit that this repo has
|
|
lost that argument repeatedly — a regex over shell is not a parser, and round four would find
|
|
round five.
|
|
|
|
So the line-level heuristic is WITHDRAWN. The whole `run` script is handed to `bash`, exactly as
|
|
the runner does. The heredoc above then runs, writes to `/dev/null`, checks nothing, and exits
|
|
0 — so the stale-catalog case fails to redden and the proof reports the defect instead of
|
|
stepping around it. No shell parsing, and the ambiguous cases resolve by execution.
|
|
"""
|
|
doc = yaml.safe_load((workflow or WORKFLOW).read_text())
|
|
|
|
# The workflow's TRIGGERS, before its jobs. A `decisions-guard` job that is perfectly healthy
|
|
# gates nothing if the workflow stopped running on pull requests, and starting at `jobs:` cannot
|
|
# see that. Note `on` parses to the boolean True in YAML 1.1 (the Norway problem's cousin), so
|
|
# the key is looked up both ways rather than assumed.
|
|
triggers = (doc or {}).get("on", (doc or {}).get(True)) or {}
|
|
names = set(triggers) if isinstance(triggers, dict) else {triggers} if isinstance(triggers, str) else set(triggers)
|
|
assert "pull_request" in names, (
|
|
"pr-checks.yml no longer runs on `pull_request`, so NO gate in it — including the catalog "
|
|
f"guard — fires on a PR. Triggers found: {sorted(str(n) for n in names)}"
|
|
)
|
|
|
|
jobs = (doc or {}).get("jobs") or {}
|
|
job = jobs.get("decisions-guard")
|
|
assert job is not None, (
|
|
"no `decisions-guard` job in pr-checks.yml. Either it was renamed or it was removed — the "
|
|
f"second is the far more serious finding. Jobs present: {sorted(jobs)}"
|
|
)
|
|
assert not _disabled(job), (
|
|
"the `decisions-guard` job is disabled at the job level "
|
|
f"(if: {job.get('if')!r}, continue-on-error: {job.get('continue-on-error')!r}), so nothing "
|
|
"in it runs — including the catalog guard"
|
|
)
|
|
return [str(step["run"]) for step in (job.get("steps") or []) if step.get("run") and not _disabled(step)]
|
|
|
|
|
|
def _unwrap(value: str) -> str:
|
|
"""Strip an `${{ ... }}` expression wrapper, if present, and lowercase.
|
|
|
|
Written as a regex over the WHOLE value rather than `.strip("${{ }}")`, which strips a character
|
|
SET — it would turn `"false}"` into `"false"` and reads as though it removed a wrapper it never
|
|
checked for.
|
|
"""
|
|
inner = value.strip()
|
|
m = re.fullmatch(r"\$\{\{(.*)\}\}", inner, flags=re.DOTALL)
|
|
if m:
|
|
inner = m.group(1)
|
|
return inner.strip().lower()
|
|
|
|
|
|
def _falsey(value) -> bool:
|
|
"""A literal false, however this workflow dialect spells it.
|
|
|
|
`if: false`, `if: "false"` and `if: ${{ false }}` all mean never. The middle and last are the
|
|
ones a text comparison misses; the last was a live false green — `${{ false }}` is the ordinary
|
|
spelling in Actions-flavoured YAML, and it read as wired.
|
|
|
|
An expression that is merely falsy AT RUN TIME (`if: ${{ github.event_name == 'x' }}`) is not
|
|
decidable here and is deliberately not guessed at.
|
|
"""
|
|
if value is False:
|
|
return True
|
|
if not isinstance(value, str):
|
|
return False
|
|
return _unwrap(value) == "false"
|
|
|
|
|
|
def _truthy_literal(value) -> bool:
|
|
if value is True:
|
|
return True
|
|
if not isinstance(value, str):
|
|
return False
|
|
return _unwrap(value) == "true"
|
|
|
|
|
|
def _disabled(node: dict) -> bool:
|
|
"""A job or step that cannot fail the run: switched off, or allowed to fail.
|
|
|
|
`continue-on-error: true` is the subtle one — the step still runs and still reports, but its
|
|
failure does not fail the job, so it is not a gate.
|
|
"""
|
|
return _falsey(node.get("if", True)) or _truthy_literal(node.get("continue-on-error", False))
|
|
|
|
|
|
def _ci_check_command(workflow: Path | None = None) -> list[str]:
|
|
"""The catalog step's script, DERIVED from the workflow and executed whole.
|
|
|
|
`testing.guard-derives-population-from-source`: a hand-copied command is a second copy of the
|
|
workflow that drifts silently, and this test's whole value is that it runs what CI runs.
|
|
"""
|
|
runs = _active_runs(workflow)
|
|
matches = [r for r in runs if "build_decisions_catalog.py" in r and "--check" in r]
|
|
assert matches, (
|
|
"the `decisions-guard` job has no ACTIVE step mentioning "
|
|
"`build_decisions_catalog.py --check`. The catalog guard has stopped running in CI — that "
|
|
f"is the finding, not this test's failure. Active step scripts in that job: {runs}"
|
|
)
|
|
assert len(matches) == 1, f"expected exactly one such step, found {matches}"
|
|
script = matches[0]
|
|
assert "${{" not in script, (
|
|
"the catalog step's script interpolates an Actions expression, which cannot be expanded "
|
|
f"outside the runner — this proof would be executing something else: {script!r}"
|
|
)
|
|
# Substitute the interpreter only where `python3` is a bare command word. A plain
|
|
# `str.replace` rewrites EVERY occurrence, including inside a path — `/usr/bin/python3` would
|
|
# become `/usr/bin/<venv>/bin/python3` and fail with ENOENT, a red blaming the catalog guard for
|
|
# something this line did.
|
|
return ["bash", "-c", re.sub(r"(?<![\w/])python3\b", sys.executable, script)]
|
|
|
|
|
|
def _proof_holds(workflow: Path, repo: Path) -> bool:
|
|
"""Does the whole guarantee hold — fresh corpus passes AND stale corpus fails?
|
|
|
|
The disablement cases below assert on THIS rather than on whether extraction raises, because
|
|
the ways a guard can stop gating do not all surface at the same place. Deletion and disablement
|
|
surface as a failed extraction; a heredoc or an `echo` surfaces only when the script is run and
|
|
reports success over a stale catalog. One predicate covers both.
|
|
"""
|
|
try:
|
|
cmd = _ci_check_command(workflow)
|
|
except AssertionError:
|
|
return False
|
|
|
|
readme = repo / "docs" / "decisions" / "README.md"
|
|
original = readme.read_text(encoding="utf-8")
|
|
try:
|
|
readme.write_text(original, encoding="utf-8")
|
|
fresh = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
|
|
if fresh.returncode != 0:
|
|
return False
|
|
readme.write_text(original + "\nSTALE INJECTED LINE\n", encoding="utf-8")
|
|
stale = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
|
|
return stale.returncode != 0
|
|
finally:
|
|
readme.write_text(original, encoding="utf-8")
|
|
|
|
|
|
_STEP = (
|
|
" - name: Active catalog in sync\n"
|
|
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"label,mutate",
|
|
[
|
|
(
|
|
"the step is COMMENTED OUT",
|
|
lambda s: s.replace(
|
|
_STEP,
|
|
" # - name: Active catalog in sync\n"
|
|
" # run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
|
|
1,
|
|
),
|
|
),
|
|
("the step is DELETED", lambda s: s.replace(_STEP, "", 1)),
|
|
(
|
|
"the step is switched off with `if: false`",
|
|
lambda s: s.replace(
|
|
_STEP,
|
|
" - name: Active catalog in sync\n if: false\n"
|
|
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
|
|
1,
|
|
),
|
|
),
|
|
(
|
|
"the step is switched off with `if: ${{ false }}`",
|
|
lambda s: s.replace(
|
|
_STEP,
|
|
" - name: Active catalog in sync\n if: ${{ false }}\n"
|
|
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
|
|
1,
|
|
),
|
|
),
|
|
(
|
|
"the step is allowed to fail with `continue-on-error: true`",
|
|
lambda s: s.replace(
|
|
_STEP,
|
|
" - name: Active catalog in sync\n continue-on-error: true\n"
|
|
" run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
|
|
1,
|
|
),
|
|
),
|
|
(
|
|
"the whole JOB is switched off with `if: false`",
|
|
lambda s: s.replace(
|
|
" decisions-guard:\n name: decisions lifecycle\n runs-on: small\n"
|
|
" if: github.event_name == 'pull_request'\n",
|
|
" decisions-guard:\n name: decisions lifecycle\n runs-on: small\n if: false\n",
|
|
1,
|
|
),
|
|
),
|
|
(
|
|
"the command survives only as TEXT in another step's `echo`",
|
|
lambda s: s.replace(
|
|
_STEP,
|
|
" - name: Note\n run: |\n"
|
|
" echo we no longer run: PYTHONPATH=. python3 "
|
|
"scripts/build_decisions_catalog.py --check\n",
|
|
1,
|
|
),
|
|
),
|
|
(
|
|
"the WORKFLOW no longer runs on pull requests",
|
|
lambda s: s.replace("on:\n pull_request:", "on:\n workflow_dispatch:", 1),
|
|
),
|
|
(
|
|
"the command survives only as HEREDOC DATA",
|
|
lambda s: s.replace(
|
|
_STEP,
|
|
" - name: Note\n run: |\n"
|
|
" cat <<'EOF' > /dev/null\n"
|
|
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n"
|
|
" EOF\n",
|
|
1,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
def test_a_guard_that_stopped_RUNNING_is_DETECTED(tmp_path, label, mutate):
|
|
"""Eight ways the catalog guard can stop gating, each of which must be caught.
|
|
|
|
Commenting out is one of them, and the only one a text scan detects. The last two
|
|
leave the command in the file, parseable and even matchable — as `echo` argument and as heredoc
|
|
data — which is why the proof executes the step's whole script instead of a line lifted out of it.
|
|
"""
|
|
raw = WORKFLOW.read_text()
|
|
mutated = mutate(raw)
|
|
assert mutated != raw, f"the mutation for {label!r} matched nothing; RETARGET it"
|
|
|
|
alt = tmp_path / "pr-checks.yml"
|
|
alt.write_text(mutated)
|
|
repo = tmp_path / "repo-copy"
|
|
repo.mkdir()
|
|
_seed_decisions_copy(repo)
|
|
|
|
assert not _proof_holds(alt, repo), (
|
|
f"the catalog guard still reported as gating when {label}. CI would run nothing and this "
|
|
"file would report full coverage."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"label,replacement",
|
|
[
|
|
(
|
|
"block scalar",
|
|
" - name: Active catalog in sync\n run: |\n"
|
|
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
|
|
),
|
|
(
|
|
"backslash continuation",
|
|
" - name: Active catalog in sync\n run: |\n"
|
|
" PYTHONPATH=. python3 \\\n"
|
|
" scripts/build_decisions_catalog.py --check\n",
|
|
),
|
|
(
|
|
"a leading `set -euo pipefail`",
|
|
" - name: Active catalog in sync\n run: |\n"
|
|
" set -euo pipefail\n"
|
|
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n",
|
|
),
|
|
(
|
|
"wrapped in a shell block",
|
|
" - name: Active catalog in sync\n run: |\n"
|
|
" if true; then\n"
|
|
" PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check\n"
|
|
" fi\n",
|
|
),
|
|
],
|
|
)
|
|
def test_the_step_may_be_REFORMATTED_without_being_flagged(tmp_path, label, replacement):
|
|
"""The negative controls for the eight above: a legitimate rewrite must NOT be flagged.
|
|
|
|
`run: |` is idiomatic in this very job (the sibling "Validate decision lifecycle" step uses it),
|
|
and the text-scanning version reported that form as "the guard has stopped running in CI" — a
|
|
false red whose message asserts a regression that has not happened. A detector that cannot tell
|
|
a reformat from a removal trains its readers to ignore it, which is the whole subject of #806.
|
|
|
|
A line-matching version had to be taught each of these shapes one at a time, and the
|
|
continuation case defeated two rounds of it. Executing the script gets all four for free: the
|
|
question "is the guard still gating" is answered by running it, not by recognising how it was
|
|
written.
|
|
"""
|
|
raw = WORKFLOW.read_text()
|
|
mutated = raw.replace(_STEP, replacement, 1)
|
|
assert mutated != raw, "retarget this reformatting; the step's text has changed"
|
|
|
|
alt = tmp_path / "pr-checks.yml"
|
|
alt.write_text(mutated)
|
|
repo = tmp_path / "repo-copy"
|
|
repo.mkdir()
|
|
_seed_decisions_copy(repo)
|
|
|
|
assert _proof_holds(alt, repo), f"a legitimate reformat ({label}) was reported as a removal"
|
|
|
|
|
|
def test_CLI_subprocess_exits_nonzero_on_a_stale_catalog(tmp_path):
|
|
"""The real CI invocation, as a subprocess, against a real corpus.
|
|
|
|
This is the `__main__` → `SystemExit(main())` wiring proof: `main()` returning 1 is worthless if
|
|
the process still exits 0, and the workflow step reads nothing but the exit code. It is the
|
|
#751/#719 shape the decision record names — a green result behind wiring that is not connected.
|
|
|
|
The FRESH half is not optional decoration. Without it this test passes whenever the subprocess
|
|
dies for any reason at all — an import error, a missing file in the copy, a syntax error — none
|
|
of which is the guard detecting anything. `arbitrary-sample-gives-false-negatives` in reverse:
|
|
a non-zero exit is only evidence when the same harness is shown to exit zero on a clean corpus.
|
|
"""
|
|
repo = tmp_path / "repo-copy"
|
|
repo.mkdir()
|
|
_seed_decisions_copy(repo)
|
|
cmd = _ci_check_command()
|
|
|
|
fresh = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
|
|
assert fresh.returncode == 0, (
|
|
"the copied corpus does not even pass --check when untouched, so a non-zero exit below "
|
|
f"would be the harness failing rather than the guard firing: {fresh.stdout!r} {fresh.stderr!r}"
|
|
)
|
|
|
|
readme = repo / "docs" / "decisions" / "README.md"
|
|
readme.write_text(readme.read_text(encoding="utf-8") + "\nSTALE INJECTED LINE\n", encoding="utf-8")
|
|
|
|
stale = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, timeout=60)
|
|
assert stale.returncode != 0, (
|
|
"the CLI wiring did not surface a stale catalog as a non-zero exit — CI would report "
|
|
f"green over a stale README.md: stdout={stale.stdout!r} stderr={stale.stderr!r}"
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# THE MUTATION PROOF — disarm `main()`'s stale-detection clause alone, --check must stop detecting
|
|
# ------------------------------------------------------------------------------------------------
|
|
|
|
|
|
def _load_mutated_module(tmp_path: Path, mutated_source: str):
|
|
mutated_path = tmp_path / "mutated_build_decisions_catalog.py"
|
|
mutated_path.write_text(mutated_source, encoding="utf-8")
|
|
spec = importlib.util.spec_from_file_location("mutated_build_decisions_catalog", mutated_path)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_MUTATION_disarming_the_stale_comparison_stops_detection(tmp_path, monkeypatch):
|
|
"""`testing.guard-ships-with-mutation-proof` (#775).
|
|
|
|
`main()`'s ONLY stale-detection logic is the clause `want.strip() != have.strip()`. Disarm that
|
|
clause alone — replace it with the constant `False` in an isolated copy of the module, changing
|
|
nothing else — and `--check` must stop reporting staleness. If it still returns 1, the deny is
|
|
coming from somewhere other than the clause the guard is supposed to hang on, and every test
|
|
above proves nothing about it.
|
|
"""
|
|
text = CATALOG_SCRIPT.read_text(encoding="utf-8")
|
|
clause = "want.strip() != have.strip()"
|
|
assert clause in text, (
|
|
"the stale-detection clause has moved or been reworded; RETARGET this mutation at its new "
|
|
"location rather than loosening the string match — a mutation that silently stops mutating "
|
|
"is the exact failure this file exists to catch"
|
|
)
|
|
mutated_source = text.replace(clause, "False", 1)
|
|
assert mutated_source != text and clause not in mutated_source, (
|
|
"the replacement did not change the source, so the mutant is the subject"
|
|
)
|
|
|
|
fresh = _current_catalog_text()
|
|
output = tmp_path / "README.md"
|
|
output.write_text(fresh.rstrip("\n") + "\nEXTRA STALE LINE\n", encoding="utf-8")
|
|
|
|
# POSITIVE CONTROL FIRST. Without this, the mutation assertion below would pass just as well if
|
|
# `main(["--check"])` never detected anything at all on this input — "the mutant is silent"
|
|
# proves nothing unless the real guard is first shown to be loud on the exact same input.
|
|
monkeypatch.setattr(bc, "OUTPUT", output)
|
|
assert bc.main(["--check"]) == 1, (
|
|
"the UNMUTATED guard did not detect the staleness on this input, so a silent mutant below "
|
|
"would prove nothing about the clause"
|
|
)
|
|
|
|
mutant = _load_mutated_module(tmp_path, mutated_source)
|
|
mutant.OUTPUT = output
|
|
assert mutant.main(["--check"]) == 0, (
|
|
"disarming `want.strip() != have.strip()` alone did not stop --check from reporting "
|
|
"staleness, so that clause is not what the guard's exit code hangs on"
|
|
)
|