diff --git a/.changeset/fierce-wasps-run.md b/.changeset/fierce-wasps-run.md new file mode 100644 index 000000000..9a4b217cc --- /dev/null +++ b/.changeset/fierce-wasps-run.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 4290 +--- +Verification reports now record a deterministic content fingerprint of their covered inputs (phase PLAN/SUMMARY, mapped requirements, implementation files in the change set); `readVerificationStatus` recomputes it and reports `stale` on any mismatch, fail-closed on a missing/unreadable/confinement-escaping covered file. Legacy reports without fingerprint metadata keep the prior SUMMARY-mtime staleness check unchanged. (#4155) diff --git a/CONTEXT.md b/CONTEXT.md index f0bc8109a..1b3c56afe 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,7 +24,7 @@ Module owning phase create, rename, complete, remove, list, and plan-index opera Module owning phase-effort estimation and its calibration against measured reality (ADR-2629, epic #1952). Pure — no I/O, no config reads; the CLI seam (`src/estimate-cli.cts`, verbs `estimate-check` / `estimate-calibration`) owns reading `.planning/config.json` and `.planning/estimation-calibration.json`. Interface: `parseEstimate`/`renderEstimate` (the PLAN.md `estimate: {tokens, tasks, confidence}` block), `parseActuals`/`renderActuals` (the SUMMARY.md `actuals: {tokens, tasks, commits}` block), `deriveConfidence(sampleCount) → low|med|high`, `classifyAgainstBudget(estimate, budget) → {overBudget, ratio, recommendation, budgetValid}`, `computeCalibration(samples) → {factor, sampleCount, applied, confidence, clamped}`, `applyCalibration`, `parseCalibrationDocument`/`renderCalibrationDocument`, `extractFrontmatterBlock` (leading-`---`-anchored scalar-block reader; stays hand-rolled for its TYPE CONTRACT — it returns numeric-looking values as numbers so `parseEstimate`/`parseActuals` see the types they validate, whereas the migrated `extractFrontmatter`, ADR-3473 §8.1/#3881, resolves every scalar as a string under js-yaml's FAILSAFE_SCHEMA; migrating this function onto the shared parser is follow-on work under ADR-3473 §8.1, not done), `calibrationBasis` (returns `estimate.raw_tokens` when present, else `tokens` — calibration must measure actual/raw or the loop un-corrects itself), and `measureTokens` (a re-export of `prompt-budget`'s `estimateTokens`). **Domain terms: _raw_ vs _calibrated_ tokens** — the same token count in two mutually incompatible states, carried by the compile-time brands `RawTokens` (the planner's uncorrected projection, and the only legal calibration denominator) and `CalibratedTokens` (the projection with the project's factor applied, and the only figure meaningful against the budget), constructed at trust boundaries via `asRawTokens` / `asCalibratedTokens` (#2671). The brands erase at compile time — the emitted `.cjs`, the CLI JSON, and both frontmatter schemas are unchanged — and exist because mixing the two states was NOT catchable at runtime: both are positive integers of the same magnitude, and the mix-up shipped twice past a green ~26,800-test suite (#2631 factor², #2632 self-defeating loop). `asRawTokens` refuses a `CalibratedTokens` by design; the single legitimate crossover (a pre-#2632 plan whose `tokens` IS the raw projection) lives behind one commented assertion in `calibrationBasis`. Compile fixtures: `tests/fixtures/brand-typing/`. **_smart zone_** — the usable prefix of a model's context window before output quality degrades, expressed as the configurable `workflow.smart_zone_tokens` budget (default 100000, a *policy default* rather than a benchmark constant since the effective ceiling is model/task-dependent); **_estimate/actuals_** — a projected phase cost recorded at plan time and the measured cost recorded at completion, both on the **same `estimateTokens` scale** so their ratio measures the miss rather than a difference between two measurement methods. Two invariants: (1) every signal is **exogenous** — the correction routes on a measured actual/estimate ratio and `confidence` routes on a calibration sample count, never on a model's self-assessment (this project measured self-rated confidence and found it weak — `gsd-core/references/honest-verifier.md:25-29`; see `.out-of-scope/general-purpose-agent-prompt-skills.md`); (2) the over-budget flag is **advisory** — a warning plus a split recommendation, never a block. Calibration is median-of-ratios, clamped to `[0.5, 3.0]`, and inert below 3 samples. CLI seam verbs: `estimate-check` (classify one figure; `--calibrated` when the input already has the factor applied — omitting it squares the correction), `estimate-calibration` (report the current factor), `estimate-calibrate` (#2632 — pair every completed phase's PLAN `estimate` with its SUMMARY `actuals`, rebuild `.planning/estimation-calibration.json` idempotently, and report the result; this is what closes the loop). Source of truth: `gsd-core/bin/lib/phase-estimation.cjs` and `src/estimate-cli.cts`. Test anchors: `tests/phase-estimation.test.cjs`, `tests/estimate-calibrate.test.cjs`. ### Verification Module -Module owning the canonical phase-verification status projection shared by phase transition, progress, manager, autonomous, and closeout readiness paths. `readVerificationStatus(phaseDir, opts?)` reads the first `*-VERIFICATION.md` frontmatter `status`, maps it through `VERIFICATION_ROUTING_TABLE`, and fail-closes — only `{passed}` satisfies the canonical gate; `missing`/`unknown`/`gaps_found`/`human_needed`/`stale` all route away from "complete" (#1522). `findStaleVerificationSummary` flags a SUMMARY newer than the VERIFICATION file (status `stale`). Both honor a no-throw, degrade-to-safe contract (any FS error → `missing` / not-stale) and an injectable `opts.fs` seam. `isPhaseComplete(phaseDir, deps?)` is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186, disk-strict per #2957): it wraps `readVerificationStatus`, calling it UNCONDITIONALLY — plan count is never a precondition, so a zero-plan phase with a passing `*-VERIFICATION.md` is complete (#3168) — and returns `{ value: { complete, verification }, scope }`; `complete` is exactly `verification.status === 'passed'`. A ROADMAP checkbox carries no machine authority and is never consulted. `cmdPhaseComplete`, `buildPhaseCompletionProjection`, and `buildStateFrontmatter` all route through it. Source of truth: `gsd-core/bin/lib/verification.cjs` (generated from `src/verification.cts`). +Module owning the canonical phase-verification status projection shared by phase transition, progress, manager, autonomous, and closeout readiness paths. `readVerificationStatus(phaseDir, opts?)` reads the first `*-VERIFICATION.md` frontmatter `status`, maps it through `VERIFICATION_ROUTING_TABLE`, and fail-closes — only `{passed}` satisfies the canonical gate; `missing`/`unknown`/`gaps_found`/`human_needed`/`stale` all route away from "complete" (#1522). Staleness is one of TWO mutually exclusive checks, selected by whether the report DECLARES a fingerprint (#4155, i.e. either `covered_files` or `covered_digest` is present — a report that declares only one, or an empty/malformed pair, fails closed to `stale` rather than silently downgrading to the weaker legacy check): a report with a well-formed pair is checked by `computeCoveredDigest(projectRoot, coveredFiles)` — a deterministic sha256-of-sha256s over the SORTED, de-duplicated, `./`/`..`-canonicalized covered set, hashed by file BYTES always through the real `node:fs`, never a caller-injected `opts.fs` seam (`covered_files` spans the whole `projectRoot`, not just `.planning/`, so a caller-scoped containment wrapper narrower than `projectRoot` would reject legitimate covered files; the `realRel`-vs-`realRoot` re-check inside `computeCoveredDigest` is the actual confinement boundary, against `projectRoot`) — recomputed and compared to the stored digest. `allCurrentArtifactsCovered` additionally re-scans the LIVE phase directory (via `scanPhasePlans`, short-circuited so it only runs once the digest itself has matched, and fails closed to `false` on any scope other than `SCOPE.COMPLETE`) for every current `*-PLAN.md`/`*-SUMMARY.md` and requires each to be represented in `covered_files`: a plan or summary added to the phase AFTER verification, never declared, would otherwise pass the digest check untouched. ANY mismatch — digest, an uncovered current artifact, or a covered path that is missing, unreadable, or escapes `projectRoot` (`findProjectRoot(phaseDir)`, canonicalized via real `fs`, never the injected seam — it is a trusted caller-derived anchor, not attacker-influenced covered-input data) — routes to `stale`, fail-closed (`computeCoveredDigest` returns `null` for the whole set rather than a partial digest). A report with no fingerprint metadata at all (every report predating #4155) falls back to the legacy `findStaleVerificationSummary`, which flags a SUMMARY newer than the VERIFICATION file — unchanged. The verifier computes `covered_digest` via the CLI (`verification.fingerprint ...`, routed in `verification-command-router.cjs`), never by hand — this is deterministic math, not an LLM-estimated value. The legacy path alone keeps the module's original no-throw, degrade-to-safe contract (any FS error → `missing` / not-stale); the fingerprint path's contract is stricter (any FS error → `stale`), matching #4155's fail-closed design. `isPhaseComplete(phaseDir, deps?)` is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186, disk-strict per #2957): it wraps `readVerificationStatus`, calling it UNCONDITIONALLY — plan count is never a precondition, so a zero-plan phase with a passing `*-VERIFICATION.md` is complete (#3168) — and returns `{ value: { complete, verification }, scope }`; `complete` is exactly `verification.status === 'passed'`. A ROADMAP checkbox carries no machine authority and is never consulted. `cmdPhaseComplete`, `buildPhaseCompletionProjection`, and `buildStateFrontmatter` all route through it. Source of truth: `gsd-core/bin/lib/verification.cjs` (generated from `src/verification.cts`). `SEAM.verification-isphasecomplete.owns=isPhaseComplete(phaseDir, deps?) is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186)` `SEAM.verification-isphasecomplete.enforced-by=test:tests/verification-status.test.cjs` diff --git a/agents/gsd-verifier.md b/agents/gsd-verifier.md index 0806fe639..c487d4e83 100644 --- a/agents/gsd-verifier.md +++ b/agents/gsd-verifier.md @@ -668,6 +668,8 @@ If `valid != true`, refuse to verify. Surface the discrepancy and ask the user t **ALWAYS use the Write tool to create files** — never use `Bash(cat << 'EOF')` or heredoc commands for file creation. +**#4155:** `covered_files`: every phase PLAN/SUMMARY (+superseded, nested `plans/`), mapped requirement, changed impl file — ROOT-relative. `gsd_run query verification.fingerprint {phaseDir} {file}...`, copy output — never hand-write `covered_digest`. + Create `.planning/phases/{phase_dir}/{phase_num}-VERIFICATION.md`: ```markdown @@ -676,6 +678,8 @@ phase: XX-name verified: YYYY-MM-DDTHH:MM:SSZ status: passed | gaps_found | human_needed score: N/M must-haves verified +covered_files: [...] +covered_digest: "v1:sha256:..." behavior_unverified: 0 # Count of ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truths (present + wired, behavior not exercised); each is detailed in behavior_unverified_items below (and in human_verification when status is human_needed) overrides_applied: 0 # Count of PASSED (override) items included in score overrides: # Only if overrides exist — carried forward or newly added @@ -935,6 +939,7 @@ return
No messages
// Always shows "no messages" - [ ] Gaps structured in YAML frontmatter (if gaps_found) - [ ] Deferred items structured in YAML frontmatter (if deferred items exist) - [ ] Re-verification metadata included (if previous existed) +- [ ] fingerprint fields written via verification.fingerprint (#4155) - [ ] VERIFICATION.md created with complete report - [ ] Results returned to orchestrator (NOT committed) diff --git a/docs/how-to/verify-and-ship.md b/docs/how-to/verify-and-ship.md index c05ce4b10..bda414dd8 100644 --- a/docs/how-to/verify-and-ship.md +++ b/docs/how-to/verify-and-ship.md @@ -103,6 +103,19 @@ If your branch contains `.planning/` commits that you do not want reviewers to s --- +## Why a passing verification can turn stale + +A `*-VERIFICATION.md` reporting `status: passed` is re-checked, not cached, every time a completion gate reads it. Two ways it can flip to `status: stale`: + +- **Content fingerprint (default for new reports).** The verifier records a digest of every input its verdict covers — the phase's `PLAN.md`/`SUMMARY.md` files, mapped requirements, and implementation files in the change set. If any of those files changes, disappears, or becomes unreadable after verification, the gate recomputes the digest, finds a mismatch, and reports `stale`. This catches drift even when nothing touches `SUMMARY.md` itself — an implementation file edited after verification is enough. +- **Legacy SUMMARY-mtime check.** A `*-VERIFICATION.md` written before this fingerprint existed has no digest to check, so it keeps the older rule: `stale` when a `SUMMARY.md` is newer than the verification report. + +Either way, `stale` routes the same as any other incomplete state: re-run `/gsd-verify-work 1` before shipping. + +The content fingerprint hashes covered files exactly as they sit on disk, including line endings. A checkout without a `.gitattributes` `eol=lf` rule pinning text files to LF (a Windows checkout with `core.autocrlf=true`, for example) can report `stale` on unchanged content — fix with a `.gitattributes` `eol=lf` rule, not by treating it as drift. + +--- + ## Closing a milestone If this was the last phase in the milestone, run the milestone audit and archive it: diff --git a/gsd-core/templates/verification-report.md b/gsd-core/templates/verification-report.md index 37929c7e5..ab52300aa 100644 --- a/gsd-core/templates/verification-report.md +++ b/gsd-core/templates/verification-report.md @@ -12,6 +12,11 @@ phase: XX-name verified: YYYY-MM-DDTHH:MM:SSZ status: passed | gaps_found | human_needed score: N/M must-haves verified +covered_files: # #4155 — see agents/gsd-verifier.md's "Create VERIFICATION.md" step for what belongs here and how to compute it + - .planning/phases/XX-name/{phase_num}-{plan}-PLAN.md + - .planning/phases/XX-name/{phase_num}-{plan}-SUMMARY.md + - src/{changed-file}.cts +covered_digest: "v1:sha256:{digest from verification.fingerprint}" behavior_unverified: 0 # Count of ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truths (present + wired, behavior not exercised) behavior_unverified_items: # Only if behavior_unverified > 0 — the truths above as structured items; emitted regardless of overall status - truth: "Observable truth whose state transition or cancellation/cleanup/ordering invariant no test exercises" diff --git a/src/planning-inspect.cts b/src/planning-inspect.cts index 6b743c116..fe60b61e8 100644 --- a/src/planning-inspect.cts +++ b/src/planning-inspect.cts @@ -303,7 +303,7 @@ function readDocument(filePath: string, root: string): { text: string | null; ex function containmentEnforcingVerificationFs(planningRoot: string): { readdirSync(dir: string): string[]; readFileSync(filePath: string, encoding: 'utf-8'): string; - statSync(filePath: string): { mtimeMs: number }; + statSync(filePath: string): { mtimeMs: number; isFile(): boolean }; } { function assertContained(target: string): void { if (!isPathContained(target, planningRoot)) { @@ -319,7 +319,7 @@ function containmentEnforcingVerificationFs(planningRoot: string): { assertContained(filePath); return fs.readFileSync(filePath, encoding); }, - statSync(filePath: string): { mtimeMs: number } { + statSync(filePath: string): { mtimeMs: number; isFile(): boolean } { assertContained(filePath); return fs.statSync(filePath); }, diff --git a/src/verification-command-router.cts b/src/verification-command-router.cts index 32905de04..6598a61c6 100644 --- a/src/verification-command-router.cts +++ b/src/verification-command-router.cts @@ -18,6 +18,7 @@ const { routeCjsCommandFamily } = cjsCommandRouterAdapter; interface VerificationModule { cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void; cmdVerificationResolveFile(cwd: string, phaseDirArg: string | undefined, raw: boolean): void; + cmdVerificationFingerprint(cwd: string, phaseDirArg: string | undefined, files: string[], raw: boolean): void; } interface RouteVerificationCommandOptions { @@ -30,7 +31,7 @@ interface RouteVerificationCommandOptions { // ─── Implementation ─────────────────────────────────────────────────────────── -const VERIFICATION_SUBCOMMANDS = ['status', 'resolve-file']; +const VERIFICATION_SUBCOMMANDS = ['status', 'resolve-file', 'fingerprint']; function routeVerificationCommand({ verification, @@ -49,6 +50,7 @@ function routeVerificationCommand({ handlers: { status: () => verification.cmdVerificationStatus(cwd, args[2], raw), 'resolve-file': () => verification.cmdVerificationResolveFile(cwd, args[2], raw), + fingerprint: () => verification.cmdVerificationFingerprint(cwd, args[2], args.slice(3), raw), }, }); } diff --git a/src/verification.cts b/src/verification.cts index 510da63dd..239c89b7e 100644 --- a/src/verification.cts +++ b/src/verification.cts @@ -29,6 +29,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import crypto from 'node:crypto'; +import { findProjectRoot } from './project-root.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module import io = require('./io.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module @@ -147,9 +149,22 @@ function projectNextCommand(bare: string, runtime: string, tail = ''): string { interface FsLike { readdirSync(dir: string): string[]; readFileSync(filePath: string, encoding: 'utf-8'): string; - statSync(filePath: string): { mtimeMs: number }; + statSync(filePath: string): { mtimeMs: number; isFile(): boolean }; } +/** + * Real `node:fs`-backed default satisfying FsLike. Every method wraps a call + * to `fs.` rather than capturing the function reference — existing + * tests mock individual `fs` methods in place (`t.mock.method(fs, 'statSync', …)`), + * and a captured reference taken at module-load time would be invisible to + * that late mock, silently un-mocking this seam's "default" path. + */ +const defaultFsImpl: FsLike = { + readdirSync: (dir: string) => fs.readdirSync(dir), + readFileSync: (filePath: string, encoding: 'utf-8') => fs.readFileSync(filePath, encoding), + statSync: (filePath: string) => fs.statSync(filePath), +}; + /** * Outcome of a staleness check. `determined:false` means the check could NOT * run to completion (an fs / scanPhasePlans / injected-clock failure) — this @@ -177,6 +192,167 @@ function toPosix(p: string): string { return p.replace(/\\/g, '/'); } +/** + * #4155: canonicalize a covered-input path before it becomes either a + * dedup/sort/hash key or a confinement-check subject. `path.posix.normalize` + * collapses `./`, redundant slashes, and internal `..` segments (`a/../../b` + * → `../b`) — without this, two spellings of the SAME file (`src/x.cts` vs + * `./src/x.cts`) hash as different covered inputs (spurious `stale`, or a + * file double-counted into the digest under two keys), and an escape + * disguised by an internal `..` segment slips past a check that only looks + * at the string's start. + */ +function normalizeRel(p: string): string { + return path.posix.normalize(toPosix(p)); +} + +/** Canonicalize a covered-files list: normalize, de-duplicate, sort — the SAME + * transform computeCoveredDigest and cmdVerificationFingerprint both need + * (the digest's own key order; the CLI's own `covered_files` JSON output). */ +function canonicalizeCoveredFiles(files: readonly string[]): string[] { + return Array.from(new Set(files.map(normalizeRel))).sort(); +} + +// ─── #4155: covered-input fingerprint ────────────────────────────────────────── + +/** + * Bump on any change to the digest's input shape (path list, hashing order, + * per-file hash algorithm) so an old stored digest can never collide with a + * differently-computed new one — a version mismatch is just a mismatch. + */ +const FINGERPRINT_VERSION = 1; + +/** + * #4155: recompute the deterministic content fingerprint over a verifier's + * declared covered-input set (phase PLAN/SUMMARY, mapped requirements, + * implementation files in the change set) and return the versioned digest + * string, or `null` if the set cannot be resolved. + * + * Determinism: paths are de-duplicated and SORTED before hashing (directory + * enumeration order is irrelevant), each path is resolved relative to + * `projectRoot` (the absolute checkout path never enters the digest), and + * file BYTES are hashed (mtime never enters the digest). + * + * NOT normalized: line endings. Unlike the report-frontmatter read (which + * runs every VERIFICATION.md through `normalizeLineEndings`), covered-file + * bytes are hashed exactly as they sit on disk. A covered text file checked + * out with CRLF line endings (e.g. a Windows checkout without a `.gitattributes + * eol=lf` rule pinning it to LF) hashes differently than the same file on an + * LF checkout — a real cross-platform digest mismatch, not a bug, since GSD + * installs into arbitrary user projects with no guaranteed line-ending policy. + * + + * Fail closed: a covered path that is empty, absolute, escapes + * `projectRoot` (`..` traversal), or cannot be read (missing, unreadable, + * not a regular file) makes the WHOLE fingerprint unresolvable — returns + * `null` — rather than silently hashing a partial set. Callers treat `null` + * as stale (#4155), the same fail-closed shape #3057 B3 established for the + * legacy mtime staleness check. + * + * Always reads through the REAL `node:fs`, never a caller-injected `FsLike` + * seam — same reasoning as the root canonicalization below, extended to + * every covered file: `covered_files` is expected to span the whole + * `projectRoot` (implementation files under `src/`, not just `.planning/` + * artifacts), so a caller-scoped containment wrapper narrower than + * `projectRoot` (e.g. `planning-inspect.cts`'s `containmentEnforcingVerificationFs`, + * confined to `.planning/`) would reject every implementation-file read and + * report EVERY fingerprinted phase permanently `stale` regardless of actual + * drift — the bug this comment now documents against regressing. The + * `realRel`-vs-`realRoot` re-check a few lines below already does the real + * confinement work (against `projectRoot`, the correct boundary for this + * data), so no security property is lost by bypassing a narrower seam here. + */ +function computeCoveredDigest(projectRoot: string, coveredFiles: readonly string[]): string | null { + const uniqueSorted = canonicalizeCoveredFiles(coveredFiles); + if (uniqueSorted.length === 0) return null; + + // Canonicalize the root ONCE — every candidate's realpath is checked against + // this, not the possibly-symlinked `projectRoot` argument itself. Always via + // the REAL fs, never fsImpl: `projectRoot` is a trusted anchor the CALLER + // derived (findProjectRoot), not attacker-influenced covered-input data — + // routing it through a caller-scoped containment seam (e.g. #4155's + // containmentEnforcingVerificationFs, confined to `.planning/`, a proper + // SUBSET of `projectRoot`) would reject the root itself and fail every + // lookup regardless of whether the covered files are legitimate. + let realRoot: string; + try { + realRoot = fs.realpathSync(projectRoot); + } catch { + return null; + } + + const parts: string[] = []; + for (const rel of uniqueSorted) { + // `normalizeRel` (already applied by `canonicalizeCoveredFiles` above) + // collapses internal `..` segments before `rel` ever reaches here + // (`a/../../b` → `../b`), so this start-of-string check is already the + // full lexical confinement test — no separate post-`path.resolve` + // re-check can observe a different answer. + if (rel === '' || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) return null; + const resolved = path.resolve(projectRoot, rel); + let bytes: Buffer; + try { + // A regular file INSIDE projectRoot can still be a symlink whose TARGET + // escapes it — statSync/readFileSync follow symlinks, so the lexical + // confinement check above is not enough. realpathSync resolves the + // actual target; re-confining against realRoot closes that gap. + const real = fs.realpathSync(resolved); + const realRel = path.relative(realRoot, real); + if (realRel === '' || realRel === '..' || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) { + return null; + } + const st = fs.statSync(real); + if (!st.isFile()) return null; + bytes = fs.readFileSync(real); + } catch { + return null; + } + const fileHash = crypto.createHash('sha256').update(bytes).digest('hex'); + parts.push(`${rel}\n${fileHash}\n`); + } + + const aggregate = crypto + .createHash('sha256') + .update(`v${FINGERPRINT_VERSION}\n${parts.join('')}`, 'utf-8') + .digest('hex'); + return `v${FINGERPRINT_VERSION}:sha256:${aggregate}`; +} + +/** + * #4155: the content fingerprint only recomputes digests for paths the + * verifier actually DECLARED in `covered_files` — it has no way to notice a + * plan or summary added to the phase directory AFTER verification if that + * new file was never declared. This closes that gap the same way the + * legacy mtime check always did: by re-scanning the LIVE directory (not the + * declared list) for every current `*-PLAN.md`/`*-SUMMARY.md` and checking + * each is represented in `coveredFiles` — matched by suffix (mirrors + * `matchRequestedFile`'s convention) since `coveredFiles` holds + * project-root-relative paths while the scan returns phase-relative + * filenames. Returns `true` if every current plan/summary is covered, + * `false` otherwise — callers only ever branch on this pass/fail, so no + * caller needs which artifact was uncovered. + * + * Fails CLOSED on an incomplete scan: `scanPhasePlans` never throws on a + * readdir failure — it reports it via `scope` (`SCOPE.UNREADABLE` for the + * phase dir itself, `SCOPE.TRUNCATED` for an unreadable nested `plans/`) + * with whatever files it DID manage to enumerate, per `SCOPE`'s own + * contract (`planning-scope.cts`): zero items under a non-`COMPLETE` scope + * is a NON-answer, never "this phase has no plans." Branching on `scope` + * here (rather than a try/catch, which this scan never triggers) is what + * makes an unreadable `plans/` dir report `false` instead of silently + * treating its invisible contents as vacuously covered — the same + * fail-open regression #3057 B3 fixed for the legacy path. + */ +function allCurrentArtifactsCovered(phaseDir: string, coveredFiles: readonly string[]): boolean { + const scan = scanPhasePlans(phaseDir); + if (scan.scope !== SCOPE.COMPLETE) return false; + const coveredPosix = canonicalizeCoveredFiles(coveredFiles); + return [...scan.allPlanFiles, ...scan.summaryFiles].every((artifact) => { + const artifactPosix = toPosix(artifact); + return coveredPosix.some((c) => c === artifactPosix || c.endsWith(`/${artifactPosix}`)); + }); +} + /** * Match a git-emitted (repo-root-relative) path back to the caller's * phaseDir-relative request by exact match or `/`-bounded suffix — precise @@ -526,7 +702,7 @@ interface VerificationStatusResult { function findStaleVerificationSummary( phaseDir: string, - fsImpl: FsLike = fs, + fsImpl: FsLike = defaultFsImpl, phaseCleanCommitTimesMs: PhaseCleanCommitTimesFn = defaultPhaseCleanCommitTimesMs, ): StaleCheckResult { // FS errors (TOCTOU: a SUMMARY listed by scanPhasePlans then removed before statSync; @@ -611,7 +787,7 @@ function readVerificationStatus( phaseDir: string, opts: ReadVerificationStatusOptions = {}, ): VerificationStatusResult { - const fsImpl: FsLike = opts.fs ?? fs; + const fsImpl: FsLike = opts.fs ?? defaultFsImpl; const phaseCleanCommitTimesMs: PhaseCleanCommitTimesFn = opts.phaseCleanCommitTimesMs ?? defaultPhaseCleanCommitTimesMs; const runtime = opts.runtime ?? 'claude'; @@ -652,6 +828,7 @@ function readVerificationStatus( // extractFrontmatter anchors at byte 0, so body `status:` lines are ignored. const filePath = path.join(phaseDir, verificationFile); let rawStatus: string | null = null; + let fm: ReturnType = {}; try { // #3707-CR follow-up MINOR 1: normalize line endings at this read // boundary — this function's own `readFileSync` is the equivalent seam @@ -664,7 +841,7 @@ function readVerificationStatus( // verification as if the step never ran, the fail-safe direction but the // same root cause as the false-clean class fixed elsewhere in #3707-CR. const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8')); - const fm = extractFrontmatter(content, filePath); + fm = extractFrontmatter(content, filePath); const statusVal = fm['status']; // status is always a scalar string in a well-formed VERIFICATION.md frontmatter; // only accept string values — arrays and objects are not valid status values. @@ -691,8 +868,53 @@ function readVerificationStatus( }; } - const staleCheck = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs); - if (staleCheck.determined && staleCheck.stale) { + // #4155: a report that declares a covered-input fingerprint is checked by + // RECOMPUTING that fingerprint over current file content — strictly + // content-grounded, and it REPLACES (not supplements) the legacy + // SUMMARY-mtime check below for that report. A report with no fingerprint + // metadata (every report written before #4155) keeps the exact legacy + // mtime-based behavior, unchanged. + const coveredFilesVal = fm['covered_files']; + const coveredDigestVal = fm['covered_digest']; + // A report OPTS IN to the fingerprint check by declaring EITHER field — + // once opted in, an incomplete or malformed pair (one field present but + // not the other, an empty array, a non-array, a blank digest) fails closed + // to `stale` rather than silently downgrading to the weaker legacy + // mtime-only check, which would only ever notice a newer SUMMARY. + const declaresFingerprint = coveredFilesVal !== undefined || coveredDigestVal !== undefined; + const hasWellFormedFingerprint = + Array.isArray(coveredFilesVal) && + coveredFilesVal.length > 0 && + coveredFilesVal.every((f) => typeof f === 'string') && + typeof coveredDigestVal === 'string' && + coveredDigestVal.trim().length > 0; + + let staleCheckIndeterminate = false; + let isStale: boolean; + if (declaresFingerprint) { + // Stated directly rather than relying on `null !== coveredDigestVal` + // being true whenever the pair is malformed: `!hasWellFormedFingerprint` + // fails closed explicitly, and its `||` short-circuit means + // computeCoveredDigest/allCurrentArtifactsCovered never run on a + // malformed (wrong-shaped) `coveredFilesVal`. The two `||`s after it + // short-circuit in turn: the live-directory re-scan (for a plan/summary + // added AFTER verification and never declared in covered_files) only + // runs once the digest itself has already matched. + isStale = + !hasWellFormedFingerprint || + computeCoveredDigest(findProjectRoot(phaseDir), coveredFilesVal) !== coveredDigestVal || + !allCurrentArtifactsCovered(phaseDir, coveredFilesVal); + } else { + const staleCheck = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs); + isStale = staleCheck.determined && staleCheck.stale; + // staleCheck is either {determined:true, stale:false} (checked; nothing + // stale) or {determined:false} (could not check — fs/scan/clock failure). + // Both fall through to normal routing below (the pre-existing no-throw + // fail-open contract is unchanged), but the indeterminate case is flagged + // on the returned result so a caller can tell the two apart (#3057 B3). + staleCheckIndeterminate = !staleCheck.determined; + } + if (isStale) { const entry = VERIFICATION_ROUTING_TABLE['stale']; return { status: entry.status, @@ -700,12 +922,6 @@ function readVerificationStatus( next_command: projectNextCommand('verify-work', runtime, phaseArg), }; } - // staleCheck is either {determined:true, stale:false} (checked; nothing - // stale) or {determined:false} (could not check — fs/scan/clock failure). - // Both fall through to normal routing below (the pre-existing no-throw - // fail-open contract is unchanged), but the indeterminate case is flagged - // on the returned result so a caller can tell the two apart (#3057 B3). - const staleCheckIndeterminate = !staleCheck.determined; // 3. Route — exclude internal sentinels from raw-file lookup (they are // constructed internally above, never written by the verifier). @@ -783,7 +999,7 @@ function isPhaseComplete( phaseDir: string, deps: IsPhaseCompleteDeps = {}, ): { value: PhaseCompletionValue; scope: Scope } { - const fsImpl: FsLike = deps.fs ?? fs; + const fsImpl: FsLike = deps.fs ?? defaultFsImpl; let readable = true; try { fsImpl.readdirSync(phaseDir); @@ -865,6 +1081,60 @@ function cmdVerificationResolveFile(cwd: string, phaseDirArg: string | undefined output({ verification_file: verificationPath }, raw, verificationPath); } +/** + * CLI command handler (#4155): compute the covered-input fingerprint the + * verifier embeds in VERIFICATION.md frontmatter (`covered_files`, + * `covered_digest`). The verifier is an LLM agent, not a hashing engine — + * this command does the deterministic math so the agent only has to name + * the covered paths and copy the result into frontmatter. + * + * Emits `{ covered_files: , covered_digest: }` + * on success. A covered path that is missing, unreadable, or escapes the + * project root fails the WHOLE command (fail closed — a partial fingerprint + * would be worse than none): `error()` is called and nothing is emitted. + * + * @param cwd - Current working directory. + * @param phaseDirArg - Phase directory path (absolute or relative to cwd); + * its project root is the base covered paths resolve against. + * @param files - Covered-input paths, relative to the project root. + * @param raw - Whether to emit raw (non-JSON) output: just the + * `covered_digest` string, so `VAR=$(gsd_run query + * verification.fingerprint "$PHASE_DIR" ... --raw)` is + * directly assignable. `covered_files` is unambiguous + * from the caller's own input list in that mode, so + * only the computed digest needs a raw form. + */ +function cmdVerificationFingerprint( + cwd: string, + phaseDirArg: string | undefined, + files: string[], + raw: boolean, +): void { + if (!phaseDirArg) { + error('phase directory required for verification.fingerprint'); + return; + } + if (files.length === 0) { + error('at least one covered file required for verification.fingerprint'); + return; + } + const phaseDir = path.resolve(cwd, phaseDirArg); + const projectRoot = findProjectRoot(phaseDir); + // canonicalizeCoveredFiles here is for the emitted `covered_files` field — + // computeCoveredDigest canonicalizes its own `coveredFiles` argument + // internally too (it must, for callers like readVerificationStatus that + // pass raw, un-canonicalized frontmatter values), so passing an + // already-canonical list keeps that internal pass a cheap no-op rather + // than a second meaningfully different canonicalization. + const uniqueSorted = canonicalizeCoveredFiles(files); + const digest = computeCoveredDigest(projectRoot, uniqueSorted); + if (digest === null) { + error('could not compute fingerprint — a covered file is missing, unreadable, or escapes the project root'); + return; + } + output({ covered_files: uniqueSorted, covered_digest: digest }, raw, digest); +} + export = { VERIFIER_STATUSES, VERIFICATION_ROUTING_TABLE, @@ -876,4 +1146,6 @@ export = { isPhaseComplete, cmdVerificationStatus, cmdVerificationResolveFile, + computeCoveredDigest, + cmdVerificationFingerprint, }; diff --git a/tests/verification-status.test.cjs b/tests/verification-status.test.cjs index f14514ada..105b83094 100644 --- a/tests/verification-status.test.cjs +++ b/tests/verification-status.test.cjs @@ -39,7 +39,7 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, createTempGitProject } = require('./helpers.cjs'); const { runGit: seamRunGit, OUTCOME } = require('./helpers/process-seam.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); @@ -52,6 +52,7 @@ const { resolveUatFile, readVerificationStatus, findStaleVerificationSummary, + computeCoveredDigest, } = require('../gsd-core/bin/lib/verification.cjs'); // #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. @@ -1461,6 +1462,438 @@ describe('#3057 B3: staleness check — indeterminate is distinguishable from no }); }); +// ─── #4155: covered-input fingerprint ───────────────────────────────────────── +// +// A VERIFICATION.md that declares `covered_files` + `covered_digest` in its +// frontmatter is checked by RECOMPUTING that digest over current file content +// — this REPLACES the legacy SUMMARY-mtime check for that report (not merely +// supplements it). A report with no fingerprint metadata keeps the exact +// legacy mtime behavior (already covered above). + +describe('#4155: computeCoveredDigest — direct unit coverage', () => { + test('same covered set, different array order → identical digest (order-independent)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-order-')); + t.after(() => cleanup(root)); + fs.writeFileSync(path.join(root, 'a.txt'), 'A'); + fs.writeFileSync(path.join(root, 'b.txt'), 'B'); + + const d1 = computeCoveredDigest(root, ['a.txt', 'b.txt']); + const d2 = computeCoveredDigest(root, ['b.txt', 'a.txt']); + assert.equal(d1, d2); + assert.match(d1, /^v1:sha256:[0-9a-f]{64}$/); + }); + + test('a "./"-prefixed path and its bare equivalent → identical digest (canonicalized, not double-counted)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-dotslash-')); + t.after(() => cleanup(root)); + fs.writeFileSync(path.join(root, 'impl.txt'), 'content'); + + const bare = computeCoveredDigest(root, ['impl.txt']); + const dotSlash = computeCoveredDigest(root, ['./impl.txt']); + assert.equal(dotSlash, bare, '"./impl.txt" must canonicalize to the same key as "impl.txt"'); + + // Both spellings together must not double-hash the same file into the digest. + const combined = computeCoveredDigest(root, ['impl.txt', './impl.txt']); + assert.equal(combined, bare); + }); + + test('an internal ".." segment disguising an escape (not just a leading one) → null (fail closed)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-internal-dotdot-')); + t.after(() => cleanup(root)); + fs.mkdirSync(path.join(root, 'a')); + fs.writeFileSync(path.join(path.dirname(root), 'outside.txt'), 'secret'); + // 'a/../../outside.txt' does not start with '../' as written, but + // normalizes to '../outside.txt' — an escape a purely-prefix check misses. + assert.equal(computeCoveredDigest(root, ['a/../../outside.txt']), null); + }); + + test('a covered file whose content changes → digest changes', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-changed-')); + t.after(() => cleanup(root)); + const target = path.join(root, 'impl.txt'); + fs.writeFileSync(target, 'before'); + const before = computeCoveredDigest(root, ['impl.txt']); + fs.writeFileSync(target, 'after'); + const after = computeCoveredDigest(root, ['impl.txt']); + assert.notEqual(before, after); + }); + + test('a covered file that is missing → null (fail closed)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-missing-')); + t.after(() => cleanup(root)); + assert.equal(computeCoveredDigest(root, ['does-not-exist.txt']), null); + }); + + test('a covered file that is unreadable (directory, not a regular file) → null (fail closed)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-unreadable-')); + t.after(() => cleanup(root)); + fs.mkdirSync(path.join(root, 'a-directory')); + assert.equal(computeCoveredDigest(root, ['a-directory']), null); + }); + + test('a covered path that escapes the project root via ".." → null (fail closed)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-escape-')); + t.after(() => cleanup(root)); + fs.writeFileSync(path.join(path.dirname(root), 'outside.txt'), 'secret'); + assert.equal(computeCoveredDigest(root, ['../outside.txt']), null); + }); + + test('an absolute covered path → null (fail closed)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-absolute-')); + t.after(() => cleanup(root)); + const absolute = path.join(root, 'impl.txt'); + fs.writeFileSync(absolute, 'x'); + assert.equal(computeCoveredDigest(root, [absolute]), null); + }); + + test('an empty covered-files array → null', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-empty-')); + t.after(() => cleanup(root)); + assert.equal(computeCoveredDigest(root, []), null); + }); + + test('an in-root symlink whose TARGET escapes the project root → null (fail closed, not the target\'s content)', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-symlink-')); + t.after(() => cleanup(root)); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-symlink-outside-')); + t.after(() => cleanup(outside)); + const outsideFile = path.join(outside, 'secret.txt'); + fs.writeFileSync(outsideFile, 'not covered by this project'); + const linkPath = path.join(root, 'impl.txt'); + fs.symlinkSync(outsideFile, linkPath); + + assert.equal(computeCoveredDigest(root, ['impl.txt']), null); + }); + +}); + +describe('#4155: readVerificationStatus — fingerprint supersedes legacy mtime staleness', () => { + test('a covered implementation file OUTSIDE .planning/, read through a .planning/-confined opts.fs (src/planning-inspect.cts\'s exact seam) → status stays passed, not stale', () => { + // #4155 review finding (blocking): src/planning-inspect.cts:1253 injects + // containmentEnforcingVerificationFs(paths.planning) — confined to + // `.planning/` — as `opts.fs`. Before the fix, computeCoveredDigest routed + // every per-file read through that SAME injected fsImpl, so any covered + // implementation file (which the issue mandates live under `src/`, outside + // `.planning/`) made the confinement wrapper throw, which computeCoveredDigest + // caught and turned into `null`, which readVerificationStatus routed to + // `stale` — permanently, regardless of whether anything actually changed. + // This reproduces that exact call shape with a real nested .planning/ + // project (findProjectRoot must resolve past the phase dir to the real + // root) and a confinement fs scoped ONLY to .planning/, mirroring + // planning-inspect.cts's containmentEnforcingVerificationFs byte-for-byte. + const projectDir = createTempGitProject(); + try { + const planningRoot = path.join(projectDir, '.planning'); + const phaseDir = path.join(planningRoot, 'phases', '01-example'); + fs.mkdirSync(phaseDir, { recursive: true }); + const implPath = path.join(projectDir, 'src', 'impl.ts'); + fs.mkdirSync(path.dirname(implPath), { recursive: true }); + fs.writeFileSync(implPath, 'export const x = 1;\n'); + + const digest = computeCoveredDigest(projectDir, ['src/impl.ts']); + fs.writeFileSync( + path.join(phaseDir, '01-VERIFICATION.md'), + `---\nstatus: passed\ncovered_files:\n - src/impl.ts\ncovered_digest: "${digest}"\n---\n`, + ); + + // A naive lexical-prefix check (no realpath resolution) is a stricter + // stand-in for src/planning-inspect.cts's real containmentEnforcingVerificationFs + // here: it throws on strictly MORE paths than the real one (it can't + // tell a legitimate in-root path from a symlink, so it rejects both), + // which makes this test fail harder, not weaker, if the fix regresses. + function assertContained(target) { + if (!path.resolve(target).startsWith(planningRoot + path.sep)) { + throw new Error(`planning-inspect: path escapes planning root: ${target}`); + } + } + const containmentEnforcingVerificationFs = { + readdirSync: (dir) => { + assertContained(dir); + return fs.readdirSync(dir); + }, + readFileSync: (filePath, encoding) => { + assertContained(filePath); + return fs.readFileSync(filePath, encoding); + }, + statSync: (filePath) => { + assertContained(filePath); + return fs.statSync(filePath); + }, + }; + + const result = readVerificationStatus(phaseDir, { + fs: containmentEnforcingVerificationFs, + phaseCleanCommitTimesMs: () => new Map(), + }); + assert.equal(result.status, 'passed'); + } finally { + cleanup(projectDir); + } + }); + + test('unchanged covered inputs → status stays passed even though a SUMMARY is newer (legacy mtime check bypassed)', (t) => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-unchanged-')); + t.after(() => cleanup(baseDir)); + const dir = path.join(baseDir, '01-foo'); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'impl.txt'), 'implementation content'); + const summaryPath = path.join(dir, '01-01-SUMMARY.md'); + fs.writeFileSync(summaryPath, '# Summary'); + // The SUMMARY is a covered artifact too — isolates this test to the mtime + // vs. content-digest distinction, not the #4155 completeness check below. + const digest = computeCoveredDigest(dir, ['impl.txt', '01-01-SUMMARY.md']); + + fs.writeFileSync( + path.join(dir, '01-VERIFICATION.md'), + `---\nstatus: passed\ncovered_files:\n - impl.txt\n - 01-01-SUMMARY.md\ncovered_digest: "${digest}"\n---\n`, + ); + // SUMMARY newer than VERIFICATION — the LEGACY check would call this + // stale. The fingerprint check must be the one that actually runs. + setMtime(path.join(dir, '01-VERIFICATION.md'), '2026-01-01T00:00:00.000Z'); + setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); + + const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); + assert.equal(result.status, 'passed'); + }); + + test('a plan or summary added to the phase dir after verification, never declared in covered_files → stale (#4155 completeness check)', (t) => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-uncovered-artifact-')); + t.after(() => cleanup(baseDir)); + const dir = path.join(baseDir, '01-foo'); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'impl.txt'), 'content'); + const digest = computeCoveredDigest(dir, ['impl.txt']); + fs.writeFileSync( + path.join(dir, '01-VERIFICATION.md'), + `---\nstatus: passed\ncovered_files:\n - impl.txt\ncovered_digest: "${digest}"\n---\n`, + ); + // A SUMMARY appears after verification — never declared, so the recomputed + // digest over the ORIGINAL covered set still matches. Only the live + // directory re-scan can catch this. + fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), '# Summary\n'); + + const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); + assert.equal(result.status, 'stale'); + }); + + test('an unreadable nested plans/ dir fails CLOSED to stale, not open to passed (#4155 review finding: allCurrentArtifactsCovered must branch on scanPhasePlans scope, not a try/catch it never throws into)', (t) => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-scan-unreadable-')); + t.after(() => cleanup(baseDir)); + const dir = path.join(baseDir, '01-foo'); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'impl.txt'), 'content'); + const digest = computeCoveredDigest(dir, ['impl.txt']); + fs.writeFileSync( + path.join(dir, '01-VERIFICATION.md'), + `---\nstatus: passed\ncovered_files:\n - impl.txt\ncovered_digest: "${digest}"\n---\n`, + ); + const nestedDir = path.join(dir, 'plans'); + fs.mkdirSync(nestedDir); + fs.writeFileSync(path.join(nestedDir, '01-02-PLAN.md'), '# Nested plan, never declared\n'); + + // fs.chmodSync(nestedDir, 0o000) is NOT used: this suite may run as root + // (CI/Docker), where mode bits are bypassed entirely, making the test + // pass with zero real coverage. A monkeypatch is CONTRIBUTING.md's + // documented fault-injection convention for exactly this reason (mirrors + // tests/broken-windows.test.cjs's writeLedgerAtomic pre-image test). The + // compiled src/plan-scan.cjs calls `node_fs_1.readdirSync(nestedDir)` — a + // property read on the SAME node:fs module object `fs` here resolves to + // (module caching), so patching that property is visible to it. + const originalReaddirSync = fs.readdirSync; + fs.readdirSync = (target, ...rest) => { + if (path.resolve(target) === path.resolve(nestedDir)) { + throw Object.assign(new Error('EACCES: permission denied (simulated)'), { code: 'EACCES' }); + } + return originalReaddirSync(target, ...rest); + }; + try { + // Guard: the digest alone must NOT already be stale — isolates this + // test to the scan-failure branch, not a digest mismatch. + const unpatchedScan = originalReaddirSync(dir); + assert.ok(unpatchedScan.includes('plans'), 'guard: nested plans/ dir must exist on disk'); + + const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); + assert.equal( + result.status, + 'stale', + 'an unreadable plans/ dir must fail CLOSED — its invisible contents (an undeclared plan) can never be proven covered', + ); + } finally { + fs.readdirSync = originalReaddirSync; + } + }); + + test('a covered file that changed after verification → status is stale', (t) => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-stale-changed-')); + t.after(() => cleanup(baseDir)); + const dir = path.join(baseDir, '01-foo'); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'impl.txt'), 'original content'); + const digest = computeCoveredDigest(dir, ['impl.txt']); + fs.writeFileSync( + path.join(dir, '01-VERIFICATION.md'), + `---\nstatus: passed\ncovered_files:\n - impl.txt\ncovered_digest: "${digest}"\n---\n`, + ); + // Evidence drifts after verification — no SUMMARY touched at all, so the + // legacy mtime check would see nothing stale. + fs.writeFileSync(path.join(dir, 'impl.txt'), 'drifted content'); + + const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); + assert.equal(result.status, 'stale'); + assert.equal(result.next_command, '/gsd-verify-work 01'); + }); + + // Ponytail #4155 review finding: "disappeared" and "escapes confinement" + // integration tests previously here re-proved computeCoveredDigest → null + // through the identical `recomputed !== coveredDigestVal` stale branch the + // "changed" test above already wires — the null-producing mechanisms + // themselves are unit-tested directly in the computeCoveredDigest describe + // block ("a covered file that is missing", "a covered path that escapes + // the project root via .."). + + // Ponytail #4155 review finding: these three were near-identical + // fixture-copies of the same `hasWellFormedFingerprint === false` branch — + // an incomplete/malformed `covered_files`+`covered_digest` pair fails + // closed to `stale` rather than silently downgrading to the legacy check. + for (const [name, frontmatter] of [ + [ + 'covered_files present but covered_digest missing (incomplete pair)', + '---\nstatus: passed\ncovered_files:\n - impl.txt\n---\n', + ], + [ + 'covered_digest present but covered_files missing (incomplete pair)', + '---\nstatus: passed\ncovered_digest: "v1:sha256:deadbeef"\n---\n', + ], + [ + 'an empty covered_files array with covered_digest present', + '---\nstatus: passed\ncovered_files: []\ncovered_digest: "v1:sha256:deadbeef"\n---\n', + ], + ]) { + test(`${name} → stale (fail closed, not a silent legacy downgrade)`, (t) => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-malformed-fingerprint-')); + t.after(() => cleanup(baseDir)); + const dir = path.join(baseDir, '01-foo'); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), frontmatter); + + const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); + assert.equal(result.status, 'stale'); + }); + } + + test('a legacy report with no fingerprint metadata still uses the mtime check', (t) => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4155-legacy-')); + t.after(() => cleanup(baseDir)); + const dir = path.join(baseDir, '01-foo'); + fs.mkdirSync(dir); + const verificationPath = path.join(dir, '01-VERIFICATION.md'); + const summaryPath = path.join(dir, '01-01-SUMMARY.md'); + writeVerificationMd(dir, '01-VERIFICATION.md', 'passed'); + fs.writeFileSync(summaryPath, '# Summary'); + setMtime(verificationPath, '2026-01-01T00:00:00.000Z'); + setMtime(summaryPath, '2026-01-01T00:01:00.000Z'); + + const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() }); + assert.equal(result.status, 'stale', 'unchanged: legacy mtime staleness must still fire with no covered_digest'); + }); +}); + +describe('#4155: verification.fingerprint CLI', () => { + const { runGsdTools, createTempGitProject } = require('./helpers.cjs'); + + test('emits a covered_digest matching the direct computeCoveredDigest call, sorted covered_files', () => { + const projectDir = createTempGitProject(); + try { + const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); + + const res = runGsdTools( + [ + 'verification', + 'fingerprint', + phaseDir, + '.planning/phases/01-example/01-01-SUMMARY.md', + '.planning/phases/01-example/01-01-PLAN.md', + ], + projectDir, + ); + assert.equal(res.success, true, `expected success, got: ${res.output}${res.error}`); + const parsed = JSON.parse(res.output); + assert.deepEqual(parsed.covered_files, [ + '.planning/phases/01-example/01-01-PLAN.md', + '.planning/phases/01-example/01-01-SUMMARY.md', + ]); + const expected = computeCoveredDigest(projectDir, [ + '.planning/phases/01-example/01-01-PLAN.md', + '.planning/phases/01-example/01-01-SUMMARY.md', + ]); + assert.equal(parsed.covered_digest, expected); + } finally { + cleanup(projectDir); + } + }); + + test('a missing covered file fails the whole command (fail closed, no partial fingerprint)', () => { + const projectDir = createTempGitProject(); + try { + const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example'); + fs.mkdirSync(phaseDir, { recursive: true }); + + const res = runGsdTools( + ['verification', 'fingerprint', phaseDir, 'does-not-exist.md'], + projectDir, + ); + assert.equal(res.success, false); + } finally { + cleanup(projectDir); + } + }); + + test('end-to-end through a real nested .planning/ project: a project-root-relative src/ file is covered, resolved, and hashed correctly', () => { + // #4155 review finding: unit fixtures elsewhere in this file put phaseDir + // directly under an ownerless tmpdir, so findProjectRoot(phaseDir) falls + // back to phaseDir itself and never exercises real multi-level + // resolution. This test uses a genuine `.planning/phases/NN-x/` tree + // under a real project root, and covers an implementation file OUTSIDE + // `.planning/` entirely — the exact shape a live verifier agent produces. + const projectDir = createTempGitProject(); + try { + const phaseDir = path.join(projectDir, '.planning', 'phases', '01-example'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); + fs.mkdirSync(path.join(projectDir, 'src')); + fs.writeFileSync(path.join(projectDir, 'src', 'thing.cts'), 'export const x = 1;\n'); + + const coveredFiles = [ + '.planning/phases/01-example/01-01-PLAN.md', + '.planning/phases/01-example/01-01-SUMMARY.md', + 'src/thing.cts', + ]; + const fpRes = runGsdTools(['verification', 'fingerprint', phaseDir, ...coveredFiles], projectDir); + assert.equal(fpRes.success, true, `expected success, got: ${fpRes.output}${fpRes.error}`); + const { covered_files: sortedCovered, covered_digest: digest } = JSON.parse(fpRes.output); + + fs.writeFileSync( + path.join(phaseDir, '01-VERIFICATION.md'), + `---\nstatus: passed\ncovered_files:\n${sortedCovered.map((f) => ` - ${f}`).join('\n')}\ncovered_digest: "${digest}"\n---\n`, + ); + + const passing = readVerificationStatus(phaseDir, { phaseCleanCommitTimesMs: () => new Map() }); + assert.equal(passing.status, 'passed'); + + // Now edit the implementation file OUTSIDE .planning/ — must go stale. + fs.writeFileSync(path.join(projectDir, 'src', 'thing.cts'), 'export const x = 2;\n'); + const afterEdit = readVerificationStatus(phaseDir, { phaseCleanCommitTimesMs: () => new Map() }); + assert.equal(afterEdit.status, 'stale'); + } finally { + cleanup(projectDir); + } + }); +}); + // ─── #2617: next_command runtime projection ────────────────────────────────── // // Regression tests for #2617 — verification-status `next_command` bypassed the