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

@@ -5275,3 +5275,68 @@ describe('#4040 partial-init completeness fields', () => {
'new-project gate must resume a partial bootstrap instead of erroring');
});
});
// ── #4731 — hard-wrapped Goal/Requirements fields read past the line break ───
// The roadmapper soft-wraps long fields at ~85 chars; the five single-line
// field regexes truncated every wrapped Goal/Requirements at the first line:
// plan-phase's phase_req_ids silently dropped the IDs on continuation lines
// (silently escaping the Requirements Coverage Gate) and get-phase/analyze
// cut the goal mid-sentence.
describe('init plan-phase — wrapped Goal/Requirements fields (#4731)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-4731-');
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
['# Requirements', '', '- [ ] **REQ-01**: thing', '- [ ] **REQ-11**: thing'].join('\n'),
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Phases',
'',
'- [ ] **Phase 1: Demo** - Goal',
'',
'## Phase Details',
'',
'### Phase 1: Demo',
'',
'**Goal:** Deliver a small demo feature that exercises the planning pipeline end to end with a',
'goal sentence long enough to wrap onto a second line',
'**Requirements**: REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09,',
'REQ-10, REQ-11',
'**Plans**: 1 plans',
'',
].join('\n'),
);
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-demo');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '---\nphase: 01-demo\nplan: 01\n---\n# Plan');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '---\nphase: 01-demo\nplan: 01\n---\n# Summary');
});
afterEach(() => cleanup(tmpDir));
test('wrapped Requirements yield all eleven IDs (#4731)', () => {
const result = runGsdTools('init plan-phase 1 --pick phase_req_ids', tmpDir);
assert.equal(
result.output.trim(),
'REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11',
'continuation-line REQ-10/REQ-11 must not be silently dropped',
);
});
test('wrapped Goal is returned in full (#4731)', () => {
const result = runGsdTools('query roadmap.get-phase 1 --pick goal', tmpDir);
assert.equal(
result.output.trim(),
'Deliver a small demo feature that exercises the planning pipeline end to end with a goal sentence long enough to wrap onto a second line',
'the goal must read past the hard wrap',
);
});
});

View File

