/** * UAT Predicate — Pure-computation UAT pass/fail evaluation * * Evaluates all *-UAT.md and *-VERIFICATION.md files in a phase directory and * returns a typed report. Used by `phase uat-passed` to harden against the * naive whole-file regex in cmdPhaseComplete which false-matches `result:` lines * inside frontmatter, fenced code blocks, blockquotes, and HTML comments. * * Issue #247 — phase uat-passed predicate * * ADR-457 build-at-publish: compiled by tsc to gsd-core/bin/lib/uat-predicate.cjs. */ import fs from 'node:fs'; import path from 'node:path'; // 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 markdownSectionizer = require('./markdown-sectionizer.cjs'); const { stripFencedCode } = markdownSectionizer; // eslint-disable-next-line @typescript-eslint/no-require-imports import verification = require('./verification.cjs'); const { readVerificationStatus } = verification; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); const { scopeToPhase } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import coreUtils = require('./core-utils.cjs'); const { normalizeLineEndings } = coreUtils; // ─── Types ──────────────────────────────────────────────────────────────────── interface UatCheckItem { file: string; test: number; name: string; result: string; passing: boolean; } interface UatPassedReport { passed: boolean; uat_files: string[]; verification_files: string[]; checks: UatCheckItem[]; blockers: string[]; no_uat_artifacts: boolean; policy: { require_verification: boolean; }; /** * #3057 B3: true when the `requireVerification` policy check's own * readVerificationStatus call could not complete its internal staleness * check (fs / scanPhasePlans / clock failure). `passed`/`blockers` above are * UNCHANGED by this — the pre-existing fail-open routing (indeterminate * treated as not-stale) is preserved — this only makes the fact visible to * cmdPhaseUatPassed's JSON output (which spreads this whole report), rather * than silently indistinguishable from "checked; nothing is stale". Always * false when `requireVerification` is not set (the check is never reached). */ verification_stale_check_indeterminate: boolean; } // ─── Blocking state sets (documented for maintainability) ───────────────────── // UAT file frontmatter `status` values that indicate the file is not fully done const BLOCKING_UAT_FM_STATUSES = new Set([ 'partial', 'diagnosed', 'pending', 'blocked', 'in_progress', 'failed', ]); // UAT file frontmatter `result` values that indicate failure const BLOCKING_UAT_FM_RESULTS = new Set(['pending', 'blocked', 'failed']); // Canonical VERIFICATION frontmatter `status` value that indicates passing. const PASSING_VERIFICATION_STATUSES = new Set(['passed']); // VERIFICATION file frontmatter `status` values that explicitly block const BLOCKING_VERIFICATION_FM_STATUSES = new Set([ 'human_needed', 'gaps_found', 'pending', 'blocked', 'partial', 'failed', 'in_progress', ]); // UAT test-item `result` values that count as passing const PASSING_RESULTS = new Set(['passed', 'pass']); // ─── stripFalsePositiveContexts ─────────────────────────────────────────────── /** * Remove contexts that can contain `result: ...` lines that are NOT real test results: * (a) leading frontmatter block at byte 0 * (b) HTML comments (unterminated comments swallow to EOF — fail-closed) * (c) fenced code blocks (backtick and tilde, indented too) via CommonMark state machine * (d) blockquote lines * * Each step is a composable function (Kernighan's Law — independently testable). * Returns surviving lines joined by '\n'. Robust to CRLF input. */ function stripFalsePositiveContexts(content: string): string { // Step (a): strip leading frontmatter block only at byte 0 let stripped = content.replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(\r?\n|$)/, ''); // Step (b): remove HTML comments anywhere; unterminated comment swallows to EOF stripped = stripped.replace(/|$)/g, ''); // Step (c): remove fenced code blocks via the canonical seam (ADR-1372 T5) stripped = stripFencedCode(stripped).text; // Step (d): remove blockquote lines stripped = stripped .split('\n') .filter(line => !/^\s*>/.test(line)) .join('\n'); return stripped; } /** * Analyse raw markdown for structural anomalies (unterminated fence / comment). * Exported for unit-testability and used by evaluateUatPassed for per-file malformed detection. * * FIX C: properly balanced comments are stripped before checking for a dangling `. Using indexOf (not a regex .replace of the comment // token) avoids the js/incomplete-multi-character-sanitization pattern — and // is exact: a closed earlier comment never masks a later unterminated one. let unterminatedComment = false; for (let i = 0; ; ) { const open = raw.indexOf('', open + 4); if (close === -1) { unterminatedComment = true; break; } i = close + 3; } // Fence state machine gives the accurate unterminated-fence signal (seam, ADR-1372 T5). const { unterminatedFence } = stripFencedCode(raw); return { unterminatedFence, unterminatedComment }; } // ─── parseUatResultItems ────────────────────────────────────────────────────── /** * HEADING-BLOCK parser: scan the CLEANED body (after stripFalsePositiveContexts) * for UAT test blocks. * * For each ### N. Name heading, the block spans until the next ### heading or EOF. * Within each block, find a column-0 anchored result line (rejects indented YAML * block-scalar bodies and inline/quoted fakes). * * - If a heading block has NO column-0 result line → emit result:'missing' (blocker). * - Support bracketed [passed] and bare passed (#2273). * - Returns ALL items (both passing and non-passing). */ function parseUatResultItems(cleanContent: string): Array<{ test: number; name: string; result: string }> { const items: Array<{ test: number; name: string; result: string }> = []; // Find all ### N. Name headings. // #3078-CR MEDIUM (security review follow-up): STRUCTURE and ATTRIBUTION // need different split frames. This is a STRUCTURE scan — finding where a // heading block begins — and there is no attribution distinction to // preserve, so split on any of `\n`, U+2028, U+2029: a heading delimited by // an exotic line separator (origin/next's `/m`-anchored scan found these; // a naive `split('\n')`-only port silently stopped finding them, making the // gate MORE permissive than origin/next) is found exactly like a // `\n`-delimited one. Contrast the `result:` scan below, which is an // ATTRIBUTION scan and must NOT do this. const HEADING_LINE_RE = /^###\s*(\d+)\.\s*(.+)$/; const headings: Array<{ index: number; lineStart: number; test: number; name: string }> = []; { // All three separators are exactly one UTF-16 code unit, so the // `line.length + 1` offset arithmetic below stays valid regardless of // which separator terminated a given line. const lines = cleanContent.split(/[\n\u2028\u2029]/); let offset = 0; for (const line of lines) { const hMatch = line.match(HEADING_LINE_RE); if (hMatch) { headings.push({ index: offset + hMatch[0].length, lineStart: offset, test: parseInt(hMatch[1], 10), name: hMatch[2].trim(), }); } offset += line.length + 1; // +1 for the separator consumed by split } } for (let i = 0; i < headings.length; i++) { const h = headings[i]; const blockStart = h.index; // A block spans until the START of the next heading's line (tracked // directly from the same split-frame scan above), not a re-search for a // literal '\n###' over unsplit text -- the latter would silently miss a // next heading delimited by U+2028/U+2029 instead of '\n' and swallow // every subsequent block into this one. const blockContent = i + 1 < headings.length ? cleanContent.slice(blockStart, headings[i + 1].lineStart) : cleanContent.slice(blockStart); // Column-0 result line, split-then-match (#3078-CR MEDIUM — same fix as // the heading scan above): test each already-split line individually // against a single-line (no `/m` anchor) pattern instead of anchoring // over unsplit `blockContent`, so a `result:`-shaped line reachable only // via a U+2028/U+2029 separator inside an `expected: |` scalar body can // never register as a fake column-0 match. FIRST MATCH WINS — no // ambiguity counting, matching src/uat.cts's contract. // Uses [ \t]* (not \s*) so the captured value must sit on the SAME line as result:. // A result: key with the value on a subsequent line yields no match → 'missing' (blocker). const RESULT_LINE_RE = /^result:[ \t]*\[?([\w-]+)\]?/i; const resultMatch = blockContent .split('\n') .map((line) => line.match(RESULT_LINE_RE)) .find((m): m is RegExpMatchArray => m !== null) ?? null; if (resultMatch) { items.push({ test: h.test, name: h.name, result: resultMatch[1].toLowerCase(), }); } else { // No column-0 result line → emit 'missing' (a non-passing state) items.push({ test: h.test, name: h.name, result: 'missing', }); } } return items; } // ─── evaluateUatPassed ──────────────────────────────────────────────────────── /** * Evaluate all UAT/VERIFICATION files in a phase directory. * Returns a UatPassedReport with the locked, stable shape defined by the interface. * * FAIL-CLOSED: any absence/ambiguity/malformed input → NOT passed. * Pass requires at least one real passing check AND no blockers. */ function evaluateUatPassed( phaseFullDir: string, opts?: { policy?: { requireVerification?: boolean } }, ): UatPassedReport { const requireVerification = opts?.policy?.requireVerification === true; const blockers: string[] = []; const checks: UatCheckItem[] = []; const uatFiles: string[] = []; const verificationFiles: string[] = []; // Read the directory — if unreadable, treat as no files (fail-closed: no artifacts → not passed) let dirEntries: string[] = []; try { dirEntries = fs.readdirSync(phaseFullDir); } catch { // Unreadable dir — no_uat_artifacts:true, passed:false const no_uat_artifacts = true; if (requireVerification) { blockers.push('policy: verification required but no passing *-VERIFICATION.md found'); } return { passed: false, uat_files: [], verification_files: [], checks: [], blockers, no_uat_artifacts, policy: { require_verification: requireVerification }, // readVerificationStatus was never reached on this early-return path. verification_stale_check_indeterminate: false, }; } // Filter UAT and VERIFICATION files using the same filter as cmdPhaseComplete, // scoped to THIS phase's own token (#3511) — a stray, cross-phase, or ad-hoc // file can no longer contribute a blocker to a phase it does not belong to. const phaseDirBaseName = path.basename(phaseFullDir); const uatFileNames = scopeToPhase( dirEntries.filter(f => f.includes('-UAT') && f.endsWith('.md')), phaseDirBaseName, ); const verFileNames = scopeToPhase( dirEntries.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), phaseDirBaseName, ); // ── Process UAT files ────────────────────────────────────────────────────── for (const file of uatFileNames) { uatFiles.push(file); const uatFilePath = path.join(phaseFullDir, file); let raw = ''; try { // #3078-CR MEDIUM: normalize line endings at the read boundary — the // same seam src/uat.cts and src/verification.cts route through — so a // lone-CR *-UAT.md is not read as one unbroken line by the column-0 // scans below. raw = normalizeLineEndings(fs.readFileSync(uatFilePath, 'utf-8')); } catch { blockers.push(`${file}: could not read file`); continue; } // ── Per-file malformed markdown guard ────────────────────────────────── // FIX D: use accurate signals from analyzeMarkdown instead of heuristics. // unterminatedFence: CommonMark state machine detects a genuinely unclosed fence. // unterminatedComment: strips balanced comments first, then checks for leftover