refine: consolidate verdict parsing and schema validation

Extract verdict extraction, normalization, and schema validation into a
single verdict-parser.ts module. This fixes inconsistent normalization
where `passed` was normalized to `pass` in state.ts but not in
auto-dispatch.ts or auto-prompts.ts, and centralizes scattered verdict
schema definitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-25 22:48:10 -06:00
parent ef5006e16d
commit 26aa82f02e
5 changed files with 113 additions and 28 deletions

View file

@ -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.`,

View file

@ -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,

View file

@ -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

View file

@ -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 ──────────────────────────────────────────────────

View file

@ -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";
}