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

@@ -44,7 +44,7 @@ import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
import roadmapParserMod = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter, currentMilestoneRawRanges } = roadmapParserMod;
const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter, currentMilestoneRawRanges, withPhaseSection } = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -1479,6 +1479,12 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// inline / backticked prose literal cannot match. Milestone-scoped below
// (mutateMilestonePhase) so a Backlog entry or a same-numbered shipped-
// milestone phase cannot be flipped either.
// ADR-2143 §4 note: this is the phase-LIST checkbox — it lives in the
// milestone's `- [ ] Phase N: …` checklist, OUTSIDE any `### Phase N`
// detail section, so there is no section for withPhaseSection to bind
// to. Left as a milestone-slice-scoped regex (not migrated to the
// sectionizer seam); see planCountBodyPattern below for the sites that
// WERE migrated.
const checkboxPattern = new RegExp(
`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`,
'im',
@@ -1518,10 +1524,14 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
roadmapContent = roadmapContent.replace(tableRowPattern, updateProgressRow);
}
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i',
);
// ADR-2143 §4: the plan-count write is now routed through
// withPhaseSection (see mutateMilestonePhase below), which hands this
// pattern ONLY phase N's own detail-section body — so the pattern no
// longer needs its own `#{2,4}\s*Phase\s+N` anchor + skip-ahead-past-
// interior-headings lookahead; the section boundary itself confines
// the match (the #2067/#2200 boundary-crossing class is now
// structurally impossible for this site rather than regex-enforced).
const planCountBodyPattern = /(\*\*Plans:\*\*\s*)[^\n]+/i;
const phaseInfoSummaries = phaseInfo['summaries'] as string[];
@@ -1534,17 +1544,25 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
const mutateMilestonePhase = (slice: string): string => {
let s = slice;
s = s.replace(checkboxPattern, `$1x$2 (completed ${today})`);
s = s.replace(planCountPattern, `$1${summaryCount}/${planCount} plans complete`);
for (const summaryFile of phaseInfoSummaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
'i',
);
s = s.replace(planCheckboxPattern, '$1x$2');
}
// ADR-2143 §4: the plan-count write and the per-plan checkbox flips
// are both scoped to phase N's OWN detail section via
// withPhaseSection — the edit callback below only ever sees that
// section's body, so neither regex can escape into a sibling
// phase's section, a shipped milestone, or a Backlog entry.
s = withPhaseSection(s, phaseNum, (body) => {
let b = body.replace(planCountBodyPattern, `$1${summaryCount}/${planCount} plans complete`);
for (const summaryFile of phaseInfoSummaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
'i',
);
b = b.replace(planCheckboxPattern, '$1x$2');
}
return b;
});
return s;
};