diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index db88b5e7f..26bde55ea 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -11,9 +11,10 @@ import type { GSDState } from "./types.js"; import type { GSDPreferences } from "./preferences.js"; -import type { UatType } from "./files.js"; -import { loadFile, extractUatType, loadActiveOverrides } from "./files.js"; +import { loadFile, loadActiveOverrides } from "./files.js"; import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; +import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js"; +import { extractUatType } from "./files.js"; import { resolveMilestoneFile, @@ -188,20 +189,10 @@ export const DISPATCH_RULES: DispatchRule[] = [ if (!resultFile) continue; const content = await loadFile(resultFile); if (!content) continue; - const verdictMatch = content.match(/verdict:\s*([\w-]+)/i); - const verdict = verdictMatch?.[1]?.toLowerCase(); - - // Determine acceptable verdicts based on UAT type. - // mixed / human-experience / live-runtime modes may legitimately - // produce PARTIAL when all automatable checks pass but human-only - // checks remain — this should not block progression. - const acceptableVerdicts: string[] = ["pass", "passed"]; + const verdict = extractVerdict(content); const uatType = extractUatType(content); - if (uatType === "mixed" || uatType === "human-experience" || uatType === "live-runtime") { - acceptableVerdicts.push("partial"); - } - if (verdict && !acceptableVerdicts.includes(verdict)) { + if (verdict && !isAcceptableUatVerdict(verdict, uatType)) { return { action: "stop" as const, reason: `UAT verdict for ${sliceId} is "${verdict}" — blocking progression until resolved.\nReview the UAT result and update the verdict to PASS, or re-run /gsd auto after fixing.`, diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 876e68cb8..593a961e6 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -6,8 +6,9 @@ * utility. */ -import { loadFile, parseContinue, parseSummary, extractUatType, loadActiveOverrides, formatOverridesSection, parseTaskPlanFile } from "./files.js"; +import { loadFile, parseContinue, parseSummary, loadActiveOverrides, formatOverridesSection, parseTaskPlanFile } from "./files.js"; import type { Override, UatType } from "./files.js"; +import { hasVerdict, getUatType } from "./verdict-parser.js"; import { loadPrompt, inlineTemplate } from "./prompt-loader.js"; import { resolveMilestoneFile, resolveSliceFile, resolveSlicePath, @@ -781,8 +782,8 @@ export async function checkNeedsRunUat( const uatContent = await loadFile(uatFile); if (!uatContent) return null; // If the UAT file already contains a verdict, UAT has been run — skip - if (/verdict:\s*[\w-]+/i.test(uatContent)) return null; - const uatType = extractUatType(uatContent) ?? "artifact-driven"; + if (hasVerdict(uatContent)) return null; + const uatType = getUatType(uatContent); return { sliceId: sid, uatType }; } } @@ -805,8 +806,8 @@ export async function checkNeedsRunUat( const uatContentFb = await loadFile(uatFileFb); if (!uatContentFb) return null; // If the UAT file already contains a verdict, UAT has been run — skip - if (/verdict:\s*[\w-]+/i.test(uatContentFb)) return null; - const uatTypeFb = extractUatType(uatContentFb) ?? "artifact-driven"; + if (hasVerdict(uatContentFb)) return null; + const uatTypeFb = getUatType(uatContentFb); return { sliceId: uatSid, uatType: uatTypeFb }; } @@ -1504,7 +1505,7 @@ export async function buildRunUatPrompt( const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`); const uatResultPath = join(base, relSliceFile(base, mid, sliceId, "UAT")); - const uatType = extractUatType(uatContent) ?? "artifact-driven"; + const uatType = getUatType(uatContent); return loadPrompt("run-uat", { workingDirectory: base, diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 7550626c9..59ddd6d2b 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -40,6 +40,7 @@ import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser- import { join, resolve } from 'path'; import { existsSync, readdirSync } from 'node:fs'; import { debugCount, debugTime } from './debug-logger.js'; +import { extractVerdict } from './verdict-parser.js'; import { isDbAvailable, @@ -91,11 +92,8 @@ export function isMilestoneComplete(roadmap: Roadmap): boolean { * after remediation slices are executed. */ export function isValidationTerminal(validationContent: string): boolean { - const match = validationContent.match(/^---\n([\s\S]*?)\n---/); - if (!match) return false; - const verdict = match[1].match(/verdict:\s*(\S+)/); - if (!verdict) return false; - const v = verdict[1] === 'passed' ? 'pass' : verdict[1]; + const v = extractVerdict(validationContent); + if (!v) return false; // 'pass' and 'needs-attention' are always terminal. // 'needs-remediation' is treated as terminal to prevent infinite loops // when no remediation slices exist in the roadmap (#832). The validation diff --git a/src/resources/extensions/gsd/tools/validate-milestone.ts b/src/resources/extensions/gsd/tools/validate-milestone.ts index eae1d8245..856ced060 100644 --- a/src/resources/extensions/gsd/tools/validate-milestone.ts +++ b/src/resources/extensions/gsd/tools/validate-milestone.ts @@ -14,6 +14,7 @@ import { import { resolveMilestonePath, clearPathCache } from "../paths.js"; import { saveFile, clearParseCache } from "../files.js"; import { invalidateStateCache } from "../state.js"; +import { VALIDATION_VERDICTS, isValidMilestoneVerdict } from "../verdict-parser.js"; export interface ValidateMilestoneParams { milestoneId: string; @@ -71,9 +72,8 @@ export async function handleValidateMilestone( if (!params.milestoneId || typeof params.milestoneId !== "string" || params.milestoneId.trim() === "") { return { error: "milestoneId is required and must be a non-empty string" }; } - const validVerdicts = ["pass", "needs-attention", "needs-remediation"]; - if (!validVerdicts.includes(params.verdict)) { - return { error: `verdict must be one of: ${validVerdicts.join(", ")}` }; + if (!isValidMilestoneVerdict(params.verdict)) { + return { error: `verdict must be one of: ${VALIDATION_VERDICTS.join(", ")}` }; } // ── Filesystem render ────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/verdict-parser.ts b/src/resources/extensions/gsd/verdict-parser.ts new file mode 100644 index 000000000..18794436a --- /dev/null +++ b/src/resources/extensions/gsd/verdict-parser.ts @@ -0,0 +1,95 @@ +/** + * Centralized verdict extraction, normalization, and schema validation. + * + * All verdict-related logic lives here so that normalization rules + * (e.g. `passed` → `pass`) are applied consistently across the codebase. + */ + +import { extractUatType } from "./files.js"; +import type { UatType } from "./files.js"; + +// ── Verdict extraction ────────────────────────────────────────────────── + +/** + * Extract and normalize the `verdict` value from YAML frontmatter. + * + * Normalization: + * - lowercased + * - `passed` → `pass` + * + * Returns `undefined` when frontmatter is absent or has no `verdict` field. + */ +export function extractVerdict(content: string): string | undefined { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) return undefined; + const verdictMatch = fmMatch[1].match(/verdict:\s*([\w-]+)/i); + if (!verdictMatch) return undefined; + let v = verdictMatch[1].toLowerCase(); + if (v === "passed") v = "pass"; + return v; +} + +/** + * Returns `true` when the content's frontmatter contains a `verdict` field. + */ +export function hasVerdict(content: string): boolean { + return /verdict:\s*[\w-]+/i.test(content); +} + +// ── UAT verdict schema ────────────────────────────────────────────────── + +/** + * Base verdicts that are always acceptable for UAT results. + */ +export const UAT_ACCEPTABLE_VERDICTS: readonly string[] = ["pass", "passed"]; + +/** + * UAT types whose results may legitimately produce a `partial` verdict + * when all automatable checks pass but human-only checks remain. + */ +const PARTIAL_ELIGIBLE_UAT_TYPES: readonly UatType[] = [ + "mixed", + "human-experience", + "live-runtime", +]; + +/** + * Check whether a verdict is acceptable for a given UAT type. + * + * `pass` / `passed` are always acceptable. `partial` is acceptable only for + * UAT types that include non-automatable human checks. + */ +export function isAcceptableUatVerdict(verdict: string, uatType: UatType | undefined): boolean { + if (UAT_ACCEPTABLE_VERDICTS.includes(verdict)) return true; + if (verdict === "partial" && uatType && (PARTIAL_ELIGIBLE_UAT_TYPES as readonly string[]).includes(uatType)) { + return true; + } + return false; +} + +// ── Milestone validation verdict schema ───────────────────────────────── + +/** + * Valid verdicts for the `validate-milestone` tool. + */ +export const VALIDATION_VERDICTS = ["pass", "needs-attention", "needs-remediation"] as const; +export type ValidationVerdict = (typeof VALIDATION_VERDICTS)[number]; + +/** + * Check whether a string is a valid milestone validation verdict. + */ +export function isValidMilestoneVerdict(verdict: string): verdict is ValidationVerdict { + return (VALIDATION_VERDICTS as readonly string[]).includes(verdict); +} + +// ── UAT type helper ───────────────────────────────────────────────────── + +/** + * Extract the UAT type from content, defaulting to `"artifact-driven"`. + * + * The `"artifact-driven"` fallback is the original default used throughout + * the codebase when a UAT file lacks an explicit `## UAT Type` section. + */ +export function getUatType(content: string): UatType { + return extractUatType(content) ?? "artifact-driven"; +}