diff --git a/.changeset/3184-phase-uat-passed.md b/.changeset/3184-phase-uat-passed.md new file mode 100644 index 000000000..33ee1863f --- /dev/null +++ b/.changeset/3184-phase-uat-passed.md @@ -0,0 +1,6 @@ +--- +type: Added +pr: 3713 +--- +**`phase.uat-passed` query and `isPhaseUatPassed` SDK export** — adds canonical read-only query `phase.uat-passed` (alias `phase uat-passed`) and programmatic export `isPhaseUatPassed(projectDir, phase, workstream?)` returning typed `{ passed, reasons, reasonsHuman, items }`. Predicate consumes `*-HUMAN-UAT.md` files; hardens against markdown injection (YAML frontmatter, fenced code blocks, HTML comments, blockquote-prefixed lines); surfaces operator-mistake signals as typed `REASON_CODE` frozen enum values (`CASE_MISMATCH`, `ORPHAN_ITEM_MISSING_RESULT`, `BRACKETED_PLACEHOLDER`, `NO_ITEMS_EXTRACTED`). Typed `PhaseUatPassedError extends GSDError` for invalid arguments. Refs #3184. + diff --git a/get-shit-done/bin/lib/command-aliases.generated.cjs b/get-shit-done/bin/lib/command-aliases.generated.cjs index 7c48ef134..fc5873ed5 100644 --- a/get-shit-done/bin/lib/command-aliases.generated.cjs +++ b/get-shit-done/bin/lib/command-aliases.generated.cjs @@ -389,6 +389,14 @@ const PHASE_COMMAND_ALIASES = [ "subcommand": "list-artifacts", "mutation": false }, + { + "canonical": "phase.uat-passed", + "aliases": [ + "phase uat-passed" + ], + "subcommand": "uat-passed", + "mutation": false + }, { "canonical": "phase.next-decimal", "aliases": [ diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 79f1584a5..16b4bb17e 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -343,6 +343,20 @@ export type { WSTransportOptions } from './ws-transport.js'; // Query registry argv normalization (matches `gsd-sdk query` and `GSDTools` hot path) export { createRegistry, normalizeQueryCommand } from './query/index.js'; +// Phase UAT predicate — programmatic API surface (#3184) +export { + isPhaseUatPassed, + phaseUatPassed, + REASON_CODE, + ERROR_CODE, + PhaseUatPassedError, +} from './query/phase-uat-passed.js'; +export type { + UatReason, + ReasonCode, + ErrorCode, +} from './query/phase-uat-passed.js'; + // Workstream utilities export { validateWorkstreamName, relPlanningPath } from './workstream-utils.js'; diff --git a/sdk/src/query/command-aliases.generated.ts b/sdk/src/query/command-aliases.generated.ts index 6c79b91e6..93f17ac91 100644 --- a/sdk/src/query/command-aliases.generated.ts +++ b/sdk/src/query/command-aliases.generated.ts @@ -67,6 +67,7 @@ export const INIT_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [ export const PHASE_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [ { canonical: 'phase.list-plans', aliases: ['phase list-plans'], subcommand: 'list-plans', mutation: false }, { canonical: 'phase.list-artifacts', aliases: ['phase list-artifacts'], subcommand: 'list-artifacts', mutation: false }, + { canonical: 'phase.uat-passed', aliases: ['phase uat-passed'], subcommand: 'uat-passed', mutation: false }, { canonical: 'phase.next-decimal', aliases: ['phase next-decimal'], subcommand: 'next-decimal', mutation: false }, { canonical: 'phase.add', aliases: ['phase add'], subcommand: 'add', mutation: true }, { canonical: 'phase.add-batch', aliases: ['phase add-batch'], subcommand: 'add-batch', mutation: true }, diff --git a/sdk/src/query/command-family-handlers.ts b/sdk/src/query/command-family-handlers.ts index 11470e8c5..d3b00f103 100644 --- a/sdk/src/query/command-family-handlers.ts +++ b/sdk/src/query/command-family-handlers.ts @@ -24,6 +24,7 @@ import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, va import { phaseListPlans, phaseListArtifacts, } from './phase-list-queries.js'; +import { phaseUatPassed } from './phase-uat-passed.js'; import { phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete, phaseScaffold, phaseNextDecimal, phasesList, phasesClear, phasesArchive, @@ -86,6 +87,7 @@ export const FAMILY_HANDLERS: Record` 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..15158c64a --- /dev/null +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -0,0 +1,593 @@ +/** + * Tests for isPhaseUatPassed across UAT result classification paths. + */ + +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, REASON_CODE, PhaseUatPassedError, ERROR_CODE } from './phase-uat-passed.js'; +import { createRegistry } from './index.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); + }); + + it('returns passed=false with NO_UAT_FILES reason when phase dir has no UAT files', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c4-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-empty'); + await mkdir(phaseDir, { recursive: true }); + // Write a non-UAT file to ensure the dir exists but has no *-HUMAN-UAT.md + await writeFile(join(phaseDir, '05-PLAN.md'), '# Plan\nNothing here.\n'); + + 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_UAT_FILES); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('returns passed=false with NO_PHASE_DIR reason when phase has no directory', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c3-')); + try { + const otherPhaseDir = join(localTmp, '.planning', 'phases', '06-other'); + await mkdir(otherPhaseDir, { recursive: true }); + await writeFile(join(otherPhaseDir, '06-HUMAN-UAT.md'), UAT_PASS_CONTENT); + + // Query phase 5 which has NO directory in this fixture + 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_PHASE_DIR); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('returns passed=false with NON_PASS_RESULT reason when single UAT item has result: issue', async () => { + const nonPassContent = `--- +status: complete +phase: 5 +source: roadmap +started: 2026-05-18T00:00:00Z +updated: 2026-05-18T00:00:00Z +--- + +### 1. Some item +expected: thing happens +result: issue +`; + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c2-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-non-pass'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), nonPassContent); + + 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.NON_PASS_RESULT); + expect(result.reasons[0].capturedValue).toBe('issue'); + expect(result.reasons[0].itemName).toBe('Some item'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('ignores ### item content inside YAML frontmatter region', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c5-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-frontmatter-injection'); + 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 +malicious_demo: | +### 1. Frontmatter-injected item +expected: nothing +result: pass +--- + +### 1. Real item +expected: real thing +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(true); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Real item'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('ignores ### item content inside fenced code blocks', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c6-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-fenced-injection'); + 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 +--- + +Some prose. + +\`\`\`markdown +### 1. Fenced example +expected: blah +result: pass +\`\`\` + +### 1. Real item +expected: real +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Real item'); + expect(result.passed).toBe(true); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('ignores ### item content inside HTML comments', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c7-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-html-comment-injection'); + 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. Real item +expected: real +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Real item'); + expect(result.passed).toBe(true); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('ignores ### item content on blockquote-prefixed lines', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c8-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-blockquote-injection'); + 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. Quoted item +expected: blah +result: pass + +### 1. Real item +expected: real +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Real item'); + expect(result.passed).toBe(true); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('parses bold-prefixed **result:** key as equivalent to bare result:', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c9-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-bold-key'); + 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. Bold-key item +expected: thing +**result:** pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('Bold-key item'); + expect(result.items[0].result).toBe('pass'); + expect(result.passed).toBe(true); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + 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("throws PhaseUatPassedError with PROJECT_DIR_MISSING code when projectDir does not exist", async () => { + const missingPath = `/nonexistent/path/that/should/never/be/real-${Date.now()}`; + let thrown: unknown; + try { + await isPhaseUatPassed(missingPath, '5'); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(PhaseUatPassedError); + expect((thrown as PhaseUatPassedError).code).toBe(ERROR_CODE.PROJECT_DIR_MISSING); + }); + + 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 { + const phaseDir = join(localTmp, '.planning', 'phases', '05-bracketed-placeholder'); + 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 in result +expected: thing +result: [pending] +`; + 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.BRACKETED_PLACEHOLDER); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + 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 { + const phaseDir = join(localTmp, '.planning', 'phases', '05-case-mismatch'); + 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. Uppercase pass item +expected: thing happens +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.CASE_MISMATCH); + expect(result.reasons[0].capturedValue).toBe('PASS'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('preserves UAT body content when file contains an internal --- horizontal rule', async () => { + // Regression: frontmatter regex with /m flag treated mid-body --- as a + // second frontmatter block and stripped everything between them. + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-hrule-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-hrule'); + await mkdir(phaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +--- + +### 1. Item above rule +expected: works +result: pass + +--- + +### 2. Item below rule +expected: also works +result: pass +`; + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5'); + expect(result.passed).toBe(true); + expect(result.items.length).toBe(2); + expect(result.items[0].name).toBe('Item above rule'); + expect(result.items[1].name).toBe('Item below rule'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('aggregates items and reasons across multiple UAT files in the same phase', async () => { + // Finding #4: multiple *-HUMAN-UAT.md files — all-pass + one failure + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-multi-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-multi'); + await mkdir(phaseDir, { recursive: true }); + + const passFile = `--- +status: complete +phase: 5 +--- + +### 1. Passing check +expected: it works +result: pass +`; + const failFile = `--- +status: complete +phase: 5 +--- + +### 1. Failing check +expected: it works +result: issue + +### 2. Another pass +expected: also works +result: pass +`; + await writeFile(join(phaseDir, '05a-HUMAN-UAT.md'), passFile); + await writeFile(join(phaseDir, '05b-HUMAN-UAT.md'), failFile); + + const result = await isPhaseUatPassed(localTmp, '5'); + // Total items: 1 from first file + 2 from second file + expect(result.items.length).toBe(3); + // One NON_PASS_RESULT reason from the failing item + expect(result.reasons.length).toBe(1); + expect(result.reasons[0].code).toBe(REASON_CODE.NON_PASS_RESULT); + expect(result.passed).toBe(false); + // reasonsHuman must be non-empty + expect(result.reasonsHuman.length).toBe(1); + expect(typeof result.reasonsHuman[0]).toBe('string'); + expect(result.reasonsHuman[0].length).toBeGreaterThan(0); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('throws PhaseUatPassedError with INVALID_PHASE_NUM when phase arg is empty', async () => { + // Finding #5: INVALID_PHASE_NUM error path via phaseUatPassed handler + const { phaseUatPassed, ERROR_CODE: EC } = await import('./phase-uat-passed.js'); + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-invalidphase-')); + try { + let thrown: unknown; + try { + await phaseUatPassed([], localTmp); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(PhaseUatPassedError); + expect((thrown as PhaseUatPassedError).code).toBe(EC.INVALID_PHASE_NUM); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); + + it('resolves isPhaseUatPassed via workstream routing', async () => { + // Finding #6: workstream-keyed project under .planning/workstreams/ws1/phases/ + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-ws-')); + try { + const wsPhaseDir = join(localTmp, '.planning', 'workstreams', 'ws1', 'phases', '05-ws-phase'); + await mkdir(wsPhaseDir, { recursive: true }); + const content = `--- +status: complete +phase: 5 +--- + +### 1. WS item +expected: works in workstream +result: pass +`; + await writeFile(join(wsPhaseDir, '05-HUMAN-UAT.md'), content); + + const result = await isPhaseUatPassed(localTmp, '5', 'ws1'); + expect(result.passed).toBe(true); + expect(result.items.length).toBe(1); + expect(result.items[0].name).toBe('WS item'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); +}); + +describe('phase.uat-passed registry wire-up (cycle 16)', () => { + it('phase.uat-passed is registered and dispatchable through the query registry', async () => { + const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c16-')); + try { + const phaseDir = join(localTmp, '.planning', 'phases', '05-walking-skeleton'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), UAT_PASS_CONTENT); + + const registry = createRegistry(); + + // The handler must be found — if not registered, this returns undefined. + expect(registry.has('phase.uat-passed'), 'phase.uat-passed handler not found in registry').toBe(true); + + // Dispatch via the real registry path; args[0] is the phase token. + const result = await registry.dispatch('phase.uat-passed', ['5'], localTmp); + + const data = result.data as { passed: boolean; items: Array>; reasons: unknown[] }; + expect(data.passed).toBe(true); + expect(data.items.length).toBe(1); + expect(data.items[0].result).toBe('pass'); + } finally { + await rm(localTmp, { recursive: true, force: true }); + } + }); +}); diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts new file mode 100644 index 000000000..1a6f9f56c --- /dev/null +++ b/sdk/src/query/phase-uat-passed.ts @@ -0,0 +1,297 @@ +/** + * isPhaseUatPassed — SDK predicate answering "is phase N's UAT contract satisfied?" + * Implements the isPhaseUatPassed predicate per #3184. + */ + +import { readFile, readdir, stat } 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'; +import { GSDError, ErrorClassification } from '../errors.js'; +import type { QueryHandler } from './utils.js'; + +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); + +export type ReasonCode = typeof REASON_CODE[keyof typeof REASON_CODE]; + +export const ERROR_CODE = Object.freeze({ + PROJECT_DIR_MISSING: 'project_dir_missing', + INVALID_PHASE_NUM: 'invalid_phase_num', +} as const); +export type ErrorCode = typeof ERROR_CODE[keyof typeof ERROR_CODE]; + +export class PhaseUatPassedError extends GSDError { + constructor(message: string, public readonly code: ErrorCode) { + super(message, ErrorClassification.Validation); + } +} + +export type UatReason = { + code: ReasonCode; + file?: string; + itemName?: string; + capturedValue?: string; +}; + +/** Regex to parse all UAT items regardless of result value. + * Accepts optional bold markers (**key:**) around expected/result keys. */ +const UAT_ITEM_PATTERN = + /###\s*(\d+)\.\s*([^\n]+)\n(?:\*\*)?expected:(?:\*\*)?\s*([^\n]+)\n(?:\*\*)?result:(?:\*\*)?\s*(\w+)/g; + +interface UatItem { + [key: string]: unknown; + test: number; + name: string; + expected: string; + result: string; +} + +/** + * Strip regions from file content that could contain markdown-shaped text + * but should not be treated as UAT items (frontmatter, code fences, etc.). + * Passes are applied in order; each returns a sanitised string. + */ +function stripMarkdownInjection(content: string): string { + // Pass 1: strip YAML frontmatter region (---\n...\n---). No /m flag — ^ must + // anchor to position 0 only, preventing mid-body --- separators from triggering. + let s = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, ''); + // Pass 2: strip fenced code blocks (``` ... ```) + s = s.replace(/```[\s\S]*?```/g, ''); + // Pass 3: strip HTML comment regions () + s = s.replace(//g, ''); + // Pass 4: strip blockquote-prefixed lines (any line starting with optional whitespace + >) + s = s.replace(/^\s*>.*$/gm, ''); + return s; +} + +/** + * Parse all UAT items from already-stripped content. + * Callers must pass pre-stripped (sanitised) content — no internal strip is performed. + */ +function parseAllUatItems(sanitised: string): UatItem[] { + const items: UatItem[] = []; + UAT_ITEM_PATTERN.lastIndex = 0; + let m: RegExpMatchArray | null; + while ((m = UAT_ITEM_PATTERN.exec(sanitised)) !== 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; +} + +/** Build a human-readable string for a single UatReason. */ +function humanizeReason(r: UatReason): string { + switch (r.code) { + case REASON_CODE.CASE_MISMATCH: + return `"${r.itemName ?? 'item'}" has a case-mismatched result ("${r.capturedValue ?? ''}" — expected "pass")`; + case REASON_CODE.NON_PASS_RESULT: + return `"${r.itemName ?? 'item'}" has result "${r.capturedValue ?? 'unknown'}" (expected "pass")`; + case REASON_CODE.NO_ITEMS_EXTRACTED: + return `${r.file ?? 'file'} contained no UAT items`; + case REASON_CODE.NO_PHASE_DIR: + return 'Phase directory not found'; + case REASON_CODE.NO_UAT_FILES: + return 'No *-HUMAN-UAT.md files found in the phase directory'; + case REASON_CODE.HUMAN_VERIFICATION_NEEDED: + return `"${r.itemName ?? 'item'}" requires manual human verification`; + case REASON_CODE.BRACKETED_PLACEHOLDER: + return `"${r.itemName ?? 'item'}" has an unfilled placeholder result: ${r.capturedValue ?? ''}`; + case REASON_CODE.ORPHAN_ITEM_MISSING_RESULT: + return `"${r.itemName ?? 'item'}" is missing a result field`; + default: + return `${r.code}: ${JSON.stringify(r)}`; + } +} + +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, + workstream?: string, +): Promise<{ + passed: boolean; + reasons: UatReason[]; + reasonsHuman: string[]; + items: Record[]; +}> { + try { + await stat(projectDir); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new PhaseUatPassedError( + `projectDir does not exist: ${projectDir}`, + ERROR_CODE.PROJECT_DIR_MISSING, + ); + } + throw err; + } + + const dir = await resolvePhaseDir(phase, projectDir, workstream); + if (!dir) { + const reasons: UatReason[] = [{ code: REASON_CODE.NO_PHASE_DIR }]; + return { + passed: false, + reasons, + reasonsHuman: reasons.map(humanizeReason), + items: [], + }; + } + + const files = await readdir(dir); + const uatFiles = files.filter((f) => f.endsWith('-HUMAN-UAT.md')); + + if (uatFiles.length === 0) { + const reasons: UatReason[] = [{ code: REASON_CODE.NO_UAT_FILES }]; + return { + passed: false, + reasons, + reasonsHuman: reasons.map(humanizeReason), + items: [], + }; + } + + const items: UatItem[] = []; + const reasons: UatReason[] = []; + + for (const file of uatFiles) { + const filePath = join(dir, file); + 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(strippedBody); + for (const item of parsed) { + items.push(item); + if (item.result !== 'pass') { + const code = + item.result.toLowerCase() === 'pass' + ? REASON_CODE.CASE_MISMATCH + : REASON_CODE.NON_PASS_RESULT; + reasons.push({ + code, + file: relFile, + itemName: item.name, + capturedValue: item.result, + }); + } + } + + // 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*$/m); + 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); + 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', + }); + } + + // 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; + + return { passed, reasons, reasonsHuman: reasons.map(humanizeReason), items }; +} + +/** + * QueryHandler adapter for `phase.uat-passed` registry entry. + * + * args[0] — phase token (required, e.g. '5' or '05-walking-skeleton'). + * Matches the args convention used by phase.list-plans / phase.list-artifacts. + */ +export const phaseUatPassed: QueryHandler = async (args, projectDir, workstream) => { + const phase = args[0]; + if (typeof phase !== 'string' || phase.trim() === '') { + throw new PhaseUatPassedError( + 'phase argument is required', + ERROR_CODE.INVALID_PHASE_NUM, + ); + } + const result = await isPhaseUatPassed(projectDir, phase, workstream); + return { data: result }; +}; diff --git a/sdk/src/query/state-mutation.ts b/sdk/src/query/state-mutation.ts index d9355bc33..70890199a 100644 --- a/sdk/src/query/state-mutation.ts +++ b/sdk/src/query/state-mutation.ts @@ -127,7 +127,11 @@ async function isLockProcessDead(lockPath: string): Promise { try { const raw = await readFile(lockPath, 'utf-8'); const pid = parseInt(raw.trim(), 10); - if (!Number.isFinite(pid) || pid <= 0) return true; + // An empty or unparseable lock file means the writer opened the file with + // O_EXCL but hasn't finished writing the PID yet (async window between + // `open` and `writeFile`). Treat this as "unknown / still alive" — do NOT + // steal the lock, let the normal retry + timeout path handle it. + if (!Number.isFinite(pid) || pid <= 0) return null; try { process.kill(pid, 0); return false; 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;