refactor(#1387): migrate adr-parser onto markdown-sectionizer seam (epic #1372 T2) (#1388)

* refactor(#1387): migrate adr-parser onto markdown-sectionizer seam (T2)

Replace hand-rolled parseSections (split/heading-regex walk/body accumulation)
with collectSections(content, () => true) from the canonical seam. Replace
hand-rolled splitEntries bullet-strip regex with iterateBullets from the seam,
preserving the plain-text-line fallback for byte-identical output. Removes the
last inline heading/bullet scanning from adr-parser.cts; normalizeAdrHeader and
all ADR-specific classification logic are unchanged. 218/218 tests pass before
and after.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#1387): keep splitEntries flat (iterateBullets changed its contract); seam adoption stays in parseSections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#1387): drop dead preamble reconstruction; add targeted adr-parser mutation tests

parseSections' preamble reconstruction block (heading: null entry) was dead code:
both consumers (parseAdrMarkdown and parseStatusFromSections) skip heading: null
sections immediately on entry. Confirmed via analytical trace and zero-diff corpus
head-to-head across all 44 docs/adr/*.md files.

Adds 11 targeted behavioral tests to kill cheap surviving mutants:
- pushUnique intra-values dedup (kills seen.add removal mutant)
- body split/join round-trip with multi-line prose and entries
- parseStatusFromSections [0] indexing (only first line determines status)
- classifyHeader equality vs prefix-match boundary (exact match, prefix match,
  synonym+letter non-match)
- goal section prose vs entries distinction (bullet markers preserved in context)
- normalizeAdrHeader non-word char removal (parens and slash behavior)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-17 13:45:05 -04:00
committed by GitHub
parent 917d903330
commit 75c2e259d0
2 changed files with 152 additions and 14 deletions

View File

@@ -10,6 +10,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { requireSafePath } from './security.cjs';
import { collectSections } from './markdown-sectionizer.cjs';
const STATUS_REJECT_SET = new Set(['superseded', 'rejected', 'deprecated']);
@@ -231,23 +232,32 @@ interface MarkdownSection {
body: string[];
}
/**
* Thin adapter: wraps the seam's `collectSections` to produce the same
* `{ heading: string | null, body: string[] }` shape the rest of adr-parser
* consumes. ADR-1372 T2 migration.
*/
function parseSections(markdown: unknown): MarkdownSection[] {
const lines = (typeof markdown === 'string' ? markdown : '').split(/\r?\n/);
const sections: MarkdownSection[] = [];
let current: MarkdownSection = { heading: null, body: [] };
const content = typeof markdown === 'string' ? markdown : '';
for (const line of lines) {
const m = line.match(/^#{1,6}\s+(.*)$/);
if (m) {
if (current.heading || current.body.length) sections.push(current);
current = { heading: m[1].trim(), body: [] };
} else {
current.body.push(line);
}
}
// collectSections(content, () => true) collects every heading as a stop
// boundary — mirrors the old line-by-line heading walk exactly.
const sections = collectSections(content, () => true);
if (current.heading || current.body.length) sections.push(current);
return sections;
// Map seam Section → MarkdownSection. The seam's HeadingToken.text is the
// heading text after trimming (same as the old m[1].trim() capture).
// The body is a trimEnd()-ed joined string; split it back to lines to match
// the old string[] shape consumed by parseStatusFromSections / parseAdrMarkdown.
//
// Note: the old parseSections emitted a leading { heading: null, body: [...] }
// entry for preamble text before the first heading. Both consumers skip it
// immediately (parseAdrMarkdown: `if (!heading) continue`; parseStatusFromSections:
// `classifyHeader(normalizeAdrHeader(null))` → null ≠ 'status' → continue), so
// the preamble entry was dead code and is not reconstructed here.
return sections.map((sec) => ({
heading: sec.heading.text,
body: sec.body === '' ? [] : sec.body.split('\n'),
}));
}
function parseStatusFromSections(sections: MarkdownSection[]): string {