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