fix(#4731): read hard-wrapped Goal/Requirements fields past the line break (#4826)

admin_reason: missing-secondary-reviewer — self-authored overnight sweep; isolated adversarial review round completed (MEDIUM table-bleed finding fixed with RED/GREEN evidence) and sha-pinned bench 46331/0 on the merged head.
This commit is contained in:
Tom Boucher
2026-09-17 15:59:38 -04:00
committed by GitHub
parent fb3e228a0d
commit bff99a8bb5
7 changed files with 279 additions and 21 deletions

View File

@@ -141,7 +141,6 @@ const { resolveCapabilityRuntimeState } = capabilityStateMod;
void stripShippedMilestones;
// Accept all bold/colon variants of the Requirements header (#2769)
const REQUIREMENTS_HEADER_RE = /^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m;
// #2056/#2104: isForeignPrefixedPhaseQuery is imported from phase-id.cts
// (the canonical predicate). parsePhasePrefix is no longer needed locally.
@@ -939,9 +938,14 @@ function cmdInitExecutePhase(
has_reviews: false,
};
});
const reqMatch = (roadmapPhase?.['section'] as string | undefined)?.match(REQUIREMENTS_HEADER_RE);
const reqExtracted = reqMatch
? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
// #4731: multiline-aware — the Requirements field may hard-wrap, so the
// value is extracted past the line break before the ID scan.
const phaseSection = roadmapPhase?.['section'] as string | undefined;
const reqLine = phaseSection
? roadmapParser.extractPhaseFieldMultiline(phaseSection, 'Requirements')
: null;
const reqExtracted = reqLine
? reqLine.replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
: null;
const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
@@ -1109,9 +1113,14 @@ function cmdInitPlanPhase(
has_reviews: false,
};
});
const reqMatch = (roadmapPhase?.['section'] as string | undefined)?.match(REQUIREMENTS_HEADER_RE);
const reqExtracted = reqMatch
? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
// #4731: multiline-aware — the Requirements field may hard-wrap, so the
// value is extracted past the line break before the ID scan.
const phaseSection = roadmapPhase?.['section'] as string | undefined;
const reqLine = phaseSection
? roadmapParser.extractPhaseFieldMultiline(phaseSection, 'Requirements')
: null;
const reqExtracted = reqLine
? reqLine.replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
: null;
const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
@@ -2841,8 +2850,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
: content.length;
const section = content.slice(sectionStart, sectionEnd);
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
const goal = roadmapParser.extractPhaseFieldMultiline(section, 'Goal');
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;

View File

