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