A phase directory can hold more than one `*-VERIFICATION.md` — an ad-hoc `03-CORRECTION-VERIFICATION.md` worksheet beside the real `03-VERIFICATION.md`. Discovery took the alphabetically-first match, so the worksheet won and the phase could report `missing` while a passing report sat next to it. The issue named two copies. There were seven, in four grammars: two `.sort()[0]` sites in the verification module, three `.find()` over UNSORTED readdir order (phase status, and `verification_path` twice — filesystem-dependent, so two machines on one commit could disagree), and two in shell. All seven now route through one exported `resolveVerificationFile`; the shell copies via a new `verification resolve-file` verb rather than hand-rolling the rule an eighth time. Fixing five of seven would have been worse than fixing none: the verify-work workflow is a WRITER that stamps `status: passed` onto the file it picks, so canonical-aware readers plus an alphabetical writer means the human_needed→passed canonicalization silently no-ops forever while the worksheet gets stamped. That divergence did not exist on next. The first resolver was itself a regression — it preferred ANY canonically-shaped name over the phase's own report, so a stray cross-phase or sentinel-numbered file outranked it. The global-canonical preference was removed rather than narrowed; the rule is pinned to the phase token via `PHASE_NUMBER_TOKEN_SOURCE`, its existing owner. Fixed in passing: the transition workflow's awk guarded on `NR==1` instead of `FNR==1`, so across a multi-file glob it armed only on the first file — a leading worksheet with no frontmatter blocked transition even when the canonical report passed. Also removed a U+00AD soft hyphen introduced earlier on this branch. Five broader-grammar AGGREGATE scans are deliberately out of scope — a different defect class (phase-unscoped scanning), tracked as #3511. Closes #3357 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/sunny-geese-hop.md
Normal file
5
.changeset/sunny-geese-hop.md
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) || '';
|
||||
|
||||
32
src/init.cts
32
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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 `<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 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 `<phaseToken>-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 `<phase-token>-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: "<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 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,
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 <process> 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.)"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
tests/emitted-drift-acks/3357-verification-resolver.json
Normal file
11
tests/emitted-drift-acks/3357-verification-resolver.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"ai-integration-phase.md": {
|
||||
"reason": "#3423 (epic #1891 F8): +bytes are the <files_to_read> -> <required_reading> 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 <files_to_read> -> <required_reading> 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 <files_to_read> -> <required_reading> 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 <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
|
||||
},
|
||||
"quick.md": "#3423 (epic #1891 F8): +12 bytes are the <files_to_read> -> <required_reading> 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 <files_to_read> -> <required_reading> 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 <files_to_read> -> <required_reading> 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 <files_to_read> -> <required_reading> 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 <files_to_read> -> <required_reading> tag rename (15 chars per block); no prose added or rewritten. No tier crossed."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: `<phase-token>-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 `<token>-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-<NN>...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
|
||||
|
||||
Reference in New Issue
Block a user