@@ -3896,16 +3896,22 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
);
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
const reqMatch = sectionText.match(
/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i,
);
// #4731: multiline-aware — hard-wrapped Requirements read past the
// line break before the ID scan. The shared extractor also stops at
// headings and table rows, so a Requirements field followed by the
// Traceability table cannot bleed other phases' REQ-IDs into the
// citation scan (isolated-review MEDIUM on the inline lookahead,
// whose lazy capture swallowed everything to section end).
const reqLine = sectionText
? roadmapParserMod.extractPhaseFieldMultiline(sectionText, 'Requirements')
: null;
const originalReqContent = fs.readFileSync(reqPath, 'utf-8');
let reqContent = originalReqContent;
// #2316: `citedReqIds` — the REQ-IDs ROADMAP's own **Requirements:**
// line for this phase actually cites — is hoisted out of the
// `if (reqMatch)` block (previously scoped only inside it) so the
// `if (reqLine)` block (previously scoped only inside it) so the
// ghost-ID cross-check below (~#2316-1) can consult it. `TBD` is the
// literal placeholder `phase.add`/`-batch`/`-insert` seed
// (`**Requirements**: TBD`, src/phase.cts:833,920,1078) — never a
@@ -3918,18 +3924,18 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// `else`, discarding this fact silently instead of surfacing it.
const traceabilityWriteMisses: string[] = [];
if (reqMatch) {
if (reqLine) {
// #2334 HIGH 3 + #3697: selection and under-selection detection both
// live in `analyzeRequirementsLine` (module scope, above), extracted in
// round 3 so the parser is directly testable — a closure in here is
// reachable only by spawning the CLI, which no fast-check property test
// can do. `citedReqIds` is byte-identical to the expression that stood
// here; nothing about what phase-complete MARKS has changed.
const reqLineAnalysis = analyzeRequirementsLine(reqMatch[1]);
const reqLineAnalysis = analyzeRequirementsLine(reqLine);
citedReqIds = reqLineAnalysis.citedReqIds;
const reqLineWarning = formatRequirementsLineWarning(
phaseNum,
reqMatch[1],
reqLine,
reqLineAnalysis,
);
if (reqLineWarning) {

View File

@@ -2255,5 +2255,45 @@ export = {
// #3641: the scope axis's phase-ENTRY predicate, exported so roadmap
// validate's V004 document-level check routes through the same single
// owner (and its convention gate) instead of a private inline copy.
extractPhaseFieldMultiline,
hasPhaseEntries,
};
/**
* #4731 — extract a bold-labeled phase field (Goal, Requirements, ...) reading
* past hard-wrapped continuation lines. The roadmapper soft-wraps long fields
* at ~85 chars, so a single-line capture silently truncated every wrapped
* Goal/Requirements. Stops at: another `**Field**` label line, a blank line,
* or any heading. Wrapped lines are trimmed and joined with single spaces; a
* single-line field is returned unchanged (trimmed). Returns null when the
* label is absent.
*/
function extractPhaseFieldMultiline(section: string, label: string): string | null {
// #2769 label shapes: `**X:**`, `**X**:`, and the spaced `**X** :`.
const labelRe = new RegExp(
'\\*\\*' + label + '(?::\\*\\*|\\*\\*\\s*:?)\\s*([^\\n]+)',
'i',
);
const match = section.match(labelRe);
if (!match) return null;
const startIdx = match.index ?? 0;
const after = section.slice(startIdx + match[0].length);
const firstLine = match[1].trim();
const contLines = [];
const lines = after.split('\n');
for (let li = 0; li < lines.length; li++) {
const raw = lines[li];
// The split's first element is the remainder of the captured first line's
// own newline — an empty leading element is the line break, not a blank
// continuation line.
if (li === 0 && !raw.trim()) continue;
if (!raw.trim()) break;
if (/^\s*\*\*[A-Z][A-Za-z ]*:?(\*\*)?:?\s/.test(raw)) break;
if (/^\s*#{1,4}\s/.test(raw)) break;
if (/^\s*\|/.test(raw)) break;
contLines.push(raw.trim());
}
return [firstLine, ...contLines].join(' ').trim() || null;
}

View File

@@ -231,9 +231,9 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s
const section = content.slice(headerIndex, sectionEnd).trim();
// Extract goal if present (supports both **Goal:** and **Goal**: formats)
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
// Extract goal if present (supports both **Goal:** and **Goal**: formats).
// #4731: multiline-aware — hard-wrapped Goals read past the line break.
const goal = roadmapParserModule.extractPhaseFieldMultiline(section, 'Goal');
// Mode: vertical-MVP slice mode flag. Lowercased + trimmed for canonical
// comparison; unrecognized values are preserved verbatim for forward-compat.
@@ -509,8 +509,7 @@ function collectAnalyzePhases(
const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : content.length;
const section = content.slice(sectionStart, sectionEnd);
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
const goal = roadmapParserModule.extractPhaseFieldMultiline(section, 'Goal');
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
const mode = modeMatch ? modeMatch[1].trim().toLowerCase() : null;
@@ -1645,6 +1644,7 @@ function cmdRoadmapAnnotateDependencies(cwd: string, phaseNum: string | null | u
}, raw, updated ? `annotated ${waves.length} wave(s), ${crossCuttingTruths.length} constraint(s)` : 'skipped (already annotated or no plan list)');
}
export = {
cmdRoadmapGetPhase,
getRoadmapPhaseWithFallback,