diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index 0de42700c..ac368b5c6 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -306,6 +306,34 @@ result: pass } }); + it("emits NO_ITEMS_EXTRACTED reason when UAT file has no parseable items, orphans, or placeholders", async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c14-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-no-items'); + 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 +--- + +This phase doesn't have any UAT items yet. +Prose only. +`; + 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(0); + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.NO_ITEMS_EXTRACTED); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + it("emits BRACKETED_PLACEHOLDER reason when result value is wrapped in brackets", async () => { const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c13-')); try { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index af563f5d7..381236c13 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -145,6 +145,8 @@ export async function isPhaseUatPassed( const relFile = relative(projectDir, filePath); const content = await readFile(filePath, 'utf-8'); const strippedBody = stripMarkdownInjection(content); + const itemsBeforeFile = items.length; + const reasonsBeforeFile = reasons.length; const parsed = parseAllUatItems(content); for (const item of parsed) { items.push(item); @@ -209,6 +211,11 @@ export async function isPhaseUatPassed( capturedValue: 'human_needed', }); } + + // If this file contributed no items and no diagnostic reasons, flag it. + if (items.length === itemsBeforeFile && reasons.length === reasonsBeforeFile) { + reasons.push({ code: REASON_CODE.NO_ITEMS_EXTRACTED, file: relFile }); + } } const passed = items.length > 0 && reasons.length === 0;