* 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>
1152 lines
55 KiB
TypeScript
1152 lines
55 KiB
TypeScript
/**
|
||
* Verification Status — single queryable home for verification-status routing.
|
||
*
|
||
* Issue #651: consolidate the pass/gaps_found/human_needed routing that was
|
||
* previously scattered across ship.md and execute-phase.md into a single
|
||
* tested module. Both workflow files will later consume this module's routing
|
||
* table as the single source of truth.
|
||
*
|
||
* ADR-457 build-at-publish: source in src/verification.cts, compiled to
|
||
* gsd-core/bin/lib/verification.cjs (gitignored).
|
||
*
|
||
* DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix: status extraction is scoped to
|
||
* the leading YAML frontmatter block only. A `status:` line in the body (e.g.
|
||
* inside a fenced code block) is ignored — this is the exact failure mode that
|
||
* issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
|
||
* its regex at byte 0 of the document, which provides this guarantee.
|
||
*
|
||
* #2348 staleness signal: whether a *-VERIFICATION.md is stale (a summary newer
|
||
* than it) is decided from git commit time when a file is committed AND clean,
|
||
* and from filesystem mtime otherwise. mtimes are assigned at checkout time and
|
||
* are not preserved by `git clone` / `cp -R`, and any unrelated `touch` /
|
||
* reformat / editor-save re-stales a valid report — so a committed phase could
|
||
* read `passed` on one machine and `stale` on a fresh clone purely from checkout
|
||
* order. Git commit time is content-tied and clone-stable; mtime is retained
|
||
* only for uncommitted or working-tree-dirty files, where it is the true
|
||
* last-changed signal. Both are real wall-clock change times, so the comparison
|
||
* is sound even when one file uses each.
|
||
*/
|
||
|
||
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
|
||
import phaseId = require('./phase-id.cjs');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||
import frontmatterMod = require('./frontmatter.cjs');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
||
import scanPhasePlans = require('./plan-scan.cjs');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
||
import coreUtilsMod = require('./core-utils.cjs');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
|
||
import planningScopeMod = require('./planning-scope.cjs');
|
||
import { execGit } from './shell-command-projection.cjs';
|
||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||
|
||
const { output, error } = io;
|
||
const { extractPhaseToken, scopeToPhase } = phaseId;
|
||
const { extractFrontmatter } = frontmatterMod;
|
||
const { normalizeLineEndings } = coreUtilsMod;
|
||
const { SCOPE } = planningScopeMod;
|
||
type Scope = planningScopeMod.Scope;
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
/** The set of status values that the gsd-verifier agent emits. */
|
||
const VERIFIER_STATUSES: ReadonlyArray<string> = ['passed', 'gaps_found', 'human_needed'];
|
||
|
||
// ─── Routing table ────────────────────────────────────────────────────────────
|
||
|
||
interface VerificationRoute {
|
||
status: string;
|
||
next_action: string;
|
||
next_command: string;
|
||
}
|
||
|
||
/**
|
||
* Canonical routing table for verification statuses.
|
||
*
|
||
* This is the single source of truth — ship.md and execute-phase.md will
|
||
* later import from here instead of embedding their own message strings.
|
||
*
|
||
* INTERNAL SENTINELS: 'missing' and 'unknown' are operational states constructed
|
||
* internally — the verifier (gsd-verifier.md) never emits them. The verifier only
|
||
* emits values in VERIFIER_STATUSES (passed|gaps_found|human_needed). The guard in
|
||
* readVerificationStatus excludes 'missing' and 'unknown' from raw-status table
|
||
* lookup so they can only be reached via internal construction paths.
|
||
*
|
||
* For 'gaps_found', next_command is built at call time in readVerificationStatus
|
||
* by substituting the phase number — it is NOT stored as a function in the table.
|
||
*
|
||
* #2617: `next_command` here holds a BARE command name (`execute-phase`), never a
|
||
* prefixed one. Every return path projects it through `formatGsdSlash` with the
|
||
* caller's runtime, so Codex sees `$gsd-execute-phase` and slash-hyphen runtimes
|
||
* see `/gsd-execute-phase`. Storing a prefixed literal is what leaked the
|
||
* hard-coded (and deprecated) `/gsd:` colon form to every runtime.
|
||
*/
|
||
const VERIFICATION_ROUTING_TABLE: Record<string, VerificationRoute> = {
|
||
passed: {
|
||
status: 'passed',
|
||
next_action: 'Verification passed — continue.',
|
||
next_command: '',
|
||
},
|
||
gaps_found: {
|
||
status: 'gaps_found',
|
||
next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
|
||
// next_command is computed at call time; this entry is never returned directly.
|
||
next_command: '',
|
||
},
|
||
human_needed: {
|
||
status: 'human_needed',
|
||
next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
|
||
// #2617: was '' — next_action told the user to "re-run the verify step" but
|
||
// named no command, while init.cts's parallel projector emitted
|
||
// `verify-work <N>` for this same state. The two surfaces disagreed on
|
||
// whether a next command existed at all; init's answer was the useful one,
|
||
// and init now delegates here rather than re-deriving it.
|
||
next_command: 'verify-work',
|
||
},
|
||
stale: {
|
||
status: 'stale',
|
||
next_action: 'Verification is stale. Re-run verify-work before transition.',
|
||
next_command: '',
|
||
},
|
||
// INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
|
||
// the file has no parseable frontmatter status. Never emitted by the verifier.
|
||
missing: {
|
||
status: 'missing',
|
||
next_action: 'No verification report found — the verify step never completed. Running execute-phase is safe here: it resumes at the verification gates and does not re-run plans that already have a SUMMARY.md (see #2868).',
|
||
next_command: 'execute-phase',
|
||
},
|
||
// INTERNAL SENTINEL: constructed when the file has a status value not in
|
||
// VERIFIER_STATUSES. Never emitted by the verifier.
|
||
unknown: {
|
||
status: 'unknown',
|
||
next_action: '', // filled in dynamically with the raw value
|
||
next_command: 'execute-phase',
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Project a BARE command name (plus optional argument tail) into the surface the
|
||
* given runtime actually installs (#2617).
|
||
*
|
||
* `formatGsdSlash` owns the per-runtime shape (`$gsd-<cmd>` for shell-var
|
||
* runtimes like Codex, `/gsd-<cmd>` otherwise) and is idempotent, so passing an
|
||
* already-prefixed string is safe. An empty command stays empty — "no next
|
||
* command" must not become a bare prefix.
|
||
*/
|
||
function projectNextCommand(bare: string, runtime: string, tail = ''): string {
|
||
if (!bare) return '';
|
||
return `${formatGsdSlash(bare, runtime) as string}${tail}`;
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
interface FsLike {
|
||
readdirSync(dir: string): string[];
|
||
readFileSync(filePath: string, encoding: 'utf-8'): string;
|
||
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
|
||
* is distinct from `determined:true, stale:false`, which means the check ran
|
||
* to completion and genuinely found nothing stale. Collapsing the two (the
|
||
* pre-#3057 behavior: both returned `null`) let a disk-scan failure silently
|
||
* report "not stale" — the same fail-open shape as #3050. (#3057 B3)
|
||
*/
|
||
type StaleCheckResult =
|
||
| { determined: true; stale: true; verificationFile: string; summaryFile: string }
|
||
| { determined: true; stale: false }
|
||
| { determined: false };
|
||
|
||
/**
|
||
* Resolve the git commit time (epoch-ms) for each of `files` (paths relative to
|
||
* `phaseDir`) that is BOTH committed AND clean (its working-tree content matches
|
||
* HEAD), keyed by the given relative path. A file that is dirty, untracked,
|
||
* uncommitted, or in a non-repo is simply absent — callers then time it by its
|
||
* filesystem mtime. Injectable so tests exercise the clock without git. (#2348)
|
||
*/
|
||
type PhaseCleanCommitTimesFn = (phaseDir: string, files: string[]) => Map<string, number>;
|
||
|
||
/** Normalize separators to posix (git emits `/`; callers may pass `\` on Windows). */
|
||
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
|
||
* enough that a root file and a nested `plans/` file can never collide (a plain
|
||
* basename match could). Returns the original caller-form file string, or null.
|
||
*/
|
||
function matchRequestedFile(gitPath: string, requested: string[], requestedPosix: string[]): string | null {
|
||
const g = toPosix(gitPath);
|
||
for (let i = 0; i < requested.length; i++) {
|
||
const want = requestedPosix[i];
|
||
if (g === want || g.endsWith('/' + want)) return requested[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Parse `git log --format=%ct --name-only` output into file → most-recent commit
|
||
* time (ms). Output is reverse-chronological, so a file's FIRST appearance
|
||
* top-down is its latest commit. `%ct` headers are pure digits; path lines
|
||
* contain a `.` (the `.md` extension) — so the two are unambiguous.
|
||
*/
|
||
function parseCommitTimes(
|
||
stdout: string,
|
||
requested: string[],
|
||
requestedPosix: string[],
|
||
): Map<string, number> {
|
||
const out = new Map<string, number>();
|
||
let currentCt: number | null = null;
|
||
for (const line of stdout.split('\n')) {
|
||
if (line.length === 0) continue;
|
||
if (/^\d+$/.test(line)) {
|
||
currentCt = Number.parseInt(line, 10);
|
||
continue;
|
||
}
|
||
if (currentCt === null) continue;
|
||
const rel = matchRequestedFile(line, requested, requestedPosix);
|
||
if (rel !== null && !out.has(rel)) out.set(rel, currentCt * 1000);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Default resolver: two bounded git calls per phase (never one-per-file — #2348 /
|
||
* "Unbounded Subprocesses"; readVerificationStatus runs per-phase in the
|
||
* init/roadmap listing loops, so per-file spawning would fan out to P×(S+1)):
|
||
*
|
||
* 1. `git log --first-parent --format=%ct --name-only -- <files…>` for commit
|
||
* times. `--first-parent` makes merge commits report their (first-parent)
|
||
* file lists — plain `--name-only` omits merge diffs, which would silently
|
||
* under-date content that landed via a conflict-resolving merge.
|
||
* 2. `git diff --name-only HEAD -- <files…>` to drop any file whose working
|
||
* tree has diverged from HEAD: a committed-then-edited file must be timed by
|
||
* its mtime (the edit), never by its now-stale commit time.
|
||
*
|
||
* Paths pass after `--` so a dash-prefixed filename cannot be read as a flag. Any
|
||
* non-answer (no repo, no commits, missing git) yields an empty map → the caller
|
||
* times every file by mtime. Never throws. The per-phase file list is small (a
|
||
* verification report + a handful of summaries), so the argv stays far below the
|
||
* Windows 32K limit. `execGitFn` is injectable so the two-call error handling is
|
||
* unit-testable without spawning git.
|
||
*/
|
||
type ExecGitFn = typeof execGit;
|
||
|
||
function defaultPhaseCleanCommitTimesMs(
|
||
phaseDir: string,
|
||
files: string[],
|
||
execGitFn: ExecGitFn = execGit,
|
||
): Map<string, number> {
|
||
if (files.length === 0) return new Map();
|
||
const requestedPosix = files.map(toPosix);
|
||
|
||
const logRes = execGitFn(['log', '--first-parent', '--format=%ct', '--name-only', '--', ...files], {
|
||
cwd: phaseDir,
|
||
});
|
||
if (logRes.error || logRes.exitCode !== 0 || logRes.stdout.length === 0) return new Map();
|
||
const commitTimes = parseCommitTimes(logRes.stdout, files, requestedPosix);
|
||
if (commitTimes.size === 0) return commitTimes;
|
||
|
||
// Drop dirty files (working tree ≠ HEAD) so their mtime is used instead. If the
|
||
// dirty-check itself is INCONCLUSIVE (git diff errored / non-zero — as opposed
|
||
// to "ran and reported no dirty files"), we cannot prove any file is clean, so
|
||
// fail SAFE: discard the commit times and let every file fall back to mtime,
|
||
// the same direction as a git-log failure. Trusting possibly-stale commit times
|
||
// here would silently mask a real edit (false "not stale"). (#2348)
|
||
const diffRes = execGitFn(['diff', '--name-only', 'HEAD', '--', ...files], { cwd: phaseDir });
|
||
if (diffRes.error || diffRes.exitCode !== 0) return new Map();
|
||
for (const line of diffRes.stdout.split('\n')) {
|
||
if (line.length === 0) continue;
|
||
const rel = matchRequestedFile(line, files, requestedPosix);
|
||
if (rel !== null) commitTimes.delete(rel);
|
||
}
|
||
return commitTimes;
|
||
}
|
||
|
||
/**
|
||
* Build a 'missing' result from the routing table.
|
||
* Used for two early-return paths: no *-VERIFICATION.md file found, and
|
||
* file present but no parseable frontmatter status.
|
||
*/
|
||
function missingResult(runtime: string, phaseArg: string): VerificationStatusResult {
|
||
const route = VERIFICATION_ROUTING_TABLE['missing'];
|
||
return {
|
||
status: route.status,
|
||
next_action: route.next_action,
|
||
next_command: projectNextCommand(route.next_command, runtime, phaseArg),
|
||
};
|
||
}
|
||
|
||
interface ResolveVerificationFileOptions {
|
||
/**
|
||
* #3473 F2: three OTHER hand-rolled selection sites (`src/commands.cts`
|
||
* determinePhaseStatus and two `verification_path` projectors in
|
||
* `src/init.cts`) additionally accept a BARE `VERIFICATION.md` — a form
|
||
* this module's own two callers (`findStaleVerificationSummary`,
|
||
* `readVerificationStatus`) have never accepted, because a bare filename
|
||
* carries no phase token and `.endsWith('-VERIFICATION.md')` structurally
|
||
* excludes it. Defaults to `false`, which is byte-for-behavior identical to
|
||
* the pre-existing (non-optioned) resolver — no call-site edit required for
|
||
* the two callers in THIS module. Set `true` only from a call site whose
|
||
* pre-fix behavior already accepted a bare match.
|
||
*/
|
||
allowBare?: boolean;
|
||
/**
|
||
* #3492 regression fix: the phase token (`extractPhaseToken` on the phase
|
||
* directory's own basename — same grammar `src/phase-id.cts` owns via
|
||
* `PHASE_NUMBER_TOKEN_SOURCE`) THIS call is resolving for. Every call site
|
||
* knows its own phaseDir, so every call site can derive and pass this.
|
||
*
|
||
* Pinning selection to the caller's own phase is load-bearing: preferring
|
||
* ANY canonically-shaped `<token>-VERIFICATION.md` (regardless of whose
|
||
* token it carries) let a stray cross-phase or sentinel-numbered file
|
||
* (`999-VERIFICATION.md`) outrank the phase's own non-canonical report
|
||
* (`12-review-VERIFICATION.md`) — a regression this option closes.
|
||
*
|
||
* Omitted / empty when the token cannot be derived: falls back to plain
|
||
* alphabetically-first among the SCOPED dashed candidates (see
|
||
* `phaseDirName` below), never to null.
|
||
*/
|
||
phaseToken?: string;
|
||
/**
|
||
* #3511 reconciliation: the phase directory's own basename (the same value
|
||
* every call site already passes through `extractPhaseToken` to derive
|
||
* `phaseToken` above) — needed separately because the fallback below scopes
|
||
* by `isPhaseArtifact(fileName, phaseDirName)`, not by `phaseToken`.
|
||
*
|
||
* Omitted: the fallback degrades to the plain (unscoped) alphabetically-first
|
||
* pick — the original pre-#3357 behavior — never to null.
|
||
*/
|
||
phaseDirName?: string;
|
||
}
|
||
|
||
/**
|
||
* #3518: `resolveUatFile`'s options — same two knobs, same semantics, as
|
||
* `ResolveVerificationFileOptions` above (the UAT artifact is selected by the
|
||
* identical phase-pinned rule the verification report is; see
|
||
* `resolvePhaseArtifactFile` below for the single shared selection core).
|
||
*/
|
||
type ResolveUatFileOptions = ResolveVerificationFileOptions;
|
||
|
||
/**
|
||
* #3518: the shared phase-pinned artifact-selection core BOTH single-pick
|
||
* resolvers (`resolveVerificationFile` for `*-VERIFICATION.md`,
|
||
* `resolveUatFile` for `*-UAT.md`) delegate to — one rule, not two grammars
|
||
* that agree today and drift tomorrow (epic #3473 F2's defect class).
|
||
*
|
||
* `bareName` is the artifact filename WITHOUT the leading dash (`'UAT.md'`);
|
||
* a "dashed" candidate is any entry ending `-${bareName}`.
|
||
*
|
||
* Selection order:
|
||
* 1. `options.phaseToken` given and `<phaseToken>-${bareName}` is among
|
||
* the candidates — that exact file always wins: it is THIS phase's own
|
||
* artifact, and no other candidate (whichever phase's token it carries)
|
||
* can outrank it (#3492 / #3518).
|
||
* 2. Fallback — no exact phase-token match (or no token given): alphabetically
|
||
* first of the dashed candidates that are THIS phase's own, per
|
||
* `scopeToPhase(candidates, options.phaseDirName)` (#3511 reconciliation,
|
||
* below). Load-bearing: a phase whose only artifact is non-canonically
|
||
* named must keep resolving to it, not to null — this fix must not turn
|
||
* "found an artifact" into "found nothing" for anyone. A
|
||
* non-canonically-named artifact of THIS phase (e.g.
|
||
* `03-CORRECTION-VERIFICATION.md` in `03-foo`) still passes
|
||
* `isPhaseArtifact` (it names phase 03, same as the directory), so it
|
||
* is still returned here.
|
||
* 3. `options.allowBare` only — a bare `${bareName}`, ranked BELOW both
|
||
* of the above. Rationale: a dashed file names its phase, a bare one
|
||
* does not, so a dashed file (canonical or not) is always the better
|
||
* answer when both exist. Reached when neither (1) nor (2) found any
|
||
* candidate — including when (2)'s scoping filtered every dashed
|
||
* candidate out as belonging to some OTHER phase.
|
||
*
|
||
* #3511 RECONCILIATION with `isPhaseArtifact` (`src/phase-id.cts`): that
|
||
* predicate's own docblock used to flag this fallback as an open gap — its
|
||
* aggregate scans exclude a cross-phase stray, but this single-pick resolver
|
||
* did not, so it could return a stray as THE artifact while the aggregate
|
||
* scans correctly ignored it. Closed by scoping step (2) above through
|
||
* `scopeToPhase` (`src/phase-id.cts`, itself built on `isPhaseArtifact`):
|
||
* `options.phaseDirName` threads the phase directory's basename in, and the
|
||
* fallback now filters candidates through `scopeToPhase(candidates,
|
||
* phaseDirName)` before picking alphabetically-first. This does NOT reopen
|
||
* the #3357 guarantee — that guarantee is "a phase whose only report is
|
||
* non-canonically named must keep working", and a non-canonically-named
|
||
* artifact of THIS phase still passes `isPhaseArtifact` (it is membership by
|
||
* phase number, not by canonical shape), so it is still returned. Only a
|
||
* file belonging to a DIFFERENT phase is now excluded — and excluding it is
|
||
* correct: returning another phase's artifact as this phase's own is worse
|
||
* than reporting none (confidently wrong beats honestly empty).
|
||
* The fail-safe now lives entirely inside `isPhaseArtifact`, not in
|
||
* `scopeToPhase` (which is a plain filter with no unfiltered fallback):
|
||
* (a) when phase-number membership cannot be determined for `phaseDirName` at
|
||
* all (no reliable token — the zero-token directory case), every candidate is
|
||
* treated as belonging to the phase; (b) the `firstLetterPrefixed`
|
||
* bracket-ambiguity case, where a letter-prefixed-decimal dir is
|
||
* string-indistinguishable from a bracket-dir token, also includes
|
||
* everything rather than guess; (c) a token-less filename (bare
|
||
* `${bareName}`) is accepted by directory containment alone. Outside
|
||
* those cases, when scoping DOES remove every dashed candidate — a real
|
||
* cross-phase stray, or a phase whose own artifact is genuinely absent — the
|
||
* fallback below correctly falls through to `allowBare`/`null`: reporting no
|
||
* artifact, not another phase's. `options.phaseDirName` omitted entirely skips
|
||
* the filter outright (the ternary below), which is unscoped, pre-#3511
|
||
* behavior.
|
||
*
|
||
* Pure — takes an already-read directory listing and does no I/O of its own,
|
||
* so every call site keeps its existing `fsImpl` seam and no-throw contract
|
||
* untouched.
|
||
*/
|
||
function resolvePhaseArtifactFile(
|
||
entries: string[],
|
||
bareName: string,
|
||
options: ResolveVerificationFileOptions = {},
|
||
): string | null {
|
||
const candidates = entries.filter((f) => f.endsWith(`-${bareName}`)).sort();
|
||
if (candidates.length > 0) {
|
||
if (options.phaseToken) {
|
||
const thisPhaseFile = `${options.phaseToken}-${bareName}`;
|
||
if (candidates.includes(thisPhaseFile)) return thisPhaseFile;
|
||
}
|
||
// #3511: scope the fallback to files that belong to THIS phase, so a
|
||
// stray cross-phase file can no longer outrank a return of null.
|
||
// `phaseDirName` omitted, or membership undeterminable for it, →
|
||
// unscoped `candidates` (pre-#3511 behavior); otherwise strays are
|
||
// filtered out, and if that leaves nothing the code falls through to
|
||
// `allowBare`/`null` deliberately.
|
||
const scoped = options.phaseDirName
|
||
? scopeToPhase(candidates, options.phaseDirName)
|
||
: candidates;
|
||
if (scoped.length > 0) return scoped[0];
|
||
}
|
||
if (options.allowBare && entries.includes(bareName)) return bareName;
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Resolve which `*-VERIFICATION.md` entry in a phase directory's listing IS
|
||
* the phase's verification report, when more than one such file exists.
|
||
*
|
||
* #3357: a phase dir can legitimately hold more than one `*-VERIFICATION.md`
|
||
* — the real per-phase report (`03-VERIFICATION.md`) alongside an ad-hoc plan
|
||
* worksheet (`03-CORRECTION-VERIFICATION.md`). Picking "alphabetically first"
|
||
* (`'C' < 'V'`) silently chose the worksheet, which usually has no
|
||
* frontmatter `status:`, so a phase with a PASSING report read as `missing`.
|
||
* This was two independent hand-rolled `.sort()[0]` picks
|
||
* (findStaleVerificationSummary and readVerificationStatus) — this is the
|
||
* single resolver both now call (#3473 F2).
|
||
*
|
||
* Selection order: see `resolvePhaseArtifactFile` (the shared core this
|
||
* delegates to since #3518, itself phase-scoped since #3511) —
|
||
* phase-token-pinned, then phase-scoped alphabetically-first dashed
|
||
* fallback, then (allowBare only) a bare `VERIFICATION.md`. #3518 extracted
|
||
* this into the shared core without changing behavior; #3511's
|
||
* `phaseDirName` scoping now lives inside that shared core rather than here.
|
||
*/
|
||
function resolveVerificationFile(
|
||
entries: string[],
|
||
options: ResolveVerificationFileOptions = {},
|
||
): string | null {
|
||
return resolvePhaseArtifactFile(entries, 'VERIFICATION.md', options);
|
||
}
|
||
|
||
/**
|
||
* #3518: resolve which `*-UAT.md` entry in a phase directory's listing IS
|
||
* the phase's UAT artifact, when more than one such file exists — the UAT
|
||
* counterpart of `resolveVerificationFile`, sharing its exact selection rule
|
||
* via `resolvePhaseArtifactFile`.
|
||
*
|
||
* The bug this closes: both `uat_path` projectors in `src/init.cts` picked
|
||
* with a bare `.find((f) => f.endsWith('-UAT.md') || f === 'UAT.md')` over an
|
||
* unsorted `readdir` listing — no phase-membership check and no ordering — so
|
||
* a stray or cross-phase `04-UAT.md` sitting in phase 03's directory could
|
||
* become phase 03's `uat_path`, and WHICH file won was filesystem-dependent
|
||
* (creation order on APFS, hash order on ext4/XFS): two machines on the same
|
||
* commit could emit different `uat_path` values for the same phase. `uat_path`
|
||
* is consumed downstream by workflows that then read the named file, so a
|
||
* wrong path routes UAT state from another phase.
|
||
*
|
||
* Deterministic by construction: same answer on every machine. Phase-scoped
|
||
* (#3511): passing `options.phaseDirName` filters the alphabetically-first
|
||
* fallback (tier 2) to artifacts that belong to THIS phase — see
|
||
* `resolvePhaseArtifactFile` for the full selection order and scoping
|
||
* rationale.
|
||
*/
|
||
function resolveUatFile(
|
||
entries: string[],
|
||
options: ResolveUatFileOptions = {},
|
||
): string | null {
|
||
return resolvePhaseArtifactFile(entries, 'UAT.md', options);
|
||
}
|
||
|
||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||
|
||
interface ReadVerificationStatusOptions {
|
||
fs?: FsLike;
|
||
/** Injectable per-phase clean-commit-time resolver for the staleness clock (#2348). */
|
||
phaseCleanCommitTimesMs?: PhaseCleanCommitTimesFn;
|
||
/**
|
||
* Runtime whose command surface `next_command` is projected into (#2617).
|
||
* Callers that have a cwd should pass `resolveRuntime(cwd)`. Defaults to
|
||
* `'claude'`, which yields the canonical `/gsd-<cmd>` hyphen form — never the
|
||
* deprecated `/gsd:` colon form this field used to hard-code.
|
||
*/
|
||
runtime?: string;
|
||
/**
|
||
* Phase number appended to the routed command (#2617). Defaults to the token
|
||
* parsed from `phaseDir`, but only when that token is unambiguously numeric.
|
||
* Callers that already know the number pass it explicitly — `init` reaches
|
||
* this with `phaseDir` unresolved in some branches.
|
||
*/
|
||
phaseNumber?: string;
|
||
}
|
||
|
||
interface VerificationStatusResult {
|
||
status: string;
|
||
next_action: string;
|
||
next_command: string;
|
||
/**
|
||
* True when the internal staleness check (findStaleVerificationSummary)
|
||
* could not run to completion (an fs / scanPhasePlans / clock failure) —
|
||
* `status` above was routed as if the phase were not stale (the pre-existing
|
||
* no-throw fail-open contract, preserved unchanged), but this flag lets a
|
||
* caller distinguish "checked; nothing is stale" from "could not check" so
|
||
* the two are no longer silently identical (#3057 B3). Omitted (not present)
|
||
* when the staleness check ran to completion, or was never reached (e.g. the
|
||
* `gaps_found` short-circuit above it, or no verification file at all).
|
||
*/
|
||
staleCheckIndeterminate?: boolean;
|
||
}
|
||
|
||
function findStaleVerificationSummary(
|
||
phaseDir: string,
|
||
fsImpl: FsLike = defaultFsImpl,
|
||
phaseCleanCommitTimesMs: PhaseCleanCommitTimesFn = defaultPhaseCleanCommitTimesMs,
|
||
): StaleCheckResult {
|
||
// FS errors (TOCTOU: a SUMMARY listed by scanPhasePlans then removed before statSync;
|
||
// unreadable dir; broken symlink; file->dir swap) must degrade rather than throw
|
||
// uncaught into callers that are NOT under the planning lock (init.manager /
|
||
// init.progress / uat-predicate). Mirrors readVerificationStatus's no-throw
|
||
// contract; `fsImpl` threads the same injectable-fs seam for parity/testing.
|
||
// (Review B1 on #1548.) The degraded result is `{determined:false}`, NOT the
|
||
// same value as a completed "nothing is stale" check — see StaleCheckResult
|
||
// doc and #3057 B3. The caller decides how to route an indeterminate result;
|
||
// this function only reports what it actually knows.
|
||
try {
|
||
const phaseFiles = fsImpl.readdirSync(phaseDir);
|
||
// #3492: pin selection to THIS phase's own token so a stray cross-phase
|
||
// or sentinel-numbered canonically-shaped file cannot outrank this
|
||
// phase's own (possibly non-canonical) report. #3511: phaseDirName scopes
|
||
// the fallback path to this same phase (see resolveVerificationFile docs).
|
||
const phaseDirName = path.basename(phaseDir);
|
||
const phaseToken = extractPhaseToken(phaseDirName);
|
||
const verificationFile = resolveVerificationFile(phaseFiles, { phaseToken, phaseDirName });
|
||
if (!verificationFile) return { determined: true, stale: false };
|
||
|
||
const summaryFiles = (scanPhasePlans(phaseDir) as { summaryFiles: string[] }).summaryFiles
|
||
.slice()
|
||
.sort();
|
||
// No summary can be newer than the verification → never stale. Return before
|
||
// touching git so a phase with no summaries costs zero subprocesses. (#2348)
|
||
if (summaryFiles.length === 0) return { determined: true, stale: false };
|
||
|
||
// Each file's effective "last changed" time = its commit time when committed
|
||
// AND clean (content-tied and clone-stable), else its filesystem mtime (the
|
||
// uncommitted working-tree edit). Both are real wall-clock change times, so
|
||
// comparing a clean file's commit time against a dirty file's mtime is sound.
|
||
// One resolver call = two git subprocesses for the whole phase. (#2348)
|
||
const cleanCommitMs = phaseCleanCommitTimesMs(phaseDir, [verificationFile, ...summaryFiles]);
|
||
const effectiveTimeMs = (file: string): number =>
|
||
cleanCommitMs.has(file)
|
||
? (cleanCommitMs.get(file) as number)
|
||
: fsImpl.statSync(path.join(phaseDir, file)).mtimeMs;
|
||
|
||
const verificationTimeMs = effectiveTimeMs(verificationFile);
|
||
for (const summaryFile of summaryFiles) {
|
||
// The caller only needs whether the phase is stale, not which summary —
|
||
// the first stale summary (in sorted order) is enough. Short-circuit.
|
||
if (effectiveTimeMs(summaryFile) > verificationTimeMs) {
|
||
return { determined: true, stale: true, verificationFile, summaryFile };
|
||
}
|
||
}
|
||
|
||
return { determined: true, stale: false };
|
||
} catch {
|
||
return { determined: false };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Read the verification status from the first `*-VERIFICATION.md` file in
|
||
* phaseDir and return the routing result.
|
||
*
|
||
* Behavior:
|
||
* 1. Find the phase's verification report via `resolveVerificationFile`
|
||
* (canonical `<phase-token>-VERIFICATION.md` preferred; falls back to the
|
||
* alphabetically-first `*-VERIFICATION.md` that belongs to THIS phase when
|
||
* none is canonical — #3357/#3511). If none → status 'missing'.
|
||
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
|
||
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
|
||
* If no frontmatter block or no `status` key → status 'missing'.
|
||
* 3. Map to routing table. Unknown non-empty value → status 'unknown'.
|
||
*
|
||
* The internal staleness check can itself fail (fs / scanPhasePlans / clock
|
||
* error); when it does, `status` is routed as if nothing were stale (the
|
||
* pre-existing no-throw fail-open contract — unchanged), but the returned
|
||
* result carries `staleCheckIndeterminate: true` so a caller can distinguish
|
||
* "checked; nothing is stale" from "could not check" (#3057 B3).
|
||
*
|
||
* @param phaseDir - Absolute path to the phase directory.
|
||
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
|
||
* `opts.runtime` selects the command surface `next_command` is
|
||
* projected into (#2617).
|
||
*/
|
||
function readVerificationStatus(
|
||
phaseDir: string,
|
||
opts: ReadVerificationStatusOptions = {},
|
||
): VerificationStatusResult {
|
||
const fsImpl: FsLike = opts.fs ?? defaultFsImpl;
|
||
const phaseCleanCommitTimesMs: PhaseCleanCommitTimesFn =
|
||
opts.phaseCleanCommitTimesMs ?? defaultPhaseCleanCommitTimesMs;
|
||
const runtime = opts.runtime ?? 'claude';
|
||
|
||
// Phase token for the gaps_found command
|
||
const baseName = path.basename(phaseDir);
|
||
const phaseToken = extractPhaseToken(baseName);
|
||
const derivedPhaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
|
||
// #2617: the phase number becomes a COMMAND ARGUMENT, so it is appended only
|
||
// when it is unambiguously one. extractPhaseToken also returns project-code
|
||
// forms (`PROJ-07`), which are indistinguishable by shape from an ordinary
|
||
// directory name — `gsd-651-parent` yields `gsd-651` — and emitting
|
||
// `execute-phase gsd-651` is worse than emitting no argument at all. Callers
|
||
// that already know the number (init) pass it explicitly and always get it.
|
||
const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
|
||
const phaseArg = phaseArgSource ? ` ${phaseArgSource}` : '';
|
||
|
||
// 1. Find *-VERIFICATION.md
|
||
let verificationFile: string | null = null;
|
||
try {
|
||
const entries = fsImpl.readdirSync(phaseDir);
|
||
// #3492: pin selection to THIS phase's own token (already derived above
|
||
// for the routed command argument) so a stray cross-phase or
|
||
// sentinel-numbered canonically-shaped file cannot outrank this phase's
|
||
// own (possibly non-canonical) report. #3511: baseName also scopes the
|
||
// fallback path to this same phase (see resolveVerificationFile docs).
|
||
verificationFile = resolveVerificationFile(entries, { phaseToken, phaseDirName: baseName });
|
||
} catch {
|
||
// Directory unreadable → treat as missing
|
||
verificationFile = null;
|
||
}
|
||
|
||
if (!verificationFile) {
|
||
return missingResult(runtime, phaseArg);
|
||
}
|
||
|
||
// 2. Read and parse frontmatter using the shared parser.
|
||
// 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
|
||
// `planning.inspect`'s `buildUatRows`/`readDocument` route through for
|
||
// UAT/REQUIREMENTS documents, but `readVerificationStatus` had no such
|
||
// normalization of its own. A lone-CR VERIFICATION.md's `---\r...\r---`
|
||
// frontmatter fence never matched `extractFrontmatter`'s byte-0
|
||
// `---\n`/`---\r\n` check, so `status: passed` was read as absent and
|
||
// this function reported 'missing' — under-reporting a completed
|
||
// 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'));
|
||
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.
|
||
if (typeof statusVal === 'string') {
|
||
const trimmed = statusVal.trim();
|
||
rawStatus = trimmed.length > 0 ? trimmed : null;
|
||
}
|
||
} catch {
|
||
rawStatus = null;
|
||
}
|
||
|
||
if (!rawStatus) {
|
||
return missingResult(runtime, phaseArg);
|
||
}
|
||
|
||
// gaps_found takes priority over stale — gap closure is the correct next
|
||
// step regardless of whether summaries are newer than the verification file.
|
||
if (rawStatus === 'gaps_found') {
|
||
const entry = VERIFICATION_ROUTING_TABLE['gaps_found'];
|
||
return {
|
||
status: entry.status,
|
||
next_action: entry.next_action,
|
||
next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
|
||
};
|
||
}
|
||
|
||
// #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,
|
||
next_action: entry.next_action,
|
||
next_command: projectNextCommand('verify-work', runtime, phaseArg),
|
||
};
|
||
}
|
||
|
||
// 3. Route — exclude internal sentinels from raw-file lookup (they are
|
||
// constructed internally above, never written by the verifier).
|
||
if (
|
||
rawStatus in VERIFICATION_ROUTING_TABLE &&
|
||
rawStatus !== 'missing' &&
|
||
rawStatus !== 'unknown' &&
|
||
rawStatus !== 'stale' &&
|
||
rawStatus !== 'gaps_found'
|
||
) {
|
||
const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
|
||
return {
|
||
status: entry.status,
|
||
next_action: entry.next_action,
|
||
next_command: projectNextCommand(entry.next_command, runtime, phaseArg),
|
||
...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
|
||
};
|
||
}
|
||
|
||
// Unknown value
|
||
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
|
||
return {
|
||
status: unknownRoute.status,
|
||
next_action: `Unexpected verification status '${rawStatus}'. If this is an intentional non-standard marker (e.g. a hand-set failed/superseded state), no action is needed. Otherwise, run execute-phase to regenerate verification — it will not re-run plans that already have a SUMMARY.md.`,
|
||
next_command: projectNextCommand(unknownRoute.next_command, runtime, phaseArg),
|
||
...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
|
||
};
|
||
}
|
||
|
||
interface IsPhaseCompleteDeps {
|
||
fs?: FsLike;
|
||
/** Injectable per-phase clean-commit-time resolver, threaded through to readVerificationStatus. */
|
||
phaseCleanCommitTimesMs?: PhaseCleanCommitTimesFn;
|
||
/** Runtime whose command surface next_command is projected into (#2617). */
|
||
runtime?: string;
|
||
/** Phase number appended to the routed command (#2617). */
|
||
phaseNumber?: string;
|
||
}
|
||
|
||
interface PhaseCompletionValue {
|
||
complete: boolean;
|
||
verification: VerificationStatusResult;
|
||
}
|
||
|
||
/**
|
||
* isPhaseComplete — the single canonical owner of "is phase P complete?"
|
||
* (ADR-3180 §7.4, Decision 1). Sited beside readVerificationStatus, which it
|
||
* wraps.
|
||
*
|
||
* DISK-STRICT (#2957, maintainer decision 2026-08-08; ADR-3180 §7.4 amended
|
||
* af92fd4c9): readVerificationStatus is called UNCONDITIONALLY here — plan
|
||
* count is NOT a precondition. A phase with zero plans and a passing
|
||
* `*-VERIFICATION.md` is complete (#3168). A ROADMAP checkbox has no machine
|
||
* authority and is never consulted — this function never reads ROADMAP.md.
|
||
*
|
||
* `complete` is exactly `verification.status === 'passed'`. `verification`
|
||
* carries the FULL routing result (status/next_action/next_command), so a
|
||
* caller can distinguish a failing verdict (`gaps_found`/`human_needed`/
|
||
* `stale`/`unknown`) from an absent one (`missing`) — both are "not
|
||
* complete", but they are not the same non-answer.
|
||
*
|
||
* `scope` is UNREADABLE when `phaseDir` itself could not be listed — this is
|
||
* INDEPENDENT of readVerificationStatus's own no-throw fail-open contract for
|
||
* a missing `*-VERIFICATION.md` file (a well-formed answer,
|
||
* `verification.status === 'missing'`, scope COMPLETE): a caller must not
|
||
* read `value.complete: false` here as a confident "not complete" the way it
|
||
* can for a genuinely-checked missing file.
|
||
*
|
||
* Does NOT import scanPhasePlans / plan-scan.cjs — the owner consumes plan
|
||
* counts from its caller when a caller needs them for a different question
|
||
* (e.g. buildPhaseCompletionProjection's own `implementation_complete`); it
|
||
* never re-derives or requires them itself.
|
||
*/
|
||
function isPhaseComplete(
|
||
phaseDir: string,
|
||
deps: IsPhaseCompleteDeps = {},
|
||
): { value: PhaseCompletionValue; scope: Scope } {
|
||
const fsImpl: FsLike = deps.fs ?? defaultFsImpl;
|
||
let readable = true;
|
||
try {
|
||
fsImpl.readdirSync(phaseDir);
|
||
} catch {
|
||
readable = false;
|
||
}
|
||
|
||
const verification = readVerificationStatus(phaseDir, {
|
||
fs: deps.fs,
|
||
phaseCleanCommitTimesMs: deps.phaseCleanCommitTimesMs,
|
||
runtime: deps.runtime,
|
||
phaseNumber: deps.phaseNumber,
|
||
});
|
||
|
||
return {
|
||
value: {
|
||
complete: verification.status === 'passed',
|
||
verification,
|
||
},
|
||
scope: readable ? SCOPE.COMPLETE : SCOPE.UNREADABLE,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
|
||
* emit via io.output().
|
||
*
|
||
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
||
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
||
* @param raw - Whether to emit raw (non-JSON) output.
|
||
*/
|
||
function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void {
|
||
if (!phaseDirArg) {
|
||
error('phase directory required for verification.status');
|
||
return;
|
||
}
|
||
const phaseDir = path.resolve(cwd, phaseDirArg);
|
||
const result = readVerificationStatus(phaseDir, { runtime: resolveRuntime(cwd) });
|
||
output(result, raw);
|
||
}
|
||
|
||
/**
|
||
* CLI command handler: resolve which `*-VERIFICATION.md` in `phaseDirArg` is
|
||
* the phase's own report, via the shared `resolveVerificationFile` seam, and
|
||
* emit its absolute path.
|
||
*
|
||
* #3492 F3: the ONE seam shell callers (verify-work.md's writer, transition.md's
|
||
* awk reader) route through instead of hand-rolling `ls *-VERIFICATION.md |
|
||
* head -1` / an awk glob scan — both of which pick alphabetically-first and so
|
||
* diverge from every JS reader now pinned to the phase's own token.
|
||
*
|
||
* Emits `{ verification_file: "<absolute path>" | "" }` (empty when no
|
||
* candidate resolves, including an unreadable directory). `raw` emits the
|
||
* bare path string (possibly empty) so `VAR=$(gsd_run query
|
||
* verification.resolve-file "$PHASE_DIR" --raw)` is directly assignable.
|
||
*
|
||
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
||
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
||
* @param raw - Whether to emit raw (non-JSON) output.
|
||
*/
|
||
function cmdVerificationResolveFile(cwd: string, phaseDirArg: string | undefined, raw: boolean): void {
|
||
if (!phaseDirArg) {
|
||
error('phase directory required for verification.resolve-file');
|
||
return;
|
||
}
|
||
const phaseDir = path.resolve(cwd, phaseDirArg);
|
||
let verificationPath = '';
|
||
try {
|
||
const entries = fs.readdirSync(phaseDir);
|
||
const phaseDirName = path.basename(phaseDir);
|
||
const phaseToken = extractPhaseToken(phaseDirName);
|
||
const verificationFile = resolveVerificationFile(entries, { allowBare: true, phaseToken, phaseDirName });
|
||
if (verificationFile) {
|
||
verificationPath = path.join(phaseDir, verificationFile);
|
||
}
|
||
} catch {
|
||
verificationPath = '';
|
||
}
|
||
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,
|
||
defaultPhaseCleanCommitTimesMs,
|
||
resolveVerificationFile,
|
||
resolveUatFile,
|
||
findStaleVerificationSummary,
|
||
readVerificationStatus,
|
||
isPhaseComplete,
|
||
cmdVerificationStatus,
|
||
cmdVerificationResolveFile,
|
||
computeCoveredDigest,
|
||
cmdVerificationFingerprint,
|
||
};
|