feat(#3227): publish machine-readable state contract at step boundaries (#3824)

* feat(#3227): publish machine-readable state contract at step boundaries

Adds src/state-contract.cts, a best-effort publisher that writes
.planning/state.json (contract 1.0.0) at 11 step-boundary commands, so
external tools read a versioned contract instead of parsing STATE.md and
ROADMAP.md heuristically.

Composes existing owners rather than re-deriving: phase rows come from a
new locateProgressTable extracted from deriveProgressFromRoadmap (so the
snapshot can never disagree with GSD's own progress counters), milestone
identity from getMilestoneInfo, and next from classifyProject. Owners are
required lazily to avoid the state -> state-contract -> smart-entry ->
state require cycle.

Also fixes a pre-existing defect in scripts/lint-test-file-count.cjs
(maintainer-approved as a second concern): testEffectivePrefix never
stripped the suite qualifier, so 65 dotted test files counted against no
module and 9 mis-bucketed into a shorter one. Allowlist re-baselined for
the 74 files the gate can now see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3227): backfill PR number into the changeset fragment

pr:0 -> pr:3824 now that the PR exists. Doc-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3227): shape hostile-name fixtures away from the scan corpus

The two hostile-input fixtures used a literal phrase from
scripts/prompt-injection-scan.sh's corpus, so CI's Security Scan redded on
this file. These tests assert that an arbitrary phase name round-trips into
state.json as inert data -- the property holds for any string, so the
injection flavor is illustrative, not load-bearing.

Reshaped to a hyphenated fake instruction tag, which stays hostile-looking
while matching none of the scanner's patterns. Allowlisting the file was
rejected: that mechanism is for suites whose subject IS injection defense,
and it would blind the scanner to this whole file permanently.
See DEFECT.PROMPT-INJECTION-SCAN-COLLISION.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3227): ratchet the state-contract mutation floor to its measured score

The module was registered at minScore 50, the ratchet's minimum permitted
floor for a newly-registered module whose score had not been measured. This
PR's own Stryker shard measured 66.25% (run 32769289750, job 97565813640),
so the floor moves to floor(measured) - 1 = 65, per the rule the registry
documents.

66.25 is below TARGET_MUTATION_SCORE (80), so this stays a ratchet
candidate: raise as the tests improve, never lower.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-24 17:56:02 -04:00
committed by GitHub
parent fb9823e1e1
commit 596540f864
23 changed files with 2739 additions and 25 deletions

View File

@@ -21,6 +21,9 @@ import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
import ioMod = require('./io.cjs');
const { output, error, ERROR_REASON } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateContract = require('./state-contract.cjs');
const { publishStateContract } = stateContract;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
import configLoaderMod = require('./config-loader.cjs');
const { loadConfig } = configLoaderMod;
@@ -1151,6 +1154,18 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?:
if (titleWarning) result['warning'] = titleWarning;
output(result, raw, result['padded']);
// #3227 (design doc §40 row 26 / "Not-corruption" rule): every
// `publishStateContract` call site in this file is audited so a refreshed
// state.json `updated_at` always means something on disk actually moved —
// a stale-but-refreshed timestamp is worse than no refresh, because it
// reads as fresh to a downstream watcher. This site is unconditional
// because every reachable path either exits via `error()` (process.exit,
// never reaches here) or falls through to the unconditional
// `platformEnsureDir`/`platformWriteSync` pair above that always creates
// the phase directory and rewrites ROADMAP.md — there is no code path that
// reaches this line without having just written to disk. Best-effort —
// cannot throw, cannot change this command's exit code or output.
publishStateContract(cwd);
}
function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): void {
@@ -1238,6 +1253,11 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
return added;
});
output({ phases: results, count: results.length }, raw);
// #3227: unconditional here because `platformWriteSync(roadmapPath, rawContent)`
// above always rewrites ROADMAP.md for every description in the batch before
// this line is reached; the only refusal path is the `error('ROADMAP.md not
// found')` above, which terminates the process and never reaches here.
publishStateContract(cwd);
}
function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, raw: boolean): void {
@@ -1431,6 +1451,11 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra
};
output(result, raw, decimalPhase);
// #3227: unconditional here because `platformWriteSync(roadmapPath, updatedContent)`
// above always rewrites ROADMAP.md with the inserted phase before this line is
// reached; every refusal along the way (bad args, missing ROADMAP.md, unresolved
// target bullet/header) exits via `error()`, which terminates the process.
publishStateContract(cwd);
}
interface RenameDirInfo {
@@ -2110,6 +2135,12 @@ function cmdPhaseRemove(
},
raw,
);
// #3227: unconditional here because `updateRoadmapAfterPhaseRemoval` above
// always rewrites ROADMAP.md before this line is reached; every refusal path
// (bad target, missing ROADMAP.md, --force-required, renumber failure) exits
// via `error()`, and the ambiguous-match case exits via an earlier `return`
// before any file is touched.
publishStateContract(cwd);
}
interface WriteSpec {
@@ -2118,7 +2149,14 @@ interface WriteSpec {
after: string;
}
function writePlanningFileSet(writes: WriteSpec[]): void {
/**
* #3227: returns the count of writes actually applied (entries whose
* `before` differed from `after` and were therefore written to disk) — the
* caller (`cmdPhaseComplete`) uses this as its publish-gate signal, since a
* re-run against an already-completed phase can produce a `writes[]` array
* where every entry is byte-identical to what's already on disk.
*/
function writePlanningFileSet(writes: WriteSpec[]): number {
const applied: WriteSpec[] = [];
try {
for (const write of writes) {
@@ -2145,6 +2183,7 @@ function writePlanningFileSet(writes: WriteSpec[]): void {
}
throw err;
}
return applied.length;
}
function phaseDisplayNameFromRoadmap(roadmapContent: string | null, phaseNum: string | null): string | null {
@@ -2415,6 +2454,15 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// verification_stale_check_indeterminate).
let milestoneConflict: milestoneLockMod.MilestoneConflict | null = null;
// #3227: set inside `runPhaseCompleteTransaction` below from
// `writePlanningFileSet`'s applied-count return — the transaction always
// RUNS (verification passed, the lock was taken, `writes[]` was built),
// but a re-run against a phase whose ROADMAP/STATE bytes already reflect
// completion produces a `writes[]` where every entry is byte-identical to
// disk, so `writePlanningFileSet` applies none of them. That must not
// still refresh state.json's `updated_at` (design doc §40 row 26).
let anyPlanningWrite = false;
const verificationBlocked = withPlanningLock(cwd, () => {
// #3311: completing a phase while a live milestone claim (phase + session)
// holds a DIFFERENT phase means two sessions are working two phases against
@@ -3207,7 +3255,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
}
writePlanningFileSet(writes);
anyPlanningWrite = writePlanningFileSet(writes) > 0;
};
if (fs.existsSync(statePath)) {
@@ -3283,6 +3331,11 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
};
output(result, raw);
// #3227: gate on `anyPlanningWrite` (whether `writePlanningFileSet`
// actually wrote anything), not on reaching this line — reaching here only
// means verification passed and the transaction ran, not that ROADMAP.md
// or STATE.md bytes changed (see the `anyPlanningWrite` declaration above).
if (anyPlanningWrite) publishStateContract(cwd);
}
function cmdPhaseUatPassed(