fix(#2286): audit-uat surfaces Gaps section + frontmatter/heading verification items (#2317)

parseUatItems only scanned '### N.' expected/result blocks and
parseVerificationItems only recognized table/bullet/numbered shapes, so
audit-uat returned a false-clean total_items:0 when a file recorded open
findings in a '## Gaps' section, declared items in a frontmatter
human_verification: array, or used the '### N. <label>'+bold-paragraph
verification shape.

parseUatItems now also scans '## Gaps' (via collectSection + iterateBullets)
and surfaces any entry whose status != resolved. parseVerificationItems
now treats the frontmatter human_verification: array (via extractFrontmatter)
as the primary source when present, and adds a tokenizeHeadings fallback
for the '### N.'+bold-paragraph shape, preserving the existing
table/bullet/numbered recognition (no double-count, no regression).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-15 20:42:03 -04:00
committed by GitHub
parent ff9cb6069f
commit 636316f720
3 changed files with 650 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2317
---
**`audit-uat` no longer reports a false-clean `total_items: 0` when real items exist** — the parsers ignored two artifact shapes: a `## Gaps` section recording open findings, and verification items declared in frontmatter (`human_verification:` array) or as `### N.`+bold-paragraph blocks. audit-uat now surfaces unresolved `## Gaps` entries and reads the frontmatter array / heading shape, so a phase with outstanding UAT/verification work is no longer waved through as clean. (#2286)

View File

@@ -365,14 +365,203 @@ function parseUatItems(content: string): UatItem[] {
items.push(item);
}
}
items.push(...parseGapsItems(content));
return items;
}
// ─── parseGapsItems ───────────────────────────────────────────────────────────
/**
* Extract unresolved entries from a UAT file's `## Gaps` section (#2286).
*
* `## Gaps` records open findings as a YAML-lite bullet list (see
* `templates/UAT.md`'s `## Gaps` block: `- truth: "..."` followed by indented
* continuation lines `status:` / `reason:` / `severity:` / `test:` / etc.,
* and — for `artifacts:` / `missing:` — a further-nested `- ` sub-list).
* `parseUatItems`'s `### N.` test-block regex never looks at this section at
* all, so a UAT file whose only outstanding findings live in `## Gaps` was
* silently invisible — the false-negative this fix addresses.
*
* Reuses the existing `collectSection` seam (already used elsewhere in this
* file for `## Current Test` / `## Tests`) to locate the section. Field
* extraction is deliberately NOT done via `iterateBullets`: that seam folds
* every continuation line onto ONE space-joined `text` string per bullet,
* which erases line boundaries — a `key:` scan against that flattened text
* matches the FIRST `key:`-shaped substring anywhere, including one that
* happens to appear inside an EARLIER field's own quoted free-text value
* (e.g. `truth: "The status: resolved workflow should trigger"` — a real
* `status: failed` on the next line would never be reached, silently
* DROPPING a genuinely open gap — the exact false-negative class #2286
* exists to fix, so the fix must not reintroduce it). `splitGapsEntries` /
* `extractGapEntryFields` below instead walk the section PER LINE and only
* recognise a field at the START of its own (trimmed) line, so a `key:`
* embedded inside another field's quoted value can never be mistaken for a
* field declaration.
*
* Every entry whose `status` is present and NOT `resolved` (case-insensitive)
* is surfaced — mirroring the "ignore passing/resolved" convention already
* used for `### N.` test blocks (`result: pass` is never surfaced) and the
* VERIFICATION table-row PASS/resolved skip (`hasPassResult`, below). An
* entry with NO parseable `status:` field is surfaced too, as `result:
* 'unknown'` — #2286 is a false-NEGATIVE bug, and a `## Gaps` entry only
* exists to record an outstanding finding (a template-conformant RESOLVED
* entry always carries an explicit `status: resolved`); a garbled or
* non-conformant entry is far more likely to be an unresolved finding whose
* `status:` line failed to parse than a genuinely resolved one, so the
* fail-safe direction is to surface it rather than silently drop it.
*/
function parseGapsItems(content: string): UatItem[] {
const gapsSection = collectSection(
content,
(h) => /^gaps$/i.test(h.text) && h.level === 2,
{ levelBounded: true },
);
if (!gapsSection) return [];
const items: UatItem[] = [];
for (const entryLines of splitGapsEntries(gapsSection.body)) {
const fields = extractGapEntryFields(entryLines);
const rawStatus = fields.status;
if (rawStatus && rawStatus.toLowerCase() === 'resolved') continue;
// Fail-safe: missing/garbled status surfaces as 'unknown' rather than
// being dropped (see doc comment above).
const status = rawStatus || 'unknown';
const truth = fields.truth;
const reason = fields.reason;
const testNum = fields.test;
const item: UatItem = {
name: truth || rawGapEntryText(entryLines),
result: status,
category: categorizeItem(status, reason, undefined),
};
if (testNum && /^\d+$/.test(testNum)) item.test = parseInt(testNum, 10);
if (reason) item.reason = reason;
items.push(item);
}
return items;
}
/**
* Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
* `- ` bullet openers.
*
* The indentation of the FIRST bullet line encountered establishes the
* "top-level" indent for the whole section; any subsequent `- `-opening line
* at that same indent (or shallower) starts a NEW entry, while everything
* more deeply indented — field continuation lines (` status: ...`) AND
* nested sub-lists (` - src/foo.ts` under ` artifacts:`) — is folded into
* the CURRENT entry. This keeps a `artifacts:`/`missing:` sub-list's `- `
* items from being mis-split into spurious standalone entries (#2286 review
* LOW finding).
*
* Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
* the template emits) are discarded. An empty/whitespace-only section body
* (heading present, no bullets) returns `[]`.
*/
function splitGapsEntries(sectionBody: string): string[][] {
const lines = sectionBody.split('\n');
const entries: string[][] = [];
let current: string[] | null = null;
let baseIndent: number | null = null;
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '');
const bulletMatch = line.match(/^(\s*)-\s/);
if (bulletMatch) {
const indent = bulletMatch[1].length;
if (baseIndent === null) baseIndent = indent;
if (indent <= baseIndent) {
if (current) entries.push(current);
current = [line];
continue;
}
}
if (current) current.push(line);
// else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
}
if (current) entries.push(current);
return entries;
}
/**
* Extract `key: value` fields from one Gaps entry's lines, anchored to the
* START of each (bullet-marker-stripped, trimmed) line — never scanning the
* REST of a line, so a colon-bearing phrase inside a quoted `truth`/`reason`
* value is never misread as a field declaration (see `parseGapsItems`'s doc
* comment for the false-negative this specifically guards against).
*
* Recognises a double-quoted value (`truth: "..."`, stripped of its wrapping
* quotes — the value may itself contain any character, including `:`) or a
* bare value (`status: open`, `test: 2`, `artifacts: []`) taken verbatim.
* The FIRST occurrence of a given key wins (top-level fields always precede
* any nested sub-list content in the template's field ordering); later
* `key:`-shaped nested-list content is captured, if it parses as one, but
* never overrides an already-seen top-level field.
*/
function extractGapEntryFields(entryLines: string[]): Record<string, string> {
const fields: Record<string, string> = {};
const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
entryLines.forEach((rawLine, idx) => {
const line = rawLine.replace(/\r$/, '');
// Strip ONLY the entry-opening bullet marker (idx 0); a bullet marker on
// a later line belongs to a nested sub-list and is handled by
// `splitGapsEntries` already folding it in — it is not itself a field
// line unless it independently matches `key: value` after stripping.
const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
const content = idx === 0 && bulletStripped ? bulletStripped[2] : line.trim();
const m = fieldLineRe.exec(content);
if (!m) return;
const key = m[1];
let value = m[2].trim();
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
value = value.slice(1, -1);
}
if (!(key in fields)) fields[key] = value;
});
return fields;
}
/** Fallback display text for a Gaps entry with no parseable `truth:` field. */
function rawGapEntryText(entryLines: string[]): string {
return entryLines
.map((l, i) => (i === 0 ? l.replace(/^(\s*)-\s+/, '') : l.trim()))
.join(' ')
.trim();
}
// ─── parseVerificationItems ───────────────────────────────────────────────────
function parseVerificationItems(content: string, status: string): UatItem[] {
const items: UatItem[] = [];
if (status === 'human_needed') {
// #2286: the frontmatter's structured `human_verification:` YAML array
// (extractFrontmatter) is the PRIMARY source of truth when present and
// non-empty — it fully bypasses the body-shape scan below, so a file
// whose frontmatter declares the array doesn't require any particular
// `## Human Verification` body shape at all. An absent or empty array
// (length 0) falls back to the body scan unchanged.
const frontmatter = extractFrontmatter(content);
const humanVerification = frontmatter.human_verification;
if (Array.isArray(humanVerification) && humanVerification.length > 0) {
humanVerification.forEach((entry, idx) => {
items.push({
test: idx + 1,
name: normalizeHumanVerificationEntry(entry),
result: 'human_needed',
category: 'human_uat',
});
});
return items;
}
// Use the seam to locate the ## Human Verification section (ADR-1372 T5).
const hvSection = collectSection(
content,
@@ -459,12 +648,75 @@ function parseVerificationItems(content: string, status: string): UatItem[] {
});
}
}
// #2286: fall back to the `### N. <label>` heading + bold-led paragraph
// shape (the canonical form emitted by `templates/verification-report.md`
// — `### 1. {Test Name}` followed by `**Test:** ... **Expected:** ...
// **Why human:** ...`), which the table/bullet/numbered per-line scan
// above never recognises (a `###`-prefixed line matches none of those
// three patterns). Uses the same `tokenizeHeadings` seam
// `parseFirstPendingTest` already uses for `### N.` sub-headings,
// applied here to the Human Verification section body. Runs in
// addition to (a union with) the scan above — the two shapes don't
// collide, so this only adds items a `###` heading page would have
// silently produced zero for.
const hvSubHeadings = tokenizeHeadings(hvSection.body).filter(
(h) => h.level === 3 && /^\d+\.\s+/.test(h.text),
);
for (let i = 0; i < hvSubHeadings.length; i += 1) {
const current = hvSubHeadings[i];
const next = hvSubHeadings[i + 1];
const block = next
? hvSection.body.slice(current.offset, next.offset)
: hvSection.body.slice(current.offset);
const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
// Require a bold-led paragraph body (`**Test:** ...`) to distinguish
// a genuine verification item from an unrelated numbered heading.
if (!/^\s*\*\*/.test(bodyAfterHeading)) continue;
const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
if (!headingParts) continue;
items.push({
test: parseInt(headingParts[1], 10),
name: headingParts[2].trim(),
result: 'human_needed',
category: 'human_uat',
});
}
}
}
// gaps_found items are already handled by plan-phase --gaps pipeline
return items;
}
/**
* Normalize a single `human_verification:` frontmatter array entry (#2286)
* into a display-ready name.
*
* #2286 review (LOW finding): `extractFrontmatter`'s generic array-item
* parser (`src/frontmatter.cts`, the `line.trim().startsWith('- ')` branch)
* has NO notion of nested key/value objects — regardless of whether the
* source YAML was authored as `- test: "..."` (an implied-but-unsupported
* shorthand) or `- "plain string"`, it ALWAYS pushes the raw post-`- ` text
* (with only a single layer of wrapping quotes stripped) as a plain string.
* There is therefore no reliable signal here to distinguish a genuine
* `key: value`-shaped pseudo-field from a legitimate plain string that
* itself happens to start with a word and a colon (e.g. `"Confirm: the
* button responds"`). A prior version of this function stripped a leading
* `word:` prefix on the assumption it was always a flattened nested-object
* key — that assumption is false, and it silently truncated real plain-string
* content. No such stripping is applied: any residual wrapping-quote noise
* left by `extractFrontmatter`'s own (anchor-only) quote handling is cleaned
* up, and everything else is preserved verbatim.
*/
function normalizeHumanVerificationEntry(raw: unknown): string {
if (typeof raw !== 'string') {
return raw === null || raw === undefined ? '' : JSON.stringify(raw);
}
const s = raw.trim().replace(/^["']+|["']+$/g, '').trim();
return s || raw.trim();
}
// ─── categorizeItem ───────────────────────────────────────────────────────────
function categorizeItem(result: string, reason?: string, blockedBy?: string): UatCategory {

View File

@@ -423,6 +423,399 @@ All checks passed.
`status: passed file should produce 0 outstanding items, got ${output.summary.total_items}`);
assert.strictEqual(output.summary.total_files, 0);
});
// 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',
'',
'<!-- YAML format for plan-phase --gaps consumption -->',
'- 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',
'',
'<!-- YAML format for plan-phase --gaps consumption -->',
'- 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',
'',
'<!-- YAML format for plan-phase --gaps consumption -->',
'- 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',
'',
'<!-- YAML format for plan-phase --gaps consumption -->',
'- 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',
'',
'<!-- YAML format for plan-phase --gaps consumption -->',
'- 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. <label>` + bold-paragraph body shape shipped by
// templates/verification-report.md. Boundary: array length 0 / 1 / 2+.
describe('human_verification frontmatter array + heading shape (#2286)', () => {
test('an empty human_verification array (length 0) falls back to the body scan', () => {
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',
'human_verification: []',
'---',
'',
'## 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, 2,
'an empty structured array must fall back to the existing body scan, not report 0');
assert.strictEqual(output.results[0].items[0].name, 'Test SSO login with Google account');
});
test('a populated human_verification array of length 1 is sourced from frontmatter as primary', () => {
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',
'human_verification:',
' - test: "Confirm the widget renders correctly"',
'---',
'',
'## Human Verification',
'',
'None — see frontmatter human_verification array.',
].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 reflect the frontmatter array, not the unstructured body prose');
// #2286 review LOW finding: extractFrontmatter's generic array-item
// parser has no notion of nested key/value objects — a `- test: "..."`
// entry is ALWAYS flattened to the raw post-"- " text, verbatim (only
// its own wrapping quote is stripped, and only at the string's outer
// edges). normalizeHumanVerificationEntry deliberately does NOT strip
// a leading "key:"-shaped prefix (see its doc comment) because doing
// so is indistinguishable from truncating a legitimate plain string
// that starts with a word and a colon — so this documented, slightly
// ugly artifact is the CORRECT (non-data-lossy) output for this shape.
assert.strictEqual(output.results[0].items[0].name, 'test: "Confirm the widget renders correctly');
assert.strictEqual(output.results[0].items[0].category, 'human_uat');
});
// Regression: #2286 review LOW finding — a plain-string human_verification
// entry that itself starts with "Word: " must be preserved verbatim, not
// truncated by a (removed) leading-key-prefix strip.
test('a plain-string human_verification entry beginning with "Word: " is preserved verbatim', () => {
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',
'human_verification:',
' - "Confirm: the button responds"',
'---',
'',
'## Human Verification',
'',
'None.',
].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);
assert.strictEqual(output.results[0].items[0].name, 'Confirm: the button responds',
'a plain string beginning with a word and a colon must not be truncated');
});
test('a populated human_verification array of length 2+ takes priority over a differently-shaped body', () => {
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',
'human_verification:',
' - "Confirm SSO login works end to end"',
' - "Confirm MFA enrollment banner appears"',
'---',
'',
'## Human Verification',
'',
'1. A body-scan item that must NOT be double-counted',
].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,
'the structured array is the PRIMARY source and must not union with the body scan');
const names = output.results[0].items.map((item) => item.name).sort();
assert.deepStrictEqual(names, ['Confirm MFA enrollment banner appears', 'Confirm SSO login works end to end']);
});
test('recognizes the ### N. <label> + bold-paragraph Human Verification body shape', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '05-widgets');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '05-VERIFICATION.md'), [
'---',
'status: human_needed',
'phase: 05-widgets',
'---',
'',
'## Human Verification Required',
'',
'### 1. Widget render check',
'**Test:** Confirm the widget appears as expected on the dashboard.',
'**Expected:** Widget renders with live data within 2 seconds.',
'**Why human:** Visual rendering cannot be verified by static analysis.',
'',
'### 2. Notification banner check',
'**Test:** Trigger a new notification and confirm the banner appears.',
'**Expected:** Banner appears within 1 second and auto-dismisses after 5 seconds.',
'**Why human:** Timing-based UI behavior requires visual confirmation.',
].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,
'the ### N. + bold-paragraph shape must be recognized instead of returning 0 items');
assert.strictEqual(output.results[0].items[0].test, 1);
assert.strictEqual(output.results[0].items[0].name, 'Widget render check');
assert.strictEqual(output.results[0].items[1].test, 2);
assert.strictEqual(output.results[0].items[1].name, 'Notification banner check');
assert.strictEqual(output.results[0].items[0].category, 'human_uat');
});
});
});
describe('uat render-checkpoint', () => {