enhance(#4155): invalidate verification results when covered inputs change (#4290)

* enhance(#4155): invalidate verification results when covered inputs change

readVerificationStatus() now recomputes a deterministic sha256 fingerprint
over a VERIFICATION.md's declared covered_files (phase PLAN/SUMMARY,
requirements, implementation files in the verified change set) and returns
stale on any mismatch, fail-closed when a covered file is missing,
unreadable, or escapes the project root. Legacy reports with no fingerprint
metadata keep the prior SUMMARY-mtime staleness check unchanged.

The verifier computes covered_digest via the new verification.fingerprint
CLI command rather than by hand, since a digest is deterministic math, not
an LLM-estimated value.

* chore(#4155): backfill fork PR number in changeset

* fix(#4155): trim gsd-verifier.md fingerprint instructions to fit LARGE tier byte cap

* fix(#4155): address CodeRabbit findings on fingerprint fail-closed behavior

Partial fingerprint metadata (one of covered_files/covered_digest present,
the other missing or malformed) now fails closed to stale instead of
silently downgrading to the legacy mtime-only check. computeCoveredDigest
also canonicalizes with realpathSync before re-confining, so an in-root
symlink whose target escapes the project root can no longer produce a
matching digest. gsd-verifier.md restores the completeness requirement and
checklist item trimmed by the earlier size-budget fix, within the LARGE
tier byte cap.

* chore(#4155): acknowledge gsd-verifier.md growth for the #4155 fingerprint instructions

Emitted-Drift-Ack-Growth: gsd-verifier.md — adds the covered-input fingerprint instructions and frontmatter fields the #4155 verification staleness mechanism requires; trimmed to stay within the LARGE tier byte cap

* fix(#4155): address gemini adversarial review findings

computeCoveredDigest now threads the caller-supplied opts.fs seam through
its confinement and read paths instead of always using raw node:fs — a
caller like planning-inspect.cts's containmentEnforcingVerificationFs (GAP
2, #2790 follow-up) was silently bypassed for covered-input reads. The
project-root anchor itself still canonicalizes through real fs (it is a
trusted value the caller derived, not attacker-influenced covered-input
data); only per-file candidate reads go through the injected seam.

Covered-file paths are now canonicalized (./ prefixes, redundant slashes,
internal .. segments) before becoming dedup/sort/hash keys or confinement
subjects — closes both a spurious-stale false positive (two spellings of
the same file hashing differently) and a confinement gap (an internal ..
segment that doesn't start the string).

gsd-verifier.md now states covered-file paths are project-root-relative,
not phaseDir-relative, closing an ambiguity that would have made a real
verifier agent's first fingerprint invocation fail closed.

defaultFsImpl's methods now late-bind through fs.<method> rather than
capturing function references at module load — the earlier direct-capture
form was invisible to existing tests' t.mock.method(fs, 'statSync', ...)
seams, a real regression caught by the full suite (not the reviewer).

* fix(#4155): catch a plan/summary added to the phase dir after verification but never declared

The content digest only recomputes hashes for paths the verifier actually
declared in covered_files — it had no way to notice a plan or summary
added to the phase directory after verification if that new file was
never declared, silently regressing behind the legacy mtime check it
replaces (which scans the live directory, not a declared list).

findUncoveredCurrentArtifact re-scans the live phase directory for every
current *-PLAN.md/*-SUMMARY.md and requires each to be represented in
covered_files, closing that gap; a directory scan failure fails closed to
stale rather than silently skipping the check.

CONTEXT.md's Verification Module entry corrected to describe the
fingerprint path's stricter fail-closed FS-error contract (routes to
stale) instead of the module's original degrade-to-safe one (missing /
not-stale), which only the legacy path still keeps.

* refactor(#4155): extract canonicalizeCoveredFiles, add real nested-project e2e test

computeCoveredDigest and cmdVerificationFingerprint each normalized/deduped/
sorted covered_files independently — one shared helper now backs both
(gemini review's ponytail-lens finding).

Adds one CLI-to-readVerificationStatus test against a genuine
.planning/phases/NN-x/ project with an implementation file outside
.planning/ entirely, closing the review finding that prior #4155 unit
fixtures put phaseDir directly under an ownerless tmpdir (findProjectRoot
falls back to phaseDir itself there) and never exercised real multi-level
path resolution.

* fix(#4155): route computeCoveredDigest through real fs, fail closed on unreadable plans/

Two independent review rounds (opus critical-reviewer + opus ponytail +
agy, run twice) found two instances of the same fail-open class:

- computeCoveredDigest's per-file reads routed through the caller's
  injected fsImpl. planning-inspect.cts passes a `.planning/`-confined
  containment fs into readVerificationStatus's opts.fs, so any covered
  implementation file outside `.planning/` (mandatory per the issue)
  made the confinement wrapper throw, which was caught and turned into
  a stale digest -- reporting every fingerprinted phase permanently
  stale via `planning.inspect`, regardless of actual drift. Per-file
  reads now always use real node:fs, matching the pre-existing
  treatment of root canonicalization; the realRel-vs-realRoot check is
  the real confinement boundary for this data and needs no seam.

- allCurrentArtifactsCovered's try/catch never fired (scanPhasePlans
  reports readdir failures via a `scope` field, it never throws), so
  an unreadable nested plans/ dir was silently treated as "zero
  artifacts, all covered" instead of failing closed. Now branches on
  scope !== SCOPE.COMPLETE.

Also, per ponytail's second-round findings: reverted an unwarranted
FINGERPRINT_VERSION bump and digest length-prefix from the first fix
(no v1 digest has ever existed -- the feature is unreleased -- and the
prefix closed a collision that grants no capability beyond what a
writer of covered_files already has more cheaply); removed a
verifier-facing escape-hatch instruction whose own example was a case
that should trigger staleness, not bypass it; corrected CONTEXT.md
references to the renamed allCurrentArtifactsCovered and a stale
"unconditional" rescan claim; simplified the isStale derivation,
removed dead FsLike members, and tightened test coverage.

Regression tests for both fail-open bugs are included and were each
confirmed to fail against the pre-fix code before the fix landed.

full test suite: 2558/2560 pass, 2 skipped, 0 fail

* fix(#4155): trim gsd-verifier.md under the LARGE size cap

Fork CI caught what my local runs missed: the superseded/nested-plans
instruction added earlier pushed gsd-verifier.md to 49299 bytes,
147 over the LARGE tier's 49152-byte hard cap
(tests/agent-size-budget.test.cjs). Tightened the #4155 instruction's
wording and dropped a redundant inline comment tag; no content lost.

* chore(#4155): point changeset at the upstream PR number

pr: 19 was the fork PR opened for internal review-lane CI; now that
open-gsd/gsd-core#4290 exists, the changeset field must match it per
CONTRIBUTING.md's release-notes convention.

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Dennis Alexis Valin Dittrich
2026-09-05 11:42:52 +02:00
committed by GitHub
parent 5ad9a36f35
commit 5869febb16
9 changed files with 753 additions and 18 deletions

View File

@@ -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)

View File

@@ -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 <phaseDir> <file>...`, 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`

View File

@@ -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 <div>No messages</div> // 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)
</success_criteria>

View File

@@ -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:

View File

@@ -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"

View File

@@ -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);
},

View File

@@ -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),
},
});
}

View File

@@ -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.<method>` 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<typeof extractFrontmatter> = {};
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: <sorted deduped paths>, covered_digest: <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,
};

View File

@@ -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