/** * GSD Tools Tests - UAT Audit */ 'use strict'; const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const fc = require('./helpers/fast-check-setup.cjs'); const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs'); const { buildCheckpoint, CHECKPOINT_FRAMES, CHECKPOINT_LANGUAGE_ALIASES, resolveCheckpointFrame, parseDeferredItems, } = require('../gsd-core/bin/lib/uat.cjs'); describe('audit-uat command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('returns empty results when no UAT files exist', () => { // Create a phase directory with no UAT files fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-foundation', '.gitkeep'), ''); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.deepStrictEqual(output.results, []); assert.strictEqual(output.summary.total_items, 0); assert.strictEqual(output.summary.total_files, 0); }); test('detects UAT with pending items', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), `--- status: testing phase: 01-foundation started: 2025-01-01T00:00:00Z updated: 2025-01-01T00:00:00Z --- ## Tests ### 1. Login Form expected: Form displays with email and password fields result: pass ### 2. Submit Button expected: Submitting shows loading state result: pending `); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 1); assert.strictEqual(output.results[0].phase, '01'); assert.strictEqual(output.results[0].items[0].result, 'pending'); assert.strictEqual(output.results[0].items[0].category, 'pending'); assert.strictEqual(output.results[0].items[0].name, 'Submit Button'); }); // Regression: #2273 — bracketed result values [pending], [blocked], [skipped] test('detects UAT items with bracketed result values (#2273)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [ '---', 'status: testing', 'phase: 01-foundation', 'started: 2025-01-01T00:00:00Z', 'updated: 2025-01-01T00:00:00Z', '---', '', '## Tests', '', '### 1. Login Form', 'expected: Form displays correctly', 'result: [pending]', '', '### 2. Submit Button', 'expected: Shows loading state', 'result: [blocked]', 'blocked_by: #123', '', '### 3. Error Message', 'expected: Shows validation error', 'result: [skipped]', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 3, 'all 3 bracketed items should be detected'); assert.strictEqual(output.results[0].items[0].result, 'pending', '[pending] should parse as pending'); assert.strictEqual(output.results[0].items[1].result, 'blocked', '[blocked] should parse as blocked'); assert.strictEqual(output.results[0].items[2].result, 'skipped', '[skipped] should parse as skipped'); }); test('detects UAT with blocked items and categorizes blocked_by', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '02-UAT.md'), `--- status: partial phase: 02-api started: 2025-01-01T00:00:00Z updated: 2025-01-01T00:00:00Z --- ## Tests ### 1. API Health Check expected: Returns 200 OK result: blocked blocked_by: server reason: Server not running locally `); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 1); assert.strictEqual(output.results[0].items[0].result, 'blocked'); assert.strictEqual(output.results[0].items[0].category, 'server_blocked'); assert.strictEqual(output.results[0].items[0].blocked_by, 'server'); }); test('detects false completion (complete status with pending items)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-ui'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), `--- status: complete phase: 03-ui started: 2025-01-01T00:00:00Z updated: 2025-01-01T00:00:00Z --- ## Tests ### 1. Dashboard Layout expected: Cards render in grid result: pass ### 2. Mobile Responsive expected: Grid collapses to single column on mobile result: pending `); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 1); assert.strictEqual(output.results[0].status, 'complete'); assert.strictEqual(output.results[0].items[0].result, 'pending'); }); test('extracts human_needed items from VERIFICATION files', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-auth'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), `--- status: human_needed phase: 04-auth --- ## Automated Checks All passed. ## Human Verification 1. Test SSO login with Google account 2. Test password reset flow end-to-end 3. Verify MFA enrollment on new device `); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 3); assert.strictEqual(output.results[0].type, 'verification'); assert.strictEqual(output.results[0].status, 'human_needed'); assert.strictEqual(output.results[0].items[0].category, 'human_uat'); assert.strictEqual(output.results[0].items[0].name, 'Test SSO login with Google account'); }); test('scans and aggregates across multiple phases', () => { // Phase 1 with pending const phase1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phase1, { recursive: true }); fs.writeFileSync(path.join(phase1, '01-UAT.md'), `--- status: partial phase: 01-foundation started: 2025-01-01T00:00:00Z updated: 2025-01-01T00:00:00Z --- ## Tests ### 1. Test A expected: Works result: pending `); // Phase 2 with blocked const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(phase2, { recursive: true }); fs.writeFileSync(path.join(phase2, '02-UAT.md'), `--- status: partial phase: 02-api started: 2025-01-01T00:00:00Z updated: 2025-01-01T00:00:00Z --- ## Tests ### 1. Test B expected: Responds result: blocked blocked_by: server ### 2. Test C expected: Returns data result: skipped reason: device not available `); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_files, 2); assert.strictEqual(output.summary.total_items, 3); assert.strictEqual(output.summary.by_phase['01'], 1); assert.strictEqual(output.summary.by_phase['02'], 2); }); test('milestone scoping filters phases to current milestone', () => { // Create a ROADMAP.md that only references Phase 2 fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap ### Phase 2: API Layer **Goal:** Build API `); // Phase 1 (not in current milestone) with pending const phase1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phase1, { recursive: true }); fs.writeFileSync(path.join(phase1, '01-UAT.md'), `--- status: partial phase: 01-foundation started: 2025-01-01T00:00:00Z updated: 2025-01-01T00:00:00Z --- ## Tests ### 1. Old Test expected: Old behavior result: pending `); // Phase 2 (in current milestone) with pending const phase2 = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(phase2, { recursive: true }); fs.writeFileSync(path.join(phase2, '02-UAT.md'), `--- status: partial phase: 02-api started: 2025-01-01T00:00:00Z updated: 2025-01-01T00:00:00Z --- ## Tests ### 1. New Test expected: New behavior result: pending `); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); // Only Phase 2 should be included (Phase 1 not in ROADMAP) assert.strictEqual(output.summary.total_files, 1); assert.strictEqual(output.results[0].phase, '02'); }); test('summary by_category counts are correct', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-billing'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '05-UAT.md'), `--- status: partial phase: 05-billing started: 2025-01-01T00:00:00Z updated: 2025-01-01T00:00:00Z --- ## Tests ### 1. Payment Form expected: Stripe elements load result: pending ### 2. Webhook Handler expected: Processes payment events result: blocked blocked_by: third-party Stripe ### 3. Invoice PDF expected: Generates downloadable PDF result: skipped reason: needs release build ### 4. Refund Flow expected: Processes refund result: pending `); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 4); assert.strictEqual(output.summary.by_category.pending, 2); assert.strictEqual(output.summary.by_category.third_party, 1); assert.strictEqual(output.summary.by_category.build_needed, 1); }); test('ignores VERIFICATION files without human_needed or gaps_found status', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-VERIFICATION.md'), `--- status: passed phase: 01-foundation --- ## Results All checks passed. `); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 0); assert.strictEqual(output.summary.total_files, 0); }); // Regression: #2383 — human_needed items with result: PASS are still reported test('ignores human_verification items with result PASS (regression #2383)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '31-auth'); fs.mkdirSync(phaseDir, { recursive: true }); // This file has status: human_needed in frontmatter but all individual items // have result: "PASS" — they should not be reported as outstanding fs.writeFileSync(path.join(phaseDir, '31-VERIFICATION.md'), [ '---', 'status: human_needed', 'phase: 31-auth', 'gaps_remaining: []', '---', '', '## Human Verification', '', '| # | Item | Result | Evidence |', '|---|------|--------|----------|', '| 1 | Test SSO login with Google | PASS | Verified 2025-01-15 |', '| 2 | Test password reset flow | PASS | Verified 2025-01-15 |', '| 3 | Verify MFA enrollment | PASS | Verified 2025-01-15 |', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 0, `Expected 0 outstanding items but got ${output.summary.total_items} — resolved PASS items should not be counted`); assert.strictEqual(output.summary.total_files, 0); }); test('ignores human_needed VERIFICATION file when file-level status is passed (regression #2383)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '31-auth'); fs.mkdirSync(phaseDir, { recursive: true }); // When the frontmatter status is "passed", skip entirely regardless of section content fs.writeFileSync(path.join(phaseDir, '31-VERIFICATION.md'), [ '---', 'status: passed', 'phase: 31-auth', 'gaps_remaining: []', '---', '', '## Human Verification', '', '1. Test SSO login with Google account', '2. Test password reset flow end-to-end', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 0, `status: passed file should produce 0 outstanding items, got ${output.summary.total_items}`); assert.strictEqual(output.summary.total_files, 0); }); // #3511: a cross-phase, stray, or ad-hoc UAT/VERIFICATION file sitting in // this phase's directory must not surface under this phase's audit-uat // entry; this phase's own UAT/VERIFICATION artifacts must keep reporting // exactly as before (non-stray case unchanged). test('#3511: cross-phase stray UAT/VERIFICATION files in the same dir do not surface; own artifacts still do', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-foo'); fs.mkdirSync(phaseDir, { recursive: true }); // This phase's own UAT — must still report its pending item. fs.writeFileSync(path.join(phaseDir, '03-UAT.md'), [ '---', 'status: partial', '---', '', '## Tests', '', '### 1. Own Test', 'expected: Works', 'result: pending', '', ].join('\n')); // This phase's own VERIFICATION — must still report its human-needed item. fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), [ '---', 'status: human_needed', 'phase: 03-foo', '---', '', '## Human Verification', '', '1. Own human check', ].join('\n')); // Cross-phase strays sitting in the SAME directory — token "04", not "03". fs.writeFileSync(path.join(phaseDir, '04-UAT.md'), [ '---', 'status: partial', '---', '', '## Tests', '', '### 1. Stray Test', 'expected: Works', 'result: pending', '', ].join('\n')); fs.writeFileSync(path.join(phaseDir, '04-VERIFICATION.md'), [ '---', 'status: human_needed', 'phase: 04-bar', '---', '', '## Human Verification', '', '1. Stray human check', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_files, 2, `only this phase's own 2 files must be scanned; got: ${JSON.stringify(output.results.map(r => r.file))}`); assert.strictEqual(output.summary.total_items, 2, `1 own UAT item + 1 own VERIFICATION item, strays excluded; got: ${output.summary.total_items}`); assert.strictEqual(output.summary.by_phase['03'], 2, 'own phase must be credited both items'); assert.ok(!('04' in output.summary.by_phase), 'the cross-phase stray must not appear in by_phase at all'); assert.ok(!result.output.includes('04-UAT.md'), 'stray UAT filename must never surface in the output'); assert.ok(!result.output.includes('04-VERIFICATION.md'), 'stray VERIFICATION filename must never surface in the output'); assert.ok(output.results.some(r => r.file === '03-UAT.md' && r.items.some(i => i.name === 'Own Test'))); assert.ok(output.results.some(r => r.file === '03-VERIFICATION.md' && r.items.some(i => i.name === 'Own human check'))); }); // #3511 follow-up: over-exclusion check on the #2528 digit-leading-slug // family. "05-80-20-cleanup" tokenizes to "05-80-20" (mis-absorbed past // the digit run scaffold actually writes into), so a literal token compare // excluded the phase's own report — audit-uat reported total_files: 0. test('#3511 follow-up: own UAT file still surfaces from the digit-leading-slug dir "05-80-20-cleanup" (over-exclusion check)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-80-20-cleanup'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '05-UAT.md'), [ '---', 'status: partial', '---', '', '## Tests', '', '### 1. Own Test', 'expected: Works', 'result: pending', '', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_files, 1, `own UAT file in a digit-leading-slug dir must still surface; got: ${JSON.stringify(output)}`); assert.strictEqual(output.summary.by_phase['05'], 1); }); // Regression: #2286 — parseUatItems never scanned a `## Gaps` section, so a // *-UAT.md file recording its only outstanding findings there returned // total_items: 0 (false-clean). Boundary: 0 / 1 / 2+ unresolved entries. describe('Gaps section scanning (#2286)', () => { test('a Gaps-only UAT file with 0 unresolved entries (all resolved) yields no items', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [ '---', 'status: partial', 'phase: 01-foundation', '---', '', '## Gaps', '', '', '- truth: "SC1: Widget renders with data"', ' status: resolved', ' reason: "Fixed in follow-up commit"', '', '- truth: "SC2: Second finding also fixed"', ' status: resolved', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 0, 'resolved Gaps entries must not be counted as outstanding items'); assert.strictEqual(output.summary.total_files, 0); }); test('a Gaps-only UAT file with exactly 1 unresolved entry and zero ### N. test blocks yields 1 item', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [ '---', 'status: partial', 'phase: 01-foundation', '---', '', '## Gaps', '', '', '- truth: "SC1: Widget renders with data"', ' status: open', ' reason: "Missing data binding"', ' severity: major', ' test: 2', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 1, 'total_items must be > 0, not the false-clean 0'); assert.strictEqual(output.results[0].type, 'uat'); assert.strictEqual(output.results[0].items[0].name, 'SC1: Widget renders with data'); assert.strictEqual(output.results[0].items[0].result, 'open'); assert.strictEqual(output.results[0].items[0].reason, 'Missing data binding'); assert.strictEqual(output.results[0].items[0].test, 2); }); test('a Gaps section with 2+ unresolved entries surfaces all of them and skips the resolved one', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '02-UAT.md'), [ '---', 'status: partial', 'phase: 02-api', '---', '', '## Gaps', '', '', '- truth: "SC1: First outstanding gap"', ' status: failed', ' reason: "Endpoint returns 500"', '', '- truth: "SC2: Second outstanding gap"', ' status: open', '', '- truth: "SC3: Already fixed gap"', ' status: resolved', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 2, 'exactly the 2 unresolved gaps should be counted, resolved gap excluded'); const names = output.results[0].items.map((item) => item.name).sort(); assert.deepStrictEqual(names, ['SC1: First outstanding gap', 'SC2: Second outstanding gap']); }); // Regression: #2286 review HIGH finding — a naive whole-string `key:` // scan over a Gaps entry's flattened text matches the FIRST `key:`-shaped // substring anywhere, including one embedded inside an EARLIER field's // own quoted free-text value. A `truth`/`reason` value that itself // contains the literal text "status: resolved" (or "reason:"/"test:") // must never hijack the real, later `status:`/`reason:`/`test:` field — // the fix parses each field anchored to the START of its own line. test('a truth value containing the literal substring "status: resolved" does not suppress the real open status', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [ '---', 'status: partial', 'phase: 01-foundation', '---', '', '## Gaps', '', '', '- truth: "The status: resolved workflow should trigger a banner"', ' status: failed', ' reason: "Contains a reason: field embedded phrase, and test: 9 too"', ' test: 3', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 1, 'the genuinely open gap must be surfaced, not dropped because its truth text contains "status: resolved"'); const item = output.results[0].items[0]; assert.strictEqual(item.name, 'The status: resolved workflow should trigger a banner'); assert.strictEqual(item.result, 'failed', 'the REAL status: field must win, not the embedded phrase inside truth'); assert.strictEqual(item.reason, 'Contains a reason: field embedded phrase, and test: 9 too', 'the reason value is taken verbatim, including its own embedded colon-bearing phrases'); assert.strictEqual(item.test, 3, 'the REAL test: field (3) must win, not the "test: 9" phrase embedded in reason'); }); // Regression: #2286 review LOW finding — a nested `artifacts:` sub-list // (per templates/UAT.md's `## Gaps` schema) must be folded into its // parent entry, not mis-split into spurious standalone items. test('a Gaps entry with a nested artifacts sub-list parses as exactly one item', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [ '---', 'status: partial', 'phase: 01-foundation', '---', '', '## Gaps', '', '', '- truth: "SC1: Some behavior"', ' status: failed', ' reason: "reason text"', ' severity: major', ' test: 1', ' root_cause: ""', ' artifacts:', ' - src/foo.ts', ' - src/bar.ts', ' missing: []', ' debug_session: ""', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 1, 'the nested artifacts sub-list items must not spawn spurious extra Gaps items'); assert.strictEqual(output.results[0].items[0].name, 'SC1: Some behavior'); assert.strictEqual(output.results[0].items[0].category, 'unknown', 'a Gaps item with no dedicated category mapping falls back to unknown'); }); // Regression: #2286 review item 5 (fail-safe direction) — #2286 is a // false-NEGATIVE bug, so a Gaps entry with no parseable `status:` field // is surfaced (as result: 'unknown') rather than silently dropped. test('a Gaps entry with no status field is surfaced as an unknown-status item (fail-safe)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [ '---', 'status: partial', 'phase: 01-foundation', '---', '', '## Gaps', '', '- truth: "SC1: Missing status field entirely"', ' reason: "why it is open"', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 1, 'a garbled/missing status must SURFACE the entry, not silently drop it'); assert.strictEqual(output.results[0].items[0].result, 'unknown'); assert.strictEqual(output.results[0].items[0].name, 'SC1: Missing status field entirely'); }); test('an empty Gaps section (heading present, no bullets) yields 0 items without throwing', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '01-UAT.md'), [ '---', 'status: partial', 'phase: 01-foundation', '---', '', '## Gaps', '', ].join('\n')); const result = runGsdTools('audit-uat --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.summary.total_items, 0); assert.strictEqual(output.summary.total_files, 0); }); }); // Regression: #2286 — parseVerificationItems never read the frontmatter's // structured `human_verification:` YAML array, and never recognized the // `### N.