@@ -12385,6 +12385,140 @@ describe('issue #2334: ghost-REQ-ID classification must probe write surfaces, no
);
});
// ── #4731 review follow-up — the phase-complete citation scan must stop at the ─
// section's own table. The inline lookahead the first cut used stopped only at
// the next `**Bold**` label or end-of-section, so a Requirements field followed
// by a table bled every ID-shaped cell after it into the citation scan — and
// phase complete then ticked other phases' checkboxes and flipped their
// Traceability rows. The shared extractor stops at headings, table rows, and
// blank lines; these pins hold that boundary.
describe('#4731 review follow-up: Requirements citation scan stops at the section table', () => {
function build4731TableBleedFixture() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4731-table-bleed-'));
const planDir = path.join(tmpDir, '.planning');
const phase1Dir = path.join(planDir, 'phases', '01-preset');
const phase2Dir = path.join(planDir, 'phases', '02-next');
fs.mkdirSync(phase1Dir, { recursive: true });
fs.mkdirSync(phase2Dir, { recursive: true });
// REQ-99 belongs to phase 02 — it sits in phase 01's deliverables table and
// is still Pending in REQUIREMENTS.md. Phase 01's own IDs are hard-wrapped
// across three lines (the #4731 shape).
fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [
'# Requirements',
'',
'## Active',
'',
'- [ ] **REQ-11**: first wrapped requirement',
'- [ ] **REQ-12**: second wrapped requirement',
'- [ ] **REQ-13**: third wrapped requirement',
'- [ ] **REQ-99**: phase 02 requirement, still pending',
'',
'## Traceability',
'',
'| Requirement | Phase | Status |',
'|-------------|-------|--------|',
'| REQ-11 | Phase 1 | Pending |',
'| REQ-12 | Phase 1 | Pending |',
'| REQ-13 | Phase 1 | Pending |',
'| REQ-99 | Phase 2 | Pending |',
'',
].join('\n'));
fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [
'# Roadmap',
'',
'- [ ] Phase 01: Preset',
'- [ ] Phase 02: Next',
'',
'### Phase 01: Preset',
'**Goal:** Build preset',
'**Requirements**: REQ-11,',
'REQ-12,',
'REQ-13',
'',
'| Deliverable | Requirement |',
'|-------------|-------------|',
'| Widget | REQ-99 |',
'',
'**Plans:** 1 plans',
'',
'### Phase 02: Next',
'**Goal:** whatever',
'**Requirements:** REQ-99',
'**Plans:** 1 plans',
'',
'## Progress',
'',
'| Phase | Plans Complete | Status | Completed |',
'|-------|----------------|--------|-----------|',
'| 01. Preset | 0/1 | Not started | - |',
'| 02. Next | 0/1 | Not started | - |',
'',
].join('\n'));
fs.writeFileSync(path.join(planDir, 'STATE.md'), [
'---', 'milestone: v1.3', '---',
'# State',
'',
'**Current Phase:** 01',
'**Completed Phases:** 0',
'**Total Phases:** 2',
'**Progress:** 0%',
'',
].join('\n'));
for (const [dir, n] of [[phase1Dir, '01'], [phase2Dir, '02']]) {
fs.writeFileSync(path.join(dir, `${n}-01-PLAN.md`), '# Plan\nDo the work.\n');
fs.writeFileSync(path.join(dir, `${n}-01-SUMMARY.md`), '# Summary\nDone.\n');
}
return tmpDir;
}
test(
'#4731-followup: a wrapped Requirements field followed by a table cites ONLY the wrapped IDs — the table\'s REQ-99 stays Pending',
() => {
const tmpDir = build4731TableBleedFixture();
try {
const { output } = runVerifiedPhaseComplete(['phase', 'complete', '1'], tmpDir);
const parsed = JSON.parse(output);
const warnings = parsed.warnings || [];
const reqContent = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
for (const id of ['REQ-11', 'REQ-12', 'REQ-13']) {
assert.ok(
new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${id}\\*\\*`, 'i').test(reqContent),
`#4731-followup FAILED (fixture invariant): ${id} is cited by the wrapped Requirements field and must be ticked.\n${reqContent}`,
);
}
// The discriminator: REQ-99 appears AFTER the field (in the section's
// deliverables table) — the scan must have stopped at the table.
assert.ok(
/-\s*\[\s\]\s*\*\*REQ-99\*\*/i.test(reqContent),
`#4731-followup FAILED: REQ-99 lives in the table AFTER the Requirements field (it belongs to phase 02) ` +
`and must NOT have been ticked by phase 01's completion scan.\n${reqContent}`,
);
const req99Row = reqContent.split(/\r?\n/)
.filter((l) => l.trim().startsWith('|'))
.map((l) => splitTableRow(l))
.find((cells) => cells[0] && cells[0].trim().toLowerCase() === 'req-99');
assert.ok(
req99Row && /^Pending$/i.test(req99Row[req99Row.length - 1].trim()),
`#4731-followup FAILED: REQ-99's Traceability row must stay Pending — the citation scan bled into the table.\n${reqContent}`,
);
assert.ok(
!warnings.some((w) => /not registered anywhere/i.test(w) && /REQ-99/i.test(w)),
`#4731-followup FAILED: REQ-99 must not surface as a ghost — it is registered; the scan just must not reach it, ` +
`got: ${JSON.stringify(warnings)}`,
);
assert.strictEqual(parsed.requirements_updated, true, "#4731-followup FAILED (fixture invariant): the phase's own IDs must have been written");
} finally {
cleanup(tmpDir);
}
},
);
});
// ─────────────────────────────────────────────────────────────────────────────
// Regressions: issue #3697 — the `**Requirements**:` tokenizer UNDER-selects
// silently. #2339 fixed OVER-selection (the shape filter) and added the