fix(#1103): preserve newline before Plans: header in roadmap.annotate-dependencies (#1111)

The match regex's `(?:^|\n)` anchor consumes a leading newline on mid-string
matches (the common case where a `**Plans:** N plans` summary line precedes a
bare `Plans:` block). The replacement dropped that newline, fusing the summary
line onto the header — e.g. `**Plans:** 3 plansPlans:`. Re-emit the consumed
newline so markdown line boundaries are preserved.

Closes #1103

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-12 15:54:25 -04:00
committed by GitHub
parent ed47ebd971
commit 4beceb02fe
3 changed files with 127 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1111
---
**`roadmap annotate-dependencies` no longer fuses the preceding summary line onto the `Plans:` header** — when the match regex's `(?:^|\n)` anchor consumed a leading newline (mid-string match), the replacement dropped it, producing corrupted output like `**Plans:** 3 plansPlans:`. The replacement now re-emits the leading newline when present. (#1103)

View File

@@ -730,9 +730,12 @@ function cmdRoadmapAnnotateDependencies(cwd: string, phaseNum: string | null | u
}
const newListBlock = annotatedLines.join('\n') + '\n';
// #1103: when `(?:^|\n)` consumed a leading `\n` (mid-string match), re-emit it
// so the line preceding the Plans: header is not fused onto it.
const leadingNewline = plansBlockMatch[0].startsWith('\n') ? '\n' : '';
const newPhaseSection = phaseSection.replace(
plansBlockMatch[0],
plansHeader + newListBlock
leadingNewline + plansHeader + newListBlock
);
const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);

View File

@@ -387,3 +387,121 @@ describe('review fix F4 — adversarial test gaps', () => {
'wave assignment must resolve correctly from planData for bare-bold variant');
});
});
// ---------------------------------------------------------------------------
// Bug #1103 — leading newline dropped in replace, fusing adjacent lines
//
// On the SUCCESSFUL mutation path, the block matcher anchors with `(?:^|\n)`.
// On a mid-string match (a `**Plans:** N plans` bold summary line directly above
// a bare `Plans:` block) `plansBlockMatch[0]` begins with the consumed `\n`. The
// replacement did not re-emit it, fusing the summary line onto the header →
// `**Plans:** 3 plansPlans:`. Distinct from #3691's silent-no-op detection bugs;
// #3691's Bug 1 fix is what let this two-line layout be matched and exposed it.
// ---------------------------------------------------------------------------
describe('bug #1103 — annotate-dependencies preserves newline before Plans: header', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('bold **Plans:** summary line followed by bare Plans: header are not fused', (_t) => {
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Set up project',
'',
'**Plans:** 3 plans',
'Plans:',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'- [ ] 01-03-PLAN.md — Task C',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }),
'.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'annotation must succeed and write back');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Core assertion: the malformed fusion string must NOT appear.
assert.ok(
!written.includes('plansPlans:'),
`ROADMAP must not contain the fused string "plansPlans:"; got:\n${written}`
);
// The bold summary line must keep its line boundary before Plans:.
assert.ok(
written.includes('**Plans:** 3 plans\nPlans:'),
`**Plans:** 3 plans must be followed by a newline before Plans:; got:\n${written}`
);
assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md');
});
test('inline Plans: header (no bold summary prefix) still annotates after fix', (_t) => {
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans: 2 plans',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'inline Plans: header must still be annotated after fix');
});
test('mid-string Plans: header keeps exact heading→header spacing (no extra newline)', (_t) => {
// `phaseSection` always begins with the `### Phase` heading, so the `Plans:`
// match is always mid-string and `(?:^|\n)` matches a `\n` (the start-of-string
// `^` branch is unreachable through the handler). This guards the inverse of the
// bug: the re-emitted newline must restore EXACTLY the one `\n` the regex
// consumed — not a doubled blank line and not a fusion.
const roadmap = [
'### Phase 1: Foundation',
'',
'Plans:',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'Plans: must still be detected and annotated');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(
written.includes('### Phase 1: Foundation\n\nPlans:'),
`heading→Plans spacing must be preserved exactly; got:\n${written}`
);
assert.ok(!written.includes('FoundationPlans:'), 'heading must not fuse onto Plans:');
assert.ok(!written.includes('Foundation\n\n\nPlans:'), 'no doubled blank line before Plans:');
});
});