refactor(#1396): T5 — migrate uat-predicate + uat onto markdown-sectionizer seam (#1397)

- uat-predicate.cts: replace local _stripFencedBlocks (and its private
  FenceState/StripFencedResult types) with a call to stripFencedCode from
  markdown-sectionizer.cjs (ADR-1372 T5). Both stripFalsePositiveContexts
  step (c) and analyzeMarkdown now route through the seam. The three other
  passes in stripFalsePositiveContexts — frontmatter strip, HTML-comment
  strip, blockquote-line filter — remain caller-side (seam does not do these).
  The unterminatedFence signal consumed by analyzeMarkdown is preserved; it
  is now returned by stripFencedCode (same machine, same contract).

- uat.cts: migrate the ## Current Test, ## Tests, and ## Human Verification
  section-collect patterns onto collectSection/tokenizeHeadings from the seam.
  UAT-specific item parsing (### N. Name blocks, expected/result fields,
  categorization logic) stays caller-side. The HTML-comment strip within the
  Current Test body remains caller-side (UAT document structure, not seam scope).

- tests/markdown-sectionizer.test.cjs: remove the 18-case tautological parity
  guard (DEFECT.GENERATIVE-FIX). Once uat-predicate imports the seam the guard
  compares the seam to itself — removing it is the T5 commitment per ADR-1372.

4-space-indent behavior change (CommonMark correctness improvement): the seam
uses /^( {0,3})/ (CommonMark §4.5 ≤3-space indent); the retired
_stripFencedBlocks used /^(\s*)/ (any indent). A 4-space-indented ``` is no
longer treated as a fence opener (it is an indented code block per CommonMark).
Head-to-head over 9 corpus inputs: 0 diffs on all standard cases; 2 diffs only
on the synthetic 4-space-indent edge cases. No UAT fixture or test in the suite
exercises 4-space-indented fences. The change is a correctness improvement.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-17 15:44:11 -04:00
committed by GitHub
parent 6e9f8bf50b
commit 308c7be481
3 changed files with 68 additions and 193 deletions

View File

@@ -16,6 +16,9 @@ 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;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -82,8 +85,8 @@ function stripFalsePositiveContexts(content: string): string {
// Step (b): remove HTML comments anywhere; unterminated comment swallows to EOF
stripped = stripped.replace(/<!--[\s\S]*?(?:-->|$)/g, '');
// Step (c): remove fenced code blocks via CommonMark-style state machine (handles CRLF + indented fences)
stripped = _stripFencedBlocks(stripped).text;
// Step (c): remove fenced code blocks via the canonical seam (ADR-1372 T5)
stripped = stripFencedCode(stripped).text;
// Step (d): remove blockquote lines
stripped = stripped
@@ -94,61 +97,6 @@ function stripFalsePositiveContexts(content: string): string {
return stripped;
}
interface FenceState {
char: '`' | '~';
len: number;
}
interface StripFencedResult {
text: string;
unterminatedFence: boolean;
}
/**
* CommonMark-style fenced-code-block stripper.
* Tracks the opening delimiter char and length so that a ~~~ line inside a
* ``` fence is correctly treated as fence content, not a closing delimiter.
*
* Opening rule: first delimiter line with char+len sets openFence.
* Closing rule: delimiter line with SAME char, run length >= openFence.len,
* and NO trailing non-whitespace text closes the fence.
* All delimiter and content lines are dropped; non-fence lines are kept.
* Returns the kept text plus unterminatedFence:true if EOF inside a fence.
*/
function _stripFencedBlocks(content: string): StripFencedResult {
const lines = content.split('\n');
const kept: string[] = [];
let openFence: FenceState | null = null;
const delimRe = /^(\s*)(`{3,}|~{3,})(.*)$/;
for (const rawLine of lines) {
// Tolerate CRLF: strip trailing \r for matching, but we work on split-by-\n lines
// (the outer caller joined by \n already; we just handle a stray \r in the last char)
const line = rawLine.replace(/\r$/, '');
const m = delimRe.exec(line);
if (m) {
const char = m[2][0] as '`' | '~';
const len = m[2].length;
const trailing = m[3];
if (openFence === null) {
// Opening delimiter — drop this line and record the fence
openFence = { char, len };
} else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
// Closing delimiter (same char, sufficient length, no trailing text) — drop and close
openFence = null;
}
// else: mismatched delimiter inside fence (e.g. ~~~ inside ```) — drop as content
continue; // delimiter lines are always dropped
}
if (openFence === null) {
kept.push(rawLine);
}
// Lines inside fence are dropped
}
return { text: kept.join('\n'), unterminatedFence: openFence !== null };
}
/**
* Analyse raw markdown for structural anomalies (unterminated fence / comment).
* Exported for unit-testability and used by evaluateUatPassed for per-file malformed detection.
@@ -170,8 +118,8 @@ function analyzeMarkdown(raw: string): { unterminatedFence: boolean; unterminate
i = close + 3;
}
// Fence state machine gives the accurate unterminated-fence signal.
const { unterminatedFence } = _stripFencedBlocks(raw);
// Fence state machine gives the accurate unterminated-fence signal (seam, ADR-1372 T5).
const { unterminatedFence } = stripFencedCode(raw);
return { unterminatedFence, unterminatedComment };
}