diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 6b011a1d3..19cc73cbd 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -306,6 +306,39 @@ result: pass } }); + it("emits ORPHAN_ITEM_MISSING_RESULT reason for headings missing the result field", async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c12-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-orphan-heading'); + 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 +--- + +### 1. Forgot to fill this in +expected: something + +### 2. Real one +expected: works +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(1); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.ORPHAN_ITEM_MISSING_RESULT); + expect(result.reasons[0].itemName).toBe('Forgot to fill this in'); + } 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 bb41dce92..645b10ce2 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -15,6 +15,9 @@ export const REASON_CODE = Object.freeze({ NON_PASS_RESULT: 'non_pass_result', CASE_MISMATCH: 'case_mismatch', HUMAN_VERIFICATION_NEEDED: 'human_verification_needed', + ORPHAN_ITEM_MISSING_RESULT: 'orphan_item_missing_result', + BRACKETED_PLACEHOLDER: 'bracketed_placeholder', + NO_ITEMS_EXTRACTED: 'no_items_extracted', NO_PHASE_DIR: 'no_phase_dir', NO_UAT_FILES: 'no_uat_files', } as const); @@ -75,6 +78,33 @@ function parseAllUatItems(content: string): UatItem[] { return items; } +const HEADING_PATTERN = /###\s*(\d+)\.\s*([^\n]+)/g; + +/** + * Scan stripped body for `### N. Name` headings whose number is NOT represented + * in the set of captured item numbers. Returns orphan entries. + * Headings that have a bracketed result line are excluded here — they will be + * handled by bracketed-placeholder detection (cycle 13). + */ +function findOrphanHeadings( + strippedBody: string, + capturedNumbers: Set, + brackPlaceholderNumbers: Set, +): Array<{ num: number; name: string }> { + const orphans: Array<{ num: number; name: string }> = []; + HEADING_PATTERN.lastIndex = 0; + let m: RegExpMatchArray | null; + while ((m = HEADING_PATTERN.exec(strippedBody)) !== null) { + const num = parseInt(m[1], 10); + const name = m[2].trim(); + if (!capturedNumbers.has(num) && !brackPlaceholderNumbers.has(num)) { + orphans.push({ num, name }); + } + } + HEADING_PATTERN.lastIndex = 0; + return orphans; +} + export async function isPhaseUatPassed( projectDir: string, phase: string, @@ -114,6 +144,7 @@ export async function isPhaseUatPassed( const filePath = join(dir, file); const relFile = relative(projectDir, filePath); const content = await readFile(filePath, 'utf-8'); + const strippedBody = stripMarkdownInjection(content); const parsed = parseAllUatItems(content); for (const item of parsed) { items.push(item); @@ -131,6 +162,39 @@ export async function isPhaseUatPassed( } } + // Detect bracketed placeholders (cycle 13): headings with result: [value] + const brackPlaceholderNumbers = new Set(); + const BRACK_RESULT_PATTERN = /result:\s*\[(\w+)\]/g; + let bm: RegExpMatchArray | null; + BRACK_RESULT_PATTERN.lastIndex = 0; + while ((bm = BRACK_RESULT_PATTERN.exec(strippedBody)) !== null) { + // find nearest preceding heading + const before = strippedBody.slice(0, bm.index); + const headingMatch = before.match(/###\s*(\d+)\.\s*([^\n]+)\s*$/); + if (headingMatch) { + const num = parseInt(headingMatch[1], 10); + const name = headingMatch[2].trim(); + brackPlaceholderNumbers.add(num); + reasons.push({ + code: REASON_CODE.BRACKETED_PLACEHOLDER, + file: relFile, + itemName: name, + capturedValue: `[${bm[1]}]`, + }); + } + } + + // Detect orphan headings: headings with no captured item and no bracketed result. + const capturedNumbers = new Set(parsed.map((i) => i.test)); + const orphans = findOrphanHeadings(strippedBody, capturedNumbers, brackPlaceholderNumbers); + for (const orphan of orphans) { + reasons.push({ + code: REASON_CODE.ORPHAN_ITEM_MISSING_RESULT, + file: relFile, + itemName: orphan.name, + }); + } + // Merge frontmatter human_verification items into the roster. const fm = extractFrontmatter(content); const fmItems = parseVerificationFrontmatterItems(fm);