refactor(#1390): migrate check-command-router + gap-checker onto markdown-sectionizer seam (epic #1372 T3) (#1392)

- check-command-router: stripCommentsAndFences delegates fenced-code
  stripping to seam's stripFencedCode; HTML-comment stripping stays
  caller-side. extractPlanDesignatedSections replaces hand-rolled
  split(/\r?\n/) + /^#{1,6}\s+/ heading walk with collectSections
  driven by DESIGNATED_HEADINGS_RE.

- gap-checker: parseRequirements checkbox-bullet detection migrates
  to iterateBullets (checkbox markers); **ID** extracted caller-side.
  Table-row path and separator-row skip stay caller-side.

- Adds refactor-1390-t3-characterization.test.cjs (43 behavioral
  tests) that were green before and remain green after.

- T1 fail-loud / could-not-parse gate semantics unchanged (verified
  by decisions.test.cjs 59/59 and direct gate invocation).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-17 14:32:43 -04:00
committed by GitHub
parent ef2c0a2b7b
commit 3a1961ebae
3 changed files with 479 additions and 18 deletions

View File

@@ -20,6 +20,7 @@ import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal } = phaseLocatorMod;
import { extractDecisions } from './decisions.cjs';
import type { Decision } from './decisions.cjs';
import { stripFencedCode, collectSections } from './markdown-sectionizer.cjs';
import { checkUiPresence } from './ui-safety-gate.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import verifyModule = require('./verify.cjs');
@@ -134,10 +135,11 @@ const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objec
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
function stripCommentsAndFences(text: string): string {
return text
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/~~~[\s\S]*?~~~/g, ' ');
// HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
const htmlStripped = text.replace(/<!--[\s\S]*?-->/g, ' ');
// Fenced-code stripping: delegate to the canonical CommonMark-correct seam.
// replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`).
return stripFencedCode(htmlStripped).text;
}
function extractYamlBlock(frontmatter: string, key: string): string {
@@ -174,16 +176,18 @@ function extractPlanDesignatedSections(planContent: string | null | undefined):
if (block) parts.push(block);
}
// Replace hand-rolled split(/\r?\n/) + heading walk with the seam's collectSections.
// stopPredicate fires on EVERY heading (collectSections needs to start a section at
// each heading), then we filter to designated ones — same semantics as the prior
// inDesignated flag: emit the heading line + body only when DESIGNATED_HEADINGS_RE matches.
const sections = collectSections(body, () => true);
const bodyParts: string[] = [];
let inDesignated = false;
for (const line of body.split(/\r?\n/)) {
const heading = /^#{1,6}\s+/.test(line);
if (heading) {
inDesignated = DESIGNATED_HEADINGS_RE.test(line);
if (inDesignated) bodyParts.push(line);
continue;
for (const section of sections) {
const headingLine = '#'.repeat(section.heading.level) + ' ' + section.heading.text;
if (DESIGNATED_HEADINGS_RE.test(headingLine)) {
bodyParts.push(headingLine);
if (section.body) bodyParts.push(section.body);
}
if (inDesignated) bodyParts.push(line);
}
parts.push(bodyParts.join('\n'));
parts.push(extractXmlTagBodies(cleaned));

View File

@@ -28,6 +28,7 @@ const { escapeRegex } = phaseId;
import planningWorkspace = require('./planning-workspace.cjs');
const { planningPaths, planningDir, findContextMdIn } = planningWorkspace;
import { parseDecisions, extractDecisions } from './decisions.cjs';
import { iterateBullets } from './markdown-sectionizer.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -81,18 +82,26 @@ function parseRequirements(reqMd: unknown): ReqItem[] {
// Prefix-agnostic ID format: REQ-01, TST-01, BACK-07, INSP-04, etc.
const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+';
const idRe = new RegExp(`^(${ID_PATTERN})$`);
const checkboxRe = new RegExp(`^\\s*-\\s*\\[[x ]\\]\\s*\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`, 'gm');
let cm = checkboxRe.exec(reqMd);
while (cm !== null) {
const id = cm[1];
// Checkbox-bullet path: migrate to seam's iterateBullets (checkbox markers).
// The **ID** is extracted from the bullet text caller-side — the seam provides
// the raw text; we parse the bold-ID prefix from it here.
const boldIdRe = new RegExp(`^\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`);
for (const bullet of iterateBullets(reqMd)) {
if (bullet.marker !== 'checkbox-unchecked' && bullet.marker !== 'checkbox-checked') continue;
const m = boldIdRe.exec(bullet.text);
if (!m) continue;
const id = m[1];
if (!idRe.test(id)) continue;
if (!seen.has(id)) {
seen.add(id);
out.push({ id, text: (cm[2] || '').trim() });
out.push({ id, text: (m[2] || '').trim() });
}
cm = checkboxRe.exec(reqMd);
}
// Pipe-table-row path and separator-row skip stay caller-side
// (table parsing is out of seam scope per ADR-1372 T3 spec).
const tableFirstCellRe = new RegExp(`^\\s*\\|\\s*(${ID_PATTERN})\\s*\\|`);
const separatorRowRe = /^\s*\|[\s:|-]+\|\s*$/;
const lines = reqMd.split(/\r?\n/);