From d7aa4ce6e32821275b8df1413d4bcd29cdc572b8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 18 May 2026 22:52:25 -0400 Subject: [PATCH] feat(sdk): merge frontmatter human_verification items into roster (#3184) Cycle 11 of ~15: reuses parseVerificationFrontmatterItems from uat.ts. Frontmatter-declared manual-verification items roll into the same items[] roster and emit HUMAN_VERIFICATION_NEEDED reasons so passed=false is justified, not silent. --- sdk/src/query/phase-uat-passed.test.ts | 33 ++++++++++++++++++++++++++ sdk/src/query/phase-uat-passed.ts | 18 ++++++++++++++ sdk/src/query/uat.ts | 2 +- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 69f247ca2..6b011a1d3 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -273,6 +273,39 @@ expected: thing } }); + it("human_verification items in frontmatter contribute HUMAN_VERIFICATION_NEEDED reasons", async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c11-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-human-verification'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +human_verification: + - name: manual smoke test + expected: app loads +--- + +### 1. Real pass +expected: thing +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(false); + expect(result.items.length).toBe(2); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.HUMAN_VERIFICATION_NEEDED); + expect(result.reasons[0].itemName).toBe('manual smoke test'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + it("emits CASE_MISMATCH reason when result value is \"PASS\" (uppercase variant of pass)", async () => { const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c10-')); try { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index cfd1323f2..bb41dce92 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -8,10 +8,13 @@ import { readFile, readdir } from 'node:fs/promises'; import { join, relative } from 'node:path'; import { resolvePhaseDir } from './phase-list-queries.js'; +import { extractFrontmatter } from './frontmatter.js'; +import { parseVerificationFrontmatterItems } from './uat.js'; export const REASON_CODE = Object.freeze({ NON_PASS_RESULT: 'non_pass_result', CASE_MISMATCH: 'case_mismatch', + HUMAN_VERIFICATION_NEEDED: 'human_verification_needed', NO_PHASE_DIR: 'no_phase_dir', NO_UAT_FILES: 'no_uat_files', } as const); @@ -127,6 +130,21 @@ export async function isPhaseUatPassed( }); } } + + // Merge frontmatter human_verification items into the roster. + const fm = extractFrontmatter(content); + const fmItems = parseVerificationFrontmatterItems(fm); + for (const fmItem of fmItems) { + const name = String(fmItem.name ?? ''); + // Add a synthetic UatItem so items.length is accurate. + items.push({ test: -1, name, expected: String(fmItem.expected ?? ''), result: 'human_needed' }); + reasons.push({ + code: REASON_CODE.HUMAN_VERIFICATION_NEEDED, + file: relFile, + itemName: name, + capturedValue: 'human_needed', + }); + } } const passed = items.length > 0 && reasons.length === 0; diff --git a/sdk/src/query/uat.ts b/sdk/src/query/uat.ts index f2b9bde8d..6ecf96ebf 100644 --- a/sdk/src/query/uat.ts +++ b/sdk/src/query/uat.ts @@ -195,7 +195,7 @@ function parseUatItems(content: string): Record[] { * rather than the body, parseVerificationItems was returning [] because it * only searched the body for a "## Human Verification" heading. */ -function parseVerificationFrontmatterItems(fm: Record): Record[] { +export function parseVerificationFrontmatterItems(fm: Record): Record[] { const items: Record[] = []; const hvArray = fm.human_verification; if (!Array.isArray(hvArray)) return items;