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

@@ -62,6 +62,13 @@ export interface Section {
bodyEnd: number;
}
/** Options shared by `collectSection` and `withSection` (see `collectSection`'s doc comment). */
export interface CollectSectionOptions {
levelBounded?: boolean;
stopAtLevel?: number;
stripFences?: boolean;
}
/** Recognised bullet markers. */
export type BulletMarker = 'dash' | 'checkbox-unchecked' | 'checkbox-checked' | 'numbered';
@@ -340,7 +347,7 @@ export function collectSections(
export function collectSection(
content: string,
headingPredicate: (heading: HeadingToken) => boolean,
opts: { levelBounded?: boolean; stopAtLevel?: number; stripFences?: boolean } = {},
opts: CollectSectionOptions = {},
): Section | null {
if (typeof content !== 'string' || content.length === 0) return null;
@@ -618,5 +625,47 @@ export function replaceSection(content: string, section: Section, newBody: strin
return content.slice(0, section.bodyStart) + newBody + content.slice(section.bodyEnd);
}
// ─── withSection ──────────────────────────────────────────────────────────────
/**
* Locate the section whose heading matches `target`, run `edit` against ONLY
* that section's body, and splice the result back into `content`.
*
* `target` is either an exact (trimmed) heading-text match or a predicate
* function over `HeadingToken`. `edit` receives ONLY the section body — so any
* regex it runs is physically confined to that section — an edit cannot cross
* a section boundary (ADR-2143 §4, structurally retires the #2130/#2067/#2080
* boundary-crossing class, where a hand-rolled regex escaped its intended
* section and mutated a sibling/shipped/backticked-literal occurrence instead).
*
* Bounded no-op behaviour (Phase 3 of ADR-2143 adds fail-loud diagnostics on
* top of this):
* - No heading matches `target` → `content` is returned unchanged.
* - `edit` returns a non-string, or returns the same string it was given →
* `content` is returned unchanged (no-op splice avoided).
*
* `opts` is forwarded verbatim to `collectSection` (see its doc comment for
* `levelBounded` / `stopAtLevel` / `stripFences` semantics) — it lets a caller
* whose heading levels are non-uniform (e.g. a mix of `###`/`####` phase
* headings) choose the correct section-end rule instead of relying on the
* `levelBounded: true` default.
*/
export function withSection(
content: string,
target: string | ((h: HeadingToken) => boolean),
edit: (body: string) => string,
opts: CollectSectionOptions = {},
): string {
if (typeof content !== 'string') return content;
const predicate = typeof target === 'function'
? target
: (h: HeadingToken) => h.text.trim() === target.trim();
const section = collectSection(content, predicate, opts);
if (!section) return content; // bounded no-op on miss (Phase 3 adds fail-loud)
const newBody = edit(section.body);
if (typeof newBody !== 'string' || newBody === section.body) return content;
return replaceSection(content, section, newBody);
}
// Consumers: require('../gsd-core/bin/lib/markdown-sectionizer.cjs')
// Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).

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;
};

View File

@@ -13,6 +13,7 @@
* - ./phase-id.cjs (escapeRegex, phaseMarkdownRegexSource)
* - ./planning-workspace.cjs (planningDir)
* - ./shell-command-projection.cjs (platformReadSync)
* - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection)
*/
import fs from 'node:fs';
@@ -32,7 +33,8 @@ const {
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir } = planningWorkspace;
import { platformReadSync } from './shell-command-projection.cjs';
import { tokenizeHeadings, stripTaggedBlocks } from './markdown-sectionizer.cjs';
import { tokenizeHeadings, stripTaggedBlocks, withSection } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
@@ -200,6 +202,49 @@ function replaceInCurrentMilestone(content: string, pattern: RegExp, replacement
return before + after.replace(pattern, replacement);
}
/**
* Resolve a single phase's detail-section heading (`### Phase N: …`, any level
* 1–6, via the #2121 phase-id source) and run `edit` against ONLY that
* section's body. Delegates to `withSection` (markdown-sectionizer.cjs), so a
* per-phase ROADMAP edit is structurally bounded to that phase's own section —
* it cannot escape into a sibling phase, a shipped-milestone `<details>` block,
* or a backticked prose literal (ADR-2143 §4).
*
* `content` is expected to already be scoped to the current milestone's raw
* range(s) by the caller (see `currentMilestoneRawRanges`) — `withPhaseSection`
* composes with that milestone-level scoping rather than replacing it.
*
* The matched phase number must be delimited by whitespace, a colon, an
* open-paren tag, or end-of-heading — never a bare `\b`. A trailing `\b` sits
* between the last digit and a following `.` or letter, so it would let a
* query for phase `1` prefix-match a decimal sub-phase heading like
* `### Phase 1.1: Sub` or a distinct suffixed phase like `### Phase 1A: …`.
*
* The phase token must additionally anchor to the START of the heading text
* (after an optional leading `[tag]`, mirroring `findRoadmapPhaseInContent`
* below) — never merely appear anywhere in it. Without this anchor, a query
* for phase `1` would match a SIBLING phase whose own TITLE happens to
* mention "Phase 1" (e.g. `### Phase 3: Migrate off Phase 1 legacy pipeline`),
* and — because `collectSection` picks the first matching heading in document
* order — that sibling would be hijacked instead of the real Phase 1 section.
*
* The section body is bounded by `{ levelBounded: false }`: it ends at the
* next ATX heading of ANY level, not merely a heading at or above the phase
* heading's own level. Real ROADMAPs are not guaranteed to use a uniform
* phase-heading level, so a level-bounded stop could fold a deeper sibling
* heading (e.g. a `####` phase following a `###` phase) into this phase's
* body and let `edit` reach into it.
*/
function withPhaseSection(
content: string,
phaseId: unknown,
edit: (body: string) => string,
): string {
const src = phaseMarkdownRegexSource(phaseId);
const headingRe = new RegExp(`^\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${src}(?=[\\s:(]|$)`, 'i');
return withSection(content, (h: HeadingToken) => headingRe.test(h.text), edit, { levelBounded: false });
}
// ─── Roadmap phase lookup ─────────────────────────────────────────────────────
// #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**`
@@ -650,4 +695,5 @@ export = {
getMilestoneInfo,
getMilestonePhaseFilter,
currentMilestoneRawRanges,
withPhaseSection,
};