From a4e15d56160a8e9a6a682914cbab0c1cead8df32 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 28 Apr 2026 20:22:59 -0400 Subject: [PATCH] fix(#2788): audit-uat reads human_verification items from frontmatter (#2814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#2788): audit-uat reads frontmatter human_verification array parseVerificationItems only searched the body for a '## Human Verification' section. gsd-verifier writes items to the frontmatter human_verification: YAML array, so audit-uat returned total_items: 0 for all such files. Two fixes: 1. Read frontmatter human_verification: array first (via extractFrontmatter); return those items if present (primary path for gsd-verifier output). 2. Relax the body-section heading regex to accept underscore separators and parenthetical suffixes (e.g. '## human_verification (action required)'). Regression test: tests/bug-2788-audit-uat-frontmatter.test.cjs * fix(#2788): address CodeRabbit — trim whitespace entries, support hyphenated headings, bash fence --- sdk/src/query/uat.ts | 57 +++++- tests/bug-2788-audit-uat-frontmatter.test.cjs | 175 ++++++++++++++++++ 2 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 tests/bug-2788-audit-uat-frontmatter.test.cjs diff --git a/sdk/src/query/uat.ts b/sdk/src/query/uat.ts index ef3ce12d4..f2b9bde8d 100644 --- a/sdk/src/query/uat.ts +++ b/sdk/src/query/uat.ts @@ -188,11 +188,62 @@ function parseUatItems(content: string): Record[] { return items; } +/** + * Parse frontmatter human_verification: YAML array entries into audit items. + * + * Fixes #2788: when gsd-verifier encodes human items in YAML frontmatter + * rather than the body, parseVerificationItems was returning [] because it + * only searched the body for a "## Human Verification" heading. + */ +function parseVerificationFrontmatterItems(fm: Record): Record[] { + const items: Record[] = []; + const hvArray = fm.human_verification; + if (!Array.isArray(hvArray)) return items; + + let i = 0; + for (const entry of hvArray) { + i++; + if (typeof entry === 'string') { + const name = entry.trim(); + if (name.length > 0) { + items.push({ test: i, name, result: 'human_needed', category: 'human_uat' }); + } + } else if (typeof entry === 'object' && entry !== null) { + const obj = entry as Record; + // Accept any string property as the item name; prefer 'test' key. + const name = (obj.test as string | undefined) || (obj.name as string | undefined) || ''; + if (name) { + const item: Record = { + test: i, + name: String(name).trim(), + result: 'human_needed', + category: 'human_uat', + }; + if (obj.expected) item.expected = String(obj.expected).trim(); + if (obj.why_human) item.why_human = String(obj.why_human).trim(); + items.push(item); + } + } + } + return items; +} + /** Port of `parseVerificationItems` from `uat.cjs`. */ -function parseVerificationItems(content: string, status: string): Record[] { +function parseVerificationItems(content: string, status: string, fm?: Record): Record[] { const items: Record[] = []; if (status === 'human_needed') { - const hvSection = content.match(/##\s*Human Verification.*?\n([\s\S]*?)(?=\n##\s|\n---\s|$)/i); + // Check frontmatter human_verification: array first (#2788). + // gsd-verifier writes items here; body-section fallback is secondary. + if (fm) { + const fmItems = parseVerificationFrontmatterItems(fm); + if (fmItems.length > 0) return fmItems; + } + + // Body fallback: match ## human_verification or ## Human Verification + // (case-insensitive, underscore or space, with optional parenthetical). + const hvSection = content.match( + /##\s*human[_\s-]verification[^\n]*\n([\s\S]*?)(?=\n##\s|\n---\s|$)/i + ); if (hvSection) { const lines = hvSection[1].split('\n'); for (const line of lines) { @@ -273,7 +324,7 @@ export const auditUat: QueryHandler = async (_args, projectDir, workstream) => { const fm = extractFrontmatter(content); const status = (fm.status || 'unknown') as string; if (status === 'human_needed' || status === 'gaps_found') { - const items = parseVerificationItems(content, status); + const items = parseVerificationItems(content, status, fm); if (items.length > 0) { results.push({ phase: phaseNum, diff --git a/tests/bug-2788-audit-uat-frontmatter.test.cjs b/tests/bug-2788-audit-uat-frontmatter.test.cjs new file mode 100644 index 000000000..ca6d0dbba --- /dev/null +++ b/tests/bug-2788-audit-uat-frontmatter.test.cjs @@ -0,0 +1,175 @@ +/** + * Regression test for bug #2788 + * + * `gsd-sdk query audit-uat` returned total_items: 0 for VERIFICATION.md + * files where human-needed items were encoded in the frontmatter + * `human_verification:` YAML array (the format written by gsd-verifier), + * or where the body section heading used `## human_verification` (underscore) + * instead of `## Human Verification` (space). + * + * Root cause: + * 1. parseVerificationItems only searched the body for "## Human Verification" + * (space, case-insensitive) — never read frontmatter. + * 2. The body-section regex did not accept underscore in the heading name. + * + * Fix: parseVerificationItems now reads the frontmatter human_verification: + * array first (via extractFrontmatter). Falls back to body-section scan + * with a relaxed regex that accepts underscore and parenthetical suffixes. + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { createTempProject, cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const SDK_CLI = path.join(REPO_ROOT, 'sdk', 'dist', 'cli.js'); + +function runAuditUat(projectDir) { + const argv = ['query', 'audit-uat', '--project-dir', projectDir]; + let stdout = ''; + let exitCode = 0; + try { + stdout = execFileSync(process.execPath, [SDK_CLI, ...argv], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GSD_SESSION_KEY: '' }, + }); + } catch (err) { + exitCode = err.status ?? 1; + stdout = err.stdout?.toString() ?? ''; + } + let json = null; + try { json = JSON.parse(stdout.trim()); } catch { /* ok */ } + return { exitCode, json }; +} + +/** Set up a project with a ROADMAP.md milestone and a phase VERIFICATION.md */ +function setupProject(tmpDir, verificationContent) { + const planningDir = path.join(tmpDir, '.planning'); + const phaseDir = path.join(planningDir, 'phases', '03-invoicing'); + fs.mkdirSync(phaseDir, { recursive: true }); + + // Write a minimal ROADMAP.md so getMilestonePhaseFilter works + fs.writeFileSync( + path.join(planningDir, 'ROADMAP.md'), + [ + '# Roadmap', + '', + '## v1.0 — MVP', + '', + '### Phase 3: Invoicing', + ].join('\n') + ); + + // Write STATE.md with current milestone + fs.writeFileSync( + path.join(planningDir, 'STATE.md'), + [ + '---', + 'version: "v1.0"', + '---', + '# State', + ].join('\n') + ); + + fs.writeFileSync( + path.join(phaseDir, '03-invoicing-VERIFICATION.md'), + verificationContent + ); +} + +describe('bug-2788: audit-uat reads frontmatter human_verification array', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-test-2788-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('frontmatter human_verification: array items are reported', () => { + // This is the format gsd-verifier writes; before fix total_items was 0 + const content = [ + '---', + 'phase: 03-invoicing', + 'status: human_needed', + 'human_verification:', + ' - test: "Manual frontend smoke — /invoices, /invoices/new"', + ' expected: "All four routes render correctly"', + ' why_human: "Visual rendering cannot be verified by static code inspection"', + ' - test: "Run integration test suite against a real Postgres"', + ' expected: "105 currently-skipped tests pass green"', + ' why_human: "Docker not installed locally"', + '---', + '', + '# Phase 3 Verification Report', + '', + 'No body Human Verification section here.', + ].join('\n'); + + setupProject(tmpDir, content); + const result = runAuditUat(tmpDir); + + assert.strictEqual(result.exitCode, 0, 'should exit 0'); + assert.ok(result.json !== null, 'should emit JSON'); + assert.ok( + result.json.summary.total_items >= 2, + `total_items should be >= 2, got ${result.json.summary.total_items}` + ); + }); + + test('body ## human_verification (underscore) heading is parsed', () => { + const content = [ + '---', + 'phase: 03-invoicing', + 'status: human_needed', + '---', + '', + '# Phase 3 Verification Report', + '', + '## human_verification (action required by Sammy)', + '', + '- Manual frontend smoke — /invoices, /invoices/new: verify all four routes render', + '- Run integration test suite against a real Postgres database', + ].join('\n'); + + setupProject(tmpDir, content); + const result = runAuditUat(tmpDir); + + assert.strictEqual(result.exitCode, 0, 'should exit 0'); + assert.ok(result.json !== null, 'should emit JSON'); + assert.ok( + result.json.summary.total_items >= 2, + `total_items should be >= 2, got ${result.json.summary.total_items}` + ); + }); + + test('body ## Human Verification (space) heading still works', () => { + const content = [ + '---', + 'phase: 03-invoicing', + 'status: human_needed', + '---', + '', + '# Phase 3 Verification Report', + '', + '## Human Verification', + '', + '- Manual frontend smoke test — verify all four routes render correctly or describe what breaks', + '- Run integration test suite: 105 tests must pass green', + ].join('\n'); + + setupProject(tmpDir, content); + const result = runAuditUat(tmpDir); + + assert.strictEqual(result.exitCode, 0, 'should exit 0'); + assert.ok(result.json?.summary.total_items >= 2, `total_items should be >= 2`); + }); +});