fix(tri_loop): damage-repair requires --snapshot instead of defaulting to a path nothing creates - #2338
Merged
Merged
Conversation
…g to a path nothing creates DEFAULT_SNAPSHOT pointed at docs/corpus/damage_snapshot_2026-08-15.json. docs/corpus/ has zero paths on master, and the companion writer damage_freeze.py defaults its --out to docs/corpus/damage_snapshot.json -- a different name -- so the two tools never connected on defaults even once the directory existed. Removed the default rather than making the pair agree. Agreeing would mean committing a snapshot, whose per-file digests go stale the instant any spec changes, at which point the tool's own staleness check refuses on every run. The rule adopted instead, which corpus_status.py already followed for this same artifact: a reader must not guess at an input path, because it cannot know which freeze the caller meant; a writer may default its output path, because it creates the file. So damage-freeze --out keeps its default and damage-repair --snapshot has none. No caller is affected: damage_snapshot appears in exactly two places repo-wide, both the defaults themselves, and nothing in the repo invokes damage-repair. scripts/ci/test_damage_repair_snapshot_required.py freezes a one-spec corpus at the writer's default name and then asserts four independent properties of the reader: it refuses with no --snapshot, says so in those words, names no concrete snapshot path of its own, and still runs when given one. Against the previous code it fails 3/4. G1 alone passed there -- the defective tool already exited 2 -- which is why the other three exist. Closes #2327
Contributor
PR DashboardGenerated at: 2026-08-21 15:45:34 UTC
Summary
Seal Status
|
Contributor
|
📓 NotebookLM Notebook linked to this PR
This notebook contains session context, decisions, and artifacts for this work. |
gHashTag
enabled auto-merge (squash)
August 21, 2026 15:45
This was referenced Aug 21, 2026
Contributor
PR DashboardGenerated at: 2026-08-21 15:48:34 UTC
Summary
Seal Status
|
Contributor
|
📓 NotebookLM Notebook linked to this PR
This notebook contains session context, decisions, and artifacts for this work. |
Contributor
|
📓 NotebookLM Notebook linked to this PR
This notebook contains session context, decisions, and artifacts for this work. |
Contributor
PR DashboardGenerated at: 2026-08-21 15:50:13 UTC
Summary
Seal Status
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2327
The defect was still live on
origin/masterRe-verified at
4ea72c322fa5572fc0c33fb5deedb739b7ad6c6a. All three line numbers in the issue are exact:scripts/tri_loop/damage_repair.py:82DEFAULT_SNAPSHOT = "docs/corpus/damage_snapshot_2026-08-15.json"scripts/tri_loop/damage_freeze.py:130out_path = "docs/corpus/damage_snapshot.json"scripts/tri_loop/corpus_status.py:132ap.add_argument("--snapshot", required=True, type=Path, help="tri damage-freeze json")docs/corpus/has zero paths on master (git ls-tree -r origin/mastermatches onlydocs/CORPUS-RATCHET.md, a different path).The two defaults disagreeing is not a theoretical problem. On a clean checkout of master, running the pair back to back with no arguments:
The reader reports "no snapshot" while the freeze it could have used is sitting beside it in the same directory.
What I checked before choosing a shape, and what I chose
Chosen: make
--snapshotrequired, followingcorpus_status.py. Not "make the defaults agree".Why not make them agree. To agree on "a path that exists", a snapshot would have to be committed. A snapshot pins
file_sha256per file plus acorpus_sha256; committed, it goes stale the moment any spec changes, and this tool's own staleness check (REFUSING: the corpus moved since the snapshot was frozen) would then refuse on every run. Agreeing on the undated name without committing it leaves a default pointing at a file that still doesn't exist until someone runs freeze — the same dangling shape, just likelier to connect by accident.The rule adopted instead, which
corpus_status.py:132already followed for this same artifact: a reader must not guess at an input path, because it cannot know which freeze the caller meant; a writer may default its output path, because it creates the file rather than hoping one is there. Under that linedamage_freeze.py:130's default is legitimate and is deliberately left alone;damage_repair.py:82's is not. It is also the same instinct as the staleness check: a tool that refuses to repair against the wrong snapshot should equally refuse to invent which snapshot you meant.Effect on existing callers — checked, and it is zero.
damage_snapshotappears in exactly two places repo-wide, both the defaults themselves. Nothing in the repo invokesdamage-repairat all:scripts/ci/loop-tools-tracked.shonly asserts the file is tracked and the subcommand routes, andloop-tools-gate.ymlrunstri damage, a different subcommand. No doc references it.One honest correction to the issue's framing. I could not reproduce a fail-open in the "proceeds with garbage" sense — master's reader exits 2, and the digest guard would catch corpus drift if the dated file ever existed. The defect I did reproduce is a dangling default: the no-argument path is dead, and the error misdirects, telling you no snapshot exists while one does. Either fix removes the guess; this one removes the possibility of a guess.
Also changed: the surviving
no snapshot at <path>message now echoes back the path the caller passed instead of a hardcoded literal, so the remedy it prints cannot drift from the request the way the two defaults did.Mutant evidence
New test
scripts/ci/test_damage_repair_snapshot_required.py. It builds a one-spec corpus and freezes it at the writer's default name, so a default-named snapshot is present on disk for every case — a reader that quietly adopts it is guessing, and that is a failure here. Four guards, failures collected rather than raised so one break cannot hide the other three. No compiler, no corpus, pure Python.Failing against master's current code — 3 of 4 guards:
G1passes on master. The defective tool already exited 2, so a guard that only asked "did it exit non-zero" would have been green on this defect. That is precisely why G2 and G3 exist.One mutant per guard, each leaving the earlier guards passing so every guard is proven independently:
snapshot = "docs/corpus/damage_snapshot.json"G1 refuses: exited 0 with no --snapshot. A snapshot named docs/corpus/damage_snapshot.json was present, so the tool adopted a freeze the caller never named.--snapshot is unset.G2 right reason: the refusal does not both name --snapshot and say it is required. Exiting non-zero for an unrelated reason would look identical.G3 resolves no path: the refusal names 'docs/corpus'. The reader must not point at a concrete snapshot file it was not given -- a hardcoded path here is exactly the defect #2327 reported.if snapshot is None:→if True:G4 still works: an explicit --snapshot to a real freeze exited 2. The refusal in G1 is then not a required-argument check, it is a tool that never runs.M2, M3 and M4 each fired their own guard with all others still passing, so no guard is riding on another's failure. G4 is the anti-vacuity guard — without it, M4 (
always refuse) would satisfy G1–G3 and look like a fix.All mutants reverted;
grep -n MUTANTreturns nothing; the suite is 4/4 green on this branch:Gates
Wired into
loop-tools-gate, which already covers these tools, is path-filtered onscripts/tri_loop/**, and needs no build. Ran every step of that workflow locally:loop-tools-tracked.shPASS,py_compile10/10 ok, dispatcher ok, new test 4/4.The local
.githooks/pre-commitcould not run: it shells out tot27c check-now, andt27cis not git-tracked (git ls-files | grep -c 't27c$'→0) — a build artifact, not a file missing from my checkout, and building it was not affordable here. So I verified the gate's actual assertions instead, the onesscripts/ci/now-sync-gate-diff.shenforces in CI: presence (addsdocs/now/2026-08-21-damage-repair-snapshot-required.md, matching the gate regex), freshness (2026-08-21== UTC today), content (2 headings, 7 bullets).check-now-freshnessin CI is the authoritative check and gates this PR regardless.Corpus Ratchet,Seal CoverageandFPGA E2E Buildare already red on master, are not required, and are unrelated to this change.Adjacent, deliberately not fixed here
damage_freeze.py's--outdefault is left as-is — under the reader/writer rule above it is correct, since freeze creates the file.Filed separately rather than widened into this diff: #2341 —
damage_repair.pyparsesargvby hand with noelsebranch, so an unknown flag is silently ignored. Verified: a typo'd--clas DC-13bfd302drops the scope and repairs every class (2 instead of 1), exiting 0 as if it had done what was asked. Distinct defect, distinct fix, not in scope here.