* 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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user