feat(sdk): merge frontmatter human_verification items into roster (#3184)

Cycle 11 of ~15: reuses parseVerificationFrontmatterItems from
uat.ts. Frontmatter-declared manual-verification items roll
into the same items[] roster and emit HUMAN_VERIFICATION_NEEDED
reasons so passed=false is justified, not silent.
This commit is contained in:
Tom Boucher
2026-05-18 22:52:25 -04:00
parent 29a48d8a78
commit d7aa4ce6e3
3 changed files with 52 additions and 1 deletions

View File

@@ -273,6 +273,39 @@ expected: thing
}
});
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("emits CASE_MISMATCH reason when result value is \"PASS\" (uppercase variant of pass)", async () => {
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c10-'));
try {

View File

@@ -8,10 +8,13 @@
import { readFile, readdir } 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';
export const REASON_CODE = Object.freeze({
NON_PASS_RESULT: 'non_pass_result',
CASE_MISMATCH: 'case_mismatch',
HUMAN_VERIFICATION_NEEDED: 'human_verification_needed',
NO_PHASE_DIR: 'no_phase_dir',
NO_UAT_FILES: 'no_uat_files',
} as const);
@@ -127,6 +130,21 @@ export async function isPhaseUatPassed(
});
}
}
// 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',
});
}
}
const passed = items.length > 0 && reasons.length === 0;

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;