Merge pull request #94 from open-gsd/feat/phase-uat-passed-3184

feat(sdk): isPhaseUatPassed predicate + phase.uat-passed query (#3184)
This commit is contained in:
Colin Johnson
2026-05-22 11:49:33 -04:00
committed by GitHub
11 changed files with 929 additions and 3 deletions

View File

@@ -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.
<!-- docs-exempt: SDK-only programmatic export in v1 per issue scope. Docs for phase.uat-passed deferred to follow-up issue pending @henjolo design confirmation. -->

View File

@@ -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": [

View File

@@ -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';

View File

@@ -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 },

View File

@@ -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<string, Readonly<Record<string, QueryHandle
phase: {
'phase.list-plans': phaseListPlans,
'phase.list-artifacts': phaseListArtifacts,
'phase.uat-passed': phaseUatPassed,
'phase.add': phaseAdd,
'phase.add-batch': phaseAddBatch,
'phase.insert': phaseInsert,

View File

@@ -6,6 +6,7 @@ import type { CommandManifestEntry } from './command-manifest.types.js';
export const PHASE_COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
{ family: 'phase', canonical: 'phase.list-plans', aliases: ['phase list-plans'], mutation: false, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.list-artifacts', aliases: ['phase list-artifacts'], mutation: false, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.uat-passed', aliases: ['phase uat-passed'], mutation: false, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.next-decimal', aliases: ['phase next-decimal'], mutation: false, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.add', aliases: ['phase add'], mutation: true, outputMode: 'json' },
{ family: 'phase', canonical: 'phase.add-batch', aliases: ['phase add-batch'], mutation: true, outputMode: 'json' },

View File

@@ -17,7 +17,7 @@ import {
import type { QueryHandler } from './utils.js';
/** Resolve `.planning/phases/<dir>` for a phase token, or null. */
async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise<string | null> {
export async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise<string | null> {
const phasesDir = planningPaths(projectDir, workstream).phases;
const normalized = normalizePhaseName(phase);
try {

View File

@@ -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. Commented-out 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('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<Record<string, unknown>>; 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 });
}
});
});

View File

@@ -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(/<!--[\s\S]*?-->/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<number>,
brackPlaceholderNumbers: Set<number>,
): 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<string, unknown>[];
}> {
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<number>();
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 };
};

View File

@@ -127,7 +127,11 @@ async function isLockProcessDead(lockPath: string): Promise<boolean | null> {
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;

View File

@@ -195,7 +195,7 @@ function parseUatItems(content: string): Record<string, unknown>[] {
* rather than the body, parseVerificationItems was returning [] because it
* only searched the body for a "## Human Verification" heading.
*/
function parseVerificationFrontmatterItems(fm: Record<string, unknown>): Record<string, unknown>[] {
export function parseVerificationFrontmatterItems(fm: Record<string, unknown>): Record<string, unknown>[] {
const items: Record<string, unknown>[] = [];
const hvArray = fm.human_verification;
if (!Array.isArray(hvArray)) return items;