@opengsd/gsd-core 1.14.0 → 1.16.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 +1 -1
- package/.opencode/plugins/gsd-core.js +85 -5
- package/README.ja-JP.md +3 -3
- package/README.ko-KR.md +3 -3
- package/README.pt-BR.md +3 -3
- package/README.zh-CN.md +3 -3
- package/agents/gsd-code-fixer.compact.md +7 -6
- package/agents/gsd-code-fixer.md +9 -8
- package/agents/gsd-code-reviewer.compact.md +5 -3
- package/agents/gsd-code-reviewer.md +8 -6
- package/agents/gsd-debug-session-manager.compact.md +17 -2
- package/agents/gsd-debug-session-manager.md +17 -2
- package/agents/gsd-debugger.md +3 -3
- package/agents/gsd-eval-auditor.compact.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +17 -12
- package/agents/gsd-intel-updater.compact.md +1 -1
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +2 -2
- package/agents/gsd-phase-researcher.md +19 -11
- package/agents/gsd-plan-checker.md +15 -9
- package/agents/gsd-planner.md +15 -11
- package/agents/gsd-project-researcher.compact.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-ui-auditor.compact.md +21 -30
- package/agents/gsd-ui-auditor.md +166 -37
- package/agents/gsd-ui-researcher.compact.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +37 -14
- package/bin/install.js +764 -287
- package/commands/gsd/add-tests.md +6 -1
- package/commands/gsd/ai-integration-phase.md +6 -1
- package/commands/gsd/audit-fix.md +5 -0
- package/commands/gsd/audit-milestone.md +6 -1
- package/commands/gsd/autonomous.md +7 -2
- package/commands/gsd/capture.md +9 -5
- package/commands/gsd/code-review.md +7 -2
- package/commands/gsd/complete-milestone.md +4 -0
- package/commands/gsd/config.md +7 -3
- package/commands/gsd/debug.md +11 -7
- package/commands/gsd/discuss-phase.md +7 -3
- package/commands/gsd/docs-update.md +12 -7
- package/commands/gsd/eval-review.md +6 -1
- package/commands/gsd/execute-phase.md +12 -7
- package/commands/gsd/extract-learnings.md +5 -0
- package/commands/gsd/fast.md +4 -0
- package/commands/gsd/forensics.md +5 -1
- package/commands/gsd/graphify.md +10 -6
- package/commands/gsd/health.md +5 -0
- package/commands/gsd/help.md +7 -2
- package/commands/gsd/import.md +7 -3
- package/commands/gsd/inbox.md +5 -0
- package/commands/gsd/ingest-docs.md +5 -1
- package/commands/gsd/manager.md +6 -1
- package/commands/gsd/map-codebase.md +7 -3
- package/commands/gsd/mempalace-capture.md +12 -4
- package/commands/gsd/mempalace-recall.md +5 -1
- package/commands/gsd/milestone-summary.md +5 -1
- package/commands/gsd/mvp-phase.md +8 -3
- package/commands/gsd/new-milestone.md +6 -1
- package/commands/gsd/new-project.md +5 -0
- package/commands/gsd/next.md +6 -1
- package/commands/gsd/ns-context.md +4 -0
- package/commands/gsd/ns-ideate.md +4 -0
- package/commands/gsd/ns-manage.md +4 -0
- package/commands/gsd/ns-project.md +4 -0
- package/commands/gsd/ns-review.md +4 -0
- package/commands/gsd/ns-workflow.md +4 -0
- package/commands/gsd/onboard.md +6 -1
- package/commands/gsd/pause-work.md +5 -1
- package/commands/gsd/phase.md +8 -4
- package/commands/gsd/plan-phase.md +6 -1
- package/commands/gsd/plan-review-convergence.md +11 -7
- package/commands/gsd/pr-branch.md +4 -0
- package/commands/gsd/profile-user.md +5 -1
- package/commands/gsd/progress.md +7 -2
- package/commands/gsd/quick-batch.md +21 -9
- package/commands/gsd/quick.md +12 -7
- package/commands/gsd/review.md +7 -4
- package/commands/gsd/secure-phase.md +6 -1
- package/commands/gsd/ship.md +5 -0
- package/commands/gsd/sketch.md +7 -2
- package/commands/gsd/spec-phase.md +5 -1
- package/commands/gsd/spike.md +8 -3
- package/commands/gsd/surface.md +5 -1
- package/commands/gsd/thread.md +4 -0
- package/commands/gsd/ui-phase.md +6 -1
- package/commands/gsd/ui-review.md +6 -1
- package/commands/gsd/ultraplan-phase.md +5 -1
- package/commands/gsd/undo.md +5 -1
- package/commands/gsd/update.md +6 -2
- package/commands/gsd/validate-phase.md +6 -1
- package/commands/gsd/verify-work.md +6 -1
- package/commands/gsd/workspace.md +7 -3
- package/gsd-core/bin/gsd-tools.cjs +477 -78
- package/gsd-core/bin/lib/active-workstream-store.cjs +15 -0
- package/gsd-core/bin/lib/adr-parser.cjs +3 -1
- package/gsd-core/bin/lib/agent-install-check.cjs +4 -1
- package/gsd-core/bin/lib/audit.cjs +144 -42
- package/gsd-core/bin/lib/broken-windows.cjs +13 -13
- package/gsd-core/bin/lib/capability-activation.cjs +9 -4
- package/gsd-core/bin/lib/capability-registry.cjs +197 -222
- package/gsd-core/bin/lib/capability-validator.cjs +16 -1
- package/gsd-core/bin/lib/check-auto-mode.cjs +35 -0
- package/gsd-core/bin/lib/check-command-router.cjs +164 -1625
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +13 -2
- package/gsd-core/bin/lib/cli-exit.cjs +12 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +32 -33
- package/gsd-core/bin/lib/command-aliases.cjs +7 -0
- package/gsd-core/bin/lib/command-routing-hub.cjs +48 -1
- package/gsd-core/bin/lib/commands.cjs +343 -207
- package/gsd-core/bin/lib/complexity-trigger.cjs +8 -7
- package/gsd-core/bin/lib/config-loader.cjs +65 -4
- package/gsd-core/bin/lib/config.cjs +76 -19
- package/gsd-core/bin/lib/core-utils.cjs +6 -1
- package/gsd-core/bin/lib/coverage.cjs +4 -8
- package/gsd-core/bin/lib/decision-coverage-support.cjs +259 -0
- package/gsd-core/bin/lib/decisions.cjs +30 -14
- package/gsd-core/bin/lib/drift.cjs +177 -42
- package/gsd-core/bin/lib/frontmatter-fence.cjs +90 -0
- package/gsd-core/bin/lib/frontmatter-splice.cjs +494 -0
- package/gsd-core/bin/lib/frontmatter.cjs +426 -234
- package/gsd-core/bin/lib/gap-checker.cjs +72 -29
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +381 -0
- package/gsd-core/bin/lib/gate-args.cjs +53 -0
- package/gsd-core/bin/lib/gate-codebase-drift.cjs +285 -0
- package/gsd-core/bin/lib/gate-config.cjs +46 -0
- package/gsd-core/bin/lib/gate-context-drift.cjs +141 -0
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +169 -0
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +126 -0
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +555 -0
- package/gsd-core/bin/lib/gate-evidence.cjs +138 -0
- package/gsd-core/bin/lib/gate-exit.cjs +27 -0
- package/gsd-core/bin/lib/gate-gap-analysis-plan-post.cjs +61 -0
- package/gsd-core/bin/lib/gate-phase-context.cjs +170 -0
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +1 -1
- package/gsd-core/bin/lib/gate-predicate.cjs +165 -0
- package/gsd-core/bin/lib/gate-prohibition-enforcement.cjs +94 -0
- package/gsd-core/bin/lib/gate-schema-drift.cjs +165 -0
- package/gsd-core/bin/lib/gate-tdd-red-evidence.cjs +100 -0
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +182 -0
- package/gsd-core/bin/lib/gate-ui-plan.cjs +86 -0
- package/gsd-core/bin/lib/gate-ui-safety.cjs +80 -0
- package/gsd-core/bin/lib/gate-verdict.cjs +64 -0
- package/gsd-core/bin/lib/gate-verify-command-paths.cjs +78 -0
- package/gsd-core/bin/lib/gate-verify-failure-directions.cjs +41 -0
- package/gsd-core/bin/lib/graphify.cjs +10 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +43 -47
- package/gsd-core/bin/lib/health-diagnostic.cjs +45 -8
- package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
- package/gsd-core/bin/lib/init.cjs +364 -132
- package/gsd-core/bin/lib/install-engine.cjs +30 -33
- package/gsd-core/bin/lib/install-profiles.cjs +7 -4
- package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
- package/gsd-core/bin/lib/io.cjs +122 -3
- package/gsd-core/bin/lib/loop-resolver.cjs +95 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +75 -1
- package/gsd-core/bin/lib/milestone.cjs +37 -6
- package/gsd-core/bin/lib/model-resolver.cjs +171 -62
- package/gsd-core/bin/lib/observability/event.cjs +1 -1
- package/gsd-core/bin/lib/observability/logger.cjs +46 -1
- package/gsd-core/bin/lib/pattern.cjs +10 -0
- package/gsd-core/bin/lib/phase-command-router.cjs +20 -5
- package/gsd-core/bin/lib/phase-estimation.cjs +5 -4
- package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
- package/gsd-core/bin/lib/phase-id.cjs +110 -8
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -2
- package/gsd-core/bin/lib/phase-locator.cjs +29 -10
- package/gsd-core/bin/lib/phase-status.cjs +360 -0
- package/gsd-core/bin/lib/phase.cjs +489 -88
- package/gsd-core/bin/lib/plan-document.cjs +142 -20
- package/gsd-core/bin/lib/plan-drift-guard.cjs +5 -0
- package/gsd-core/bin/lib/planning-document.cjs +692 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +60 -9
- package/gsd-core/bin/lib/planning-snapshot.cjs +18 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +83 -55
- package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
- package/gsd-core/bin/lib/pristine-baseline.cjs +10 -0
- package/gsd-core/bin/lib/probe-core.cjs +7 -1
- package/gsd-core/bin/lib/profile-output.cjs +6 -3
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +0 -55
- package/gsd-core/bin/lib/project-root.cjs +41 -2
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +35 -9
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +11 -8
- package/gsd-core/bin/lib/real-home-guard.cjs +9 -1
- package/gsd-core/bin/lib/report-parser.cjs +269 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-command-router.cjs +25 -19
- package/gsd-core/bin/lib/roadmap-parser.cjs +242 -15
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +1653 -65
- package/gsd-core/bin/lib/roadmap.cjs +405 -88
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +373 -187
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +57 -1
- package/gsd-core/bin/lib/runtime-homes.cjs +14 -7
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +522 -624
- package/gsd-core/bin/lib/runtime-name-policy.cjs +246 -21
- package/gsd-core/bin/lib/runtime-slash.cjs +47 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +47 -8
- package/gsd-core/bin/lib/smart-entry.cjs +19 -3
- package/gsd-core/bin/lib/stale-bake-guard.cjs +32 -48
- package/gsd-core/bin/lib/state-contract.cjs +15 -18
- package/gsd-core/bin/lib/state-document.cjs +100 -22
- package/gsd-core/bin/lib/state-transition.cjs +39 -2
- package/gsd-core/bin/lib/state.cjs +256 -105
- package/gsd-core/bin/lib/surface.cjs +19 -2
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +48 -79
- package/gsd-core/bin/lib/uat-predicate.cjs +359 -38
- package/gsd-core/bin/lib/uat.cjs +432 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +167 -40
- package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
- package/gsd-core/bin/lib/vendor/README.md +31 -9
- package/gsd-core/bin/lib/vendor/saxes.cjs +1934 -0
- package/gsd-core/bin/lib/vendor/saxes.cjs.LICENSE.txt +92 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs +8927 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs.LICENSE.txt +152 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +1378 -274
- package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
- package/gsd-core/bin/lib/verify-command-router.cjs +18 -7
- package/gsd-core/bin/lib/verify.cjs +357 -531
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +14 -6
- package/gsd-core/bin/lib/workstream-inventory.cjs +31 -21
- package/gsd-core/bin/lib/workstream-name-policy.cjs +31 -1
- package/gsd-core/bin/lib/workstream.cjs +11 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
- package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
- package/gsd-core/bin/shared/config-defaults.manifest.json +8 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
- package/gsd-core/references/autonomous-smart-discuss.md +2 -1
- package/gsd-core/references/autonomous-ui-design-contract.md +3 -3
- package/gsd-core/references/checkpoints.md +5 -3
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe.md +195 -21
- package/gsd-core/references/execute-mvp-tdd.md +5 -10
- package/gsd-core/references/execute-phase-between-wave-reset.md +10 -6
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +22 -11
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +7 -1
- package/gsd-core/references/model-profiles.md +1 -1
- package/gsd-core/references/offer-next.md +1 -1
- package/gsd-core/references/phase-argument-parsing.md +9 -7
- package/gsd-core/references/phase-id-convention.md +28 -0
- package/gsd-core/references/planner-gap-closure.md +2 -0
- package/gsd-core/references/planner-load-graph-context.md +24 -13
- package/gsd-core/references/planner-verify-command-grounding.md +14 -0
- package/gsd-core/references/planning-config.md +12 -3
- package/gsd-core/references/spidr-splitting.md +1 -1
- package/gsd-core/references/tdd.md +37 -8
- package/gsd-core/references/ui-consideration-probe.md +10 -5
- package/gsd-core/references/verifier-phase-gates.md +5 -2
- package/gsd-core/references/verify-command-path-resolvability.md +10 -2
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -3
- package/gsd-core/references/worktree-path-safety.md +321 -0
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/UAT.md +17 -1
- package/gsd-core/templates/config.json +2 -11
- package/gsd-core/templates/verification-report.md +1 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +8 -7
- package/gsd-core/workflows/add-tests.md +4 -3
- package/gsd-core/workflows/add-todo.md +6 -5
- package/gsd-core/workflows/ai-integration-phase.md +13 -4
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +4 -3
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
- package/gsd-core/workflows/autonomous.md +43 -23
- package/gsd-core/workflows/check-todos.md +7 -6
- package/gsd-core/workflows/cleanup.md +2 -2
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +20 -13
- package/gsd-core/workflows/code-review-fix.md +112 -25
- package/gsd-core/workflows/code-review.md +146 -135
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +4 -3
- package/gsd-core/workflows/complete-milestone.md +13 -8
- package/gsd-core/workflows/debug.md +32 -7
- package/gsd-core/workflows/diagnose-issues.md +3 -2
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +4 -3
- package/gsd-core/workflows/do.md +2 -2
- package/gsd-core/workflows/docs-update.md +6 -5
- package/gsd-core/workflows/edit-phase.md +4 -3
- package/gsd-core/workflows/eval-review.md +14 -5
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1019 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +15 -4
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +10 -7
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +3 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +187 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +2 -3
- package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
- package/gsd-core/workflows/execute-phase.md +96 -178
- package/gsd-core/workflows/execute-plan.md +18 -23
- package/gsd-core/workflows/explore.md +4 -4
- package/gsd-core/workflows/extract-learnings.md +4 -2
- package/gsd-core/workflows/fast.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +3 -2
- package/gsd-core/workflows/help/modes/full.compact.md +3 -3
- package/gsd-core/workflows/help/modes/full.md +5 -5
- package/gsd-core/workflows/help/modes/topic.md +15 -5
- package/gsd-core/workflows/import.md +4 -3
- package/gsd-core/workflows/inbox.md +2 -2
- package/gsd-core/workflows/ingest-docs.md +3 -3
- package/gsd-core/workflows/insert-phase.md +4 -3
- package/gsd-core/workflows/list-seeds.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +6 -4
- package/gsd-core/workflows/map-codebase.md +5 -4
- package/gsd-core/workflows/milestone-summary.md +3 -2
- package/gsd-core/workflows/mvp-phase.md +14 -14
- package/gsd-core/workflows/new-milestone.md +11 -11
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
- package/gsd-core/workflows/new-project.md +7 -7
- package/gsd-core/workflows/new-workspace.md +2 -2
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/note.md +1 -1
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +2 -2
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
- package/gsd-core/workflows/plan-phase.md +50 -24
- package/gsd-core/workflows/plan-review-convergence.md +21 -5
- package/gsd-core/workflows/plant-seed.md +62 -20
- package/gsd-core/workflows/pr-branch.md +113 -13
- package/gsd-core/workflows/profile-user.md +2 -2
- package/gsd-core/workflows/progress.md +19 -49
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +27 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +4 -4
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +30 -11
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +9 -3
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
- package/gsd-core/workflows/quick-batch.md +15 -10
- package/gsd-core/workflows/quick.md +62 -39
- package/gsd-core/workflows/reapply-patches.md +9 -3
- package/gsd-core/workflows/remove-phase.md +3 -2
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +3 -2
- package/gsd-core/workflows/review.md +33 -17
- package/gsd-core/workflows/scan.md +3 -2
- package/gsd-core/workflows/secure-phase.md +13 -13
- package/gsd-core/workflows/settings-advanced.md +30 -10
- package/gsd-core/workflows/settings-integrations.md +2 -3
- package/gsd-core/workflows/settings.md +4 -4
- package/gsd-core/workflows/ship.md +14 -13
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +2 -2
- package/gsd-core/workflows/spec-phase.md +15 -5
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/sync-skills.md +5 -5
- package/gsd-core/workflows/thread.md +2 -2
- package/gsd-core/workflows/transition.md +13 -23
- package/gsd-core/workflows/ui-phase.md +48 -11
- package/gsd-core/workflows/ui-review.md +21 -6
- package/gsd-core/workflows/ultraplan-phase.md +3 -2
- package/gsd-core/workflows/undo.md +339 -20
- package/gsd-core/workflows/update.md +7 -7
- package/gsd-core/workflows/validate-phase.md +12 -13
- package/gsd-core/workflows/verify-work/detail/elaboration.md +43 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -3
- package/gsd-core/workflows/verify-work.md +129 -55
- package/hooks/dist/gsd-agent-isolation-guard.js +32 -0
- package/hooks/dist/gsd-check-update-worker.js +8 -0
- package/hooks/dist/gsd-check-update.js +8 -0
- package/hooks/dist/gsd-context-monitor.js +31 -8
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -0
- package/hooks/dist/gsd-secret-read-guard.js +161 -4
- package/hooks/dist/gsd-statusline.js +85 -20
- package/hooks/dist/gsd-update-banner.js +8 -0
- package/hooks/dist/gsd-validate-commit.sh +63 -4
- package/hooks/dist/gsd-windsurf-pre-write.js +11 -2
- package/hooks/dist/gsd-workflow-guard.js +5 -4
- package/hooks/dist/gsd-worktree-path-guard.js +6 -2
- package/hooks/dist/lib/cli-exit.js +12 -0
- package/hooks/dist/lib/git-probe.js +17 -1
- package/hooks/dist/lib/isolation-sentinel.js +2 -2
- package/hooks/gsd-agent-isolation-guard.js +32 -0
- package/hooks/gsd-check-update-worker.js +8 -0
- package/hooks/gsd-check-update.js +8 -0
- package/hooks/gsd-context-monitor.js +31 -8
- package/hooks/gsd-cursor-subagent-start.js +8 -0
- package/hooks/gsd-secret-read-guard.js +161 -4
- package/hooks/gsd-statusline.js +85 -20
- package/hooks/gsd-update-banner.js +8 -0
- package/hooks/gsd-validate-commit.sh +63 -4
- package/hooks/gsd-windsurf-pre-write.js +11 -2
- package/hooks/gsd-workflow-guard.js +5 -4
- package/hooks/gsd-worktree-path-guard.js +6 -2
- package/hooks/hooks.json +5 -5
- package/hooks/lib/cli-exit.js +12 -0
- package/hooks/lib/git-probe.js +17 -1
- package/hooks/lib/isolation-sentinel.js +2 -2
- package/package.json +22 -4
- package/scripts/build-hooks.js +15 -6
- package/scripts/changeset/parse.cjs +52 -4
- package/scripts/check-contract-drift.cjs +127 -11
- package/scripts/ci-timeout-report.cjs +770 -4
- package/scripts/command-contract-helpers.cjs +15 -8
- package/scripts/docs-guard-registry.cjs +34 -0
- package/scripts/gen-features.cjs +13 -8
- package/scripts/gen-hooks-cli-exit.cjs +12 -28
- package/scripts/gen-loop-host-contract.cjs +79 -1
- package/scripts/gen-platform-conformance-tier.cjs +187 -1
- package/scripts/gen-plugin-skills.cjs +87 -1
- package/scripts/gen-research-agents.cjs +24 -31
- package/scripts/gen-scripts-cli-exit.cjs +30 -3
- package/scripts/gen-test-timings.cjs +32 -7
- package/scripts/lib/cli-exit.cjs +12 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +34 -2
- package/scripts/lib/ndjson-reporter.cjs +31 -5
- package/scripts/lib/platform-conformance-tier.generated.cjs +45 -5
- package/scripts/lib/registration-ledger-preload.cjs +155 -0
- package/scripts/lib/vendor-bundle.cjs +59 -0
- package/scripts/lib/vendor-licenses/saxes-6.0.0.txt +64 -0
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-completion-predicate-drift.cjs +18 -19
- package/scripts/lint-descriptions.cjs +7 -3
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +38 -1
- package/scripts/lint-eslint-glob-coverage.allowlist.json +20 -0
- package/scripts/lint-frontmatter-fence-drift.cjs +313 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +122 -16
- package/scripts/lint-phase-arg-assignment.cjs +257 -0
- package/scripts/lint-phase-enumeration-drift.cjs +12 -8
- package/scripts/lint-phase-id-drift.cjs +319 -5
- package/scripts/lint-planning-document-positive-control.cjs +329 -0
- package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
- package/scripts/lint-response-language-coverage.cjs +3 -0
- package/scripts/lint-retired-runtime-name.cjs +619 -0
- package/scripts/lint-skill-deps.cjs +7 -3
- package/scripts/lint-state-write-path-drift.cjs +93 -0
- package/scripts/lint-test-file-count.allowlist.json +38 -9
- package/scripts/lint-test-file-count.cjs +34 -1
- package/scripts/lint-vendored-deps.cjs +41 -5
- package/scripts/lint-workflow-shellcheck-baseline.json +25 -10
- package/scripts/mutation-matrix.cjs +50 -5
- package/scripts/prompt-injection-scan.sh +4 -0
- package/scripts/release-tarball-smoke.cjs +194 -1
- package/scripts/require-issue-link-policy.cjs +6 -2
- package/scripts/sync-runtime-launcher.cjs +184 -2
- package/scripts/verify-npm-publish.cjs +76 -20
- package/skills/gsd-add-tests/SKILL.md +6 -1
- package/skills/gsd-ai-integration-phase/SKILL.md +6 -1
- package/skills/gsd-audit-fix/SKILL.md +5 -0
- package/skills/gsd-audit-milestone/SKILL.md +6 -1
- package/skills/gsd-autonomous/SKILL.md +7 -2
- package/skills/gsd-capture/SKILL.md +9 -5
- package/skills/gsd-code-review/SKILL.md +7 -2
- package/skills/gsd-complete-milestone/SKILL.md +4 -0
- package/skills/gsd-config/SKILL.md +8 -4
- package/skills/gsd-debug/SKILL.md +11 -7
- package/skills/gsd-discuss-phase/SKILL.md +7 -3
- package/skills/gsd-docs-update/SKILL.md +12 -7
- package/skills/gsd-eval-review/SKILL.md +6 -1
- package/skills/gsd-execute-phase/SKILL.md +12 -7
- package/skills/gsd-extract-learnings/SKILL.md +5 -0
- package/skills/gsd-fast/SKILL.md +4 -0
- package/skills/gsd-forensics/SKILL.md +5 -1
- package/skills/gsd-graphify/SKILL.md +10 -6
- package/skills/gsd-health/SKILL.md +5 -0
- package/skills/gsd-help/SKILL.md +7 -2
- package/skills/gsd-import/SKILL.md +7 -3
- package/skills/gsd-inbox/SKILL.md +5 -0
- package/skills/gsd-ingest-docs/SKILL.md +5 -1
- package/skills/gsd-manager/SKILL.md +6 -1
- package/skills/gsd-map-codebase/SKILL.md +7 -3
- package/skills/gsd-mempalace-capture/SKILL.md +12 -4
- package/skills/gsd-mempalace-recall/SKILL.md +5 -1
- package/skills/gsd-milestone-summary/SKILL.md +5 -1
- package/skills/gsd-mvp-phase/SKILL.md +8 -3
- package/skills/gsd-new-milestone/SKILL.md +6 -1
- package/skills/gsd-new-project/SKILL.md +5 -0
- package/skills/gsd-next/SKILL.md +6 -1
- package/skills/gsd-ns-context/SKILL.md +4 -0
- package/skills/gsd-ns-ideate/SKILL.md +4 -0
- package/skills/gsd-ns-manage/SKILL.md +4 -0
- package/skills/gsd-ns-project/SKILL.md +4 -0
- package/skills/gsd-ns-review/SKILL.md +4 -0
- package/skills/gsd-ns-workflow/SKILL.md +4 -0
- package/skills/gsd-onboard/SKILL.md +6 -1
- package/skills/gsd-pause-work/SKILL.md +5 -1
- package/skills/gsd-phase/SKILL.md +8 -4
- package/skills/gsd-plan-phase/SKILL.md +6 -1
- package/skills/gsd-plan-review-convergence/SKILL.md +10 -6
- package/skills/gsd-pr-branch/SKILL.md +4 -0
- package/skills/gsd-profile-user/SKILL.md +5 -1
- package/skills/gsd-progress/SKILL.md +7 -2
- package/skills/gsd-quick/SKILL.md +16 -10
- package/skills/gsd-quick-batch/SKILL.md +21 -9
- package/skills/gsd-review/SKILL.md +7 -4
- package/skills/gsd-review-backlog/SKILL.md +3 -2
- package/skills/gsd-secure-phase/SKILL.md +6 -1
- package/skills/gsd-ship/SKILL.md +5 -0
- package/skills/gsd-sketch/SKILL.md +7 -2
- package/skills/gsd-spec-phase/SKILL.md +5 -1
- package/skills/gsd-spike/SKILL.md +8 -3
- package/skills/gsd-surface/SKILL.md +5 -1
- package/skills/gsd-thread/SKILL.md +4 -0
- package/skills/gsd-ui-phase/SKILL.md +6 -1
- package/skills/gsd-ui-review/SKILL.md +6 -1
- package/skills/gsd-ultraplan-phase/SKILL.md +5 -1
- package/skills/gsd-undo/SKILL.md +5 -1
- package/skills/gsd-update/SKILL.md +6 -2
- package/skills/gsd-validate-phase/SKILL.md +6 -1
- package/skills/gsd-verify-work/SKILL.md +6 -1
- package/skills/gsd-workspace/SKILL.md +7 -3
- package/skills/gsd-workstreams/SKILL.md +6 -6
- package/vscode/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* Roadmap Upgrade — Migration tool for converting legacy 'Phase N' phase IDs
|
|
4
|
-
* to milestone-prefixed 'Phase M-NN' form.
|
|
4
|
+
* to milestone-prefixed 'Phase M-NN', or legacy/M-NN IDs to bracket form.
|
|
5
5
|
*
|
|
6
6
|
* ADR-457 build-at-publish: the hand-written bin/lib/roadmap-upgrade.cjs collapsed
|
|
7
7
|
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
|
@@ -18,8 +18,68 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
|
|
|
18
18
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
19
19
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
20
20
|
const phaseIdMod = require("./phase-id.cjs");
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
|
+
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
|
+
const planningScopeMod = require("./planning-scope.cjs");
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
26
|
+
const frontmatterMod = require("./frontmatter.cjs");
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
|
+
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
30
|
+
const phaseMod = require("./phase.cjs");
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
|
+
const coreUtilsMod = require("./core-utils.cjs");
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
34
|
+
const markdownSectionizerMod = require("./markdown-sectionizer.cjs");
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
|
+
const planScanMod = require("./plan-scan.cjs");
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const stateMod = require("./state.cjs");
|
|
39
|
+
const planning_document_cjs_1 = require("./planning-document.cjs");
|
|
21
40
|
const { planningDir } = planningWorkspace;
|
|
22
|
-
const {
|
|
41
|
+
const { listAllPhaseDirs } = phaseLocatorMod;
|
|
42
|
+
const { SCOPE } = planningScopeMod;
|
|
43
|
+
// #4698 Blocker 1 (round 2): reuse the shared frontmatter reader/writer
|
|
44
|
+
// (src/frontmatter.cts) rather than a bespoke YAML touch — `spliceFrontmatter`
|
|
45
|
+
// already solves "change exactly one key, preserve every other key's raw text
|
|
46
|
+
// byte-for-byte", which is exactly the contract a `depends_on` rewrite needs.
|
|
47
|
+
const { extractFrontmatter, spliceFrontmatter, isFrontmatterWriteRefusal } = frontmatterMod;
|
|
48
|
+
/**
|
|
49
|
+
* The migration's fail-closed error for a phase artifact whose frontmatter the shared
|
|
50
|
+
* writer refuses to rewrite: it names the artifact's full path and, for a write refusal,
|
|
51
|
+
* the refusal code (also set as `code` on the error) — so the user can find the file and
|
|
52
|
+
* see why (found while implementing #5105).
|
|
53
|
+
*/
|
|
54
|
+
function frontmatterRewriteError(what, filePath, sourceToken, targetToken, err) {
|
|
55
|
+
const code = isFrontmatterWriteRefusal(err) ? err.code : undefined;
|
|
56
|
+
const rewriteErr = new Error(`Cannot rewrite ${what} in ${JSON.stringify(filePath)} for phase token change `
|
|
57
|
+
+ `${JSON.stringify(sourceToken)} -> ${JSON.stringify(targetToken)}`
|
|
58
|
+
+ `${code ? ` [${code}]` : ''}: ${err.message}`);
|
|
59
|
+
if (code)
|
|
60
|
+
rewriteErr.code = code;
|
|
61
|
+
return rewriteErr;
|
|
62
|
+
}
|
|
63
|
+
const { milestoneSections, isPhaseHeadingText } = roadmapParserMod;
|
|
64
|
+
const { normalizeDependencyToken } = phaseMod;
|
|
65
|
+
// #4144 round 5 Blocker 3: the single owner of the canonical short-alias
|
|
66
|
+
// derivation the real resolver's own canonicalToId map is built from
|
|
67
|
+
// (src/phase.cts) — reused here rather than re-derived.
|
|
68
|
+
const { extractCanonicalPlanId } = coreUtilsMod;
|
|
69
|
+
// #4144 round 5 Blocker 4: the readers' own fence-scanning engine
|
|
70
|
+
// (tokenizeHeadings is built on this same seam) — reused here instead of a
|
|
71
|
+
// third independent fence parser.
|
|
72
|
+
const { scanFencedBlocks, updateBullet, updateHeading } = markdownSectionizerMod;
|
|
73
|
+
// ADR-5057 §6 (Phase 13, #5217): every mutation of a planning artifact goes
|
|
74
|
+
// through its seam — headings/bullets through the sectionizer, PROJECT.md
|
|
75
|
+
// prose through PlanningDoc, STATE.md through its own write seam.
|
|
76
|
+
const { readModifyWriteStateMd } = stateMod;
|
|
77
|
+
// #4144 round 5 follow-up (lint-plan-count-drift): the plan-scan owner's own
|
|
78
|
+
// root-plan-file predicate (src/plan-scan.cts) — reused in
|
|
79
|
+
// computeDependsOnRewrites instead of a private `/-PLAN\.md$/i` filename
|
|
80
|
+
// re-derivation of the same membership test.
|
|
81
|
+
const { isRootPlanFile } = planScanMod;
|
|
82
|
+
const { BRACKET_ID_SRC, BRACKET_PROJECT_CODE_SRC, isBracketPhaseTokenRepresentable, isSentinelPhaseId, normalizePhaseName, OPTIONAL_PHASE_TAG_SOURCE, PHASE_HEADING_BASELINE, phaseHeadingPrefixSrcFor, PHASE_NUMBER_TOKEN_SOURCE, phaseArtifactTokenSpan, stripProjectCodePrefix, toDir, } = phaseIdMod;
|
|
23
83
|
// ─── Regex helpers ────────────────────────────────────────────────────────────
|
|
24
84
|
// Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
|
|
25
85
|
// Captures: (hashes)(spaces)(phase-number)(rest-of-line)
|
|
@@ -29,6 +89,92 @@ const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d+
|
|
|
29
89
|
// Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
|
|
30
90
|
// The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
|
|
31
91
|
const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
|
|
92
|
+
// Bracket headings are terminal migration targets. Both the bracket identity
|
|
93
|
+
// and the following phase token come from the phase-id owner (#2128).
|
|
94
|
+
// #4698 Blocker 3 (round 2): widened with (uncaptured) OPTIONAL_PHASE_TAG_SOURCE
|
|
95
|
+
// so an ALREADY-migrated bracket heading that carries a tag
|
|
96
|
+
// (`### [GSD.02] 05 (Cluster B): Name`) is still recognized as migrated —
|
|
97
|
+
// this constant is exclusively bracket-owned (no shared consumer whose group
|
|
98
|
+
// indices this would disturb), so it is safe to widen in place.
|
|
99
|
+
const BRACKET_PHASE_HEADING_RE = new RegExp(`^#{2,4}\\s*\\[${BRACKET_ID_SRC}\\][ \\t]*(?:Phase\\s+)?${PHASE_NUMBER_TOKEN_SOURCE}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
|
|
100
|
+
// phase-id-owner: ADR-612 PR-3 exclusively owns the deprecated M-NN source grammar during the migration window.
|
|
101
|
+
// M-NN has exactly one consumer (parseBracketSourcePhases, via
|
|
102
|
+
// MNN_PHASE_HEADING_BRACKET_RE below) — the historical milestone-prefixed
|
|
103
|
+
// target never recognizes M-NN input at all — so only the shared token
|
|
104
|
+
// source lives here; the heading regex itself is bracket-path-owned.
|
|
105
|
+
const MNN_SOURCE_TOKEN_SOURCE = '\\d+-\\d+(?:-\\d+)?';
|
|
106
|
+
const PROJECT_CODE_RE = new RegExp(`^${BRACKET_PROJECT_CODE_SRC}$`);
|
|
107
|
+
// #4698 Blocker 3 (round 2): bracket-source-parsing-OWNED copies of the
|
|
108
|
+
// legacy/M-NN heading grammars above, widened with a CAPTURED
|
|
109
|
+
// `OPTIONAL_PHASE_TAG_SOURCE` so a heading the runtime already supports —
|
|
110
|
+
// `### Phase 2 (Cluster B): Beta`, the optional parenthetical tag between the
|
|
111
|
+
// phase number and the colon (src/phase-id.cts, #1729) — is recognized here
|
|
112
|
+
// too, instead of failing every grammar and vanishing from the plan silently
|
|
113
|
+
// (the exact reported defect). `src/roadmap.cts`'s real bracket readers
|
|
114
|
+
// compose the phaseHeadingPrefixSrcFor selector's result with
|
|
115
|
+
// PHASE_NUMBER_TOKEN_SOURCE + OPTIONAL_PHASE_TAG_SOURCE + ':' for BOTH the
|
|
116
|
+
// bracket and label-only intros (e.g. `src/roadmap.cts:184`) — so the
|
|
117
|
+
// bracket heading grammar DOES have a tag slot, in this exact position, and
|
|
118
|
+
// this migrator must emit into it rather than drop the tag or refuse it.
|
|
119
|
+
// (Named here only for the reader tracing the claim, never called: this file
|
|
120
|
+
// does not consume that convention-gated selector, so scripts/lint-phase-id-
|
|
121
|
+
// drift.cjs's closed selector-consumer census must not, and does not, count
|
|
122
|
+
// it as one — see tests/adr-612-bracket-heading-selection.test.cjs.)
|
|
123
|
+
//
|
|
124
|
+
// LEGACY_PHASE_HEADING_BRACKET_RE is a SEPARATE constant from
|
|
125
|
+
// LEGACY_PHASE_HEADING_RE (used by the historical milestone-prefixed
|
|
126
|
+
// target's OWN parseRoadmapPhases and its own separate heading-rewrite regex
|
|
127
|
+
// in computeMigrationPlan, both with an established, untouched group-index
|
|
128
|
+
// contract) rather than widening that shared regex in place: widening
|
|
129
|
+
// recognition there alone would newly COUNT a tagged heading that target's
|
|
130
|
+
// own rewrite regex still cannot rewrite (a different, unfixed regex),
|
|
131
|
+
// trading a silent skip for a silent half-migration. M-NN needs no such
|
|
132
|
+
// pairing — it has exactly one consumer (this bracket-only source parser),
|
|
133
|
+
// per MNN_SOURCE_TOKEN_SOURCE's own comment above.
|
|
134
|
+
// #4144 round 6 (W-CRLF): the trailing capture is `(.*\r?)`, not `(.*)`. JS
|
|
135
|
+
// `.` never matches `\r` (it is its own LineTerminator, ECMA-262), so a CRLF
|
|
136
|
+
// roadmap's `\r` sat just past the `(.*)` group's end — present in the LINE
|
|
137
|
+
// but absent from `headingTail`, which the heading rebuild below is built
|
|
138
|
+
// from. Since the roadmap rewrite (`rewriteRoadmapLines`) replaces a touched line's FULL text with
|
|
139
|
+
// the rebuilt one, every converted heading silently lost its `\r` while
|
|
140
|
+
// every untouched line (and every checklist bullet, whose rewrite instead
|
|
141
|
+
// slices the line's own remainder rather than reassembling captured groups)
|
|
142
|
+
// kept it — a mixed-EOL file despite this function's own "preserve every
|
|
143
|
+
// terminator" contract. The explicit `\r?` re-admits it into the SAME group
|
|
144
|
+
// `headingTail` is read from, so the rebuilt line carries it forward.
|
|
145
|
+
const LEGACY_PHASE_HEADING_BRACKET_RE = new RegExp(`^(#{2,4})\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE})\\s*:(.*\\r?)`, 'i');
|
|
146
|
+
const MNN_PHASE_HEADING_BRACKET_RE = new RegExp(`^(#{2,4})\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(${MNN_SOURCE_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE})\\s*:(.*\\r?)`, 'i');
|
|
147
|
+
// #4698 Blocker 3 (round 2), part (b): a line that STARTS like a phase
|
|
148
|
+
// heading, or like a bracket-shaped heading, but is parsed by NONE of the
|
|
149
|
+
// three recognized grammars above must never be silently skipped — that is
|
|
150
|
+
// exactly how a tagged/malformed heading went unmigrated while the roadmap
|
|
151
|
+
// still got stamped with the target convention (the reported defect's root
|
|
152
|
+
// cause: partial conversion read as "done"). `computeBracketPlan` refuses
|
|
153
|
+
// before any write when either check below matches a line that none of
|
|
154
|
+
// BRACKET_PHASE_HEADING_RE / MNN_PHASE_HEADING_BRACKET_RE /
|
|
155
|
+
// LEGACY_PHASE_HEADING_BRACKET_RE accepted.
|
|
156
|
+
//
|
|
157
|
+
// #4144 round 5 Blocker 1 (regression from round 3): the "phase-like" half
|
|
158
|
+
// of that refusal used to be a bare `/^#{2,4}\s*Phase\b/i` — anything
|
|
159
|
+
// starting with the word "Phase" — which also caught
|
|
160
|
+
// gsd-core/templates/roadmap.md's own shipped `## Phase Details` section
|
|
161
|
+
// heading (no phase number, no colon) and aborted migration of every
|
|
162
|
+
// roadmap built from that template. The refusal may only fire for a heading
|
|
163
|
+
// the READERS' OWN phase-heading grammar (roadmap-parser.cts's
|
|
164
|
+
// hasPhaseEntries, exported as `isPhaseHeadingText`) would itself treat as a
|
|
165
|
+
// phase heading — reusing that single owner rather than a second, looser
|
|
166
|
+
// grammar here. `isPhaseLikeHeadingLine` strips the `#{2,4}` markers a raw
|
|
167
|
+
// source line still carries (tokenizeHeadings' own `h.text` already has
|
|
168
|
+
// them stripped; a source line handed to this scanner does not) before
|
|
169
|
+
// testing.
|
|
170
|
+
const HEADING_HASH_PREFIX_RE = /^#{2,4}[ \t]*/;
|
|
171
|
+
const isPhaseLikeHeadingLine = (line) => {
|
|
172
|
+
const match = HEADING_HASH_PREFIX_RE.exec(line);
|
|
173
|
+
if (!match)
|
|
174
|
+
return false;
|
|
175
|
+
return isPhaseHeadingText(line.slice(match[0].length));
|
|
176
|
+
};
|
|
177
|
+
const BRACKET_HEADING_LIKE_RE = /^#{2,4}\s*\[[^\]]{1,200}\]/;
|
|
32
178
|
// ─── Pure computation helpers ─────────────────────────────────────────────────
|
|
33
179
|
/**
|
|
34
180
|
* Parse the ROADMAP.md content and build a list of phase entries with their
|
|
@@ -128,12 +274,1345 @@ function buildNewDirName(oldDirName, newId, projectCode) {
|
|
|
128
274
|
const newBase = slug ? `${paddedMilestone}-${subStr}-${slug}` : `${paddedMilestone}-${subStr}`;
|
|
129
275
|
return projectCode ? `${projectCode}-${newBase}` : newBase;
|
|
130
276
|
}
|
|
277
|
+
const pad2 = (value) => String(value).padStart(2, '0');
|
|
278
|
+
/**
|
|
279
|
+
* #4144 round 6 B2/B3: the section-keyed maps below (`sectionLegacyMap`) key
|
|
280
|
+
* on a `milestoneSections` range's own `start` offset, which is always >= 0
|
|
281
|
+
* for a real section. This sentinel covers legacy entries with NO attributed
|
|
282
|
+
* section at all (single-section / STATE.md-fallback repositories), where
|
|
283
|
+
* there is only one bucket to begin with.
|
|
284
|
+
*/
|
|
285
|
+
const GLOBAL_SECTION_KEY = -1;
|
|
286
|
+
/**
|
|
287
|
+
* #4144 round 5 Blocker 4 (Warn): the line indices `parseBracketSourcePhases`
|
|
288
|
+
* must never classify as a heading of any kind because a fenced code block
|
|
289
|
+
* covers them — mirrors the READERS' own fence-awareness (tokenizeHeadings,
|
|
290
|
+
* markdown-sectionizer.cts, used at roadmap-parser.cts:689) via the SAME
|
|
291
|
+
* `scanFencedBlocks` engine, rather than a third fence parser. Both fence
|
|
292
|
+
* delimiter lines themselves are included (harmless: neither is a phase
|
|
293
|
+
* heading), and an unterminated trailing fence covers to the end of the
|
|
294
|
+
* document, matching `scanFencedBlocks`' own EOF-still-open semantics.
|
|
295
|
+
*/
|
|
296
|
+
function fencedLineIndices(lines) {
|
|
297
|
+
const fenced = new Set();
|
|
298
|
+
for (const block of scanFencedBlocks(lines)) {
|
|
299
|
+
const end = block.closeLineIdx === -1 ? lines.length - 1 : block.closeLineIdx;
|
|
300
|
+
for (let i = block.openLineIdx; i <= end; i++)
|
|
301
|
+
fenced.add(i);
|
|
302
|
+
}
|
|
303
|
+
return fenced;
|
|
304
|
+
}
|
|
305
|
+
function parseBracketSourcePhases(lines) {
|
|
306
|
+
const results = [];
|
|
307
|
+
// #4698 Blocker 3 (round 2), part (b): every phase-like/bracket-like line
|
|
308
|
+
// this loop could not place into any of the three recognized grammars.
|
|
309
|
+
const unparsed = [];
|
|
310
|
+
let currentMilestoneInt = null;
|
|
311
|
+
const fenced = fencedLineIndices(lines);
|
|
312
|
+
for (let i = 0; i < lines.length; i++) {
|
|
313
|
+
if (fenced.has(i))
|
|
314
|
+
continue;
|
|
315
|
+
const line = lines[i];
|
|
316
|
+
const milestoneMatch = line.match(MILESTONE_HEADING_RE);
|
|
317
|
+
if (milestoneMatch) {
|
|
318
|
+
currentMilestoneInt = parseInt(milestoneMatch[1], 10);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (BRACKET_PHASE_HEADING_RE.test(line)) {
|
|
322
|
+
results.push({ lineIndex: i, alreadyMigrated: true });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// M-NN must be tested before legacy. It is a convertible source under
|
|
326
|
+
// bracket, not the terminal convention it is for the legacy migrator.
|
|
327
|
+
const mnnMatch = line.match(MNN_PHASE_HEADING_BRACKET_RE);
|
|
328
|
+
if (mnnMatch) {
|
|
329
|
+
const segments = mnnMatch[2].split('-');
|
|
330
|
+
results.push({
|
|
331
|
+
lineIndex: i,
|
|
332
|
+
alreadyMigrated: false,
|
|
333
|
+
source: 'mnn',
|
|
334
|
+
milestoneInt: parseInt(segments[0], 10),
|
|
335
|
+
sourceToken: mnnMatch[2],
|
|
336
|
+
headingTag: mnnMatch[3] ?? '',
|
|
337
|
+
headingTail: mnnMatch[4],
|
|
338
|
+
hashes: mnnMatch[1],
|
|
339
|
+
});
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const legacyMatch = line.match(LEGACY_PHASE_HEADING_BRACKET_RE);
|
|
343
|
+
if (legacyMatch) {
|
|
344
|
+
results.push({
|
|
345
|
+
lineIndex: i,
|
|
346
|
+
alreadyMigrated: false,
|
|
347
|
+
source: 'legacy',
|
|
348
|
+
milestoneInt: currentMilestoneInt,
|
|
349
|
+
sourceToken: legacyMatch[2],
|
|
350
|
+
headingTag: legacyMatch[3] ?? '',
|
|
351
|
+
headingTail: legacyMatch[4],
|
|
352
|
+
hashes: legacyMatch[1],
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (isPhaseLikeHeadingLine(line) || BRACKET_HEADING_LIKE_RE.test(line)) {
|
|
357
|
+
unparsed.push(line);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { entries: results, unparsed };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Resolve bracket target tokens. M-NN sources preserve their integer
|
|
364
|
+
* identity while moving the milestone into the bracket: `2-01` → `01`;
|
|
365
|
+
* `2-04-01` → `04.01`. Legacy phases receive a deterministic per-milestone
|
|
366
|
+
* counter — but #4144 round 5 Blocker 2: a preserved M-NN token is NEVER
|
|
367
|
+
* reassignable, so it must RESERVE its own leading integer segment against
|
|
368
|
+
* that same milestone's legacy counter before any legacy phase is numbered,
|
|
369
|
+
* or the two axes can silently collide (`2-01` preserved as `01` and a
|
|
370
|
+
* later legacy `Phase 3` also counter-assigned `01`, under the same
|
|
371
|
+
* milestone). `lines` is the raw ROADMAP.md source, used only to name each
|
|
372
|
+
* colliding heading verbatim in the refusal below — never to re-derive
|
|
373
|
+
* identity.
|
|
374
|
+
*
|
|
375
|
+
* Two passes, in `entries`' own document order (never re-sorted):
|
|
376
|
+
* 1. Every preserved M-NN entry gets its own deterministic token AND
|
|
377
|
+
* reserves that token's leading integer segment for its milestone.
|
|
378
|
+
* 2. Every legacy entry gets the next per-milestone counter value pass 1
|
|
379
|
+
* did not reserve.
|
|
380
|
+
* A generic post-pass then refuses, before any write, if two entries under
|
|
381
|
+
* the same milestone still resolved to the same final token — covering both
|
|
382
|
+
* "two preserved M-NN spellings of the same integer" (`2-01` and `2-1`, both
|
|
383
|
+
* `01`) and any other collision pass 1/2 failed to keep disjoint.
|
|
384
|
+
*/
|
|
385
|
+
function assignBracketTokens(entries, lines) {
|
|
386
|
+
const mappings = new Map();
|
|
387
|
+
// Pass 1: preserved M-NN tokens.
|
|
388
|
+
const reservedByMilestone = new Map();
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
if (entry.alreadyMigrated || entry.source !== 'mnn')
|
|
391
|
+
continue;
|
|
392
|
+
if (entry.milestoneInt === null || entry.milestoneInt === undefined)
|
|
393
|
+
continue;
|
|
394
|
+
const segments = entry.sourceToken.split('-');
|
|
395
|
+
const token = segments.slice(1).map((segment) => pad2(parseInt(segment, 10))).join('.');
|
|
396
|
+
const leadingReserved = parseInt(segments[1], 10);
|
|
397
|
+
if (!reservedByMilestone.has(entry.milestoneInt)) {
|
|
398
|
+
reservedByMilestone.set(entry.milestoneInt, new Set());
|
|
399
|
+
}
|
|
400
|
+
reservedByMilestone.get(entry.milestoneInt).add(leadingReserved);
|
|
401
|
+
mappings.set(entry.lineIndex, {
|
|
402
|
+
lineIndex: entry.lineIndex,
|
|
403
|
+
milestoneInt: entry.milestoneInt,
|
|
404
|
+
token,
|
|
405
|
+
source: 'mnn',
|
|
406
|
+
sourceToken: entry.sourceToken,
|
|
407
|
+
phaseName: (entry.headingTail ?? '').trim(),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
// Pass 2: legacy phases, walking the next per-milestone counter value that
|
|
411
|
+
// pass 1 did not reserve.
|
|
412
|
+
const nextCandidateByMilestone = new Map();
|
|
413
|
+
for (const entry of entries) {
|
|
414
|
+
if (entry.alreadyMigrated || entry.source !== 'legacy')
|
|
415
|
+
continue;
|
|
416
|
+
if (entry.milestoneInt === null || entry.milestoneInt === undefined)
|
|
417
|
+
continue;
|
|
418
|
+
const reserved = reservedByMilestone.get(entry.milestoneInt) ?? new Set();
|
|
419
|
+
let candidate = nextCandidateByMilestone.get(entry.milestoneInt) ?? 1;
|
|
420
|
+
while (reserved.has(candidate))
|
|
421
|
+
candidate++;
|
|
422
|
+
nextCandidateByMilestone.set(entry.milestoneInt, candidate + 1);
|
|
423
|
+
mappings.set(entry.lineIndex, {
|
|
424
|
+
lineIndex: entry.lineIndex,
|
|
425
|
+
milestoneInt: entry.milestoneInt,
|
|
426
|
+
token: pad2(candidate),
|
|
427
|
+
source: 'legacy',
|
|
428
|
+
sourceToken: entry.sourceToken,
|
|
429
|
+
phaseName: (entry.headingTail ?? '').trim(),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const byMilestoneToken = new Map();
|
|
433
|
+
for (const mapping of mappings.values()) {
|
|
434
|
+
const key = `${mapping.milestoneInt}::${mapping.token}`;
|
|
435
|
+
if (!byMilestoneToken.has(key))
|
|
436
|
+
byMilestoneToken.set(key, []);
|
|
437
|
+
byMilestoneToken.get(key).push(mapping);
|
|
438
|
+
}
|
|
439
|
+
const colliding = [...byMilestoneToken.values()]
|
|
440
|
+
.filter((group) => group.length > 1)
|
|
441
|
+
.flat()
|
|
442
|
+
.sort((a, b) => a.lineIndex - b.lineIndex);
|
|
443
|
+
if (colliding.length > 0) {
|
|
444
|
+
throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: two phase headings under the same '
|
|
445
|
+
+ 'milestone would resolve to the same bracket token. Refusing rather than colliding distinct phase '
|
|
446
|
+
+ 'identities:\n'
|
|
447
|
+
+ colliding.map((mapping) => ` ${lines[mapping.lineIndex]}`).join('\n'));
|
|
448
|
+
}
|
|
449
|
+
return mappings;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* #4144 round 6 B1: a legacy phase token (raw, as spelled in a `### Phase N:`
|
|
453
|
+
* heading) that the readers' own sentinel classifier (`isSentinelPhaseId`,
|
|
454
|
+
* legacy/bare-leading-int branch — the SAME rule `listAllPhaseDirs` and every
|
|
455
|
+
* phase count already exclude `999.x`/`0.x` sentinels through) treats as a
|
|
456
|
+
* sentinel. Returns the sentinel's own bracket MILESTONE integer (0 or 999)
|
|
457
|
+
* so the caller can attribute the phase to that milestone directly, bypassing
|
|
458
|
+
* whatever `## vN.M` section happens to enclose the heading on disk — under
|
|
459
|
+
* bracket, a sentinel's identity IS the sentinel milestone (`[CODE.999]` /
|
|
460
|
+
* `[CODE.00]`), never the real milestone section it was filed under.
|
|
461
|
+
*/
|
|
462
|
+
function legacySentinelMilestone(sourceToken) {
|
|
463
|
+
if (!isSentinelPhaseId(sourceToken))
|
|
464
|
+
return null;
|
|
465
|
+
const match = stripProjectCodePrefix(sourceToken).match(/^0*(\d+)/);
|
|
466
|
+
return match ? parseInt(match[1], 10) : null;
|
|
467
|
+
}
|
|
468
|
+
function asciiInteger(segment) {
|
|
469
|
+
if (segment.length === 0)
|
|
470
|
+
return null;
|
|
471
|
+
for (const character of segment) {
|
|
472
|
+
if (character < '0' || character > '9')
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
return parseInt(segment, 10);
|
|
476
|
+
}
|
|
477
|
+
function legacyLookupKey(token) {
|
|
478
|
+
return String(normalizePhaseName(token)).toLowerCase();
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Return the old directory's slug when it belongs to a mapping, otherwise
|
|
482
|
+
* null. M-NN matching consumes exactly the expected number of numeric fields,
|
|
483
|
+
* so a digit-leading slug remains a slug instead of becoming another identity
|
|
484
|
+
* segment (the ambiguity the bracket convention removes).
|
|
485
|
+
*
|
|
486
|
+
* `matchedToken` is the identity segment(s) exactly AS SPELLED on disk (e.g.
|
|
487
|
+
* "02-04" or "02.1"), distinct from `mapping.sourceToken` (the ROADMAP
|
|
488
|
+
* heading's own spelling, which can differ in zero-padding). #4698 Blocker 3
|
|
489
|
+
* needs this exact on-disk spelling to locate and rename the phase's
|
|
490
|
+
* artifact files, whose filenames are written to match the DIRECTORY, not
|
|
491
|
+
* the heading.
|
|
492
|
+
*/
|
|
493
|
+
function matchBracketSourceDir(dirName, mapping) {
|
|
494
|
+
const stripped = stripProjectCodePrefix(dirName);
|
|
495
|
+
if (mapping.source === 'mnn') {
|
|
496
|
+
const expected = mapping.sourceToken.split('-').map((segment) => parseInt(segment, 10));
|
|
497
|
+
const parts = stripped.split('-');
|
|
498
|
+
if (parts.length < expected.length)
|
|
499
|
+
return null;
|
|
500
|
+
for (let i = 0; i < expected.length; i++) {
|
|
501
|
+
const actual = asciiInteger(parts[i]);
|
|
502
|
+
if (actual === null || actual !== expected[i])
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
slug: parts.slice(expected.length).join('-'),
|
|
507
|
+
matchedToken: parts.slice(0, expected.length).join('-'),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const legacyDirMatch = stripped.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})(?:-(.*))?$`, 'i'));
|
|
511
|
+
if (!legacyDirMatch)
|
|
512
|
+
return null;
|
|
513
|
+
if (legacyLookupKey(legacyDirMatch[1]) !== legacyLookupKey(mapping.sourceToken))
|
|
514
|
+
return null;
|
|
515
|
+
return { slug: legacyDirMatch[2] ?? '', matchedToken: legacyDirMatch[1] };
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* #4698 Blocker 3: a directory rename changes the phase's on-disk token, but
|
|
519
|
+
* the phase-qualified artifact FILENAMES inside it (`03-VERIFICATION.md`,
|
|
520
|
+
* `03-01-PLAN.md`, `03-CONTEXT.md`, `03-RESEARCH.md`, ...) keep spelling the
|
|
521
|
+
* OLD token unless renamed too. `isPhaseArtifact`'s bracket branch
|
|
522
|
+
* (src/phase-id.cts) compares the new bracket directory's own qualified/bare
|
|
523
|
+
* token against each candidate filename and excludes anything that
|
|
524
|
+
* disagrees — so a stale-prefixed artifact silently drops out of
|
|
525
|
+
* bracket-convention reads (verification, plan/summary scans) the moment its
|
|
526
|
+
* directory is renamed. Fix: ask `phaseArtifactTokenSpan`, the reader-owned
|
|
527
|
+
* membership seam, for the exact token span of every ROOT-LEVEL file (never
|
|
528
|
+
* anything inside a nested `plans/` subdirectory — see the docblock note
|
|
529
|
+
* below), then replace exactly that span with the new bracket artifact token
|
|
530
|
+
* while keeping the rest of the filename unchanged.
|
|
531
|
+
*
|
|
532
|
+
* NESTED `plans/` ARTIFACTS ARE OUT OF SCOPE: the #3139 nested layout writes
|
|
533
|
+
* `plans/PLAN-<n>.md` / `plans/SUMMARY-<n>.md` with NO phase-token prefix at
|
|
534
|
+
* all (phase.cts: "pairs a nested `plans/PLAN-01.md`... layout-agnostic" —
|
|
535
|
+
* the phase identity comes from the CONTAINING directory alone). There is
|
|
536
|
+
* nothing to rename there, so this function only ever reads the phase
|
|
537
|
+
* directory's own root entries (`fs.readdirSync` is not recursive), which
|
|
538
|
+
* naturally excludes the `plans/` subdirectory itself (a directory, not a
|
|
539
|
+
* file) without any extra filtering.
|
|
540
|
+
*
|
|
541
|
+
* Collisions are refused HERE, before `computeBracketPlan` returns — the
|
|
542
|
+
* same "refuse before any write" contract every other hard-refusal in this
|
|
543
|
+
* file already follows, so a colliding fixture is caught on dry-run too, not
|
|
544
|
+
* just on apply.
|
|
545
|
+
*/
|
|
546
|
+
function computeArtifactRenames(dirName, oldDirPath, sourceToken, targetToken) {
|
|
547
|
+
if (!sourceToken || sourceToken === targetToken)
|
|
548
|
+
return [];
|
|
549
|
+
let entries;
|
|
550
|
+
try {
|
|
551
|
+
entries = node_fs_1.default.readdirSync(oldDirPath, { withFileTypes: true });
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
const fileNames = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
557
|
+
const renames = [];
|
|
558
|
+
for (const fileName of fileNames) {
|
|
559
|
+
const tokenSpan = phaseArtifactTokenSpan(fileName, dirName);
|
|
560
|
+
if (tokenSpan === null)
|
|
561
|
+
continue;
|
|
562
|
+
const newName = fileName.slice(0, tokenSpan.start) + targetToken + fileName.slice(tokenSpan.end);
|
|
563
|
+
if (newName === fileName)
|
|
564
|
+
continue;
|
|
565
|
+
renames.push({ oldName: fileName, newName });
|
|
566
|
+
}
|
|
567
|
+
if (renames.length === 0)
|
|
568
|
+
return renames;
|
|
569
|
+
// Refuse before any write: a renamed target must not collide with a file
|
|
570
|
+
// that already has that name and is not itself being renamed away.
|
|
571
|
+
const renamedAway = new Set(renames.map((r) => r.oldName));
|
|
572
|
+
const staticNames = new Set(fileNames.filter((f) => !renamedAway.has(f)));
|
|
573
|
+
const producerByTarget = new Map();
|
|
574
|
+
for (const rename of renames) {
|
|
575
|
+
if (staticNames.has(rename.newName)) {
|
|
576
|
+
throw new Error(`Cannot rename artifact ${JSON.stringify(rename.oldName)} to ${JSON.stringify(rename.newName)} `
|
|
577
|
+
+ `in phase directory ${JSON.stringify(dirName)}: a different file already has that name.`);
|
|
578
|
+
}
|
|
579
|
+
const producer = producerByTarget.get(rename.newName);
|
|
580
|
+
if (producer) {
|
|
581
|
+
throw new Error(`Cannot migrate phase artifacts in ${JSON.stringify(dirName)}: both ${JSON.stringify(producer)} `
|
|
582
|
+
+ `and ${JSON.stringify(rename.oldName)} would rename to ${JSON.stringify(rename.newName)}.`);
|
|
583
|
+
}
|
|
584
|
+
producerByTarget.set(rename.newName, rename.oldName);
|
|
585
|
+
}
|
|
586
|
+
return renames;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* #4698 Blocker 1 (round 2): a directory rename changes the phase's on-disk
|
|
590
|
+
* token, and `computeArtifactRenames` (above) renames that directory's own
|
|
591
|
+
* artifact FILENAMES to match — but a SIBLING plan file's `depends_on`
|
|
592
|
+
* frontmatter that names one of those renamed files by its OLD
|
|
593
|
+
* phase-qualified id (`depends_on: ["03-01"]`, naming `03-01-PLAN.md`) keeps
|
|
594
|
+
* spelling the OLD token. The real dependency resolver
|
|
595
|
+
* (`computeDependencyLevels`, src/phase.cts) resolves `depends_on` tokens
|
|
596
|
+
* against `RawPlan.id`s derived from THIS SAME PHASE DIRECTORY's own
|
|
597
|
+
* filenames only (`cmdPhasePlanIndex` scans one phase directory at a time) —
|
|
598
|
+
* so once the sibling's filename changes, the stale token matches no plan id
|
|
599
|
+
* in the directory at all, the edge is dropped as "unresolved" (#3427), and
|
|
600
|
+
* the dependent plan fails readiness (`missingEvidence`) even after its
|
|
601
|
+
* predecessor completes — reproducing the reported defect exactly.
|
|
602
|
+
*
|
|
603
|
+
* Fix: derive the old and new plan IDs from the exact artifact-renames plan,
|
|
604
|
+
* then compare each `depends_on` value through `normalizeDependencyToken`,
|
|
605
|
+
* the real resolver's exported case-folding seam. This means the migration
|
|
606
|
+
* rewrites exactly those tokens the resolver considers equal to a renamed
|
|
607
|
+
* plan ID. Bare in-phase short forms and tokens naming other plans remain
|
|
608
|
+
* untouched.
|
|
609
|
+
*
|
|
610
|
+
* `depends_on` is the only frontmatter field THIS function rewrites. It is
|
|
611
|
+
* the only field the real DEPENDENCY resolver reads for cross-plan identity:
|
|
612
|
+
* `parsePlanDocument` (src/plan-document.cts) reads `phase`/`plan` as scalar
|
|
613
|
+
* display fields, never assembling a `<phase>-<NN>` token from them, and a
|
|
614
|
+
* `*-SUMMARY.md`'s own `requires:` block is prose (a human-readable "what
|
|
615
|
+
* this phase needed" list) that `computeDependencyLevels` never parses —
|
|
616
|
+
* rewriting either would touch bytes THAT resolver does not read. `phase:`
|
|
617
|
+
* is a different story for a DIFFERENT reader: `history-digest`
|
|
618
|
+
* (src/commands.cts, `cmdHistoryDigest`) keys phases and decisions by that
|
|
619
|
+
* exact scalar, so a stale `phase:` left at the old token after a
|
|
620
|
+
* renumbering migration files a phase's decisions under a different phase's
|
|
621
|
+
* new identity. `computePhaseFrontmatterRewrites` below is the one that
|
|
622
|
+
* corrects it, on the reader-owned `phaseArtifactTokenSpan` membership this
|
|
623
|
+
* function's own `fileRenames` parameter is built from.
|
|
624
|
+
*
|
|
625
|
+
* NESTED `plans/` ARTIFACTS ARE OUT OF SCOPE, for the identical reason
|
|
626
|
+
* `computeArtifactRenames` excludes them (see that function's docblock): a
|
|
627
|
+
* plain, non-recursive `readdirSync` of the phase directory's root already
|
|
628
|
+
* excludes `plans/`'s contents.
|
|
629
|
+
*/
|
|
630
|
+
function computeDependsOnRewrites(oldDirPath, sourceToken, targetToken, fileRenames) {
|
|
631
|
+
if (!sourceToken || sourceToken === targetToken)
|
|
632
|
+
return [];
|
|
633
|
+
let entries;
|
|
634
|
+
try {
|
|
635
|
+
entries = node_fs_1.default.readdirSync(oldDirPath, { withFileTypes: true });
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return [];
|
|
639
|
+
}
|
|
640
|
+
const renameByOldName = new Map(fileRenames.map((r) => [r.oldName, r.newName]));
|
|
641
|
+
const renamedPlanIdByToken = new Map();
|
|
642
|
+
const registerDependencyAlias = (oldAlias, newAlias) => {
|
|
643
|
+
const comparisonToken = normalizeDependencyToken(oldAlias);
|
|
644
|
+
const existing = renamedPlanIdByToken.get(comparisonToken);
|
|
645
|
+
if (existing !== undefined && existing !== newAlias) {
|
|
646
|
+
throw new Error(`Cannot migrate depends_on references in ${JSON.stringify(node_path_1.default.basename(oldDirPath))}: `
|
|
647
|
+
+ `renamed plan IDs collide when compared by the dependency resolver.`);
|
|
648
|
+
}
|
|
649
|
+
renamedPlanIdByToken.set(comparisonToken, newAlias);
|
|
650
|
+
};
|
|
651
|
+
for (const rename of fileRenames) {
|
|
652
|
+
// #4144 round 5 follow-up: the plan-scan owner's own root-plan-file test
|
|
653
|
+
// (src/plan-scan.cts), not a private re-derivation of the `-PLAN.md`
|
|
654
|
+
// filename filter — the owner also accepts bare `PLAN.md` and the
|
|
655
|
+
// legacy delimited slug form (`3-PLAN-01-setup.md`), which the old
|
|
656
|
+
// inline `/-PLAN\.md$/i` regex rejected outright, silently skipping any
|
|
657
|
+
// depends_on alias registration for such a plan.
|
|
658
|
+
if (!isRootPlanFile(rename.oldName) || !isRootPlanFile(rename.newName))
|
|
659
|
+
continue;
|
|
660
|
+
const oldPlanId = rename.oldName.replace(/-PLAN\.md$/i, '');
|
|
661
|
+
const newPlanId = rename.newName.replace(/-PLAN\.md$/i, '');
|
|
662
|
+
registerDependencyAlias(oldPlanId, newPlanId);
|
|
663
|
+
// #4144 round 5 Blocker 3: the real resolver (computeDependencyLevels /
|
|
664
|
+
// resolveDependencyId, src/phase.cts) also resolves a depends_on token
|
|
665
|
+
// against extractCanonicalPlanId's shorter alias (core-utils.cts) — a
|
|
666
|
+
// plan slugged as `03-01-setup-PLAN.md` is reachable as `03-01` as well
|
|
667
|
+
// as its full id. Index that alias too, mapped to the SAME alias of the
|
|
668
|
+
// renamed file — reusing extractCanonicalPlanId rather than re-deriving
|
|
669
|
+
// it — or a depends_on naming a renamed plan by its canonical alias
|
|
670
|
+
// (instead of its full slugged id) silently stops resolving after the
|
|
671
|
+
// rename.
|
|
672
|
+
registerDependencyAlias(extractCanonicalPlanId(rename.oldName), extractCanonicalPlanId(rename.newName));
|
|
673
|
+
}
|
|
674
|
+
const rewrites = [];
|
|
675
|
+
for (const entry of entries) {
|
|
676
|
+
if (!entry.isFile() || !isRootPlanFile(entry.name))
|
|
677
|
+
continue;
|
|
678
|
+
const filePath = node_path_1.default.join(oldDirPath, entry.name);
|
|
679
|
+
let content;
|
|
680
|
+
try {
|
|
681
|
+
content = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
let fm;
|
|
687
|
+
try {
|
|
688
|
+
fm = extractFrontmatter(content, filePath);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
// Mirrors parsePlanDocument's own depends_on normalization exactly
|
|
694
|
+
// (src/plan-document.cts) — an array of strings, a single non-empty
|
|
695
|
+
// string coerced to a one-element array, or nothing.
|
|
696
|
+
const rawDeps = fm['depends_on'];
|
|
697
|
+
const deps = Array.isArray(rawDeps)
|
|
698
|
+
? rawDeps.map(String)
|
|
699
|
+
: typeof rawDeps === 'string' && rawDeps.trim() !== ''
|
|
700
|
+
? [rawDeps]
|
|
701
|
+
: null;
|
|
702
|
+
if (!deps || deps.length === 0)
|
|
703
|
+
continue;
|
|
704
|
+
let changed = false;
|
|
705
|
+
const newDeps = deps.map((dep) => {
|
|
706
|
+
const replacement = renamedPlanIdByToken.get(normalizeDependencyToken(dep));
|
|
707
|
+
if (replacement === undefined)
|
|
708
|
+
return dep;
|
|
709
|
+
changed = true;
|
|
710
|
+
return replacement;
|
|
711
|
+
});
|
|
712
|
+
if (!changed)
|
|
713
|
+
continue;
|
|
714
|
+
let newContent;
|
|
715
|
+
try {
|
|
716
|
+
newContent = spliceFrontmatter(content, { ...fm, depends_on: newDeps });
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
// A depends_on value the shared writer cannot faithfully re-serialize
|
|
720
|
+
// must never be silently dropped — refuse before any write, the same
|
|
721
|
+
// contract every other unrepresentable case in this file already
|
|
722
|
+
// follows (e.g. computeArtifactRenames' collision refusal above).
|
|
723
|
+
throw frontmatterRewriteError('depends_on', filePath, sourceToken, targetToken, err);
|
|
724
|
+
}
|
|
725
|
+
rewrites.push({
|
|
726
|
+
oldName: entry.name,
|
|
727
|
+
finalName: renameByOldName.get(entry.name) ?? entry.name,
|
|
728
|
+
from: content,
|
|
729
|
+
to: newContent,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return rewrites;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* #4144 round 6 (W-phase): a renamed artifact's `phase:` frontmatter scalar
|
|
736
|
+
* keeps spelling the OLD legacy/M-NN token, and `history-digest`
|
|
737
|
+
* (src/commands.cts:547, `const phaseNum = fm['phase'] || dir.split('-')[0]`)
|
|
738
|
+
* keys phases and decisions by that exact field — the field IS used as
|
|
739
|
+
* identity by that reader, contrary to `computeDependsOnRewrites`' own
|
|
740
|
+
* docblock claim above (corrected there). After a renumbering migration, a
|
|
741
|
+
* phase's decisions were filed under whatever OTHER phase's new token
|
|
742
|
+
* happened to collide with its own stale value.
|
|
743
|
+
*
|
|
744
|
+
* Scoped to phase-QUALIFIED artifacts (`phaseArtifactTokenSpan`, the same
|
|
745
|
+
* reader-owned membership predicate `computeArtifactRenames` uses), not
|
|
746
|
+
* every file in the directory — a `phase:` value matching `sourceToken` in
|
|
747
|
+
* ANY spelling the readers accept (`03`, `3`, `02.1` — compared through
|
|
748
|
+
* `legacyLookupKey`, the same equivalence `matchBracketSourceDir` already
|
|
749
|
+
* uses) is rewritten to the phase's own new bracket token, via the same
|
|
750
|
+
* `extractFrontmatter`/`spliceFrontmatter` pair every other frontmatter
|
|
751
|
+
* rewrite in this file uses.
|
|
752
|
+
*
|
|
753
|
+
* `dependsOnRewrites` (already computed for this same directory) is reused
|
|
754
|
+
* rather than re-read: a file needing BOTH a depends_on rewrite and a phase
|
|
755
|
+
* rewrite gets its existing entry's `.to` spliced again in place — one
|
|
756
|
+
* combined write — instead of two independent passes racing to overwrite
|
|
757
|
+
* each other's change at apply time. A file needing only the phase rewrite
|
|
758
|
+
* gets a new entry appended. Returns the merged array in the exact shape
|
|
759
|
+
* `PhaseRename.dependsOnRewrites` already carries, so `applyMigration`'s
|
|
760
|
+
* existing apply/rollback loop needs no changes to consume it.
|
|
761
|
+
*/
|
|
762
|
+
function computePhaseFrontmatterRewrites(oldDirPath, dirName, sourceToken, targetToken, fileRenames, dependsOnRewrites) {
|
|
763
|
+
if (!sourceToken || sourceToken === targetToken)
|
|
764
|
+
return dependsOnRewrites;
|
|
765
|
+
let entries;
|
|
766
|
+
try {
|
|
767
|
+
entries = node_fs_1.default.readdirSync(oldDirPath, { withFileTypes: true });
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
return dependsOnRewrites;
|
|
771
|
+
}
|
|
772
|
+
const renameByOldName = new Map(fileRenames.map((r) => [r.oldName, r.newName]));
|
|
773
|
+
const byOldName = new Map(dependsOnRewrites.map((r) => [r.oldName, r]));
|
|
774
|
+
const sourceKey = legacyLookupKey(sourceToken);
|
|
775
|
+
const result = [...dependsOnRewrites];
|
|
776
|
+
for (const entry of entries) {
|
|
777
|
+
if (!entry.isFile())
|
|
778
|
+
continue;
|
|
779
|
+
if (phaseArtifactTokenSpan(entry.name, dirName) === null)
|
|
780
|
+
continue;
|
|
781
|
+
const existing = byOldName.get(entry.name);
|
|
782
|
+
const filePath = node_path_1.default.join(oldDirPath, entry.name);
|
|
783
|
+
let originalContent;
|
|
784
|
+
let baseContent;
|
|
785
|
+
if (existing) {
|
|
786
|
+
originalContent = existing.from;
|
|
787
|
+
baseContent = existing.to;
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
try {
|
|
791
|
+
originalContent = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
baseContent = originalContent;
|
|
797
|
+
}
|
|
798
|
+
let fm;
|
|
799
|
+
try {
|
|
800
|
+
fm = extractFrontmatter(originalContent, filePath);
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const rawPhase = fm['phase'];
|
|
806
|
+
if (typeof rawPhase !== 'string' && typeof rawPhase !== 'number')
|
|
807
|
+
continue;
|
|
808
|
+
if (legacyLookupKey(String(rawPhase)) !== sourceKey)
|
|
809
|
+
continue;
|
|
810
|
+
let baseFm;
|
|
811
|
+
try {
|
|
812
|
+
baseFm = extractFrontmatter(baseContent, filePath);
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
let newContent;
|
|
818
|
+
try {
|
|
819
|
+
newContent = spliceFrontmatter(baseContent, { ...baseFm, phase: targetToken });
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
// Same refuse-before-any-write contract every other unrepresentable
|
|
823
|
+
// frontmatter rewrite in this file already follows.
|
|
824
|
+
throw frontmatterRewriteError('phase frontmatter', filePath, sourceToken, targetToken, err);
|
|
825
|
+
}
|
|
826
|
+
const finalName = renameByOldName.get(entry.name) ?? entry.name;
|
|
827
|
+
if (existing) {
|
|
828
|
+
existing.to = newContent;
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
const rewrite = { oldName: entry.name, finalName, from: originalContent, to: newContent };
|
|
832
|
+
result.push(rewrite);
|
|
833
|
+
byOldName.set(entry.name, rewrite);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return result;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* #4698 Blocker 2: how many integer identity segments THIS mapping requires
|
|
840
|
+
* a directory to match. An M-NN mapping with more segments (`2-04-01`, a
|
|
841
|
+
* child) is strictly more specific than one with fewer (`2-04`, its parent)
|
|
842
|
+
* — the parent's own match is a textual PREFIX of the child's, so a
|
|
843
|
+
* directory satisfying both must resolve to the more specific (longer)
|
|
844
|
+
* identity, never whichever candidate the resolution loop reaches first.
|
|
845
|
+
* A legacy token is always matched exactly (see `matchBracketSourceDir`'s
|
|
846
|
+
* legacy branch: equality against ONE specific normalized token, never a
|
|
847
|
+
* prefix of another legacy token), so it has no competing specificity of its
|
|
848
|
+
* own to rank — treated as a single segment.
|
|
849
|
+
*/
|
|
850
|
+
function bracketMappingSpecificity(mapping) {
|
|
851
|
+
return mapping.source === 'mnn' ? mapping.sourceToken.split('-').length : 1;
|
|
852
|
+
}
|
|
853
|
+
function buildBracketDirName(projectCode, mapping, slug, sourceDir) {
|
|
854
|
+
const milestone = pad2(mapping.milestoneInt);
|
|
855
|
+
const [phase, subphase] = mapping.token.split('.');
|
|
856
|
+
if (!slug)
|
|
857
|
+
return `${projectCode}.${milestone}-${mapping.token}`;
|
|
858
|
+
try {
|
|
859
|
+
return toDir({
|
|
860
|
+
project: projectCode,
|
|
861
|
+
milestone,
|
|
862
|
+
phase,
|
|
863
|
+
...(subphase ? { subphase } : {}),
|
|
864
|
+
}, slug);
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
throw new Error(`Cannot build bracket directory for phase ${JSON.stringify(mapping.sourceToken)} `
|
|
868
|
+
+ `from source directory ${JSON.stringify(sourceDir)}: ${err.message}`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function computeBracketPlan(cwd) {
|
|
872
|
+
const pDir = planningDir(cwd);
|
|
873
|
+
const roadmapPath = node_path_1.default.join(pDir, 'ROADMAP.md');
|
|
874
|
+
const configPath = node_path_1.default.join(pDir, 'config.json');
|
|
875
|
+
const phasesDir = node_path_1.default.join(pDir, 'phases');
|
|
876
|
+
const done = {
|
|
877
|
+
alreadyMigrated: true,
|
|
878
|
+
phases: [],
|
|
879
|
+
roadmapEdits: [],
|
|
880
|
+
crossRefEdits: [],
|
|
881
|
+
targetConvention: 'bracket',
|
|
882
|
+
};
|
|
883
|
+
let configData = {};
|
|
884
|
+
try {
|
|
885
|
+
configData = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
|
|
886
|
+
}
|
|
887
|
+
catch { /* config may not exist */ }
|
|
888
|
+
const projectCode = typeof configData['project_code'] === 'string' && configData['project_code'].length > 0
|
|
889
|
+
? configData['project_code']
|
|
890
|
+
: null;
|
|
891
|
+
let roadmapContent;
|
|
892
|
+
try {
|
|
893
|
+
roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf8');
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
throw new Error(`ROADMAP.md not found at ${roadmapPath}`);
|
|
897
|
+
}
|
|
898
|
+
const lines = roadmapContent.split('\n');
|
|
899
|
+
const { entries: parsed, unparsed } = parseBracketSourcePhases(lines);
|
|
900
|
+
// #4698 Blocker 3 (round 2), part (b): a heading that starts like a phase
|
|
901
|
+
// heading (or a bracket-shaped heading) but matched none of the three
|
|
902
|
+
// recognized grammars is never silently skipped — refuse before any write
|
|
903
|
+
// and list every offender verbatim, exactly like refuseMixedRoadmap below
|
|
904
|
+
// does for a textual mix. Checked before the "zero recognized headings"
|
|
905
|
+
// guard: a roadmap with some genuinely recognized headings AND one
|
|
906
|
+
// unparseable phase-like heading is the reported defect's exact shape, and
|
|
907
|
+
// this message is the more actionable of the two for that case.
|
|
908
|
+
if (unparsed.length > 0) {
|
|
909
|
+
throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: found heading(s) that start like a '
|
|
910
|
+
+ 'phase heading but do not match any recognized grammar (legacy "### Phase N: Name", M-NN '
|
|
911
|
+
+ '"### Phase M-NN: Name", or bracket "### [CODE.MM] NN: Name" — each optionally followed by a '
|
|
912
|
+
+ '"(Tag)" before the colon). Refusing rather than silently skipping them. Unrecognized heading(s):\n'
|
|
913
|
+
+ unparsed.map((l) => ` ${l}`).join('\n'));
|
|
914
|
+
}
|
|
915
|
+
// #4698 Blocker 2: an unrecognized or partially-migrated roadmap must
|
|
916
|
+
// refuse outright, never silently return a "successful" empty/partial plan.
|
|
917
|
+
// Bracket is the terminal convention — once config says "bracket", the
|
|
918
|
+
// guard below (`unconverted.length === 0`) short-circuits every later run,
|
|
919
|
+
// so a roadmap this planner failed to fully convert can never be revisited
|
|
920
|
+
// by a corrected re-run unless it is refused now, before any write.
|
|
921
|
+
const alreadyBracket = parsed.filter((entry) => entry.alreadyMigrated);
|
|
922
|
+
const unconverted = parsed.filter((entry) => !entry.alreadyMigrated);
|
|
923
|
+
if (parsed.length === 0) {
|
|
924
|
+
throw new Error('No recognized phase headings found in ROADMAP.md. The bracket migrator recognizes exactly '
|
|
925
|
+
+ 'three phase heading shapes: bracket ("### [CODE.MM] NN: Name"), M-NN ("### Phase M-NN: Name"), '
|
|
926
|
+
+ 'or legacy ("### Phase N: Name"). Add at least one recognized phase heading, then re-run.');
|
|
927
|
+
}
|
|
928
|
+
const refuseMixedRoadmap = () => {
|
|
929
|
+
throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: it still has unconverted phase '
|
|
930
|
+
+ 'headings. Refusing to guess at a partial migration. Unconverted headings:\n'
|
|
931
|
+
+ unconverted.map((entry) => ` ${lines[entry.lineIndex]}`).join('\n'));
|
|
932
|
+
};
|
|
933
|
+
// A textual mix (some headings already bracket, others not) is never safe
|
|
934
|
+
// to resume automatically.
|
|
935
|
+
if (alreadyBracket.length > 0 && unconverted.length > 0)
|
|
936
|
+
refuseMixedRoadmap();
|
|
937
|
+
// Every recognized heading is already bracket text: nothing left to
|
|
938
|
+
// convert, regardless of what config.json's own phase_id_convention says.
|
|
939
|
+
if (unconverted.length === 0)
|
|
940
|
+
return done;
|
|
941
|
+
// From here, unconverted.length > 0. If config already claims "bracket",
|
|
942
|
+
// it disagrees with the roadmap's own text — the same refusal as the
|
|
943
|
+
// textual mix above, never `done`: a stale/incorrect config value must
|
|
944
|
+
// never hide un-migrated content or make it permanently unreachable.
|
|
945
|
+
if (configData['phase_id_convention'] === 'bracket')
|
|
946
|
+
refuseMixedRoadmap();
|
|
947
|
+
if (!projectCode) {
|
|
948
|
+
throw new Error('Cannot migrate to the bracket convention without a project_code in .planning/config.json '
|
|
949
|
+
+ '(bracket phase IDs are [CODE.MM] NN). Set "project_code" first, then re-run.');
|
|
950
|
+
}
|
|
951
|
+
if (!PROJECT_CODE_RE.test(projectCode)) {
|
|
952
|
+
throw new Error(`Cannot migrate to the bracket convention with invalid project_code ${JSON.stringify(projectCode)}.`);
|
|
953
|
+
}
|
|
954
|
+
const code = projectCode;
|
|
955
|
+
const sourcePhases = unconverted;
|
|
956
|
+
const unrepresentableLegacy = sourcePhases.filter((entry) => entry.source === 'legacy' && !isBracketPhaseTokenRepresentable(entry.sourceToken));
|
|
957
|
+
if (unrepresentableLegacy.length > 0) {
|
|
958
|
+
throw new Error('Cannot migrate source phase token(s) whose identity axis the bracket grammar cannot represent. '
|
|
959
|
+
+ 'Refusing rather than collapsing distinct phase identities:\n'
|
|
960
|
+
+ unrepresentableLegacy.map((entry) => ` Phase ${entry.sourceToken}`).join('\n'));
|
|
961
|
+
}
|
|
962
|
+
const lineOffsets = [];
|
|
963
|
+
let nextLineOffset = 0;
|
|
964
|
+
for (const line of lines) {
|
|
965
|
+
lineOffsets.push(nextLineOffset);
|
|
966
|
+
nextLineOffset += line.length + 1;
|
|
967
|
+
}
|
|
968
|
+
const sections = milestoneSections(roadmapContent);
|
|
969
|
+
const sectionsForOffset = (offset) => sections
|
|
970
|
+
.filter((section) => section.start <= offset && offset < section.end)
|
|
971
|
+
.sort((a, b) => (a.end - a.start) - (b.end - b.start));
|
|
972
|
+
const phaseBearingSections = new Set(sourcePhases.flatMap((entry) => sectionsForOffset(lineOffsets[entry.lineIndex] ?? -1)));
|
|
973
|
+
const unattributedLegacy = [];
|
|
974
|
+
for (const entry of sourcePhases) {
|
|
975
|
+
if (entry.source !== 'legacy')
|
|
976
|
+
continue;
|
|
977
|
+
// #4144 round 6 B1: a legacy sentinel (999.x icebox / 0.x backlog) is
|
|
978
|
+
// NEVER attributed to the enclosing `## vN.M` section — it belongs to
|
|
979
|
+
// its own sentinel milestone regardless of which real milestone section
|
|
980
|
+
// it happens to be filed under on disk.
|
|
981
|
+
const sentinelMilestone = legacySentinelMilestone(entry.sourceToken);
|
|
982
|
+
if (sentinelMilestone !== null) {
|
|
983
|
+
entry.milestoneInt = sentinelMilestone;
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
const containing = sectionsForOffset(lineOffsets[entry.lineIndex] ?? -1);
|
|
987
|
+
const attributed = containing.find((section) => section.milestoneInt !== null);
|
|
988
|
+
if (attributed?.milestoneInt !== null && attributed?.milestoneInt !== undefined) {
|
|
989
|
+
entry.milestoneInt = attributed.milestoneInt;
|
|
990
|
+
entry.attributedSection = attributed;
|
|
991
|
+
}
|
|
992
|
+
else if (phaseBearingSections.size > 1) {
|
|
993
|
+
unattributedLegacy.push(entry);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (unattributedLegacy.length > 0) {
|
|
997
|
+
throw new Error('Cannot attribute legacy phase heading(s) to a readable milestone section in a multi-milestone '
|
|
998
|
+
+ 'ROADMAP. Refusing rather than applying the current STATE milestone to ambiguous history:\n'
|
|
999
|
+
+ unattributedLegacy.map((entry) => ` Phase ${entry.sourceToken}`).join('\n'));
|
|
1000
|
+
}
|
|
1001
|
+
// Real single-milestone repositories may omit a `## vN.M` heading while
|
|
1002
|
+
// carrying project-prefixed phase directories. Resolve those legacy entries
|
|
1003
|
+
// from STATE.md instead of silently producing an empty migration plan.
|
|
1004
|
+
if (phaseBearingSections.size <= 1
|
|
1005
|
+
&& sourcePhases.some((entry) => entry.source === 'legacy' && entry.milestoneInt == null)) {
|
|
1006
|
+
let fallbackMilestone = null;
|
|
1007
|
+
try {
|
|
1008
|
+
const state = node_fs_1.default.readFileSync(node_path_1.default.join(pDir, 'STATE.md'), 'utf8');
|
|
1009
|
+
const stateMilestone = state.match(/^milestone:\s*v?(\d+)/im);
|
|
1010
|
+
if (stateMilestone)
|
|
1011
|
+
fallbackMilestone = parseInt(stateMilestone[1], 10);
|
|
1012
|
+
}
|
|
1013
|
+
catch { /* STATE.md may not exist */ }
|
|
1014
|
+
if (fallbackMilestone !== null) {
|
|
1015
|
+
for (const entry of sourcePhases) {
|
|
1016
|
+
if (entry.source === 'legacy' && entry.milestoneInt == null) {
|
|
1017
|
+
entry.milestoneInt = fallbackMilestone;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
const unresolvedLegacy = sourcePhases.filter((entry) => entry.source === 'legacy' && entry.milestoneInt == null);
|
|
1023
|
+
if (unresolvedLegacy.length > 0) {
|
|
1024
|
+
throw new Error('Cannot determine a milestone for legacy phase headings. Add a ## vN.M roadmap heading '
|
|
1025
|
+
+ 'or a milestone field in .planning/STATE.md, then re-run.');
|
|
1026
|
+
}
|
|
1027
|
+
// #4144 round 6 B3: the same legacy phase number appearing TWICE within
|
|
1028
|
+
// one milestone SECTION (the same granularity B2's checklist attribution
|
|
1029
|
+
// uses) is a duplicate identity, not two phases — `assignBracketTokens`'s
|
|
1030
|
+
// own per-milestone counter would otherwise silently invent a phantom
|
|
1031
|
+
// phase for the second heading (a distinct bracket token backed by no
|
|
1032
|
+
// second directory). Refuse before any write rather than guess.
|
|
1033
|
+
const legacyBySectionToken = new Map();
|
|
1034
|
+
for (const entry of sourcePhases) {
|
|
1035
|
+
if (entry.source !== 'legacy')
|
|
1036
|
+
continue;
|
|
1037
|
+
if (legacySentinelMilestone(entry.sourceToken) !== null)
|
|
1038
|
+
continue;
|
|
1039
|
+
const sectionKey = entry.attributedSection ? entry.attributedSection.start : GLOBAL_SECTION_KEY;
|
|
1040
|
+
const dedupeKey = `${sectionKey}::${legacyLookupKey(entry.sourceToken)}`;
|
|
1041
|
+
if (!legacyBySectionToken.has(dedupeKey))
|
|
1042
|
+
legacyBySectionToken.set(dedupeKey, []);
|
|
1043
|
+
legacyBySectionToken.get(dedupeKey).push(entry);
|
|
1044
|
+
}
|
|
1045
|
+
const duplicateLegacy = [...legacyBySectionToken.values()].filter((group) => group.length > 1).flat();
|
|
1046
|
+
if (duplicateLegacy.length > 0) {
|
|
1047
|
+
throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: the same legacy phase number appears '
|
|
1048
|
+
+ 'more than once in one milestone section. Refusing rather than inventing a phantom phase:\n'
|
|
1049
|
+
+ duplicateLegacy.map((entry) => ` ${lines[entry.lineIndex]}`).join('\n'));
|
|
1050
|
+
}
|
|
1051
|
+
const idMapping = assignBracketTokens(sourcePhases, lines);
|
|
1052
|
+
// #4144 round 6 B2: keyed by SECTION identity (a section's own `start`
|
|
1053
|
+
// offset via `milestoneSections` — the SAME attribution headings use, set
|
|
1054
|
+
// on `entry.attributedSection` above), never by `milestoneInt` alone. Two
|
|
1055
|
+
// sections can share the same leading major integer (`## v2.0`, `## v2.1`)
|
|
1056
|
+
// while each restarts its own legacy phase numbering; a milestoneInt-only
|
|
1057
|
+
// key let the later section's token silently overwrite the earlier
|
|
1058
|
+
// section's in this same map. `GLOBAL_SECTION_KEY` covers legacy entries
|
|
1059
|
+
// with no attributed section at all (single-section / STATE.md-fallback
|
|
1060
|
+
// repositories), where there is only one bucket to begin with.
|
|
1061
|
+
const sectionLegacyMap = new Map();
|
|
1062
|
+
for (const entry of sourcePhases) {
|
|
1063
|
+
if (entry.source !== 'legacy')
|
|
1064
|
+
continue;
|
|
1065
|
+
const mapping = idMapping.get(entry.lineIndex);
|
|
1066
|
+
if (!mapping)
|
|
1067
|
+
continue;
|
|
1068
|
+
const sectionKey = entry.attributedSection ? entry.attributedSection.start : GLOBAL_SECTION_KEY;
|
|
1069
|
+
if (!sectionLegacyMap.has(sectionKey))
|
|
1070
|
+
sectionLegacyMap.set(sectionKey, new Map());
|
|
1071
|
+
sectionLegacyMap.get(sectionKey).set(legacyLookupKey(mapping.sourceToken), { token: mapping.token, milestoneInt: mapping.milestoneInt });
|
|
1072
|
+
}
|
|
1073
|
+
const phaseDirListing = listAllPhaseDirs(phasesDir, { includeSentinels: true });
|
|
1074
|
+
if (phaseDirListing.scope === SCOPE.UNREADABLE) {
|
|
1075
|
+
throw new Error(`Cannot read phase directories at ${phasesDir}`);
|
|
1076
|
+
}
|
|
1077
|
+
const existingDirs = phaseDirListing.value;
|
|
1078
|
+
// #4144 round 7 W1: derive the comparison slug the EXACT way the harness
|
|
1079
|
+
// derives a DIRECTORY's own slug — never a bespoke unlimited-length
|
|
1080
|
+
// slugify. `phase insert` writes "(INSERTED)" into the HEADING text
|
|
1081
|
+
// (phase.cts) but never into the directory it creates (its slug comes
|
|
1082
|
+
// from the bare `description`, which never carries the marker) — the
|
|
1083
|
+
// same marker `roadmap.cts:494` strips before slugifying a heading name
|
|
1084
|
+
// for its own comparisons. And `init`/`phase add` truncate at
|
|
1085
|
+
// `generateSlugInternal`'s own default maxLen (60 — init.cts:1190/:2284),
|
|
1086
|
+
// never `null` (unlimited). Comparing under a DIFFERENT rule than the one
|
|
1087
|
+
// that created the directory refused two-milestone roadmaps the
|
|
1088
|
+
// tooling's own commands produced.
|
|
1089
|
+
const slugify = (text) => (coreUtilsMod.generateSlugInternal(text.replace(/\(INSERTED\)/i, '').trim()) ?? '');
|
|
1090
|
+
// #4144 round 7 W2, corrected round 8 B1: a plan-level ambiguity check
|
|
1091
|
+
// that runs BEFORE the resolution loop below and never touches its
|
|
1092
|
+
// (unmodified, round-6) algorithm. For every directory that structurally
|
|
1093
|
+
// ties with more than one candidate at its own best specificity, group
|
|
1094
|
+
// directories by the EXACT set of candidates they tie with — two
|
|
1095
|
+
// directories tying with the identical candidate set are contesting the
|
|
1096
|
+
// identical pool — and refuse ONLY when that group's directory count
|
|
1097
|
+
// EXCEEDS its candidate count. A stale duplicate-number directory sorting
|
|
1098
|
+
// between two real ones (e.g. `01-gamma`, `01-gamma-old`, `01-zeta`, all
|
|
1099
|
+
// three tying with {Zeta, Gamma}) is exactly this: 3 directories for 2
|
|
1100
|
+
// candidates. Left unchecked, the resolution loop's own "only one
|
|
1101
|
+
// candidate left" shortcut (used when a directory's tie has already
|
|
1102
|
+
// shrunk to a single remaining candidate) accepts whichever directory is
|
|
1103
|
+
// visited LAST in that group WITHOUT ever verifying its slug — silently
|
|
1104
|
+
// dropping the true match for the other leftover directory from the plan.
|
|
1105
|
+
//
|
|
1106
|
+
// round 8 B1: the round-7 condition (`dirs.length !== mappingLines.length`)
|
|
1107
|
+
// over-refused — it also caught the far MORE common shape where a tie
|
|
1108
|
+
// group has FEWER directories than candidates: a later milestone that
|
|
1109
|
+
// reuses a legacy number but is only planned (no directory yet), an
|
|
1110
|
+
// archived `<details>` milestone whose directory was already cleaned up,
|
|
1111
|
+
// or a decimal insert whose sibling milestone has no directory at all.
|
|
1112
|
+
// That shape is never the silent-drop hazard above: the depletion loop
|
|
1113
|
+
// below only ever ran out of unused candidates for a directory when a tie
|
|
1114
|
+
// group had MORE directories than candidates (and since #4698 a directory
|
|
1115
|
+
// whose every matching heading is already claimed refuses in that loop
|
|
1116
|
+
// instead of falling through). With fewer (or equal) directories than candidates,
|
|
1117
|
+
// every directory the loop below visits is either resolved by its own
|
|
1118
|
+
// slug comparison or refused loudly on its own — a BALANCED group (equal
|
|
1119
|
+
// counts — e.g. exactly `01-gamma`/`01-zeta` tying with {Zeta, Gamma}) is
|
|
1120
|
+
// one instance of this and is left entirely to the resolution loop below,
|
|
1121
|
+
// unchanged: that loop's own slug-driven, elimination-assisted pairing
|
|
1122
|
+
// already resolves a balanced group correctly (round 6 B3), including a
|
|
1123
|
+
// directory whose own slug is a paraphrase of its phase's name rather than
|
|
1124
|
+
// an exact match. So the safe pre-check condition is `dirs > candidates`,
|
|
1125
|
+
// never `dirs !== candidates` — this check only catches a genuine
|
|
1126
|
+
// directory-count SURPLUS, never a deficit and never re-litigates a
|
|
1127
|
+
// balanced pairing.
|
|
1128
|
+
// #4144 round 8 W2: for every directory that was ever part of a
|
|
1129
|
+
// multi-directory tie group (2+ directories structurally contesting the
|
|
1130
|
+
// identical candidate set — a duplicate/stale legacy-number directory
|
|
1131
|
+
// sharing the tree with the real one), record the group's directory
|
|
1132
|
+
// count here. The resolution loop below consults this to decide whether
|
|
1133
|
+
// its own "single remaining candidate" shortcut may skip the slug check:
|
|
1134
|
+
// it may only do so for a directory that was NEVER part of such a group
|
|
1135
|
+
// (group size 1 — an ordinary, unambiguous structural match, including one
|
|
1136
|
+
// whose own slug paraphrases its phase's name rather than matching it
|
|
1137
|
+
// exactly, round 6 B3's r4e case). A directory that WAS part of a
|
|
1138
|
+
// multi-directory group must still pass the slug check even once
|
|
1139
|
+
// elimination has narrowed it down to a single remaining candidate,
|
|
1140
|
+
// because elimination alone cannot tell a stale duplicate from the real
|
|
1141
|
+
// directory (see W2 below).
|
|
1142
|
+
const dirTieGroupSize = new Map();
|
|
1143
|
+
{
|
|
1144
|
+
const allMappings = [...idMapping.values()];
|
|
1145
|
+
const ambiguityGroups = new Map();
|
|
1146
|
+
for (const dirName of existingDirs) {
|
|
1147
|
+
let bestSpecificity = -1;
|
|
1148
|
+
let tied = [];
|
|
1149
|
+
for (const mapping of allMappings) {
|
|
1150
|
+
if (!matchBracketSourceDir(dirName, mapping))
|
|
1151
|
+
continue;
|
|
1152
|
+
const specificity = bracketMappingSpecificity(mapping);
|
|
1153
|
+
if (specificity > bestSpecificity) {
|
|
1154
|
+
bestSpecificity = specificity;
|
|
1155
|
+
tied = [mapping];
|
|
1156
|
+
}
|
|
1157
|
+
else if (specificity === bestSpecificity) {
|
|
1158
|
+
tied.push(mapping);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (tied.length <= 1)
|
|
1162
|
+
continue;
|
|
1163
|
+
const groupKey = tied.map((mapping) => mapping.lineIndex).sort((a, b) => a - b).join(',');
|
|
1164
|
+
if (!ambiguityGroups.has(groupKey)) {
|
|
1165
|
+
ambiguityGroups.set(groupKey, { dirs: [], mappingLines: tied.map((mapping) => mapping.lineIndex) });
|
|
1166
|
+
}
|
|
1167
|
+
ambiguityGroups.get(groupKey).dirs.push(dirName);
|
|
1168
|
+
}
|
|
1169
|
+
for (const group of ambiguityGroups.values()) {
|
|
1170
|
+
// round 8 B1: only a directory SURPLUS is the silent-drop hazard this
|
|
1171
|
+
// check exists for; fewer (or exactly as many) directories than
|
|
1172
|
+
// candidates is left to the slug-driven resolution loop below.
|
|
1173
|
+
if (group.dirs.length > group.mappingLines.length) {
|
|
1174
|
+
throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: '
|
|
1175
|
+
+ `${group.dirs.length} phase director${group.dirs.length === 1 ? 'y' : 'ies'} `
|
|
1176
|
+
+ `(${group.dirs.map((dir) => JSON.stringify(dir)).join(', ')}) tie with the same `
|
|
1177
|
+
+ `${group.mappingLines.length} candidate phase heading(s) — an unresolvable count mismatch. `
|
|
1178
|
+
+ 'Refusing rather than silently dropping a real directory from the plan:\n'
|
|
1179
|
+
+ group.mappingLines.map((lineIndex) => ` ${lines[lineIndex]}`).join('\n'));
|
|
1180
|
+
}
|
|
1181
|
+
for (const dir of group.dirs)
|
|
1182
|
+
dirTieGroupSize.set(dir, group.dirs.length);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
const orderedMappings = [...idMapping.values()].map((mapping) => ({ mapping, used: false, claimedBy: null }));
|
|
1186
|
+
const phases = [];
|
|
1187
|
+
for (const dirName of existingDirs) {
|
|
1188
|
+
// #4698 Blocker 2: scan EVERY still-unused candidate and keep the MOST
|
|
1189
|
+
// SPECIFIC match (the one requiring the most integer segments), rather
|
|
1190
|
+
// than stopping at the first one found. A parent M-NN mapping is a
|
|
1191
|
+
// prefix of its own child's mapping and matches the child's directory
|
|
1192
|
+
// just as readily as the child's own mapping does — resolving on
|
|
1193
|
+
// encounter order let a parent heading that happens to precede its
|
|
1194
|
+
// child's heading claim the child's directory, leaving the true parent
|
|
1195
|
+
// unmapped. Specificity is independent of both roadmap-heading order and
|
|
1196
|
+
// directory-listing order, so this resolves correctly regardless of
|
|
1197
|
+
// which directory this loop visits first.
|
|
1198
|
+
//
|
|
1199
|
+
// #4144 round 6 B3: more than one candidate can tie at the SAME best
|
|
1200
|
+
// specificity — e.g. the identical legacy number "1" under two different
|
|
1201
|
+
// milestones (`## v1.0` Zeta, `## v2.0` Gamma) both structurally match a
|
|
1202
|
+
// directory named after either one, since `matchBracketSourceDir`'s
|
|
1203
|
+
// legacy branch does not know about milestones. Collect every tie rather
|
|
1204
|
+
// than keeping only the first-encountered one, then disambiguate by
|
|
1205
|
+
// comparing the directory's OWN slug against each candidate's phase name
|
|
1206
|
+
// slugified the same way `toDir` sanitizes one; exactly one match wins.
|
|
1207
|
+
//
|
|
1208
|
+
// #4144 round 7 W2, corrected round 8 W2: the plan-level check above
|
|
1209
|
+
// already refuses a genuine cardinality mismatch (more directories than
|
|
1210
|
+
// candidates in a shared tie group), so by the time this loop runs, any
|
|
1211
|
+
// directory whose tie shrinks to exactly one still-unused candidate
|
|
1212
|
+
// belongs to a group with dirs <= candidates. That is NOT the same as
|
|
1213
|
+
// saying the remaining candidate is automatically correct: in a
|
|
1214
|
+
// BALANCED group (dirs === candidates, e.g. exactly two real
|
|
1215
|
+
// directories for two real phases) it usually is, because every
|
|
1216
|
+
// directory in that group is claimed by its own true match in turn —
|
|
1217
|
+
// but when a stale duplicate-number directory is among them (`01-alpha`
|
|
1218
|
+
// / `01-alpha-old` both tying with {Alpha, Beta}), the real directory
|
|
1219
|
+
// claims its own phase by slug first and the stale leftover would
|
|
1220
|
+
// otherwise be handed the other phase by elimination alone, with no
|
|
1221
|
+
// slug agreement ever checked. See `dirTieGroupSize` below: a directory
|
|
1222
|
+
// that was never part of a multi-directory tie group may still skip
|
|
1223
|
+
// straight to its sole match with no check at all (there is no sibling
|
|
1224
|
+
// to confuse it with); one that WAS still gets a (prefix-tolerant, not
|
|
1225
|
+
// exact) sanity check against the sole remaining candidate.
|
|
1226
|
+
//
|
|
1227
|
+
// #4698: the scan runs over EVERY candidate, used or not, and only then
|
|
1228
|
+
// narrows to the unused ones. Scanning the unused candidates alone let
|
|
1229
|
+
// two directories that each uniquely match the same single heading
|
|
1230
|
+
// (`01-alpha` / `01-alpha-old` under one `### Phase 1: Alpha`; no tie
|
|
1231
|
+
// group, so the pre-check above never sees them) fall through: the
|
|
1232
|
+
// first visited claimed the heading, the second found nothing unused
|
|
1233
|
+
// and was silently skipped, left on disk under its legacy name after
|
|
1234
|
+
// ROADMAP.md was converted and the convention stamped. In an M-NN tree
|
|
1235
|
+
// the same scan was worse than a skip: a duplicate child directory
|
|
1236
|
+
// whose child heading was already claimed fell back to its still-unused
|
|
1237
|
+
// PARENT heading and was renamed as the parent phase. A directory that
|
|
1238
|
+
// structurally matches at least one heading now either resolves or
|
|
1239
|
+
// refuses; only a directory matching no heading at all is passed over.
|
|
1240
|
+
let bestSpecificity = -1;
|
|
1241
|
+
let matched = [];
|
|
1242
|
+
for (const candidate of orderedMappings) {
|
|
1243
|
+
const match = matchBracketSourceDir(dirName, candidate.mapping);
|
|
1244
|
+
if (!match)
|
|
1245
|
+
continue;
|
|
1246
|
+
const specificity = bracketMappingSpecificity(candidate.mapping);
|
|
1247
|
+
if (specificity > bestSpecificity) {
|
|
1248
|
+
bestSpecificity = specificity;
|
|
1249
|
+
matched = [{ candidate, match }];
|
|
1250
|
+
}
|
|
1251
|
+
else if (specificity === bestSpecificity) {
|
|
1252
|
+
matched.push({ candidate, match });
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (matched.length === 0)
|
|
1256
|
+
continue;
|
|
1257
|
+
const tied = matched.filter((entry) => !entry.candidate.used);
|
|
1258
|
+
if (tied.length === 0) {
|
|
1259
|
+
const claimants = [...new Set(matched.map((entry) => entry.candidate.claimedBy).filter((name) => name !== null))];
|
|
1260
|
+
throw new Error(`Cannot resolve phase directory ${JSON.stringify(dirName)}: every phase heading it matches was already `
|
|
1261
|
+
+ `claimed by another directory sharing its phase number (${claimants.map((name) => JSON.stringify(name)).join(', ')}). `
|
|
1262
|
+
+ 'Refusing rather than silently leaving it unrenamed on disk; remove or rename the stale duplicate '
|
|
1263
|
+
+ 'and re-run:\n'
|
|
1264
|
+
+ matched.map((entry) => ` ${lines[entry.candidate.mapping.lineIndex]}`).join('\n'));
|
|
1265
|
+
}
|
|
1266
|
+
let winner = tied[0];
|
|
1267
|
+
if (tied.length > 1) {
|
|
1268
|
+
const dirSlug = slugify(tied[0].match.slug);
|
|
1269
|
+
const bySlug = tied.filter((entry) => slugify(entry.candidate.mapping.phaseName) === dirSlug);
|
|
1270
|
+
if (bySlug.length !== 1) {
|
|
1271
|
+
throw new Error(`Cannot resolve phase directory ${JSON.stringify(dirName)}: it matches ${tied.length} candidate `
|
|
1272
|
+
+ `phase heading(s) with the same specificity and ${bySlug.length === 0 ? 'none of them' : 'more than one of them'} `
|
|
1273
|
+
+ 'share its slug. Refusing rather than guessing which phase it identifies:\n'
|
|
1274
|
+
+ tied.map((entry) => ` ${lines[entry.candidate.mapping.lineIndex]}`).join('\n'));
|
|
1275
|
+
}
|
|
1276
|
+
winner = bySlug[0];
|
|
1277
|
+
}
|
|
1278
|
+
else if ((dirTieGroupSize.get(dirName) ?? 1) > 1) {
|
|
1279
|
+
// #4144 round 8 W2: exactly one unused candidate remains for this
|
|
1280
|
+
// directory, but it was reached by ELIMINATION out of a
|
|
1281
|
+
// multi-directory tie group (another directory shares its legacy
|
|
1282
|
+
// number) — the round-6 depletion loop accepted whichever directory
|
|
1283
|
+
// is visited LAST in such a group without ever checking its slug,
|
|
1284
|
+
// which is exactly how a stale duplicate-number directory (`01-alpha`
|
|
1285
|
+
// / `01-alpha-old`, both tying with {Alpha, Beta}) ends up claiming
|
|
1286
|
+
// the OTHER real phase once the real directory has already claimed
|
|
1287
|
+
// its own. A directory's own slug is often an abbreviation of the
|
|
1288
|
+
// full phase name rather than an exact match (round 6 B3's r4e: dir
|
|
1289
|
+
// `01-zeta` for phase "Zeta - the return"), so this check is
|
|
1290
|
+
// prefix-tolerant in either direction rather than requiring exact
|
|
1291
|
+
// equality — but a slug that shares NO relation at all to the sole
|
|
1292
|
+
// remaining candidate (c2: "alpha-old" vs "beta") is refused rather
|
|
1293
|
+
// than silently handed a phase it never named.
|
|
1294
|
+
const dirSlug = slugify(tied[0].match.slug);
|
|
1295
|
+
const candidateSlug = slugify(tied[0].candidate.mapping.phaseName);
|
|
1296
|
+
const agrees = candidateSlug.startsWith(dirSlug) || dirSlug.startsWith(candidateSlug);
|
|
1297
|
+
if (!agrees) {
|
|
1298
|
+
throw new Error(`Cannot resolve phase directory ${JSON.stringify(dirName)}: another directory sharing its legacy `
|
|
1299
|
+
+ 'number already claimed a different phase, and this directory\'s own slug shares no relation to '
|
|
1300
|
+
+ 'the one remaining candidate phase heading. Refusing rather than guessing which phase it '
|
|
1301
|
+
+ 'identifies:\n'
|
|
1302
|
+
+ ` ${lines[tied[0].candidate.mapping.lineIndex]}`);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
winner.candidate.used = true;
|
|
1306
|
+
winner.candidate.claimedBy = dirName;
|
|
1307
|
+
const hit = winner.candidate;
|
|
1308
|
+
const matchedSlug = winner.match.slug;
|
|
1309
|
+
const matchedToken = winner.match.matchedToken;
|
|
1310
|
+
const newDir = buildBracketDirName(code, hit.mapping, matchedSlug, dirName);
|
|
1311
|
+
if (newDir !== dirName) {
|
|
1312
|
+
const oldDirPath = node_path_1.default.join(phasesDir, dirName);
|
|
1313
|
+
// #4698 Blocker 3: rename this directory's own phase-qualified
|
|
1314
|
+
// artifacts (computed against the OLD path — nothing has moved yet,
|
|
1315
|
+
// this is still plan computation) so they keep matching their
|
|
1316
|
+
// phase's new bracket token after the directory itself is renamed.
|
|
1317
|
+
const fileRenames = computeArtifactRenames(dirName, oldDirPath, matchedToken, hit.mapping.token);
|
|
1318
|
+
// #4698 Blocker 1 (round 2): rewrite depends_on references (in this
|
|
1319
|
+
// same directory's own *-PLAN.md files) that name a sibling by the
|
|
1320
|
+
// OLD token — see computeDependsOnRewrites' docblock. Also computed
|
|
1321
|
+
// against the OLD path; nothing has moved yet.
|
|
1322
|
+
const dependsOnRewrites = computeDependsOnRewrites(oldDirPath, matchedToken, hit.mapping.token, fileRenames);
|
|
1323
|
+
phases.push({
|
|
1324
|
+
oldId: hit.mapping.sourceToken,
|
|
1325
|
+
newId: `${code}.${pad2(hit.mapping.milestoneInt)}-${hit.mapping.token}`,
|
|
1326
|
+
oldDir: dirName,
|
|
1327
|
+
newDir,
|
|
1328
|
+
fileRenames,
|
|
1329
|
+
// #4144 round 6 (W-phase): also rewrite any renamed artifact's
|
|
1330
|
+
// `phase:` frontmatter scalar that still spells the OLD token — see
|
|
1331
|
+
// computePhaseFrontmatterRewrites' docblock. Combined with the
|
|
1332
|
+
// depends_on rewrites above (not a second independent write) so a
|
|
1333
|
+
// file needing both never has one silently clobber the other.
|
|
1334
|
+
dependsOnRewrites: computePhaseFrontmatterRewrites(oldDirPath, dirName, matchedToken, hit.mapping.token, fileRenames, dependsOnRewrites),
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
// #4144 round 6 (W-collision): the plan never checked that a TARGET
|
|
1339
|
+
// directory name was free, so a dry-run reported a clean plan `apply`
|
|
1340
|
+
// could not perform (a pre-existing NON-EMPTY target fails mid-apply with
|
|
1341
|
+
// ENOTEMPTY, rollback restores) or silently REPLACED (a pre-existing EMPTY
|
|
1342
|
+
// target — POSIX rename semantics). Every other collision class in this
|
|
1343
|
+
// file (artifact renames above, token collisions in assignBracketTokens,
|
|
1344
|
+
// depends_on aliases) is refused at PLAN time; this closes the one
|
|
1345
|
+
// remaining seam left to apply. A target occupied by another directory
|
|
1346
|
+
// THIS SAME PLAN is also renaming away is not a collision — that directory
|
|
1347
|
+
// vacates the name as part of this same migration.
|
|
1348
|
+
const renamedOldDirs = new Set(phases.map((rename) => rename.oldDir));
|
|
1349
|
+
for (const rename of phases) {
|
|
1350
|
+
if (renamedOldDirs.has(rename.newDir))
|
|
1351
|
+
continue;
|
|
1352
|
+
if (node_fs_1.default.existsSync(node_path_1.default.join(phasesDir, rename.newDir))) {
|
|
1353
|
+
throw new Error(`Cannot migrate phase directory ${JSON.stringify(rename.oldDir)} to ${JSON.stringify(rename.newDir)}: `
|
|
1354
|
+
+ 'a directory already exists at that path and is not itself part of this migration. Refusing '
|
|
1355
|
+
+ 'rather than overwriting or colliding with it at apply time.');
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const roadmapEdits = [];
|
|
1359
|
+
for (const entry of sourcePhases) {
|
|
1360
|
+
const mapping = idMapping.get(entry.lineIndex);
|
|
1361
|
+
if (!mapping)
|
|
1362
|
+
continue;
|
|
1363
|
+
const oldLine = lines[entry.lineIndex];
|
|
1364
|
+
// #4698 Blocker 3 (round 2): the tag (if any) is re-inserted right after
|
|
1365
|
+
// the phase token and before the colon — the exact slot the real bracket
|
|
1366
|
+
// heading grammar accepts one in (see LEGACY_PHASE_HEADING_BRACKET_RE's
|
|
1367
|
+
// docblock).
|
|
1368
|
+
const heading = `${entry.hashes} [${code}.${pad2(mapping.milestoneInt)}] ${mapping.token}${entry.headingTag ?? ''}:`;
|
|
1369
|
+
const newLine = heading + (entry.headingTail ?? '');
|
|
1370
|
+
if (newLine !== oldLine) {
|
|
1371
|
+
roadmapEdits.push({ lineIndex: entry.lineIndex, from: oldLine, to: newLine });
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
// #4144 round 6 B4: the READERS' own checklist grammar
|
|
1375
|
+
// (`src/roadmap.cts`'s `cmdRoadmapAnalyze checklistPattern`, the exact
|
|
1376
|
+
// scan `missing_phase_details` is computed from) requires a bold `**`
|
|
1377
|
+
// immediately before the `Phase` label and — unlike the previous regex
|
|
1378
|
+
// here — never requires a colon anywhere after the token:
|
|
1379
|
+
// `- [ ] **Phase 7** - Eta` and `- [x] **Phase 8**: Theta` are both real
|
|
1380
|
+
// phase references to it. A colon-requiring copy left such bullets
|
|
1381
|
+
// byte-identical while their headings converted, so a "done" migration
|
|
1382
|
+
// reported them missing (`missing_phase_details`). This migrator keeps its
|
|
1383
|
+
// own long-standing OPTIONAL-bold tolerance (`\*{0,2}` — a plain,
|
|
1384
|
+
// non-bold `- [x] Phase 3: Gamma` has converted since round 1 and must
|
|
1385
|
+
// keep doing so), so the actual fix is dropping the colon requirement, not
|
|
1386
|
+
// narrowing to bold-only: the "Phase " intro comes from the shared
|
|
1387
|
+
// `phaseHeadingPrefixSrcFor` selector (never a second copy of that text),
|
|
1388
|
+
// and the bullet/checkbox/bold-open prefix is its own capture group
|
|
1389
|
+
// (group 1) — the "Phase " intro text itself is matched but deliberately
|
|
1390
|
+
// left OUT of any group, since the bracket form drops the word "Phase"
|
|
1391
|
+
// entirely. The token and optional tag are captured here, and the REST OF
|
|
1392
|
+
// THE LINE — bold close, tag, separator, colon, whatever follows — is
|
|
1393
|
+
// preserved verbatim via `line.slice(match[0].length)` below, never
|
|
1394
|
+
// reconstructed.
|
|
1395
|
+
// #4144 round 7 W3: `[-*]`, not a literal `-` — the same list-marker class
|
|
1396
|
+
// `BULLET_PHASE_LINE_PATTERN` (roadmap-parser.cts:1446, the scanner
|
|
1397
|
+
// `scanMilestonePhaseIds` uses) already accepts. A `* [ ] **Phase 1: …**`
|
|
1398
|
+
// bullet stayed legacy after an otherwise-done migration, so the
|
|
1399
|
+
// bracket-mode milestone id set carried the stale legacy token alongside
|
|
1400
|
+
// the bracket ones.
|
|
1401
|
+
const CHECKLIST_BULLET_PREFIX_SRC = '[-*]\\s*\\[[ x]\\]\\s*\\*{0,2}';
|
|
1402
|
+
const CHECKLIST_PHASE_INTRO_SRC = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, undefined, true);
|
|
1403
|
+
const mnnChecklistRe = new RegExp(`^(\\s*${CHECKLIST_BULLET_PREFIX_SRC})${CHECKLIST_PHASE_INTRO_SRC}(${MNN_SOURCE_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE})`, 'i');
|
|
1404
|
+
const legacyChecklistRe = new RegExp(`^(\\s*${CHECKLIST_BULLET_PREFIX_SRC})${CHECKLIST_PHASE_INTRO_SRC}(${PHASE_NUMBER_TOKEN_SOURCE})(${OPTIONAL_PHASE_TAG_SOURCE})`, 'i');
|
|
1405
|
+
// #4144 round 8 I1: `legacyChecklistRe` above requires nothing at all
|
|
1406
|
+
// after the number/tag — deliberately, since Tier 1 (reader-recognized,
|
|
1407
|
+
// `isReaderRecognizedBullet` below) must match exactly what the readers'
|
|
1408
|
+
// own grammar matches, and neither `roadmap.cts:770`'s checklistPattern
|
|
1409
|
+
// nor `phase.cts`'s heading branch require a boundary there either (a
|
|
1410
|
+
// bold `**Phase 1-on-1 meetings**` is read by every reader as naming
|
|
1411
|
+
// phase 1, trailing text and all — narrowing the MATCH itself would
|
|
1412
|
+
// silently un-recognize exactly the reader-recognized bullets round 6 B4
|
|
1413
|
+
// exists to keep converting). Tier 2 (this migrator's own wider,
|
|
1414
|
+
// non-bold tolerance) has no such reader grammar to mirror, so it applies
|
|
1415
|
+
// its own separate, narrower boundary check below: mirroring
|
|
1416
|
+
// `phase.cts:4358-4361`'s checkbox-branch separator (colon/em-dash/
|
|
1417
|
+
// en-dash/hyphen) and `BULLET_PHASE_LINE_PATTERN`'s dash family rather
|
|
1418
|
+
// than inventing a fourth grammar, plus the ordinary "end of a plain
|
|
1419
|
+
// word" case a bare-prose to-do bullet needs (`Phase 2 retrospective`).
|
|
1420
|
+
// Without it, Tier 2 renumbered prose that merely STARTS WITH a phase
|
|
1421
|
+
// number and continues as a hyphenated compound word, e.g. `Phase
|
|
1422
|
+
// 1-on-1 meetings` -> `[GSD.01] 01-on-1 meetings` — the hyphen glued
|
|
1423
|
+
// directly onto the number with no separating whitespace is the one shape
|
|
1424
|
+
// every boundary alternative below rejects; a colon/dash preceded by
|
|
1425
|
+
// whitespace (or attached directly, for colon/em-dash/en-dash only, the
|
|
1426
|
+
// same tolerance the heading and bullet-line grammars already give those
|
|
1427
|
+
// three), a bold-close `**`, end of line, or whitespace before an
|
|
1428
|
+
// ordinary (non-hyphen) word all still pass.
|
|
1429
|
+
const TIER2_TOKEN_BOUNDARY_RE = /^(?:\s*[:—–]|\s+-|\*\*|$|\s+[^\s-])/;
|
|
1430
|
+
// #4144 round 6 (C-fence): fence handling was heading-only —
|
|
1431
|
+
// parseBracketSourcePhases skips fenced lines via `fencedLineIndices`
|
|
1432
|
+
// (round 5 W4), but this checklist loop walked raw `lines` with no fence
|
|
1433
|
+
// awareness at all, so a checklist bullet inside a fenced EXAMPLE block
|
|
1434
|
+
// was still rewritten. Same engine, same "never a phase heading/bullet of
|
|
1435
|
+
// any kind inside a fence" rule the heading parse already follows.
|
|
1436
|
+
const fencedChecklistLines = fencedLineIndices(lines);
|
|
1437
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1438
|
+
if (fencedChecklistLines.has(i))
|
|
1439
|
+
continue;
|
|
1440
|
+
const line = lines[i];
|
|
1441
|
+
if (MILESTONE_HEADING_RE.test(line))
|
|
1442
|
+
continue;
|
|
1443
|
+
if (roadmapEdits.some((edit) => edit.lineIndex === i))
|
|
1444
|
+
continue;
|
|
1445
|
+
const mnnChecklist = line.match(mnnChecklistRe);
|
|
1446
|
+
if (mnnChecklist) {
|
|
1447
|
+
const segments = mnnChecklist[2].split('-');
|
|
1448
|
+
const milestone = parseInt(segments[0], 10);
|
|
1449
|
+
const token = segments.slice(1).map((segment) => pad2(parseInt(segment, 10))).join('.');
|
|
1450
|
+
const replacement = `${mnnChecklist[1]}[${code}.${pad2(milestone)}] ${token}${mnnChecklist[3]}`;
|
|
1451
|
+
roadmapEdits.push({
|
|
1452
|
+
lineIndex: i,
|
|
1453
|
+
from: line,
|
|
1454
|
+
to: replacement + line.slice(mnnChecklist[0].length),
|
|
1455
|
+
});
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
const legacyChecklist = line.match(legacyChecklistRe);
|
|
1459
|
+
if (!legacyChecklist)
|
|
1460
|
+
continue;
|
|
1461
|
+
const key = legacyLookupKey(legacyChecklist[2]);
|
|
1462
|
+
// #4144 round 7 B2: the READERS' own checklist grammar
|
|
1463
|
+
// (`src/roadmap.cts` checklistPattern, the exact scan
|
|
1464
|
+
// `missing_phase_details` is computed from) requires an EXACT bold `**`
|
|
1465
|
+
// immediately before the `Phase` label. This migrator's own
|
|
1466
|
+
// long-standing tolerance additionally accepts 0 or 1 asterisks
|
|
1467
|
+
// (`\*{0,2}` in CHECKLIST_BULLET_PREFIX_SRC above) — captured group 1
|
|
1468
|
+
// therefore ends in `**` if and only if the bullet is the exact shape a
|
|
1469
|
+
// reader recognizes. A bullet the readers recognize that cannot be
|
|
1470
|
+
// attributed to any converted phase is a genuine partial-conversion
|
|
1471
|
+
// hazard and must refuse; a bullet only THIS migrator's wider tolerance
|
|
1472
|
+
// matches is not a phase reference to any reader — converted when it
|
|
1473
|
+
// resolves (keeps the long-standing non-bold conversion case green),
|
|
1474
|
+
// left byte-identical rather than refused when it does not.
|
|
1475
|
+
const isReaderRecognizedBullet = /\*\*$/.test(legacyChecklist[1]);
|
|
1476
|
+
// #4144 round 8 I1: Tier 2 additionally requires a token boundary right
|
|
1477
|
+
// after the number/tag (see `TIER2_TOKEN_BOUNDARY_RE` above) — Tier 1
|
|
1478
|
+
// never does, since narrowing the shared match itself would silently
|
|
1479
|
+
// un-recognize a bold bullet the readers' own ungated grammar still
|
|
1480
|
+
// reads as naming that phase. A Tier-2 bullet that fails the boundary
|
|
1481
|
+
// (`- [ ] Phase 1-on-1 meetings`) is treated exactly like one no reader
|
|
1482
|
+
// recognizes at all: left byte-identical, never resolved or refused.
|
|
1483
|
+
if (!isReaderRecognizedBullet && !TIER2_TOKEN_BOUNDARY_RE.test(line.slice(legacyChecklist[0].length))) {
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
let resolved;
|
|
1487
|
+
// #4144 round 7 B1: a checklist bullet naming a SENTINEL phase (999.x
|
|
1488
|
+
// icebox / 0.x backlog) bypasses section attribution entirely — the same
|
|
1489
|
+
// rule the HEADING side already applies via `legacySentinelMilestone`
|
|
1490
|
+
// (consulted before `entry.attributedSection` is ever set, above).
|
|
1491
|
+
// Sentinel entries are always filed under GLOBAL_SECTION_KEY (never
|
|
1492
|
+
// `attributedSection`, since the milestone-attribution loop `continue`s
|
|
1493
|
+
// past that assignment for a sentinel), so a sentinel bullet must be
|
|
1494
|
+
// looked up there directly — never in whichever real `## vN.M` section
|
|
1495
|
+
// its own line happens to sit inside on disk (nothing stops an author
|
|
1496
|
+
// from listing an icebox item next to the real phases it is scheduled
|
|
1497
|
+
// near).
|
|
1498
|
+
const bulletSentinelMilestone = legacySentinelMilestone(legacyChecklist[2]);
|
|
1499
|
+
if (bulletSentinelMilestone !== null) {
|
|
1500
|
+
resolved = sectionLegacyMap.get(GLOBAL_SECTION_KEY)?.get(key);
|
|
1501
|
+
}
|
|
1502
|
+
else {
|
|
1503
|
+
// #4144 round 6 B2: a bullet INSIDE a specific milestone section is
|
|
1504
|
+
// attributed to THAT section alone — never a different section that
|
|
1505
|
+
// happens to share the same leading major integer.
|
|
1506
|
+
const bulletSection = sectionsForOffset(lineOffsets[i] ?? -1)
|
|
1507
|
+
.find((section) => section.milestoneInt !== null) ?? null;
|
|
1508
|
+
if (bulletSection) {
|
|
1509
|
+
resolved = sectionLegacyMap.get(bulletSection.start)?.get(key);
|
|
1510
|
+
}
|
|
1511
|
+
else {
|
|
1512
|
+
// #4144 round 8 B2 (was round 7 B2): outside every section, a
|
|
1513
|
+
// checklist bullet resolves the way round 6 did, for EITHER tier —
|
|
1514
|
+
// first against the no-section bucket itself (an exact,
|
|
1515
|
+
// unambiguous per-key lookup: entries with no attributed section,
|
|
1516
|
+
// such as an entire roadmap derived from a single STATE.md fallback
|
|
1517
|
+
// milestone with no `## vN.M` headings at all, are filed here), and
|
|
1518
|
+
// only when that bucket does not define the token, against the
|
|
1519
|
+
// unique candidate across every bucket. Round 7 gated this whole
|
|
1520
|
+
// lookup on `isReaderRecognizedBullet`, so a non-bold (Tier 2)
|
|
1521
|
+
// bullet outside every section was never resolved at all — on the
|
|
1522
|
+
// committed project-prefixed-single-milestone fixture (no sections,
|
|
1523
|
+
// everything filed under the no-section bucket) that left
|
|
1524
|
+
// `- [ ] Phase 1: Intake` / `- [ ] Phase 2: Delivery` legacy after
|
|
1525
|
+
// an otherwise-"done" migration.
|
|
1526
|
+
resolved = sectionLegacyMap.get(GLOBAL_SECTION_KEY)?.get(key);
|
|
1527
|
+
if (!resolved) {
|
|
1528
|
+
const candidates = [];
|
|
1529
|
+
for (const lookup of sectionLegacyMap.values()) {
|
|
1530
|
+
const candidate = lookup.get(key);
|
|
1531
|
+
if (candidate)
|
|
1532
|
+
candidates.push(candidate);
|
|
1533
|
+
}
|
|
1534
|
+
if (candidates.length > 1) {
|
|
1535
|
+
// #4144 round 8 B2: only a bullet the readers themselves
|
|
1536
|
+
// recognize as a phase reference can turn this ambiguity into a
|
|
1537
|
+
// refusal — Tier 2's own wider tolerance leaves it byte-identical
|
|
1538
|
+
// instead (the `!resolved` branch below), never guessing and
|
|
1539
|
+
// never blocking the whole migration over a to-do bullet no
|
|
1540
|
+
// reader was ever going to check.
|
|
1541
|
+
if (isReaderRecognizedBullet) {
|
|
1542
|
+
throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: a checklist bullet outside every '
|
|
1543
|
+
+ 'milestone section names a legacy phase number that more than one milestone section defines. '
|
|
1544
|
+
+ 'Refusing rather than guessing which phase it means:\n'
|
|
1545
|
+
+ ` ${line}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
else {
|
|
1549
|
+
resolved = candidates[0];
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
if (!resolved) {
|
|
1555
|
+
if (bulletSentinelMilestone !== null) {
|
|
1556
|
+
// #4144 round 8 W1: a sentinel bullet (999.x icebox / 0.x backlog)
|
|
1557
|
+
// with no converted sentinel phase to resolve to — e.g. an icebox
|
|
1558
|
+
// item with no matching `### Phase 999.x:` heading anywhere — is
|
|
1559
|
+
// left byte-identical rather than refused. `roadmap analyze`
|
|
1560
|
+
// deliberately excludes sentinel checklist entries from
|
|
1561
|
+
// `missing_phase_details` (roadmap.cts:795-798, `!isSentinelPhase`),
|
|
1562
|
+
// so the "a reader would report it missing" rationale the Tier-1
|
|
1563
|
+
// refusal below exists for does not apply here: every reader still
|
|
1564
|
+
// classifies the unconverted line as sentinel under bracket too
|
|
1565
|
+
// (its checklist grammar captures no bracket id, and
|
|
1566
|
+
// `isSentinelPhaseId` falls back to the same bare-999/0.x rule
|
|
1567
|
+
// whether or not the line converted).
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
if (!isReaderRecognizedBullet) {
|
|
1571
|
+
// #4144 round 7 B2: no reader treats this bullet as a phase
|
|
1572
|
+
// reference (roadmap.cts:770 requires bold, roadmap-parser.cts:1446
|
|
1573
|
+
// requires `**Phase`, phase.cts's checkbox branch requires a
|
|
1574
|
+
// separator after the token) — there is nothing for a "done"
|
|
1575
|
+
// migration to have missed. Left byte-identical, never refused.
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
// #4144 round 6 B4: the readers' own grammar recognizes this bullet as
|
|
1579
|
+
// a phase reference (mnnChecklistRe/legacyChecklistRe just matched
|
|
1580
|
+
// it), but this migrator could not attribute it to any converted
|
|
1581
|
+
// phase. Leaving it byte-identical would stamp the migration done
|
|
1582
|
+
// while a reader-recognized checklist entry stays unconverted —
|
|
1583
|
+
// refuse before any write instead, naming the bullet.
|
|
1584
|
+
throw new Error('Cannot safely migrate ROADMAP.md to the bracket convention: a checklist bullet the readers '
|
|
1585
|
+
+ 'recognize as a phase reference could not be attributed to any converted phase. Refusing rather '
|
|
1586
|
+
+ 'than leaving it unconverted in an otherwise-migrated roadmap:\n'
|
|
1587
|
+
+ ` ${line}`);
|
|
1588
|
+
}
|
|
1589
|
+
const replacement = `${legacyChecklist[1]}[${code}.${pad2(resolved.milestoneInt)}] ${resolved.token}`
|
|
1590
|
+
+ `${legacyChecklist[3]}`;
|
|
1591
|
+
roadmapEdits.push({
|
|
1592
|
+
lineIndex: i,
|
|
1593
|
+
from: line,
|
|
1594
|
+
to: replacement + line.slice(legacyChecklist[0].length),
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
// Bare STATE.md / PROJECT.md `Phase N` prose carries no milestone context in
|
|
1598
|
+
// multi-milestone repositories. That emit concern remains deferred; guessing
|
|
1599
|
+
// here would recreate the ambiguity this migration removes.
|
|
1600
|
+
const crossRefEdits = [];
|
|
1601
|
+
return {
|
|
1602
|
+
alreadyMigrated: false,
|
|
1603
|
+
phases,
|
|
1604
|
+
roadmapEdits,
|
|
1605
|
+
crossRefEdits,
|
|
1606
|
+
targetConvention: 'bracket',
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
131
1609
|
// ─── computeMigrationPlan ─────────────────────────────────────────────────────
|
|
132
1610
|
/**
|
|
133
1611
|
* Compute a migration plan without touching the filesystem.
|
|
134
1612
|
*/
|
|
135
1613
|
function computeMigrationPlan(cwd, options = {}) {
|
|
136
|
-
|
|
1614
|
+
if (options['convention'] === 'bracket')
|
|
1615
|
+
return computeBracketPlan(cwd);
|
|
137
1616
|
const pDir = planningDir(cwd);
|
|
138
1617
|
const roadmapPath = node_path_1.default.join(pDir, 'ROADMAP.md');
|
|
139
1618
|
const configPath = node_path_1.default.join(pDir, 'config.json');
|
|
@@ -358,40 +1837,71 @@ function computeMigrationPlan(cwd, options = {}) {
|
|
|
358
1837
|
crossRefEdits,
|
|
359
1838
|
};
|
|
360
1839
|
}
|
|
1840
|
+
// ─── Seam-routed planning writes (ADR-5057 §6, Phase 13, #5217) ───────────────
|
|
361
1841
|
/**
|
|
362
|
-
* Apply
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
1842
|
+
* Apply the plan's ROADMAP.md line edits through the sectionizer seam: a
|
|
1843
|
+
* heading edit through `updateHeading`, a checklist edit through
|
|
1844
|
+
* `updateBullet`. Both match on the plan's own `lineIndex` AND exact line text
|
|
1845
|
+
* (`split('\n')` indexing, a trailing `\r` is part of the line), so two
|
|
1846
|
+
* headings with identical text in different milestones stay distinct, a stale
|
|
1847
|
+
* edit is left untouched, and every untouched byte is copied verbatim.
|
|
1848
|
+
*
|
|
1849
|
+
* An edit whose target line is still its `from` text after both seams ran was
|
|
1850
|
+
* refused by the seam (a line the sectionizer does not read as a heading or
|
|
1851
|
+
* bullet — fenced, or not CommonMark-shaped). That is thrown, never skipped:
|
|
1852
|
+
* a silently-skipped edit leaves ROADMAP.md half-migrated, and the mixed-state
|
|
1853
|
+
* guard then refuses every retry. `applyMigration` rolls back on the throw.
|
|
373
1854
|
*/
|
|
374
|
-
function
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
1855
|
+
function rewriteRoadmapLines(content, plannedEdits) {
|
|
1856
|
+
// The planner reads raw lines, so it also plans edits for phase-shaped lines
|
|
1857
|
+
// inside a fenced example block. Fenced content is never a heading or a
|
|
1858
|
+
// bullet (the sectionizer's own view), so those edits are dropped here —
|
|
1859
|
+
// not thrown: a fenced example must neither be rewritten nor block the
|
|
1860
|
+
// migration.
|
|
1861
|
+
const fencedLines = new Set();
|
|
1862
|
+
const physicalLines = content.split('\n');
|
|
1863
|
+
for (const block of scanFencedBlocks(physicalLines)) {
|
|
1864
|
+
const last = block.closeLineIdx === -1 ? physicalLines.length - 1 : block.closeLineIdx;
|
|
1865
|
+
for (let i = block.openLineIdx; i <= last; i++)
|
|
1866
|
+
fencedLines.add(i);
|
|
1867
|
+
}
|
|
1868
|
+
const edits = plannedEdits.filter((edit) => !fencedLines.has(edit.lineIndex));
|
|
1869
|
+
let result = content;
|
|
1870
|
+
for (const edit of edits) {
|
|
1871
|
+
const atTarget = (rawLine, lineIndex) => lineIndex === edit.lineIndex && rawLine === edit.from;
|
|
1872
|
+
const swap = () => edit.to;
|
|
1873
|
+
result = updateHeading(result, (_heading, rawLine, lineIndex) => atTarget(rawLine, lineIndex), swap);
|
|
1874
|
+
result = updateBullet(result, (_text, rawLine, lineIndex) => atTarget(rawLine, lineIndex), swap);
|
|
1875
|
+
}
|
|
1876
|
+
const lines = result.split('\n');
|
|
1877
|
+
for (const edit of edits) {
|
|
1878
|
+
if (edit.from !== edit.to && lines[edit.lineIndex] === edit.from) {
|
|
1879
|
+
throw new Error(`ROADMAP.md line ${edit.lineIndex + 1} (${JSON.stringify(edit.from)}) is not a heading or checklist `
|
|
1880
|
+
+ 'line the planning seam can rewrite');
|
|
1881
|
+
}
|
|
392
1882
|
}
|
|
393
1883
|
return result;
|
|
394
1884
|
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Apply the cross-reference substitutions to one planning artifact's content
|
|
1887
|
+
* through `PlanningDoc` (`replaceProse`), in plan order. A document the seam
|
|
1888
|
+
* cannot read (an unterminated frontmatter fence) is refused — thrown, so the
|
|
1889
|
+
* migration rolls back — rather than rewritten blind.
|
|
1890
|
+
*/
|
|
1891
|
+
function substituteCrossRefs(content, fileName, edits) {
|
|
1892
|
+
const parsed = (0, planning_document_cjs_1.parsePlanningDoc)(content, fileName);
|
|
1893
|
+
if (!parsed.ok) {
|
|
1894
|
+
throw new Error(`${fileName} cannot be read as a planning document: ${parsed.reason}`);
|
|
1895
|
+
}
|
|
1896
|
+
let doc = parsed.value;
|
|
1897
|
+
for (const edit of edits) {
|
|
1898
|
+
const next = (0, planning_document_cjs_1.replaceProse)(doc, edit.from, edit.to);
|
|
1899
|
+
if (!next.ok)
|
|
1900
|
+
throw new Error(`${fileName} cross-reference rewrite refused: ${next.reason}`);
|
|
1901
|
+
doc = next.value;
|
|
1902
|
+
}
|
|
1903
|
+
return doc.source;
|
|
1904
|
+
}
|
|
395
1905
|
// ─── applyMigration ───────────────────────────────────────────────────────────
|
|
396
1906
|
/**
|
|
397
1907
|
* Apply the migration plan computed by computeMigrationPlan().
|
|
@@ -446,7 +1956,13 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
446
1956
|
}
|
|
447
1957
|
};
|
|
448
1958
|
try {
|
|
449
|
-
// 1. Rename phase directories
|
|
1959
|
+
// 1. Rename phase directories, then (#4698 Blocker 3) any phase-qualified
|
|
1960
|
+
// artifact filenames inside them whose names still spell the
|
|
1961
|
+
// pre-migration phase token. File renames are recorded onto the SAME
|
|
1962
|
+
// `performedRenames` list, immediately after their own directory's
|
|
1963
|
+
// entry — rollback below walks this list newest-first, so a file rename
|
|
1964
|
+
// is always reversed before the directory rename that contains it, which
|
|
1965
|
+
// is the only order that can succeed.
|
|
450
1966
|
for (const phaseEntry of plan.phases) {
|
|
451
1967
|
const oldPath = node_path_1.default.join(phasesDir, phaseEntry.oldDir);
|
|
452
1968
|
const newPath = node_path_1.default.join(phasesDir, phaseEntry.newDir);
|
|
@@ -454,53 +1970,119 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
454
1970
|
(0, shell_command_projection_cjs_1.retryRenameSync)(oldPath, newPath);
|
|
455
1971
|
performedRenames.push({ oldPath, newPath });
|
|
456
1972
|
renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
|
|
1973
|
+
for (const fileRename of phaseEntry.fileRenames ?? []) {
|
|
1974
|
+
const oldFilePath = node_path_1.default.join(newPath, fileRename.oldName);
|
|
1975
|
+
const newFilePath = node_path_1.default.join(newPath, fileRename.newName);
|
|
1976
|
+
if (node_fs_1.default.existsSync(oldFilePath)) {
|
|
1977
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(oldFilePath, newFilePath);
|
|
1978
|
+
performedRenames.push({ oldPath: oldFilePath, newPath: newFilePath });
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
// #4698 Blocker 1 (round 2): rewrite depends_on references, AFTER
|
|
1982
|
+
// the artifact renames above so `rewrite.finalName` already exists
|
|
1983
|
+
// on disk at its final name. The backup is keyed by the file's
|
|
1984
|
+
// ORIGINAL pre-migration absolute path (`oldPath`, this directory's
|
|
1985
|
+
// own pre-rename path — still a valid STRING even though nothing
|
|
1986
|
+
// lives there right now) rather than its current path: the rollback
|
|
1987
|
+
// below reverses every entry in `performedRenames` (this directory's
|
|
1988
|
+
// rename AND its file renames) BEFORE it ever consults
|
|
1989
|
+
// `fileBackups`, so by the time that restore runs, this exact file
|
|
1990
|
+
// is already back at `oldPath`/`rewrite.oldName` — which is the only
|
|
1991
|
+
// path the backup can be keyed by for the restore to land correctly.
|
|
1992
|
+
// Content, unlike a path, is never touched by the rename reversal,
|
|
1993
|
+
// so keying by the post-rollback path is the only order that works.
|
|
1994
|
+
for (const rewrite of phaseEntry.dependsOnRewrites ?? []) {
|
|
1995
|
+
const currentPath = node_path_1.default.join(newPath, rewrite.finalName);
|
|
1996
|
+
if (node_fs_1.default.existsSync(currentPath)) {
|
|
1997
|
+
const originalPath = node_path_1.default.join(oldPath, rewrite.oldName);
|
|
1998
|
+
// `rewrite.to` is the frontmatter seam's own output
|
|
1999
|
+
// (`spliceFrontmatter`, computed in computeDependsOnRewrites); it is
|
|
2000
|
+
// only persisted over the exact content it was computed from, so a
|
|
2001
|
+
// plan file edited since the plan was made is refused, never
|
|
2002
|
+
// clobbered with a stale whole-file image. The check runs BEFORE
|
|
2003
|
+
// the backup is recorded: the backup holds `rewrite.from`, which is
|
|
2004
|
+
// only the file's real content once this check has passed — recording
|
|
2005
|
+
// it first would make the rollback restore stale content over the
|
|
2006
|
+
// edited file.
|
|
2007
|
+
if (node_fs_1.default.readFileSync(currentPath, 'utf8') !== rewrite.from) {
|
|
2008
|
+
throw new Error(`${JSON.stringify(node_path_1.default.join('phases', phaseEntry.newDir, rewrite.finalName))} changed since the migration plan was computed`);
|
|
2009
|
+
}
|
|
2010
|
+
if (!fileBackups.has(originalPath)) {
|
|
2011
|
+
fileBackups.set(originalPath, { existed: true, content: rewrite.from });
|
|
2012
|
+
}
|
|
2013
|
+
node_fs_1.default.writeFileSync(currentPath, rewrite.to, 'utf8');
|
|
2014
|
+
editedFiles.push(node_path_1.default.join('phases', phaseEntry.newDir, rewrite.finalName));
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
457
2017
|
}
|
|
458
2018
|
}
|
|
459
2019
|
// 2. Rewrite ROADMAP.md phase headings
|
|
460
2020
|
if (plan.roadmapEdits.length > 0) {
|
|
461
2021
|
const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf8');
|
|
462
|
-
const newRoadmapContent =
|
|
2022
|
+
const newRoadmapContent = rewriteRoadmapLines(roadmapContent, plan.roadmapEdits);
|
|
463
2023
|
snapshotFile(roadmapPath);
|
|
464
2024
|
node_fs_1.default.writeFileSync(roadmapPath, newRoadmapContent, 'utf8');
|
|
465
2025
|
editedFiles.push('ROADMAP.md');
|
|
466
2026
|
}
|
|
467
2027
|
// 3. Rewrite cross-refs in STATE.md and PROJECT.md
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
2028
|
+
// Two targets, two writes, one per seam (ADR-5057 §6): PROJECT.md's
|
|
2029
|
+
// content is rewritten by PlanningDoc, STATE.md goes through its own
|
|
2030
|
+
// read-modify-write seam (ADR-3408: lock, frontmatter sync and
|
|
2031
|
+
// preservation; body-only edit).
|
|
2032
|
+
const crossRefsFor = (fileName) => plan.crossRefEdits.filter((edit) => edit.file === fileName);
|
|
2033
|
+
const projectEdits = crossRefsFor('PROJECT.md');
|
|
2034
|
+
const projectPath = node_path_1.default.join(pDir, 'PROJECT.md');
|
|
2035
|
+
if (projectEdits.length > 0 && node_fs_1.default.existsSync(projectPath)) {
|
|
2036
|
+
const original = node_fs_1.default.readFileSync(projectPath, 'utf8');
|
|
2037
|
+
const rewritten = substituteCrossRefs(original, 'PROJECT.md', projectEdits);
|
|
2038
|
+
if (rewritten !== original) {
|
|
2039
|
+
snapshotFile(projectPath);
|
|
2040
|
+
node_fs_1.default.writeFileSync(projectPath, rewritten, 'utf8');
|
|
2041
|
+
editedFiles.push('PROJECT.md');
|
|
472
2042
|
}
|
|
473
|
-
crossRefsByFile.get(edit.file).push(edit);
|
|
474
2043
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
// Replace all occurrences
|
|
484
|
-
content = content.split(edit.from).join(edit.to);
|
|
485
|
-
changed = true;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
if (changed) {
|
|
489
|
-
snapshotFile(filePath);
|
|
490
|
-
node_fs_1.default.writeFileSync(filePath, content, 'utf8');
|
|
491
|
-
editedFiles.push(fileName);
|
|
2044
|
+
const stateEdits = crossRefsFor('STATE.md');
|
|
2045
|
+
const stateMdPath = node_path_1.default.join(pDir, 'STATE.md');
|
|
2046
|
+
if (stateEdits.length > 0 && node_fs_1.default.existsSync(stateMdPath)) {
|
|
2047
|
+
const original = node_fs_1.default.readFileSync(stateMdPath, 'utf8');
|
|
2048
|
+
if (substituteCrossRefs(original, 'STATE.md', stateEdits) !== original) {
|
|
2049
|
+
snapshotFile(stateMdPath);
|
|
2050
|
+
readModifyWriteStateMd(stateMdPath, (current) => substituteCrossRefs(current, 'STATE.md', stateEdits), cwd, { resync: false });
|
|
2051
|
+
editedFiles.push('STATE.md');
|
|
492
2052
|
}
|
|
493
2053
|
}
|
|
494
|
-
// 4. Update config.json
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
2054
|
+
// 4. Update config.json to the convention named by this plan — but only
|
|
2055
|
+
// when the plan actually converted at least one IDENTITY (#4698 Blocker
|
|
2056
|
+
// 1/2). `plan.phases` holds DIRECTORY renames, not converted HEADINGS —
|
|
2057
|
+
// gating on `phases.length` alone left a roadmap with recognizable
|
|
2058
|
+
// headings but zero phase directories on disk (nothing to rename) with
|
|
2059
|
+
// its ROADMAP.md already rewritten to the target convention's text while
|
|
2060
|
+
// config stayed unset, and Blocker 2's own mixed/partial guard then
|
|
2061
|
+
// permanently refuses every retry (headings already read as the target
|
|
2062
|
+
// convention, config does not). An empty plan (e.g. a roadmap this
|
|
2063
|
+
// migrator failed to recognize) must still never stamp
|
|
2064
|
+
// phase_id_convention: computeBracketPlan's own idempotency guard treats
|
|
2065
|
+
// that stamp as proof the migration already finished, so a write here
|
|
2066
|
+
// with nothing converted would make the correct re-run (once the roadmap
|
|
2067
|
+
// is fixed) permanently unreachable. Applies identically to both targets
|
|
2068
|
+
// — legacy (milestone-prefixed) plans omit targetConvention and retain
|
|
2069
|
+
// the historical default.
|
|
2070
|
+
if (plan.phases.length > 0 || plan.roadmapEdits.length > 0) {
|
|
2071
|
+
let configData = {};
|
|
2072
|
+
try {
|
|
2073
|
+
configData = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
|
|
2074
|
+
}
|
|
2075
|
+
catch { /* config may not exist yet */ }
|
|
2076
|
+
configData['phase_id_convention'] = plan.targetConvention ?? 'milestone-prefixed';
|
|
2077
|
+
snapshotFile(configPath);
|
|
2078
|
+
try {
|
|
2079
|
+
node_fs_1.default.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
|
|
2080
|
+
}
|
|
2081
|
+
catch (err) {
|
|
2082
|
+
throw new Error(`config.json write phase failed: ${err.message}`);
|
|
2083
|
+
}
|
|
2084
|
+
editedFiles.push('config.json');
|
|
498
2085
|
}
|
|
499
|
-
catch { /* config may not exist yet */ }
|
|
500
|
-
configData['phase_id_convention'] = 'milestone-prefixed';
|
|
501
|
-
snapshotFile(configPath);
|
|
502
|
-
node_fs_1.default.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
|
|
503
|
-
editedFiles.push('config.json');
|
|
504
2086
|
}
|
|
505
2087
|
catch (err) {
|
|
506
2088
|
// Surgical rollback: reverse the renames (newest first) and restore every
|
|
@@ -531,4 +2113,10 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
531
2113
|
module.exports = {
|
|
532
2114
|
computeMigrationPlan,
|
|
533
2115
|
applyMigration,
|
|
2116
|
+
computeDependsOnRewrites,
|
|
2117
|
+
// #4698 review round 28 precedent (ADR-1508): exported solely so the property
|
|
2118
|
+
// test drives the REAL roadmap transform `applyMigration` writes through —
|
|
2119
|
+
// which is now a composition of sectionizer seams (`updateHeading`,
|
|
2120
|
+
// `updateBullet`), not a line-indexer of its own. Not a new production seam.
|
|
2121
|
+
rewriteRoadmapLines,
|
|
534
2122
|
};
|