Migrate 9 files' entire core surface to the leaf modules directly (behaviour-identical — leaves are the objects core re-exports by reference): config, docs, gap-checker, graphify-command-router (namespace core.output -> io.output), init (17 core symbols -> 8 leaves), profile-output, uat, verification, workstream. All 9 now import zero core symbols and are removed from the allowlist (18 -> 9). Stale core.* docstrings corrected. core.cts re-exports untouched (still serve the remaining 9 files); teardown is T-final. No behaviour change. Closes #1283 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
238 lines
9.2 KiB
TypeScript
238 lines
9.2 KiB
TypeScript
/**
|
|
* Verification Status — single queryable home for verification-status routing.
|
|
*
|
|
* Issue #651: consolidate the pass/gaps_found/human_needed routing that was
|
|
* previously scattered across ship.md and execute-phase.md into a single
|
|
* tested module. Both workflow files will later consume this module's routing
|
|
* table as the single source of truth.
|
|
*
|
|
* ADR-457 build-at-publish: source in src/verification.cts, compiled to
|
|
* gsd-core/bin/lib/verification.cjs (gitignored).
|
|
*
|
|
* DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix: status extraction is scoped to
|
|
* the leading YAML frontmatter block only. A `status:` line in the body (e.g.
|
|
* inside a fenced code block) is ignored — this is the exact failure mode that
|
|
* issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
|
|
* its regex at byte 0 of the document, which provides this guarantee.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
|
|
import io = require('./io.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
import phaseId = require('./phase-id.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
import frontmatterMod = require('./frontmatter.cjs');
|
|
|
|
const { output, error } = io;
|
|
const { extractPhaseToken } = phaseId;
|
|
const { extractFrontmatter } = frontmatterMod;
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
/** The set of status values that the gsd-verifier agent emits. */
|
|
const VERIFIER_STATUSES: ReadonlyArray<string> = ['passed', 'gaps_found', 'human_needed'];
|
|
|
|
// ─── Routing table ────────────────────────────────────────────────────────────
|
|
|
|
interface VerificationRoute {
|
|
status: string;
|
|
next_action: string;
|
|
next_command: string;
|
|
}
|
|
|
|
/**
|
|
* Canonical routing table for verification statuses.
|
|
*
|
|
* This is the single source of truth — ship.md and execute-phase.md will
|
|
* later import from here instead of embedding their own message strings.
|
|
*
|
|
* INTERNAL SENTINELS: 'missing' and 'unknown' are operational states constructed
|
|
* internally — the verifier (gsd-verifier.md) never emits them. The verifier only
|
|
* emits values in VERIFIER_STATUSES (passed|gaps_found|human_needed). The guard in
|
|
* readVerificationStatus excludes 'missing' and 'unknown' from raw-status table
|
|
* lookup so they can only be reached via internal construction paths.
|
|
*
|
|
* For 'gaps_found', next_command is built at call time in readVerificationStatus
|
|
* by substituting the phase number — it is NOT stored as a function in the table.
|
|
*/
|
|
const VERIFICATION_ROUTING_TABLE: Record<string, VerificationRoute> = {
|
|
passed: {
|
|
status: 'passed',
|
|
next_action: 'Verification passed — continue.',
|
|
next_command: '',
|
|
},
|
|
gaps_found: {
|
|
status: 'gaps_found',
|
|
next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
|
|
// next_command is computed at call time; this entry is never returned directly.
|
|
next_command: '',
|
|
},
|
|
human_needed: {
|
|
status: 'human_needed',
|
|
next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
|
|
next_command: '',
|
|
},
|
|
// INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
|
|
// the file has no parseable frontmatter status. Never emitted by the verifier.
|
|
missing: {
|
|
status: 'missing',
|
|
next_action: 'No verification report found — the verify step never completed. Re-run execute-phase.',
|
|
next_command: '/gsd:execute-phase',
|
|
},
|
|
// INTERNAL SENTINEL: constructed when the file has a status value not in
|
|
// VERIFIER_STATUSES. Never emitted by the verifier.
|
|
unknown: {
|
|
status: 'unknown',
|
|
next_action: '', // filled in dynamically with the raw value
|
|
next_command: '/gsd:execute-phase',
|
|
},
|
|
};
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
interface FsLike {
|
|
readdirSync(dir: string): string[];
|
|
readFileSync(filePath: string, encoding: 'utf-8'): string;
|
|
}
|
|
|
|
/**
|
|
* Build a 'missing' result from the routing table.
|
|
* Used for two early-return paths: no *-VERIFICATION.md file found, and
|
|
* file present but no parseable frontmatter status.
|
|
*/
|
|
function missingResult(): VerificationStatusResult {
|
|
const route = VERIFICATION_ROUTING_TABLE['missing'];
|
|
return {
|
|
status: route.status,
|
|
next_action: route.next_action,
|
|
next_command: route.next_command,
|
|
};
|
|
}
|
|
|
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
interface ReadVerificationStatusOptions {
|
|
fs?: FsLike;
|
|
}
|
|
|
|
interface VerificationStatusResult {
|
|
status: string;
|
|
next_action: string;
|
|
next_command: string;
|
|
}
|
|
|
|
/**
|
|
* Read the verification status from the first `*-VERIFICATION.md` file in
|
|
* phaseDir and return the routing result.
|
|
*
|
|
* Behavior:
|
|
* 1. Find the first file matching `*-VERIFICATION.md` (sorted, take first).
|
|
* If none → status 'missing'.
|
|
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
|
|
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
|
|
* If no frontmatter block or no `status` key → status 'missing'.
|
|
* 3. Map to routing table. Unknown non-empty value → status 'unknown'.
|
|
*
|
|
* @param phaseDir - Absolute path to the phase directory.
|
|
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
|
|
*/
|
|
function readVerificationStatus(
|
|
phaseDir: string,
|
|
opts: ReadVerificationStatusOptions = {},
|
|
): VerificationStatusResult {
|
|
const fsImpl: FsLike = opts.fs ?? fs;
|
|
|
|
// Phase token for the gaps_found command
|
|
const baseName = path.basename(phaseDir);
|
|
const phaseToken = extractPhaseToken(baseName);
|
|
const phaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
|
|
|
|
// 1. Find *-VERIFICATION.md
|
|
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;
|
|
} catch {
|
|
// Directory unreadable → treat as missing
|
|
verificationFile = null;
|
|
}
|
|
|
|
if (!verificationFile) {
|
|
return missingResult();
|
|
}
|
|
|
|
// 2. Read and parse frontmatter using the shared parser.
|
|
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
|
|
const filePath = path.join(phaseDir, verificationFile);
|
|
let rawStatus: string | null = null;
|
|
try {
|
|
const content = fsImpl.readFileSync(filePath, 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
const statusVal = fm['status'];
|
|
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
|
|
// only accept string values — arrays and objects are not valid status values.
|
|
if (typeof statusVal === 'string') {
|
|
const trimmed = statusVal.trim();
|
|
rawStatus = trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
} catch {
|
|
rawStatus = null;
|
|
}
|
|
|
|
if (!rawStatus) {
|
|
return missingResult();
|
|
}
|
|
|
|
// 3. Route — exclude internal sentinels from raw-file lookup (they are
|
|
// constructed internally above, never written by the verifier).
|
|
if (rawStatus in VERIFICATION_ROUTING_TABLE && rawStatus !== 'missing' && rawStatus !== 'unknown') {
|
|
const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
|
|
// gaps_found: build the phase-specific command here rather than in the table.
|
|
const next_command =
|
|
rawStatus === 'gaps_found'
|
|
? `/gsd:plan-phase ${phaseNumber} --gaps`
|
|
: entry.next_command;
|
|
return {
|
|
status: entry.status,
|
|
next_action: entry.next_action,
|
|
next_command,
|
|
};
|
|
}
|
|
|
|
// Unknown value
|
|
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
|
|
return {
|
|
status: unknownRoute.status,
|
|
next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`,
|
|
next_command: unknownRoute.next_command,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
|
|
* emit via io.output().
|
|
*
|
|
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
|
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
|
* @param raw - Whether to emit raw (non-JSON) output.
|
|
*/
|
|
function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void {
|
|
if (!phaseDirArg) {
|
|
error('phase directory required for verification.status');
|
|
return;
|
|
}
|
|
const phaseDir = path.resolve(cwd, phaseDirArg);
|
|
const result = readVerificationStatus(phaseDir);
|
|
output(result, raw);
|
|
}
|
|
|
|
export = {
|
|
VERIFIER_STATUSES,
|
|
VERIFICATION_ROUTING_TABLE,
|
|
readVerificationStatus,
|
|
cmdVerificationStatus,
|
|
};
|