chore(#2143): withSection bounded-mutation seam + phase.cts migration — Phase 2 (#2250)

* chore(#2143): bounded-mutation seam (withSection/withPhaseSection) + phase.cts migration — Phase 2

Phase 2 of epic #2143 (ADR-2143 §4): add a bounded-mutation primitive so a
per-phase ROADMAP edit is structurally confined to that phase's own section,
and migrate the phase-scoped mutation sites in `phase.cts` onto it.

- `src/markdown-sectionizer.cts`: `withSection(content, target, edit, opts?)` —
  resolves a section via `collectSection` and applies `edit` to ONLY that
  section's body, re-serialising via `replaceSection`. The edit callback sees
  only the section body, so any regex it runs is physically confined.
- `src/roadmap-parser.cts`: `withPhaseSection(content, phaseId, edit)` —
  resolves a phase's `### Phase N` detail-section heading via the #2121
  phase-id source and delegates to `withSection`. Heading match is anchored to
  the heading start (a sibling phase whose title mentions the number is not
  hijacked) and bounds at the next ATX heading of any level (`levelBounded:false`).
- `src/phase.cts`: `mutateMilestonePhase`'s plan-count and per-plan-checkbox
  writes now route through `withPhaseSection` — structurally retiring the
  #2130 / #2067 / #2080 boundary-crossing class for these sites. The phase-LIST
  checkbox is intentionally left milestone-slice-scoped (it lives outside any
  `### Phase N` detail section). Cross-phase renumbering is untouched.
- Property test (fast-check): editing phase k leaves every sibling section
  byte-identical; regression tests for title-collision + mixed heading depth.

Behaviour-preserving (verified by old-vs-new differential runs on real
fixtures). Extend-never-mutate (ADR-2143 §2). Registration: CONTEXT.md +
docs/INVENTORY.md export lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#2243): backfill changeset PR number (#2250)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-13 20:39:22 -04:00
committed by GitHub
parent d49ac81306
commit efd04716da
8 changed files with 500 additions and 23 deletions

View File

@@ -32,6 +32,7 @@ const {
getRoadmapPhaseInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
withPhaseSection,
} = roadmapParser;
// ─── helpers ─────────────────────────────────────────────────────────────────
@@ -732,6 +733,157 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => {
});
});
// ─── withPhaseSection (ADR-2143 §4 — bounded mutation) ────────────────────────
describe('roadmap-parser: withPhaseSection', () => {
test('mutating phase k leaves phase j (j≠k) byte-identical', () => {
const content = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'**Goal:** Setup',
'**Plans:** 1 plans',
'',
'### Phase 2: API',
'**Goal:** Build API',
'**Plans:** 1 plans',
'',
'### Phase 3: Polish',
'**Goal:** Harden',
'**Plans:** 1 plans',
'',
].join('\n');
const result = withPhaseSection(content, '2', (body) =>
body.replace(/(\*\*Plans:\*\*\s*)[^\n]+/i, '$11/1 plans complete'),
);
assert.ok(
result.includes('### Phase 2: API\n**Goal:** Build API\n**Plans:** 1/1 plans complete'),
'phase 2 (the target) is updated',
);
assert.ok(
result.includes('### Phase 1: Foundation\n**Goal:** Setup\n**Plans:** 1 plans'),
'phase 1 (j≠k) is byte-identical',
);
assert.ok(
result.includes('### Phase 3: Polish\n**Goal:** Harden\n**Plans:** 1 plans'),
'phase 3 (j≠k) is byte-identical',
);
});
test('a greedy edit callback cannot escape phase N\'s own section', () => {
const content = [
'### Phase 1: Alpha',
'alpha body',
'### Phase 2: Beta',
'beta body',
'### Phase 3: Gamma',
'gamma body',
].join('\n') + '\n';
const result = withPhaseSection(content, '2', (body) => body.replace(/[\s\S]*/, 'REPLACED'));
assert.ok(result.includes('### Phase 1: Alpha\nalpha body'), 'Phase 1 untouched by a greedy regex targeting Phase 2');
assert.ok(result.includes('### Phase 3: Gamma\ngamma body'), 'Phase 3 untouched by a greedy regex targeting Phase 2');
assert.ok(result.includes('### Phase 2: Beta\nREPLACED'), 'Phase 2 was the intended target');
});
test('no matching phase heading -> content unchanged (bounded no-op)', () => {
const content = '### Phase 1: Foundation\n**Plans:** 1 plans\n';
const result = withPhaseSection(content, '99', (body) => body + ' MUTATED');
assert.equal(result, content, 'no Phase 99 heading -> unchanged');
});
test('resolves the phase heading via the #2121 phase-id source (zero-padding tolerant)', () => {
const content = [
'### Phase 02: Padded',
'**Plans:** 1 plans',
'### Phase 3: Next',
'**Plans:** 1 plans',
].join('\n') + '\n';
// Query with the un-padded form ("2") — must still resolve "Phase 02".
const result = withPhaseSection(content, '2', (body) => body.replace('1 plans', '1/1 plans complete'));
assert.ok(result.includes('### Phase 02: Padded\n**Plans:** 1/1 plans complete'), 'un-padded query resolves padded heading');
assert.ok(result.includes('### Phase 3: Next\n**Plans:** 1 plans'), 'Phase 3 untouched');
});
test('a query for phase "1" does not prefix-match a decimal sub-phase heading "Phase 1.1"', () => {
// Sub-phase appears BEFORE the parent phase in the document, so a bare
// `\b`-terminated regex (which would match "1" as a prefix of "1.1")
// could resolve the wrong (first-encountered) section.
const content = [
'### Phase 1.1: Sub',
'sub body',
'### Phase 1: Base',
'base body',
].join('\n') + '\n';
const result = withPhaseSection(content, '1', (body) => body + ' EDITED');
assert.ok(result.includes('### Phase 1.1: Sub\nsub body\n'), 'Phase 1.1 body is byte-identical (untouched)');
assert.ok(!result.includes('sub body EDITED'), 'the edit did not land in Phase 1.1');
assert.ok(result.includes('### Phase 1: Base\nbase body EDITED'), "Phase 1's own body received the edit");
const subResult = withPhaseSection(content, '1.1', (body) => body + ' EDITED');
assert.ok(subResult.includes('### Phase 1.1: Sub\nsub body EDITED'), "Phase 1.1's own body received the edit");
assert.ok(subResult.includes('### Phase 1: Base\nbase body\n'), 'Phase 1 body is byte-identical (untouched)');
});
test('Blocker 1 regression: a query for phase "1" is not hijacked by a sibling phase whose TITLE mentions "Phase 1"', () => {
// Phase 3's own title mentions "Phase 1" ("Migrate off Phase 1 pipeline")
// and appears BEFORE the real Phase 1 heading in document order. Under the
// OLD unanchored regex (`(?:^|\s)Phase\s+1(?=[\s:(]|$)`), that substring
// inside Phase 3's heading text would match first — and because
// `collectSection` picks the FIRST matching heading, `withPhaseSection`
// would edit Phase 3's section instead of Phase 1's.
const content = [
'### Phase 3: Migrate off Phase 1 pipeline',
'**Plans:** 1 plans',
'### Phase 1: Foundation',
'**Plans:** 1 plans',
].join('\n') + '\n';
const result = withPhaseSection(content, '1', (body) =>
body.replace(/(\*\*Plans:\*\*\s*)[^\n]+/, '$1DONE'),
);
assert.ok(
result.includes('### Phase 1: Foundation\n**Plans:** DONE'),
"Phase 1's own Plans line is edited",
);
assert.ok(
result.includes('### Phase 3: Migrate off Phase 1 pipeline\n**Plans:** 1 plans'),
'Phase 3 (title mentions "Phase 1") is byte-identical — not hijacked',
);
});
test('Blocker 2 regression: a following DEEPER heading is not folded into phase 1\'s section body', () => {
// Phase 1 is `###` (level 3); the very next heading, `#### Phase 2: API`
// (level 4), is DEEPER than Phase 1. Under the default `levelBounded: true`
// stop rule, a deeper heading does not terminate the section (it only stops
// at a heading whose level <= the target's own level), so Phase 2's whole
// section — including its `**Plans:**` line — would be folded into Phase
// 1's body and reachable by `edit`.
const content = [
'### Phase 1: Foundation',
'#### Phase 2: API',
'**Plans:** 1 plans',
'### Phase 3: Polish',
'**Plans:** 1 plans',
].join('\n') + '\n';
const phase2Snippet = '#### Phase 2: API\n**Plans:** 1 plans';
assert.ok(content.includes(phase2Snippet), 'sanity: fixture contains the expected Phase 2 snippet');
const result = withPhaseSection(content, '1', (body) => `${body}[EDITED]`);
assert.ok(
result.includes(phase2Snippet),
"Phase 2's heading + Plans line stay contiguous and byte-identical — Phase 1's edit did not reach into it",
);
assert.ok(!result.includes('1 plans[EDITED]'), "the edit did not land inside Phase 2's Plans line");
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2554-decimal-phase-filter.test.cjs — consolidation epic #1969 (B3 #1972)