diff --git a/.changeset/sunny-geese-hop.md b/.changeset/sunny-geese-hop.md new file mode 100644 index 000000000..5e1ef6231 --- /dev/null +++ b/.changeset/sunny-geese-hop.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3513 +--- +**A phase with more than one `*-VERIFICATION.md` no longer reports the wrong one** — verification-report discovery took the alphabetically-first match, so an ad-hoc worksheet such as `03-CORRECTION-VERIFICATION.md` beat the real `03-VERIFICATION.md` sitting beside it and the phase could report `missing` while a passing report existed. Three further copies of the same lookup picked whichever file the filesystem happened to list first, making phase status and the reported `verification_path` vary between machines. All five now share one resolver that prefers the canonically-named report and is deterministic when it has to fall back. (#3357) diff --git a/gsd-core/workflows/transition.md b/gsd-core/workflows/transition.md index a520fb8e5..15d6eaad0 100644 --- a/gsd-core/workflows/transition.md +++ b/gsd-core/workflows/transition.md @@ -102,12 +102,22 @@ cat .planning/config.json 2>/dev/null || true **Check for verification debt in this phase:** ```bash -# Run a preliminary frontmatter check via awk — the runtime launcher is not yet -# defined at this step, so avoid any runtime tool calls here. +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi +# #3492: resolve THIS phase's own report through the single shared seam +# (src/verification.cts resolveVerificationFile) instead of a blind +# `*-VERIFICATION.md` glob — a stray ad-hoc worksheet (e.g. +# `03-CORRECTION-VERIFICATION.md`) alphabetically outranks the real report +# and previously fed this awk parse the wrong file. +VERIFICATION_FILE=$(gsd_run query verification.resolve-file .planning/phases/XX-current --raw 2>/dev/null) # awk extracts only the status: field between the two --- fences to avoid # false positives from historical body text (e.g. previous_status: gaps_found). -VERIFY_STATUS=$(awk 'NR==1&&/^---$/{in_fm=1;next}in_fm&&/^---$/{exit}in_fm&&/^status: /{print $2}' \ - .planning/phases/XX-current/*-VERIFICATION.md 2>/dev/null | head -1) +# FNR (not NR) re-arms the frontmatter scan per input file: NR only ever arms +# on the very FIRST line of the very first file, so a multi-file input would +# silently read empty status for every file after the first. The resolver +# above always hands back a single path, but the parse stays correct even if +# that ever changes. +VERIFY_STATUS=$(awk 'FNR==1&&/^---$/{in_fm=1;next}in_fm&&/^---$/{exit}in_fm&&/^status: /{print $2}' \ + "$VERIFICATION_FILE" 2>/dev/null | head -1) ``` **If VERIFY_STATUS is not `passed`:** @@ -120,10 +130,10 @@ Verification incomplete: ${VERIFY_STATUS:-missing} Resolve before transition. Review: `/gsd:audit-uat` ``` -This preliminary check blocks obviously unresolved verification before the -launcher is available. `gsd-tools.cjs query phase.complete` remains the -authoritative stale-aware gate and fail-closes unless canonical verification -status is `passed`. +This preliminary check blocks obviously unresolved verification early, ahead +of the authoritative gate below. `gsd-tools.cjs query phase.complete` (in +`update_roadmap_and_state`) remains the authoritative stale-aware gate and +fail-closes unless canonical verification status is `passed`. **If all plans complete:** @@ -190,7 +200,6 @@ If found, delete them — phase is complete, handoffs are stale. **Delegate ROADMAP.md and STATE.md updates to `gsd-tools.cjs query phase.complete`:** ```bash -_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi TRANSITION=$(gsd_run query phase.complete "${current_phase}") ``` @@ -442,7 +451,7 @@ ROADMAP=$(gsd_run query roadmap.analyze) This returns all phases with goals, disk status, and completion info. -**Section-manifest gate (#2994):** `gsd_run` is already established above (`update_roadmap_and_state` step) — fetch the dedicated `init.transition` bundle for the workstream-collision-check gate below: +**Section-manifest gate (#2994):** `gsd_run` is already established above (`verify_completion` step) — fetch the dedicated `init.transition` bundle for the workstream-collision-check gate below: ```bash INIT_TRANSITION=$(gsd_run query init.transition) diff --git a/gsd-core/workflows/verify-work.md b/gsd-core/workflows/verify-work.md index c98d854e0..0e9c69153 100644 --- a/gsd-core/workflows/verify-work.md +++ b/gsd-core/workflows/verify-work.md @@ -570,7 +570,7 @@ If execution verification is waiting only on human UAT and this session recorded ```bash PHASE_DIR=$(printf '%s' "$INIT" | jq -r '.phase_dir // empty') -VERIFICATION_FILE=$(ls "${PHASE_DIR}"/*-VERIFICATION.md 2>/dev/null | head -1) +VERIFICATION_FILE=$(gsd_run query verification.resolve-file "$PHASE_DIR" --raw 2>/dev/null) VERIFICATION_STATUS=$(gsd_run query verification.status "$PHASE_DIR" 2>/dev/null) VERIFICATION_STATUS_VALUE=$(printf '%s' "$VERIFICATION_STATUS" | jq -r '.status // empty' 2>/dev/null || echo "") PHASE_VERIFICATION_STATUS="$VERIFICATION_STATUS_VALUE" diff --git a/src/commands.cts b/src/commands.cts index d3b17815f..a3fee9521 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -60,6 +60,9 @@ import { clampPercent } from './phase-lifecycle.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planScanMod = require('./plan-scan.cjs'); const { scanPhasePlans } = planScanMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module +import verificationMod = require('./verification.cjs'); +const { resolveVerificationFile } = verificationMod; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -159,7 +162,15 @@ function determinePhaseStatus(plans: number, summaries: number, phaseDir: string // summaries >= plans — check verification try { const files = fs.readdirSync(phaseDir); - const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md')); + // #3473 F2: routed through the shared resolver (readdir order is + // filesystem-dependent, so the prior hand-rolled `.find()` could pick + // either file when a phase held both a canonical report and an ad-hoc + // `-CORRECTION-VERIFICATION.md` worksheet — see #3357). + // #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. + const phaseToken = extractPhaseToken(path.basename(phaseDir)); + const verificationFile = resolveVerificationFile(files, { allowBare: true, phaseToken }); if (verificationFile) { const verificationFilePath = path.join(phaseDir, verificationFile); const content = platformReadSync(verificationFilePath) || ''; diff --git a/src/init.cts b/src/init.cts index 6fd6b70ad..52f541084 100644 --- a/src/init.cts +++ b/src/init.cts @@ -89,7 +89,7 @@ const { extractCurrentMilestone, } = roadmapParser; const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils; -const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId } = phaseId; +const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken } = phaseId; const { pruneOrphanedWorktrees } = worktreeSafety; const { @@ -103,7 +103,7 @@ const { const { determinePhaseStatus } = commandsMod; const { extractFrontmatter } = frontmatterMod; -const { isPhaseComplete } = verificationMod; +const { isPhaseComplete, resolveVerificationFile } = verificationMod; const { evaluateUatPassed } = uatPredicateMod; const { resolveLoopHooks } = loopResolverMod; const { loadRegistry } = capabilityLoaderMod; @@ -1144,9 +1144,17 @@ function cmdInitPlanPhase( if (researchFile) { result['research_path'] = toPosixPath(path.join(phaseDirFull, researchFile)); } - const verificationFile = files.find( - (f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md', - ); + // #3473 F2: routed through the shared resolver — readdir order is + // filesystem-dependent, so the prior hand-rolled `.find()` could pick + // either file when a phase held both a canonical report and an ad-hoc + // `-CORRECTION-VERIFICATION.md` worksheet (#3357). + // #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. + const verificationFile = resolveVerificationFile(files, { + allowBare: true, + phaseToken: extractPhaseToken(path.basename(phaseDirFull)), + }); if (verificationFile) { result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile)); } @@ -1969,9 +1977,17 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void { if (researchFile) { result['research_path'] = toPosixPath(path.join(phaseDirFull, researchFile)); } - const verificationFile = files.find( - (f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md', - ); + // #3473 F2: routed through the shared resolver — readdir order is + // filesystem-dependent, so the prior hand-rolled `.find()` could pick + // either file when a phase held both a canonical report and an ad-hoc + // `-CORRECTION-VERIFICATION.md` worksheet (#3357). + // #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. + const verificationFile = resolveVerificationFile(files, { + allowBare: true, + phaseToken: extractPhaseToken(path.basename(phaseDirFull)), + }); if (verificationFile) { result['verification_path'] = toPosixPath(path.join(phaseDirFull, verificationFile)); } diff --git a/src/verification-command-router.cts b/src/verification-command-router.cts index 3c42e71e4..32905de04 100644 --- a/src/verification-command-router.cts +++ b/src/verification-command-router.cts @@ -17,6 +17,7 @@ const { routeCjsCommandFamily } = cjsCommandRouterAdapter; interface VerificationModule { cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void; + cmdVerificationResolveFile(cwd: string, phaseDirArg: string | undefined, raw: boolean): void; } interface RouteVerificationCommandOptions { @@ -29,7 +30,7 @@ interface RouteVerificationCommandOptions { // ─── Implementation ─────────────────────────────────────────────────────────── -const VERIFICATION_SUBCOMMANDS = ['status']; +const VERIFICATION_SUBCOMMANDS = ['status', 'resolve-file']; function routeVerificationCommand({ verification, @@ -47,6 +48,7 @@ function routeVerificationCommand({ `Unknown verification subcommand. Available: ${available.join(', ')}`, handlers: { status: () => verification.cmdVerificationStatus(cwd, args[2], raw), + 'resolve-file': () => verification.cmdVerificationResolveFile(cwd, args[2], raw), }, }); } diff --git a/src/verification.cts b/src/verification.cts index d1eeae196..ead8315cf 100644 --- a/src/verification.cts +++ b/src/verification.cts @@ -282,6 +282,88 @@ function missingResult(runtime: string, phaseArg: string): VerificationStatusRes }; } +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 `-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 all dashed candidates (the original pre-#3357 + * behavior), never to null. + */ + phaseToken?: string; +} + +/** + * 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: + * 1. `options.phaseToken` given and `-VERIFICATION.md` is among + * the candidates — that exact file always wins. This is THIS phase's own + * report; no other candidate (canonically-shaped or not, whichever + * phase's token it carries) can outrank it (#3492). + * 2. Fallback — no exact phase-token match (or no token given): alphabetically + * first of ALL `*-VERIFICATION.md` entries, unchanged from the original + * pre-#3357 behavior. Load-bearing: a phase whose only report is + * non-canonically named must keep resolving to it, not to null — this + * fix must not turn "found a report" into "found nothing" for anyone. + * 3. `options.allowBare` only — a bare `VERIFICATION.md`, 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 only when neither (1) nor (2) found + * any dashed candidate at all. + * + * 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 resolveVerificationFile( + entries: string[], + options: ResolveVerificationFileOptions = {}, +): string | null { + const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort(); + if (candidates.length > 0) { + if (options.phaseToken) { + const thisPhaseFile = `${options.phaseToken}-VERIFICATION.md`; + if (candidates.includes(thisPhaseFile)) return thisPhaseFile; + } + return candidates[0]; + } + if (options.allowBare && entries.includes('VERIFICATION.md')) return 'VERIFICATION.md'; + return null; +} + // ─── Public API ─────────────────────────────────────────────────────────────── interface ReadVerificationStatusOptions { @@ -337,7 +419,11 @@ function findStaleVerificationSummary( // this function only reports what it actually knows. try { const phaseFiles = fsImpl.readdirSync(phaseDir); - const verificationFile = phaseFiles.filter((f) => f.endsWith('-VERIFICATION.md')).sort()[0]; + // #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. + const phaseToken = extractPhaseToken(path.basename(phaseDir)); + const verificationFile = resolveVerificationFile(phaseFiles, { phaseToken }); if (!verificationFile) return { determined: true, stale: false }; const summaryFiles = (scanPhasePlans(phaseDir) as { summaryFiles: string[] }).summaryFiles @@ -378,7 +464,9 @@ function findStaleVerificationSummary( * phaseDir and return the routing result. * * Behavior: - * 1. Find the first file matching `*-VERIFICATION.md` (sorted, take first). + * 1. Find the phase's verification report via `resolveVerificationFile` + * (canonical `-VERIFICATION.md` preferred; falls back to the + * alphabetically-first `*-VERIFICATION.md` when none is canonical — #3357). * 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). @@ -422,8 +510,11 @@ function readVerificationStatus( let verificationFile: string | null = null; try { const entries = fsImpl.readdirSync(phaseDir); - const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort(); - verificationFile = candidates.length > 0 ? candidates[0] : null; + // #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. + verificationFile = resolveVerificationFile(entries, { phaseToken }); } catch { // Directory unreadable → treat as missing verificationFile = null; @@ -600,12 +691,53 @@ function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw 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: "" | "" }` (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 phaseToken = extractPhaseToken(path.basename(phaseDir)); + const verificationFile = resolveVerificationFile(entries, { allowBare: true, phaseToken }); + if (verificationFile) { + verificationPath = path.join(phaseDir, verificationFile); + } + } catch { + verificationPath = ''; + } + output({ verification_file: verificationPath }, raw, verificationPath); +} + export = { VERIFIER_STATUSES, VERIFICATION_ROUTING_TABLE, defaultPhaseCleanCommitTimesMs, + resolveVerificationFile, findStaleVerificationSummary, readVerificationStatus, isPhaseComplete, cmdVerificationStatus, + cmdVerificationResolveFile, }; diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index 9ede32218..82af45739 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -2215,6 +2215,31 @@ describe('stats command', () => { assert.strictEqual(stats.plan_percent, 67); }); + // #3473 F2 (companion to #3357): determinePhaseStatus now resolves its + // *-VERIFICATION.md via the shared resolveVerificationFile resolver instead + // of a hand-rolled `.find()` over unsorted readdir() order. Before this fix, + // which of a canonical report and an ad-hoc `-CORRECTION-VERIFICATION.md` + // worksheet "won" was filesystem-dependent; the canonical report must now + // win deterministically regardless of directory-listing order. + test('#3473 F2: phase status resolves the canonical report over a -CORRECTION- worksheet, not readdir order', () => { + const p1 = path.join(tmpDir, '.planning', 'phases', '03-api'); + fs.mkdirSync(p1, { recursive: true }); + fs.writeFileSync(path.join(p1, '03-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(p1, '03-01-SUMMARY.md'), '# Summary'); + // The ad-hoc worksheet reports gaps_found; if it won the pick, the phase + // would read 'Executed', not 'Complete'. + fs.writeFileSync(path.join(p1, '03-CORRECTION-VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Correction worksheet'); + fs.writeFileSync(path.join(p1, '03-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification'); + + const result = runGsdTools('stats', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const stats = JSON.parse(result.output); + const phase = stats.phases.find(p => p.number === '03'); + assert.ok(phase, 'phase 03 must be present in stats output'); + assert.strictEqual(phase.status, 'Complete', 'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet'); + }); + test('counts requirements from REQUIREMENTS.md', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), diff --git a/tests/emitted-drift-acks/1526-auto-chain-transition-delegation.json b/tests/emitted-drift-acks/1526-auto-chain-transition-delegation.json deleted file mode 100644 index 09c622bd5..000000000 --- a/tests/emitted-drift-acks/1526-auto-chain-transition-delegation.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": 1, - "paths": { - "transition.md": { - "reason": "#1526 (delegate, user decision 2026-08-13): added a `post_completion_mode` step at the top of so transition.md is reusable when invoked by execute-phase AFTER phase.complete + verification already ran. The mode documents two invocations — standalone (run all steps) and post-completion delegation (SKIP verify_completion + update_roadmap_and_state to avoid a double phase.complete; RUN cleanup_handoff; BEGIN at evolve_project). Growth (~1.4 KB) is this one new step; no other step changed. (execute-phase.md, also touched by this PR, shrank slightly — its inline update_project_md + offer_next were replaced by a shorter delegation step — so it needs no ack.)" - } - } -} diff --git a/tests/emitted-drift-acks/3357-verification-resolver.json b/tests/emitted-drift-acks/3357-verification-resolver.json new file mode 100644 index 000000000..39b0627be --- /dev/null +++ b/tests/emitted-drift-acks/3357-verification-resolver.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "paths": { + "transition.md": { + "reason": "#3357: verify_completion now resolves THIS phase's own verification report through the shared `verification.resolve-file` seam (src/verification.cts resolveVerificationFile) instead of a blind `*-VERIFICATION.md` glob feeding awk directly, so a stray ad-hoc worksheet (e.g. `03-CORRECTION-VERIFICATION.md`) can no longer alphabetically outrank the real report. Growth is +632 bytes (23,247 -> 23,879): the `gsd_run query verification.resolve-file` call plus its comment, and the FNR-vs-NR frontmatter re-arm fix (with rationale comment) for correctness across the resolver's single-path result. The canonical runtime-launcher preamble was relocated (not duplicated) from `update_roadmap_and_state` up to `verify_completion` since that step now needs `gsd_run` earlier in document order; net preamble count is unchanged at exactly one." + }, + "verify-work.md": { + "reason": "#3357: the `03-VERIFICATION.md` staleness check now resolves the phase's own report via `gsd_run query verification.resolve-file \"$PHASE_DIR\" --raw` instead of `ls \"${PHASE_DIR}\"/*-VERIFICATION.md | head -1`, routing through the same shared resolver seam as transition.md so both call sites agree on which file is canonical. Growth is +13 bytes (38,983 -> 38,996), the delta between the old ls/head-1 pipeline and the gsd_run call." + } + } +} diff --git a/tests/emitted-drift-acks/3423-required-reading.json b/tests/emitted-drift-acks/3423-required-reading.json deleted file mode 100644 index 2d69f49c3..000000000 --- a/tests/emitted-drift-acks/3423-required-reading.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "version": 1, - "paths": { - "ai-integration-phase.md": { - "reason": "#3423 (epic #1891 F8): +bytes are the -> tag rename (15 chars per block); no prose added or rewritten. No tier crossed." - }, - "diagnose-issues.md": "#3423 (epic #1891 F8): +6 bytes are the -> tag rename (2 tag tokens, +3 bytes each); no prose added or rewritten. No tier crossed.", - "eval-review.md": { - "reason": "#3423 (epic #1891 F8): +bytes are the -> tag rename (15 chars per block); no prose added or rewritten. No tier crossed." - }, - "new-milestone.md": { - "reason": "#3423 (epic #1891 F8): +bytes are the -> tag rename (15 chars per block); no prose added or rewritten. No tier crossed." - }, - "quick.md": "#3423 (epic #1891 F8): +12 bytes are the -> tag rename (4 tag tokens, +3 bytes each); no prose added or rewritten. No tier crossed.", - "secure-phase.md": { - "reason": "#3423 (epic #1891 F8): +bytes are the -> tag rename (15 chars per block); no prose added or rewritten. No tier crossed." - }, - "ui-review.md": { - "reason": "#3423 (epic #1891 F8): +bytes are the -> tag rename (15 chars per block); no prose added or rewritten. No tier crossed." - }, - "validate-phase.md": { - "reason": "#3423 (epic #1891 F8): +bytes are the -> tag rename (15 chars per block); no prose added or rewritten. No tier crossed." - }, - "verify-work.md": { - "reason": "#3423 (epic #1891 F8): +bytes are the -> tag rename (15 chars per block); no prose added or rewritten. No tier crossed." - } - } -} diff --git a/tests/init.test.cjs b/tests/init.test.cjs index c86742938..becc78743 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -120,6 +120,29 @@ describe('init commands', () => { assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md')); }); + // #3473 F2 (companion to #3357): init plan-phase's verification_path + // projector now resolves via the shared resolveVerificationFile resolver + // instead of a hand-rolled `.find()` over unsorted readdir() order. The + // canonical report must win over an ad-hoc -CORRECTION- worksheet + // deterministically, regardless of directory-listing order. + test('#3473 F2: init plan-phase resolves the canonical report over a -CORRECTION- worksheet', () => { + seedPhase(tmpDir, '03-api', { + '03-CORRECTION-VERIFICATION.md': '# Ad-hoc correction worksheet', + '03-VERIFICATION.md': '# Verification', + }); + writePlanningDocs(tmpDir); + + const result = runGsdTools('init plan-phase 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual( + output.verification_path, + absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'), + 'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet', + ); + }); + // #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project // code, so a foreign-prefixed workstream/task id like "MEM-01" collapsed to // "01" and resolved to the unrelated numeric Phase 01. init plan-phase must @@ -362,6 +385,27 @@ describe('init commands', () => { assert.strictEqual(output.uat_path, absPlanningPath(tmpDir, 'phases', '03-api', '03-UAT.md')); }); + // #3473 F2 (companion to #3357): init phase-op's verification_path projector + // — the second of the two now-fixed init.cts sites — same regression as + // the plan-phase test above. + test('#3473 F2: init phase-op resolves the canonical report over a -CORRECTION- worksheet', () => { + seedPhase(tmpDir, '03-api', { + '03-CORRECTION-VERIFICATION.md': '# Ad-hoc correction worksheet', + '03-VERIFICATION.md': '# Verification', + }); + writePlanningDocs(tmpDir); + + const result = runGsdTools('init phase-op 03', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual( + output.verification_path, + absPlanningPath(tmpDir, 'phases', '03-api', '03-VERIFICATION.md'), + 'the canonical 03-VERIFICATION.md must win over the CORRECTION worksheet', + ); + }); + test('init plan-phase detects has_reviews and reviews_path when REVIEWS.md exists', () => { seedPhase(tmpDir, '03-api', { '03-REVIEWS.md': '# Cross-AI Reviews', diff --git a/tests/verification-status.test.cjs b/tests/verification-status.test.cjs index e8a22741b..8114616f3 100644 --- a/tests/verification-status.test.cjs +++ b/tests/verification-status.test.cjs @@ -14,8 +14,20 @@ * 8. CRLF line endings in frontmatter * 9. Body-only file (no frontmatter block) → missing * 10. Nonexistent phase directory → missing - * 11. Multiple *-VERIFICATION.md files → first by sort + * 11. Multiple *-VERIFICATION.md files, none matching the phase's own token → + * alphabetically-first FALLBACK wins (the phase-pinned rule's #2 tier — + * see #3492 below for the primary, phase-pinned tier) * 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation) + * 13. #3357/#3492: `-VERIFICATION.md` resolution — resolveVerificationFile + * unit coverage plus behavioral tests through readVerificationStatus and + * findStaleVerificationSummary. THE CONTRACT (#3492): a candidate whose + * name exactly matches THIS phase's own token always wins, even over a + * different phase's canonically-shaped file; alphabetical-first among all + * dashed candidates is only the fallback when no exact match exists. The + * resolveVerificationFile unit tests are the reliable anchors for this — + * the readVerificationStatus/findStaleVerificationSummary behavioral tests + * are illustrative (their outcome also depends on directory-basename + * token derivation, not exercised in isolation there). * * PORTABILITY: pure JS — no shell-outs, no bash fences. * Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE. @@ -35,7 +47,9 @@ const { VERIFIER_STATUSES, VERIFICATION_ROUTING_TABLE, defaultPhaseCleanCommitTimesMs, + resolveVerificationFile, readVerificationStatus, + findStaleVerificationSummary, } = require('../gsd-core/bin/lib/verification.cjs'); // #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. @@ -313,8 +327,14 @@ describe('verification-status', () => { assert.equal(result.next_command, '/gsd-execute-phase'); }); - // Multiple *-VERIFICATION.md files → deterministic pick (first by sort) - test('multiple *-VERIFICATION.md files in dir → first by sort order wins', () => { + // Multiple *-VERIFICATION.md files, NEITHER matching the phase dir's own + // token → deterministic FALLBACK pick (first by sort). This is the #2 tier + // of the #3492 phase-pinned rule, not the contract itself — see the + // `#3357/#3492` describe block below for the primary, phase-pinned tier + // (resolveVerificationFile unit tests are the reliable anchors there). + // mkPhaseDir's random-suffixed basename never matches "01"/"02", so this + // exercises the fallback by construction. + test('multiple *-VERIFICATION.md files, none matching the phase token → alphabetically-first FALLBACK wins', () => { const dir = mkPhaseDir('multi'); try { // Write two files: alphabetically "01-a" comes before "02-b" @@ -326,7 +346,7 @@ describe('verification-status', () => { assert.equal( result.status, 'passed', - 'When multiple *-VERIFICATION.md files exist, the first by lexicographic sort must be used', + 'With no exact phase-token match, the first by lexicographic sort must be used', ); } finally { cleanup(dir); @@ -811,6 +831,299 @@ describe('verification-status', () => { }); +// ─── #3357/#3492: phase-pinned *-VERIFICATION.md resolution ────────────────── +// +// 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`). The original "alphabetically +// first" pick chose the worksheet ('C' < 'V'), and a worksheet with no +// frontmatter `status:` made the whole phase read `missing` even though a +// passing report sat right next to it (#3357). +// +// #3492 REGRESSION this block anchors: the #3357 fix's first cut preferred +// ANY canonically-shaped `-VERIFICATION.md`, regardless of WHOSE token +// it carried — so a stray cross-phase or sentinel-numbered canonical file +// (`999-VERIFICATION.md`) could outrank the querying phase's own (possibly +// non-canonical) report. THE CONTRACT (verified against the built lib): +// ['12-review-VERIFICATION.md', '999-VERIFICATION.md'] resolves to +// '12-review-VERIFICATION.md' for phase token '12' (was '999-…'). +// ['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'] resolves to +// '04-VERIFICATION.md' for phase token '04' (was '03-CORRECTION-…'). +// resolveVerificationFile is the single resolver findStaleVerificationSummary, +// readVerificationStatus, commands.cts's determinePhaseStatus, and both +// init.cts verification_path projectors all call, every one pinned to its own +// phaseDir's token (#3473 F2 / #3492). +// +// These resolveVerificationFile unit tests are the RELIABLE ANCHORS for the +// phase-pinned rule (a real `phaseToken` string, no filesystem/readdir order +// involved). The readVerificationStatus/findStaleVerificationSummary +// behavioral tests further down are illustrative only — their outcome +// additionally depends on the temp directory's basename tokenizing the way +// the test expects. +describe('#3357/#3492: phase-pinned *-VERIFICATION.md resolution when multiple candidates exist', () => { + + test('#3492 regression counterexample 1: a sentinel-numbered stray file does not outrank this phase\'s own non-canonical report', () => { + assert.equal( + resolveVerificationFile( + ['12-review-VERIFICATION.md', '999-VERIFICATION.md'], + { phaseToken: '12-review' }, + ), + '12-review-VERIFICATION.md', + 'this phase (token "12-review") owns 12-review-VERIFICATION.md; 999-VERIFICATION.md is a different phase and must not win', + ); + }); + + test('#3492 regression counterexample 2: a cross-phase CORRECTION worksheet does not outrank this phase\'s own canonical report', () => { + assert.equal( + resolveVerificationFile( + ['03-CORRECTION-VERIFICATION.md', '04-VERIFICATION.md'], + { phaseToken: '04' }, + ), + '04-VERIFICATION.md', + 'this phase (token "04") owns 04-VERIFICATION.md; the 03-CORRECTION worksheet belongs to a different phase', + ); + }); + + test('exact phase-token match wins over an ad-hoc -CORRECTION- worksheet for the SAME phase', () => { + assert.equal( + resolveVerificationFile( + ['03-CORRECTION-VERIFICATION.md', '03-VERIFICATION.md'], + { phaseToken: '03' }, + ), + '03-VERIFICATION.md', + 'the phase\'s own 03-VERIFICATION.md must win over its CORRECTION worksheet, not lose alphabetically', + ); + }); + + test('order-independence: same candidates reversed → same answer', () => { + assert.equal( + resolveVerificationFile( + ['03-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md'], + { phaseToken: '03' }, + ), + '03-VERIFICATION.md', + 'input order must not change which file is selected', + ); + }); + + test('decimal phase token: 35.1-VERIFICATION.md wins over a -CORRECTION- sibling', () => { + assert.equal( + resolveVerificationFile( + ['35.1-CORRECTION-VERIFICATION.md', '35.1-VERIFICATION.md'], + { phaseToken: '35.1' }, + ), + '35.1-VERIFICATION.md', + ); + }); + + test('letter-suffixed phase token: 03A-VERIFICATION.md wins over a -CORRECTION- sibling', () => { + assert.equal( + resolveVerificationFile( + ['03A-CORRECTION-VERIFICATION.md', '03A-VERIFICATION.md'], + { phaseToken: '03A' }, + ), + '03A-VERIFICATION.md', + ); + }); + + test('multi-canonical tiebreak: no exact phase-token match among several canonically-shaped candidates → alphabetically first', () => { + // Neither candidate's token is "50" — this is the (b) fallback tier, and + // it must stay a plain alphabetical pick (not a second, separate + // "canonical-shaped" preference — that concept no longer exists; #3492 + // removed it because it was exactly the regression mechanism above). + assert.equal( + resolveVerificationFile( + ['12-VERIFICATION.md', '999-VERIFICATION.md'], + { phaseToken: '50' }, + ), + '12-VERIFICATION.md', + '"12-VERIFICATION.md" sorts before "999-VERIFICATION.md" and neither matches phase token "50"', + ); + }); + + test('fallback: only a non-canonical file present → that file is still returned', () => { + // Load-bearing: a phase whose only report is non-canonically named must + // keep resolving to it, not to null — even when the phase token is known + // and does not exactly match. + assert.equal( + resolveVerificationFile(['01-review-VERIFICATION.md'], { phaseToken: '01' }), + '01-review-VERIFICATION.md', + ); + }); + + test('fallback determinism: several non-canonical files, no phase token given → alphabetically first (unchanged)', () => { + assert.equal( + resolveVerificationFile(['02-b-VERIFICATION.md', '01-a-VERIFICATION.md']), + '01-a-VERIFICATION.md', + ); + }); + + test('no phaseToken and no exact match → falls back to alphabetically-first, never null, when candidates exist', () => { + // #3492: an undeliverable/absent phase token must degrade to the original + // pre-#3357 behavior (alphabetically-first), not to null. + assert.equal( + resolveVerificationFile(['999-VERIFICATION.md', '03-CORRECTION-VERIFICATION.md']), + '03-CORRECTION-VERIFICATION.md', + 'with no phaseToken, plain alphabetical order decides — "03-…" sorts before "999-…"', + ); + }); + + test('no matches → null', () => { + assert.equal(resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md'], { phaseToken: '03' }), null); + }); + + test('unrelated files are not miscounted as candidates', () => { + // 03-PLAN.md / 03-SUMMARY.md never end in "-VERIFICATION.md". A bare + // "VERIFICATION.md" (no leading phase-token dash) is also never a + // candidate — it fails the very `.endsWith('-VERIFICATION.md')` filter + // that builds the candidate list in the first place (the string is one + // character too short to end with a leading-dash suffix). + assert.equal( + resolveVerificationFile(['03-PLAN.md', '03-SUMMARY.md', 'VERIFICATION.md'], { phaseToken: '03' }), + null, + 'a bare VERIFICATION.md is never a dashed candidate', + ); + }); + + test('behavioral (readVerificationStatus): a phase with both its own report and a cross-phase stray reports the OWN report\'s status, not the stray\'s', () => { + // The directory basename is "03-canonical-test" so extractPhaseToken + // derives token "03" — the exact same derivation readVerificationStatus + // performs internally, so this exercises the real production call path + // (not just the pure resolver), pinned to counterexample 2's shape. + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-parent-')); + const dir = path.join(baseDir, '03-canonical-test'); + fs.mkdirSync(dir); + try { + // A stray cross-phase canonical file with a DIFFERENT status — must not + // be picked for THIS (token "03") phase. + writeVerificationMd(dir, '99-VERIFICATION.md', 'gaps_found'); + // This phase's own (non-canonical, ad-hoc) report — must win. + writeVerificationMd(dir, '03-CORRECTION-VERIFICATION.md', 'passed'); + + const result = readVerificationStatus(dir); + assert.equal( + result.status, + 'passed', + 'the phase must report its OWN report\'s status, not a cross-phase stray\'s', + ); + } finally { + cleanup(baseDir); + } + }); + + test('behavioral (readVerificationStatus): a phase with both its own canonical report and an ad-hoc worksheet reports the canonical report\'s status, not missing (#3357 original regression)', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3357-parent-')); + const dir = path.join(baseDir, '03-canonical-test'); + fs.mkdirSync(dir); + try { + // The ad-hoc worksheet has no frontmatter `status:` at all — this is + // the exact original #3357 failure mode: 'C' < 'V' picked this file + // first and the phase read 'missing' despite the passing report sitting + // right next to it. + fs.writeFileSync( + path.join(dir, '03-CORRECTION-VERIFICATION.md'), + '# Ad-hoc correction worksheet\n\nNo frontmatter status here.\n', + ); + writeVerificationMd(dir, '03-VERIFICATION.md', 'passed'); + + const result = readVerificationStatus(dir); + assert.equal( + result.status, + 'passed', + 'the phase must report the canonical report\'s status, not missing', + ); + } finally { + cleanup(baseDir); + } + }); + + test('behavioral (findStaleVerificationSummary): staleness is checked against THIS phase\'s own report, not a cross-phase stray', () => { + // A stray cross-phase file ("99-VERIFICATION.md") is alphabetically AFTER + // this phase's own "03-VERIFICATION.md", so this also demonstrates the + // pin is not merely riding on alphabetical luck. + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3492-stale-parent-')); + const dir = path.join(baseDir, '03-stale-pin-test'); + fs.mkdirSync(dir); + try { + writeVerificationMd(dir, '03-VERIFICATION.md', 'passed'); + writeVerificationMd(dir, '99-VERIFICATION.md', 'passed'); + setMtime(path.join(dir, '03-VERIFICATION.md'), '2020-01-01T00:00:00Z'); + setMtime(path.join(dir, '99-VERIFICATION.md'), '2020-01-01T00:00:00Z'); + + // Root-style summary placement (mirrors the #2348 fixtures above) — + // scanPhasePlans's nested-layout matcher requires `SUMMARY-...md` + // inside a `plans/` subdir; a root-named `03-01-SUMMARY.md` dropped into + // `plans/` matches neither isRootSummaryFile (wrong directory) nor + // isNestedSummaryFile (wrong filename shape), so summaryFiles reads + // empty and the phase is never stale — not what this test means to + // exercise. + const summaryPath = path.join(dir, '03-01-SUMMARY.md'); + fs.writeFileSync(summaryPath, '# summary\n'); + setMtime(summaryPath, '2021-01-01T00:00:00Z'); + + // Force the mtime path (no git clock) by injecting an empty resolver — + // mirrors the existing #2348 test pattern elsewhere in this file. + const result = findStaleVerificationSummary(dir, fs, () => new Map()); + assert.equal(result.determined, true); + assert.equal(result.stale, true, 'the phase\'s own 03-VERIFICATION.md is older than its summary'); + assert.equal( + result.verificationFile, + '03-VERIFICATION.md', + 'staleness must be computed against the phase\'s own report, not the cross-phase 99-VERIFICATION.md stray', + ); + } finally { + cleanup(baseDir); + } + }); + +}); + +// ─── #3473 F2: resolveVerificationFile allowBare option ────────────────────── +// +// commands.cts (determinePhaseStatus) and two verification_path projectors in +// init.cts each hand-rolled a fourth variant of this same selection: they +// additionally accept a BARE `VERIFICATION.md`, which this module's own two +// callers (findStaleVerificationSummary, readVerificationStatus) never have. +// `allowBare` threads that one behavioral difference through the single +// resolver instead of leaving a fourth hand-rolled implementation behind +// (#3473 F2). A bare match is ranked BELOW any dashed candidate — canonical +// or not — because a dashed file names its phase and a bare one does not. +describe('#3473 F2: resolveVerificationFile allowBare option', () => { + + test('allowBare defaults to false — a bare-only list returns null without the option', () => { + assert.equal(resolveVerificationFile(['VERIFICATION.md']), null); + }); + + test('allowBare:true, bare-only candidate → bare file returned', () => { + assert.equal( + resolveVerificationFile(['VERIFICATION.md'], { allowBare: true }), + 'VERIFICATION.md', + ); + }); + + test('allowBare:true, bare + non-canonical dashed → the dashed fallback wins', () => { + assert.equal( + resolveVerificationFile( + ['VERIFICATION.md', '01-review-VERIFICATION.md'], + { allowBare: true }, + ), + '01-review-VERIFICATION.md', + 'a dashed non-canonical file names its phase and must win over a bare match', + ); + }); + + test('allowBare:true, bare + canonical → the canonical file wins', () => { + assert.equal( + resolveVerificationFile( + ['VERIFICATION.md', '03-VERIFICATION.md'], + { allowBare: true }, + ), + '03-VERIFICATION.md', + ); + }); + +}); + // ─── #3057 B3: findStaleVerificationSummary — indeterminate vs not-stale ───── // // The pre-fix catch-all returned `null` on ANY fs / scanPhasePlans / clock