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).