/** * UAT Audit — Cross-phase UAT/VERIFICATION scanner * * Reads all *-UAT.md and *-VERIFICATION.md files across all phases. * Extracts non-passing items. Returns structured JSON for workflow consumption. * * ADR-457 build-at-publish: the hand-written bin/lib/uat.cjs collapsed * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour * from the prior hand-written .cjs; only strict types are added. */ import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line @typescript-eslint/no-require-imports import io = require('./io.cjs'); const { output, error } = io; // eslint-disable-next-line @typescript-eslint/no-require-imports import markdownSectionizer = require('./markdown-sectionizer.cjs'); const { collectSection, tokenizeHeadings } = markdownSectionizer; // eslint-disable-next-line @typescript-eslint/no-require-imports import markdownTable = require('./markdown-table.cjs'); const { splitTableRow, isDelimiterRow } = markdownTable; // eslint-disable-next-line @typescript-eslint/no-require-imports import coreUtils = require('./core-utils.cjs'); const { toPosixPath } = coreUtils; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); const { planningDir } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); const { extractFrontmatter } = frontmatter; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocator = require('./phase-locator.cjs'); const { getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocator; import { requireSafePath, sanitizeForDisplay } from './security.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module import configLoader = require('./config-loader.cjs'); const { loadConfig } = configLoader; // ─── Types ──────────────────────────────────────────────────────────────────── type UatResult = string; type UatCategory = 'server_blocked' | 'device_needed' | 'build_needed' | 'third_party' | 'blocked' | 'skipped_unresolved' | 'pending' | 'human_uat' | 'unknown' | 'deferred'; interface UatItem { test?: number; name: string; expected?: string; result: UatResult; category: UatCategory; reason?: string; blocked_by?: string; } interface UatFileResult { phase: string; phase_dir: string; file: string; file_path: string; type: 'uat' | 'verification' | 'deferred'; status: string; /** * Milestone version whose archive this phase dir was read from * (`.planning/milestones/-phases/`), or undefined for a phase still * in the active `.planning/phases/` tree. Lets a consumer label provenance * instead of presenting archived and in-flight work identically. */ archived_milestone?: string; items: UatItem[]; } interface CurrentTest { complete: boolean; number?: number; name?: string; expected?: string; } // ─── cmdAuditUat ───────────────────────────────────────────────────────────── function cmdAuditUat(cwd: string, raw: boolean): void { const phasesDir = path.join(planningDir(cwd), 'phases'); const hasActivePhases = fs.existsSync(phasesDir); // #2766: on milestone completion `milestone.cts` MOVES each phase dir into // `.planning/milestones/-phases/` (archive-by-default since #1871), // leaving `.planning/phases/` empty or absent. Scanning only the active tree // meant a partly-archived project silently omitted the archived phases, and a // fully-archived one hard-errored with "No phases directory found" — // indistinguishable from a broken install. Outstanding UAT items do not stop // mattering when a milestone closes: a deferred human-UAT scenario or a // `skipped` live-stack test is exactly what gets archived still-open. // // Reuses the canonical `getArchivedPhaseDirs` seam (phase-locator.cts), which // `findPhaseInternal` already uses for this same fallback, so the archive // layout convention stays owned by one module. const archivedDirs = getArchivedPhaseDirs(cwd); if (!hasActivePhases && archivedDirs.length === 0) { error('No phases directory found in planning directory'); } const results: UatFileResult[] = []; // Active dirs are milestone-filtered; archived dirs deliberately are NOT. // listMilestonePhaseDirs derives the CURRENT milestone's phase directories // (window + sentinel filtered) from ROADMAP.md, and archived phases belong // to past milestones by definition — so applying it to them discards every // one and silently reinstates the bug. const scanTargets: { dir: string; phaseDir: string; milestone?: string }[] = []; if (hasActivePhases) { // #3185 (ADR-3180 Decision 1): routed through the canonical owner // instead of a hand-rolled readdirSync + isDirInMilestone filter, which // also never excluded sentinels, unlike the owner. const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value; for (const dir of dirs) { scanTargets.push({ dir, phaseDir: path.join(phasesDir, dir) }); } } for (const archived of archivedDirs) { scanTargets.push({ dir: archived.name, phaseDir: archived.fullPath, milestone: archived.milestone, }); } for (const { dir, phaseDir, milestone } of scanTargets) { const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); const phaseNum = phaseMatch ? phaseMatch[1] : dir; const files = fs.readdirSync(phaseDir); // Process UAT files — scoped to THIS phase's own token (#3511) via // scopeToPhase, so a stray, cross-phase, or ad-hoc file cannot be reported // under this phase's audit-uat entry. A phase whose own UAT file is // genuinely absent scopes to empty and contributes nothing — correct, and // the reason scopeToPhase has no unfiltered fallback. for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), dir)) { const uatFilePath = path.join(phaseDir, file); const content = fs.readFileSync(uatFilePath, 'utf-8'); const items = parseUatItems(content); if (items.length > 0) { results.push({ phase: phaseNum, phase_dir: dir, file, file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))), type: 'uat', status: (extractFrontmatter(content, uatFilePath).status as string || 'unknown'), archived_milestone: milestone, items, }); } } // Process VERIFICATION files — scoped to THIS phase's own token (#3511) // for the same reason as the UAT loop above. for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), dir)) { const verificationFilePath = path.join(phaseDir, file); const content = fs.readFileSync(verificationFilePath, 'utf-8'); const status = extractFrontmatter(content, verificationFilePath).status as string || 'unknown'; if (status === 'human_needed' || status === 'gaps_found') { const items = parseVerificationItems(content, status, verificationFilePath); if (items.length > 0) { results.push({ phase: phaseNum, phase_dir: dir, file, file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))), type: 'verification', status, archived_milestone: milestone, items, }); } } } // Process deferred-items.md (#2287) — the SCOPE BOUNDARY convention // (agents/gsd-executor.md) has the executor log out-of-scope discoveries // to this file; nothing previously read it back. Surface every // UNRESOLVED entry (see parseDeferredItems for the resolved/unresolved // parsing rule) as a 'deferred'-typed result, keeping deferred-items.md // itself the single source of truth — no duplicate pending-todo entry // required. const deferredFile = 'deferred-items.md'; if (files.includes(deferredFile)) { const content = fs.readFileSync(path.join(phaseDir, deferredFile), 'utf-8'); const items = parseDeferredItems(content); if (items.length > 0) { results.push({ phase: phaseNum, phase_dir: dir, file: deferredFile, file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, deferredFile))), type: 'deferred', status: 'unresolved', archived_milestone: milestone, items, }); } } } // Compute summary const summary: { total_files: number; total_items: number; by_category: Record; by_phase: Record; } = { total_files: results.length, total_items: results.reduce((sum, r) => sum + r.items.length, 0), by_category: {}, by_phase: {}, }; for (const r of results) { if (!summary.by_phase[r.phase]) summary.by_phase[r.phase] = 0; for (const item of r.items) { summary.by_phase[r.phase]++; const cat = item.category || 'unknown'; summary.by_category[cat] = (summary.by_category[cat] || 0) + 1; } } output({ results, summary }, raw, undefined); } // ─── cmdRenderCheckpoint ────────────────────────────────────────────────────── function cmdRenderCheckpoint(cwd: string, options: { file?: string } = {}, raw: boolean): void { const filePath = options.file; if (!filePath) { error('UAT file required: use uat render-checkpoint --file '); } const resolvedPath = requireSafePath(filePath, cwd, 'UAT file', { allowAbsolute: true }); if (!fs.existsSync(resolvedPath)) { error(`UAT file not found: ${filePath}`); } const content = fs.readFileSync(resolvedPath, 'utf-8'); const currentTest = parseCurrentTest(content); if (currentTest.complete) { error('UAT session is already complete; no pending checkpoint to render'); } const config = loadConfig(cwd); const responseLanguage = typeof config.response_language === 'string' ? config.response_language : undefined; const checkpoint = buildCheckpoint(currentTest as Required> & { complete: false }, responseLanguage); output({ file_path: toPosixPath(path.relative(cwd, resolvedPath)), test_number: currentTest.number, test_name: currentTest.name, checkpoint, }, raw, checkpoint); } // ─── parseCurrentTest ───────────────────────────────────────────────────────── function parseCurrentTest(content: string): CurrentTest { // Use the seam to locate the ## Current Test section (ADR-1372 T5). // HTML-comment stripping within the section body is UAT-specific, so we keep // the comment removal caller-side after extracting the body. const currentTestSection = collectSection( content, (h) => /^current\s+test$/i.test(h.text) && h.level === 2, { levelBounded: true }, ); if (!currentTestSection) { error('UAT file is missing a Current Test section'); } // Remove any leading HTML comment block (UAT-specific document structure) const rawBody = currentTestSection!.body.replace(/^\s*\n?/, ''); const section = rawBody.trimEnd(); if (!section.trim()) { error('Current Test section is empty'); } if (/\[testing complete\]/i.test(section)) { return { complete: true }; } const numberMatch = section.match(/^number:\s*(\d+)\s*$/m); const nameMatch = section.match(/^name:\s*(.+)\s*$/m); const expectedBlockMatch = section.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m) || section.match(/^expected:\s*\|\n([\s\S]+)/m); const expectedInlineMatch = section.match(/^expected:\s*(.+)\s*$/m); if (!numberMatch || !nameMatch || (!expectedBlockMatch && !expectedInlineMatch)) { if (!numberMatch && !nameMatch && !expectedBlockMatch && !expectedInlineMatch) { const pendingTest = parseFirstPendingTest(content); if (pendingTest) { return pendingTest; } error('Current Test section is non-structured and no pending UAT test remains to resume'); } error('Current Test section is malformed'); } let expected: string; if (expectedBlockMatch) { expected = expectedBlockMatch[1] .split('\n') .map((line: string) => line.replace(/^ {2}/, '')) .join('\n') .trim(); } else { expected = expectedInlineMatch![1].trim(); } return { complete: false, number: parseInt(numberMatch![1], 10), name: sanitizeForDisplay(nameMatch![1].trim()), expected: sanitizeForDisplay(expected), }; } function parseFirstPendingTest(content: string): CurrentTest | null { // Use the seam to locate the ## Tests section (ADR-1372 T5). const testsSection = collectSection( content, (h) => /^tests$/i.test(h.text) && h.level === 2, { levelBounded: true }, ); if (!testsSection) { return null; } const sectionBody = testsSection.body; // Within the Tests section body, find ### N. Name sub-headings. // tokenizeHeadings operates on the section body as a standalone document, // filtering to level-3 headings matching the UAT-specific "N. Name" pattern. // The UAT-specific item parsing (number extraction, result parsing) stays caller-side. const subHeadings = tokenizeHeadings(sectionBody).filter( (h) => h.level === 3 && /^\d+\.\s+/.test(h.text), ); for (let i = 0; i < subHeadings.length; i += 1) { const current = subHeadings[i]; const next = subHeadings[i + 1]; // Slice the block for this sub-test from the section body text const block = next ? sectionBody.slice(current.offset, next.offset) : sectionBody.slice(current.offset); if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) { continue; } // Extract the UAT-specific number and name from the heading text const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/); if (!headingParts) continue; const testNumber = parseInt(headingParts[1], 10); const testName = headingParts[2].trim(); const expected = parseExpectedFromTestBlock(block); if (!expected) { error(`Pending UAT test ${testNumber} is missing an expected field`); } return { complete: false, number: testNumber, name: sanitizeForDisplay(testName), expected: sanitizeForDisplay(expected), }; } return null; } function parseExpectedFromTestBlock(block: string): string | null { const expectedBlockMatch = block.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m) || block.match(/^expected:\s*\|\n([\s\S]+)/m); if (expectedBlockMatch) { return expectedBlockMatch[1] .split('\n') .map((line: string) => line.replace(/^ {2}/, '')) .join('\n') .trim(); } const expectedInlineMatch = block.match(/^expected:\s*(.+)\s*$/m); return expectedInlineMatch ? expectedInlineMatch[1].trim() : null; } // ─── buildCheckpoint ────────────────────────────────────────────────────────── // // Localized frame strings (#2402): the checkpoint banner + instruction line are // the byte-for-byte block verify-work.md reprints verbatim, so the model can't // translate it after the fact — the frame must already be in `response_language` // when this function returns it. Bounded table with an ENGLISH FALLBACK for // unset/unrecognized languages keeps the default path byte-identical. interface CheckpointFrame { banner: string; instruction: string; direction?: 'rtl'; } const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical const CHECKPOINT_FRAMES: Record = { english: { banner: 'CHECKPOINT: Verification Required', instruction: 'Type `pass` or describe what\'s wrong.', }, spanish: { banner: 'PUNTO DE CONTROL: Verificación requerida', instruction: 'Escribe `pass` o describe qué está mal.', }, french: { banner: 'POINT DE CONTRÔLE : Vérification requise', instruction: 'Tapez `pass` ou décrivez ce qui ne va pas.', }, german: { banner: 'KONTROLLPUNKT: Überprüfung erforderlich', instruction: 'Gib `pass` ein oder beschreibe, was nicht stimmt.', }, portuguese: { banner: 'PONTO DE VERIFICAÇÃO: Verificação necessária', instruction: 'Digite `pass` ou descreva o que está errado.', }, japanese: { banner: 'チェックポイント: 検証が必要です', instruction: '`pass` と入力するか、問題点を説明してください。', }, chinese: { banner: '检查点:需要验证', instruction: '输入 `pass` 或描述问题所在。', }, korean: { banner: '체크포인트: 검증 필요', instruction: '`pass`를 입력하거나 문제를 설명하세요.', }, italian: { banner: 'PUNTO DI CONTROLLO: Verifica richiesta', instruction: 'Digita `pass` o descrivi cosa non va.', }, dutch: { banner: 'CONTROLEPUNT: Verificatie vereist', instruction: 'Typ `pass` of beschrijf wat er mis is.', }, polish: { banner: 'PUNKT KONTROLNY: Wymagana weryfikacja', instruction: 'Wpisz `pass` lub opisz, co jest nie tak.', }, russian: { banner: 'КОНТРОЛЬНАЯ ТОЧКА: требуется проверка', instruction: 'Введите `pass` или опишите, что не так.', }, ukrainian: { banner: 'КОНТРОЛЬНА ТОЧКА: потрібна перевірка', instruction: 'Введіть `pass` або опишіть, що не так.', }, turkish: { banner: 'KONTROL NOKTASI: Doğrulama gerekli', instruction: '`pass` yazın veya sorunu açıklayın.', }, hindi: { banner: 'चेकपॉइंट: सत्यापन आवश्यक', instruction: '`pass` लिखें या बताएं कि क्या गलत है।', }, arabic: { banner: 'نقطة تحقق: المراجعة مطلوبة', instruction: 'اكتب `pass` أو صف المشكلة.', direction: 'rtl', }, vietnamese: { banner: 'ĐIỂM KIỂM TRA: Cần xác minh', instruction: 'Nhập `pass` hoặc mô tả vấn đề.', }, indonesian: { banner: 'TITIK PEMERIKSAAN: Verifikasi diperlukan', instruction: 'Ketik `pass` atau jelaskan apa yang salah.', }, }; // Free-form response_language aliases → canonical CHECKPOINT_FRAMES key. const CHECKPOINT_LANGUAGE_ALIASES: Record = { english: 'english', en: 'english', 'en-us': 'english', 'en-gb': 'english', spanish: 'spanish', es: 'spanish', 'español': 'spanish', espanol: 'spanish', castellano: 'spanish', french: 'french', fr: 'french', 'français': 'french', francais: 'french', german: 'german', de: 'german', deutsch: 'german', portuguese: 'portuguese', pt: 'portuguese', 'pt-br': 'portuguese', 'português': 'portuguese', portugues: 'portuguese', 'brazilian portuguese': 'portuguese', japanese: 'japanese', ja: 'japanese', '日本語': 'japanese', chinese: 'chinese', zh: 'chinese', 'zh-cn': 'chinese', 'zh-tw': 'chinese', mandarin: 'chinese', 'simplified chinese': 'chinese', 'traditional chinese': 'chinese', '中文': 'chinese', korean: 'korean', ko: 'korean', '한국어': 'korean', italian: 'italian', it: 'italian', italiano: 'italian', dutch: 'dutch', nl: 'dutch', nederlands: 'dutch', flemish: 'dutch', vlaams: 'dutch', polish: 'polish', pl: 'polish', polski: 'polish', russian: 'russian', ru: 'russian', 'ru-ru': 'russian', 'русский': 'russian', ukrainian: 'ukrainian', uk: 'ukrainian', ua: 'ukrainian', 'українська': 'ukrainian', turkish: 'turkish', tr: 'turkish', 'türkçe': 'turkish', turkce: 'turkish', hindi: 'hindi', hi: 'hindi', 'हिन्दी': 'hindi', 'हिंदी': 'hindi', arabic: 'arabic', ar: 'arabic', 'العربية': 'arabic', vietnamese: 'vietnamese', vi: 'vietnamese', 'tiếng việt': 'vietnamese', 'tieng viet': 'vietnamese', indonesian: 'indonesian', id: 'indonesian', 'bahasa indonesia': 'indonesian', }; function resolveCheckpointFrame(responseLanguage: string | undefined): CheckpointFrame { if (!responseLanguage) return CHECKPOINT_FRAMES.english; const key = CHECKPOINT_LANGUAGE_ALIASES[ responseLanguage.trim().normalize('NFC').toLowerCase() ]; return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english; } // Approximate terminal-cell width. East Asian Width W/F code points occupy two // cells, while Unicode combining marks occupy no additional cell beyond their // base character. Counting only W/F ranges is insufficient for scripts such as // Devanagari: Hindi vowel signs and viramas are combining marks, and treating // each as a full cell visibly shifts the checkpoint box's right border. function isWideCodePoint(codePoint: number): boolean { return ( (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo codePoint === 0x2329 || codePoint === 0x232a || (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals .. CJK Symbols and Punctuation (codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana .. CJK Compatibility (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Extension A (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi Syllables (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs (codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms (codePoint >= 0xff00 && codePoint <= 0xff60) || // Fullwidth Forms (codePoint >= 0xffe0 && codePoint <= 0xffe6) || (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Unified Ideographs Extension B+ / supplementary ); } // Non-spacing/enclosing marks and format controls occupy zero terminal cells. // Spacing combining marks (General_Category=Mc), such as Devanagari vowel // signs, still advance the cursor and must contribute one column. const ZERO_WIDTH_MARK_RE = /\p{gc=Mn}|\p{gc=Me}|\p{gc=Cf}/u; // Iterates by Unicode code point (not UTF-16 code unit) so astral characters // are measured once, not as two surrogate units. function displayWidth(text: string): number { let width = 0; for (const ch of text) { if (ZERO_WIDTH_MARK_RE.test(ch)) continue; width += isWideCodePoint(ch.codePointAt(0) as number) ? 2 : 1; } return width; } // Pads `text` into a `║ text… ║` line matching CHECKPOINT_BOX_WIDTH. Content // that overflows the box (a longer translated string) is left unpadded rather // than truncated — a slightly ragged border beats losing text. function checkpointBoxLine(text: string): string { const innerWidth = CHECKPOINT_BOX_WIDTH - 2; const content = ` ${text}`; const padLength = innerWidth - displayWidth(content); const padded = padLength > 0 ? content + ' '.repeat(padLength) : content; return `║${padded}║`; } const RTL_ISOLATE = '\u2067'; const POP_DIRECTIONAL_ISOLATE = '\u2069'; function isolateCheckpointFrameText(text: string, frame: CheckpointFrame): string { return frame.direction === 'rtl' ? `${RTL_ISOLATE}${text}${POP_DIRECTIONAL_ISOLATE}` : text; } function buildCheckpoint(currentTest: { number: number; name: string; expected: string }, responseLanguage?: string): string { const frame = resolveCheckpointFrame(responseLanguage); const banner = isolateCheckpointFrameText(frame.banner, frame); const instruction = isolateCheckpointFrameText(frame.instruction, frame); return [ '╔══════════════════════════════════════════════════════════════╗', checkpointBoxLine(banner), '╚══════════════════════════════════════════════════════════════╝', '', `**Test ${currentTest.number}: ${currentTest.name}**`, '', currentTest.expected, '', '──────────────────────────────────────────────────────────────', instruction, '──────────────────────────────────────────────────────────────', ].join('\n'); } // ─── parseUatItems ──────────────────────────────────────────────────────────── function parseUatItems(content: string): UatItem[] { const items: UatItem[] = []; // Match test blocks: ### N. Name\nexpected: ...\nresult: ...\n // Accept both bare (result: pending) and bracketed (result: [pending]) formats (#2273) const testPattern = /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*\[?(\w+)\]?(?:\n(?:reported|reason|blocked_by):\s*[^\n]*)?/g; let match: RegExpExecArray | null; while ((match = testPattern.exec(content)) !== null) { const [, num, name, expected, result] = match; if (result === 'pending' || result === 'skipped' || result === 'blocked') { // Extract optional fields — limit to current test block (up to next ### or EOF) const afterMatch = content.slice(match.index); const nextHeading = afterMatch.indexOf('\n###', 1); const blockText = nextHeading > 0 ? afterMatch.slice(0, nextHeading) : afterMatch; const reasonMatch = blockText.match(/reason:\s*(.+)/); const blockedByMatch = blockText.match(/blocked_by:\s*(.+)/); const item: UatItem = { test: parseInt(num, 10), name: name.trim(), expected: expected.trim(), result, category: categorizeItem(result, reasonMatch?.[1], blockedByMatch?.[1]), }; if (reasonMatch) item.reason = reasonMatch[1].trim(); if (blockedByMatch) item.blocked_by = blockedByMatch[1].trim(); 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); } // #2766: union with the table form. A `|`-leading line is never a `- ` bullet // opener, so a section mixing bullet entries and a table surfaces both with no // double-counting. items.push(...parseGapsTableItems(gapsSection.body)); return items; } /** * Split a section body into its GFM pipe tables, one entry per table (#2766). * * Shared by `parseGapsTableItems` and `parseDeferredTableItems` so the * header/delimiter/table-boundary handling — the fiddly part — lives in exactly * one place, and the two consumers only decide what a data row MEANS. * * Header detection is lookahead-free: the last data-shaped row is held in * `pending` until the NEXT line decides its fate — a delimiter row * (`|---|---|`) proves the held row was a header, anything else promotes it to a * data row. So a conventional table drops exactly its header, a HEADERLESS table * keeps every row (hand-authored planning tables often omit the delimiter), and * a header with no data rows yields nothing. A prose or blank line ends the * current table, so two tables separated by text are read independently and each * drops its own header. * * Reuses the canonical `isDelimiterRow` shape check from markdown-table.cts * rather than re-deriving it. Deliberately NOT routed through * `parseMarkdownTable`, which reads only the FIRST table in a body and treats * ragged/headerless shapes as errors (ADR-2143 §3) — correct for the mandated * tables in STATE.md/ROADMAP.md, but the wrong contract here, where a malformed * hand-written table must still surface its rows rather than be dropped. */ function collectTableRows(sectionBody: string): { header: string[] | null; rows: string[][] }[] { const tables: { header: string[] | null; rows: string[][] }[] = []; let current: { header: string[] | null; rows: string[][] } | null = null; let pending: string[] | null = null; const ensure = (): void => { if (!current) current = { header: null, rows: [] }; }; const flushPending = (): void => { if (pending) { ensure(); current!.rows.push(pending); pending = null; } }; const endTable = (): void => { flushPending(); if (current) { tables.push(current); current = null; } }; for (const rawLine of sectionBody.split('\n')) { const line = rawLine.replace(/\r$/, '').trim(); if (!line.startsWith('|')) { endTable(); continue; } const cells = splitTableRow(line); if (cells.length === 0) continue; if (isDelimiterRow(cells)) { ensure(); current!.header = pending; // may be null for a delimiter-first table pending = null; continue; } flushPending(); pending = cells; } endTable(); return tables; } /** * Header-name → canonical Gaps field (#2766). * * Anchored on the `## Gaps` field vocabulary `templates/UAT.md` mandates for the * YAML-lite bullet form (truth/status/reason/severity/test), plus the obvious * synonyms a human writing the same information as a table reaches for instead. */ const GAPS_COLUMN_ALIASES: Record = { truth: 'truth', gap: 'truth', finding: 'truth', item: 'truth', description: 'truth', issue: 'truth', name: 'truth', status: 'status', result: 'status', state: 'status', reason: 'reason', note: 'reason', notes: 'reason', detail: 'reason', details: 'reason', evidence: 'reason', severity: 'severity', test: 'test', '#': 'test', 'test #': 'test', 'test number': 'test', }; function mapGapsHeader(header: string[] | null): Record | null { if (!header) return null; const columns: Record = {}; header.forEach((cell, idx) => { const key = GAPS_COLUMN_ALIASES[cell.trim().toLowerCase().replace(/\*+/g, '')]; if (key && !(key in columns)) columns[key] = idx; }); return Object.keys(columns).length > 0 ? columns : null; } /** * Extract gap entries from GFM pipe tables in a `## Gaps` section (#2766) — a * UNION with the YAML-lite bullet scan in `parseGapsItems`, for the same reason * `parseDeferredTableItems` exists: `splitGapsEntries` keys entirely on `- ` * bullet openers, so a table-shaped `## Gaps` section yielded ZERO items and * every finding in it was silently invisible. * * Neither `templates/UAT.md` nor `templates/verification-report.md` documents a * table for this section (both mandate the bullet/numbered form), so a table * here is off-template hand-authoring — which is precisely why it must not fail * silently. Note `parseVerificationItems` in this same file already reads table * rows AND numbered AND bullet items as a union because the live sections mix * shapes; the Gaps and deferred parsers never got the same treatment. * * When a header row is present its columns are mapped by name against the * template's own field vocabulary (see GAPS_COLUMN_ALIASES) so a tabled gap * carries the same status/reason/test fields as its bullet equivalent and * `categorizeItem` classifies it identically. With no recognizable header, the * row degrades to a joined-cells name with status `unknown` — surfaced, not * dropped, matching this module's established fail-safe stance. * * Resolution follows the bullet path exactly: an entry is skipped ONLY on an * explicit resolved marker — the mapped `status` column reading `resolved`, or, * absent a status column, any cell reading exactly `resolved`. A gap with no * parseable status is NEVER treated as resolved. */ function parseGapsTableItems(sectionBody: string): UatItem[] { const items: UatItem[] = []; for (const { header, rows } of collectTableRows(sectionBody)) { const columns = mapGapsHeader(header); for (const cells of rows) { const at = (key: string): string => (columns && key in columns ? (cells[columns[key]] ?? '').trim() : ''); const rawStatus = at('status'); if (rawStatus && rawStatus.toLowerCase() === 'resolved') continue; // No status column: fall back to an explicit resolved marker in any cell // (the headerless-table equivalent of `status: resolved`). if (!columns || !('status' in columns)) { if (cells.some(c => /^resolved$/i.test(c.trim()))) continue; } const truth = at('truth'); const reason = at('reason'); const testNum = at('test'); const name = truth || cells.filter(c => c !== '').join(' — '); if (!name) continue; const status = rawStatus || 'unknown'; const item: UatItem = { name, result: status, category: categorizeItem(status, reason || undefined, undefined), }; if (testNum && /^\d+$/.test(testNum)) item.test = parseInt(testNum, 10); if (reason) item.reason = reason; items.push(item); } } return items; } // ─── parseDeferredItems ──────────────────────────────────────────────────────── /** * Extract unresolved entries from a phase directory's `deferred-items.md` * (#2287) — the SCOPE BOUNDARY convention `agents/gsd-executor.md` instructs * the executor to follow: "Log out-of-scope discoveries to `deferred-items.md` * in the phase directory". Nothing previously read this file back, so a * deferred entry was permanently invisible outside the phase directory. * * The writer convention (unchanged by this fix, per the issue's stated * out-of-scope) emits a plain bullet list, typically under a `## Deferred * Items` heading (see the issue's own reproduction fixture), one entry per * top-level `- ` line with optional indented continuation lines. There is no * mandated heading text, so if no `## Deferred Items`-shaped level-2 heading * is found, the WHOLE file is scanned as the entry list — fail-safe, so an * agent writing a differently-headed (or headless) deferred-items.md still * has its entries surfaced rather than silently skipped. * * Reuses the same per-line field/entry-splitting seams as `parseGapsItems` * (`splitGapsEntries`, `extractGapEntryFields`, `rawGapEntryText`) — an entry * is RESOLVED only when it carries an explicit `status: resolved` field * (case-insensitive), mirroring the established Gaps convention so a human or * follow-up agent can mark a deferred item done in place, keeping * `deferred-items.md` the single source of truth (no duplicate * `.planning/todos/pending/*.md` entry required). Every other entry — * including one with no `status:` field at all — is UNRESOLVED and is * surfaced. * * #3457: when the section body contains headings, entries are delimited by * LEAF headings (see `splitDeferredHeadingEntries`) rather than by bullets — * the executor convention writes one deferred item as a heading followed by * sibling `- **Field:** …` bullets, which the bullet-only split mis-counted as * one item PER BULLET. A body with no headings keeps the original * one-bullet-per-item split unchanged. */ function parseDeferredItems(content: string): UatItem[] { const deferredSection = collectSection( content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true }, ); const sectionBody = deferredSection ? deferredSection.body : content; const items: UatItem[] = []; // #3457: heading-delimited shape — an entry's fields live in sibling bullets // (`- **Status:** resolved`), so the bullet marker is stripped on EVERY line // before field extraction, not just line 0 (which `extractGapEntryFields` // does for the headless/Gaps shape, where a later `- ` line is a nested // sub-list, not a field). const headingEntries = splitDeferredHeadingEntries(sectionBody); const entries = headingEntries !== null ? headingEntries.map((entryLines) => ({ lines: entryLines, fields: extractGapEntryFields(entryLines.map(stripLeadingBulletMarker)), })) : splitGapsEntries(sectionBody).map((entryLines) => ({ lines: entryLines, fields: extractGapEntryFields(entryLines), })); for (const { lines: entryLines, fields } of entries) { const rawStatus = fields.status; if (rawStatus && rawStatus.toLowerCase() === 'resolved') continue; const text = rawGapEntryText(entryLines); if (!text) continue; items.push({ name: text, result: 'unresolved', category: 'deferred', }); } // #2766: union with the table form — see parseDeferredTableItems. Executors // write this file by hand with no mandated shape, and a GFM table is a natural // choice for the common "test → failing seeds" case, which produced ZERO items. items.push(...parseDeferredTableItems(sectionBody)); return items; } /** * Strip one leading `- ` bullet marker (#3457). Heading-delimited deferred * entries carry their fields as sibling bullets; `extractGapEntryFields` only * de-bullets line 0 (Gaps-protective — there, a later `- ` line is a nested * sub-list), so the deferred heading path de-bullets every line itself before * field extraction. Non-bullet lines pass through untouched. */ function stripLeadingBulletMarker(line: string): string { return line.replace(/^(\s*)-\s+/, ''); } /** * Split a deferred-items section body into entries delimited by LEAF headings * (#3457). Returns `null` when the body contains no heading at all — the * caller then falls back to `splitGapsEntries`, keeping headless * one-bullet-per-item files byte-for-byte on the pre-#3457 path. * * A heading is a CONTAINER (group/provenance/title label, contributes no * entry) iff the NEXT heading is deeper — a deeper heading lives inside its * span. Otherwise it is a LEAF: an entry boundary. This handles all three * corpus shapes without hardcoding a depth: flat `#` title + `##` entries * (title's next heading is deeper → container; each `##` followed by a * same-or-shallower heading → leaf), a `##` container with `###` entries * (container's next heading is deeper), and mixed-depth files where a * childless `##` entry sits alongside a `##` group with `###` children — every * childless heading is a leaf at whatever depth it is written. The shallower * rules the issue reports as already tried (split on every heading; shallowest * level; deepest level) each mis-count one of these shapes. * * A leaf entry is [heading text, ...body lines up to the next heading] and is * kept only when its body (minus table lines) contains at least one `- ` * bullet: * - a prose-only or bare heading contributes nothing — "prose is not an item" * is this parser's pre-existing contract (see the `# Notes` case); * - a table-only body is left entirely to `parseDeferredTableItems`, which * unions over the same section body, so the heading cannot double-count the * table's rows. * * Lines before the first heading, and lines directly under a container heading * (before its first child), are split one-bullet-per-item by the unchanged * `splitGapsEntries` — headless parity, so loose bullets before a later * heading group (the mixed shape) stay one item each. */ function splitDeferredHeadingEntries(sectionBody: string): string[][] | null { const headings = tokenizeHeadings(sectionBody); if (headings.length === 0) return null; const lines = sectionBody.split('\n'); const headingByLine = new Map(); for (let i = 0; i < headings.length; i++) { // Container iff the next heading is deeper (see doc comment). An empty // heading text (`##` alone) does not itself mean container — the flag is // carried explicitly so a bare LEAF heading still opens an entry. const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level; headingByLine.set(headings[i].line, { text: headings[i].text, isContainer }); } const entries: string[][] = []; let current: string[] | null = null; // accumulating a leaf heading's entry let pending: string[] = []; // preamble / container-heading body lines let currentHasBullet = false; const flushCurrent = (): void => { // Keep the leaf entry only when its body carries a bullet; the heading // text line itself (element 0) never counts as one. if (current !== null && currentHasBullet) entries.push(current); current = null; currentHasBullet = false; }; const flushPending = (): void => { entries.push(...splitGapsEntries(pending.join('\n'))); pending = []; }; for (let i = 0; i < lines.length; i++) { const lineNo = i + 1; const heading = headingByLine.get(lineNo); if (heading !== undefined) { flushCurrent(); // Headless-shaped region (preamble / container-direct bullets) ends at // ANY heading; flushing here keeps entries in document order even when // a container's direct bullets precede its first child entry. flushPending(); if (!heading.isContainer) { // Leaf heading: open an entry with the heading text as line 0. current = [heading.text]; currentHasBullet = false; } continue; } // Table lines belong to parseDeferredTableItems, never to a heading entry. if (/^\s*\|/.test(lines[i].replace(/\r$/, ''))) continue; if (current !== null) { current.push(lines[i]); if (/^\s*-\s/.test(lines[i].replace(/\r$/, ''))) currentHasBullet = true; } else { pending.push(lines[i]); } } flushCurrent(); flushPending(); return entries; } /** * Extract deferred entries from GFM pipe tables in a deferred-items.md body * (#2766) — a UNION with the bullet scan in `parseDeferredItems`. * * Cells are joined with ` — ` rather than taking only the first: these tables * carry the useful detail in the later columns (the failing seeds, the reason, * the owner), and dropping them would surface a name with no context. * * A row is skipped when any cell reads exactly `resolved`/`done`/`pass` * (case-insensitive), mirroring the "explicit resolution only" convention * `parseGapsItems` uses for `status: resolved` and `parseVerificationItems` uses * for its `hasPassResult` cell scan — so a human can close a tabled deferred * item in place and keep deferred-items.md the single source of truth. * * Deliberately permissive: an unrelated table in a deferred-items.md (say a * table of environment notes) will surface as deferred entries. That is the * correct fail-safe direction for a false-NEGATIVE bug — the whole file exists to * record outstanding work, and this module's established stance (see * parseGapsItems' 'unknown'-status fallback) is to surface a questionable entry * rather than silently drop a real one. */ function parseDeferredTableItems(sectionBody: string): UatItem[] { const items: UatItem[] = []; for (const { rows } of collectTableRows(sectionBody)) { for (const cells of rows) { if (cells.some(c => /^(resolved|done|pass)$/i.test(c))) continue; const name = cells.filter(c => c !== '').join(' — '); if (!name) continue; items.push({ name, result: 'unresolved', category: 'deferred', }); } } 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 `` 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. * * #3457: markdown emphasis around the KEY (`**Status:** resolved` — the * deferred-items convention bolds every field, and a bolded resolution marker * previously failed this regex outright and surfaced as its own bogus * unresolved entry) is unwrapped before the match, still anchored at the * start of the line. The unwrapped key is lower-cased, because the bolded * convention form is Title-cased (`**Status:**`) while the field vocabulary * this module reads is lowercase (`status`) — the same normalization * `mapGapsHeader` already applies to table header cells. Bare (unbolded) keys * keep their literal case, and mid-line emphasis is untouched, preserving the * start-anchored decoy invariant above. */ function extractGapEntryFields(entryLines: string[]): Record { const fields: Record = {}; const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/; const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/; 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()) .replace(boldedKeyRe, (_m, key: string) => `${key.toLowerCase()}:`); 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, sourcePath?: 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, sourcePath); 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, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true }, ); if (hvSection) { // #2245 review Fix 3: reverted to the pre-Phase-4 (HEAD 2cbf18642) // implementation. The live Human Verification section is NOT a strict // GFM table — the planner/verifier templates mix table rows, numbered // items, and bullet items in the same section (and a `### N.` heading // format is common too), so a table-XOR-list read (parse a table, and // if it parses, suppress numbered/bullet items entirely) silently // dropped items on any mixed or malformed section: a malformed // `| N | … |` table with no valid header/delimiter yielded ZERO items // instead of reading the rows positionally. This per-line scan reads // table rows AND numbered items AND bullet items as a UNION (whichever // pattern a given line matches), exactly like OLD, and reads // `| N | desc |` rows even without a valid table header/delimiter. // // #2245 audit: the table-row branch's CELL SPLIT is name/position- // addressed via `splitTableRow` (escape-aware, canonical) instead of a // hand-rolled pipe regex — candidacy itself is decided WITHOUT a table // regex (a leading `|` plus a purely-numeric first cell), so this no // longer needs an allow-adhoc-markdown suppression at all. const lines = hvSection.body.split('\n'); for (const line of lines) { const trimmedLine = line.trim(); // Match table rows: | N | description | ... — candidacy requires a // leading pipe and a purely-numeric first cell (mirrors what the old // regex effectively required: a "|digit|" cell immediately followed // by more content), with at least 2 physical cells so a bare "| N |" // with nothing after it is NOT treated as a row. // // #2245 review Fix 9: this is NOT the same as OLD for a row whose // ONLY content past the digit cell is trailing whitespace (e.g. // "| N | ", no second delimiting `|`). OLD's `([^|]+)` regex ran // against the RAW (untrimmed) line and its `\s*` would backtrack to // let `[^|]+` swallow that trailing whitespace, so OLD matched and // pushed an item with an EMPTY (`.trim()`-collapsed) name. Here, // `trimmedLine = line.trim()` strips that trailing whitespace BEFORE // `splitTableRow` ever sees it, collapsing the line to a single cell // (`candidateCells.length === 1`), which fails the `>= 2` check — // the item is silently dropped instead. A real, acceptable behaviour // change (an empty-named UAT item is not useful either way), but the // two implementations are NOT equivalent on this input. let tableCells: string[] | null = null; if (trimmedLine.startsWith('|')) { const candidateCells = splitTableRow(trimmedLine); if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) { tableCells = candidateCells; } } // Match bullet items: - description const bulletMatch = line.match(/^[-*]\s+(.+)/); // Match numbered items: 1. description const numberedMatch = line.match(/^(\d+)\.\s+(.+)/); if (tableCells) { // Skip rows that already have a passing result (PASS, pass, resolved, etc.) // — checked over every cell AFTER the description column, mirroring // OLD's rowRemainder scan (which only ever saw cells past the // description, the description itself having already been consumed). const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c)); if (hasPassResult) continue; items.push({ test: parseInt(tableCells[0], 10), name: tableCells[1] ?? '', result: 'human_needed', category: 'human_uat', }); } else if (numberedMatch) { items.push({ test: parseInt(numberedMatch[1], 10), name: numberedMatch[2].trim(), result: 'human_needed', category: 'human_uat', }); } else if (bulletMatch && bulletMatch[1].length > 10) { items.push({ name: bulletMatch[1].trim(), result: 'human_needed', category: 'human_uat', }); } } // #2286: fall back to the `### N.