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:
6
.changeset/3184-phase-uat-passed.md
Normal file
6
.changeset/3184-phase-uat-passed.md
Normal 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. -->
|
||||||
@@ -389,6 +389,14 @@ const PHASE_COMMAND_ALIASES = [
|
|||||||
"subcommand": "list-artifacts",
|
"subcommand": "list-artifacts",
|
||||||
"mutation": false
|
"mutation": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"canonical": "phase.uat-passed",
|
||||||
|
"aliases": [
|
||||||
|
"phase uat-passed"
|
||||||
|
],
|
||||||
|
"subcommand": "uat-passed",
|
||||||
|
"mutation": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"canonical": "phase.next-decimal",
|
"canonical": "phase.next-decimal",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
|
|||||||
@@ -343,6 +343,20 @@ export type { WSTransportOptions } from './ws-transport.js';
|
|||||||
// Query registry argv normalization (matches `gsd-sdk query` and `GSDTools` hot path)
|
// Query registry argv normalization (matches `gsd-sdk query` and `GSDTools` hot path)
|
||||||
export { createRegistry, normalizeQueryCommand } from './query/index.js';
|
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
|
// Workstream utilities
|
||||||
export { validateWorkstreamName, relPlanningPath } from './workstream-utils.js';
|
export { validateWorkstreamName, relPlanningPath } from './workstream-utils.js';
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const INIT_COMMAND_ALIASES: readonly FamilyCommandAlias[] = [
|
|||||||
export const PHASE_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-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.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.next-decimal', aliases: ['phase next-decimal'], subcommand: 'next-decimal', mutation: false },
|
||||||
{ canonical: 'phase.add', aliases: ['phase add'], subcommand: 'add', mutation: true },
|
{ canonical: 'phase.add', aliases: ['phase add'], subcommand: 'add', mutation: true },
|
||||||
{ canonical: 'phase.add-batch', aliases: ['phase add-batch'], subcommand: 'add-batch', mutation: true },
|
{ canonical: 'phase.add-batch', aliases: ['phase add-batch'], subcommand: 'add-batch', mutation: true },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents, va
|
|||||||
import {
|
import {
|
||||||
phaseListPlans, phaseListArtifacts,
|
phaseListPlans, phaseListArtifacts,
|
||||||
} from './phase-list-queries.js';
|
} from './phase-list-queries.js';
|
||||||
|
import { phaseUatPassed } from './phase-uat-passed.js';
|
||||||
import {
|
import {
|
||||||
phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete,
|
phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete,
|
||||||
phaseScaffold, phaseNextDecimal, phasesList, phasesClear, phasesArchive,
|
phaseScaffold, phaseNextDecimal, phasesList, phasesClear, phasesArchive,
|
||||||
@@ -86,6 +87,7 @@ export const FAMILY_HANDLERS: Record<string, Readonly<Record<string, QueryHandle
|
|||||||
phase: {
|
phase: {
|
||||||
'phase.list-plans': phaseListPlans,
|
'phase.list-plans': phaseListPlans,
|
||||||
'phase.list-artifacts': phaseListArtifacts,
|
'phase.list-artifacts': phaseListArtifacts,
|
||||||
|
'phase.uat-passed': phaseUatPassed,
|
||||||
'phase.add': phaseAdd,
|
'phase.add': phaseAdd,
|
||||||
'phase.add-batch': phaseAddBatch,
|
'phase.add-batch': phaseAddBatch,
|
||||||
'phase.insert': phaseInsert,
|
'phase.insert': phaseInsert,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { CommandManifestEntry } from './command-manifest.types.js';
|
|||||||
export const PHASE_COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
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-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.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.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', aliases: ['phase add'], mutation: true, outputMode: 'json' },
|
||||||
{ family: 'phase', canonical: 'phase.add-batch', aliases: ['phase add-batch'], mutation: true, outputMode: 'json' },
|
{ family: 'phase', canonical: 'phase.add-batch', aliases: ['phase add-batch'], mutation: true, outputMode: 'json' },
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import type { QueryHandler } from './utils.js';
|
import type { QueryHandler } from './utils.js';
|
||||||
|
|
||||||
/** Resolve `.planning/phases/<dir>` for a phase token, or null. */
|
/** 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 phasesDir = planningPaths(projectDir, workstream).phases;
|
||||||
const normalized = normalizePhaseName(phase);
|
const normalized = normalizePhaseName(phase);
|
||||||
try {
|
try {
|
||||||
|
|||||||
593
sdk/src/query/phase-uat-passed.test.ts
Normal file
593
sdk/src/query/phase-uat-passed.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
297
sdk/src/query/phase-uat-passed.ts
Normal file
297
sdk/src/query/phase-uat-passed.ts
Normal 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 };
|
||||||
|
};
|
||||||
@@ -127,7 +127,11 @@ async function isLockProcessDead(lockPath: string): Promise<boolean | null> {
|
|||||||
try {
|
try {
|
||||||
const raw = await readFile(lockPath, 'utf-8');
|
const raw = await readFile(lockPath, 'utf-8');
|
||||||
const pid = parseInt(raw.trim(), 10);
|
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 {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ function parseUatItems(content: string): Record<string, unknown>[] {
|
|||||||
* rather than the body, parseVerificationItems was returning [] because it
|
* rather than the body, parseVerificationItems was returning [] because it
|
||||||
* only searched the body for a "## Human Verification" heading.
|
* 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 items: Record<string, unknown>[] = [];
|
||||||
const hvArray = fm.human_verification;
|
const hvArray = fm.human_verification;
|
||||||
if (!Array.isArray(hvArray)) return items;
|
if (!Array.isArray(hvArray)) return items;
|
||||||
|
|||||||
Reference in New Issue
Block a user