fix(#2522): fold wrapped success-criteria lines into their criterion (#2637)

* test(#2522): wrapped + blank-line success-criteria parse

* fix(#2522): fold wrapped success-criteria lines into their criterion

* chore(#2522): backfill changeset pr to 2637
This commit is contained in:
Tom Boucher
2026-07-25 00:36:05 -04:00
committed by GitHub
parent 1a9ae7601b
commit 461c744c31
3 changed files with 82 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2637
---
**`roadmap get-phase` no longer drops success criteria that wrap onto a second line** — the parser broke the criteria run at any indented continuation line, truncating the wrapped criterion (losing its trailing `[REQ-ID]` tag) and silently dropping every criterion below it. `verify-work` and `plan-phase` consumed the shortened list, so a phase could be planned and certified complete against a strict subset of its own success criteria with nothing reporting the gap. Continuation lines now fold into their criterion; blank-line-separated criteria still parse. (#2522)

View File

@@ -175,10 +175,17 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s
const modeMatch = section.match(/\*\*Mode(?::\*\*|\*\*:)\s*([^\n]+)/i);
const mode = modeMatch ? modeMatch[1].trim().toLowerCase() : null;
// Extract success criteria as structured array
const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
// Extract success criteria as structured array. A criterion may wrap onto extra
// indented lines (no `N.` prefix); those continuations must fold INTO their
// criterion, not end the run (#2522 — the old `(?:\s*\d+\.\s*[^\n]+)+` broke on a
// wrapped line, truncating it and silently dropping every criterion below it).
// `\n*` before each numbered line keeps blank-line-separated criteria working.
const criteriaMatch = section.match(
/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\n*[ \t]*\d+\.[^\n]*\n?(?:[ \t]+(?!\d+\.)[^\n]*\n?)*)+)/i);
const success_criteria = criteriaMatch
? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
? criteriaMatch[1].trim().split(/\n+(?=[ \t]*\d+\.)/)
.map(entry => entry.replace(/^\s*\d+\.\s*/, '').replace(/\s*\n\s*/g, ' ').trim())
.filter(Boolean)
: [];
return {

View File

@@ -621,6 +621,73 @@ describe('roadmap get-phase success criteria', () => {
assert.ok(output.success_criteria[2].includes('Third criterion'), 'third criterion matches');
});
test('#2522: wrapped success-criteria lines fold into their criterion (not dropped/truncated)', () => {
// A criterion that wraps onto an indented continuation line must fold INTO its
// criterion (retaining its trailing [REQ-ID]), and every criterion below the wrap
// must still be parsed. The old regex broke the run at the wrap, truncating
// criterion 2 mid-sentence (losing [A-02]) and silently dropping criterion 3.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'### Phase 9: T',
'**Goal**: g',
'**Success Criteria** (what must be TRUE):',
' 1. alpha. [A-01]',
' 2. beta which is long and',
' wraps onto a second line. [A-02]',
' 3. gamma. [A-03]',
'**Plans**: TBD',
'',
].join('\n')
);
const result = runGsdTools('roadmap get-phase 9', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase should be found');
assert.strictEqual(output.success_criteria.length, 3, 'all 3 criteria must parse — wrapped continuations must not drop/truncate (#2522)');
assert.ok(output.success_criteria[1].includes('wraps onto a second line'), 'the wrapped continuation must fold into criterion 2');
assert.ok(output.success_criteria[1].includes('[A-02]'), 'the wrapped criterion must retain its trailing [REQ-ID] tag (#2522)');
assert.ok(output.success_criteria[2].includes('[A-03]'), 'criterion 3 (below the wrap) must not be dropped (#2522)');
// Boundary: the parse must not bleed past the Success Criteria block.
assert.ok(!output.success_criteria.some(c => c.includes('TBD')), 'parse must not consume the **Plans** line');
});
test('#2522: blank-line-separated success criteria still parse (the continuation fix must not regress blank lines)', () => {
// Guard against the naive-form trap the issue flags: narrowing the leading
// whitespace to [ \t]* and splitting on \n would trade wrapped-line truncation
// for blank-line truncation. Blank lines between criteria must keep working.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'### Phase 1: T',
'**Goal**: g',
'**Success Criteria** (what must be TRUE):',
' 1. alpha. [A-01]',
'',
' 2. beta. [A-02]',
'',
' 3. gamma. [A-03]',
'**Plans**: TBD',
'',
].join('\n')
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.success_criteria.length, 3, 'blank-line-separated criteria must all parse (#2522 guard)');
assert.ok(output.success_criteria[0].includes('[A-01]'));
assert.ok(output.success_criteria[1].includes('[A-02]'));
assert.ok(output.success_criteria[2].includes('[A-03]'));
});
test('returns empty array when no success criteria present', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),