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:
parent
ef5006e16d
commit
26aa82f02e
5 changed files with 113 additions and 28 deletions
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
95
src/resources/extensions/gsd/verdict-parser.ts
Normal file
95
src/resources/extensions/gsd/verdict-parser.ts
Normal 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";
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue