@opengsd/gsd-core 1.19.0 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +177 -93
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +46 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/init.cjs +181 -68
- package/gsd-core/bin/lib/install-engine.cjs +9 -9
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone.cjs +18 -50
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +627 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +3039 -666
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +51 -15
- package/gsd-core/bin/lib/state.cjs +499 -180
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/dist/lib/isolation-sentinel.js +77 -2
- package/hooks/gsd-agent-isolation-guard.js +1 -1
- package/hooks/gsd-cursor-subagent-start.js +1 -1
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/hooks/lib/isolation-sentinel.js +77 -2
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +48 -7
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
|
@@ -42,25 +42,50 @@ const coreUtilsMod = require("./core-utils.cjs");
|
|
|
42
42
|
const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, } = coreUtilsMod;
|
|
43
43
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
44
44
|
const phaseIdMod = require("./phase-id.cjs");
|
|
45
|
-
const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, isSentinelPhaseId, scopeToPhase, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE,
|
|
45
|
+
const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, phaseLookupQuery, phaseNumberForMatch, phaseKeyFromDir, customPhaseDirectoryParts, customDirectoryOwner, phaseScanTokenSource, phaseNumberTokenSource, CUSTOM_PHASE_ID_FILE, isCustomPhaseId, isSentinelPhaseId, scopeToPhase, parsePhaseId, renderPhaseId, renderMilestoneId, toDir, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, buildPhaseDeclarationScans, BRACKET_DIR_PREFIX_SRC, tokenizePhaseDependencyReferences, foldBracketId, } = phaseIdMod;
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
|
+
const phaseIdDisplayMod = require("./phase-id-display.cjs");
|
|
48
|
+
// #4304: reuse the Phase Id Display Module's milestone/phase
|
|
49
|
+
// numeric canonicalization instead of re-deriving it here — see
|
|
50
|
+
// bracketWriteContext/bracketPhaseId below.
|
|
51
|
+
const { milestoneToken, phaseToken } = phaseIdDisplayMod;
|
|
52
|
+
const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
|
|
46
53
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
47
54
|
const gate_exit_cjs_1 = require("./gate-exit.cjs");
|
|
48
55
|
const gate_verdict_cjs_1 = require("./gate-verdict.cjs");
|
|
49
56
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
|
50
57
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
51
|
-
const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
|
|
58
|
+
const { phaseDirectoryOptions, findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs, listAllPhaseDirs, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup, } = phaseLocatorMod;
|
|
52
59
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
|
|
53
60
|
const planningScopeMod = require("./planning-scope.cjs");
|
|
54
61
|
const { SCOPE } = planningScopeMod;
|
|
55
62
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
|
|
56
63
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
57
|
-
const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection,
|
|
64
|
+
const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, editCurrentMilestone, findMilestoneScopeHeadingLines, getMilestoneInfo,
|
|
65
|
+
// #5679: the STATE.md milestone read getMilestoneInfo itself uses, and the
|
|
66
|
+
// archive-aware owner of a ROADMAP offset.
|
|
67
|
+
readStateMilestoneScalar, classifyMilestoneScalar, milestoneVersionAtOffset, blankCodeAndComments, scanMilestonePhaseIds,
|
|
68
|
+
// #4304: every recognized milestone heading in the document,
|
|
69
|
+
// not just the active one — see bracketProgressSectionOwnedByOtherMilestone.
|
|
70
|
+
listMilestoneHeadings, selectMilestoneHeading,
|
|
71
|
+
// #4304: the SAME bracket milestone-boundary grammar the
|
|
72
|
+
// window locator uses (bracketAwareMilestoneSection), reused so the
|
|
73
|
+
// marker set below recognizes a version-less bracket milestone heading
|
|
74
|
+
// too, not only a version-token one.
|
|
75
|
+
isBracketMilestoneBoundary,
|
|
76
|
+
// #4304: the SAME closed/shipped-heading predicate
|
|
77
|
+
// currentMilestoneRawRanges uses, reused so the pre-mutation window guard
|
|
78
|
+
// (bracketOwnedLineOutsideActiveWindow) can recognize an archived section
|
|
79
|
+
// instead of a re-typed copy of MILESTONE_CLOSED_MARKER_PATTERN.
|
|
80
|
+
isClosedMilestoneHeading, isClosedMilestoneDetails, isRecognizedMilestoneHeading, isPhaseEntryHeading, activeScopeStart, } = roadmapParserMod;
|
|
58
81
|
// #4129: the single owner of "count the ROADMAP's milestone Complete rows"
|
|
59
82
|
// (pure computation, no I/O — no cycle on this path) for the intent-first
|
|
60
83
|
// progress counters the phase-complete transaction passes downstream.
|
|
61
84
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
|
|
62
85
|
const phaseLifecycleMod = require("./phase-lifecycle.cjs");
|
|
63
|
-
const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent
|
|
86
|
+
const { deriveProgressFromRoadmap: deriveProgressFromRoadmapForIntent, clampPercent: clampPercentForIntent } = phaseLifecycleMod;
|
|
87
|
+
// #5761: the Progress table locator and milestone-identity helpers live in their one owner.
|
|
88
|
+
const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
|
|
64
89
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
|
65
90
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
66
91
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
@@ -73,12 +98,13 @@ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
|
|
|
73
98
|
const clock_cjs_1 = require("./clock.cjs");
|
|
74
99
|
const state_transition_cjs_1 = require("./state-transition.cjs");
|
|
75
100
|
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
101
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
102
|
+
const security_cjs_1 = require("./security.cjs");
|
|
76
103
|
// #5060: the Phase Status Module owns the ROADMAP Status-cell token vocabulary.
|
|
77
104
|
const phase_status_cjs_1 = require("./phase-status.cjs");
|
|
78
|
-
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
79
105
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap.cjs is an export= CommonJS module
|
|
80
106
|
const roadmapMod = require("./roadmap.cjs");
|
|
81
|
-
const { buildPhaseHeadingRegex } = roadmapMod;
|
|
107
|
+
const { buildPhaseHeadingRegex, findPhaseTableHeader, editProgressTableSlice, foldPhaseId, insideOpenDetails, checklistEntrySrc } = roadmapMod;
|
|
82
108
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
|
|
83
109
|
const uatPredicate = require("./uat-predicate.cjs");
|
|
84
110
|
const { evaluateUatPassed } = uatPredicate;
|
|
@@ -102,6 +128,10 @@ const milestoneLockMod = require("./milestone-lock.cjs");
|
|
|
102
128
|
const planDocumentMod = require("./plan-document.cjs");
|
|
103
129
|
const { parsePlanDocument, planIdFromFile } = planDocumentMod;
|
|
104
130
|
const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, assertVerificationReportsReadable, withStateLock, updatePerformanceMetricsSection, } = stateMod;
|
|
131
|
+
// #4304: matchPhaseDirs admits legacy directories under bracket mode for
|
|
132
|
+
// migration compatibility. Only a directory carrying the owner grammar's
|
|
133
|
+
// bracket prefix may enter bracket-only result parsing.
|
|
134
|
+
const BRACKET_DIRECTORY_PREFIX_RE = new RegExp(`^${BRACKET_DIR_PREFIX_SRC}`);
|
|
105
135
|
// Any .md file with PLAN anywhere in the basename — diagnostic net
|
|
106
136
|
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
|
107
137
|
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
|
@@ -109,43 +139,6 @@ const looksLikePlanFile = (f) => /\.md$/i.test(f) &&
|
|
|
109
139
|
/PLAN/i.test(f) &&
|
|
110
140
|
!PLAN_OUTLINE_RE.test(f) &&
|
|
111
141
|
!PLAN_PRE_BOUNCE_RE.test(f);
|
|
112
|
-
/**
|
|
113
|
-
* Scope an `updateTableCell` call to the `## Traceability` (or
|
|
114
|
-
* `## Traceability Status`) heading's own section — up to the next H1/H2
|
|
115
|
-
* heading — instead of handing it the WHOLE REQUIREMENTS.md content.
|
|
116
|
-
*
|
|
117
|
-
* F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
|
|
118
|
-
* found in whatever text it is given. The shipped requirements template
|
|
119
|
-
* (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
|
|
120
|
-
* (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
|
|
121
|
-
* an unscoped whole-file call targets the Out-of-Scope table instead, fails
|
|
122
|
-
* with `{ok:false, reason:'unknown column: Status'}`, and the real
|
|
123
|
-
* Traceability row is never flipped, while the checkbox surface still flips
|
|
124
|
-
* and the command reports success (the #2140 silent-divergence class one
|
|
125
|
-
* level deeper). Mirrors `editProgressHeadingSlice` below, which scopes
|
|
126
|
-
* `## Progress` writes to that heading's own slice for the same reason.
|
|
127
|
-
*
|
|
128
|
-
* Falls back to running `updateTableCell` against the whole `text` when no
|
|
129
|
-
* `## Traceability` heading exists — matching the previous (unscoped)
|
|
130
|
-
* behaviour for a REQUIREMENTS.md whose traceability table sits under some
|
|
131
|
-
* other heading, or with no heading at all (never worse than before this fix).
|
|
132
|
-
*/
|
|
133
|
-
function updateTraceabilityCell(text, match, column, newValue) {
|
|
134
|
-
const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
|
|
135
|
-
if (!headingMatch || headingMatch.index === undefined) {
|
|
136
|
-
return (0, markdown_table_cjs_1.updateTableCell)(text, match, column, newValue);
|
|
137
|
-
}
|
|
138
|
-
const headingOffset = headingMatch.index;
|
|
139
|
-
const before = text.slice(0, headingOffset);
|
|
140
|
-
const fromHeading = text.slice(headingOffset);
|
|
141
|
-
const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
142
|
-
const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
|
|
143
|
-
const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
|
|
144
|
-
const result = (0, markdown_table_cjs_1.updateTableCell)(scoped, match, column, newValue);
|
|
145
|
-
if (!result.ok)
|
|
146
|
-
return result;
|
|
147
|
-
return { ok: true, value: before + result.value + after };
|
|
148
|
-
}
|
|
149
142
|
/**
|
|
150
143
|
* Extract the MAJOR version segment from a version-ish string: "v1", "v1.3",
|
|
151
144
|
* "V1.0", and "1.0" all yield "1"; "v2" yields "2". Used (#2334 BLOCKER fix)
|
|
@@ -213,11 +206,14 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
213
206
|
if (phase) {
|
|
214
207
|
// LOOKUP (b): search the physical set, plus archived when asked.
|
|
215
208
|
const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
|
|
216
|
-
const
|
|
209
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
210
|
+
const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
|
|
217
211
|
// The pool is #3185's (physical set + archived); the matcher is this
|
|
218
212
|
// PR's. `dirs` is deliberately not read here: on this base it is not
|
|
219
|
-
// assigned until the branch below picks a match.
|
|
220
|
-
|
|
213
|
+
// assigned until the branch below picks a match. Under bracket, the
|
|
214
|
+
// active project+milestone identity wins before migration-window legacy
|
|
215
|
+
// names; differently-qualified historical dirs are never first-picked.
|
|
216
|
+
const { matches } = matchPhaseDirsForLookup(lookupPool, lookup, undefined, directoryContext);
|
|
221
217
|
const match = matches[0];
|
|
222
218
|
if (!match) {
|
|
223
219
|
output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
|
|
@@ -231,7 +227,8 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
231
227
|
const enumerated = listMilestonePhaseDirs(phasesDir, { cwd });
|
|
232
228
|
phaseScope = enumerated.scope;
|
|
233
229
|
dirs = [...enumerated.value, ...archivedLabels];
|
|
234
|
-
|
|
230
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
231
|
+
dirs.sort((a, b) => comparePhaseNum(a, b, directoryContext));
|
|
235
232
|
}
|
|
236
233
|
if (type) {
|
|
237
234
|
const files = [];
|
|
@@ -296,45 +293,70 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
296
293
|
}
|
|
297
294
|
function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|
298
295
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
299
|
-
const
|
|
296
|
+
const lookup = resolvePhaseDirectoryLookup(cwd, basePhase);
|
|
297
|
+
const normalized = lookup.normalized;
|
|
298
|
+
// #4304: base existence and decimal-child inventory must resolve through
|
|
299
|
+
// the same convention. The former was bracket-aware while
|
|
300
|
+
// the latter still called the legacy-only scanner, proposing an occupied
|
|
301
|
+
// bracket sub-phase. Legacy conventions retain their historical call and
|
|
302
|
+
// output spelling byte-for-byte.
|
|
303
|
+
const convention = resolvePhaseIdConvention(cwd) === 'bracket' ? 'bracket' : undefined;
|
|
304
|
+
const bracketContext = convention === 'bracket'
|
|
305
|
+
? bracketWriteContext(cwd, loadConfig(cwd))
|
|
306
|
+
: null;
|
|
300
307
|
try {
|
|
301
308
|
let baseExists = false;
|
|
302
309
|
const decimalSet = new Set();
|
|
310
|
+
const bracketScanMeta = { bracketSpellingFound: false };
|
|
311
|
+
const scanDecimals = (roadmapContent) => {
|
|
312
|
+
const found = bracketContext
|
|
313
|
+
? scanExistingBracketDecimalPhaseNumbers(phasesDir, roadmapContent ? extractCurrentMilestone(roadmapContent, cwd) : '', normalized, bracketContext, bracketScanMeta)
|
|
314
|
+
: scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, normalized);
|
|
315
|
+
for (const n of found)
|
|
316
|
+
decimalSet.add(n);
|
|
317
|
+
};
|
|
303
318
|
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
304
319
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
305
320
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
306
|
-
baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
|
|
321
|
+
baseExists = matchPhaseDirs(dirs, normalized, convention, lookup.bracketContext, phaseDirectoryOptions(cwd)).matches.length > 0;
|
|
307
322
|
}
|
|
308
323
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
309
324
|
if (node_fs_1.default.existsSync(roadmapPath)) {
|
|
310
325
|
try {
|
|
311
326
|
const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
312
|
-
|
|
313
|
-
decimalSet.add(n);
|
|
314
|
-
}
|
|
327
|
+
scanDecimals(roadmapContent);
|
|
315
328
|
}
|
|
316
329
|
catch {
|
|
317
330
|
// ROADMAP.md read failure is non-fatal — fall back to the directory-only
|
|
318
331
|
// scan (empty rawContent) so on-disk decimal directories are still counted.
|
|
319
|
-
|
|
320
|
-
decimalSet.add(n);
|
|
321
|
-
}
|
|
332
|
+
scanDecimals('');
|
|
322
333
|
}
|
|
323
334
|
}
|
|
324
335
|
else {
|
|
325
|
-
|
|
326
|
-
decimalSet.add(n);
|
|
327
|
-
}
|
|
336
|
+
scanDecimals('');
|
|
328
337
|
}
|
|
338
|
+
// add-backlog still asks for parent 999 and writes legacy CK-999.x
|
|
339
|
+
// directories. Preserve that upstream output only when the sentinel
|
|
340
|
+
// parent is legacy-only; a real bracket-spelled parent keeps canonical
|
|
341
|
+
// two-digit sub-tokens like every other bracket parent.
|
|
342
|
+
const renderCanonicalBracket = bracketContext !== null
|
|
343
|
+
&& !(Number(normalized) === 999 && !bracketScanMeta.bracketSpellingFound);
|
|
329
344
|
const existingDecimals = Array.from(decimalSet)
|
|
330
345
|
.sort((a, b) => a - b)
|
|
331
|
-
.map((n) =>
|
|
346
|
+
.map((n) => renderCanonicalBracket
|
|
347
|
+
? bracketArtifactToken(bracketPhaseId(bracketContext, normalized, n))
|
|
348
|
+
: `${normalized}.${n}`);
|
|
332
349
|
let nextDecimal;
|
|
333
350
|
if (decimalSet.size === 0) {
|
|
334
|
-
nextDecimal =
|
|
351
|
+
nextDecimal = renderCanonicalBracket
|
|
352
|
+
? bracketArtifactToken(bracketPhaseId(bracketContext, normalized, 1))
|
|
353
|
+
: `${normalized}.1`;
|
|
335
354
|
}
|
|
336
355
|
else {
|
|
337
|
-
|
|
356
|
+
const next = maxPhaseNum(decimalSet) + 1;
|
|
357
|
+
nextDecimal = renderCanonicalBracket
|
|
358
|
+
? bracketArtifactToken(bracketPhaseId(bracketContext, normalized, next))
|
|
359
|
+
: `${normalized}.${next}`;
|
|
338
360
|
}
|
|
339
361
|
output({
|
|
340
362
|
found: baseExists,
|
|
@@ -355,6 +377,10 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
|
|
|
355
377
|
const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
356
378
|
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
|
|
357
379
|
const fullContent = stripShippedMilestones(rawContent);
|
|
380
|
+
const convention = resolvePhaseIdConvention(cwd);
|
|
381
|
+
// LABEL_ONLY preserves the legacy `Phase N:` reader while adding the
|
|
382
|
+
// opted-in bracket heading alternative for both the target and its boundary.
|
|
383
|
+
const headingIntro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention);
|
|
358
384
|
const escapedPhase = phaseMarkdownRegexSource(phaseNum);
|
|
359
385
|
// #5007 (Phase 6 / ADR-4910 §8): buildPhaseHeadingRegex (src/roadmap.cts) is
|
|
360
386
|
// anchored `^...$` with no 'm' flag — it is designed to test ONE
|
|
@@ -363,7 +389,7 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
|
|
|
363
389
|
// through tokenizeHeadings first is therefore required here, not a pure
|
|
364
390
|
// regex-literal swap (the design doc's "direct swap" plan does not hold
|
|
365
391
|
// once the anchor is accounted for).
|
|
366
|
-
const phaseHeader = buildPhaseHeadingRegex(escapedPhase);
|
|
392
|
+
const phaseHeader = buildPhaseHeadingRegex(escapedPhase, convention);
|
|
367
393
|
for (const content of [milestoneContent, fullContent]) {
|
|
368
394
|
const heading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).find((h) => phaseHeader.test(h.text));
|
|
369
395
|
if (!heading)
|
|
@@ -372,7 +398,7 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
|
|
|
372
398
|
const headerMatchLength = (headingLineEnd === -1 ? content.length : headingLineEnd) - heading.offset;
|
|
373
399
|
const sectionStart = heading.offset;
|
|
374
400
|
const rest = content.slice(sectionStart);
|
|
375
|
-
const nextHeader = rest.slice(headerMatchLength).match(
|
|
401
|
+
const nextHeader = rest.slice(headerMatchLength).match(new RegExp(`\\n#{2,4}\\s+${headingIntro}\\S`, 'i'));
|
|
376
402
|
const sectionEnd = nextHeader
|
|
377
403
|
? sectionStart + headerMatchLength + nextHeader.index
|
|
378
404
|
: content.length;
|
|
@@ -470,7 +496,12 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
470
496
|
error('phase identifier required');
|
|
471
497
|
}
|
|
472
498
|
const planBase = planningDir(cwd);
|
|
473
|
-
const
|
|
499
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
500
|
+
const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
|
|
501
|
+
const { normalized, convention } = lookup;
|
|
502
|
+
// #5676: `lookup.convention` is only ever 'bracket'; the directory token grammar
|
|
503
|
+
// (`03-02-beta` -> `03-02` + `beta`) needs the repo's own convention.
|
|
504
|
+
const tokenConvention = resolvePhaseIdConvention(cwd);
|
|
474
505
|
const notFound = {
|
|
475
506
|
found: false,
|
|
476
507
|
directory: null,
|
|
@@ -506,19 +537,21 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
506
537
|
}
|
|
507
538
|
notFound.searched_directories = searchDirs.map((searchDir) => toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planBase), node_path_1.default.relative(planBase, searchDir))));
|
|
508
539
|
for (const searchDir of searchDirs) {
|
|
540
|
+
const searchContext = phaseLocatorMod.phaseDirectoryContextForRoot(directoryContext, searchDir);
|
|
541
|
+
const custom = phaseIdMod.customPhaseQuery(phase, searchContext);
|
|
509
542
|
try {
|
|
510
543
|
const entries = node_fs_1.default.readdirSync(searchDir, { withFileTypes: true });
|
|
511
544
|
const dirs = entries
|
|
512
545
|
.filter((e) => e.isDirectory())
|
|
513
546
|
.map((e) => e.name)
|
|
514
|
-
.sort((a, b) => comparePhaseNum(a, b));
|
|
547
|
+
.sort((a, b) => comparePhaseNum(a, b, searchContext));
|
|
515
548
|
// #2237: fail loud when multiple directories match the same bare phase
|
|
516
549
|
// number — prevents cross-project file writes when unrelated projects
|
|
517
550
|
// share a .planning/phases/ tree.
|
|
518
551
|
// #2528: selection delegates to the canonical two-pass matcher (exact
|
|
519
552
|
// token match, then the bare-integer leading-digit-run fallback) shared
|
|
520
553
|
// with the locator and the phase-plan-index scan.
|
|
521
|
-
const { matches } =
|
|
554
|
+
const { matches, usedBareFallback } = matchPhaseDirsForLookup(dirs, lookup, searchDir === flatPhasesDir ? undefined : null, searchContext);
|
|
522
555
|
if (matches.length === 0)
|
|
523
556
|
continue;
|
|
524
557
|
if (matches.length > 1) {
|
|
@@ -530,9 +563,21 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
530
563
|
return;
|
|
531
564
|
}
|
|
532
565
|
const match = matches[0];
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
const
|
|
566
|
+
const isBracketDirectory = convention === 'bracket'
|
|
567
|
+
&& BRACKET_DIRECTORY_PREFIX_RE.test(foldBracketId(match));
|
|
568
|
+
const dirMatch = isBracketDirectory
|
|
569
|
+
? null
|
|
570
|
+
: match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${phaseNumberTokenSource(tokenConvention)})-?(.*)`, 'i')) || match.match(new RegExp(`^(${phaseNumberTokenSource(tokenConvention)})-?(.*)`, 'i'));
|
|
571
|
+
const customParts = custom ? customPhaseDirectoryParts(match, custom) : null;
|
|
572
|
+
const phaseNumber = isBracketDirectory
|
|
573
|
+
? phaseNumberForMatch(match, usedBareFallback, convention)
|
|
574
|
+
: customParts?.id || (dirMatch ? dirMatch[1] : normalized);
|
|
575
|
+
let phaseName = customParts?.slug || (dirMatch && dirMatch[2] ? dirMatch[2] : null);
|
|
576
|
+
if (isBracketDirectory) {
|
|
577
|
+
const id = parsePhaseId(match);
|
|
578
|
+
const idToken = `${id.phase}${id.subphase ? `.${id.subphase}` : ''}`;
|
|
579
|
+
phaseName = match.slice(`${id.project}.${id.milestone}-${idToken}`.length).replace(/^-/, '') || null;
|
|
580
|
+
}
|
|
536
581
|
const phaseDir = node_path_1.default.join(searchDir, match);
|
|
537
582
|
const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
|
|
538
583
|
// #3183: canonical, live (superseded-excluded) plan/summary sets
|
|
@@ -738,7 +783,9 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
738
783
|
error('phase required for phase-plan-index');
|
|
739
784
|
}
|
|
740
785
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
741
|
-
const
|
|
786
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
787
|
+
const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
|
|
788
|
+
const { normalized } = lookup;
|
|
742
789
|
let phaseDir = null;
|
|
743
790
|
let phaseDirName = null;
|
|
744
791
|
let ambiguousMatches = null;
|
|
@@ -747,12 +794,12 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
747
794
|
const dirs = entries
|
|
748
795
|
.filter((e) => e.isDirectory())
|
|
749
796
|
.map((e) => e.name)
|
|
750
|
-
.sort((a, b) => comparePhaseNum(a, b));
|
|
797
|
+
.sort((a, b) => comparePhaseNum(a, b, directoryContext));
|
|
751
798
|
// #2528: selection delegates to the canonical two-pass matcher shared with
|
|
752
799
|
// the locator and the find-phase scan (this site previously first-matched
|
|
753
800
|
// with `.find()` and had no multi-match guard — the #2237 fail-loud rule
|
|
754
801
|
// now applies here too, so the three resolution paths cannot disagree).
|
|
755
|
-
const { matches } =
|
|
802
|
+
const { matches } = matchPhaseDirsForLookup(dirs, lookup, undefined, directoryContext);
|
|
756
803
|
if (matches.length > 1) {
|
|
757
804
|
ambiguousMatches = matches;
|
|
758
805
|
}
|
|
@@ -1073,37 +1120,58 @@ function describeGoalShapedTitle(description) {
|
|
|
1073
1120
|
* and sibling-worktree phases so a new number cannot collide, but a `Depends on:` line has to point
|
|
1074
1121
|
* at something a reader of THIS roadmap can find (#5113).
|
|
1075
1122
|
*/
|
|
1076
|
-
function roadmapDeclaredPhaseNums(rawContent) {
|
|
1077
|
-
const { regex: headerPattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
|
|
1078
|
-
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
|
1123
|
+
function roadmapDeclaredPhaseNums(rawContent, withBullets = true) {
|
|
1079
1124
|
const nums = new Set();
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
if (!isSentinelPhaseId(num))
|
|
1125
|
+
for (const { regex, numGroup } of buildPhaseDeclarationScans(null, withBullets)) {
|
|
1126
|
+
let m;
|
|
1127
|
+
while ((m = regex.exec(rawContent)) !== null) {
|
|
1128
|
+
const num = parseInt(m[numGroup], 10);
|
|
1129
|
+
if (!Number.isSafeInteger(num))
|
|
1130
|
+
continue; // a 24-digit run parses to 1e24; max+1 of it is not a phase number
|
|
1131
|
+
if (isSentinelPhaseId(num))
|
|
1132
|
+
continue;
|
|
1089
1133
|
nums.add(num);
|
|
1134
|
+
}
|
|
1090
1135
|
}
|
|
1091
1136
|
return [...nums];
|
|
1092
1137
|
}
|
|
1138
|
+
/** The phase number an on-disk `[PROJECT-]NN-slug` directory holds, or null for a sentinel or a non-phase name. */
|
|
1139
|
+
function dirPhaseNum(entry) {
|
|
1140
|
+
const match = entry.match(/^(?:[A-Z][A-Z0-9]*-)?(\d+)-/);
|
|
1141
|
+
if (!match)
|
|
1142
|
+
return null;
|
|
1143
|
+
const num = parseInt(match[1], 10);
|
|
1144
|
+
return !Number.isSafeInteger(num) || isSentinelPhaseId(num) ? null : num;
|
|
1145
|
+
}
|
|
1146
|
+
/** Add the phase number of every phase directory under `phasesDir` to `used` (a missing dir adds nothing). */
|
|
1147
|
+
function collectPhaseDirNums(phasesDir, used, bracketContext) {
|
|
1148
|
+
if (!node_fs_1.default.existsSync(phasesDir))
|
|
1149
|
+
return;
|
|
1150
|
+
for (const entry of node_fs_1.default.readdirSync(phasesDir)) {
|
|
1151
|
+
// A bracket writer reserves the directories its reader resolves (#4304), not the legacy reading.
|
|
1152
|
+
const num = bracketContext ? readerResolvableBracketDirectoryPhaseNumber(entry, bracketContext) : dirPhaseNum(entry);
|
|
1153
|
+
if (num !== null)
|
|
1154
|
+
used.add(num);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1093
1157
|
/**
|
|
1094
1158
|
* The `**Depends on:**` line for a freshly added numeric phase: the nearest lower phase the roadmap
|
|
1095
1159
|
* declares, or `Nothing` when it declares none. It was `newId - 1` regardless, which named a phase
|
|
1096
1160
|
* that exists only on a sibling branch, as an orphan directory, or not at all when numbering has a
|
|
1097
1161
|
* gap (#5113). Custom-named phases carry no dependency line.
|
|
1098
1162
|
*/
|
|
1099
|
-
function dependsOnLine(newPhaseId, declared) {
|
|
1163
|
+
function dependsOnLine(newPhaseId, declared, renderDependency = (phase) => `Phase ${phase}`) {
|
|
1100
1164
|
if (typeof newPhaseId !== 'number')
|
|
1101
1165
|
return '';
|
|
1102
1166
|
let nearest = null;
|
|
1103
1167
|
for (const n of declared)
|
|
1104
1168
|
if (n < newPhaseId && (nearest === null || n > nearest))
|
|
1105
1169
|
nearest = n;
|
|
1106
|
-
return `\n**Depends on:** ${nearest === null ? 'Nothing' :
|
|
1170
|
+
return `\n**Depends on:** ${nearest === null ? 'Nothing' : renderDependency(nearest)}`;
|
|
1171
|
+
}
|
|
1172
|
+
/** `dependsOnLine` over the roadmap's declared phases: the whole roadmap, as it was before #5528. */
|
|
1173
|
+
function dependsOnFor(newPhaseId, rawContent) {
|
|
1174
|
+
return dependsOnLine(newPhaseId, roadmapDeclaredPhaseNums(rawContent));
|
|
1107
1175
|
}
|
|
1108
1176
|
/**
|
|
1109
1177
|
* Splice a phase entry into `rawContent` at `at`, changing nothing else: the blank lines around the
|
|
@@ -1123,6 +1191,74 @@ function spliceRoadmapBlock(rawContent, atOffset, block) {
|
|
|
1123
1191
|
const tail = after === '' || /^\r?\n/.test(after) ? '' : eol;
|
|
1124
1192
|
return before + lead + body + tail + after;
|
|
1125
1193
|
}
|
|
1194
|
+
/** Segment-wise numeric order of two folded phase ids; a non-numeric segment never orders after. */
|
|
1195
|
+
function comparePhaseIds(a, b) {
|
|
1196
|
+
const x = a.split('.').map(Number);
|
|
1197
|
+
const y = b.split('.').map(Number);
|
|
1198
|
+
for (let n = 0; n < Math.max(x.length, y.length); n++) {
|
|
1199
|
+
const d = (x[n] ?? -1) - (y[n] ?? -1);
|
|
1200
|
+
if (Number.isNaN(d))
|
|
1201
|
+
return -1;
|
|
1202
|
+
if (d)
|
|
1203
|
+
return d;
|
|
1204
|
+
}
|
|
1205
|
+
return 0;
|
|
1206
|
+
}
|
|
1207
|
+
const spliceAfter = (raw, at, eol, line) => `${raw.slice(0, at)}${eol}${line}${raw.slice(at)}`;
|
|
1208
|
+
/**
|
|
1209
|
+
* #5221: give a tool-created phase a ROADMAP record, so ship's fail-closed gate can read it.
|
|
1210
|
+
* Writes `- [ ] **Phase N: desc**` after the active milestone's checklist (for an insert: after
|
|
1211
|
+
* phase `after`'s own entries, else before the first later entry); with no checklist, a `N. desc | Not started` progress-table row;
|
|
1212
|
+
* with neither, nothing. Entries inside an open <details> (shipped blocks) are not the checklist.
|
|
1213
|
+
*/
|
|
1214
|
+
function recordNewPhase(raw, cwd, id, description, after, label) {
|
|
1215
|
+
const start = activeScopeStart(raw, cwd);
|
|
1216
|
+
// End at the active milestone's section so a later planned milestone's checklist is never the target.
|
|
1217
|
+
const end = currentMilestoneRawRanges(raw, cwd)?.primary.end;
|
|
1218
|
+
const scope = raw.slice(start, end !== undefined && end > start ? end : undefined);
|
|
1219
|
+
let custom = false;
|
|
1220
|
+
try {
|
|
1221
|
+
custom = loadConfig(cwd).phase_naming === 'custom';
|
|
1222
|
+
}
|
|
1223
|
+
catch { /* unreadable config: numeric ids only */ }
|
|
1224
|
+
const entrySrc = checklistEntrySrc(resolvePhaseIdConvention(cwd), false, custom);
|
|
1225
|
+
const entryRe = new RegExp(`^[ \\t]*${entrySrc}[^\\r\\n]*`, 'gim');
|
|
1226
|
+
const bullets = [...scope.matchAll(entryRe)].filter((m) => !insideOpenDetails(scope, m.index));
|
|
1227
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
1228
|
+
// A bracket writer records its entry in the bracket spelling it used for the heading (`[CK.02] 02`).
|
|
1229
|
+
const entry = `- [ ] **${label ?? `Phase ${id}`}: ${description}**`;
|
|
1230
|
+
// An id past CUSTOM_PHASE_ID_SRC's cap is written but not readable by analyze: report that, not 'checklist'.
|
|
1231
|
+
const kind = new RegExp(`^${entrySrc}`).test(entry) ? 'checklist' : 'none';
|
|
1232
|
+
if (after !== undefined) {
|
|
1233
|
+
const anchor = foldPhaseId(after);
|
|
1234
|
+
const own = bullets.filter((m) => foldPhaseId(m[1]) === anchor || foldPhaseId(m[1]).startsWith(`${anchor}.`)).at(-1);
|
|
1235
|
+
// Anchor phase has no entry: go before the first numerically later entry, so the checklist stays ordered.
|
|
1236
|
+
const later = own ? undefined : bullets.find((m) => comparePhaseIds(foldPhaseId(m[1]), anchor) > 0);
|
|
1237
|
+
if (later) {
|
|
1238
|
+
const at = start + later.index;
|
|
1239
|
+
return { content: `${raw.slice(0, at)}${entry}${eol}${raw.slice(at)}`, entry: kind };
|
|
1240
|
+
}
|
|
1241
|
+
if (own)
|
|
1242
|
+
return { content: spliceAfter(raw, start + own.index + own[0].length, eol, entry), entry: kind };
|
|
1243
|
+
}
|
|
1244
|
+
const last = bullets.at(-1);
|
|
1245
|
+
if (last)
|
|
1246
|
+
return { content: spliceAfter(raw, start + last.index + last[0].length, eol, entry), entry: kind };
|
|
1247
|
+
let wrote = false;
|
|
1248
|
+
const archived = roadmapParserMod.maskArchivedDetails(raw, cwd);
|
|
1249
|
+
const edited = editProgressTableSlice(archived.visible, (scoped) => {
|
|
1250
|
+
if (findPhaseTableHeader(scoped) === -1)
|
|
1251
|
+
return scoped;
|
|
1252
|
+
const cell = { Phase: `${id}. ${description}`, 'Plans Complete': '0/0', Status: 'Not started' };
|
|
1253
|
+
const r = (0, markdown_table_cjs_1.insertTableRow)(scoped, (col) => cell[col]);
|
|
1254
|
+
if (!r.ok)
|
|
1255
|
+
return scoped;
|
|
1256
|
+
wrote = true;
|
|
1257
|
+
return r.value;
|
|
1258
|
+
}, archived.activeRange);
|
|
1259
|
+
const content = wrote && !archived.ambiguous ? archived.restore(edited) : null;
|
|
1260
|
+
return content !== null ? { content, entry: 'progress_table' } : { content: raw, entry: 'none' };
|
|
1261
|
+
}
|
|
1126
1262
|
/**
|
|
1127
1263
|
* #3163: compute the byte offset in `rawContent` where a new `### Phase N:`
|
|
1128
1264
|
* entry should be inserted — at the end of the active phase list, scoped to the
|
|
@@ -1132,9 +1268,17 @@ function spliceRoadmapBlock(rawContent, atOffset, block) {
|
|
|
1132
1268
|
* STATE.md `milestone:` and no in-progress `🚧`/`🔄` marker), fall back to the
|
|
1133
1269
|
* legacy whole-file lastIndexOf('\n---') so simple no-milestone roadmaps keep
|
|
1134
1270
|
* their existing behavior.
|
|
1271
|
+
*
|
|
1272
|
+
* #4304: `phaseIdConvention` is threaded through to
|
|
1273
|
+
* `currentMilestoneRawRanges` so a version-less bracket milestone heading
|
|
1274
|
+
* (`## [CK.02] Current`) is still offset-scoped — omitting it here silently
|
|
1275
|
+
* degraded to the legacy-only search, which finds nothing for that heading
|
|
1276
|
+
* shape and falls back to whole-document insertion, landing `phase add` /
|
|
1277
|
+
* `add-batch` outside the active milestone. Optional and additive: every
|
|
1278
|
+
* pre-existing non-bracket caller passes nothing and compiles byte-identically.
|
|
1135
1279
|
*/
|
|
1136
|
-
function phaseEntryInsertOffset(rawContent, cwd) {
|
|
1137
|
-
const ranges = currentMilestoneRawRanges(rawContent, cwd);
|
|
1280
|
+
function phaseEntryInsertOffset(rawContent, cwd, phaseIdConvention) {
|
|
1281
|
+
const ranges = currentMilestoneRawRanges(rawContent, cwd, phaseIdConvention);
|
|
1138
1282
|
if (!ranges) {
|
|
1139
1283
|
const legacy = rawContent.lastIndexOf('\n---');
|
|
1140
1284
|
return legacy > 0 ? legacy : rawContent.length;
|
|
@@ -1186,14 +1330,142 @@ function assertDescriptionPreservesMilestoneScope(cwd, description, command) {
|
|
|
1186
1330
|
`Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
|
|
1187
1331
|
markerList + `.`);
|
|
1188
1332
|
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Resolve the write identity shared by add/insert/remove. Convention selection
|
|
1335
|
+
* happens at the caller and is the sole branch gate; project/milestone checks
|
|
1336
|
+
* here validate the identity after that branch has already been selected.
|
|
1337
|
+
*/
|
|
1338
|
+
function bracketWriteContext(cwd, config) {
|
|
1339
|
+
const project = typeof config['project_code'] === 'string' ? config['project_code'].trim() : '';
|
|
1340
|
+
if (!project) {
|
|
1341
|
+
error('phase_id_convention is "bracket" but project_code is missing in .planning/config.json');
|
|
1342
|
+
}
|
|
1343
|
+
const milestoneInfo = getMilestoneInfo(cwd);
|
|
1344
|
+
const version = milestoneInfo.value?.version ?? '';
|
|
1345
|
+
const milestone = milestoneToken(version);
|
|
1346
|
+
if (milestone === null) {
|
|
1347
|
+
error('phase_id_convention is "bracket" but the active milestone cannot be resolved');
|
|
1348
|
+
}
|
|
1349
|
+
return { project, milestone: milestone };
|
|
1350
|
+
}
|
|
1351
|
+
function bracketPhaseId(context, phase, subphase) {
|
|
1352
|
+
const phaseTok = phaseToken(phase);
|
|
1353
|
+
if (phaseTok === null) {
|
|
1354
|
+
error(`phase ${String(phase)} cannot be rendered by the bracket convention`);
|
|
1355
|
+
}
|
|
1356
|
+
const id = {
|
|
1357
|
+
project: context.project,
|
|
1358
|
+
milestone: context.milestone,
|
|
1359
|
+
phase: phaseTok,
|
|
1360
|
+
};
|
|
1361
|
+
if (subphase !== undefined) {
|
|
1362
|
+
const subphaseTok = phaseToken(subphase);
|
|
1363
|
+
if (subphaseTok === null) {
|
|
1364
|
+
error(`phase ${String(phase)} subphase ${JSON.stringify(subphase)} cannot be rendered by the bracket convention`);
|
|
1365
|
+
}
|
|
1366
|
+
id.subphase = subphaseTok;
|
|
1367
|
+
}
|
|
1368
|
+
return id;
|
|
1369
|
+
}
|
|
1370
|
+
/** The owner-rendered bare token used by plan/summary artifact filenames. */
|
|
1371
|
+
function bracketArtifactToken(id) {
|
|
1372
|
+
return renderPhaseId(id).split('] ')[1];
|
|
1373
|
+
}
|
|
1374
|
+
/** The directory-name prefix `toDir` emits for this identity, ahead of the slug. */
|
|
1375
|
+
function bracketDirPrefix(id) {
|
|
1376
|
+
const probeSlug = 'phase-slug-probe';
|
|
1377
|
+
return toDir(id, probeSlug).slice(0, -probeSlug.length);
|
|
1378
|
+
}
|
|
1379
|
+
function bracketDirSlug(dirName, id) {
|
|
1380
|
+
const prefix = bracketDirPrefix(id);
|
|
1381
|
+
return dirName.startsWith(prefix) ? dirName.slice(prefix.length) : null;
|
|
1382
|
+
}
|
|
1383
|
+
function bracketIdsInContext(phasesDir, context) {
|
|
1384
|
+
const found = [];
|
|
1385
|
+
for (const dir of readSubdirectories(phasesDir, true)) {
|
|
1386
|
+
try {
|
|
1387
|
+
const id = parsePhaseId(dir);
|
|
1388
|
+
if (id.project !== context.project || id.milestone !== context.milestone)
|
|
1389
|
+
continue;
|
|
1390
|
+
const slug = bracketDirSlug(dir, id);
|
|
1391
|
+
if (slug)
|
|
1392
|
+
found.push({ dir, id, slug });
|
|
1393
|
+
}
|
|
1394
|
+
catch {
|
|
1395
|
+
// A bracket writer shares the directory with migration-window legacy
|
|
1396
|
+
// names. Names outside the canonical bracket grammar do not allocate an
|
|
1397
|
+
// identity in this branch.
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
return found;
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Return the top-level phase number when the active bracket reader resolves
|
|
1404
|
+
* this one directory. The reader prefers an exact project+milestone bracket
|
|
1405
|
+
* identity, falls back to migration-window legacy spellings, and excludes a
|
|
1406
|
+
* qualified directory owned by another milestone. Allocation must reserve
|
|
1407
|
+
* that same universe or a newly emitted bracket directory can shadow work the
|
|
1408
|
+
* reader resolved immediately before the write.
|
|
1409
|
+
*/
|
|
1410
|
+
function readerResolvableBracketDirectoryPhaseNumber(dir, context) {
|
|
1411
|
+
const phaseKey = phaseKeyFromDir(dir, 'bracket');
|
|
1412
|
+
if (!/^\d+$/.test(phaseKey))
|
|
1413
|
+
return null;
|
|
1414
|
+
const phase = Number(phaseKey);
|
|
1415
|
+
if (!Number.isSafeInteger(phase) || isSentinelPhaseId(phase))
|
|
1416
|
+
return null;
|
|
1417
|
+
const { matches } = matchPhaseDirs([dir], phaseKey, 'bracket', context);
|
|
1418
|
+
return matches.includes(dir) ? phase : null;
|
|
1419
|
+
}
|
|
1420
|
+
function addBracketRoadmapPhaseNumbers(content, used, context) {
|
|
1421
|
+
for (const token of scanMilestonePhaseIds(content, 'bracket')) {
|
|
1422
|
+
const leading = String(token).split('.')[0];
|
|
1423
|
+
if (/^\d+$/.test(leading))
|
|
1424
|
+
used.add(Number(leading));
|
|
1425
|
+
}
|
|
1426
|
+
// #5528: a phase declared only as a checklist bullet (`- [ ] **[GSD.02] 05: x**`, with or without a
|
|
1427
|
+
// checkbox, letter-suffixed or word-first) is invisible to the heading scan above but still owns its
|
|
1428
|
+
// number. The bracket declaration scans see it; an id of another milestone only over-counts.
|
|
1429
|
+
if (!context)
|
|
1430
|
+
return;
|
|
1431
|
+
const active = `${context.project}.${context.milestone}`.toUpperCase();
|
|
1432
|
+
for (const { regex, idGroup, numGroup } of buildPhaseDeclarationScans('bracket')) {
|
|
1433
|
+
if (idGroup === null)
|
|
1434
|
+
continue;
|
|
1435
|
+
let hit;
|
|
1436
|
+
while ((hit = regex.exec(content)) !== null) {
|
|
1437
|
+
const num = parseInt(hit[numGroup], 10);
|
|
1438
|
+
if (!Number.isSafeInteger(num) || isSentinelPhaseId(num))
|
|
1439
|
+
continue;
|
|
1440
|
+
// A legacy `- [x] Phase 5: x` bullet (migration window) carries no bracket id: it still owns its number.
|
|
1441
|
+
if (!hit[idGroup] || hit[idGroup].toUpperCase() === active)
|
|
1442
|
+
used.add(num);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
function collectBracketPhaseNumbers(roadmapContent, phasesDir, context) {
|
|
1447
|
+
const used = new Set();
|
|
1448
|
+
addBracketRoadmapPhaseNumbers(roadmapContent, used, context);
|
|
1449
|
+
for (const dir of readSubdirectories(phasesDir, true)) {
|
|
1450
|
+
const phase = readerResolvableBracketDirectoryPhaseNumber(dir, context);
|
|
1451
|
+
if (phase !== null)
|
|
1452
|
+
used.add(phase);
|
|
1453
|
+
}
|
|
1454
|
+
used.delete(0);
|
|
1455
|
+
used.delete(999);
|
|
1456
|
+
return used;
|
|
1457
|
+
}
|
|
1189
1458
|
/**
|
|
1190
1459
|
* #3849 — widen "used phase numbers" beyond this checkout. Every sibling git
|
|
1191
1460
|
* worktree carries its own `.planning/` on its own branch, so a phase minted
|
|
1192
1461
|
* there is invisible to the cwd-scoped sources (headers, bullets, on-disk
|
|
1193
|
-
* dirs).
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1462
|
+
* dirs). Legacy identities scan each sibling's phase-directory names and whole
|
|
1463
|
+
* ROADMAP because their phase number has no milestone qualifier. Bracket ids
|
|
1464
|
+
* reserve the same reader-resolvable union used locally: exact identities for
|
|
1465
|
+
* the active project+milestone, migration-window legacy directory spellings,
|
|
1466
|
+
* and accepted ROADMAP spellings when the sibling is on that same bracket
|
|
1467
|
+
* identity. Each writer creates its directory in the same planning lock as its
|
|
1468
|
+
* ROADMAP entry.
|
|
1197
1469
|
*
|
|
1198
1470
|
* #4225 — the horizon must track the ALLOCATION scope. When the allocation is
|
|
1199
1471
|
* workstream-scoped (`--ws`/`GSD_WORKSTREAM`, resolved into the env before
|
|
@@ -1211,14 +1483,15 @@ function assertDescriptionPreservesMilestoneScope(cwd, description, command) {
|
|
|
1211
1483
|
* allocation then behaves exactly as it did before this horizon existed.
|
|
1212
1484
|
* A sibling that simply lacks the active workstream's directory is the same
|
|
1213
1485
|
* fail-open case: it contributes nothing. Sentinels reuse the canonical
|
|
1214
|
-
* `isSentinelPhaseId
|
|
1215
|
-
* so decimal sub-phases (`411.1-foo`) are correctly not integers.
|
|
1486
|
+
* `isSentinelPhaseId` (`isSentinelPhaseDir` for bracket dirs); the dir pattern is the same one the
|
|
1487
|
+
* on-disk scan uses, so decimal sub-phases (`411.1-foo`, `GSD.02-05.1-foo`) are correctly not integers.
|
|
1216
1488
|
*/
|
|
1217
|
-
function collectSiblingWorktreePhaseNums(cwd, used) {
|
|
1489
|
+
function collectSiblingWorktreePhaseNums(cwd, used, bracketContext) {
|
|
1218
1490
|
let porcelain;
|
|
1219
1491
|
try {
|
|
1220
1492
|
porcelain = (0, node_child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain'], {
|
|
1221
1493
|
cwd,
|
|
1494
|
+
env: (0, shell_command_projection_cjs_1.scrubGitLocationEnv)(process.env),
|
|
1222
1495
|
encoding: 'utf-8',
|
|
1223
1496
|
// Same subprocess band as the other git call sites (smart-entry, check-command-router):
|
|
1224
1497
|
// inside the 5-30s git window, hidden console window on Windows, bounded buffer.
|
|
@@ -1237,16 +1510,6 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
|
|
|
1237
1510
|
// try/catch below still keeps any resolution failure fail-open.
|
|
1238
1511
|
const ws = process.env['GSD_WORKSTREAM'] ?? null;
|
|
1239
1512
|
const siblingPlanningDir = (wt) => planningDir(wt, ws);
|
|
1240
|
-
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
|
|
1241
|
-
// Same header shape the allocators scan locally (#1729 tag tolerance).
|
|
1242
|
-
// #5007 (Phase 6 / ADR-4910 §8): migrated onto buildPhaseHeadingScanRegex
|
|
1243
|
-
// (src/phase-id.cjs), the shared "which phases exist in this content" scan
|
|
1244
|
-
// owner. LABEL_ONLY baseline with no convention argument reproduces the
|
|
1245
|
-
// prior literal `Phase\s+` (no bracket tolerance) byte-for-byte — this is a
|
|
1246
|
-
// counter, matching the LABEL_ONLY docstring guidance exactly, so it is
|
|
1247
|
-
// deliberately NOT widened onto the bracket grammar the way the real-heading
|
|
1248
|
-
// readers (getRoadmapModeForPhase, phaseDisplayNameFromRoadmap) are.
|
|
1249
|
-
const { regex: headerPattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
|
|
1250
1513
|
for (const line of porcelain.split('\n')) {
|
|
1251
1514
|
if (!line.startsWith('worktree '))
|
|
1252
1515
|
continue;
|
|
@@ -1254,124 +1517,258 @@ function collectSiblingWorktreePhaseNums(cwd, used) {
|
|
|
1254
1517
|
if (!wt || node_path_1.default.resolve(wt) === node_path_1.default.resolve(cwd))
|
|
1255
1518
|
continue;
|
|
1256
1519
|
try {
|
|
1257
|
-
|
|
1258
|
-
const match = entry.match(dirNumPattern);
|
|
1259
|
-
if (!match)
|
|
1260
|
-
continue;
|
|
1261
|
-
const num = parseInt(match[1], 10);
|
|
1262
|
-
if (!isSentinelPhaseId(num))
|
|
1263
|
-
used.add(num);
|
|
1264
|
-
}
|
|
1520
|
+
collectPhaseDirNums(node_path_1.default.join(siblingPlanningDir(wt), 'phases'), used, bracketContext);
|
|
1265
1521
|
}
|
|
1266
1522
|
catch {
|
|
1267
1523
|
/* worktree has no .planning (or no copy of this scope) — normal, contributes nothing */
|
|
1268
1524
|
}
|
|
1269
1525
|
try {
|
|
1270
1526
|
const content = node_fs_1.default.readFileSync(node_path_1.default.join(siblingPlanningDir(wt), 'ROADMAP.md'), 'utf-8');
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1527
|
+
if (bracketContext) {
|
|
1528
|
+
// Resolved without `bracketWriteContext`, whose `error()` writes to
|
|
1529
|
+
// stderr before throwing: a sibling with no project code or no
|
|
1530
|
+
// resolvable milestone simply contributes nothing.
|
|
1531
|
+
const siblingProject = loadConfig(wt)['project_code'];
|
|
1532
|
+
const siblingVersion = getMilestoneInfo(wt).value?.version ?? '';
|
|
1533
|
+
if (typeof siblingProject === 'string'
|
|
1534
|
+
&& siblingProject.trim() === bracketContext.project
|
|
1535
|
+
&& milestoneToken(siblingVersion) === bracketContext.milestone) {
|
|
1536
|
+
addBracketRoadmapPhaseNumbers(extractCurrentMilestone(content, wt), used, bracketContext);
|
|
1537
|
+
}
|
|
1538
|
+
continue;
|
|
1277
1539
|
}
|
|
1540
|
+
// Headers only for non-bracket repos, as before #5528 (#3849).
|
|
1541
|
+
for (const num of roadmapDeclaredPhaseNums(content, false))
|
|
1542
|
+
used.add(num);
|
|
1278
1543
|
}
|
|
1279
1544
|
catch {
|
|
1280
1545
|
/* no roadmap in that worktree (or scope) — normal, contributes nothing */
|
|
1281
1546
|
}
|
|
1282
1547
|
}
|
|
1283
1548
|
}
|
|
1549
|
+
/** The highest used number (a loop, not `Math.max(...set)`, which overflows the argument limit on a large set). */
|
|
1550
|
+
function maxPhaseNum(used) {
|
|
1551
|
+
let max = 0;
|
|
1552
|
+
for (const n of used)
|
|
1553
|
+
if (n > max)
|
|
1554
|
+
max = n;
|
|
1555
|
+
return max;
|
|
1556
|
+
}
|
|
1557
|
+
/** The number after `max`, stepping past the backlog sentinel (999) so allocation never lands on one. */
|
|
1558
|
+
function nextPhaseNum(max) {
|
|
1559
|
+
let n = max + 1;
|
|
1560
|
+
while (isSentinelPhaseId(n))
|
|
1561
|
+
n += 1;
|
|
1562
|
+
return n;
|
|
1563
|
+
}
|
|
1564
|
+
/** Phase numbers already taken: the current milestone's text, phase directories, sibling worktrees (#1229, #3849, #5528). */
|
|
1565
|
+
function collectUsedPhaseNums(cwd, content) {
|
|
1566
|
+
const used = new Set(roadmapDeclaredPhaseNums(content));
|
|
1567
|
+
collectPhaseDirNums(node_path_1.default.join(planningDir(cwd), 'phases'), used);
|
|
1568
|
+
collectSiblingWorktreePhaseNums(cwd, used);
|
|
1569
|
+
return used;
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* #4304: `toDir` throws a raw `Error` for a description whose
|
|
1573
|
+
* slug sanitizes to empty (e.g. a description that transliterates to
|
|
1574
|
+
* nothing) or is all-digit. An uncaught throw from inside a mutation loop
|
|
1575
|
+
* is worse than a refusal — it can leave earlier iterations' directories
|
|
1576
|
+
* already created with no clean error surface. This wraps that one call so
|
|
1577
|
+
* every bracket phase-directory allocation refuses through `error(...)`
|
|
1578
|
+
* (clean stderr message, controlled exit) instead of crashing.
|
|
1579
|
+
*/
|
|
1580
|
+
function bracketDirNameOrRefuse(id, slug, description) {
|
|
1581
|
+
try {
|
|
1582
|
+
return toDir(id, slug);
|
|
1583
|
+
}
|
|
1584
|
+
catch (e) {
|
|
1585
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1586
|
+
return error(`Cannot create a phase directory for "${description}": ${msg}`);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* #4304: fail closed before a bracket writer creates or renames into a phase
|
|
1591
|
+
* directory. Allocation intentionally ignores symlink entries, so the exact
|
|
1592
|
+
* destination must be checked with lstat (including dangling links) before a
|
|
1593
|
+
* recursive mkdir or child write can follow it. The canonical containment
|
|
1594
|
+
* predicate then resolves the existing path, or its nearest existing parent,
|
|
1595
|
+
* so a destination reached through a symlinked ancestor cannot escape the
|
|
1596
|
+
* planning phases directory either. A pre-pass supplies simulated occupancy
|
|
1597
|
+
* after deletion and earlier renames; the rename loop checks the live disk.
|
|
1598
|
+
*/
|
|
1599
|
+
function assertPhaseDirectoryDestinationSafe(phasesDir, dirName, operation, occupiedNames) {
|
|
1600
|
+
const destination = node_path_1.default.join(phasesDir, dirName);
|
|
1601
|
+
let stat = null;
|
|
1602
|
+
try {
|
|
1603
|
+
stat = node_fs_1.default.lstatSync(destination);
|
|
1604
|
+
}
|
|
1605
|
+
catch (e) {
|
|
1606
|
+
const code = e.code;
|
|
1607
|
+
if (code !== 'ENOENT') {
|
|
1608
|
+
error(`Cannot ${operation} phase directory "${dirName}": unable to inspect the destination (${code ?? 'unknown error'})`);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (stat?.isSymbolicLink()) {
|
|
1612
|
+
error(`Cannot ${operation} phase directory "${dirName}": the destination is a symbolic link`);
|
|
1613
|
+
}
|
|
1614
|
+
const destinationOccupied = occupiedNames
|
|
1615
|
+
? occupiedNames.has(dirName) || (stat !== null && !stat.isDirectory())
|
|
1616
|
+
: stat !== null;
|
|
1617
|
+
if (operation === 'renumber into' && destinationOccupied) {
|
|
1618
|
+
error(`Cannot ${operation} phase directory "${dirName}": the destination already exists`);
|
|
1619
|
+
}
|
|
1620
|
+
if ((0, security_cjs_1.tryWithinRoot)(destination, phasesDir, security_cjs_1.PathAcceptance.AbsoluteInsideRoot) === null) {
|
|
1621
|
+
error(`Cannot ${operation} phase directory "${dirName}": the destination resolves outside the planning phases directory`);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* #4304: the bracket writers mint every identity from the milestone's own
|
|
1626
|
+
* numbering. A caller-supplied `--id` or `phase_naming: "custom"` has no
|
|
1627
|
+
* bracket write semantics yet: #5067 owns their precedence (a supplied id is
|
|
1628
|
+
* validated against the resolved convention, "custom" keeps bypassing
|
|
1629
|
+
* generation). Until it lands, refuse before any write rather than discard
|
|
1630
|
+
* the id or override the naming mode.
|
|
1631
|
+
*/
|
|
1632
|
+
function assertBracketGeneratedIdentity(command, config, customId) {
|
|
1633
|
+
if (customId) {
|
|
1634
|
+
error(`${command}: --id ${JSON.stringify(customId)} is not supported under phase_id_convention "bracket"; ` +
|
|
1635
|
+
'bracket phase identities are generated from the active milestone. ' +
|
|
1636
|
+
'Custom id precedence under the bracket convention lands with #5067.');
|
|
1637
|
+
}
|
|
1638
|
+
if (config['phase_naming'] === 'custom') {
|
|
1639
|
+
error(`${command}: phase_naming "custom" is not supported under phase_id_convention "bracket"; ` +
|
|
1640
|
+
'bracket phase identities are generated from the active milestone. ' +
|
|
1641
|
+
'Custom naming precedence under the bracket convention lands with #5067.');
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* #4304: refuse a bracket `phase add` / `add-batch` whose located milestone
|
|
1646
|
+
* window is a decoy. A non-milestone heading carrying the active version
|
|
1647
|
+
* token ahead of the real milestone heading (`## Goals for v2.0` before
|
|
1648
|
+
* `## [CK.02] v2.0 Current`) is where the read side locates the window, so
|
|
1649
|
+
* the new entry would land in the decoy section, outside every reader's view
|
|
1650
|
+
* of the milestone. The window is accepted when its own heading carries the
|
|
1651
|
+
* active bracket qualifier; otherwise a live (unfenced, not historical)
|
|
1652
|
+
* milestone heading elsewhere that carries both the active qualifier and the
|
|
1653
|
+
* active version token is the real one, and the writer refuses before any
|
|
1654
|
+
* write, naming both lines. Read-side window selection is unchanged.
|
|
1655
|
+
*/
|
|
1656
|
+
function assertBracketAddWindowLocated(rawContent, cwd, context, command) {
|
|
1657
|
+
const ranges = currentMilestoneRawRanges(rawContent, cwd, 'bracket');
|
|
1658
|
+
if (!ranges)
|
|
1659
|
+
return;
|
|
1660
|
+
const lineAt = (offset) => {
|
|
1661
|
+
const end = rawContent.indexOf('\n', offset);
|
|
1662
|
+
return rawContent.slice(offset, end === -1 ? rawContent.length : end).replace(/\r$/, '');
|
|
1663
|
+
};
|
|
1664
|
+
const qualifierRe = new RegExp(`^\\[${(0, pattern_cjs_1.escapeRegex)(context.project)}\\.${(0, pattern_cjs_1.escapeRegex)(context.milestone)}\\]`, 'i');
|
|
1665
|
+
const windowHeading = lineAt(ranges.primary.start);
|
|
1666
|
+
if (qualifierRe.test(windowHeading.replace(/^#+[ \t]*/, '')))
|
|
1667
|
+
return;
|
|
1668
|
+
const version = String(getMilestoneInfo(cwd).value?.version ?? '');
|
|
1669
|
+
if (!version)
|
|
1670
|
+
return;
|
|
1671
|
+
const versionRe = new RegExp(`(?<![\\w.])v?${(0, pattern_cjs_1.escapeRegex)(version.replace(/^v/i, ''))}(?![\\w.-])`, 'i');
|
|
1672
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(rawContent);
|
|
1673
|
+
const insideWindow = (offset) => (offset >= ranges.primary.start && offset < ranges.primary.end)
|
|
1674
|
+
|| Boolean(ranges.details && offset >= ranges.details.start && offset < ranges.details.end);
|
|
1675
|
+
const realHeading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(rawContent).find((heading) => !insideWindow(heading.offset)
|
|
1676
|
+
&& !historicalLineStarts.has(heading.offset)
|
|
1677
|
+
&& rawContent[heading.offset] === '#'
|
|
1678
|
+
&& isRecognizedMilestoneHeading(heading.text, heading.level)
|
|
1679
|
+
&& qualifierRe.test(heading.text.trim())
|
|
1680
|
+
&& versionRe.test(heading.text));
|
|
1681
|
+
if (!realHeading)
|
|
1682
|
+
return;
|
|
1683
|
+
const lineNumber = (offset) => rawContent.slice(0, offset).split('\n').length;
|
|
1684
|
+
error(`${command}: the active milestone window was located at ROADMAP.md line ${lineNumber(ranges.primary.start)} `
|
|
1685
|
+
+ `(${JSON.stringify(windowHeading.trim())}), but the milestone heading for ${renderMilestoneId(context)} `
|
|
1686
|
+
+ `${version} is at line ${lineNumber(realHeading.offset)} (${JSON.stringify(lineAt(realHeading.offset).trim())}). `
|
|
1687
|
+
+ 'Writing the new phase there would place it outside the milestone. Rename or remove the earlier heading '
|
|
1688
|
+
+ 'so it no longer carries the active version token, then retry.');
|
|
1689
|
+
}
|
|
1284
1690
|
function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
1285
1691
|
if (!description) {
|
|
1286
1692
|
error('description required for phase add');
|
|
1287
1693
|
}
|
|
1288
1694
|
assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add');
|
|
1695
|
+
const convention = resolvePhaseIdConvention(cwd);
|
|
1696
|
+
// The naming projection reads config silently, so a corrupt project config is
|
|
1697
|
+
// reported as `config_parse_failed` before the loader's own diagnostics run.
|
|
1698
|
+
const naming = phaseDirectoryOptions(cwd, undefined, '');
|
|
1699
|
+
if (customId != null && convention !== 'bracket') {
|
|
1700
|
+
const usage = phaseLocatorMod.customPhaseNamingUsage(cwd, naming);
|
|
1701
|
+
if (usage)
|
|
1702
|
+
error(usage, naming.configError ? ERROR_REASON.CONFIG_PARSE_FAILED : ERROR_REASON.PHASE_NAMING_NOT_CUSTOM);
|
|
1703
|
+
}
|
|
1289
1704
|
const config = loadConfig(cwd);
|
|
1290
1705
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1291
1706
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
1292
1707
|
error('ROADMAP.md not found');
|
|
1293
1708
|
}
|
|
1294
1709
|
const slug = generateSlugInternal(description) || '';
|
|
1295
|
-
|
|
1710
|
+
if (convention === 'bracket')
|
|
1711
|
+
assertBracketGeneratedIdentity('phase add', config, customId);
|
|
1712
|
+
const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
|
|
1713
|
+
const { newPhaseId, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
|
|
1296
1714
|
const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
1715
|
+
if (bracketContext)
|
|
1716
|
+
assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add');
|
|
1297
1717
|
const content = extractCurrentMilestone(rawContent, cwd);
|
|
1298
|
-
const projectCode = config.project_code || '';
|
|
1299
|
-
const prefix = projectCode ? `${projectCode}-` : '';
|
|
1300
1718
|
let _newPhaseId;
|
|
1301
|
-
let _dirName;
|
|
1302
|
-
if (
|
|
1719
|
+
let _dirName = '';
|
|
1720
|
+
if (bracketContext) {
|
|
1721
|
+
const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
1722
|
+
const usedPhaseNums = collectBracketPhaseNumbers(content, phasesOnDisk, bracketContext);
|
|
1723
|
+
collectSiblingWorktreePhaseNums(cwd, usedPhaseNums, bracketContext);
|
|
1724
|
+
_newPhaseId = nextPhaseNum(maxPhaseNum(usedPhaseNums));
|
|
1725
|
+
_dirName = bracketDirNameOrRefuse(bracketPhaseId(bracketContext, _newPhaseId), slug, description);
|
|
1726
|
+
}
|
|
1727
|
+
else if (naming.phaseNaming === 'custom') {
|
|
1303
1728
|
_newPhaseId = customId || slug.toUpperCase();
|
|
1304
1729
|
if (!_newPhaseId)
|
|
1305
1730
|
error('--id required when phase_naming is "custom"');
|
|
1306
|
-
_dirName = `${prefix}${_newPhaseId}-${slug}`;
|
|
1307
1731
|
}
|
|
1308
1732
|
else {
|
|
1309
|
-
// Collect all phase numbers visible in the current-milestone content.
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
// argument — a counter site, matching phase-id.cts's own LABEL_ONLY
|
|
1317
|
-
// docstring guidance, reproducing the prior `Phase\s+` literal (no
|
|
1318
|
-
// bracket tolerance) byte-for-byte. See collectSiblingWorktreePhaseNums
|
|
1319
|
-
// (above) for the identical migration on the same regex shape.
|
|
1320
|
-
const { regex: headerPattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
|
|
1321
|
-
// 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
|
|
1322
|
-
// The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
|
|
1323
|
-
// or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
|
|
1324
|
-
// are counted and cannot collide with a freshly-added phase. (#1229)
|
|
1325
|
-
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
|
1326
|
-
const usedPhaseNums = new Set();
|
|
1327
|
-
let m;
|
|
1328
|
-
while ((m = headerPattern.exec(content)) !== null) {
|
|
1329
|
-
const num = parseInt(m[phaseNumGroup], 10);
|
|
1330
|
-
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1331
|
-
if (!isSentinelPhaseId(num))
|
|
1332
|
-
usedPhaseNums.add(num);
|
|
1333
|
-
}
|
|
1334
|
-
while ((m = bulletPattern.exec(content)) !== null) {
|
|
1335
|
-
const num = parseInt(m[1], 10);
|
|
1336
|
-
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1337
|
-
if (!isSentinelPhaseId(num))
|
|
1338
|
-
usedPhaseNums.add(num);
|
|
1339
|
-
}
|
|
1340
|
-
// 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
|
|
1341
|
-
const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
1342
|
-
if (node_fs_1.default.existsSync(phasesOnDisk)) {
|
|
1343
|
-
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
|
|
1344
|
-
for (const entry of node_fs_1.default.readdirSync(phasesOnDisk)) {
|
|
1345
|
-
const match = entry.match(dirNumPattern);
|
|
1346
|
-
if (!match)
|
|
1347
|
-
continue;
|
|
1348
|
-
const num = parseInt(match[1], 10);
|
|
1349
|
-
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1350
|
-
if (!isSentinelPhaseId(num))
|
|
1351
|
-
usedPhaseNums.add(num);
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
// phase.add appends after the highest *used* number. Collecting numbers from
|
|
1355
|
-
// section headers, roadmap bullets, AND on-disk dirs above is what prevents the
|
|
1356
|
-
// #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
|
|
1357
|
-
// an existing number.
|
|
1358
|
-
// 4) Sibling git worktrees (#3849) — same max+1, wider horizon: a number
|
|
1359
|
-
// taken on another branch is still taken.
|
|
1360
|
-
collectSiblingWorktreePhaseNums(cwd, usedPhaseNums);
|
|
1361
|
-
const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
|
|
1362
|
-
_newPhaseId = maxUsed + 1;
|
|
1363
|
-
const paddedNum = String(_newPhaseId).padStart(2, '0');
|
|
1364
|
-
_dirName = `${prefix}${paddedNum}-${slug}`;
|
|
1733
|
+
// Collect all phase numbers visible in the current-milestone content. Three sources are
|
|
1734
|
+
// scanned so a phase in ANY representation (section header, roadmap bullet, or on-disk
|
|
1735
|
+
// directory) is counted: a bullet-only Phase N must not collide with a fresh one (#1229),
|
|
1736
|
+
// and under the bracket convention `[CODE.MM] NN` forms count in all three (#5528).
|
|
1737
|
+
// Header/bullet scans are #1729 pre-colon-tag tolerant and #3185 sentinel-filtered.
|
|
1738
|
+
const usedPhaseNums = collectUsedPhaseNums(cwd, content);
|
|
1739
|
+
_newPhaseId = nextPhaseNum(maxPhaseNum(usedPhaseNums));
|
|
1365
1740
|
}
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1741
|
+
if (bracketContext) {
|
|
1742
|
+
const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', _dirName);
|
|
1743
|
+
assertPhaseDirectoryDestinationSafe(node_path_1.default.dirname(dirPath), _dirName, 'create');
|
|
1744
|
+
(0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
|
|
1745
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
|
|
1746
|
+
}
|
|
1747
|
+
else {
|
|
1748
|
+
[{ dirName: _dirName }] = phaseLocatorMod.createPhaseDirectories(cwd, [{
|
|
1749
|
+
id: String(_newPhaseId), slug,
|
|
1750
|
+
directoryId: typeof _newPhaseId === 'number' ? String(_newPhaseId).padStart(2, '0') : _newPhaseId,
|
|
1751
|
+
}], { ...naming, roadmap: content });
|
|
1752
|
+
}
|
|
1753
|
+
let phaseEntry;
|
|
1754
|
+
if (bracketContext && typeof _newPhaseId === 'number') {
|
|
1755
|
+
const id = bracketPhaseId(bracketContext, _newPhaseId);
|
|
1756
|
+
const display = renderPhaseId(id);
|
|
1757
|
+
const declared = new Set();
|
|
1758
|
+
addBracketRoadmapPhaseNumbers(content, declared);
|
|
1759
|
+
const dependsOn = dependsOnLine(_newPhaseId, [...declared], (phase) => renderPhaseId(bracketPhaseId(bracketContext, phase)));
|
|
1760
|
+
phaseEntry =
|
|
1761
|
+
`\n### ${display}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${id.phase} to break down)\n`;
|
|
1762
|
+
}
|
|
1763
|
+
else {
|
|
1764
|
+
const dependsOn = naming.phaseNaming === 'custom' ? '' : dependsOnFor(_newPhaseId, rawContent);
|
|
1765
|
+
phaseEntry =
|
|
1766
|
+
`\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_newPhaseId} to break down)\n`;
|
|
1767
|
+
}
|
|
1768
|
+
const insertAt = phaseEntryInsertOffset(rawContent, cwd, convention);
|
|
1769
|
+
const recorded = recordNewPhase(spliceRoadmapBlock(rawContent, insertAt, phaseEntry), cwd, _newPhaseId, description, undefined, bracketContext && typeof _newPhaseId === 'number' ? renderPhaseId(bracketPhaseId(bracketContext, _newPhaseId)) : undefined);
|
|
1770
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, recorded.content, { verbatim: true });
|
|
1771
|
+
return { newPhaseId: _newPhaseId, dirName: _dirName, roadmapEntry: recorded.entry };
|
|
1375
1772
|
});
|
|
1376
1773
|
const titleWarning = describeGoalShapedTitle(description);
|
|
1377
1774
|
const result = {
|
|
@@ -1380,7 +1777,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
1380
1777
|
name: description,
|
|
1381
1778
|
slug,
|
|
1382
1779
|
directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
|
|
1383
|
-
naming_mode:
|
|
1780
|
+
naming_mode: naming.phaseNaming,
|
|
1781
|
+
roadmap_entry: roadmapEntry,
|
|
1384
1782
|
};
|
|
1385
1783
|
if (titleWarning)
|
|
1386
1784
|
result['warning'] = titleWarning;
|
|
@@ -1409,94 +1807,111 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
1409
1807
|
assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add-batch');
|
|
1410
1808
|
}
|
|
1411
1809
|
const config = loadConfig(cwd);
|
|
1810
|
+
const convention = resolvePhaseIdConvention(cwd);
|
|
1811
|
+
const naming = phaseDirectoryOptions(cwd, undefined, '');
|
|
1812
|
+
if (descriptions.includes('--id') && convention !== 'bracket') {
|
|
1813
|
+
error(phaseLocatorMod.customPhaseNamingUsage(cwd, naming) ?? '--id is not supported by phase add-batch', naming.phaseNaming === 'custom' ? ERROR_REASON.USAGE : naming.configError ? ERROR_REASON.CONFIG_PARSE_FAILED : ERROR_REASON.PHASE_NAMING_NOT_CUSTOM);
|
|
1814
|
+
}
|
|
1412
1815
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1413
1816
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
1414
1817
|
error('ROADMAP.md not found');
|
|
1415
1818
|
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1819
|
+
if (convention === 'bracket')
|
|
1820
|
+
assertBracketGeneratedIdentity('phase add-batch', config);
|
|
1821
|
+
const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
|
|
1418
1822
|
const results = withPlanningLock(cwd, () => {
|
|
1419
1823
|
let rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
1824
|
+
if (bracketContext)
|
|
1825
|
+
assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add-batch');
|
|
1420
1826
|
const content = extractCurrentMilestone(rawContent, cwd);
|
|
1421
1827
|
let maxPhase = 0;
|
|
1422
|
-
if (
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
1427
|
-
// #5007 (Phase 6 / ADR-4910 §8): migrated onto buildPhaseHeadingScanRegex
|
|
1428
|
-
// (src/phase-id.cjs) with the LABEL_ONLY baseline and no convention
|
|
1429
|
-
// argument — same counter-site migration as cmdPhaseAdd's identical
|
|
1430
|
-
// regex above.
|
|
1431
|
-
const { regex: phasePattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.LABEL_ONLY);
|
|
1432
|
-
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
|
1433
|
-
let m;
|
|
1434
|
-
while ((m = phasePattern.exec(content)) !== null) {
|
|
1435
|
-
const num = parseInt(m[phaseNumGroup], 10);
|
|
1436
|
-
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1437
|
-
if (isSentinelPhaseId(num))
|
|
1438
|
-
continue;
|
|
1439
|
-
if (num > maxPhase)
|
|
1440
|
-
maxPhase = num;
|
|
1441
|
-
}
|
|
1442
|
-
while ((m = bulletPattern.exec(content)) !== null) {
|
|
1443
|
-
const num = parseInt(m[1], 10);
|
|
1444
|
-
if (isSentinelPhaseId(num))
|
|
1445
|
-
continue;
|
|
1446
|
-
if (num > maxPhase)
|
|
1447
|
-
maxPhase = num;
|
|
1448
|
-
}
|
|
1449
|
-
const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
1450
|
-
if (node_fs_1.default.existsSync(phasesOnDisk)) {
|
|
1451
|
-
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
|
|
1452
|
-
for (const entry of node_fs_1.default.readdirSync(phasesOnDisk)) {
|
|
1453
|
-
const match = entry.match(dirNumPattern);
|
|
1454
|
-
if (!match)
|
|
1455
|
-
continue;
|
|
1456
|
-
const num = parseInt(match[1], 10);
|
|
1457
|
-
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1458
|
-
if (isSentinelPhaseId(num))
|
|
1459
|
-
continue;
|
|
1460
|
-
if (num > maxPhase)
|
|
1461
|
-
maxPhase = num;
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
// 4) Sibling git worktrees (#3849) — same max+1, wider horizon.
|
|
1465
|
-
const siblingNums = new Set();
|
|
1466
|
-
collectSiblingWorktreePhaseNums(cwd, siblingNums);
|
|
1467
|
-
for (const num of siblingNums) {
|
|
1468
|
-
if (num > maxPhase)
|
|
1469
|
-
maxPhase = num;
|
|
1470
|
-
}
|
|
1828
|
+
if (bracketContext) {
|
|
1829
|
+
const used = collectBracketPhaseNumbers(content, node_path_1.default.join(planningDir(cwd), 'phases'), bracketContext);
|
|
1830
|
+
collectSiblingWorktreePhaseNums(cwd, used, bracketContext);
|
|
1831
|
+
maxPhase = maxPhaseNum(used);
|
|
1471
1832
|
}
|
|
1472
|
-
|
|
1833
|
+
else if (naming.phaseNaming !== 'custom') {
|
|
1834
|
+
// Same three cwd-scoped sources as cmdPhaseAdd (#1229, #5528): headers, roadmap bullets,
|
|
1835
|
+
// on-disk dirs, then sibling worktrees (#3849) — same max+1, wider horizon.
|
|
1836
|
+
maxPhase = maxPhaseNum(collectUsedPhaseNums(cwd, content));
|
|
1837
|
+
}
|
|
1838
|
+
// #4304: compute and validate every item's slug/dirName
|
|
1839
|
+
// BEFORE the first `platformEnsureDir` — a bracket `toDir` failure
|
|
1840
|
+
// (e.g. a description that sanitizes to an empty slug) must refuse the
|
|
1841
|
+
// whole batch with zero directories created, not throw mid-loop after
|
|
1842
|
+
// earlier items already created theirs. This first pass touches no
|
|
1843
|
+
// disk.
|
|
1844
|
+
const validated = [];
|
|
1473
1845
|
for (const description of descriptions) {
|
|
1474
1846
|
const slug = generateSlugInternal(description) || '';
|
|
1475
1847
|
let newPhaseId;
|
|
1476
1848
|
let dirName;
|
|
1477
|
-
if (
|
|
1849
|
+
if (bracketContext) {
|
|
1850
|
+
maxPhase = nextPhaseNum(maxPhase);
|
|
1851
|
+
newPhaseId = maxPhase;
|
|
1852
|
+
dirName = bracketDirNameOrRefuse(bracketPhaseId(bracketContext, newPhaseId), slug, description);
|
|
1853
|
+
}
|
|
1854
|
+
else if (naming.phaseNaming === 'custom') {
|
|
1478
1855
|
newPhaseId = slug.toUpperCase();
|
|
1479
|
-
dirName =
|
|
1856
|
+
dirName = '';
|
|
1480
1857
|
}
|
|
1481
1858
|
else {
|
|
1482
|
-
maxPhase
|
|
1859
|
+
maxPhase = nextPhaseNum(maxPhase);
|
|
1483
1860
|
newPhaseId = maxPhase;
|
|
1484
|
-
dirName =
|
|
1861
|
+
dirName = '';
|
|
1485
1862
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1863
|
+
validated.push({ description, slug, newPhaseId, dirName });
|
|
1864
|
+
}
|
|
1865
|
+
// Batch creation is all-or-nothing: validate every bracket destination
|
|
1866
|
+
// before the first directory or ROADMAP byte is written.
|
|
1867
|
+
if (bracketContext) {
|
|
1868
|
+
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
1869
|
+
for (const { dirName } of validated) {
|
|
1870
|
+
assertPhaseDirectoryDestinationSafe(phasesDir, dirName, 'create');
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
if (!bracketContext) {
|
|
1874
|
+
// One preflight over the whole batch (identity collisions, occupied
|
|
1875
|
+
// targets, containment) before the first directory is created.
|
|
1876
|
+
const prepared = phaseLocatorMod.createPhaseDirectories(cwd, validated.map(item => ({
|
|
1877
|
+
id: String(item.newPhaseId), slug: item.slug,
|
|
1878
|
+
directoryId: typeof item.newPhaseId === 'number' ? String(item.newPhaseId).padStart(2, '0') : item.newPhaseId,
|
|
1879
|
+
})), { ...naming, roadmap: content });
|
|
1880
|
+
prepared.forEach((entry, index) => { validated[index].dirName = entry.dirName; });
|
|
1881
|
+
}
|
|
1882
|
+
const added = [];
|
|
1883
|
+
for (const { description, slug, newPhaseId, dirName } of validated) {
|
|
1884
|
+
if (bracketContext) {
|
|
1885
|
+
const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', dirName);
|
|
1886
|
+
(0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
|
|
1887
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
|
|
1888
|
+
}
|
|
1889
|
+
let phaseEntry;
|
|
1890
|
+
if (bracketContext && typeof newPhaseId === 'number') {
|
|
1891
|
+
const id = bracketPhaseId(bracketContext, newPhaseId);
|
|
1892
|
+
const declared = new Set();
|
|
1893
|
+
// rawContent includes earlier batch items, which become prerequisites.
|
|
1894
|
+
addBracketRoadmapPhaseNumbers(extractCurrentMilestone(rawContent, cwd), declared);
|
|
1895
|
+
const dependsOn = dependsOnLine(newPhaseId, [...declared], (phase) => renderPhaseId(bracketPhaseId(bracketContext, phase)));
|
|
1896
|
+
phaseEntry =
|
|
1897
|
+
`\n### ${renderPhaseId(id)}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${id.phase} to break down)\n`;
|
|
1898
|
+
}
|
|
1899
|
+
else {
|
|
1900
|
+
const dependsOn = naming.phaseNaming === 'custom' ? '' : dependsOnFor(newPhaseId, rawContent);
|
|
1901
|
+
phaseEntry =
|
|
1902
|
+
`\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${newPhaseId} to break down)\n`;
|
|
1903
|
+
}
|
|
1904
|
+
const insertAt = phaseEntryInsertOffset(rawContent, cwd, convention);
|
|
1905
|
+
const recorded = recordNewPhase(spliceRoadmapBlock(rawContent, insertAt, phaseEntry), cwd, newPhaseId, description, undefined, bracketContext && typeof newPhaseId === 'number' ? renderPhaseId(bracketPhaseId(bracketContext, newPhaseId)) : undefined);
|
|
1906
|
+
rawContent = recorded.content;
|
|
1493
1907
|
added.push({
|
|
1908
|
+
roadmap_entry: recorded.entry,
|
|
1494
1909
|
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
|
1495
1910
|
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
|
1496
1911
|
name: description,
|
|
1497
1912
|
slug,
|
|
1498
1913
|
directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
|
|
1499
|
-
naming_mode:
|
|
1914
|
+
naming_mode: naming.phaseNaming,
|
|
1500
1915
|
});
|
|
1501
1916
|
}
|
|
1502
1917
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, rawContent, { verbatim: true });
|
|
@@ -1570,13 +1985,199 @@ function scanExistingDecimalPhaseNumbers(phasesDir, rawContent, base) {
|
|
|
1570
1985
|
while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
|
|
1571
1986
|
decimalSet.add(parseInt(rmMatch[1], 10));
|
|
1572
1987
|
}
|
|
1573
|
-
const checklistDecimalPattern =
|
|
1988
|
+
const checklistDecimalPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${phaseMarkdownRegexSource(base)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi');
|
|
1574
1989
|
let clMatch;
|
|
1575
1990
|
while ((clMatch = checklistDecimalPattern.exec(rawContent)) !== null) {
|
|
1576
1991
|
decimalSet.add(parseInt(clMatch[1], 10));
|
|
1577
1992
|
}
|
|
1578
1993
|
return decimalSet;
|
|
1579
1994
|
}
|
|
1995
|
+
function scanExistingBracketDecimalPhaseNumbers(phasesDir, roadmapContent, base, context, meta) {
|
|
1996
|
+
// A bracket repository can be mid-migration, and add-backlog deliberately
|
|
1997
|
+
// remains a legacy sentinel writer. Inventory is therefore the UNION of
|
|
1998
|
+
// the legacy spellings and the canonical bracket spellings that readers
|
|
1999
|
+
// accept. Do not route the 999 parent exclusively through
|
|
2000
|
+
// scanMilestonePhaseIds: that reader intentionally excludes the icebox
|
|
2001
|
+
// range for milestone counting, while allocation must include it.
|
|
2002
|
+
const decimalSet = scanExistingDecimalPhaseNumbers(phasesDir, roadmapContent, base);
|
|
2003
|
+
for (const token of scanMilestonePhaseIds(roadmapContent, 'bracket')) {
|
|
2004
|
+
const [phase, subphase, extra] = String(token).split('.');
|
|
2005
|
+
if (!extra && Number(phase) === Number(base) && /^\d+$/.test(subphase ?? '')) {
|
|
2006
|
+
decimalSet.add(Number(subphase));
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
for (const { id } of bracketIdsInContext(phasesDir, context)) {
|
|
2010
|
+
if (Number(id.phase) !== Number(base))
|
|
2011
|
+
continue;
|
|
2012
|
+
if (meta)
|
|
2013
|
+
meta.bracketSpellingFound = true;
|
|
2014
|
+
if (id.subphase)
|
|
2015
|
+
decimalSet.add(Number(id.subphase));
|
|
2016
|
+
}
|
|
2017
|
+
const intro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, 'bracket', true);
|
|
2018
|
+
const entryPattern = new RegExp(`^${intro}(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[ \\t]*:`, 'i');
|
|
2019
|
+
const addRoadmapToken = (bracketId, token) => {
|
|
2020
|
+
const [phase, subphase, extra] = token.split('.');
|
|
2021
|
+
if (extra || Number(phase) !== Number(base) || !/^\d+$/.test(subphase ?? ''))
|
|
2022
|
+
return;
|
|
2023
|
+
if (bracketId) {
|
|
2024
|
+
if (foldBracketId(bracketId) !== foldBracketId(`${context.project}.${context.milestone}`))
|
|
2025
|
+
return;
|
|
2026
|
+
if (meta)
|
|
2027
|
+
meta.bracketSpellingFound = true;
|
|
2028
|
+
}
|
|
2029
|
+
decimalSet.add(Number(subphase));
|
|
2030
|
+
};
|
|
2031
|
+
// tokenizeHeadings is the fence-aware reader surface and, unlike the
|
|
2032
|
+
// milestone membership scanner, retains sentinel headings for this
|
|
2033
|
+
// allocation-specific inventory.
|
|
2034
|
+
for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent)) {
|
|
2035
|
+
if (heading.level < 2 || heading.level > 4)
|
|
2036
|
+
continue;
|
|
2037
|
+
const match = entryPattern.exec(heading.text);
|
|
2038
|
+
if (match)
|
|
2039
|
+
addRoadmapToken(match[1], match[2]);
|
|
2040
|
+
}
|
|
2041
|
+
// The manager accepts bracket checklist rows without requiring a detail
|
|
2042
|
+
// heading. Match that same phase-intro grammar here so ROADMAP-only children
|
|
2043
|
+
// still reserve their number. Fenced examples are documentation, not live
|
|
2044
|
+
// inventory.
|
|
2045
|
+
const fencedLines = fencedRoadmapLineNumbers(roadmapContent);
|
|
2046
|
+
for (const [index, line] of roadmapContent.split('\n').entries()) {
|
|
2047
|
+
if (fencedLines.has(index + 1))
|
|
2048
|
+
continue;
|
|
2049
|
+
const checklist = parsePhaseChecklistLine(line.replace(/\r$/, ''), 'bracket');
|
|
2050
|
+
if (checklist)
|
|
2051
|
+
addRoadmapToken(checklist.bracketId, checklist.phaseToken);
|
|
2052
|
+
}
|
|
2053
|
+
return decimalSet;
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* #4304: the ONE bracket-argument canonicalization `phase
|
|
2057
|
+
* remove` and `phase insert` both need, extracted from cmdPhaseRemove's own
|
|
2058
|
+
* canonicalization so insert accepts every form remove does instead of maintaining a
|
|
2059
|
+
* second, narrower copy that could silently disagree with it. A bare token
|
|
2060
|
+
* (`2`, `02`, `002`) canonicalizes through phase-id-display's `phaseToken`
|
|
2061
|
+
* adapter; a qualified/display token (`CK.02-02`, `[CK.02] 02`) canonicalizes
|
|
2062
|
+
* through phase-id.cts's strict `parsePhaseId`, refusing before any mutation
|
|
2063
|
+
* when it names a different milestone. `actionVerb` (e.g. "remove", "insert
|
|
2064
|
+
* after") only varies the refusal wording.
|
|
2065
|
+
*/
|
|
2066
|
+
function canonicalizeBracketPhaseArgument(context, targetPhase, actionVerb) {
|
|
2067
|
+
const isQualified = targetPhase.startsWith('[') || targetPhase.includes('-');
|
|
2068
|
+
if (isQualified) {
|
|
2069
|
+
let qualifiedId = null;
|
|
2070
|
+
try {
|
|
2071
|
+
if (targetPhase.startsWith('[')) {
|
|
2072
|
+
const close = targetPhase.indexOf(']');
|
|
2073
|
+
// The owned-line reader canonicalizes numeric segments via phaseToken
|
|
2074
|
+
// before strict identity parsing; CLI display ids use that same owner.
|
|
2075
|
+
qualifiedId = phaseIdFromOwnedParts(targetPhase.slice(1, close), targetPhase.slice(close + 1).trim());
|
|
2076
|
+
}
|
|
2077
|
+
else {
|
|
2078
|
+
qualifiedId = parsePhaseId(targetPhase);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
catch {
|
|
2082
|
+
return error(`Phase ${targetPhase} cannot be resolved to a bracket phase number`, ERROR_REASON.USAGE);
|
|
2083
|
+
}
|
|
2084
|
+
if (qualifiedId === null) {
|
|
2085
|
+
return error(`Phase ${targetPhase} cannot be resolved to a bracket phase number`, ERROR_REASON.USAGE);
|
|
2086
|
+
}
|
|
2087
|
+
if (qualifiedId.project !== context.project || qualifiedId.milestone !== context.milestone) {
|
|
2088
|
+
error(`Phase ${targetPhase} belongs to milestone [${qualifiedId.project}.${qualifiedId.milestone}], `
|
|
2089
|
+
+ `but the active milestone is [${context.project}.${context.milestone}]. `
|
|
2090
|
+
+ `Refusing to ${actionVerb} a phase outside the active milestone.`);
|
|
2091
|
+
}
|
|
2092
|
+
const isDecimal = qualifiedId.subphase !== undefined;
|
|
2093
|
+
const prefix = bracketDirPrefix(qualifiedId);
|
|
2094
|
+
const slug = targetPhase.startsWith(prefix) ? targetPhase.slice(prefix.length) : undefined;
|
|
2095
|
+
return { normalized: isDecimal ? `${qualifiedId.phase}.${qualifiedId.subphase}` : qualifiedId.phase, isDecimal, slug };
|
|
2096
|
+
}
|
|
2097
|
+
const canonicalToken = phaseToken(targetPhase);
|
|
2098
|
+
if (canonicalToken === null) {
|
|
2099
|
+
error(`Phase ${targetPhase} cannot be resolved to a bracket phase number`);
|
|
2100
|
+
return { normalized: '', isDecimal: false };
|
|
2101
|
+
}
|
|
2102
|
+
return { normalized: canonicalToken, isDecimal: canonicalToken.includes('.') };
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* Select `phase insert`'s bracket target from the reader-owned active ranges.
|
|
2106
|
+
* A raw range may still contain a shipped `<details>` archive, so range
|
|
2107
|
+
* membership alone is not evidence that a same-numbered heading is live.
|
|
2108
|
+
* Reuse bracket removal's historical-section classifier and owned-line parser:
|
|
2109
|
+
* the latter derives its bracket intro from phase-id.cts's exported read
|
|
2110
|
+
* grammar, then `sameBracketPhaseId` qualifies project, milestone, and the
|
|
2111
|
+
* canonical phase number together. The insertion boundary comes from the same
|
|
2112
|
+
* fence-aware heading tokens, never a raw next-heading regex. A fenced block
|
|
2113
|
+
* that contains a phase-heading example is kept byte-identical and begins a
|
|
2114
|
+
* protected boundary, so the live inserted heading lands before the example
|
|
2115
|
+
* instead of inside its fence. No bare-number fallback exists here.
|
|
2116
|
+
*/
|
|
2117
|
+
function activeBracketInsertHeading(content, targetId, ranges) {
|
|
2118
|
+
if (!ranges)
|
|
2119
|
+
return null;
|
|
2120
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
|
|
2121
|
+
const fencedLineNumbers = fencedRoadmapLineNumbers(content);
|
|
2122
|
+
const searchRanges = [ranges.primary, ...(ranges.details ? [ranges.details] : [])];
|
|
2123
|
+
const lines = splitRoadmapLineRecords(content);
|
|
2124
|
+
const lineByStart = new Map(lines.map((line) => [line.start, line]));
|
|
2125
|
+
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
2126
|
+
const rawLines = content.split('\n');
|
|
2127
|
+
const protectedFenceStarts = (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(rawLines)
|
|
2128
|
+
.filter((block) => {
|
|
2129
|
+
const lastIndex = block.closeLineIdx === -1 ? rawLines.length - 1 : block.closeLineIdx;
|
|
2130
|
+
for (let index = block.openLineIdx + 1; index < lastIndex; index++) {
|
|
2131
|
+
if (classifyBracketOwnedLine(rawLines[index].replace(/\r$/, '')).kind === 'heading')
|
|
2132
|
+
return true;
|
|
2133
|
+
}
|
|
2134
|
+
return false;
|
|
2135
|
+
})
|
|
2136
|
+
.map((block) => lines[block.openLineIdx]?.start)
|
|
2137
|
+
.filter((start) => start !== undefined);
|
|
2138
|
+
for (const range of searchRanges) {
|
|
2139
|
+
const liveHeadings = headings.filter((heading) => heading.offset >= range.start
|
|
2140
|
+
&& heading.offset < range.end
|
|
2141
|
+
&& !historicalLineStarts.has(heading.offset));
|
|
2142
|
+
for (let headingIndex = 0; headingIndex < liveHeadings.length; headingIndex++) {
|
|
2143
|
+
const heading = liveHeadings[headingIndex];
|
|
2144
|
+
const line = lineByStart.get(heading.offset);
|
|
2145
|
+
if (!line || fencedLineNumbers.has(line.lineNumber))
|
|
2146
|
+
continue;
|
|
2147
|
+
const owned = classifyBracketOwnedLine(line.text);
|
|
2148
|
+
if (owned.kind !== 'heading' || !owned.id || !sameBracketPhaseId(owned.id, targetId))
|
|
2149
|
+
continue;
|
|
2150
|
+
let insertAt = range.end;
|
|
2151
|
+
for (const nextHeading of liveHeadings.slice(headingIndex + 1)) {
|
|
2152
|
+
const nextLine = lineByStart.get(nextHeading.offset);
|
|
2153
|
+
if (!nextLine)
|
|
2154
|
+
continue;
|
|
2155
|
+
const nextOwned = classifyBracketOwnedLine(nextLine.text);
|
|
2156
|
+
if (nextOwned.kind !== 'heading')
|
|
2157
|
+
continue;
|
|
2158
|
+
// #5599: bracket ids have one subphase level, so both nested and
|
|
2159
|
+
// sibling allocations append after this phase's existing children.
|
|
2160
|
+
if (nextOwned.id
|
|
2161
|
+
&& nextOwned.id.project === targetId.project
|
|
2162
|
+
&& nextOwned.id.milestone === targetId.milestone
|
|
2163
|
+
&& isPhaseTokenWithin(bracketArtifactToken(nextOwned.id), targetId.phase))
|
|
2164
|
+
continue;
|
|
2165
|
+
insertAt = nextHeading.offset;
|
|
2166
|
+
break;
|
|
2167
|
+
}
|
|
2168
|
+
for (const fenceStart of protectedFenceStarts) {
|
|
2169
|
+
if (fenceStart > heading.offset && fenceStart < insertAt)
|
|
2170
|
+
insertAt = fenceStart;
|
|
2171
|
+
}
|
|
2172
|
+
for (const historicalStart of historicalLineStarts) {
|
|
2173
|
+
if (historicalStart > heading.offset && historicalStart < insertAt)
|
|
2174
|
+
insertAt = historicalStart;
|
|
2175
|
+
}
|
|
2176
|
+
return { start: line.start, length: line.text.length + line.eol.length, insertAt };
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
return null;
|
|
2180
|
+
}
|
|
1580
2181
|
/**
|
|
1581
2182
|
* #5599: true when `token` (a heading/bullet phase number such as "13.1.2") is
|
|
1582
2183
|
* a STRICT descendant of `ancestor` ("13"). Compared segment-wise so "130" is
|
|
@@ -1592,6 +2193,34 @@ function isPhaseTokenWithin(token, ancestor) {
|
|
|
1592
2193
|
const canon = (segment) => segment.replace(/^0+(?=\d)/, '').toLowerCase();
|
|
1593
2194
|
return a.every((segment, i) => canon(segment) === canon(t[i]));
|
|
1594
2195
|
}
|
|
2196
|
+
/**
|
|
2197
|
+
* #5679: the `phase insert` error for a phase that exists in the ROADMAP but
|
|
2198
|
+
* outside the current milestone. Names the milestone that holds it (archive
|
|
2199
|
+
* aware, never the current one) and the current milestone, saying "STATE.md's"
|
|
2200
|
+
* only when STATE.md's `milestone:` is where that version came from.
|
|
2201
|
+
*/
|
|
2202
|
+
function otherMilestonePhaseMessage(rawContent, foundAt, cwd, afterPhase) {
|
|
2203
|
+
let stateVersion = null;
|
|
2204
|
+
try {
|
|
2205
|
+
const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'STATE.md'));
|
|
2206
|
+
const scalar = stateRaw === null ? null : readStateMilestoneScalar(stateRaw);
|
|
2207
|
+
if (scalar !== null)
|
|
2208
|
+
stateVersion = classifyMilestoneScalar(scalar).version;
|
|
2209
|
+
}
|
|
2210
|
+
catch { /* unreadable STATE.md: getMilestoneInfo's own fallback below decides */ }
|
|
2211
|
+
const derivedVersion = getMilestoneInfo(cwd).value?.version ?? null;
|
|
2212
|
+
const current = stateVersion ?? derivedVersion;
|
|
2213
|
+
const owner = milestoneVersionAtOffset(rawContent, foundAt);
|
|
2214
|
+
const ownerClause = owner && owner.toLowerCase() !== current?.toLowerCase()
|
|
2215
|
+
? `is in milestone ${owner}`
|
|
2216
|
+
: 'is outside the current milestone';
|
|
2217
|
+
let currentClause = 'the current milestone does not include it';
|
|
2218
|
+
if (stateVersion)
|
|
2219
|
+
currentClause = `STATE.md's current milestone is ${stateVersion}`;
|
|
2220
|
+
else if (derivedVersion)
|
|
2221
|
+
currentClause = `the current milestone is ${derivedVersion}`;
|
|
2222
|
+
return `Phase ${afterPhase} ${ownerClause}, but ${currentClause}. phase insert only targets phases in the current milestone.`;
|
|
2223
|
+
}
|
|
1595
2224
|
function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested') {
|
|
1596
2225
|
if (!afterPhase || !description) {
|
|
1597
2226
|
error('after-phase and description required for phase insert');
|
|
@@ -1602,23 +2231,55 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
|
|
|
1602
2231
|
error('ROADMAP.md not found');
|
|
1603
2232
|
}
|
|
1604
2233
|
const slug = generateSlugInternal(description) || '';
|
|
1605
|
-
const
|
|
2234
|
+
const insertConfig = loadConfig(cwd);
|
|
2235
|
+
const convention = resolvePhaseIdConvention(cwd);
|
|
2236
|
+
const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, insertConfig) : null;
|
|
2237
|
+
const { decimalPhase, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
|
|
1606
2238
|
const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
1607
2239
|
const content = extractCurrentMilestone(rawContent, cwd);
|
|
1608
|
-
|
|
2240
|
+
// #4304: canonicalize a bracket argument
|
|
2241
|
+
// through the SAME adapter `phase remove` uses
|
|
2242
|
+
// (canonicalizeBracketPhaseArgument) instead of the legacy
|
|
2243
|
+
// normalizePhaseName, which pads only the phase's FIRST segment ("1.1"
|
|
2244
|
+
// -> "01.1", never matching the bracket-canonical "01.01" heading) and
|
|
2245
|
+
// never accepted a qualified/display form ("CK.02-02", "[CK.02] 02") at
|
|
2246
|
+
// all. Every other convention keeps its untouched normalizePhaseName
|
|
2247
|
+
// behavior.
|
|
2248
|
+
let normalizedAfter;
|
|
2249
|
+
if (bracketContext) {
|
|
2250
|
+
normalizedAfter = canonicalizeBracketPhaseArgument(bracketContext, afterPhase, 'insert after').normalized;
|
|
2251
|
+
}
|
|
2252
|
+
else {
|
|
2253
|
+
normalizedAfter = normalizePhaseName(afterPhase);
|
|
2254
|
+
}
|
|
2255
|
+
// #4304: a bracket id supports at most one decimal level
|
|
2256
|
+
// (phase.subphase). Nesting one level deeper under an already-decimal
|
|
2257
|
+
// afterPhase would produce a three-level id no bracket function can
|
|
2258
|
+
// represent — bracketPhaseId/toDir would otherwise throw uncaught deep
|
|
2259
|
+
// inside directory/heading computation instead of refusing cleanly,
|
|
2260
|
+
// before any mutation. Sibling allocation is unaffected: it joins
|
|
2261
|
+
// afterPhase's PARENT level, which is always representable.
|
|
2262
|
+
if (bracketContext && allocation === 'nested' && normalizedAfter.includes('.')) {
|
|
2263
|
+
error(`Cannot insert a nested sub-phase under phase ${normalizedAfter}: bracket phase ids support at most one decimal level. Use --sibling, or insert after the parent phase instead.`);
|
|
2264
|
+
}
|
|
1609
2265
|
const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter);
|
|
1610
2266
|
const insertConvention = resolvePhaseIdConvention(cwd);
|
|
1611
|
-
|
|
1612
|
-
// is anchored `^...$` with no 'm' flag — designed to test ONE
|
|
1613
|
-
// already-tokenized heading LINE, not to `.test()` against multi-line
|
|
1614
|
-
// `content` directly (the design doc's "no anchoring change needed"
|
|
1615
|
-
// claim for this site does not hold once the anchor is accounted for —
|
|
1616
|
-
// same discrepancy as getRoadmapModeForPhase and phaseDisplayNameFromRoadmap
|
|
1617
|
-
// above/below). Routed through tokenizeHeadings, mirroring
|
|
1618
|
-
// searchPhaseInContent's own usage.
|
|
2267
|
+
const headingIntro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention);
|
|
1619
2268
|
const targetPattern = buildPhaseHeadingRegex(afterPhaseEscaped, insertConvention);
|
|
1620
|
-
const
|
|
1621
|
-
|
|
2269
|
+
const bracketSectionRanges = bracketContext
|
|
2270
|
+
? currentMilestoneRawRanges(rawContent, cwd, 'bracket')
|
|
2271
|
+
: null;
|
|
2272
|
+
const [targetPhase, targetSubphase] = normalizedAfter.split('.');
|
|
2273
|
+
const bracketTargetId = bracketContext
|
|
2274
|
+
? bracketPhaseId(bracketContext, targetPhase, targetSubphase)
|
|
2275
|
+
: null;
|
|
2276
|
+
const bracketHeading = bracketTargetId
|
|
2277
|
+
? activeBracketInsertHeading(rawContent, bracketTargetId, bracketSectionRanges)
|
|
2278
|
+
: null;
|
|
2279
|
+
const headingMatch = bracketContext
|
|
2280
|
+
? bracketHeading !== null
|
|
2281
|
+
: (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).some((h) => targetPattern.test(h.text));
|
|
2282
|
+
const bulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${headingIntro}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
|
|
1622
2283
|
// #5007 (Phase 6 / ADR-4910 §8): a pure "does ANY phase heading exist"
|
|
1623
2284
|
// boolean — neither shared owner exposes this shape (buildPhaseHeadingRegex
|
|
1624
2285
|
// is phase-N-specific; buildPhaseHeadingScanRegex always captures a phase
|
|
@@ -1629,19 +2290,45 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
|
|
|
1629
2290
|
// there is no anchor in this source.
|
|
1630
2291
|
const anyHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}\\d`, 'i');
|
|
1631
2292
|
const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
|
|
1632
|
-
|
|
2293
|
+
// #4304: bracket identities live in headings only, so a bracket ROADMAP never takes the legacy bullet-insertion branch — a bullet-only bracket ROADMAP falls through to the checklist-refusal path below instead.
|
|
2294
|
+
const liveCurrent = blankCodeAndComments(content);
|
|
2295
|
+
const isBulletStyle = !bracketContext && !headingMatch && bulletPattern.test(liveCurrent) && !roadmapHasHeadingPhases;
|
|
2296
|
+
if (bracketContext && !bracketHeading) {
|
|
2297
|
+
const searchedRanges = bracketSectionRanges?.details
|
|
2298
|
+
? 'primary and Phase Details ranges'
|
|
2299
|
+
: 'primary range';
|
|
2300
|
+
error(`Could not find live ${renderPhaseId(bracketTargetId)} heading in the active milestone window (${searchedRanges})`);
|
|
2301
|
+
}
|
|
1633
2302
|
if (!headingMatch && !isBulletStyle) {
|
|
1634
|
-
const checklistPattern =
|
|
1635
|
-
if (checklistPattern.test(
|
|
2303
|
+
const checklistPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${headingIntro}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
|
|
2304
|
+
if (checklistPattern.test(liveCurrent)) {
|
|
1636
2305
|
error(`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`);
|
|
1637
2306
|
}
|
|
2307
|
+
// #5679: this whole-file probe only chooses the error message; insert
|
|
2308
|
+
// stays current-milestone-only and never writes to another milestone.
|
|
2309
|
+
// Fenced blocks and HTML comments are masked (same length, offsets stay aligned) so example text never counts as a phase.
|
|
2310
|
+
const liveContent = blankCodeAndComments(rawContent);
|
|
2311
|
+
const headingOffset = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(liveContent).find((h) => targetPattern.test(h.text))?.offset;
|
|
2312
|
+
const checklistOffset = checklistPattern.exec(liveContent)?.index;
|
|
2313
|
+
const foundOffsets = [headingOffset, checklistOffset].filter((o) => o !== undefined);
|
|
2314
|
+
if (foundOffsets.length > 0) {
|
|
2315
|
+
error(otherMilestonePhaseMessage(rawContent, Math.min(...foundOffsets), cwd, afterPhase));
|
|
2316
|
+
}
|
|
1638
2317
|
error(`Phase ${afterPhase} not found in ROADMAP.md`);
|
|
1639
2318
|
}
|
|
1640
2319
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
1641
|
-
|
|
1642
|
-
|
|
2320
|
+
// #4304: reuse the SAME canonicalization computed above
|
|
2321
|
+
// (phaseToken on bracket, normalizePhaseName otherwise) rather than a
|
|
2322
|
+
// second, independent normalizePhaseName(afterPhase) call that would
|
|
2323
|
+
// silently disagree with it on bracket.
|
|
2324
|
+
const normalizedBase = normalizedAfter;
|
|
2325
|
+
const decimalSet = bracketContext
|
|
2326
|
+
? scanExistingBracketDecimalPhaseNumbers(phasesDir, content, normalizedBase, bracketContext)
|
|
2327
|
+
: scanExistingDecimalPhaseNumbers(phasesDir, rawContent, normalizedBase);
|
|
1643
2328
|
const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
|
|
1644
|
-
let
|
|
2329
|
+
let selectedBase = normalizedBase;
|
|
2330
|
+
let selectedNextDecimal = nextDecimal;
|
|
2331
|
+
let _decimalPhase = `${selectedBase}.${selectedNextDecimal}`;
|
|
1645
2332
|
// #4569: sibling allocation joins afterPhase's PARENT level instead of nesting
|
|
1646
2333
|
// one level deeper under afterPhase itself. A top-level phase (no existing
|
|
1647
2334
|
// decimal segment) has no sibling level to join; nested is the only sensible
|
|
@@ -1649,26 +2336,49 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
|
|
|
1649
2336
|
const lastDotIndex = normalizedBase.lastIndexOf('.');
|
|
1650
2337
|
if (allocation === 'sibling' && lastDotIndex !== -1) {
|
|
1651
2338
|
const parentBase = normalizedBase.slice(0, lastDotIndex);
|
|
1652
|
-
const siblingDecimalSet =
|
|
2339
|
+
const siblingDecimalSet = bracketContext
|
|
2340
|
+
? scanExistingBracketDecimalPhaseNumbers(phasesDir, content, parentBase, bracketContext)
|
|
2341
|
+
: scanExistingDecimalPhaseNumbers(phasesDir, rawContent, parentBase);
|
|
1653
2342
|
const siblingNextDecimal = siblingDecimalSet.size === 0 ? 1 : Math.max(...siblingDecimalSet) + 1;
|
|
2343
|
+
selectedBase = parentBase;
|
|
2344
|
+
selectedNextDecimal = siblingNextDecimal;
|
|
1654
2345
|
_decimalPhase = `${parentBase}.${siblingNextDecimal}`;
|
|
1655
2346
|
}
|
|
2347
|
+
const bracketId = bracketContext
|
|
2348
|
+
? bracketPhaseId(bracketContext, selectedBase, selectedNextDecimal)
|
|
2349
|
+
: null;
|
|
2350
|
+
if (bracketId)
|
|
2351
|
+
_decimalPhase = bracketArtifactToken(bracketId);
|
|
1656
2352
|
// #5599: the family whose subtree the new entry must land at the end of —
|
|
1657
2353
|
// the parent for a sibling allocation, the target itself for a nested one.
|
|
1658
2354
|
const subtreeRoot = allocation === 'sibling' && lastDotIndex !== -1
|
|
1659
2355
|
? normalizedBase.slice(0, lastDotIndex)
|
|
1660
2356
|
: normalizedBase;
|
|
1661
|
-
const
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
2357
|
+
const insertNaming = phaseDirectoryOptions(cwd, undefined, rawContent);
|
|
2358
|
+
// #4304: route the bracket directory-name allocation
|
|
2359
|
+
// through the SAME bracketDirNameOrRefuse wrapper `phase add`/`phase
|
|
2360
|
+
// add-batch` already use, instead of a raw `toDir` call —
|
|
2361
|
+
// an empty or all-digit slug now refuses cleanly through error(...)
|
|
2362
|
+
// before any mutation, matching their wording, instead of an uncaught
|
|
2363
|
+
// "toDir: slug sanitizes to empty" throw.
|
|
2364
|
+
let _dirName;
|
|
2365
|
+
if (bracketId) {
|
|
2366
|
+
_dirName = bracketDirNameOrRefuse(bracketId, slug, description);
|
|
2367
|
+
}
|
|
2368
|
+
else {
|
|
2369
|
+
// An inserted child inherits its parent's identity policy: legacy numeric
|
|
2370
|
+
// families must still renumber together when their parent moves.
|
|
2371
|
+
[{ dirName: _dirName }] = phaseLocatorMod.createPhaseDirectories(cwd, [{
|
|
2372
|
+
id: _decimalPhase, slug,
|
|
2373
|
+
recordIdentity: phaseIdMod.customPhaseQuery(afterPhase, insertNaming) !== null,
|
|
2374
|
+
}], insertNaming);
|
|
2375
|
+
}
|
|
1665
2376
|
const dirPath = node_path_1.default.join(planningDir(cwd), 'phases', _dirName);
|
|
1666
|
-
(0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
|
|
1667
|
-
(0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
|
|
1668
2377
|
let updatedContent;
|
|
2378
|
+
let roadmapEntry;
|
|
1669
2379
|
if (isBulletStyle) {
|
|
1670
|
-
const boldBulletPattern =
|
|
1671
|
-
const useBold = boldBulletPattern.test(
|
|
2380
|
+
const boldBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i', { bold: 'required' });
|
|
2381
|
+
const useBold = boldBulletPattern.test(liveCurrent);
|
|
1672
2382
|
const phaseLabel = useBold
|
|
1673
2383
|
? `**Phase ${_decimalPhase}: ${description}**`
|
|
1674
2384
|
: `Phase ${_decimalPhase}: ${description}`;
|
|
@@ -1680,6 +2390,8 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
|
|
|
1680
2390
|
// templating it via detectEol(rawContent) was inert dead code. '\n'
|
|
1681
2391
|
// matches what platformWriteSync enforces anyway.
|
|
1682
2392
|
const bulletEntry = `\n- [ ] ${phaseLabel}`;
|
|
2393
|
+
// A non-bold bullet is not an entry `roadmap analyze` reads (checklistEntrySrc needs `**`).
|
|
2394
|
+
roadmapEntry = new RegExp(checklistEntrySrc(insertConvention, false, true), 'm').test(bulletEntry) ? 'checklist' : 'none';
|
|
1683
2395
|
// #3413: was `[^\n]*`, which on CRLF content swallows the line's
|
|
1684
2396
|
// trailing \r into the match, shifting bulletLineEnd to land BETWEEN
|
|
1685
2397
|
// the \r and \n of the original CRLF pair — a pure splice-POSITION
|
|
@@ -1688,17 +2400,18 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
|
|
|
1688
2400
|
// .md targets regardless). Widening to [^\r\n]* stops the match at the
|
|
1689
2401
|
// true line-content boundary so bulletLineEnd lands cleanly before the
|
|
1690
2402
|
// terminator.
|
|
1691
|
-
const targetBulletPattern =
|
|
1692
|
-
|
|
2403
|
+
const targetBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*`, 'i');
|
|
2404
|
+
// Match against the masked text (same length, offsets aligned) so a fenced or commented example bullet is never the splice anchor; splice into the original text at the masked match index.
|
|
2405
|
+
const bulletMatchResult = blankCodeAndComments(rawContent).match(targetBulletPattern);
|
|
1693
2406
|
if (!bulletMatchResult) {
|
|
1694
2407
|
error(`Could not find Phase ${afterPhase} bullet line`);
|
|
1695
2408
|
}
|
|
1696
|
-
const bulletLineEnd =
|
|
2409
|
+
const bulletLineEnd = bulletMatchResult.index + bulletMatchResult[0].length;
|
|
1697
2410
|
const afterBullet = rawContent.slice(bulletLineEnd);
|
|
1698
2411
|
// #5599: land after the last bullet inside the allocation family's
|
|
1699
2412
|
// subtree, not before the first following bullet of any number (which is
|
|
1700
2413
|
// the target's own later decimal child when one exists).
|
|
1701
|
-
const bulletRe =
|
|
2414
|
+
const bulletRe = new RegExp(`\\r?\\n${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)()}Phase\\s+(?<tok>\\d[^\\s:*]*)[^\\r\\n]*`, 'gim');
|
|
1702
2415
|
let insertIdx = bulletLineEnd;
|
|
1703
2416
|
for (const m of afterBullet.matchAll(bulletRe)) {
|
|
1704
2417
|
if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
|
|
@@ -1711,7 +2424,13 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
|
|
|
1711
2424
|
rawContent.slice(0, insertIdx) + bulletEntry + rawContent.slice(insertIdx);
|
|
1712
2425
|
}
|
|
1713
2426
|
else {
|
|
1714
|
-
const
|
|
2427
|
+
const headingId = bracketId ? renderPhaseId(bracketId) : `Phase ${_decimalPhase}`;
|
|
2428
|
+
let dependsId = `Phase ${afterPhase}`;
|
|
2429
|
+
if (bracketContext) {
|
|
2430
|
+
const [dependsPhase, dependsSubphase] = normalizedBase.split('.');
|
|
2431
|
+
dependsId = renderPhaseId(bracketPhaseId(bracketContext, dependsPhase, dependsSubphase));
|
|
2432
|
+
}
|
|
2433
|
+
const phaseEntry = `\n### ${headingId}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** ${dependsId}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_decimalPhase} to break down)\n`;
|
|
1715
2434
|
// #5007 (Phase 6 / ADR-4910 §8): this site needs the WHOLE matched
|
|
1716
2435
|
// heading line plus its trailing newline (for splicing the new entry
|
|
1717
2436
|
// in immediately after it) — neither shared owner returns that shape
|
|
@@ -1721,51 +2440,79 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
|
|
|
1721
2440
|
// migration above (same describe block, same afterPhaseEscaped/
|
|
1722
2441
|
// insertConvention inputs).
|
|
1723
2442
|
const headerPattern = new RegExp(`(#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*\\n)`, 'i');
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
2443
|
+
// #4304: bracket insertion is located by `activeBracketInsertHeading`
|
|
2444
|
+
// above, which requires all three ownership facts together: the line is
|
|
2445
|
+
// inside the reader-owned active window, is not historical under the
|
|
2446
|
+
// shared remove/guard classifier, and parses to the active bracket
|
|
2447
|
+
// identity plus canonical target number through the exported read
|
|
2448
|
+
// grammar. This closes the raw-range hole where an archived `[CK.01]
|
|
2449
|
+
// 01` inside `[CK.02]`'s primary bytes won a bare-number search. The
|
|
2450
|
+
// legacy path below keeps its whole-document pattern byte-for-behavior.
|
|
2451
|
+
//
|
|
2452
|
+
// #4304: the pre-flight headingMatch check above ran
|
|
2453
|
+
// against extractCurrentMilestone's content, which (for a bracket
|
|
2454
|
+
// repo) merges the primary section with a later "(Phase Details)"
|
|
2455
|
+
// section carrying the same milestone identity — so it can pass even
|
|
2456
|
+
// when the target's own detail heading lives ONLY in that Phase
|
|
2457
|
+
// Details range, separated from primary by an unrelated sibling
|
|
2458
|
+
// milestone. Search the SAME two raw ranges bracket removal
|
|
2459
|
+
// discovers (primary, then details) instead of primary alone, so a
|
|
2460
|
+
// heading the pre-flight check can see is also found here.
|
|
2461
|
+
const headerSearchRanges = bracketSectionRanges
|
|
2462
|
+
? [
|
|
2463
|
+
bracketSectionRanges.primary,
|
|
2464
|
+
...(bracketSectionRanges.details ? [bracketSectionRanges.details] : []),
|
|
2465
|
+
]
|
|
2466
|
+
: [{ start: 0, end: rawContent.length }];
|
|
2467
|
+
let searchStart = bracketHeading?.start ?? headerSearchRanges[0].start;
|
|
2468
|
+
let searchEnd = bracketHeading?.insertAt ?? headerSearchRanges[0].end;
|
|
2469
|
+
let headerLength = bracketHeading?.length ?? 0;
|
|
2470
|
+
if (!bracketHeading) {
|
|
2471
|
+
let headerMatch = null;
|
|
2472
|
+
for (const range of headerSearchRanges) {
|
|
2473
|
+
const searchWindow = rawContent.slice(range.start, range.end);
|
|
2474
|
+
const match = searchWindow.match(headerPattern);
|
|
2475
|
+
if (match) {
|
|
2476
|
+
headerMatch = match;
|
|
2477
|
+
searchStart = range.start + match.index;
|
|
2478
|
+
searchEnd = range.end;
|
|
2479
|
+
headerLength = match[0].length;
|
|
2480
|
+
break;
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
if (!headerMatch) {
|
|
2484
|
+
error(`Could not find Phase ${afterPhase} header`);
|
|
2485
|
+
}
|
|
1727
2486
|
}
|
|
1728
|
-
const headerIdx =
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
// directly after the target section. Found while migrating the
|
|
1742
|
-
// neighboring sites, not deferred: this is the same defect class the
|
|
1743
|
-
// whole Phase 6 effort exists to drain, just an unmarked instance of it.
|
|
1744
|
-
// Composed with the SAME baseline/convention as headerPattern above so
|
|
1745
|
-
// target-discovery and next-boundary-discovery agree. The base
|
|
1746
|
-
// `#{2,4}\s+` (mandatory whitespace after the hashes, not `\s*`) is
|
|
1747
|
-
// preserved exactly as the prior literal had it — a pre-existing,
|
|
1748
|
-
// deliberately-unchanged asymmetry with the other sites in this file.
|
|
1749
|
-
const nextPhaseHeadingPrefix = `#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}`;
|
|
1750
|
-
// #5599: the boundary is the first following heading OUTSIDE the
|
|
1751
|
-
// allocation family's subtree. Taking the first heading of any number
|
|
1752
|
-
// spliced the new section above the target's own later decimal children
|
|
1753
|
-
// (13, 13.2, 13.1, 14); the allocator already guarantees the new number
|
|
1754
|
-
// exceeds every existing descendant, so "after the last descendant" is
|
|
1755
|
-
// the numerically correct position.
|
|
1756
|
-
const nextPhaseRe = new RegExp(`\\r?\\n${nextPhaseHeadingPrefix}(?<tok>\\d[^\\s:]*)`, 'gi');
|
|
1757
|
-
let insertIdx = rawContent.length;
|
|
1758
|
-
for (const m of afterHeader.matchAll(nextPhaseRe)) {
|
|
1759
|
-
if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
|
|
1760
|
-
insertIdx = headerIdx + headerMatch[0].length + m.index;
|
|
1761
|
-
break;
|
|
2487
|
+
const headerIdx = searchStart;
|
|
2488
|
+
let insertIdx = bracketHeading?.insertAt ?? searchEnd;
|
|
2489
|
+
if (!bracketHeading) {
|
|
2490
|
+
const afterHeader = rawContent.slice(headerIdx + headerLength, searchEnd);
|
|
2491
|
+
const nextPhaseHeadingPrefix = `#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}`;
|
|
2492
|
+
// #5599: legacy insertion lands after the allocation family's entire
|
|
2493
|
+
// subtree; bracket insertion keeps its fence-aware, milestone-scoped boundary.
|
|
2494
|
+
const nextPhaseRe = new RegExp(`\\r?\\n${nextPhaseHeadingPrefix}(?<tok>\\d[^\\s:]*)`, 'gi');
|
|
2495
|
+
for (const m of afterHeader.matchAll(nextPhaseRe)) {
|
|
2496
|
+
if (!isPhaseTokenWithin(m.groups.tok, subtreeRoot)) {
|
|
2497
|
+
insertIdx = headerIdx + headerLength + m.index;
|
|
2498
|
+
break;
|
|
2499
|
+
}
|
|
1762
2500
|
}
|
|
1763
2501
|
}
|
|
1764
|
-
updatedContent =
|
|
1765
|
-
|
|
2502
|
+
({ content: updatedContent, entry: roadmapEntry } = recordNewPhase(rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx), cwd, _decimalPhase, description, afterPhase, bracketId ? renderPhaseId(bracketId) : undefined));
|
|
2503
|
+
}
|
|
2504
|
+
// #4304: every validation above — the pre-flight heading
|
|
2505
|
+
// check, the header/bullet-line search, and computing `updatedContent`
|
|
2506
|
+
// — must succeed (or `error()` out, which never returns) before the new
|
|
2507
|
+
// phase's directory is created. A failing insert now leaves `.planning`
|
|
2508
|
+
// byte-identical.
|
|
2509
|
+
if (bracketId) {
|
|
2510
|
+
assertPhaseDirectoryDestinationSafe(node_path_1.default.dirname(dirPath), _dirName, 'create');
|
|
1766
2511
|
}
|
|
1767
|
-
(0, shell_command_projection_cjs_1.
|
|
1768
|
-
|
|
2512
|
+
(0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
|
|
2513
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
|
|
2514
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent, bracketContext ? { preserveFencedMarkdownStructure: true } : undefined);
|
|
2515
|
+
return { decimalPhase: _decimalPhase, dirName: _dirName, roadmapEntry };
|
|
1769
2516
|
});
|
|
1770
2517
|
const result = {
|
|
1771
2518
|
phase_number: decimalPhase,
|
|
@@ -1773,6 +2520,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
|
|
|
1773
2520
|
name: description,
|
|
1774
2521
|
slug,
|
|
1775
2522
|
directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
|
|
2523
|
+
roadmap_entry: roadmapEntry,
|
|
1776
2524
|
};
|
|
1777
2525
|
output(result, raw, decimalPhase);
|
|
1778
2526
|
// #3227: unconditional here because `platformWriteSync(roadmapPath, updatedContent)`
|
|
@@ -1781,12 +2529,28 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
|
|
|
1781
2529
|
// target bullet/header) exits via `error()`, which terminates the process.
|
|
1782
2530
|
publishStateContract(cwd);
|
|
1783
2531
|
}
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
const
|
|
2532
|
+
/** Refuse every ambiguous owner before deletion or the first sibling rename. */
|
|
2533
|
+
function assertUnambiguousCustomDirectories(dirs, context) {
|
|
2534
|
+
for (const dir of dirs) {
|
|
2535
|
+
const candidates = phaseIdMod.customDirectoryCandidates(dir, context);
|
|
2536
|
+
if (candidates.length > 1) {
|
|
2537
|
+
error(`Ambiguous custom phase directory ${dir}; candidates: ${candidates.join(', ')}. Refusing removal or rename without ${CUSTOM_PHASE_ID_FILE}. Write the owning ID into ${dir}/${CUSTOM_PHASE_ID_FILE} and retry`, ERROR_REASON.AMBIGUOUS_PHASE_OWNER, { candidates, marker_path: toPosixPath(node_path_1.default.join(dir, CUSTOM_PHASE_ID_FILE)) });
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
/**
|
|
2542
|
+
* Directories a numeric removal renumbers. The renamers and the `phase remove`
|
|
2543
|
+
* preflight both read this one selector, so the set the preflight refuses on is
|
|
2544
|
+
* the set the renamers would touch. A directory a record or a unique known id
|
|
2545
|
+
* owns is a stable custom identity and is never renumbered; an ambiguous
|
|
2546
|
+
* markerless one stays selected so the caller refuses before any write.
|
|
2547
|
+
*/
|
|
2548
|
+
function renameEligible(dirs, context) {
|
|
2549
|
+
return dirs.filter(dir => phaseIdMod.customDirectoryCandidates(dir, context).length !== 1);
|
|
2550
|
+
}
|
|
2551
|
+
function selectDecimalRenames(dirs, baseInt, removedDecimal, context) {
|
|
1787
2552
|
const decPattern = new RegExp(`^(0*${baseInt})\\.(\\d+)-(.+)$`);
|
|
1788
|
-
|
|
1789
|
-
const toRename = dirs
|
|
2553
|
+
return renameEligible(dirs, context)
|
|
1790
2554
|
.map((dir) => {
|
|
1791
2555
|
const m = dir.match(decPattern);
|
|
1792
2556
|
return m
|
|
@@ -1795,14 +2559,41 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
|
|
|
1795
2559
|
})
|
|
1796
2560
|
.filter((item) => item !== null && item.oldDecimal > removedDecimal)
|
|
1797
2561
|
.sort((a, b) => b.oldDecimal - a.oldDecimal);
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
const
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
2562
|
+
}
|
|
2563
|
+
function selectIntegerRenames(dirs, removedInt, context) {
|
|
2564
|
+
return renameEligible(dirs, context)
|
|
2565
|
+
.map((dir) => {
|
|
2566
|
+
const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
|
|
2567
|
+
if (!m)
|
|
2568
|
+
return null;
|
|
2569
|
+
const dirInt = parseInt(m[1], 10);
|
|
2570
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
2571
|
+
return dirInt > removedInt && !isSentinelPhaseId(dirInt)
|
|
2572
|
+
? {
|
|
2573
|
+
dir,
|
|
2574
|
+
oldInt: dirInt,
|
|
2575
|
+
letter: m[2] ? m[2].toUpperCase() : '',
|
|
2576
|
+
decimal: m[3] ? parseInt(m[3], 10) : null,
|
|
2577
|
+
slug: m[4],
|
|
2578
|
+
}
|
|
2579
|
+
: null;
|
|
2580
|
+
})
|
|
2581
|
+
.filter((item) => item !== null)
|
|
2582
|
+
.sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
|
|
2583
|
+
}
|
|
2584
|
+
function renameDecimalPhases(phasesDir, baseInt, removedDecimal, context) {
|
|
2585
|
+
const renamedDirs = [];
|
|
2586
|
+
const renamedFiles = [];
|
|
2587
|
+
const toRename = selectDecimalRenames(readSubdirectories(phasesDir, true), baseInt, removedDecimal, context);
|
|
2588
|
+
assertUnambiguousCustomDirectories(toRename.map(item => item.dir), context);
|
|
2589
|
+
for (const item of toRename) {
|
|
2590
|
+
const newDecimal = item.oldDecimal - 1;
|
|
2591
|
+
const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
|
|
2592
|
+
const newPhaseId = `${baseInt}.${newDecimal}`;
|
|
2593
|
+
const newDirName = `${item.prefix}.${newDecimal}-${item.slug}`;
|
|
2594
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
|
|
2595
|
+
renamedDirs.push({ from: item.dir, to: newDirName });
|
|
2596
|
+
for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
|
|
1806
2597
|
if (f.includes(oldPhaseId)) {
|
|
1807
2598
|
const newFileName = f.replace(oldPhaseId, newPhaseId);
|
|
1808
2599
|
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), node_path_1.default.join(phasesDir, newDirName, newFileName));
|
|
@@ -1835,30 +2626,12 @@ function findOrphanedDisplacementName(dir, fileName) {
|
|
|
1835
2626
|
}
|
|
1836
2627
|
return null;
|
|
1837
2628
|
}
|
|
1838
|
-
function renameIntegerPhases(phasesDir, removedInt) {
|
|
2629
|
+
function renameIntegerPhases(phasesDir, removedInt, context) {
|
|
1839
2630
|
const renamedDirs = [];
|
|
1840
2631
|
const renamedFiles = [];
|
|
1841
2632
|
const renamedFileCollisions = [];
|
|
1842
|
-
const
|
|
1843
|
-
|
|
1844
|
-
.map((dir) => {
|
|
1845
|
-
const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
|
|
1846
|
-
if (!m)
|
|
1847
|
-
return null;
|
|
1848
|
-
const dirInt = parseInt(m[1], 10);
|
|
1849
|
-
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1850
|
-
return dirInt > removedInt && !isSentinelPhaseId(dirInt)
|
|
1851
|
-
? {
|
|
1852
|
-
dir,
|
|
1853
|
-
oldInt: dirInt,
|
|
1854
|
-
letter: m[2] ? m[2].toUpperCase() : '',
|
|
1855
|
-
decimal: m[3] ? parseInt(m[3], 10) : null,
|
|
1856
|
-
slug: m[4],
|
|
1857
|
-
}
|
|
1858
|
-
: null;
|
|
1859
|
-
})
|
|
1860
|
-
.filter((item) => item !== null)
|
|
1861
|
-
.sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
|
|
2633
|
+
const toRename = selectIntegerRenames(readSubdirectories(phasesDir, true), removedInt, context);
|
|
2634
|
+
assertUnambiguousCustomDirectories(toRename.map(item => item.dir), context);
|
|
1862
2635
|
for (const item of toRename) {
|
|
1863
2636
|
const newInt = item.oldInt - 1;
|
|
1864
2637
|
const newPadded = String(newInt).padStart(2, '0');
|
|
@@ -2000,7 +2773,7 @@ function findDataRowLine(sectionText, dataRowIndex) {
|
|
|
2000
2773
|
// the caller report accurately, the same fix #3685 applied to
|
|
2001
2774
|
// `cmdPhaseComplete` and #2640/#2974 already applied to this same function's
|
|
2002
2775
|
// sibling `stateUpdated` flag a few lines below in `cmdPhaseRemove`.
|
|
2003
|
-
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
|
|
2776
|
+
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd, customTarget, stableIds) {
|
|
2004
2777
|
return withPlanningLock(cwd, () => {
|
|
2005
2778
|
const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
2006
2779
|
let content = originalContent;
|
|
@@ -2012,7 +2785,8 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
2012
2785
|
// makes the token padding-insensitive in both directions without widening
|
|
2013
2786
|
// to other ids: the token stays anchored between `Phase\s+`/line-start and
|
|
2014
2787
|
// `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
|
|
2015
|
-
const padTolerant = `0*${escaped}`;
|
|
2788
|
+
const padTolerant = customTarget ? escaped : `0*${escaped}`;
|
|
2789
|
+
const stableIdentity = (id) => stableIds.has((isCustomPhaseId(id) ? id : normalizePhaseName(id)).toLowerCase());
|
|
2016
2790
|
// SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
|
|
2017
2791
|
// detail section INCLUDING its own heading line. Migrated onto deleteSection
|
|
2018
2792
|
// (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
|
|
@@ -2026,7 +2800,11 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
2026
2800
|
// its tracking table.
|
|
2027
2801
|
const phaseHeadingRe = new RegExp(`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
|
|
2028
2802
|
content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
|
|
2029
|
-
content = content.replace(
|
|
2803
|
+
content = content.replace(
|
|
2804
|
+
// The entry prefix is the phase-entry grammar's (#5764): line-anchored, so a
|
|
2805
|
+
// mid-line mention of the phase is not deleted. `decorated` keeps the tolerance this
|
|
2806
|
+
// delete always had for an entry the readers skip (`- [x] ~~**Phase 2: X**~~`, an emoji).
|
|
2807
|
+
new RegExp(`\\n?${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ decorated: true })}Phase[ \\t]+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gim'), '');
|
|
2030
2808
|
// ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
|
|
2031
2809
|
// for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
|
|
2032
2810
|
// of updateTableCell). Scoped to the `## Progress` section — mirroring
|
|
@@ -2042,229 +2820,1636 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
2042
2820
|
// `|2|0/2|Planned|-|`) undeleted — its closing `|` follows the digit with
|
|
2043
2821
|
// no whitespace to match (#2245 audit) — and which was also unscoped to
|
|
2044
2822
|
// any particular table.
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
const
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
};
|
|
2823
|
+
// #5762: the slice is the Progress owner's (`editRoadmapProgress`), the write side of the
|
|
2824
|
+
// rule every reader uses; `headingless: 'skip'` keeps the pre-owner "no `## Progress`
|
|
2825
|
+
// heading, no row delete" (a row delete must not guess a table by column names alone).
|
|
2826
|
+
const matchRemovedProgressRow = (row) => {
|
|
2827
|
+
const firstCellRaw = (Object.values(row)[0] ?? '').trim();
|
|
2828
|
+
if (customTarget) {
|
|
2829
|
+
return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
|
2830
|
+
}
|
|
2831
|
+
if (isDecimal) {
|
|
2832
|
+
return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
|
2833
|
+
}
|
|
2834
|
+
const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
|
|
2835
|
+
if (!leadingMatch || leadingMatch[2])
|
|
2836
|
+
return false;
|
|
2837
|
+
return parseInt(leadingMatch[1], 10) === removedInt;
|
|
2838
|
+
};
|
|
2839
|
+
content = (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (progressSection) => {
|
|
2063
2840
|
const deleteResult = (0, markdown_table_cjs_1.deleteTableRow)(progressSection, matchRemovedProgressRow);
|
|
2064
|
-
|
|
2065
|
-
|
|
2841
|
+
return deleteResult.ok ? deleteResult.value : progressSection;
|
|
2842
|
+
}, { headingless: 'skip' });
|
|
2843
|
+
if (!isDecimal && !customTarget) {
|
|
2844
|
+
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
|
2845
|
+
// is re-emitted verbatim — a tagged later phase still gets renumbered.
|
|
2846
|
+
// #5007 (Phase 6 / ADR-4910 §8): composes phaseHeadingPrefixSrcFor
|
|
2847
|
+
// directly (needs prefix/number/suffix as three separate capture groups
|
|
2848
|
+
// for a verbatim in-place rewrite — neither shared owner returns that
|
|
2849
|
+
// shape). Two deliberate departures from the design doc's suggested
|
|
2850
|
+
// composition, both kept for zero behavior change on this
|
|
2851
|
+
// highest-blast-radius site (feeds phase remove's renumber path):
|
|
2852
|
+
// 1. The `#{2,4}\s*` heading marker is kept explicit and folded INTO
|
|
2853
|
+
// the prefix capture group — the design's snippet omitted it
|
|
2854
|
+
// entirely (phaseHeadingPrefixSrcFor never includes it; every
|
|
2855
|
+
// caller adds it, e.g. buildPhaseHeadingScanRegex), and the
|
|
2856
|
+
// original group 1 here captured the marker too.
|
|
2857
|
+
// 2. No convention argument is threaded (LABEL_ONLY with
|
|
2858
|
+
// convention=undefined), even though phaseHeadingPrefixSrcFor
|
|
2859
|
+
// COULD accept one — this keeps the composed source byte-identical
|
|
2860
|
+
// to the prior `Phase\s+` literal, a deliberate non-widening for
|
|
2861
|
+
// this specific site (unlike cmdPhaseInsert's targetPattern).
|
|
2862
|
+
// The number group widens from `\d+(?:\.\d+)?` to the full
|
|
2863
|
+
// PHASE_NUMBER_TOKEN_SOURCE (letter suffix + multi-segment decimals) —
|
|
2864
|
+
// this is SAFE, not a behavior change: decrementRoadmapPhaseToken's own
|
|
2865
|
+
// regex (`^(\d+)(\.\d+)?$`) rejects anything wider and returns the
|
|
2866
|
+
// token unchanged, so a heading this widening newly matches (e.g.
|
|
2867
|
+
// "Phase 12A:") is written back byte-for-byte unchanged, identical to
|
|
2868
|
+
// the pre-migration outcome of the whole regex simply not matching it.
|
|
2869
|
+
content = content.replace(new RegExp(`(#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY)})(${PHASE_NUMBER_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE}\\s*:)`, 'gi'), (_match, prefix, num, suffix) => `${prefix}${stableIdentity(num) ? num : decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
|
|
2870
|
+
content = content.replace(
|
|
2871
|
+
// `decorated`, as the delete above: a struck-through or emoji entry is renumbered too,
|
|
2872
|
+
// or two entries would share one number after the remove.
|
|
2873
|
+
new RegExp(`(${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ decorated: true })}Phase[ \\t]+)(\\d+)([ \\t]*:|[ \\t]+|(?=\\r?$))`, 'gim'), (_match, prefix, num, suffix) => `${prefix}${stableIdentity(num) ? num : decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
|
|
2874
|
+
// ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
|
|
2875
|
+
// updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
|
|
2876
|
+
// directly above). The prior whole-document regex
|
|
2877
|
+
// `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
|
|
2878
|
+
// file — including a same-shaped cell in an UNRELATED, earlier table
|
|
2879
|
+
// (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
|
|
2880
|
+
// preceding `## Progress`; #2245-class scoping defect, same family as
|
|
2881
|
+
// the row-delete fix above). Scoped here to the `## Progress` section
|
|
2882
|
+
// only, mirroring that same section-slice-then-splice-back pattern.
|
|
2883
|
+
//
|
|
2884
|
+
// Loops because updateTableCell only rewrites the FIRST matching row
|
|
2885
|
+
// per call. `processedOrdinalRows` tracks by row INDEX (stable across
|
|
2886
|
+
// iterations — this only edits cell content, it never inserts/deletes
|
|
2887
|
+
// rows) so an already-decremented row's new value — which may still
|
|
2888
|
+
// numerically exceed `removedInt` — is never re-selected and
|
|
2889
|
+
// decremented a second time (matching on the row's CURRENT value alone,
|
|
2890
|
+
// without this guard, would keep re-firing on each pass).
|
|
2891
|
+
//
|
|
2892
|
+
// `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
|
|
2893
|
+
// required: a decimal sub-phase ordinal like `2.5` (no whitespace
|
|
2894
|
+
// between the dot and the next character) never matches it, so it is
|
|
2895
|
+
// left untouched — identical decimal-safety to the prior behaviour.
|
|
2896
|
+
//
|
|
2897
|
+
// updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
|
|
2898
|
+
// only, so the row's original leading/trailing alignment padding is
|
|
2899
|
+
// recovered by a narrow, anchored lookup within that row's OWN raw
|
|
2900
|
+
// line — addressed by ROW INDEX (`matchedRowIndex`, via
|
|
2901
|
+
// `findDataRowLine`), not by searching the whole section for content
|
|
2902
|
+
// matching the trimmed value (F8 #2245 review: two rows with identical
|
|
2903
|
+
// trimmed Phase text, or a row whose already-rewritten new value
|
|
2904
|
+
// coincides with another row's pre-edit text, would otherwise resolve
|
|
2905
|
+
// to the WRONG row's padding — the first/leftmost content match found).
|
|
2906
|
+
// The lookup searches for `escapeCell(current)` (F3 #2245 review: the
|
|
2907
|
+
// ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
|
|
2908
|
+
// escaped form, so searching for the unescaped `current` would
|
|
2909
|
+
// silently fail to find an escaped-pipe cell's own line — preserving
|
|
2910
|
+
// every other byte of the row (ADR-2143 §7 byte-parity) while only the
|
|
2911
|
+
// digits actually change.
|
|
2912
|
+
// #5762: the slice is the Progress owner's; no `## Progress` heading means no renumber.
|
|
2913
|
+
content = (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (progressSection) => {
|
|
2914
|
+
let ordinalSection = progressSection;
|
|
2915
|
+
const phaseCellShapeRe = /^(\d+)(\.\s)/;
|
|
2916
|
+
const processedOrdinalRows = new Set();
|
|
2917
|
+
let matchedRowIndex = null;
|
|
2918
|
+
for (;;) {
|
|
2919
|
+
matchedRowIndex = null;
|
|
2920
|
+
const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
|
|
2921
|
+
if (processedOrdinalRows.has(index))
|
|
2922
|
+
return false;
|
|
2923
|
+
const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
|
|
2924
|
+
if (!m || stableIdentity(m[1]))
|
|
2925
|
+
return false;
|
|
2926
|
+
const num = parseInt(m[1], 10);
|
|
2927
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
2928
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
2929
|
+
return false;
|
|
2930
|
+
processedOrdinalRows.add(index);
|
|
2931
|
+
matchedRowIndex = index;
|
|
2932
|
+
return true;
|
|
2933
|
+
}, 'Phase', (current) => {
|
|
2934
|
+
const m = phaseCellShapeRe.exec(current);
|
|
2935
|
+
if (!m)
|
|
2936
|
+
return current;
|
|
2937
|
+
const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
|
|
2938
|
+
const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
|
|
2939
|
+
const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
|
|
2940
|
+
const padMatch = targetLine
|
|
2941
|
+
? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
2942
|
+
: null;
|
|
2943
|
+
const leadPad = padMatch ? padMatch[1] : ' ';
|
|
2944
|
+
const trailPad = padMatch ? padMatch[2] : ' ';
|
|
2945
|
+
return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
|
|
2946
|
+
});
|
|
2947
|
+
if (!cellResult.ok)
|
|
2948
|
+
break;
|
|
2949
|
+
ordinalSection = cellResult.value;
|
|
2950
|
+
}
|
|
2951
|
+
return ordinalSection;
|
|
2952
|
+
}, { headingless: 'skip' });
|
|
2953
|
+
content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${stableIdentity(phaseNum) ? phaseNum : decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
|
|
2954
|
+
// #5007 (Phase 6 / ADR-4910): migrated the "Depends on" bold-field
|
|
2955
|
+
// decrement off two hand-rolled regexes — one per bold-placement
|
|
2956
|
+
// variant, `**Depends on**: Phase N` and `**Depends on:** Phase N` —
|
|
2957
|
+
// onto the PlanningDoc `boldField` read/write seam (`writePlansField`
|
|
2958
|
+
// above is the established precedent for this migration shape).
|
|
2959
|
+
// `BOLD_FIELD_RE` (planning-document.cts) recognizes BOTH placements
|
|
2960
|
+
// as the SAME field label ("Depends on") — `parseBoldFieldLine` strips
|
|
2961
|
+
// the token down to its inner text regardless of which side of `**`
|
|
2962
|
+
// the colon lands on — so this single loop naturally replaces both
|
|
2963
|
+
// regexes with one call site; no variant-specific branching remains.
|
|
2964
|
+
// Unlike `writePlansField`'s single `findField` lookup (scoped to one
|
|
2965
|
+
// already-isolated phase section), a whole ROADMAP.md can carry a
|
|
2966
|
+
// "Depends on" field in EVERY phase's detail section, so this walks
|
|
2967
|
+
// every `boldField` node in the whole parsed document whose label is
|
|
2968
|
+
// "Depends on" and rewrites each one independently — a
|
|
2969
|
+
// representability refusal or absent match on one field leaves that
|
|
2970
|
+
// field untouched and never blocks any other field, mirroring the
|
|
2971
|
+
// prior regexes' per-occurrence independence. A parse failure or an
|
|
2972
|
+
// unreadable-nodes refusal on `serialize` leaves `content` byte-for-
|
|
2973
|
+
// byte untouched, the same silent no-op the prior regexes had for any
|
|
2974
|
+
// input they didn't match (neither ever threw or warned).
|
|
2975
|
+
const dependsOnParsed = (0, planning_document_cjs_1.parsePlanningDoc)(content, 'ROADMAP.md');
|
|
2976
|
+
if (dependsOnParsed.ok) {
|
|
2977
|
+
let dependsOnDoc = dependsOnParsed.value;
|
|
2978
|
+
const dependsOnFieldIds = dependsOnDoc.nodes
|
|
2979
|
+
.filter((n) => n.kind === 'boldField' && n.label === 'Depends on')
|
|
2980
|
+
.map((n) => n.id);
|
|
2981
|
+
for (const fieldId of dependsOnFieldIds) {
|
|
2982
|
+
const currentRead = (0, planning_document_cjs_1.readNode)(dependsOnDoc, fieldId);
|
|
2983
|
+
if (!currentRead.ok)
|
|
2984
|
+
continue;
|
|
2985
|
+
const depMatch = /^(Phase\s+)(\d+(?:\.\d+)?)\b/i.exec(currentRead.value);
|
|
2986
|
+
if (!depMatch)
|
|
2987
|
+
continue;
|
|
2988
|
+
const [wholeMatch, prefix, num] = depMatch;
|
|
2989
|
+
const newValue = `${prefix}${stableIdentity(num) ? num : decrementRoadmapPhaseToken(num, removedInt)}` + currentRead.value.slice(wholeMatch.length);
|
|
2990
|
+
if (newValue === currentRead.value)
|
|
2991
|
+
continue;
|
|
2992
|
+
const staged = (0, planning_document_cjs_1.setFieldValue)(dependsOnDoc, fieldId, newValue);
|
|
2993
|
+
if (staged.ok)
|
|
2994
|
+
dependsOnDoc = staged.value;
|
|
2995
|
+
}
|
|
2996
|
+
const dependsOnOut = (0, planning_document_cjs_1.serialize)(dependsOnDoc);
|
|
2997
|
+
if (dependsOnOut.ok)
|
|
2998
|
+
content = dependsOnOut.value;
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
|
|
3002
|
+
// #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
|
|
3003
|
+
// persists), not the raw pre-normalize `content` string, against the raw
|
|
3004
|
+
// pre-mutation `originalContent` read above — a raw `!==` here reports a
|
|
3005
|
+
// false `true` whenever this transform's regenerated output takes a
|
|
3006
|
+
// different-but-equivalent shape than the already-normalized on-disk
|
|
3007
|
+
// original (same normalization-order artifact #3685 fixed at
|
|
3008
|
+
// cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
|
|
3009
|
+
return (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content);
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
/**
|
|
3013
|
+
* #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
|
|
3014
|
+
* the leading frontmatter block's closing `---` fence — so a body field never
|
|
3015
|
+
* lands before the opening fence. The former whole-content prepend
|
|
3016
|
+
* (`field + content`) put the line ABOVE the opening `---`, and
|
|
3017
|
+
* syncStateFrontmatter then treated the scrambled fence structure as TWO
|
|
3018
|
+
* frontmatter blocks, rebuilding a derived one on top of the original
|
|
3019
|
+
* (milestone_name from a ROADMAP heading, total_phases counting the removed
|
|
3020
|
+
* phase, a stray 'Total Phases: 0' between fences). A file with no leading
|
|
3021
|
+
* frontmatter is all body: the field goes to content start, preserving the
|
|
3022
|
+
* former behavior for that shape.
|
|
3023
|
+
*
|
|
3024
|
+
* The block is the one `locateFrontmatterFence` finds (the one fence owner), so this writer
|
|
3025
|
+
* and every STATE.md reader agree on where the body starts. The blank line and the field go
|
|
3026
|
+
* right after the closing fence line, ended by that line's own line ending — so a CRLF file
|
|
3027
|
+
* gains CRLF lines only (#3572 review: a blanket re-join on '\r\n' doubled every carriage
|
|
3028
|
+
* return; joining the new lines on a bare '\n' mixed line endings).
|
|
3029
|
+
*/
|
|
3030
|
+
function insertStateBodyFieldAtTop(content, fieldLine) {
|
|
3031
|
+
const fence = (0, frontmatter_fence_cjs_1.locateFrontmatterFence)(content);
|
|
3032
|
+
if (fence?.closed) {
|
|
3033
|
+
const nl = content[fence.closingFenceEnd] === '\r' ? '\r\n' : '\n';
|
|
3034
|
+
return `${content.slice(0, fence.closingFenceEnd)}${nl}${nl}${fieldLine}${content.slice(fence.closingFenceEnd)}`;
|
|
3035
|
+
}
|
|
3036
|
+
// All body: the field is the new first line, ended like the document's first line.
|
|
3037
|
+
const firstNewline = content.indexOf('\n');
|
|
3038
|
+
return fieldLine + (firstNewline > 0 && content[firstNewline - 1] === '\r' ? '\r\n' : '\n') + content;
|
|
3039
|
+
}
|
|
3040
|
+
function renameBracketArtifactFiles(phaseDir, oldId, newId, renamedFiles, renamedFileCollisions) {
|
|
3041
|
+
const oldToken = bracketArtifactToken(oldId);
|
|
3042
|
+
const newToken = bracketArtifactToken(newId);
|
|
3043
|
+
for (const file of node_fs_1.default.readdirSync(phaseDir)) {
|
|
3044
|
+
if (!file.startsWith(oldToken) || !/^(?:[-.]|$)/.test(file.slice(oldToken.length)))
|
|
3045
|
+
continue;
|
|
3046
|
+
const newFileName = newToken + file.slice(oldToken.length);
|
|
3047
|
+
const source = node_path_1.default.join(phaseDir, file);
|
|
3048
|
+
const destination = node_path_1.default.join(phaseDir, newFileName);
|
|
3049
|
+
if (node_fs_1.default.existsSync(destination)) {
|
|
3050
|
+
const displaced = findOrphanedDisplacementName(phaseDir, newFileName);
|
|
3051
|
+
if (!displaced) {
|
|
3052
|
+
renamedFileCollisions.push({ from: file, to: newFileName, displaced_to: null });
|
|
3053
|
+
continue;
|
|
2066
3054
|
}
|
|
3055
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(destination, node_path_1.default.join(phaseDir, displaced));
|
|
3056
|
+
renamedFileCollisions.push({ from: file, to: newFileName, displaced_to: displaced });
|
|
3057
|
+
}
|
|
3058
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(source, destination);
|
|
3059
|
+
renamedFiles.push({ from: file, to: newFileName });
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
/**
|
|
3063
|
+
* #4304: the ONE identity mapping shared by the disk rename
|
|
3064
|
+
* (renameBracketPhases, below) and the ROADMAP rewrite
|
|
3065
|
+
* (updateRoadmapAfterBracketPhaseRemoval) — disk and ROADMAP can never
|
|
3066
|
+
* disagree because both consume this same computation instead of each
|
|
3067
|
+
* deriving its own. Identities are collected from the same directory scan
|
|
3068
|
+
* renameBracketPhases uses to find rename candidates, unioned with every
|
|
3069
|
+
* heading/checklist/progress line inside the active milestone's own ranges
|
|
3070
|
+
* (primary + Phase Details): a phase can exist in ROADMAP with no directory
|
|
3071
|
+
* yet, or on disk with no matching ROADMAP line, and either source alone
|
|
3072
|
+
* can miss a decimal sub-phase identity. Both sources are canonical bracket
|
|
3073
|
+
* spellings only; cmdPhaseRemove refuses before any mutation while a legacy
|
|
3074
|
+
* spelling resolves to an identity this mapping deletes, moves, or lands on
|
|
3075
|
+
* (legacySpellingsEntangledWithBracketRemoval). Integer removal maps every phase
|
|
3076
|
+
* N > removed to N-1 (a sub-phase's own number is unchanged); sub-phase
|
|
3077
|
+
* removal maps every sub-phase S > removed within the target phase to S-1.
|
|
3078
|
+
* The result is sorted ascending by phase, then subphase (a bare phase before
|
|
3079
|
+
* its own sub-phases), so applying every entry to the same line in sequence
|
|
3080
|
+
* never re-matches a value an earlier entry just wrote: each entry's new
|
|
3081
|
+
* value is one below its old value, and every entry that could still hold
|
|
3082
|
+
* that value as its old value has already run. Bare and sub-phase entries
|
|
3083
|
+
* are independent of each other because the rewriters never match a bare
|
|
3084
|
+
* id inside a longer sub-phase id (`03` inside `03.01`).
|
|
3085
|
+
*/
|
|
3086
|
+
function computeBracketRenumberMapping(phasesDir, roadmapContent, ranges, context, removedInt, removedSubphase) {
|
|
3087
|
+
const identities = new Map();
|
|
3088
|
+
const record = (phase, subphase) => {
|
|
3089
|
+
identities.set(`${phase}.${subphase ?? ''}`, { phase, subphase });
|
|
3090
|
+
};
|
|
3091
|
+
for (const { id } of bracketIdsInContext(phasesDir, context)) {
|
|
3092
|
+
record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
|
|
3093
|
+
}
|
|
3094
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
|
|
3095
|
+
const fencedLineNumbers = fencedRoadmapLineNumbers(roadmapContent);
|
|
3096
|
+
for (const line of splitRoadmapLineRecords(roadmapContent)) {
|
|
3097
|
+
if (!lineStartsInActiveMilestone(line.start, ranges))
|
|
3098
|
+
continue;
|
|
3099
|
+
if (historicalLineStarts.has(line.start) || fencedLineNumbers.has(line.lineNumber))
|
|
3100
|
+
continue;
|
|
3101
|
+
const { id } = classifyBracketOwnedLine(line.text);
|
|
3102
|
+
if (!id || id.project !== context.project || id.milestone !== context.milestone)
|
|
3103
|
+
continue;
|
|
3104
|
+
record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
|
|
3105
|
+
}
|
|
3106
|
+
const filtered = [...identities.values()].filter(({ phase, subphase }) => {
|
|
3107
|
+
if (removedSubphase !== undefined) {
|
|
3108
|
+
return phase === removedInt && subphase !== undefined && subphase > removedSubphase;
|
|
3109
|
+
}
|
|
3110
|
+
return phase > removedInt && !isSentinelPhaseId(phase);
|
|
3111
|
+
});
|
|
3112
|
+
filtered.sort((a, b) => (a.phase - b.phase) || ((a.subphase ?? 0) - (b.subphase ?? 0)));
|
|
3113
|
+
return filtered.map(({ phase, subphase }) => ({
|
|
3114
|
+
oldId: bracketPhaseId(context, phase, subphase),
|
|
3115
|
+
newId: removedSubphase !== undefined
|
|
3116
|
+
? bracketPhaseId(context, phase, subphase - 1)
|
|
3117
|
+
: bracketPhaseId(context, phase - 1, subphase),
|
|
3118
|
+
}));
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* CommonMark fence exclusion shared by bracket removal's inventory and
|
|
3122
|
+
* pre-mutation ownership guard. A fenced heading/checklist is documentation,
|
|
3123
|
+
* never evidence about the live phase tree. Unclosed fences own through EOF.
|
|
3124
|
+
*/
|
|
3125
|
+
function fencedRoadmapLineNumbers(content) {
|
|
3126
|
+
const fenced = new Set();
|
|
3127
|
+
const rawLines = content.split('\n');
|
|
3128
|
+
for (const block of (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(rawLines)) {
|
|
3129
|
+
const lastIndex = block.closeLineIdx === -1 ? rawLines.length - 1 : block.closeLineIdx;
|
|
3130
|
+
for (let index = block.openLineIdx; index <= lastIndex; index++) {
|
|
3131
|
+
fenced.add(index + 1);
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
return fenced;
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* #4304: single fence-aware details-container tracker for bracket writers.
|
|
3138
|
+
* Every real tag carries its nesting depth before and after the tag, and every
|
|
3139
|
+
* matched block spans its own opening through the corresponding close. A
|
|
3140
|
+
* nested close therefore cannot terminate its enclosing block. Unclosed
|
|
3141
|
+
* blocks still contribute depth to the tag stream so deletion stays bounded.
|
|
3142
|
+
*/
|
|
3143
|
+
function trackRoadmapDetails(content) {
|
|
3144
|
+
const lines = splitRoadmapLineRecords(content);
|
|
3145
|
+
const fencedLineNumbers = fencedRoadmapLineNumbers(content);
|
|
3146
|
+
const tags = [];
|
|
3147
|
+
const blocks = [];
|
|
3148
|
+
const stack = [];
|
|
3149
|
+
const detailsTagRe = /<\/?details\b[^>]*>/gi;
|
|
3150
|
+
let lineIndex = 0;
|
|
3151
|
+
let match;
|
|
3152
|
+
while ((match = detailsTagRe.exec(content)) !== null) {
|
|
3153
|
+
while (lineIndex + 1 < lines.length && lines[lineIndex + 1].start <= match.index)
|
|
3154
|
+
lineIndex += 1;
|
|
3155
|
+
const line = lines[lineIndex];
|
|
3156
|
+
if (!line || fencedLineNumbers.has(line.lineNumber))
|
|
3157
|
+
continue;
|
|
3158
|
+
const closing = /^<\/details\b/i.test(match[0]);
|
|
3159
|
+
const depthBefore = stack.length;
|
|
3160
|
+
let opening;
|
|
3161
|
+
if (closing)
|
|
3162
|
+
opening = stack.pop();
|
|
3163
|
+
const tag = {
|
|
3164
|
+
offset: match.index,
|
|
3165
|
+
end: match.index + match[0].length,
|
|
3166
|
+
lineStart: line.start,
|
|
3167
|
+
closing,
|
|
3168
|
+
depthBefore,
|
|
3169
|
+
depthAfter: stack.length + (closing ? 0 : 1),
|
|
3170
|
+
};
|
|
3171
|
+
if (!closing)
|
|
3172
|
+
stack.push(tag);
|
|
3173
|
+
tags.push(tag);
|
|
3174
|
+
if (opening) {
|
|
3175
|
+
blocks.push({
|
|
3176
|
+
start: opening.offset,
|
|
3177
|
+
end: tag.end,
|
|
3178
|
+
startLine: opening.lineStart,
|
|
3179
|
+
endLine: tag.lineStart,
|
|
3180
|
+
text: content.slice(opening.offset, tag.end),
|
|
3181
|
+
});
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
return { tags, blocks };
|
|
3185
|
+
}
|
|
3186
|
+
/**
|
|
3187
|
+
* #4304: does the bracket phase about to be removed (an
|
|
3188
|
+
* INTEGER phase, never a subphase itself — `phase remove NN.SS` is a
|
|
3189
|
+
* different, unaffected path) still have its own sub-phases?
|
|
3190
|
+
* `computeBracketRenumberMapping`'s own filter only ever maps
|
|
3191
|
+
* `phase > removedInt` — the removed phase's OWN sub-phases (`phase ===
|
|
3192
|
+
* removedInt`) are never mapped, deleted, or reported — so removing an
|
|
3193
|
+
* integer phase that still has sub-phases left them orphaned on disk and
|
|
3194
|
+
* in ROADMAP while the NEXT phase's sub-phases renumbered onto the SAME
|
|
3195
|
+
* identities, manufacturing duplicates. Scans the two sources
|
|
3196
|
+
* computeBracketRenumberMapping unions (the canonical directory scan, and
|
|
3197
|
+
* every heading/checklist/progress line outside CommonMark fences and inside
|
|
3198
|
+
* the active milestone's own ranges), plus the legacy-spelled child
|
|
3199
|
+
* directories the active reader resolves, which the mapping never
|
|
3200
|
+
* inventories. A child spelled either way refuses here; a legacy spelling of
|
|
3201
|
+
* any other identity the removal moves is refused by
|
|
3202
|
+
* legacySpellingsEntangledWithBracketRemoval.
|
|
3203
|
+
*/
|
|
3204
|
+
function bracketPhaseOwnSubphases(phasesDir, roadmapContent, ranges, context, targetInt) {
|
|
3205
|
+
const bySubphase = new Map();
|
|
3206
|
+
const record = (phase, subphase) => {
|
|
3207
|
+
if (phase === targetInt && subphase !== undefined) {
|
|
3208
|
+
bySubphase.set(subphase, bracketPhaseId(context, phase, subphase));
|
|
3209
|
+
}
|
|
3210
|
+
};
|
|
3211
|
+
for (const { id } of bracketIdsInContext(phasesDir, context)) {
|
|
3212
|
+
record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
|
|
3213
|
+
}
|
|
3214
|
+
// #4304: bracketIdsInContext skips names outside the
|
|
3215
|
+
// canonical bracket grammar, but the reader also resolves migration-window
|
|
3216
|
+
// legacy children such as `CK-02.1-child`, and the allocator already
|
|
3217
|
+
// reserves them (readerResolvableBracketDirectoryPhaseNumber). Inventory
|
|
3218
|
+
// that same universe the same way: take each directory's own key and keep
|
|
3219
|
+
// a sub-phase of the target only when the active bracket reader resolves
|
|
3220
|
+
// that directory for that key. `record` dedupes canonical children.
|
|
3221
|
+
for (const dir of readSubdirectories(phasesDir, true)) {
|
|
3222
|
+
const [phasePart, subphasePart, ...rest] = phaseKeyFromDir(dir, 'bracket').split('.');
|
|
3223
|
+
if (rest.length > 0 || subphasePart === undefined)
|
|
3224
|
+
continue;
|
|
3225
|
+
if (!/^\d+$/.test(phasePart) || !/^\d+$/.test(subphasePart))
|
|
3226
|
+
continue;
|
|
3227
|
+
if (Number(phasePart) !== targetInt)
|
|
3228
|
+
continue;
|
|
3229
|
+
const key = `${phasePart}.${subphasePart}`;
|
|
3230
|
+
if (!matchPhaseDirs([dir], key, 'bracket', context).matches.includes(dir))
|
|
3231
|
+
continue;
|
|
3232
|
+
record(targetInt, Number(subphasePart));
|
|
3233
|
+
}
|
|
3234
|
+
const roadmapLines = splitRoadmapLineRecords(roadmapContent);
|
|
3235
|
+
const fencedLineNumbers = fencedRoadmapLineNumbers(roadmapContent);
|
|
3236
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
|
|
3237
|
+
for (const line of roadmapLines) {
|
|
3238
|
+
if (!lineStartsInActiveMilestone(line.start, ranges))
|
|
3239
|
+
continue;
|
|
3240
|
+
if (fencedLineNumbers.has(line.lineNumber) || historicalLineStarts.has(line.start))
|
|
3241
|
+
continue;
|
|
3242
|
+
const { id } = classifyBracketOwnedLine(line.text);
|
|
3243
|
+
if (!id || id.project !== context.project || id.milestone !== context.milestone)
|
|
3244
|
+
continue;
|
|
3245
|
+
record(Number(id.phase), id.subphase === undefined ? undefined : Number(id.subphase));
|
|
3246
|
+
}
|
|
3247
|
+
return [...bySubphase.keys()].sort((a, b) => a - b).map((s) => bySubphase.get(s));
|
|
3248
|
+
}
|
|
3249
|
+
/**
|
|
3250
|
+
* #4304: identify ROADMAP lines owned by historical milestone sections.
|
|
3251
|
+
* A `<details>` block is historical only when the roadmap reader's own
|
|
3252
|
+
* closed-summary classifier says it is; an active collapsed phase list stays
|
|
3253
|
+
* live. Outside details, a reader-recognized CLOSED/ARCHIVED/SHIPPED milestone
|
|
3254
|
+
* heading owns the section through the next reader-recognized milestone
|
|
3255
|
+
* heading at the same or shallower level. Headings come from tokenizeHeadings,
|
|
3256
|
+
* so fenced examples are neither historical markers nor section resets.
|
|
3257
|
+
* Recognition is imported from the window locator's milestone-vs-phase
|
|
3258
|
+
* grammar before the marker predicate is applied, so an ordinary phase title
|
|
3259
|
+
* containing FAILED or ✅ never opens or resets a historical section.
|
|
3260
|
+
*
|
|
3261
|
+
* Details membership comes from the shared fence-aware depth tracker, so every
|
|
3262
|
+
* line remains archived while any enclosing details block is a closed
|
|
3263
|
+
* milestone archive. This classifier is consumed by both the active-window
|
|
3264
|
+
* safety guard and bracket removal's rewrite pass. Historical lines are
|
|
3265
|
+
* evidence of neither an active-window mismatch nor a reference that removal
|
|
3266
|
+
* may rewrite or delete.
|
|
3267
|
+
*/
|
|
3268
|
+
function archivedOrClosedMilestoneLineStarts(content) {
|
|
3269
|
+
const historical = new Set();
|
|
3270
|
+
const headingsByOffset = new Map((0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).map((heading) => [heading.offset, heading]));
|
|
3271
|
+
const fencedLineNumbers = fencedRoadmapLineNumbers(content);
|
|
3272
|
+
const lines = splitRoadmapLineRecords(content);
|
|
3273
|
+
const details = trackRoadmapDetails(content);
|
|
3274
|
+
const historicalDetailsLineStarts = new Set();
|
|
3275
|
+
for (const block of details.blocks) {
|
|
3276
|
+
if (!isClosedMilestoneDetails(block.text))
|
|
3277
|
+
continue;
|
|
3278
|
+
for (const line of lines) {
|
|
3279
|
+
if (line.start >= block.startLine && line.start <= block.endLine) {
|
|
3280
|
+
historicalDetailsLineStarts.add(line.start);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
const detailsTagsByLine = new Map();
|
|
3285
|
+
for (const tag of details.tags) {
|
|
3286
|
+
const lineTags = detailsTagsByLine.get(tag.lineStart) ?? [];
|
|
3287
|
+
lineTags.push(tag);
|
|
3288
|
+
detailsTagsByLine.set(tag.lineStart, lineTags);
|
|
3289
|
+
}
|
|
3290
|
+
let detailsDepth = 0;
|
|
3291
|
+
let closedHeadingLevel = 0;
|
|
3292
|
+
for (const line of lines) {
|
|
3293
|
+
const fenced = fencedLineNumbers.has(line.lineNumber);
|
|
3294
|
+
const lineTags = detailsTagsByLine.get(line.start) ?? [];
|
|
3295
|
+
const insideDetails = detailsDepth > 0 || lineTags.some((tag) => !tag.closing);
|
|
3296
|
+
if (historicalDetailsLineStarts.has(line.start) || closedHeadingLevel > 0)
|
|
3297
|
+
historical.add(line.start);
|
|
3298
|
+
if (lineTags.length > 0)
|
|
3299
|
+
detailsDepth = lineTags[lineTags.length - 1].depthAfter;
|
|
3300
|
+
if (insideDetails) {
|
|
3301
|
+
continue;
|
|
3302
|
+
}
|
|
3303
|
+
if (fenced) {
|
|
3304
|
+
continue;
|
|
3305
|
+
}
|
|
3306
|
+
const heading = headingsByOffset.get(line.start);
|
|
3307
|
+
if (heading) {
|
|
3308
|
+
const level = heading.level;
|
|
3309
|
+
const milestoneHeading = isRecognizedMilestoneHeading(heading.text, level);
|
|
3310
|
+
if (milestoneHeading && closedHeadingLevel && level <= closedHeadingLevel)
|
|
3311
|
+
closedHeadingLevel = 0;
|
|
3312
|
+
if (!closedHeadingLevel && milestoneHeading && isClosedMilestoneHeading(heading.text)) {
|
|
3313
|
+
closedHeadingLevel = level;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
if (closedHeadingLevel > 0)
|
|
3317
|
+
historical.add(line.start);
|
|
3318
|
+
}
|
|
3319
|
+
return historical;
|
|
3320
|
+
}
|
|
3321
|
+
/**
|
|
3322
|
+
* #4304: a pre-mutation SAFETY CHECK, not a fix to the read
|
|
3323
|
+
* side's own window selection (mislocating the active window is PR-6 /
|
|
3324
|
+
* #4751 territory — this function does not touch that). When the read side
|
|
3325
|
+
* mislocates the active milestone window — a non-closed heading carrying
|
|
3326
|
+
* the active version token sits BEFORE the real milestone heading ("##
|
|
3327
|
+
* Goals for v2.0"), a document-level Progress heading placed before the
|
|
3328
|
+
* milestone heading, or no milestone heading exists at all — the checklist/
|
|
3329
|
+
* heading rewrite loop in `updateRoadmapAfterBracketPhaseRemoval` never
|
|
3330
|
+
* reaches the target's REAL heading/checklist lines, because they sit
|
|
3331
|
+
* outside `ranges`. Nothing downstream of that silently-empty rewrite stops
|
|
3332
|
+
* the destructive directory delete/rename that already ran by the time the
|
|
3333
|
+
* ROADMAP rewrite would have reported the mismatch, so the command "succeeds"
|
|
3334
|
+
* with an empty report while the target's heading and checklist survive
|
|
3335
|
+
* beside the sibling renumbered onto its old identity.
|
|
3336
|
+
*
|
|
3337
|
+
* Scans the WHOLE document outside CommonMark fences (not `ranges` — the
|
|
3338
|
+
* located window is exactly
|
|
3339
|
+
* what is in question) for the target's own heading/checklist lines via the
|
|
3340
|
+
* SAME classifier (`classifyBracketOwnedLine`) the rewrite loop uses. If at
|
|
3341
|
+
* least one is found and NONE of them fall inside the located `ranges`
|
|
3342
|
+
* (primary or details) — including when `ranges` is null, i.e. no milestone
|
|
3343
|
+
* window was located at all — the target is misplaced relative to what the
|
|
3344
|
+
* read side thinks is active, and the caller must refuse before touching
|
|
3345
|
+
* disk. A target correctly inside `ranges` (the overwhelmingly common case)
|
|
3346
|
+
* returns null immediately; this never widens or narrows behavior for a
|
|
3347
|
+
* correctly-located window.
|
|
3348
|
+
*/
|
|
3349
|
+
function bracketOwnedLineOutsideActiveWindow(content, targetId, ranges) {
|
|
3350
|
+
// Heading deletion (`deleteSection`, scoped to `preDeleteRanges`) and
|
|
3351
|
+
// checklist-row deletion (the per-line loop, gated on `active`) are two
|
|
3352
|
+
// INDEPENDENT scoped operations in `updateRoadmapAfterBracketPhaseRemoval`
|
|
3353
|
+
// — a degenerate window can legitimately contain one kind of owned line
|
|
3354
|
+
// while missing the other (with no milestone heading, the version-less
|
|
3355
|
+
// bracket fallback selects the first PHASE heading as if it were the milestone heading, so
|
|
3356
|
+
// the resulting window happens to span every later phase HEADING to EOF
|
|
3357
|
+
// while the checklist bullets — which sit ABOVE that heading — are still
|
|
3358
|
+
// entirely outside it). Finding the heading safely inside must never mask
|
|
3359
|
+
// a checklist row that is not, or vice versa: track each kind separately.
|
|
3360
|
+
let headingInside = false;
|
|
3361
|
+
let checklistInside = false;
|
|
3362
|
+
let firstOutsideHeading = null;
|
|
3363
|
+
let firstOutsideChecklist = null;
|
|
3364
|
+
// #4304: a line archived inside <details> or sitting under a CLOSED milestone
|
|
3365
|
+
// heading (isClosedMilestoneHeading — the SAME predicate
|
|
3366
|
+
// currentMilestoneRawRanges itself uses to skip a closed heading when
|
|
3367
|
+
// selecting the active one) is a SHIPPED milestone's own line — never
|
|
3368
|
+
// evidence the ACTIVE window is mislocated. Same-code point releases
|
|
3369
|
+
// (milestoneToken folds v2.0/v2.1 to one bracket id) legitimately carry
|
|
3370
|
+
// the same [CODE.MM] NN identity in both the shipped archive/section and
|
|
3371
|
+
// the active phase's heading-only entry (exactly what `phase add` writes,
|
|
3372
|
+
// with no checklist bullet of its own) — this never widens what counts as
|
|
3373
|
+
// OUTSIDE, only narrows which OUTSIDE lines count as evidence.
|
|
3374
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
|
|
3375
|
+
const fencedLineNumbers = fencedRoadmapLineNumbers(content);
|
|
3376
|
+
for (const line of splitRoadmapLineRecords(content)) {
|
|
3377
|
+
if (fencedLineNumbers.has(line.lineNumber))
|
|
3378
|
+
continue;
|
|
3379
|
+
const owned = classifyBracketOwnedLine(line.text);
|
|
3380
|
+
if (!owned.id || (owned.kind !== 'heading' && owned.kind !== 'checklist'))
|
|
3381
|
+
continue;
|
|
3382
|
+
if (!sameBracketPhaseId(owned.id, targetId))
|
|
3383
|
+
continue;
|
|
3384
|
+
const insideActive = Boolean(ranges
|
|
3385
|
+
&& ((line.start >= ranges.primary.start && line.start < ranges.primary.end)
|
|
3386
|
+
|| (ranges.details !== null
|
|
3387
|
+
&& line.start >= ranges.details.start && line.start < ranges.details.end)));
|
|
3388
|
+
if (historicalLineStarts.has(line.start))
|
|
3389
|
+
continue;
|
|
3390
|
+
if (owned.kind === 'heading') {
|
|
3391
|
+
if (insideActive)
|
|
3392
|
+
headingInside = true;
|
|
3393
|
+
else if (!firstOutsideHeading)
|
|
3394
|
+
firstOutsideHeading = { lineNumber: line.lineNumber, text: line.text.trim() };
|
|
3395
|
+
}
|
|
3396
|
+
else {
|
|
3397
|
+
if (insideActive)
|
|
3398
|
+
checklistInside = true;
|
|
3399
|
+
else if (!firstOutsideChecklist)
|
|
3400
|
+
firstOutsideChecklist = { lineNumber: line.lineNumber, text: line.text.trim() };
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
const outside = firstOutsideHeading && !headingInside ? firstOutsideHeading
|
|
3404
|
+
: firstOutsideChecklist && !checklistInside ? firstOutsideChecklist : null;
|
|
3405
|
+
return { outside, hasLiveTarget: headingInside || checklistInside };
|
|
3406
|
+
}
|
|
3407
|
+
function bracketRenameCandidates(phasesDir, context, mapping) {
|
|
3408
|
+
const mappingKey = (phase, subphase) => `${Number(phase)}.${subphase === undefined ? '' : Number(subphase)}`;
|
|
3409
|
+
const byKey = new Map();
|
|
3410
|
+
for (const { oldId, newId } of mapping)
|
|
3411
|
+
byKey.set(mappingKey(oldId.phase, oldId.subphase), newId);
|
|
3412
|
+
return bracketIdsInContext(phasesDir, context)
|
|
3413
|
+
.filter(({ id }) => byKey.has(mappingKey(id.phase, id.subphase)))
|
|
3414
|
+
.sort((a, b) => {
|
|
3415
|
+
const phaseDelta = Number(a.id.phase) - Number(b.id.phase);
|
|
3416
|
+
return phaseDelta !== 0
|
|
3417
|
+
? phaseDelta
|
|
3418
|
+
: Number(a.id.subphase ?? 0) - Number(b.id.subphase ?? 0);
|
|
3419
|
+
})
|
|
3420
|
+
.map((item) => {
|
|
3421
|
+
const newId = byKey.get(mappingKey(item.id.phase, item.id.subphase));
|
|
3422
|
+
return { item, newId, newDirName: toDir(newId, item.slug) };
|
|
3423
|
+
});
|
|
3424
|
+
}
|
|
3425
|
+
function assertBracketRenameDestinationsSafe(phasesDir, context, mapping, targetDir) {
|
|
3426
|
+
const occupied = new Set(readSubdirectories(phasesDir));
|
|
3427
|
+
if (targetDir !== null)
|
|
3428
|
+
occupied.delete(targetDir);
|
|
3429
|
+
for (const { item, newDirName } of bracketRenameCandidates(phasesDir, context, mapping)) {
|
|
3430
|
+
// Simulate the same ascending sequence the rename loop will apply. A
|
|
3431
|
+
// destination is free only after its target or earlier source vacates it.
|
|
3432
|
+
assertPhaseDirectoryDestinationSafe(phasesDir, newDirName, 'renumber into', occupied);
|
|
3433
|
+
occupied.delete(item.dir);
|
|
3434
|
+
occupied.add(newDirName);
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
function renameBracketPhases(phasesDir, context, mapping) {
|
|
3438
|
+
const renamedDirs = [];
|
|
3439
|
+
const renamedFiles = [];
|
|
3440
|
+
const renamedFileCollisions = [];
|
|
3441
|
+
for (const { item, newId, newDirName } of bracketRenameCandidates(phasesDir, context, mapping)) {
|
|
3442
|
+
// Recheck immediately before the rename as well as cmdPhaseRemove's
|
|
3443
|
+
// pre-mutation pass, closing the validation-to-use gap within this loop.
|
|
3444
|
+
assertPhaseDirectoryDestinationSafe(phasesDir, newDirName, 'renumber into');
|
|
3445
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
|
|
3446
|
+
renamedDirs.push({ from: item.dir, to: newDirName });
|
|
3447
|
+
renameBracketArtifactFiles(node_path_1.default.join(phasesDir, newDirName), item.id, newId, renamedFiles, renamedFileCollisions);
|
|
3448
|
+
}
|
|
3449
|
+
return { renamedDirs, renamedFiles, renamedFileCollisions };
|
|
3450
|
+
}
|
|
3451
|
+
const BRACKET_OWNED_PHASE_INTRO_SRC = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, 'bracket', true);
|
|
3452
|
+
const BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC = `(${PHASE_NUMBER_TOKEN_SOURCE})`;
|
|
3453
|
+
const BRACKET_OWNED_TAG_SRC = '(?:[ \\t]*\\([^)\\r\\n]{0,200}\\))?';
|
|
3454
|
+
// #4304: every reader compiles BRACKET_OWNED_PHASE_INTRO_SRC's
|
|
3455
|
+
// own source (phaseHeadingPrefixSrcFor's bracket alternative) with the `i`
|
|
3456
|
+
// flag — `BRACKET_PROJECT_CODE_SRC` is deliberately spelled `[A-Z]...` on the
|
|
3457
|
+
// understanding that recognition folds case at compile time, never in the
|
|
3458
|
+
// source. The owned-line expressions below compile it with that same flag;
|
|
3459
|
+
// compiled with no flags at all, `[ck.02] 02:`,
|
|
3460
|
+
// `[CK.02] phase 02:` and `[CK.02] PHASE 02:` (case variants the read
|
|
3461
|
+
// grammar and `phase insert`/`phase add` accept) would
|
|
3462
|
+
// never classify as owned lines here. Headings and progress cells still compile
|
|
3463
|
+
// this source locally; checklist rows route through parsePhaseChecklistLine,
|
|
3464
|
+
// the same semantic reader used by init manager.
|
|
3465
|
+
const BRACKET_HEADING_LINE_RE = new RegExp(`^ {0,3}#{2,4}[ \\t]*${BRACKET_OWNED_PHASE_INTRO_SRC}`
|
|
3466
|
+
+ `${BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC}${BRACKET_OWNED_TAG_SRC}[ \\t]*:`, 'i');
|
|
3467
|
+
const BRACKET_CELL_ID_RE = new RegExp(`^${BRACKET_OWNED_PHASE_INTRO_SRC}${BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC}`
|
|
3468
|
+
+ `${BRACKET_OWNED_TAG_SRC}(?:[ \\t]*:|[ \\t]|$)`, 'i');
|
|
3469
|
+
function splitRoadmapLineRecords(content) {
|
|
3470
|
+
const records = [];
|
|
3471
|
+
const lineRe = /([^\r\n]*)(\r\n|\n|$)/g;
|
|
3472
|
+
let match;
|
|
3473
|
+
let lineNumber = 1;
|
|
3474
|
+
while ((match = lineRe.exec(content)) !== null) {
|
|
3475
|
+
if (match[0] === '')
|
|
3476
|
+
break;
|
|
3477
|
+
records.push({
|
|
3478
|
+
text: match[1],
|
|
3479
|
+
eol: match[2],
|
|
3480
|
+
start: match.index,
|
|
3481
|
+
lineNumber,
|
|
3482
|
+
});
|
|
3483
|
+
lineNumber += 1;
|
|
3484
|
+
}
|
|
3485
|
+
return records;
|
|
3486
|
+
}
|
|
3487
|
+
function phaseIdFromOwnedParts(bracketId, phaseNumber) {
|
|
3488
|
+
if (!bracketId || !phaseNumber)
|
|
3489
|
+
return null;
|
|
3490
|
+
try {
|
|
3491
|
+
// #4304: parsePhaseId's own display-form regex requires an
|
|
3492
|
+
// uppercase project code and checks canonicality by requiring the
|
|
3493
|
+
// re-rendered id to be byte-equal to the input, so a lowercase capture
|
|
3494
|
+
// from the case-insensitive owned-line regexes above (`[ck.02]
|
|
3495
|
+
// 02:`) would throw here and classify as "not owned", the
|
|
3496
|
+
// identity-recognition gap `foldBracketId`'s own doc comment in
|
|
3497
|
+
// phase-id.cts warns about ("fold before any identity operation; never
|
|
3498
|
+
// fold for display"). This is an identity operation.
|
|
3499
|
+
//
|
|
3500
|
+
// #4304: the captured NUMBER needs the exact same
|
|
3501
|
+
// treatment. BRACKET_OWNED_PHASE_TOKEN_CAPTURE_SRC is deliberately
|
|
3502
|
+
// TOLERANT (PHASE_NUMBER_TOKEN_SOURCE, the read side's own grammar) so it
|
|
3503
|
+
// captures "2", "002", and "02.1" — the same non-canonical spellings
|
|
3504
|
+
// `roadmap get-phase`/`analyze`/`validate`, `phase insert` and
|
|
3505
|
+
// `phase add` treat as real phases, but parsePhaseId's
|
|
3506
|
+
// canonicality check (render(parse(x)) === x) throws on every one of
|
|
3507
|
+
// them, so without this step the line would classify as 'other' and its
|
|
3508
|
+
// owning heading/checklist/progress row would never be recognized.
|
|
3509
|
+
// Canonicalize through the SAME adapter the bare-token argument path
|
|
3510
|
+
// uses (phase-id-display's `phaseToken`, per-segment pad2) BEFORE
|
|
3511
|
+
// parsePhaseId, exactly as `canonicalizeBracketPhaseArgument` already
|
|
3512
|
+
// does for a CLI argument. A token phaseToken cannot canonicalize (a
|
|
3513
|
+
// legacy M-NN letter suffix) falls through unchanged, which still fails
|
|
3514
|
+
// parsePhaseId's own grammar precisely as before — this only widens
|
|
3515
|
+
// acceptance to spellings phaseToken itself accepts.
|
|
3516
|
+
const canonicalNumber = phaseToken(phaseNumber) ?? phaseNumber;
|
|
3517
|
+
return parsePhaseId(`[${foldBracketId(bracketId)}] ${canonicalNumber}`);
|
|
3518
|
+
}
|
|
3519
|
+
catch {
|
|
3520
|
+
return null;
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
function phaseIdFromOwnedLineMatch(match) {
|
|
3524
|
+
return phaseIdFromOwnedParts(match?.[1], match?.[2]);
|
|
3525
|
+
}
|
|
3526
|
+
function classifyBracketOwnedLine(line) {
|
|
3527
|
+
const headingId = phaseIdFromOwnedLineMatch(BRACKET_HEADING_LINE_RE.exec(line));
|
|
3528
|
+
if (headingId)
|
|
3529
|
+
return { kind: 'heading', id: headingId };
|
|
3530
|
+
const checklist = parsePhaseChecklistLine(line, 'bracket');
|
|
3531
|
+
const checklistId = phaseIdFromOwnedParts(checklist?.bracketId, checklist?.phaseToken);
|
|
3532
|
+
if (checklistId)
|
|
3533
|
+
return { kind: 'checklist', id: checklistId };
|
|
3534
|
+
if (/^[ \t]*\|/.test(line)) {
|
|
3535
|
+
const firstCell = (0, markdown_table_cjs_1.splitTableRow)(line)[0]?.replace(/^\*\*(.*)\*\*$/, '$1') ?? '';
|
|
3536
|
+
const progressId = phaseIdFromOwnedLineMatch(BRACKET_CELL_ID_RE.exec(firstCell));
|
|
3537
|
+
if (progressId)
|
|
3538
|
+
return { kind: 'progress', id: progressId };
|
|
3539
|
+
}
|
|
3540
|
+
return { kind: 'other', id: null };
|
|
3541
|
+
}
|
|
3542
|
+
function sameBracketPhaseId(a, b) {
|
|
3543
|
+
return a.project === b.project
|
|
3544
|
+
&& a.milestone === b.milestone
|
|
3545
|
+
&& a.phase === b.phase
|
|
3546
|
+
&& a.subphase === b.subphase;
|
|
3547
|
+
}
|
|
3548
|
+
/**
|
|
3549
|
+
* A bracket removal may share the live tree with migration-window legacy
|
|
3550
|
+
* spellings, but it must never mutate that legacy identity indirectly by
|
|
3551
|
+
* renumbering later bracket phases onto it. This check covers a target that
|
|
3552
|
+
* resolves only to legacy spellings; legacySpellingsEntangledWithBracketRemoval
|
|
3553
|
+
* covers a legacy spelling of any other identity the removal moves. Resolve both
|
|
3554
|
+
* directory and heading evidence before any write. `matchPhaseDirs` owns legacy directory selection;
|
|
3555
|
+
* `BRACKET_HEADING_LINE_RE` owns the reader-tolerant heading grammar and its
|
|
3556
|
+
* legacy `Phase N:` alternative. A real, non-historical bracket heading for the
|
|
3557
|
+
* target makes this an ordinary bracket removal even when legacy siblings are
|
|
3558
|
+
* also present.
|
|
3559
|
+
*/
|
|
3560
|
+
function legacyOnlyBracketRemovalTarget(subdirs, roadmapContent, targetPhase, targetId, hasBracketDirectory) {
|
|
3561
|
+
const legacyNormalized = normalizePhaseName(targetPhase);
|
|
3562
|
+
const legacyDirectories = matchPhaseDirs(subdirs, legacyNormalized).matches.filter((dir) => {
|
|
3563
|
+
try {
|
|
3564
|
+
parsePhaseId(dir);
|
|
3565
|
+
return false;
|
|
3566
|
+
}
|
|
3567
|
+
catch {
|
|
3568
|
+
return true;
|
|
3569
|
+
}
|
|
3570
|
+
});
|
|
3571
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
|
|
3572
|
+
const legacyHeadings = [];
|
|
3573
|
+
let hasBracketHeading = false;
|
|
3574
|
+
for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent)) {
|
|
3575
|
+
if (heading.level < 2 || heading.level > 4)
|
|
3576
|
+
continue;
|
|
3577
|
+
const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
|
|
3578
|
+
const owned = classifyBracketOwnedLine(headingLine);
|
|
3579
|
+
if (owned.kind === 'heading' && owned.id) {
|
|
3580
|
+
if (!historicalLineStarts.has(heading.offset) && sameBracketPhaseId(owned.id, targetId)) {
|
|
3581
|
+
hasBracketHeading = true;
|
|
3582
|
+
}
|
|
3583
|
+
continue;
|
|
3584
|
+
}
|
|
3585
|
+
// The shared bracket selector includes the legacy `Phase N:` alternative.
|
|
3586
|
+
// If classification did not yield a bracket id, capture that alternative's
|
|
3587
|
+
// numeric token and compare it through the same writer canonicalizer.
|
|
3588
|
+
const legacyMatch = BRACKET_HEADING_LINE_RE.exec(headingLine);
|
|
3589
|
+
const canonicalNumber = legacyMatch?.[2] ? phaseToken(legacyMatch[2]) : null;
|
|
3590
|
+
const targetNumber = targetId.subphase ? `${targetId.phase}.${targetId.subphase}` : targetId.phase;
|
|
3591
|
+
if (canonicalNumber === targetNumber)
|
|
3592
|
+
legacyHeadings.push(headingLine);
|
|
3593
|
+
}
|
|
3594
|
+
if (hasBracketDirectory || hasBracketHeading)
|
|
3595
|
+
return null;
|
|
3596
|
+
if (legacyDirectories.length === 0 && legacyHeadings.length === 0)
|
|
3597
|
+
return null;
|
|
3598
|
+
return { directories: legacyDirectories, headings: legacyHeadings };
|
|
3599
|
+
}
|
|
3600
|
+
/**
|
|
3601
|
+
* #4304: legacy spellings the active bracket reader resolves to an identity
|
|
3602
|
+
* this removal deletes, renumbers, or renumbers onto. The removal renames only
|
|
3603
|
+
* canonical bracket directories and rewrites only bracket-owned ROADMAP lines
|
|
3604
|
+
* (the inventory computeBracketRenumberMapping unions), so such a spelling
|
|
3605
|
+
* would keep its old number: orphaned, or read as whichever phase lands on
|
|
3606
|
+
* that number. Integer removal moves whole phases, so a spelling counts when
|
|
3607
|
+
* its phase number is the target's or any mapped phase's, old or new, which
|
|
3608
|
+
* also covers a legacy sub-phase under a moving parent. Sub-phase removal
|
|
3609
|
+
* compares full identities. A legacy phase outside that set keeps a number
|
|
3610
|
+
* nothing claims after the removal, so it does not block it.
|
|
3611
|
+
*
|
|
3612
|
+
* Directories are inventoried the way the allocator and the removal guard for
|
|
3613
|
+
* the target's own sub-phases do: each non-canonical directory's
|
|
3614
|
+
* `phaseKeyFromDir` key, kept only when `matchPhaseDirs` resolves the directory
|
|
3615
|
+
* for that key. Headings use legacyOnlyBracketRemovalTarget's recognition,
|
|
3616
|
+
* limited to the active milestone's own non-historical lines.
|
|
3617
|
+
*/
|
|
3618
|
+
function legacySpellingsEntangledWithBracketRemoval(phasesDir, roadmapContent, ranges, context, removedInt, removedSubphase, mapping) {
|
|
3619
|
+
const identityKey = (phase, subphase) => `${phase}.${subphase ?? ''}`;
|
|
3620
|
+
const movingPhases = new Set([removedInt]);
|
|
3621
|
+
const movingIdentities = new Set([identityKey(removedInt, removedSubphase)]);
|
|
3622
|
+
for (const { oldId, newId } of mapping) {
|
|
3623
|
+
for (const id of [oldId, newId]) {
|
|
3624
|
+
const subphase = id.subphase === undefined ? undefined : Number(id.subphase);
|
|
3625
|
+
movingPhases.add(Number(id.phase));
|
|
3626
|
+
movingIdentities.add(identityKey(Number(id.phase), subphase));
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
const entangled = (phase, subphase) => (removedSubphase === undefined
|
|
3630
|
+
? movingPhases.has(phase)
|
|
3631
|
+
: subphase !== undefined && movingIdentities.has(identityKey(phase, subphase)));
|
|
3632
|
+
const numericIdentity = (token) => {
|
|
3633
|
+
const [phasePart, subphasePart, ...rest] = token.split('.');
|
|
3634
|
+
if (rest.length > 0 || !/^\d+$/.test(phasePart))
|
|
3635
|
+
return null;
|
|
3636
|
+
if (subphasePart !== undefined && !/^\d+$/.test(subphasePart))
|
|
3637
|
+
return null;
|
|
3638
|
+
const phase = Number(phasePart);
|
|
3639
|
+
if (isSentinelPhaseId(phase))
|
|
3640
|
+
return null;
|
|
3641
|
+
return subphasePart === undefined ? { phase } : { phase, subphase: Number(subphasePart) };
|
|
3642
|
+
};
|
|
3643
|
+
const found = [];
|
|
3644
|
+
for (const dir of readSubdirectories(phasesDir, true)) {
|
|
3645
|
+
try {
|
|
3646
|
+
parsePhaseId(dir);
|
|
3647
|
+
continue;
|
|
3648
|
+
}
|
|
3649
|
+
catch {
|
|
3650
|
+
// Not canonical bracket grammar: a migration-window legacy spelling.
|
|
3651
|
+
}
|
|
3652
|
+
const key = phaseKeyFromDir(dir, 'bracket');
|
|
3653
|
+
const identity = numericIdentity(key);
|
|
3654
|
+
if (!identity || !entangled(identity.phase, identity.subphase))
|
|
3655
|
+
continue;
|
|
3656
|
+
if (!matchPhaseDirs([dir], key, 'bracket', context).matches.includes(dir))
|
|
3657
|
+
continue;
|
|
3658
|
+
found.push({ kind: 'directory', spelling: dir, id: bracketPhaseId(context, identity.phase, identity.subphase) });
|
|
3659
|
+
}
|
|
3660
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(roadmapContent);
|
|
3661
|
+
for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent)) {
|
|
3662
|
+
if (heading.level < 2 || heading.level > 4)
|
|
3663
|
+
continue;
|
|
3664
|
+
if (!lineStartsInActiveMilestone(heading.offset, ranges) || historicalLineStarts.has(heading.offset))
|
|
3665
|
+
continue;
|
|
3666
|
+
const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
|
|
3667
|
+
if (classifyBracketOwnedLine(headingLine).id)
|
|
3668
|
+
continue;
|
|
3669
|
+
const legacyMatch = BRACKET_HEADING_LINE_RE.exec(headingLine);
|
|
3670
|
+
const canonicalNumber = legacyMatch?.[2] ? phaseToken(legacyMatch[2]) : null;
|
|
3671
|
+
const identity = canonicalNumber ? numericIdentity(canonicalNumber) : null;
|
|
3672
|
+
if (!identity || !entangled(identity.phase, identity.subphase))
|
|
3673
|
+
continue;
|
|
3674
|
+
found.push({ kind: 'heading', spelling: headingLine, id: bracketPhaseId(context, identity.phase, identity.subphase) });
|
|
3675
|
+
}
|
|
3676
|
+
return found;
|
|
3677
|
+
}
|
|
3678
|
+
/** Name a legacy spelling, the bracket identity it resolves to, and its bracket form. */
|
|
3679
|
+
function describeEntangledLegacySpelling({ kind, spelling, id }) {
|
|
3680
|
+
return kind === 'directory'
|
|
3681
|
+
? `directory ${JSON.stringify(spelling)} is ${renderPhaseId(id)} (bracket prefix ${JSON.stringify(bracketDirPrefix(id))})`
|
|
3682
|
+
: `heading ${JSON.stringify(spelling)} is ${renderPhaseId(id)}`;
|
|
3683
|
+
}
|
|
3684
|
+
/**
|
|
3685
|
+
* Classify a fence-excluded heading token through the roadmap reader's shared
|
|
3686
|
+
* bracket-plus-legacy phase-entry predicate, then decide whether it names a
|
|
3687
|
+
* phase other than the selected removal target. Bracket headings retain their
|
|
3688
|
+
* full project+milestone identity; a bare numeric `Phase NN:` heading is
|
|
3689
|
+
* compared in the active target context. A reader-recognized custom legacy id
|
|
3690
|
+
* cannot equal a numeric bracket target and is therefore distinct.
|
|
3691
|
+
*/
|
|
3692
|
+
function isDistinctReaderPhaseHeading(heading, targetId) {
|
|
3693
|
+
if (heading.level < 2 || heading.level > 4)
|
|
3694
|
+
return false;
|
|
3695
|
+
if (!isPhaseEntryHeading(heading.text, 'bracket'))
|
|
3696
|
+
return false;
|
|
3697
|
+
const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
|
|
3698
|
+
const owned = classifyBracketOwnedLine(headingLine);
|
|
3699
|
+
if (owned.kind === 'heading' && owned.id)
|
|
3700
|
+
return !sameBracketPhaseId(owned.id, targetId);
|
|
3701
|
+
const numericMatch = BRACKET_HEADING_LINE_RE.exec(headingLine);
|
|
3702
|
+
const canonicalNumber = numericMatch?.[2] ? phaseToken(numericMatch[2]) : null;
|
|
3703
|
+
if (!canonicalNumber)
|
|
3704
|
+
return true;
|
|
3705
|
+
const [phase, subphase] = canonicalNumber.split('.');
|
|
3706
|
+
return phase !== targetId.phase || subphase !== targetId.subphase;
|
|
3707
|
+
}
|
|
3708
|
+
function dashBracketPhaseId(id) {
|
|
3709
|
+
return `${id.project}.${id.milestone}-${id.phase}${id.subphase ? `.${id.subphase}` : ''}`;
|
|
3710
|
+
}
|
|
3711
|
+
/** The `PP[.SS][-LL]` tail of a rendered bracket phase id — renderPhaseId minus its milestone-bracket prefix. */
|
|
3712
|
+
function bracketPhaseNumberSrc(id) {
|
|
3713
|
+
return renderPhaseId(id).slice(renderMilestoneId(id).length + 1);
|
|
3714
|
+
}
|
|
3715
|
+
function replaceQualifiedBracketReference(line, oldId, newId) {
|
|
3716
|
+
const boundary = 'A-Za-z0-9.-';
|
|
3717
|
+
const newNumber = bracketPhaseNumberSrc(newId);
|
|
3718
|
+
const oldDisplay = renderPhaseId(oldId);
|
|
3719
|
+
const qualified = tokenizePhaseDependencyReferences(line, 'bracket')
|
|
3720
|
+
.filter((token) => {
|
|
3721
|
+
if (token.kind !== 'qualified' || token.token !== oldDisplay)
|
|
3722
|
+
return false;
|
|
3723
|
+
const before = token.referenceStart === undefined ? '' : line[token.referenceStart - 1] ?? '';
|
|
3724
|
+
const after = line[token.end] ?? '';
|
|
3725
|
+
return !new RegExp(`[${boundary}]`).test(before) && !new RegExp(`[${boundary}]`).test(after);
|
|
3726
|
+
})
|
|
3727
|
+
.sort((a, b) => b.start - a.start);
|
|
3728
|
+
let rewritten = line;
|
|
3729
|
+
for (const token of qualified) {
|
|
3730
|
+
rewritten = rewritten.slice(0, token.start) + newNumber + rewritten.slice(token.end);
|
|
3731
|
+
}
|
|
3732
|
+
const oldDash = dashBracketPhaseId(oldId);
|
|
3733
|
+
const newDash = dashBracketPhaseId(newId);
|
|
3734
|
+
return rewritten.replace(new RegExp(`(?<![${boundary}])${(0, pattern_cjs_1.escapeRegex)(oldDash)}(?![${boundary}])`, 'g'), () => newDash);
|
|
3735
|
+
}
|
|
3736
|
+
function replaceBareBracketArtifactReference(line, oldId, newId) {
|
|
3737
|
+
const oldToken = bracketArtifactToken(oldId);
|
|
3738
|
+
const newToken = bracketArtifactToken(newId);
|
|
3739
|
+
const filename = `${(0, pattern_cjs_1.escapeRegex)(oldToken)}-\\d{2}`
|
|
3740
|
+
+ '(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\\.md';
|
|
3741
|
+
return line.replace(new RegExp(`(?<![A-Za-z0-9_./-])${filename}(?![A-Za-z0-9_.-])`, 'g'), (match) => newToken + match.slice(oldToken.length));
|
|
3742
|
+
}
|
|
3743
|
+
function lineStartsInActiveMilestone(lineStart, ranges) {
|
|
3744
|
+
if (!ranges)
|
|
3745
|
+
return false;
|
|
3746
|
+
return (lineStart >= ranges.primary.start && lineStart < ranges.primary.end)
|
|
3747
|
+
|| Boolean(ranges.details && lineStart >= ranges.details.start && lineStart < ranges.details.end);
|
|
3748
|
+
}
|
|
3749
|
+
/**
|
|
3750
|
+
* #4304: a NARROWER boundary than currentMilestoneRawRanges'
|
|
3751
|
+
* own primary/details ranges. Those stop only at a RECOGNIZED bracket/
|
|
3752
|
+
* version-bearing milestone heading, by design (the read path's own
|
|
3753
|
+
* milestone-scanning use case treats an unrelated heading — a
|
|
3754
|
+
* `## Requirements Traceability`, a `## Notes` — as staying inside the
|
|
3755
|
+
* current milestone's content, not as a boundary). A pipe-table row has no
|
|
3756
|
+
* "this is my milestone's own table" marker beyond ordinary heading
|
|
3757
|
+
* nesting, so deciding whether a progress/table row belongs to THIS
|
|
3758
|
+
* milestone needs the plain Markdown rule instead: stop at the next
|
|
3759
|
+
* heading of any kind at or above the milestone heading's own level —
|
|
3760
|
+
* the SAME rule deleteSection/collectSection already apply elsewhere.
|
|
3761
|
+
*/
|
|
3762
|
+
function bracketMilestoneOwnTableEnd(content, sectionStart, headings) {
|
|
3763
|
+
const level = (content.slice(sectionStart).match(/^#{1,4}/) ?? ['#'])[0].length;
|
|
3764
|
+
for (const h of headings) {
|
|
3765
|
+
if (h.offset <= sectionStart)
|
|
3766
|
+
continue;
|
|
3767
|
+
if (h.level <= level)
|
|
3768
|
+
return h.offset;
|
|
3769
|
+
}
|
|
3770
|
+
return content.length;
|
|
3771
|
+
}
|
|
3772
|
+
/**
|
|
3773
|
+
* #4304: does `lineStart` fall inside the active milestone's
|
|
3774
|
+
* OWN progress/table content — its primary or details section, bounded by
|
|
3775
|
+
* `bracketMilestoneOwnTableEnd` rather than the wider currentMilestoneRawRanges
|
|
3776
|
+
* end?
|
|
3777
|
+
*/
|
|
3778
|
+
function lineStartsInMilestoneOwnTable(content, lineStart, ranges, headings) {
|
|
3779
|
+
if (!ranges)
|
|
3780
|
+
return false;
|
|
3781
|
+
if (lineStart >= ranges.primary.start
|
|
3782
|
+
&& lineStart < bracketMilestoneOwnTableEnd(content, ranges.primary.start, headings)) {
|
|
3783
|
+
return true;
|
|
3784
|
+
}
|
|
3785
|
+
return Boolean(ranges.details
|
|
3786
|
+
&& lineStart >= ranges.details.start
|
|
3787
|
+
&& lineStart < bracketMilestoneOwnTableEnd(content, ranges.details.start, headings));
|
|
3788
|
+
}
|
|
3789
|
+
// #4304: the ONE textual expression of "this heading is
|
|
3790
|
+
// titled Progress" — the title starts with the word "Progress", any
|
|
3791
|
+
// suffix admitted ("Progress", "Progress (v2.1)", "Progress — current"),
|
|
3792
|
+
// case-insensitive. `bracketProgressSectionRange` below already tolerated a
|
|
3793
|
+
// suffix at its own fixed level-2 anchor; `isProgressHeading` inside
|
|
3794
|
+
// `bracketOwnProgressSectionRanges` required an EXACT "progress" match, so
|
|
3795
|
+
// a suffixed OWN heading ("## Progress (v2.1)", "### Progress (v2.0)")
|
|
3796
|
+
// never engaged the own-section scope even though the document-first scope
|
|
3797
|
+
// already matched the same suffix. Both interpolate this one source now.
|
|
3798
|
+
// #5762: that one source now lives in the Roadmap Progress Module (`isProgressHeadingTitle`);
|
|
3799
|
+
// `bracketProgressSectionRange` below is `findProgressSection` minus archived milestones.
|
|
3800
|
+
/**
|
|
3801
|
+
* #4304: the SAME `## Progress`-section scope legacy's
|
|
3802
|
+
* updateRoadmapAfterPhaseRemoval already uses (#2012, src/phase.cts:2482-2500)
|
|
3803
|
+
* — the first `## Progress` heading (case-insensitive) through the next
|
|
3804
|
+
* `#`/`##` heading or EOF. Lets a DOCUMENT-LEVEL Progress table that sits
|
|
3805
|
+
* textually outside the active milestone's own ranges (e.g. after a later
|
|
3806
|
+
* milestone's own section, the existing "global Progress table" shape) stay
|
|
3807
|
+
* in scope for the target row's own deletion, exactly as it already was.
|
|
3808
|
+
*/
|
|
3809
|
+
function bracketProgressSectionRange(content) {
|
|
3810
|
+
// #5762: the Progress owner's section (first level-2 Progress heading to the next level-1/2
|
|
3811
|
+
// heading), minus the archived/closed milestone headings this remover must not touch.
|
|
3812
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
|
|
3813
|
+
return (0, roadmap_progress_cjs_1.findProgressSection)(content, { exclude: (offset) => historicalLineStarts.has(offset) });
|
|
3814
|
+
}
|
|
3815
|
+
/**
|
|
3816
|
+
* #4304: every distinct RECOGNIZED milestone
|
|
3817
|
+
* heading in the document, one representative offset per VERSION (one
|
|
3818
|
+
* carrying a version token — `listMilestoneHeadings`' own grammar, via the
|
|
3819
|
+
* SAME selection rule, `selectMilestoneHeading`, `currentMilestoneRawRanges`
|
|
3820
|
+
* already uses to pick "the" heading for a single version) PLUS one marker
|
|
3821
|
+
* per version-LESS bracket milestone heading recognized by the window
|
|
3822
|
+
* locator's own grammar (`isBracketMilestoneBoundary`,
|
|
3823
|
+
* src/roadmap-parser.cts — the SAME predicate `bracketAwareMilestoneSection`
|
|
3824
|
+
* uses to decide a candidate heading is a milestone boundary while walking
|
|
3825
|
+
* to find where the ACTIVE milestone's own section ends).
|
|
3826
|
+
*
|
|
3827
|
+
* A version-less ADR-612 canonical milestone heading
|
|
3828
|
+
* (`## [CK.02] Shipped ✅`, no `vX.Y` anywhere) is a marker because the
|
|
3829
|
+
* window locator recognizes it as a milestone boundary when walking the
|
|
3830
|
+
* document. Without it, a same-code shipped/active pair sharing no version
|
|
3831
|
+
* token would leave the shipped milestone's own `## Progress` heading
|
|
3832
|
+
* invisible to the sandwich test, and its own Complete row would be
|
|
3833
|
+
* deleted as if the table were shared/global.
|
|
3834
|
+
*
|
|
3835
|
+
* Deliberately NOT merged into one "one representative per identity" pass:
|
|
3836
|
+
* a shipped and active milestone commonly share the SAME bracket code
|
|
3837
|
+
* (`milestoneToken` folds `v2.0`/`v2.1` alike) while remaining two SEPARATE
|
|
3838
|
+
* heading positions that each open their own section — collapsing them by
|
|
3839
|
+
* id would silently drop one of the two boundaries the sandwich test needs.
|
|
3840
|
+
* Version-token headings keep their existing one-per-version selection
|
|
3841
|
+
* (unaffected, since every such heading already carries the distinguishing
|
|
3842
|
+
* signal); only headings the version grammar cannot see at all (no `vX.Y`
|
|
3843
|
+
* anywhere on the line) fall through to the boundary-predicate scan, so a
|
|
3844
|
+
* heading already represented by the version loop is never double-counted
|
|
3845
|
+
* merely for also being bracket-shaped (its own "(Phase Details)"
|
|
3846
|
+
* continuation heading carries the SAME version token and is excluded here
|
|
3847
|
+
* exactly as it always was — `selectMilestoneHeading` picks only the first
|
|
3848
|
+
* non-closed occurrence of that version, never the continuation).
|
|
3849
|
+
* `content[h.offset] === '#'` mirrors `bracketFallbackHeadingMatches`'s own
|
|
3850
|
+
* ATX-only guard (a `<summary>` line naming a milestone is never a heading).
|
|
3851
|
+
* Sorted ascending. Used to decide which milestone (if any) a document-first
|
|
3852
|
+
* `## Progress` heading sits immediately after, with nothing of its own kind
|
|
3853
|
+
* in between.
|
|
3854
|
+
*/
|
|
3855
|
+
function bracketRecognizedMilestoneMarkers(content) {
|
|
3856
|
+
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
3857
|
+
const versions = new Set(listMilestoneHeadings(content).map((h) => h.version));
|
|
3858
|
+
const markers = [];
|
|
3859
|
+
for (const version of versions) {
|
|
3860
|
+
const selected = selectMilestoneHeading(content, version);
|
|
3861
|
+
if (selected?.index !== undefined)
|
|
3862
|
+
markers.push(selected.index);
|
|
3863
|
+
}
|
|
3864
|
+
for (const h of headings) {
|
|
3865
|
+
if (h.level > 3 || content[h.offset] !== '#')
|
|
3866
|
+
continue;
|
|
3867
|
+
// Already represented above: this heading carries a version token the
|
|
3868
|
+
// first loop already enumerated (its own group's selected marker, or —
|
|
3869
|
+
// for a same-version continuation like "(Phase Details)" — the OTHER
|
|
3870
|
+
// occurrence `selectMilestoneHeading` picked instead). Mirrors
|
|
3871
|
+
// `extractMilestoneHeadingName`'s own no-`expectedVersion` grammar
|
|
3872
|
+
// (src/roadmap-parser.cts) literally rather than importing a private
|
|
3873
|
+
// helper across the module boundary for one boolean test.
|
|
3874
|
+
if (/v\d+(?:\.\d+)*(?:[-.][A-Za-z0-9]+)*/i.test(h.text))
|
|
3875
|
+
continue;
|
|
3876
|
+
// #4304: a "(Phase Details)" heading is always a CONTINUATION of
|
|
3877
|
+
// whatever milestone opened before it (`currentMilestoneRawRanges`'s own
|
|
3878
|
+
// `detailsMatch` grammar, src/roadmap-parser.cts:2288-2294), never a
|
|
3879
|
+
// second, separate milestone marker in its own right — the version loop
|
|
3880
|
+
// above already skips a VERSIONED continuation for exactly this reason
|
|
3881
|
+
// (comment above); a version-LESS one (`## [CK.02] Current (Phase
|
|
3882
|
+
// Details)`) fell through to this boundary-predicate scan uncaught,
|
|
3883
|
+
// because `isBracketMilestoneBoundary` is called with `selectedBracketId`
|
|
3884
|
+
// `null` here and so cannot recognize it as a continuation of its own
|
|
3885
|
+
// milestone.
|
|
3886
|
+
if (/\(Phase\s+Details\)/i.test(h.text))
|
|
3887
|
+
continue;
|
|
3888
|
+
if (!isBracketMilestoneBoundary(h.text, h.level, null))
|
|
3889
|
+
continue;
|
|
3890
|
+
markers.push(h.offset);
|
|
3891
|
+
}
|
|
3892
|
+
return Array.from(new Set(markers)).sort((a, b) => a - b);
|
|
3893
|
+
}
|
|
3894
|
+
/**
|
|
3895
|
+
* #4304: does the DOCUMENT-FIRST
|
|
3896
|
+
* `## Progress` heading `bracketProgressSectionRange` found belong to a
|
|
3897
|
+
* DIFFERENT, non-active milestone's OWN dedicated section, rather than
|
|
3898
|
+
* being a genuinely document-level/shared table?
|
|
3899
|
+
*
|
|
3900
|
+
* The answer "the active milestone has a Progress heading of its own, and
|
|
3901
|
+
* this isn't it, so it must be someone else's" would over-claim: a
|
|
3902
|
+
* genuinely global `## Progress` that lists every milestone's rows (before
|
|
3903
|
+
* any milestone heading at all, or trailing after the LAST recognized
|
|
3904
|
+
* milestone heading with nothing bounding it on the far side) is neither
|
|
3905
|
+
* the active milestone's own nor any OTHER milestone's dedicated section.
|
|
3906
|
+
*
|
|
3907
|
+
* The rule asks a POSITIONAL question instead, over every recognized
|
|
3908
|
+
* milestone heading in the document (`bracketRecognizedMilestoneMarkers`),
|
|
3909
|
+
* not just the active one: is this Progress heading SANDWICHED strictly
|
|
3910
|
+
* between two recognized milestone headings — i.e. does it immediately
|
|
3911
|
+
* follow one specific milestone's own heading (nothing else of that kind in
|
|
3912
|
+
* between) AND does some other recognized milestone heading follow it
|
|
3913
|
+
* later in the document? Only then is it unambiguously that earlier
|
|
3914
|
+
* milestone's own trailing section (a shipped milestone's own table, or the
|
|
3915
|
+
* same-code two-versions shape). A Progress heading with NOTHING preceding it (top of document) or
|
|
3916
|
+
* NOTHING following it (trailing after the last recognized milestone
|
|
3917
|
+
* heading) is open, shared territory — never "elsewhere" — because a
|
|
3918
|
+
* milestone's own dedicated section and a document-wide table that merely
|
|
3919
|
+
* happens to sit after the last milestone's content are textually
|
|
3920
|
+
* indistinguishable by position alone once nothing bounds the far side.
|
|
3921
|
+
*/
|
|
3922
|
+
function bracketProgressSectionOwnedByOtherMilestone(content, progressStart, activeRanges, ownProgressSectionRanges) {
|
|
3923
|
+
// Fast path: exactly one of the ACTIVE milestone's own recognized
|
|
3924
|
+
// Progress-titled headings (any level) is never "elsewhere".
|
|
3925
|
+
if (ownProgressSectionRanges.some((r) => r.start === progressStart))
|
|
3926
|
+
return false;
|
|
3927
|
+
// #4304: the positional question below applies only when the ACTIVE
|
|
3928
|
+
// milestone owns a Progress heading of its own; only then can a DIFFERENT
|
|
3929
|
+
// Progress heading be "someone else's". Without that precondition, a
|
|
3930
|
+
// document whose ACTIVE milestone has no dedicated Progress heading of its
|
|
3931
|
+
// own (the common single-shared-table layout: one global `## Progress`
|
|
3932
|
+
// table, no per-milestone one) would have its shared table declared
|
|
3933
|
+
// another milestone's the moment ANY version-bearing heading (a
|
|
3934
|
+
// `## Backlog (v4.0 candidates)` line, a changelog entry) followed it in
|
|
3935
|
+
// the document, because the positional scan alone cannot distinguish "this
|
|
3936
|
+
// table is the NEXT milestone's own dedicated section" from "this table is
|
|
3937
|
+
// shared and a later milestone heading simply comes after it in the file".
|
|
3938
|
+
// With no own Progress heading at all, the active milestone has no OWN claim
|
|
3939
|
+
// any Progress heading could be "instead of", so
|
|
3940
|
+
// a shared table can never be misread as belonging to a different one.
|
|
3941
|
+
if (ownProgressSectionRanges.length === 0)
|
|
3942
|
+
return false;
|
|
3943
|
+
// #4304: drop any marker lying STRICTLY inside the ACTIVE milestone's
|
|
3944
|
+
// own ranges — a heading inside the active's own window (a same-id prose
|
|
3945
|
+
// sub-heading like "### [CK.02] Notes", admitted by the boundary-predicate
|
|
3946
|
+
// scan above because it is bracket-shaped and version-less) never opens a
|
|
3947
|
+
// DIFFERENT milestone's section; it is content the active milestone itself
|
|
3948
|
+
// owns. A marker AT the range's own start (the active's own primary/details
|
|
3949
|
+
// heading) is kept — that is the "sandwiched under the ACTIVE milestone's
|
|
3950
|
+
// own heading" case just below, not an interior heading. Same-code SHIPPED
|
|
3951
|
+
// headings are never inside the active's own ranges, so they stay markers.
|
|
3952
|
+
const markers = bracketRecognizedMilestoneMarkers(content).filter((offset) => {
|
|
3953
|
+
if (!activeRanges)
|
|
3954
|
+
return true;
|
|
3955
|
+
const insidePrimary = offset > activeRanges.primary.start && offset < activeRanges.primary.end;
|
|
3956
|
+
const insideDetails = activeRanges.details !== null
|
|
3957
|
+
&& offset > activeRanges.details.start && offset < activeRanges.details.end;
|
|
3958
|
+
return !insidePrimary && !insideDetails;
|
|
3959
|
+
});
|
|
3960
|
+
let precedingIndex = -1;
|
|
3961
|
+
for (let i = 0; i < markers.length; i++) {
|
|
3962
|
+
if (markers[i] <= progressStart)
|
|
3963
|
+
precedingIndex = i;
|
|
3964
|
+
else
|
|
3965
|
+
break;
|
|
3966
|
+
}
|
|
3967
|
+
// Nothing precedes it (top-of-document global table): not owned by anyone.
|
|
3968
|
+
if (precedingIndex === -1)
|
|
3969
|
+
return false;
|
|
3970
|
+
// Sandwiched under the ACTIVE milestone's own heading: not "elsewhere".
|
|
3971
|
+
const precedingOffset = markers[precedingIndex];
|
|
3972
|
+
if (activeRanges && precedingOffset === activeRanges.primary.start)
|
|
3973
|
+
return false;
|
|
3974
|
+
// Owned by a DIFFERENT milestone only when something else recognized
|
|
3975
|
+
// follows it — a trailing section after the LAST recognized milestone
|
|
3976
|
+
// heading is open territory, never exclusively that last milestone's own.
|
|
3977
|
+
return precedingIndex < markers.length - 1;
|
|
3978
|
+
}
|
|
3979
|
+
/**
|
|
3980
|
+
* #4304: the active milestone's OWN heading
|
|
3981
|
+
* titled "Progress" (`isProgressHeadingTitle` — any suffix, any
|
|
3982
|
+
* level, case-insensitive), found ANYWHERE inside its primary or details
|
|
3983
|
+
* ranges — regardless of what precedes it within those ranges.
|
|
3984
|
+
* `bracketMilestoneOwnTableEnd`/`lineStartsInMilestoneOwnTable` above anchor
|
|
3985
|
+
* ONLY at the milestone/details heading itself, so an intervening heading of
|
|
3986
|
+
* level <= the milestone's own (a `## Notes` aside, or the milestone's OWN
|
|
3987
|
+
* `## Progress` heading when a shipped sibling sharing the bracket code
|
|
3988
|
+
* sorts its own `## Progress` first in the document) closed that "own
|
|
3989
|
+
* table" window before ever reaching the table it was
|
|
3990
|
+
* meant to include. This is ADDITIVE, never a replacement for it: the
|
|
3991
|
+
* existing bare-table-directly-after-phase-headings scope still
|
|
3992
|
+
* independently keeps an unrelated `## Requirements Traceability` table
|
|
3993
|
+
* out of its deletion scope, because that heading is not titled "Progress".
|
|
3994
|
+
*
|
|
3995
|
+
* The exact-title check required "progress", so a suffixed
|
|
3996
|
+
* title ("## Progress (v2.1)", "### Progress (v2.0)") never engaged this
|
|
3997
|
+
* scope even though the document-first `bracketProgressSectionRange` scope
|
|
3998
|
+
* already matched the same suffix — a same-code two-versions shipped/active
|
|
3999
|
+
* pair each titling their own table "Progress (vX.Y)" left the ACTIVE
|
|
4000
|
+
* milestone's own table unrecognized as its own.
|
|
4001
|
+
*
|
|
4002
|
+
* A second gap the same title-widening exposes: a LEVEL-2 own Progress
|
|
4003
|
+
* heading whose title EMBEDS the active milestone's own version token
|
|
4004
|
+
* ("## Progress (v2.1)", level 2, same level as the milestone heading
|
|
4005
|
+
* itself) is exactly what `currentMilestoneRawRanges`' own section-end scan
|
|
4006
|
+
* mistakes for a NEW milestone's heading (it stops at any heading matching
|
|
4007
|
+
* `v\d+\.\d+|✅|📋|🚧`, not just a real milestone one) — ending `ranges`
|
|
4008
|
+
* immediately BEFORE this heading, so it never falls inside
|
|
4009
|
+
* `ranges.primary`/`.details` even once titled "Progress". A heading is
|
|
4010
|
+
* never titled merely "Progress" AND is a different milestone's own
|
|
4011
|
+
* section-opening heading at once, so a Progress-titled heading sitting
|
|
4012
|
+
* EXACTLY at the wide range's own end boundary is still the active
|
|
4013
|
+
* milestone's own — the range ended there only because this heading's own
|
|
4014
|
+
* suffix looked like a marker, not because a real, different milestone
|
|
4015
|
+
* heading intervened.
|
|
4016
|
+
*/
|
|
4017
|
+
function bracketOwnProgressSectionRanges(content, ranges, headings) {
|
|
4018
|
+
if (!ranges)
|
|
4019
|
+
return [];
|
|
4020
|
+
const isProgressHeading = (h) => (0, roadmap_progress_cjs_1.isProgressHeadingTitle)(h.text);
|
|
4021
|
+
const withinActive = (offset) => (offset >= ranges.primary.start && offset < ranges.primary.end)
|
|
4022
|
+
|| Boolean(ranges.details && offset >= ranges.details.start && offset < ranges.details.end)
|
|
4023
|
+
|| offset === ranges.primary.end
|
|
4024
|
+
|| Boolean(ranges.details && offset === ranges.details.end);
|
|
4025
|
+
const out = [];
|
|
4026
|
+
for (const h of headings) {
|
|
4027
|
+
if (!isProgressHeading(h) || !withinActive(h.offset))
|
|
4028
|
+
continue;
|
|
4029
|
+
out.push({ start: h.offset, end: bracketMilestoneOwnTableEnd(content, h.offset, headings) });
|
|
4030
|
+
}
|
|
4031
|
+
return out;
|
|
4032
|
+
}
|
|
4033
|
+
// #4304: shared "tolerant" trailing boundary for the
|
|
4034
|
+
// reporting-only detection regexes below. `(?!\d|\.\d)` blocks extending
|
|
4035
|
+
// into more digits or a ".digit" continuation (so a bare identity never
|
|
4036
|
+
// falsely matches inside a longer number or a different decimal sibling),
|
|
4037
|
+
// but — unlike the rewrite functions' own boundary — permits a following
|
|
4038
|
+
// '.', '-', letter, or end of line, so a genuinely stale mention survives
|
|
4039
|
+
// detection even when it sits before sentence-final punctuation or is
|
|
4040
|
+
// embedded in a directory-name suffix the rewriter deliberately declines
|
|
4041
|
+
// to touch (detection must be allowed to find more than the rewriter is
|
|
4042
|
+
// safe to fix).
|
|
4043
|
+
const BRACKET_REPORT_TOLERANT_BOUNDARY_SRC = '(?!\\d|\\.\\d)';
|
|
4044
|
+
function bracketQualifiedMentionedInLine(line, id) {
|
|
4045
|
+
const targetDisplay = renderPhaseId(id);
|
|
4046
|
+
if (tokenizePhaseDependencyReferences(line, 'bracket')
|
|
4047
|
+
.some((token) => token.kind === 'qualified' && token.token === targetDisplay))
|
|
4048
|
+
return true;
|
|
4049
|
+
const dash = dashBracketPhaseId(id);
|
|
4050
|
+
return new RegExp(`(?<![A-Za-z0-9.-])${(0, pattern_cjs_1.escapeRegex)(dash)}${BRACKET_REPORT_TOLERANT_BOUNDARY_SRC}`).test(line);
|
|
4051
|
+
}
|
|
4052
|
+
function bracketLegacyPhaseMentionedInLine(line, id) {
|
|
4053
|
+
const token = bracketArtifactToken(id);
|
|
4054
|
+
return new RegExp(
|
|
4055
|
+
// #4304: a bracket-QUALIFIED, labeled mention
|
|
4056
|
+
// ("[CK.02] Phase 03") is handled completely by
|
|
4057
|
+
// replaceQualifiedBracketReference now — it is never "the legacy bare
|
|
4058
|
+
// 'Phase NN' spelling this detector exists for. Without the negative
|
|
4059
|
+
// lookbehind, the token search matched INSIDE that qualified mention
|
|
4060
|
+
// too (nothing distinguished "Phase 03" preceded by "[CK.02] " from a
|
|
4061
|
+
// genuinely unqualified "Phase 03:" heading), so a correctly-renumbered
|
|
4062
|
+
// labeled line was reported as a dangling reference to the OLD id it no
|
|
4063
|
+
// longer contains.
|
|
4064
|
+
`(?<!\\][ \\t]{0,10})\\bPhase[ \\t]+${(0, pattern_cjs_1.escapeRegex)(token)}${BRACKET_REPORT_TOLERANT_BOUNDARY_SRC}`, 'i').test(line);
|
|
4065
|
+
}
|
|
4066
|
+
function bracketArtifactMentionedInLine(line, id) {
|
|
4067
|
+
const token = bracketArtifactToken(id);
|
|
4068
|
+
const filename = `${(0, pattern_cjs_1.escapeRegex)(token)}-\\d{2}(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\\.md`;
|
|
4069
|
+
return new RegExp(`(?<![A-Za-z0-9_./-])${filename}(?![A-Za-z0-9_.-])`).test(line);
|
|
4070
|
+
}
|
|
4071
|
+
/**
|
|
4072
|
+
* #4304: computed from the ORIGINAL (pre-rewrite) line, never
|
|
4073
|
+
* the persisted content: re-searching the PERSISTED text for a
|
|
4074
|
+
* pre-renumber id produces false positives whenever two or more phases
|
|
4075
|
+
* shift (a later phase's NEW value collides textually with an earlier
|
|
4076
|
+
* phase's OLD value).
|
|
4077
|
+
*
|
|
4078
|
+
* The removed identity is a dangling reference by construction wherever it
|
|
4079
|
+
* is mentioned — nothing ever rewrites a reference to a deleted phase — so
|
|
4080
|
+
* every spelling is checked directly with the tolerant boundary. A
|
|
4081
|
+
* renumbered (old -> new) identity is "left untouched" only if running the
|
|
4082
|
+
* SAME rewrite this line would actually receive still leaves a mention of
|
|
4083
|
+
* the old identity behind afterward: the qualified-reference and
|
|
4084
|
+
* bare-artifact rewriters have their own, stricter boundaries (e.g. they
|
|
4085
|
+
* decline to rewrite immediately before a sentence-final period, or a
|
|
4086
|
+
* bare token embedded in a directory-path segment), so a mention can be
|
|
4087
|
+
* real and still survive the rewrite. The legacy "Phase NN" spelling is
|
|
4088
|
+
* never rewritten by any bracket rewriter, so it is always checked
|
|
4089
|
+
* directly for a renumbered identity too.
|
|
4090
|
+
*/
|
|
4091
|
+
function lineContainsTrackedBracketIdentity(originalLine, removedId, mapping) {
|
|
4092
|
+
if (bracketQualifiedMentionedInLine(originalLine, removedId)
|
|
4093
|
+
|| bracketLegacyPhaseMentionedInLine(originalLine, removedId)
|
|
4094
|
+
|| bracketArtifactMentionedInLine(originalLine, removedId)) {
|
|
4095
|
+
return true;
|
|
4096
|
+
}
|
|
4097
|
+
for (const { oldId, newId } of mapping) {
|
|
4098
|
+
if (bracketLegacyPhaseMentionedInLine(originalLine, oldId))
|
|
4099
|
+
return true;
|
|
4100
|
+
const afterQualified = replaceQualifiedBracketReference(originalLine, oldId, newId);
|
|
4101
|
+
if (bracketQualifiedMentionedInLine(afterQualified, oldId))
|
|
4102
|
+
return true;
|
|
4103
|
+
const afterArtifact = replaceBareBracketArtifactReference(originalLine, oldId, newId);
|
|
4104
|
+
if (bracketArtifactMentionedInLine(afterArtifact, oldId))
|
|
4105
|
+
return true;
|
|
4106
|
+
}
|
|
4107
|
+
return false;
|
|
4108
|
+
}
|
|
4109
|
+
/**
|
|
4110
|
+
* #4304: maximum deletion boundary for a selected bracket phase heading.
|
|
4111
|
+
* The active milestone range is the outer bound. Inside it, an HTML details
|
|
4112
|
+
* boundary wins sooner. The shared fence-aware depth tracker preserves the
|
|
4113
|
+
* closing tag of the exact container that encloses the target, or the opening
|
|
4114
|
+
* tag of a container that starts after an outside target. Nested details do
|
|
4115
|
+
* not count until their own close has restored the target's original depth.
|
|
4116
|
+
* The deletion extent also stops at the next distinct phase heading recognized
|
|
4117
|
+
* by the reader, whatever its heading depth; ordinary deeper subheadings remain
|
|
4118
|
+
* part of the target section.
|
|
4119
|
+
*/
|
|
4120
|
+
function bracketPhaseDeletionContainerBoundary(content, targetOffset) {
|
|
4121
|
+
const tags = trackRoadmapDetails(content).tags;
|
|
4122
|
+
let targetDepth = 0;
|
|
4123
|
+
for (const tag of tags) {
|
|
4124
|
+
if (tag.offset >= targetOffset)
|
|
4125
|
+
break;
|
|
4126
|
+
targetDepth = tag.depthAfter;
|
|
4127
|
+
}
|
|
4128
|
+
for (const tag of tags) {
|
|
4129
|
+
if (tag.offset < targetOffset)
|
|
4130
|
+
continue;
|
|
4131
|
+
if (targetDepth === 0 && !tag.closing)
|
|
4132
|
+
return tag.lineStart;
|
|
4133
|
+
if (targetDepth > 0 && tag.closing && tag.depthBefore === targetDepth)
|
|
4134
|
+
return tag.lineStart;
|
|
4135
|
+
}
|
|
4136
|
+
return null;
|
|
4137
|
+
}
|
|
4138
|
+
/**
|
|
4139
|
+
* #4304: the pure ROADMAP rewrite for a bracket removal, computed from the
|
|
4140
|
+
* content alone so `phase remove` can run it before any mutation and refuse
|
|
4141
|
+
* on what it would leave behind. `survivingTargetRows` lists every live
|
|
4142
|
+
* (unfenced, not historical) pipe-table row keyed by the removed identity
|
|
4143
|
+
* that the rewrite keeps: a shared Progress table the scope rules cannot
|
|
4144
|
+
* attribute, or a non-Progress table such as a phase-keyed traceability
|
|
4145
|
+
* table. Keeping such a row leaves a dangling identity that the renumbered
|
|
4146
|
+
* sibling then duplicates; deleting it could drop data the row carries.
|
|
4147
|
+
*/
|
|
4148
|
+
function planBracketRoadmapRemoval(originalContent, targetId, mapping, cwd) {
|
|
4149
|
+
// #4304: scope the section deletion to the active
|
|
4150
|
+
// milestone's own ranges (the SAME primary+details discovery the
|
|
4151
|
+
// checklist-row deletion below already uses), computed from the
|
|
4152
|
+
// content BEFORE deletion. Without this, deleteSection removes the
|
|
4153
|
+
// FIRST matching heading in the whole document: a shipped milestone
|
|
4154
|
+
// and the active one sharing the same bracket code (milestoneToken
|
|
4155
|
+
// folds e.g. v2.0 and v2.1 to one [CK.02]) let the shipped section's
|
|
4156
|
+
// own detail heading be deleted while the active one survives.
|
|
4157
|
+
const preDeleteRanges = currentMilestoneRawRanges(originalContent, cwd, 'bracket');
|
|
4158
|
+
// #4304: historical protection applies while SELECTING the section, not
|
|
4159
|
+
// only during the later per-line rewrite. A closed details archive can
|
|
4160
|
+
// sit inside the raw active-milestone range and carry the same folded
|
|
4161
|
+
// bracket id as the live phase; choosing that heading first deletes
|
|
4162
|
+
// history and leaves the live target behind.
|
|
4163
|
+
const preDeleteHistoricalLineStarts = archivedOrClosedMilestoneLineStarts(originalContent);
|
|
4164
|
+
const isTargetHeading = (heading) => {
|
|
4165
|
+
// #4304: classify the heading through the SAME shared
|
|
4166
|
+
// owned-line grammar (classifyBracketOwnedLine / BRACKET_HEADING_LINE_RE)
|
|
4167
|
+
// the checklist/progress-row deletion below already uses, instead of
|
|
4168
|
+
// a literal `startsWith(targetDisplay)`; that comparison only ever
|
|
4169
|
+
// recognized the display spelling ("[CK.02] 02"), so the read-grammar-
|
|
4170
|
+
// admitted labeled spelling ("[CK.02] Phase 02:", pinned at
|
|
4171
|
+
// tests/adr-612-bracket-grammar.test.cjs:644) was never matched here
|
|
4172
|
+
// and its detail section survived a "removal" that deleted every
|
|
4173
|
+
// other owned line for the same identity.
|
|
4174
|
+
if (heading.level < 2 || heading.level > 4)
|
|
4175
|
+
return false;
|
|
4176
|
+
const headingLine = '#'.repeat(heading.level) + ' ' + heading.text;
|
|
4177
|
+
const owned = classifyBracketOwnedLine(headingLine);
|
|
4178
|
+
if (owned.kind !== 'heading' || !owned.id || !sameBracketPhaseId(owned.id, targetId)) {
|
|
4179
|
+
return false;
|
|
4180
|
+
}
|
|
4181
|
+
if (preDeleteHistoricalLineStarts.has(heading.offset))
|
|
4182
|
+
return false;
|
|
4183
|
+
if (!preDeleteRanges)
|
|
4184
|
+
return true;
|
|
4185
|
+
return ((heading.offset >= preDeleteRanges.primary.start && heading.offset < preDeleteRanges.primary.end)
|
|
4186
|
+
|| Boolean(preDeleteRanges.details
|
|
4187
|
+
&& heading.offset >= preDeleteRanges.details.start
|
|
4188
|
+
&& heading.offset < preDeleteRanges.details.end));
|
|
4189
|
+
};
|
|
4190
|
+
const selectedTargetHeading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(originalContent).find(isTargetHeading);
|
|
4191
|
+
let deletionEndOffset;
|
|
4192
|
+
if (selectedTargetHeading) {
|
|
4193
|
+
const nextDistinctPhaseHeading = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(originalContent).find((heading) => heading.offset > selectedTargetHeading.offset
|
|
4194
|
+
&& isDistinctReaderPhaseHeading(heading, targetId));
|
|
4195
|
+
const containingRange = preDeleteRanges
|
|
4196
|
+
? [preDeleteRanges.primary, ...(preDeleteRanges.details ? [preDeleteRanges.details] : [])]
|
|
4197
|
+
.find((range) => selectedTargetHeading.offset >= range.start && selectedTargetHeading.offset < range.end)
|
|
4198
|
+
: null;
|
|
4199
|
+
const containerBoundary = bracketPhaseDeletionContainerBoundary(originalContent, selectedTargetHeading.offset);
|
|
4200
|
+
const historicalBoundary = [...preDeleteHistoricalLineStarts]
|
|
4201
|
+
.filter((offset) => offset > selectedTargetHeading.offset)
|
|
4202
|
+
.sort((a, b) => a - b)[0];
|
|
4203
|
+
deletionEndOffset = Math.min(containingRange?.end ?? originalContent.length, containerBoundary ?? originalContent.length, historicalBoundary ?? originalContent.length, nextDistinctPhaseHeading?.offset ?? originalContent.length);
|
|
4204
|
+
}
|
|
4205
|
+
let content = (0, markdown_sectionizer_cjs_1.deleteSection)(originalContent, isTargetHeading, { endOffset: deletionEndOffset });
|
|
4206
|
+
let roadmapLinesRewritten = content === originalContent ? 0 : 1;
|
|
4207
|
+
const ranges = currentMilestoneRawRanges(content, cwd, 'bracket');
|
|
4208
|
+
// #4304: progress/table-row deletion is
|
|
4209
|
+
// scoped to the active milestone's OWN table content
|
|
4210
|
+
// (bracketMilestoneOwnTableEnd, narrower than `ranges` itself, see its
|
|
4211
|
+
// own doc comment) plus its own "Progress"-titled heading found ANYWHERE
|
|
4212
|
+
// in its ranges (bracketOwnProgressSectionRanges, additive:
|
|
4213
|
+
// covers a `## Notes` aside or a per-milestone `## Progress` the plain
|
|
4214
|
+
// own-table-end closes over too early) plus a document-level
|
|
4215
|
+
// `## Progress` section that is not itself owned by a DIFFERENT
|
|
4216
|
+
// milestone (legacy's own #2012 scope, plus an ownership gate),
|
|
4217
|
+
// never the whole document, so a same-identity row in another pipe table
|
|
4218
|
+
// (a shipped milestone sharing the same bracket code, an unrelated
|
|
4219
|
+
// Requirements Traceability table) is never deleted.
|
|
4220
|
+
const headingsForOwnTable = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
4221
|
+
const progressSectionRange = bracketProgressSectionRange(content);
|
|
4222
|
+
const ownProgressSectionRanges = bracketOwnProgressSectionRanges(content, ranges, headingsForOwnTable);
|
|
4223
|
+
const progressSectionOwnedElsewhere = progressSectionRange
|
|
4224
|
+
? bracketProgressSectionOwnedByOtherMilestone(content, progressSectionRange.start, ranges, ownProgressSectionRanges)
|
|
4225
|
+
: false;
|
|
4226
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
|
|
4227
|
+
const fencedLineNumbers = fencedRoadmapLineNumbers(content);
|
|
4228
|
+
// A row inside a Progress-titled section that the ownership rule assigns
|
|
4229
|
+
// to a different milestone is that milestone's own record: kept, and not
|
|
4230
|
+
// a surviving row of the removed identity.
|
|
4231
|
+
const progressSections = headingsForOwnTable
|
|
4232
|
+
.filter((heading) => (0, roadmap_progress_cjs_1.isProgressHeadingTitle)(heading.text))
|
|
4233
|
+
.map((heading) => ({
|
|
4234
|
+
start: heading.offset,
|
|
4235
|
+
end: bracketMilestoneOwnTableEnd(content, heading.offset, headingsForOwnTable),
|
|
4236
|
+
ownedElsewhere: bracketProgressSectionOwnedByOtherMilestone(content, heading.offset, ranges, ownProgressSectionRanges),
|
|
4237
|
+
}));
|
|
4238
|
+
const inProgressSectionOwnedByOtherMilestone = (lineStart) => {
|
|
4239
|
+
const enclosing = progressSections.filter((section) => lineStart >= section.start && lineStart < section.end);
|
|
4240
|
+
return enclosing.length > 0 && enclosing[enclosing.length - 1].ownedElsewhere;
|
|
4241
|
+
};
|
|
4242
|
+
// A row is a Progress-table row only when the nearest heading above it is
|
|
4243
|
+
// a Progress-titled heading, a phase heading (the bare table that follows
|
|
4244
|
+
// the phase list), or the milestone or details heading that opens the
|
|
4245
|
+
// window. Any other heading, at any level, opens a different table (a
|
|
4246
|
+
// phase-keyed Requirements Traceability table), whose rows are never
|
|
4247
|
+
// deleted: a row keyed by the removed identity there refuses the removal.
|
|
4248
|
+
const windowAnchors = new Set(ranges ? [ranges.primary.start, ...(ranges.details ? [ranges.details.start] : [])] : []);
|
|
4249
|
+
const nearestHeadingKeepsProgressTable = (lineStart) => {
|
|
4250
|
+
let nearest;
|
|
4251
|
+
for (const heading of headingsForOwnTable) {
|
|
4252
|
+
if (heading.offset >= lineStart)
|
|
4253
|
+
break;
|
|
4254
|
+
nearest = heading;
|
|
2067
4255
|
}
|
|
2068
|
-
if (!
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
// matching the trimmed value (F8 #2245 review: two rows with identical
|
|
2125
|
-
// trimmed Phase text, or a row whose already-rewritten new value
|
|
2126
|
-
// coincides with another row's pre-edit text, would otherwise resolve
|
|
2127
|
-
// to the WRONG row's padding — the first/leftmost content match found).
|
|
2128
|
-
// The lookup searches for `escapeCell(current)` (F3 #2245 review: the
|
|
2129
|
-
// ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
|
|
2130
|
-
// escaped form, so searching for the unescaped `current` would
|
|
2131
|
-
// silently fail to find an escaped-pipe cell's own line — preserving
|
|
2132
|
-
// every other byte of the row (ADR-2143 §7 byte-parity) while only the
|
|
2133
|
-
// digits actually change.
|
|
2134
|
-
const ordinalHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
|
|
2135
|
-
if (ordinalHeadingMatch && ordinalHeadingMatch.index !== undefined) {
|
|
2136
|
-
const ordinalHeadingOffset = ordinalHeadingMatch.index;
|
|
2137
|
-
const ordinalBefore = content.slice(0, ordinalHeadingOffset);
|
|
2138
|
-
const ordinalFromHeading = content.slice(ordinalHeadingOffset);
|
|
2139
|
-
const ordinalNextHeadingOffset = ordinalFromHeading.search(/\n#{1,2}[ \t]/);
|
|
2140
|
-
let ordinalSection = ordinalNextHeadingOffset >= 0
|
|
2141
|
-
? ordinalFromHeading.slice(0, ordinalNextHeadingOffset)
|
|
2142
|
-
: ordinalFromHeading;
|
|
2143
|
-
const ordinalRest = ordinalNextHeadingOffset >= 0 ? ordinalFromHeading.slice(ordinalNextHeadingOffset) : '';
|
|
2144
|
-
const phaseCellShapeRe = /^(\d+)(\.\s)/;
|
|
2145
|
-
const processedOrdinalRows = new Set();
|
|
2146
|
-
let matchedRowIndex = null;
|
|
2147
|
-
for (;;) {
|
|
2148
|
-
matchedRowIndex = null;
|
|
2149
|
-
const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
|
|
2150
|
-
if (processedOrdinalRows.has(index))
|
|
2151
|
-
return false;
|
|
2152
|
-
const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
|
|
2153
|
-
if (!m)
|
|
2154
|
-
return false;
|
|
2155
|
-
const num = parseInt(m[1], 10);
|
|
2156
|
-
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
2157
|
-
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
2158
|
-
return false;
|
|
2159
|
-
processedOrdinalRows.add(index);
|
|
2160
|
-
matchedRowIndex = index;
|
|
2161
|
-
return true;
|
|
2162
|
-
}, 'Phase', (current) => {
|
|
2163
|
-
const m = phaseCellShapeRe.exec(current);
|
|
2164
|
-
if (!m)
|
|
2165
|
-
return current;
|
|
2166
|
-
const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
|
|
2167
|
-
const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
|
|
2168
|
-
const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
|
|
2169
|
-
const padMatch = targetLine
|
|
2170
|
-
? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
2171
|
-
: null;
|
|
2172
|
-
const leadPad = padMatch ? padMatch[1] : ' ';
|
|
2173
|
-
const trailPad = padMatch ? padMatch[2] : ' ';
|
|
2174
|
-
return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
|
|
2175
|
-
});
|
|
2176
|
-
if (!cellResult.ok)
|
|
2177
|
-
break;
|
|
2178
|
-
ordinalSection = cellResult.value;
|
|
2179
|
-
}
|
|
2180
|
-
content = ordinalBefore + ordinalSection + ordinalRest;
|
|
4256
|
+
if (!nearest || windowAnchors.has(nearest.offset))
|
|
4257
|
+
return true;
|
|
4258
|
+
if ((0, roadmap_progress_cjs_1.isProgressHeadingTitle)(nearest.text))
|
|
4259
|
+
return true;
|
|
4260
|
+
return classifyBracketOwnedLine('#'.repeat(nearest.level) + ' ' + nearest.text).kind === 'heading';
|
|
4261
|
+
};
|
|
4262
|
+
// #4304: the referencesLeftUntouched report is computed
|
|
4263
|
+
// from each KEPT line's ORIGINAL (pre-rewrite) text, never the
|
|
4264
|
+
// persisted (already-rewritten) content: re-searching persisted text
|
|
4265
|
+
// for a pre-renumber id produces false positives whenever two or more
|
|
4266
|
+
// phases shift (a later phase's NEW value collides textually with an
|
|
4267
|
+
// earlier phase's OLD value). A line
|
|
4268
|
+
// that gets DELETED here (the target's own owned heading/checklist/
|
|
4269
|
+
// progress row) can never be "left untouched": it does not exist in
|
|
4270
|
+
// the output at all, so only kept lines are considered.
|
|
4271
|
+
const keptOriginalLines = [];
|
|
4272
|
+
const survivingTargetRows = [];
|
|
4273
|
+
const rewritten = [];
|
|
4274
|
+
for (const line of splitRoadmapLineRecords(content)) {
|
|
4275
|
+
const active = lineStartsInActiveMilestone(line.start, ranges);
|
|
4276
|
+
const historical = historicalLineStarts.has(line.start);
|
|
4277
|
+
const fenced = fencedLineNumbers.has(line.lineNumber);
|
|
4278
|
+
if (fenced) {
|
|
4279
|
+
rewritten.push(line.text + line.eol);
|
|
4280
|
+
keptOriginalLines.push({ text: line.text, active: false });
|
|
4281
|
+
continue;
|
|
4282
|
+
}
|
|
4283
|
+
const owned = classifyBracketOwnedLine(line.text);
|
|
4284
|
+
const underProgressTableHeading = owned.kind === 'progress' && nearestHeadingKeepsProgressTable(line.start);
|
|
4285
|
+
const inMilestoneOwnTable = underProgressTableHeading
|
|
4286
|
+
&& (lineStartsInMilestoneOwnTable(content, line.start, ranges, headingsForOwnTable)
|
|
4287
|
+
|| ownProgressSectionRanges.some((r) => line.start >= r.start && line.start < r.end));
|
|
4288
|
+
const inProgressSection = underProgressTableHeading && Boolean(progressSectionRange
|
|
4289
|
+
&& !progressSectionOwnedElsewhere
|
|
4290
|
+
&& line.start >= progressSectionRange.start
|
|
4291
|
+
&& line.start < progressSectionRange.end);
|
|
4292
|
+
if (!historical
|
|
4293
|
+
&& owned.id
|
|
4294
|
+
&& sameBracketPhaseId(owned.id, targetId)
|
|
4295
|
+
&& ((active && owned.kind === 'checklist') || inMilestoneOwnTable || inProgressSection)) {
|
|
4296
|
+
roadmapLinesRewritten += 1;
|
|
4297
|
+
continue;
|
|
4298
|
+
}
|
|
4299
|
+
if (!historical
|
|
4300
|
+
&& owned.kind === 'progress'
|
|
4301
|
+
&& owned.id
|
|
4302
|
+
&& sameBracketPhaseId(owned.id, targetId)
|
|
4303
|
+
&& !inProgressSectionOwnedByOtherMilestone(line.start)) {
|
|
4304
|
+
survivingTargetRows.push(line.text);
|
|
4305
|
+
}
|
|
4306
|
+
let next = line.text;
|
|
4307
|
+
if (!historical) {
|
|
4308
|
+
for (const { oldId, newId } of mapping) {
|
|
4309
|
+
next = replaceQualifiedBracketReference(next, oldId, newId);
|
|
4310
|
+
if (active)
|
|
4311
|
+
next = replaceBareBracketArtifactReference(next, oldId, newId);
|
|
2181
4312
|
}
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
4313
|
+
}
|
|
4314
|
+
if (next !== line.text)
|
|
4315
|
+
roadmapLinesRewritten += 1;
|
|
4316
|
+
rewritten.push(next + line.eol);
|
|
4317
|
+
// A historical line can be physically inside the raw active milestone
|
|
4318
|
+
// range (closed details nested below the live milestone heading). It is
|
|
4319
|
+
// intentionally exempt from this mutation, so it is not an active
|
|
4320
|
+
// dangling reference for the removal report either.
|
|
4321
|
+
keptOriginalLines.push({ text: line.text, active: active && !historical });
|
|
4322
|
+
}
|
|
4323
|
+
content = rewritten.join('');
|
|
4324
|
+
return { content, roadmapLinesRewritten, keptOriginalLines, survivingTargetRows };
|
|
4325
|
+
}
|
|
4326
|
+
/**
|
|
4327
|
+
* Remove the active bracket phase and renumber its later identities. Qualified
|
|
4328
|
+
* references are rewritten roadmap-wide, including global sections and other
|
|
4329
|
+
* project-code milestone sections, except for lines inside archived details or
|
|
4330
|
+
* closed milestone sections. Those historical lines are never rewritten or
|
|
4331
|
+
* deleted. Bare artifact references remain limited to the active milestone.
|
|
4332
|
+
*/
|
|
4333
|
+
function updateRoadmapAfterBracketPhaseRemoval(roadmapPath, removedInt, removedSubphase, context, mapping, cwd) {
|
|
4334
|
+
return withPlanningLock(cwd, () => {
|
|
4335
|
+
const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
4336
|
+
const targetId = bracketPhaseId(context, removedInt, removedSubphase);
|
|
4337
|
+
const { content, roadmapLinesRewritten, keptOriginalLines } = planBracketRoadmapRemoval(originalContent, targetId, mapping, cwd);
|
|
4338
|
+
const bracketNormalization = { preserveFencedMarkdownStructure: true };
|
|
4339
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content, bracketNormalization);
|
|
4340
|
+
// platformWriteSync's own markdown normalization (_normalizeMd) inserts
|
|
4341
|
+
// blank lines around headings/fences/lists and collapses runs of 3+
|
|
4342
|
+
// blank lines, so a KEPT line's position here can shift from its
|
|
4343
|
+
// position in `keptOriginalLines`. Normalization only ever adds or
|
|
4344
|
+
// collapses BLANK lines — it never reorders or edits a non-blank
|
|
4345
|
+
// line's text — so the Nth non-blank kept (original) line always
|
|
4346
|
+
// corresponds to the Nth non-blank persisted line; blank kept lines
|
|
4347
|
+
// are skipped entirely since they can never contain a tracked identity.
|
|
4348
|
+
const persistedContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
4349
|
+
const persistedNonBlankLineNumbers = splitRoadmapLineRecords(persistedContent)
|
|
4350
|
+
.filter((line) => line.text.trim() !== '')
|
|
4351
|
+
.map((line) => line.lineNumber);
|
|
4352
|
+
const referencesLeftUntouched = [];
|
|
4353
|
+
let nonBlankIndex = 0;
|
|
4354
|
+
for (const { text, active } of keptOriginalLines) {
|
|
4355
|
+
if (text.trim() === '')
|
|
4356
|
+
continue;
|
|
4357
|
+
const persistedLineNumber = persistedNonBlankLineNumbers[nonBlankIndex];
|
|
4358
|
+
nonBlankIndex += 1;
|
|
4359
|
+
if (!active || persistedLineNumber === undefined)
|
|
4360
|
+
continue;
|
|
4361
|
+
if (lineContainsTrackedBracketIdentity(text, targetId, mapping)) {
|
|
4362
|
+
referencesLeftUntouched.push(persistedLineNumber);
|
|
2228
4363
|
}
|
|
2229
4364
|
}
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
// different-but-equivalent shape than the already-normalized on-disk
|
|
2236
|
-
// original (same normalization-order artifact #3685 fixed at
|
|
2237
|
-
// cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
|
|
2238
|
-
return (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content);
|
|
4365
|
+
return {
|
|
4366
|
+
updated: (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content, bracketNormalization),
|
|
4367
|
+
roadmapLinesRewritten,
|
|
4368
|
+
referencesLeftUntouched,
|
|
4369
|
+
};
|
|
2239
4370
|
});
|
|
2240
4371
|
}
|
|
2241
4372
|
/**
|
|
2242
|
-
* #
|
|
2243
|
-
*
|
|
2244
|
-
*
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
2247
|
-
*
|
|
2248
|
-
*
|
|
2249
|
-
*
|
|
2250
|
-
*
|
|
2251
|
-
* former behavior for that shape.
|
|
2252
|
-
*
|
|
2253
|
-
* The block is the one `locateFrontmatterFence` finds (the one fence owner), so this writer
|
|
2254
|
-
* and every STATE.md reader agree on where the body starts. The blank line and the field go
|
|
2255
|
-
* right after the closing fence line, ended by that line's own line ending — so a CRLF file
|
|
2256
|
-
* gains CRLF lines only (#3572 review: a blanket re-join on '\r\n' doubled every carriage
|
|
2257
|
-
* return; joining the new lines on a bare '\n' mixed line endings).
|
|
4373
|
+
* #4304: phase ids the reader lists but the bracket grammar cannot represent
|
|
4374
|
+
* (a letter suffix such as `02a`, or a three-level `02.01.01`) are invisible
|
|
4375
|
+
* to the renumber mapping and the owned-line rewrite. Removing a phase at or
|
|
4376
|
+
* below them would leave them on their old numbers beside renumbered
|
|
4377
|
+
* siblings, so they are reported here, from the live (unfenced, not
|
|
4378
|
+
* historical) lines of the active milestone, for the caller to refuse.
|
|
4379
|
+
* An integer removal is affected by every such id whose leading number is
|
|
4380
|
+
* at or above the removed one; a sub-phase removal by those sharing its
|
|
4381
|
+
* leading number.
|
|
2258
4382
|
*/
|
|
2259
|
-
function
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
4383
|
+
function unrepresentableBracketIdsAffectedByRemoval(content, ranges, removedInt, removedSubphase) {
|
|
4384
|
+
if (!ranges)
|
|
4385
|
+
return [];
|
|
4386
|
+
const historicalLineStarts = archivedOrClosedMilestoneLineStarts(content);
|
|
4387
|
+
const fencedLineNumbers = fencedRoadmapLineNumbers(content);
|
|
4388
|
+
const liveActive = splitRoadmapLineRecords(content)
|
|
4389
|
+
.map((line) => (lineStartsInActiveMilestone(line.start, ranges)
|
|
4390
|
+
&& !historicalLineStarts.has(line.start)
|
|
4391
|
+
&& !fencedLineNumbers.has(line.lineNumber)
|
|
4392
|
+
? line.text
|
|
4393
|
+
: ''))
|
|
4394
|
+
.join('\n');
|
|
4395
|
+
const affected = [];
|
|
4396
|
+
for (const id of scanMilestonePhaseIds(liveActive, 'bracket')) {
|
|
4397
|
+
// The owned-line grammar canonicalizes each numeric segment through
|
|
4398
|
+
// `phaseToken` (`002` reads as `02`), so representable means one or two
|
|
4399
|
+
// numeric segments that each canonicalize.
|
|
4400
|
+
const segments = String(id).split('.');
|
|
4401
|
+
if (segments.length <= 2 && segments.every((segment) => /^\d+$/.test(segment) && phaseToken(segment) !== null)) {
|
|
4402
|
+
continue;
|
|
4403
|
+
}
|
|
4404
|
+
const leading = /^(\d+)/.exec(String(id));
|
|
4405
|
+
if (!leading)
|
|
4406
|
+
continue;
|
|
4407
|
+
const phase = Number(leading[1]);
|
|
4408
|
+
if (removedSubphase === undefined ? phase >= removedInt : phase === removedInt)
|
|
4409
|
+
affected.push(String(id));
|
|
2264
4410
|
}
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
4411
|
+
return affected;
|
|
4412
|
+
}
|
|
4413
|
+
/**
|
|
4414
|
+
* #4304: `phase remove` adjusts only STATE.md's phase counts. When STATE.md's
|
|
4415
|
+
* current phase (the canonical frontmatter, `Current Phase`, scoped prose
|
|
4416
|
+
* ladder) is the removed identity or one the removal renumbers, every
|
|
4417
|
+
* current-phase field would keep naming the old identity, which afterwards
|
|
4418
|
+
* belongs to a different phase. Returns that current phase for the caller to
|
|
4419
|
+
* refuse on, or null.
|
|
4420
|
+
*/
|
|
4421
|
+
function stateCurrentPhaseAffectedByBracketRemoval(cwd, targetId, mapping) {
|
|
4422
|
+
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
4423
|
+
if (!node_fs_1.default.existsSync(statePath))
|
|
4424
|
+
return null;
|
|
4425
|
+
const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
|
|
4426
|
+
const fm = frontmatterMod.extractFrontmatter(rawState, statePath);
|
|
4427
|
+
const current = stateMod.resolveCurrentPhaseId(fm, frontmatterMod.stripFrontmatter(rawState));
|
|
4428
|
+
if (current === null || current === undefined)
|
|
4429
|
+
return null;
|
|
4430
|
+
let currentToken = String(current).trim();
|
|
4431
|
+
if (currentToken.includes('-')) {
|
|
4432
|
+
try {
|
|
4433
|
+
currentToken = renderPhaseId(parsePhaseId(currentToken));
|
|
4434
|
+
}
|
|
4435
|
+
catch {
|
|
4436
|
+
return null;
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
const match = new RegExp(`^(?:\\[(${phaseIdMod.BRACKET_PROJECT_CODE_SRC})\\.(\\d+)\\][ \\t]*)?(\\d+)(?:\\.(\\d+))?$`, 'i').exec(currentToken);
|
|
4440
|
+
if (!match)
|
|
4441
|
+
return null;
|
|
4442
|
+
// A current phase qualified with another project or milestone is not the
|
|
4443
|
+
// active milestone's phase, whatever its number.
|
|
4444
|
+
if (match[1] !== undefined
|
|
4445
|
+
&& (match[1].toUpperCase() !== targetId.project || Number(match[2]) !== Number(targetId.milestone))) {
|
|
4446
|
+
return null;
|
|
4447
|
+
}
|
|
4448
|
+
const phase = Number(match[3]);
|
|
4449
|
+
const subphase = match[4] === undefined ? undefined : Number(match[4]);
|
|
4450
|
+
const sameNumber = (id) => Number(id.phase) === phase
|
|
4451
|
+
&& (id.subphase === undefined ? subphase === undefined : Number(id.subphase) === subphase);
|
|
4452
|
+
return sameNumber(targetId) || mapping.some(({ oldId }) => sameNumber(oldId)) ? String(current).trim() : null;
|
|
2268
4453
|
}
|
|
2269
4454
|
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
2270
4455
|
if (!targetPhase)
|
|
@@ -2273,22 +4458,78 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
2273
4458
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
2274
4459
|
if (!node_fs_1.default.existsSync(roadmapPath))
|
|
2275
4460
|
error('ROADMAP.md not found');
|
|
2276
|
-
const normalized = normalizePhaseName(targetPhase);
|
|
2277
|
-
const isDecimal = targetPhase.includes('.');
|
|
2278
4461
|
const force = options.force || false;
|
|
4462
|
+
const removeConvention = resolvePhaseIdConvention(cwd);
|
|
4463
|
+
const removeContext = removeConvention === 'bracket'
|
|
4464
|
+
? bracketWriteContext(cwd, loadConfig(cwd))
|
|
4465
|
+
: null;
|
|
4466
|
+
// #4304: canonicalize every bracket argument before
|
|
4467
|
+
// directory matching or any write, through the SAME adapter `phase
|
|
4468
|
+
// insert` now uses (canonicalizeBracketPhaseArgument) — a bare token
|
|
4469
|
+
// through phase-id-display's `phaseToken`, a qualified/display token
|
|
4470
|
+
// through phase-id.cts's strict `parsePhaseId`. A rejected spelling has no
|
|
4471
|
+
// parseInt fallback, so it cannot partially name a real phase and reach
|
|
4472
|
+
// the destructive path.
|
|
4473
|
+
let normalized;
|
|
4474
|
+
let isDecimal;
|
|
4475
|
+
let requestedSlug;
|
|
4476
|
+
let removedInt;
|
|
4477
|
+
let removedSubphase;
|
|
4478
|
+
if (removeContext) {
|
|
4479
|
+
({ normalized, isDecimal, slug: requestedSlug } = canonicalizeBracketPhaseArgument(removeContext, targetPhase, 'remove'));
|
|
4480
|
+
const [phasePart, subphasePart] = normalized.split('.');
|
|
4481
|
+
removedInt = Number(phasePart);
|
|
4482
|
+
removedSubphase = subphasePart === undefined ? undefined : Number(subphasePart);
|
|
4483
|
+
if (removedSubphase === 0) {
|
|
4484
|
+
error('Cannot remove a .00 sub-phase: bracket sub-phases start at .01.', ERROR_REASON.USAGE);
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
else {
|
|
4488
|
+
// Legacy and milestone-prefixed paths retain their original permissive
|
|
4489
|
+
// normalization and parseInt behavior byte-for-byte.
|
|
4490
|
+
normalized = normalizePhaseName(targetPhase);
|
|
4491
|
+
isDecimal = targetPhase.includes('.');
|
|
4492
|
+
removedInt = parseInt(normalized, 10);
|
|
4493
|
+
removedSubphase = isDecimal ? parseInt(normalized.split('.')[1], 10) : undefined;
|
|
4494
|
+
}
|
|
2279
4495
|
const subdirs = readSubdirectories(phasesDir, true);
|
|
2280
4496
|
// #2237/#2528: every other resolution path refuses to choose between multiple
|
|
2281
4497
|
// directories claiming one phase number. This one is the DESTRUCTIVE path, so
|
|
2282
4498
|
// taking `matches[0]` silently is strictly worse than anywhere else: it turns
|
|
2283
4499
|
// "resolve nothing" into "delete one of two candidates, unrecoverably, and
|
|
2284
4500
|
// renumber every phase after it". Refuse before any file is touched.
|
|
2285
|
-
const
|
|
4501
|
+
const candidateDirs = removeContext
|
|
4502
|
+
? bracketIdsInContext(phasesDir, removeContext).map(({ dir }) => dir)
|
|
4503
|
+
: subdirs;
|
|
4504
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
4505
|
+
let { matches: phaseDirMatches } = matchPhaseDirs(candidateDirs, removeConvention === 'bracket' ? normalized : phaseLookupQuery(targetPhase, directoryContext), removeConvention === 'bracket' ? 'bracket' : undefined, undefined, directoryContext);
|
|
4506
|
+
if (removeContext && requestedSlug !== undefined) {
|
|
4507
|
+
phaseDirMatches = phaseDirMatches.filter((dir) => bracketDirSlug(dir, parsePhaseId(dir)) === requestedSlug);
|
|
4508
|
+
if (phaseDirMatches.length !== 1) {
|
|
4509
|
+
error(`Cannot remove ${targetPhase}: its slug does not exactly match a directory for phase ${normalized}.`, ERROR_REASON.USAGE);
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
const uniqueCustomTarget = phaseDirMatches.length === 1
|
|
4513
|
+
&& phaseIdMod.customDirectoryCandidates(phaseDirMatches[0], directoryContext).length === 1;
|
|
4514
|
+
const mutationCandidates = subdirs.filter(dir => {
|
|
4515
|
+
const owners = phaseIdMod.customDirectoryCandidates(dir, directoryContext);
|
|
4516
|
+
// A unique proven target excludes siblings whose longer declared owner
|
|
4517
|
+
// wins resolution. Unresolved/ambiguous queries retain the full refusal.
|
|
4518
|
+
return dir === targetPhase || (owners.includes(targetPhase) && (!uniqueCustomTarget || dir === phaseDirMatches[0]));
|
|
4519
|
+
});
|
|
4520
|
+
// The numeric renumbering set comes from the renamers' own selector.
|
|
4521
|
+
const renumbered = removeContext || isCustomPhaseId(targetPhase) ? [] : (isDecimal
|
|
4522
|
+
? selectDecimalRenames(subdirs, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10), directoryContext)
|
|
4523
|
+
: selectIntegerRenames(subdirs, parseInt(normalized, 10), directoryContext)).map(item => item.dir);
|
|
4524
|
+
assertUnambiguousCustomDirectories([...new Set([...mutationCandidates, ...renumbered])], directoryContext);
|
|
2286
4525
|
if (phaseDirMatches.length > 1) {
|
|
2287
4526
|
output({
|
|
2288
4527
|
removed: null,
|
|
2289
4528
|
error: `Phase ${normalized} is ambiguous: ${phaseDirMatches.length} directories match `
|
|
2290
4529
|
+ `(${phaseDirMatches.map((m) => `"${m}"`).join(', ')}). Refusing to remove any of them. `
|
|
2291
|
-
+
|
|
4530
|
+
+ (removeContext
|
|
4531
|
+
? 'Remove the stale duplicate directory or give it a distinct bracket phase identity, then retry.'
|
|
4532
|
+
: 'Set a distinct project_code in .planning/config.json, or pass the full directory name.'),
|
|
2292
4533
|
ambiguous_matches: phaseDirMatches,
|
|
2293
4534
|
directory_deleted: null,
|
|
2294
4535
|
renamed_directories: [],
|
|
@@ -2299,6 +4540,30 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
2299
4540
|
return;
|
|
2300
4541
|
}
|
|
2301
4542
|
const targetDir = phaseDirMatches[0] || null;
|
|
4543
|
+
const customTarget = targetDir !== null && (directoryContext.directoryIds?.[targetDir] !== undefined
|
|
4544
|
+
|| phaseIdMod.customDirectoryCandidates(targetDir, directoryContext).length > 0);
|
|
4545
|
+
const stableIds = new Set(subdirs.flatMap(dir => phaseIdMod.customDirectoryCandidates(dir, directoryContext))
|
|
4546
|
+
.map(id => (isCustomPhaseId(id) ? id : normalizePhaseName(id)).toLowerCase()));
|
|
4547
|
+
const roadmapContentBeforeRemoval = removeContext ? node_fs_1.default.readFileSync(roadmapPath, 'utf-8') : null;
|
|
4548
|
+
if (removeContext) {
|
|
4549
|
+
const targetId = bracketPhaseId(removeContext, removedInt, removedSubphase);
|
|
4550
|
+
const legacyOnly = legacyOnlyBracketRemovalTarget(subdirs, roadmapContentBeforeRemoval, targetPhase, targetId, targetDir !== null);
|
|
4551
|
+
if (legacyOnly) {
|
|
4552
|
+
const evidence = [
|
|
4553
|
+
...legacyOnly.directories.map((dir) => `directory ${JSON.stringify(dir)}`),
|
|
4554
|
+
...legacyOnly.headings.map((heading) => `heading ${JSON.stringify(heading)}`),
|
|
4555
|
+
].join('; ');
|
|
4556
|
+
// roadmap upgrade converts nothing once config selects bracket (it
|
|
4557
|
+
// refuses a partial migration and treats an all-bracket ROADMAP as
|
|
4558
|
+
// done), so name the conversion itself.
|
|
4559
|
+
const directoryPrefix = legacyOnly.directories.length > 0
|
|
4560
|
+
? ` (directory prefix ${JSON.stringify(bracketDirPrefix(targetId))})`
|
|
4561
|
+
: '';
|
|
4562
|
+
error(`Cannot remove phase ${normalized} under the bracket convention: it resolves only to `
|
|
4563
|
+
+ `legacy-spelled artifacts (${evidence}). Convert them to the bracket spelling `
|
|
4564
|
+
+ `${renderPhaseId(targetId)}${directoryPrefix} before removing this phase.`);
|
|
4565
|
+
}
|
|
4566
|
+
}
|
|
2302
4567
|
if (targetDir && !force) {
|
|
2303
4568
|
// #3183: canonical summary set (root+nested) from the single owner —
|
|
2304
4569
|
// a root-only readdirSync filter left nested (#3139 layout) summaries
|
|
@@ -2309,6 +4574,113 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
2309
4574
|
error(`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`);
|
|
2310
4575
|
}
|
|
2311
4576
|
}
|
|
4577
|
+
// #4304: compute the ONE renumber mapping shared by the disk
|
|
4578
|
+
// rename and the ROADMAP rewrite before either runs, from the roadmap
|
|
4579
|
+
// content as it stands right now (only the target directory is about to
|
|
4580
|
+
// be deleted below; deletion does not change which OTHER identities the
|
|
4581
|
+
// scan finds). Both consumers below apply this exact mapping so they
|
|
4582
|
+
// cannot independently diverge on a decimal sub-phase identity.
|
|
4583
|
+
const preRemovalRanges = roadmapContentBeforeRemoval
|
|
4584
|
+
? currentMilestoneRawRanges(roadmapContentBeforeRemoval, cwd, 'bracket')
|
|
4585
|
+
: null;
|
|
4586
|
+
// #4304: refuse before any mutation when the target's own
|
|
4587
|
+
// heading/checklist line lives entirely OUTSIDE the milestone window the
|
|
4588
|
+
// read side located — a mislocated window (a decoy heading carrying the
|
|
4589
|
+
// active version token before the real milestone heading, a document-level
|
|
4590
|
+
// Progress heading placed before it, or no milestone heading at all)
|
|
4591
|
+
// otherwise lets the destructive delete/rename below proceed while the
|
|
4592
|
+
// ROADMAP rewrite silently never reaches the target's real lines. Does not
|
|
4593
|
+
// attempt to fix the window's own selection (PR-6 / #4751 territory) —
|
|
4594
|
+
// only refuses instead of half-applying.
|
|
4595
|
+
//
|
|
4596
|
+
// #4304: NOT additionally gated on `targetDir` — a ROADMAP-only
|
|
4597
|
+
// target (a phase `phase add` created with no directory materialized yet)
|
|
4598
|
+
// on a mislocated window still half-applied under the `targetDir` gate:
|
|
4599
|
+
// later directories renamed and their ROADMAP lines renumbered onto the
|
|
4600
|
+
// target's identity while the target's own heading/checklist survived,
|
|
4601
|
+
// with an empty report. The guard reads only ROADMAP content (never
|
|
4602
|
+
// `targetDir` itself), so gating it on a directory that may not exist
|
|
4603
|
+
// protected nothing; with no directory and no matching ROADMAP line the
|
|
4604
|
+
// guard already reports no outside line.
|
|
4605
|
+
if (removeContext) {
|
|
4606
|
+
const guardTargetId = bracketPhaseId(removeContext, removedInt, removedSubphase);
|
|
4607
|
+
const { outside, hasLiveTarget } = bracketOwnedLineOutsideActiveWindow(roadmapContentBeforeRemoval, guardTargetId, preRemovalRanges);
|
|
4608
|
+
if (outside) {
|
|
4609
|
+
const windowDescription = preRemovalRanges
|
|
4610
|
+
? `the located milestone window starting at line ${roadmapContentBeforeRemoval.slice(0, preRemovalRanges.primary.start).split('\n').length} (${roadmapContentBeforeRemoval
|
|
4611
|
+
.slice(preRemovalRanges.primary.start, roadmapContentBeforeRemoval.indexOf('\n', preRemovalRanges.primary.start))
|
|
4612
|
+
.trim()})`
|
|
4613
|
+
: 'no milestone heading at all';
|
|
4614
|
+
error(`Cannot remove phase ${normalized}: its own heading/checklist line `
|
|
4615
|
+
+ `(ROADMAP.md line ${outside.lineNumber}: ${JSON.stringify(outside.text)}) `
|
|
4616
|
+
+ `lies outside ${windowDescription}. ROADMAP.md's milestone heading was not `
|
|
4617
|
+
+ 'found where expected; verify the ROADMAP.md structure before removing.', ERROR_REASON.USAGE);
|
|
4618
|
+
}
|
|
4619
|
+
if (targetDir === null && !hasLiveTarget) {
|
|
4620
|
+
error(`Cannot remove phase ${normalized}: no directory or live heading/checklist exists in the active milestone window.`, ERROR_REASON.USAGE);
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
// #4304: refuse before any mutation when an INTEGER phase
|
|
4624
|
+
// still has its own sub-phases. Without this, computeBracketRenumberMapping's
|
|
4625
|
+
// filter (phase > removedInt only) never touches the removed phase's OWN
|
|
4626
|
+
// sub-phases: they stay orphaned on disk/ROADMAP while the NEXT phase's
|
|
4627
|
+
// sub-phases renumber onto the SAME identities, manufacturing duplicates
|
|
4628
|
+
// (legacy has the same defect on its own path — parity, not fixed there).
|
|
4629
|
+
if (removeContext && removedSubphase === undefined) {
|
|
4630
|
+
const ownSubphases = bracketPhaseOwnSubphases(phasesDir, roadmapContentBeforeRemoval, preRemovalRanges, removeContext, removedInt);
|
|
4631
|
+
if (ownSubphases.length > 0) {
|
|
4632
|
+
error(`Cannot remove phase ${normalized}: it still has sub-phase(s) `
|
|
4633
|
+
+ `${ownSubphases.map((id) => renderPhaseId(id)).join(', ')}. `
|
|
4634
|
+
+ 'Remove the sub-phase(s) first, then remove the phase.');
|
|
4635
|
+
}
|
|
4636
|
+
}
|
|
4637
|
+
const bracketMapping = removeContext
|
|
4638
|
+
? computeBracketRenumberMapping(phasesDir, roadmapContentBeforeRemoval, preRemovalRanges, removeContext, removedInt, removedSubphase)
|
|
4639
|
+
: [];
|
|
4640
|
+
// #4304: the mapping inventories only canonical bracket spellings. Refuse
|
|
4641
|
+
// before any mutation while a legacy spelling resolves to an identity it
|
|
4642
|
+
// deletes, renumbers, or renumbers onto; renumbering around it would leave
|
|
4643
|
+
// that spelling on its old number for the reader to misattribute.
|
|
4644
|
+
if (removeContext) {
|
|
4645
|
+
const entangled = legacySpellingsEntangledWithBracketRemoval(phasesDir, roadmapContentBeforeRemoval, preRemovalRanges, removeContext, removedInt, removedSubphase, bracketMapping);
|
|
4646
|
+
if (entangled.length > 0) {
|
|
4647
|
+
error(`Cannot remove phase ${normalized}: phase remove renumbers only bracket spellings, and `
|
|
4648
|
+
+ `${entangled.length === 1 ? 'this legacy spelling resolves' : 'these legacy spellings resolve'} `
|
|
4649
|
+
+ `to a phase it would delete or renumber: ${entangled.map(describeEntangledLegacySpelling).join('; ')}. `
|
|
4650
|
+
+ 'Convert each to its bracket spelling, then retry.');
|
|
4651
|
+
}
|
|
4652
|
+
}
|
|
4653
|
+
if (removeContext) {
|
|
4654
|
+
const targetId = bracketPhaseId(removeContext, removedInt, removedSubphase);
|
|
4655
|
+
const unrepresentable = unrepresentableBracketIdsAffectedByRemoval(roadmapContentBeforeRemoval, preRemovalRanges, removedInt, removedSubphase);
|
|
4656
|
+
if (unrepresentable.length > 0) {
|
|
4657
|
+
error(`Cannot remove phase ${normalized}: the active milestone lists phase id(s) `
|
|
4658
|
+
+ `${unrepresentable.join(', ')} that are not representable in the bracket convention, `
|
|
4659
|
+
+ 'so phase remove can neither delete nor renumber them alongside their siblings. '
|
|
4660
|
+
+ 'Renumber or remove them first, then retry.');
|
|
4661
|
+
}
|
|
4662
|
+
const plan = planBracketRoadmapRemoval(roadmapContentBeforeRemoval, targetId, bracketMapping, cwd);
|
|
4663
|
+
if (plan.survivingTargetRows.length > 0) {
|
|
4664
|
+
error(`Cannot remove phase ${normalized}: ROADMAP.md has table row(s) keyed by ${renderPhaseId(targetId)} `
|
|
4665
|
+
+ `outside the milestone's own Progress tables (${plan.survivingTargetRows.map((row) => JSON.stringify(row.trim())).join(', ')}). `
|
|
4666
|
+
+ 'Removing the phase would leave each row naming an identity a renumbered sibling takes over. '
|
|
4667
|
+
+ 'Delete or re-key those rows first, then retry.');
|
|
4668
|
+
}
|
|
4669
|
+
const currentPhase = stateCurrentPhaseAffectedByBracketRemoval(cwd, targetId, bracketMapping);
|
|
4670
|
+
if (currentPhase !== null) {
|
|
4671
|
+
error(`Cannot remove phase ${renderPhaseId(targetId)}: STATE.md's current phase is ${currentPhase}, which this `
|
|
4672
|
+
+ 'removal deletes or renumbers, and phase remove does not rewrite the current-phase fields. '
|
|
4673
|
+
+ 'Bracket phase remove deletes only phases after the current phase. To remove this one, point STATE.md\'s '
|
|
4674
|
+
+ `current phase at a phase before ${renderPhaseId(targetId)}, or, when there is none, clear the current-phase `
|
|
4675
|
+
+ 'fields (frontmatter current_phase, the Current Phase field and the Current Position Phase line), then retry.');
|
|
4676
|
+
}
|
|
4677
|
+
}
|
|
4678
|
+
// Validate every bracket rename destination before deleting the target or
|
|
4679
|
+
// changing ROADMAP/STATE, so a planted symlink produces a clean refusal
|
|
4680
|
+
// with the planning tree untouched.
|
|
4681
|
+
if (removeContext) {
|
|
4682
|
+
assertBracketRenameDestinationsSafe(phasesDir, removeContext, bracketMapping, targetDir);
|
|
4683
|
+
}
|
|
2312
4684
|
// #5118 (no write before the error): the STATE.md rewrite below rebuilds the
|
|
2313
4685
|
// frontmatter from EVERY surviving phase's report (buildStateFrontmatter →
|
|
2314
4686
|
// isPhaseComplete), and it runs AFTER the directory removal, the sibling
|
|
@@ -2330,13 +4702,22 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
2330
4702
|
let renamedFiles = [];
|
|
2331
4703
|
let renamedFileCollisions = [];
|
|
2332
4704
|
try {
|
|
2333
|
-
if (
|
|
2334
|
-
|
|
4705
|
+
if (removeContext) {
|
|
4706
|
+
// #4304: reuse the SAME removedInt/removedSubphase validated
|
|
4707
|
+
// above (before deletion) instead of re-deriving from `normalized`,
|
|
4708
|
+
// which is NaN for a qualified id like `CK.02-02`.
|
|
4709
|
+
const renamed = renameBracketPhases(phasesDir, removeContext, bracketMapping);
|
|
2335
4710
|
renamedDirs = renamed.renamedDirs;
|
|
2336
4711
|
renamedFiles = renamed.renamedFiles;
|
|
4712
|
+
renamedFileCollisions = renamed.renamedFileCollisions;
|
|
2337
4713
|
}
|
|
2338
|
-
else {
|
|
2339
|
-
const renamed =
|
|
4714
|
+
else if (!customTarget && isDecimal) {
|
|
4715
|
+
const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10), directoryContext);
|
|
4716
|
+
renamedDirs = renamed.renamedDirs;
|
|
4717
|
+
renamedFiles = renamed.renamedFiles;
|
|
4718
|
+
}
|
|
4719
|
+
else if (!customTarget) {
|
|
4720
|
+
const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10), directoryContext);
|
|
2340
4721
|
renamedDirs = renamed.renamedDirs;
|
|
2341
4722
|
renamedFiles = renamed.renamedFiles;
|
|
2342
4723
|
renamedFileCollisions = renamed.renamedFileCollisions;
|
|
@@ -2355,7 +4736,14 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
2355
4736
|
const msg = e instanceof Error ? e.message : String(e);
|
|
2356
4737
|
error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
|
|
2357
4738
|
}
|
|
2358
|
-
const
|
|
4739
|
+
const bracketRoadmapRewrite = removeContext
|
|
4740
|
+
? updateRoadmapAfterBracketPhaseRemoval(roadmapPath, removedInt, removedSubphase, removeContext, bracketMapping, cwd)
|
|
4741
|
+
: null;
|
|
4742
|
+
const roadmapUpdated = bracketRoadmapRewrite
|
|
4743
|
+
? bracketRoadmapRewrite.updated
|
|
4744
|
+
: updateRoadmapAfterPhaseRemoval(roadmapPath, directoryContext.directoryIds?.[targetDir ?? '']
|
|
4745
|
+
?? (targetDir ? phaseIdMod.customDirectoryCandidates(targetDir, directoryContext)[0] : undefined)
|
|
4746
|
+
?? targetPhase, isDecimal, parseInt(normalized, 10), cwd, customTarget, stableIds);
|
|
2359
4747
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
2360
4748
|
let stateUpdated = false;
|
|
2361
4749
|
if (node_fs_1.default.existsSync(statePath)) {
|
|
@@ -2433,6 +4821,12 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
2433
4821
|
// change, not hardcoded regardless of whether ROADMAP.md's content
|
|
2434
4822
|
// actually changed.
|
|
2435
4823
|
roadmap_updated: roadmapUpdated,
|
|
4824
|
+
...(bracketRoadmapRewrite
|
|
4825
|
+
? {
|
|
4826
|
+
roadmap_lines_rewritten: bracketRoadmapRewrite.roadmapLinesRewritten,
|
|
4827
|
+
references_left_untouched: bracketRoadmapRewrite.referencesLeftUntouched,
|
|
4828
|
+
}
|
|
4829
|
+
: {}),
|
|
2436
4830
|
state_updated: stateUpdated,
|
|
2437
4831
|
}, raw);
|
|
2438
4832
|
// #3227: unconditional here because `updateRoadmapAfterPhaseRemoval` above
|
|
@@ -3180,6 +5574,7 @@ function auditArtifactWarnings(cwd, phaseFullDir) {
|
|
|
3180
5574
|
return out;
|
|
3181
5575
|
}
|
|
3182
5576
|
function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
5577
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
3183
5578
|
if (!phaseNum) {
|
|
3184
5579
|
error('phase number required for phase complete');
|
|
3185
5580
|
}
|
|
@@ -3216,7 +5611,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
3216
5611
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
3217
5612
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
3218
5613
|
const today = clock_cjs_1.realClock.localToday();
|
|
3219
|
-
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
|
|
5614
|
+
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum, undefined, directoryContext);
|
|
3220
5615
|
if (!phaseInfoRaw) {
|
|
3221
5616
|
error(`Phase ${phaseNum} not found`);
|
|
3222
5617
|
}
|
|
@@ -3488,7 +5883,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
3488
5883
|
if (node_fs_1.default.existsSync(roadmapPath)) {
|
|
3489
5884
|
const originalRoadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
3490
5885
|
roadmapContent = originalRoadmapContent;
|
|
3491
|
-
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
|
|
5886
|
+
const phaseEscaped = phaseMarkdownRegexSource(phaseNum, directoryContext);
|
|
3492
5887
|
// #2067: the gap between `]` and `Phase N` must allow only whitespace /
|
|
3493
5888
|
// markdown bold emphasis — NOT greedy `.*`. A greedy gap matched a later
|
|
3494
5889
|
// phase whose description merely mentioned the completed phase number,
|
|
@@ -3521,9 +5916,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
3521
5916
|
// bug because a GSD-authored ROADMAP.md milestone checklist never puts
|
|
3522
5917
|
// its own `- [ ] Phase N: …` entries inside a fenced code block, but it
|
|
3523
5918
|
// is a real (and correct) behavioural difference on pathological input.
|
|
3524
|
-
|
|
5919
|
+
// The phase-entry grammar's split regexp (#5764): group 1 is everything through the
|
|
5920
|
+
// opening `[` (indent included, so a flip keeps the entry's indent), group 2 the rest.
|
|
5921
|
+
const entryTailSrc = `Phase[ \\t]+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*`;
|
|
5922
|
+
const checkboxPattern = (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(entryTailSrc, 'i', { state: 'unchecked' });
|
|
3525
5923
|
// #5165: pre-mutation read of the same checklist line, checked this time.
|
|
3526
|
-
const checkedBoxPattern =
|
|
5924
|
+
const checkedBoxPattern = (0, phase_entry_grammar_cjs_1.phaseEntrySplitRegExp)(entryTailSrc, '', { state: 'checked' });
|
|
3527
5925
|
const originalMilestoneScope = extractCurrentMilestone(originalRoadmapContent, cwd);
|
|
3528
5926
|
phaseWasAlreadyComplete = originalMilestoneScope
|
|
3529
5927
|
.split('\n')
|
|
@@ -3550,37 +5948,16 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
3550
5948
|
// milestone ROADMAP binds, so a shipped milestone's same-numbered phase
|
|
3551
5949
|
// cannot count.
|
|
3552
5950
|
if (!phaseWasAlreadyComplete) {
|
|
3553
|
-
const progressTable =
|
|
5951
|
+
const progressTable = (0, roadmap_progress_cjs_1.locateRoadmapProgress)(originalMilestoneScope).table;
|
|
3554
5952
|
if (progressTable !== null) {
|
|
3555
5953
|
const wantMilestone = progressTable.columns.includes('Milestone')
|
|
3556
|
-
? normalizeMilestoneToken(roadmapParserMod.getMilestoneInfo(cwd).value?.version)
|
|
5954
|
+
? (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(roadmapParserMod.getMilestoneInfo(cwd).value?.version)
|
|
3557
5955
|
: null;
|
|
3558
5956
|
phaseWasAlreadyComplete = progressTable.rows.some((row) => rowMatch(row)
|
|
3559
5957
|
&& (0, phase_status_cjs_1.parseRoadmapStatusCell)(row['Status']) === phase_status_cjs_1.WIRE_STATUS.COMPLETE
|
|
3560
|
-
&& (wantMilestone === null || (wantMilestone !== '' && normalizeMilestoneToken(row['Milestone']) === wantMilestone)));
|
|
5958
|
+
&& (wantMilestone === null || (wantMilestone !== '' && (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(row['Milestone']) === wantMilestone)));
|
|
3561
5959
|
}
|
|
3562
5960
|
}
|
|
3563
|
-
/**
|
|
3564
|
-
* Within `text` (already scoped to one milestone window by the
|
|
3565
|
-
* caller), scope further to the `## Progress` heading section (up to
|
|
3566
|
-
* the next `#`/`##` heading) when present, run `edit` against just
|
|
3567
|
-
* that slice, and splice the result back — falling back to the whole
|
|
3568
|
-
* `text` when no `## Progress` heading exists (mirrors phase-
|
|
3569
|
-
* lifecycle.cjs's deriveProgressFromRoadmap read-side scoping).
|
|
3570
|
-
*/
|
|
3571
|
-
const editProgressHeadingSlice = (text, edit) => {
|
|
3572
|
-
const progressMatch = text.match(/^##[ \t]+Progress\b/im);
|
|
3573
|
-
if (!progressMatch || progressMatch.index === undefined) {
|
|
3574
|
-
return edit(text);
|
|
3575
|
-
}
|
|
3576
|
-
const headingOffset = progressMatch.index;
|
|
3577
|
-
const beforeHeading = text.slice(0, headingOffset);
|
|
3578
|
-
const fromHeading = text.slice(headingOffset);
|
|
3579
|
-
const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
3580
|
-
const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
|
|
3581
|
-
const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
|
|
3582
|
-
return beforeHeading + edit(scoped) + after;
|
|
3583
|
-
};
|
|
3584
5961
|
// ADR-2143 §4: the plan-count write is now routed through
|
|
3585
5962
|
// withPhaseSection (see mutateMilestonePhase below), which hands this
|
|
3586
5963
|
// seam call ONLY phase N's own detail-section body — the section
|
|
@@ -3692,7 +6069,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
3692
6069
|
const mutateMilestonePhase = (slice) => {
|
|
3693
6070
|
let s = slice;
|
|
3694
6071
|
s = (0, markdown_sectionizer_cjs_1.updateBullet)(s, (_bulletText, rawLine) => checkboxPattern.test(rawLine), (rawLine) => rawLine.replace(checkboxPattern, `$1x$2 (completed ${today})`));
|
|
3695
|
-
|
|
6072
|
+
// #5762: `s` is already one milestone window; the Progress slice is the owner's
|
|
6073
|
+
// (`editProgressTableSlice` -> `editRoadmapProgress`), the same one `roadmap update-plan-progress` uses.
|
|
6074
|
+
s = editProgressTableSlice(s, (scoped) => {
|
|
3696
6075
|
let text = scoped;
|
|
3697
6076
|
const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
|
|
3698
6077
|
if (plansResult.ok)
|
|
@@ -3732,39 +6111,16 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
3732
6111
|
if (!coreUtilsMod.summaryPairsWithAnyPlan(phaseInfo['plans'] ?? [], summaryFile))
|
|
3733
6112
|
continue;
|
|
3734
6113
|
// ADR-5057 §6 (Phase 13, #5217): the per-plan flip is a bullet
|
|
3735
|
-
// mutation
|
|
6114
|
+
// mutation (`updateBullet` inside the helper) like the phase
|
|
3736
6115
|
// checkbox flip above — first matching bullet only, never a
|
|
3737
6116
|
// fenced line, never a mid-line occurrence.
|
|
3738
|
-
|
|
3739
|
-
b = (0, markdown_sectionizer_cjs_1.updateBullet)(b, (_bulletText, rawLine) => planCheckboxPattern.test(rawLine), (rawLine) => rawLine.replace(planCheckboxPattern, '$1x$2'));
|
|
6117
|
+
b = coreUtilsMod.tickAnchoredPlanRow(b, planId);
|
|
3740
6118
|
}
|
|
3741
6119
|
return b;
|
|
3742
6120
|
});
|
|
3743
6121
|
return s;
|
|
3744
6122
|
};
|
|
3745
|
-
|
|
3746
|
-
if (milestoneRanges) {
|
|
3747
|
-
// Splice later windows first so an earlier window's offsets are not
|
|
3748
|
-
// shifted by a length-changing mutation in a later window.
|
|
3749
|
-
const windows = [milestoneRanges.details, milestoneRanges.primary]
|
|
3750
|
-
.filter((w) => w !== null)
|
|
3751
|
-
.sort((a, b) => b.start - a.start);
|
|
3752
|
-
for (const w of windows) {
|
|
3753
|
-
roadmapContent =
|
|
3754
|
-
roadmapContent.slice(0, w.start)
|
|
3755
|
-
+ mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
|
|
3756
|
-
+ roadmapContent.slice(w.end);
|
|
3757
|
-
}
|
|
3758
|
-
}
|
|
3759
|
-
else {
|
|
3760
|
-
// #5543: the active milestone could not be resolved (no STATE
|
|
3761
|
-
// `milestone:`, no marker). Writing the whole document would let
|
|
3762
|
-
// `withPhaseSection` pick the first `### Phase N:` in document order,
|
|
3763
|
-
// which can sit inside a shipped `<details>` block. Hide the shipped
|
|
3764
|
-
// blocks from the mutation and restore them untouched; a ROADMAP with
|
|
3765
|
-
// no shipped block is mutated whole, as before.
|
|
3766
|
-
roadmapContent = editOutsideShippedBlocks(roadmapContent, mutateMilestonePhase);
|
|
3767
|
-
}
|
|
6123
|
+
roadmapContent = editCurrentMilestone(roadmapContent, cwd, mutateMilestonePhase);
|
|
3768
6124
|
writes.push({
|
|
3769
6125
|
filePath: roadmapPath,
|
|
3770
6126
|
before: originalRoadmapContent,
|
|
@@ -3778,7 +6134,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
3778
6134
|
roadmapUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalRoadmapContent, roadmapContent);
|
|
3779
6135
|
const reqPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
|
|
3780
6136
|
if (node_fs_1.default.existsSync(reqPath)) {
|
|
3781
|
-
const phaseEsc = phaseMarkdownRegexSource(phaseNum);
|
|
6137
|
+
const phaseEsc = phaseMarkdownRegexSource(phaseNum, directoryContext);
|
|
3782
6138
|
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
|
|
3783
6139
|
// #5007 (Phase 6 / ADR-4910 §8): this site captures the entire
|
|
3784
6140
|
// section BODY through to the next heading (via its own
|
|
@@ -3872,7 +6228,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
3872
6228
|
// checkbox rollback below can distinguish "row existed and accepted" from
|
|
3873
6229
|
// "row existed and rejected".
|
|
3874
6230
|
let tableHit = false;
|
|
3875
|
-
const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => {
|
|
6231
|
+
const reqUpdate = (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, reqRowMatch, 'Status', (current) => {
|
|
3876
6232
|
// #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
|
|
3877
6233
|
// gaps_found response) can complete without hand-editing the table.
|
|
3878
6234
|
if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
|
|
@@ -4059,7 +6415,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4059
6415
|
// technique milestone.cts's `hasRow` uses (:210-214): a case-
|
|
4060
6416
|
// insensitive first-cell match, regardless of current Status.
|
|
4061
6417
|
const rowProbeMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
|
|
4062
|
-
return updateTraceabilityCell(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
|
|
6418
|
+
return (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
|
|
4063
6419
|
};
|
|
4064
6420
|
const ghostReqIds = citedReqIds.filter((id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id));
|
|
4065
6421
|
if (ghostReqIds.length > 0) {
|
|
@@ -4114,6 +6470,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4114
6470
|
// where the roadmap would give `2` and a slugified title. Promoting the
|
|
4115
6471
|
// roadmap without this would silently change the reported value on
|
|
4116
6472
|
// every aligned project, which is the majority case.
|
|
6473
|
+
// #5676: the numeric phase-token grammar follows the convention, so a
|
|
6474
|
+
// milestone-prefixed `03-02-beta` is phase `3-02`, not phase `3`.
|
|
6475
|
+
const phaseConvention = resolvePhaseIdConvention(cwd);
|
|
4117
6476
|
let diskNextNum = null;
|
|
4118
6477
|
let diskNextName = null;
|
|
4119
6478
|
let roadmapNextNum = null;
|
|
@@ -4126,16 +6485,20 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4126
6485
|
// comparePhaseNum-based so `02` and `2` dedupe. With no ROADMAP.md (or no
|
|
4127
6486
|
// parseable rows) the set is empty and the scans behave exactly as
|
|
4128
6487
|
// before.
|
|
6488
|
+
// Custom IDs use declared roadmap order; the numeric-minimum comments
|
|
6489
|
+
// below describe the unchanged legacy cascade.
|
|
4129
6490
|
const roadmapCompleteNums = [];
|
|
4130
6491
|
if (roadmapContent !== null) {
|
|
4131
6492
|
try {
|
|
4132
6493
|
const milestoneForComplete = extractCurrentMilestone(roadmapContent, cwd);
|
|
4133
|
-
const completePattern =
|
|
6494
|
+
const completePattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})`, 'gi');
|
|
4134
6495
|
let cm;
|
|
4135
6496
|
while ((cm = completePattern.exec(milestoneForComplete)) !== null) {
|
|
6497
|
+
if (!(0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(cm[0]))
|
|
6498
|
+
continue;
|
|
4136
6499
|
if (isSentinelPhaseId(cm[1]))
|
|
4137
6500
|
continue;
|
|
4138
|
-
if (!roadmapCompleteNums.some((n) => comparePhaseNum(cm[1], n) === 0)) {
|
|
6501
|
+
if (!roadmapCompleteNums.some((n) => comparePhaseNum(cm[1], n, directoryContext) === 0)) {
|
|
4139
6502
|
roadmapCompleteNums.push(cm[1]);
|
|
4140
6503
|
}
|
|
4141
6504
|
}
|
|
@@ -4145,7 +6508,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4145
6508
|
* set empty — the scans then behave exactly as they did pre-#4699. */
|
|
4146
6509
|
}
|
|
4147
6510
|
}
|
|
4148
|
-
const isCompletePhaseNum = (num) => roadmapCompleteNums.some((n) => comparePhaseNum(num, n) === 0);
|
|
6511
|
+
const isCompletePhaseNum = (num) => roadmapCompleteNums.some((n) => comparePhaseNum(num, n, directoryContext) === 0);
|
|
4149
6512
|
try {
|
|
4150
6513
|
// #3185 (ADR-3180 Decision 1): "which phase directories belong to
|
|
4151
6514
|
// the CURRENT milestone" — routed through the canonical owner
|
|
@@ -4156,7 +6519,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4156
6519
|
// is not necessarily identical to the raw directory name).
|
|
4157
6520
|
const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
|
|
4158
6521
|
for (const dir of dirs) {
|
|
4159
|
-
const
|
|
6522
|
+
const custom = phaseIdMod.customPhaseQuery(dir, directoryContext);
|
|
6523
|
+
const owner = custom && customDirectoryOwner(dir, custom);
|
|
6524
|
+
const dm = owner ? [dir, owner.id, owner.slug] : dir.match(new RegExp(`^(${phaseNumberTokenSource(phaseConvention)})-?(.*)`, 'i'));
|
|
4160
6525
|
if (dm) {
|
|
4161
6526
|
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
4162
6527
|
if (isSentinelPhaseId(dm[1]))
|
|
@@ -4170,8 +6535,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4170
6535
|
// correct today — but that makes this scan's correctness depend on an
|
|
4171
6536
|
// upstream sort nothing here states. Selecting the minimum explicitly costs
|
|
4172
6537
|
// one comparison and removes the hidden coupling.
|
|
4173
|
-
if (comparePhaseNum(dm[1], phaseNum) > 0
|
|
4174
|
-
&& (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum) < 0)) {
|
|
6538
|
+
if (comparePhaseNum(dm[1], phaseNum, directoryContext) > 0
|
|
6539
|
+
&& (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum, directoryContext) < 0)) {
|
|
4175
6540
|
diskNextNum = dm[1];
|
|
4176
6541
|
diskNextName = dm[2] || null;
|
|
4177
6542
|
}
|
|
@@ -4219,8 +6584,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4219
6584
|
// checkbox branch widens, and only to the separators #2199 already
|
|
4220
6585
|
// accepts. The two branches keep separate capture groups, normalized
|
|
4221
6586
|
// just below the loop.
|
|
4222
|
-
const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${
|
|
4223
|
-
|
|
6587
|
+
const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
|
|
6588
|
+
`|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)()}Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gim');
|
|
4224
6589
|
let pm;
|
|
4225
6590
|
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
|
4226
6591
|
// #4078: normalize the two alternation branches' captures (heading
|
|
@@ -4251,8 +6616,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4251
6616
|
// Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
|
|
4252
6617
|
// the disk scan and for #2028's lowest-outstanding override; the roadmap
|
|
4253
6618
|
// defines which phases EXIST and which milestone they belong to.
|
|
4254
|
-
if (comparePhaseNum(pmNum, phaseNum) > 0
|
|
4255
|
-
&& (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum) < 0)) {
|
|
6619
|
+
if (comparePhaseNum(pmNum, phaseNum, directoryContext) > 0
|
|
6620
|
+
&& (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum, directoryContext) < 0)) {
|
|
4256
6621
|
roadmapNextNum = pmNum;
|
|
4257
6622
|
roadmapNextName = pmName
|
|
4258
6623
|
.replace(/\(INSERTED\)/i, '')
|
|
@@ -4276,7 +6641,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4276
6641
|
// and the roadmap mean the same phase" cannot drift from "N is above the
|
|
4277
6642
|
// one just completed". `02` and `2` compare equal, which is the whole
|
|
4278
6643
|
// point — they are the same phase spelled two ways.
|
|
4279
|
-
const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum) === 0;
|
|
6644
|
+
const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum, directoryContext) === 0;
|
|
4280
6645
|
nextPhaseNum = diskAgrees ? diskNextNum : roadmapNextNum;
|
|
4281
6646
|
nextPhaseName = diskAgrees ? diskNextName : roadmapNextName;
|
|
4282
6647
|
isLastPhase = false;
|
|
@@ -4320,11 +6685,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4320
6685
|
// Without it, this lowest-outstanding override was blind to dash-grammar
|
|
4321
6686
|
// rows and could not correct an out-of-order completion on the same
|
|
4322
6687
|
// mixed-grammar roadmaps that broke stage 2.
|
|
4323
|
-
const cbPattern =
|
|
6688
|
+
const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi', { state: 'captured' });
|
|
4324
6689
|
let cbm;
|
|
4325
6690
|
let lowestOutstanding = null;
|
|
4326
6691
|
while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
|
|
4327
|
-
const isChecked = cbm[1]
|
|
6692
|
+
const isChecked = (0, phase_entry_grammar_cjs_1.isBoxCharChecked)(cbm[1]);
|
|
4328
6693
|
// #2949: exclude sentinel-range phase ids (0.x backlog, 999.x) from candidacy.
|
|
4329
6694
|
// comparePhaseNum("0.1","12") === -12, so without this guard an unchecked 0.x
|
|
4330
6695
|
// backlog row sorts below every real phase and is wrongly selected as next_phase,
|
|
@@ -4332,8 +6697,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4332
6697
|
// isSentinelPhaseId covers both sentinel ranges (SENTINEL_RANGES = [0, 999]); a
|
|
4333
6698
|
// real lower-numbered outstanding phase (e.g. Phase 9) is NOT a sentinel and is
|
|
4334
6699
|
// still selected, preserving #2028's out-of-order-completion behavior.
|
|
4335
|
-
if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum) < 0) {
|
|
4336
|
-
if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
|
|
6700
|
+
if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum, directoryContext) < 0) {
|
|
6701
|
+
if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num, directoryContext) < 0) {
|
|
4337
6702
|
lowestOutstanding = {
|
|
4338
6703
|
num: cbm[2],
|
|
4339
6704
|
name: cbm[3].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'),
|
|
@@ -4362,14 +6727,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4362
6727
|
// derivation to that milestone's Progress-table rows.
|
|
4363
6728
|
const stateFm = frontmatterMod.extractFrontmatter(originalStateContent, statePath);
|
|
4364
6729
|
const storedMilestone = roadmapParserMod.getMilestoneInfo(cwd).value?.version
|
|
4365
|
-
?? milestoneVersionOf(stateFm['milestone']);
|
|
6730
|
+
?? (0, roadmap_progress_cjs_1.milestoneVersionOf)(stateFm['milestone']);
|
|
4366
6731
|
// #5165: re-completing a phase that is already complete in ROADMAP.md
|
|
4367
6732
|
// while STATE has moved past it is a no-op for STATE's position —
|
|
4368
6733
|
// the transition, the metrics row and the authoritative current-phase
|
|
4369
6734
|
// pin would all rewind it. A first-time completion (box unchecked, or
|
|
4370
6735
|
// STATE not past N, #2028 out-of-order) still transitions.
|
|
4371
6736
|
const stateAlreadyPast = phaseWasAlreadyComplete
|
|
4372
|
-
&& (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath);
|
|
6737
|
+
&& (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath, phaseConvention);
|
|
4373
6738
|
let stateContent = originalStateContent;
|
|
4374
6739
|
// ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
|
|
4375
6740
|
// shape/name, Status, Current Plan, Last Activity + Description, and
|
|
@@ -4396,10 +6761,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
4396
6761
|
let nextPhaseDisk = null;
|
|
4397
6762
|
if (nextPhaseNum && !isLastPhase) {
|
|
4398
6763
|
try {
|
|
4399
|
-
const nextInfo = findPhaseInternal(cwd, nextPhaseNum);
|
|
6764
|
+
const nextInfo = findPhaseInternal(cwd, nextPhaseNum, undefined, directoryContext);
|
|
4400
6765
|
if (nextInfo && nextInfo.found && nextInfo.directory) {
|
|
4401
6766
|
const { value } = (0, phase_status_cjs_1.phaseStatus)(node_path_1.default.join(cwd, nextInfo.directory), {
|
|
4402
|
-
convention:
|
|
6767
|
+
convention: phaseConvention,
|
|
4403
6768
|
});
|
|
4404
6769
|
nextPhaseDisk = { status: value.status, planCount: value.planCount, summaryCount: value.summaryCount };
|
|
4405
6770
|
}
|
|
@@ -4617,7 +6982,7 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
|
|
|
4617
6982
|
if (!phaseNum) {
|
|
4618
6983
|
error('phase number required for phase uat-passed');
|
|
4619
6984
|
}
|
|
4620
|
-
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
|
|
6985
|
+
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum, undefined, phaseDirectoryOptions(cwd));
|
|
4621
6986
|
if (!phaseInfoRaw) {
|
|
4622
6987
|
error(`Phase ${phaseNum} not found`);
|
|
4623
6988
|
}
|
|
@@ -4639,7 +7004,7 @@ function cmdPhaseListPlans(cwd, phaseNum, raw) {
|
|
|
4639
7004
|
if (!phaseNum) {
|
|
4640
7005
|
error('phase number required for phase list-plans');
|
|
4641
7006
|
}
|
|
4642
|
-
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
7007
|
+
const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, phaseDirectoryOptions(cwd));
|
|
4643
7008
|
if (!phaseInfo) {
|
|
4644
7009
|
output({ phase: phaseNum, plan_count: 0, has_plans: false, plans: [], phase_dir: null }, raw);
|
|
4645
7010
|
return;
|
|
@@ -4677,4 +7042,12 @@ module.exports = {
|
|
|
4677
7042
|
computeDependencyLevels,
|
|
4678
7043
|
buildShortFormToId,
|
|
4679
7044
|
normalizeDependencyToken,
|
|
7045
|
+
// Test seam (#4304): the bracket renumber mapping is a disk and ROADMAP
|
|
7046
|
+
// bijection whose order must keep every rename destination free; exposed
|
|
7047
|
+
// so a property test can drive it over generated identity sets.
|
|
7048
|
+
_computeBracketRenumberMapping: computeBracketRenumberMapping,
|
|
7049
|
+
// Test seam (#4304): the qualified-reference rewriter the ROADMAP rewrite
|
|
7050
|
+
// applies once per mapping entry, so the same property can rewrite a line
|
|
7051
|
+
// in mapping order.
|
|
7052
|
+
_replaceQualifiedBracketReference: replaceQualifiedBracketReference,
|
|
4680
7053
|
};
|