diff --git a/sdk/src/query/phase-list-queries.ts b/sdk/src/query/phase-list-queries.ts index 17335cfc0..65aae7ebe 100644 --- a/sdk/src/query/phase-list-queries.ts +++ b/sdk/src/query/phase-list-queries.ts @@ -17,7 +17,7 @@ import { import type { QueryHandler } from './utils.js'; /** Resolve `.planning/phases/` for a phase token, or null. */ -async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise { +export async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise { const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); try { diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts new file mode 100644 index 000000000..46ad6993f --- /dev/null +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -0,0 +1,45 @@ +/** + * Unit tests for isPhaseUatPassed — walking skeleton (cycle 1 of ~15). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { isPhaseUatPassed } from './phase-uat-passed.js'; + +const UAT_PASS_CONTENT = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +### 1. First item +expected: thing should happen +result: pass +`; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-uat-passed-')); + const phaseDir = join(tmpDir, '.planning', 'phases', '05-walking-skeleton'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), UAT_PASS_CONTENT); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('isPhaseUatPassed', () => { + it('returns passed=true when a single UAT file contains one pass result', async () => { + const result = await isPhaseUatPassed(tmpDir, '5'); + expect(result.passed).toBe(true); + expect(result.items.length).toBe(1); + expect(result.items[0].result).toBe('pass'); + expect(result.reasons.length).toBe(0); + }); +}); diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts new file mode 100644 index 000000000..8f72eb0bf --- /dev/null +++ b/sdk/src/query/phase-uat-passed.ts @@ -0,0 +1,60 @@ +/** + * isPhaseUatPassed — SDK predicate answering "is phase N's UAT contract satisfied?" + * + * Cycle 1 of ~15 (walking skeleton): happy path only. No injection stripping, + * no orphan detection, no human_verification frontmatter merge, no REASON_CODEs. + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { resolvePhaseDir } from './phase-list-queries.js'; + +/** Regex to parse all UAT items regardless of result value. */ +const UAT_ITEM_PATTERN = + /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*(\w+)/g; + +function parseAllUatItems(content: string): Record[] { + const items: Record[] = []; + UAT_ITEM_PATTERN.lastIndex = 0; + let m: RegExpMatchArray | null; + while ((m = UAT_ITEM_PATTERN.exec(content)) !== null) { + const [, num, name, expected, result] = m; + items.push({ + test: parseInt(num, 10), + name: name.trim(), + expected: expected.trim(), + result, + }); + } + UAT_ITEM_PATTERN.lastIndex = 0; + return items; +} + +export async function isPhaseUatPassed( + projectDir: string, + phase: string, + workstream?: string, +): Promise<{ + passed: boolean; + reasons: unknown[]; + reasonsHuman: string[]; + items: Record[]; +}> { + const dir = await resolvePhaseDir(phase, projectDir, workstream); + if (!dir) { + return { passed: false, reasons: [], reasonsHuman: [], items: [] }; + } + + const files = await readdir(dir); + const uatFiles = files.filter((f) => f.endsWith('-HUMAN-UAT.md')); + + const items: Record[] = []; + for (const file of uatFiles) { + const content = await readFile(join(dir, file), 'utf-8'); + items.push(...parseAllUatItems(content)); + } + + const passed = items.length > 0 && items.every((i) => i.result === 'pass'); + + return { passed, reasons: [], reasonsHuman: [], items }; +}