@opengsd/gsd-core 1.9.1 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -3
- package/.opencode/plugins/gsd-core.js +8 -1
- package/agents/gsd-code-fixer.md +27 -3
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-debugger.md +12 -246
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +12 -10
- package/agents/gsd-integration-checker.md +3 -0
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +46 -0
- package/agents/gsd-planner.md +49 -54
- package/agents/gsd-roadmapper.md +21 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +26 -73
- package/bin/install.js +1272 -1238
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +2 -2
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/plan-phase.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1009 -115
- package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
- package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
- package/gsd-core/bin/lib/api-coverage.cjs +123 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +926 -202
- package/gsd-core/bin/lib/broken-windows.cjs +36 -6
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +608 -148
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +507 -24
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +114 -38
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +94 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +665 -99
- package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +76 -0
- package/gsd-core/bin/lib/config.cjs +22 -2
- package/gsd-core/bin/lib/context-composer.cjs +278 -0
- package/gsd-core/bin/lib/context-predicates.cjs +506 -0
- package/gsd-core/bin/lib/core-utils.cjs +217 -40
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/docs.cjs +3 -2
- package/gsd-core/bin/lib/external-job.cjs +19 -4
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +239 -32
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
- package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
- package/gsd-core/bin/lib/graphify.cjs +142 -27
- package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-integration.cjs +13 -1
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +83 -8
- package/gsd-core/bin/lib/init.cjs +1325 -169
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +805 -264
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +160 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
- package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
- package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
- package/gsd-core/bin/lib/io.cjs +38 -3
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
- package/gsd-core/bin/lib/mcp-server.cjs +135 -3
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +821 -109
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +507 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +258 -58
- package/gsd-core/bin/lib/phase.cjs +891 -156
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +405 -84
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/section-manifest.cjs +209 -0
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
- package/gsd-core/bin/lib/smart-entry.cjs +154 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +424 -105
- package/gsd-core/bin/lib/state.cjs +1927 -401
- package/gsd-core/bin/lib/surface.cjs +35 -10
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
- package/gsd-core/bin/lib/uat.cjs +706 -64
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +33 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +287 -20
- package/gsd-core/bin/lib/verify.cjs +368 -880
- package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
- package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
- package/gsd-core/bin/lib/workstream.cjs +8 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/artifact-types.md +10 -3
- package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/debugger-techniques.md +255 -0
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/research-documentation-lookup.md +5 -3
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +8 -7
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verifier-wiring-patterns.md +100 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/references/worktree-branch-check.md +2 -2
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/summary-complex.md +2 -0
- package/gsd-core/templates/summary-minimal.md +2 -0
- package/gsd-core/templates/summary-standard.md +2 -0
- package/gsd-core/templates/summary.md +2 -0
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/audit-milestone.md +3 -0
- package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
- package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
- package/gsd-core/workflows/autonomous.md +33 -70
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +74 -166
- package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
- package/gsd-core/workflows/complete-milestone.md +160 -95
- package/gsd-core/workflows/debug.md +16 -17
- package/gsd-core/workflows/diagnose-issues.md +56 -8
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
- package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
- package/gsd-core/workflows/docs-update.md +8 -51
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
- package/gsd-core/workflows/execute-phase.md +103 -187
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/help/modes/full.md +6 -1
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +7 -6
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
- package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
- package/gsd-core/workflows/new-milestone.md +35 -47
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
- package/gsd-core/workflows/new-project.md +27 -240
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
- package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
- package/gsd-core/workflows/plan-phase.md +89 -209
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
- package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
- package/gsd-core/workflows/progress.md +45 -159
- package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
- package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
- package/gsd-core/workflows/quick.md +55 -405
- package/gsd-core/workflows/resume-project.md +3 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
- package/gsd-core/workflows/review.md +41 -13
- package/gsd-core/workflows/section-manifest.json +219 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +41 -20
- package/gsd-core/workflows/spike-wrap-up.md +20 -5
- package/gsd-core/workflows/spike.md +50 -16
- package/gsd-core/workflows/sync-skills.md +106 -13
- package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
- package/gsd-core/workflows/transition.md +53 -31
- package/gsd-core/workflows/ui-phase.md +13 -12
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
- package/gsd-core/workflows/update.md +19 -8
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
- package/gsd-core/workflows/verify-work.md +17 -65
- package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
- package/hooks/dist/gsd-check-update-worker.js +64 -12
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +45 -24
- package/hooks/dist/gsd-statusline.js +90 -6
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/gsd-worktree-path-guard.js +2 -1
- package/hooks/dist/gsd-write-guard.js +359 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +277 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-agent-isolation-guard.js +517 -0
- package/hooks/gsd-check-update-worker.js +64 -12
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +607 -26
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +45 -24
- package/hooks/gsd-statusline.js +90 -6
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/gsd-worktree-path-guard.js +2 -1
- package/hooks/gsd-write-guard.js +359 -0
- package/hooks/hooks.json +12 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +277 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +31 -10
- package/pi/gsd.cjs +71 -12
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/build-hooks.js +9 -0
- package/scripts/changeset/lint.cjs +68 -6
- package/scripts/changeset/serialize.cjs +5 -1
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-matrix.cjs +1 -1
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +439 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +150 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-section-manifest.cjs +638 -0
- package/scripts/generate-package-identity.cjs +4 -2
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-compiled-artifact-sync.cjs +6 -1
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-docs-command-form.cjs +195 -0
- package/scripts/lint-docs-required.cjs +9 -1
- package/scripts/lint-emitted-drift-ack.cjs +215 -20
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-example-parser-parity.cjs +395 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +40 -3
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/mutation-matrix.cjs +13 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +52 -6
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +2 -2
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +2 -2
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +1 -2
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -577
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/gen-emitted-baseline.cjs +0 -145
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1050
|
@@ -11,22 +11,25 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Dependencies (leaf modules only — no loadConfig):
|
|
13
13
|
* - node:fs / node:path (stdlib)
|
|
14
|
-
* - ./phase-id.cjs (
|
|
14
|
+
* - ./phase-id.cjs (phaseMarkdownRegexSource)
|
|
15
|
+
* - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam)
|
|
15
16
|
* - ./planning-workspace.cjs (planningDir)
|
|
16
17
|
* - ./shell-command-projection.cjs (platformReadSync)
|
|
17
|
-
* - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection)
|
|
18
|
+
* - ./markdown-sectionizer.cjs (tokenizeHeadings, stripTaggedBlocks, withSection, collectSection)
|
|
19
|
+
* - ./markdown-table.cjs (findTableWithColumns)
|
|
18
20
|
*/
|
|
19
21
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
20
22
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
21
23
|
};
|
|
22
24
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
23
25
|
const node_path_1 = __importDefault(require("node:path"));
|
|
26
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
24
27
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
28
|
const phaseIdModule = require("./phase-id.cjs");
|
|
26
|
-
const {
|
|
29
|
+
const { phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE,
|
|
27
30
|
// #2121: roadmapPhaseLookupSources now lives in phase-id.cjs (single owner of
|
|
28
31
|
// the lookup-source ordering); imported here rather than defined locally.
|
|
29
|
-
roadmapPhaseLookupSources, } = phaseIdModule;
|
|
32
|
+
roadmapPhaseLookupSources, extractPhaseToken, isSentinelPhaseId, } = phaseIdModule;
|
|
30
33
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
34
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
32
35
|
const { planningDir } = planningWorkspace;
|
|
@@ -35,7 +38,22 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
|
|
|
35
38
|
const unusableInputMod = require("./unusable-input.cjs");
|
|
36
39
|
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
|
|
37
40
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
41
|
+
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
|
+
const planningScopeMod = require("./planning-scope.cjs");
|
|
44
|
+
const { SCOPE } = planningScopeMod;
|
|
38
45
|
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Markers that classify a MILESTONE HEADING (or `<summary>`) as closed/shipped
|
|
48
|
+
* versus still active. Hoisted to module scope in #2562 — three call sites
|
|
49
|
+
* (`extractCurrentMilestone`, `currentMilestoneRawRanges`,
|
|
50
|
+
* `isMilestoneShippedInRoadmap`) previously kept byte-identical copies.
|
|
51
|
+
*/
|
|
52
|
+
const MILESTONE_CLOSED_MARKER_PATTERN = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
|
|
53
|
+
const MILESTONE_ACTIVE_MARKER_PATTERN = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
|
|
54
|
+
function isClosedMilestoneHeading(headingText) {
|
|
55
|
+
return MILESTONE_CLOSED_MARKER_PATTERN.test(headingText) && !MILESTONE_ACTIVE_MARKER_PATTERN.test(headingText);
|
|
56
|
+
}
|
|
39
57
|
/**
|
|
40
58
|
* Strip shipped milestone content wrapped in <details> blocks.
|
|
41
59
|
*/
|
|
@@ -43,14 +61,547 @@ function stripShippedMilestones(content) {
|
|
|
43
61
|
return (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(content, 'details');
|
|
44
62
|
}
|
|
45
63
|
/**
|
|
46
|
-
*
|
|
64
|
+
* #2562: is the milestone `version` marked SHIPPED by the ROADMAP itself?
|
|
65
|
+
*
|
|
66
|
+
* Scoped deliberately narrowly, because a false positive here reproduces the
|
|
67
|
+
* exact symptom #2562 reports ("milestone complete" while phases are unstarted):
|
|
68
|
+
*
|
|
69
|
+
* - Only a MILESTONE HEADING (`^#{1,3}` that is not a `Phase N:` heading) or a
|
|
70
|
+
* `<summary>` line can carry the signal. A bullet or checklist item that
|
|
71
|
+
* merely NAMES the version (`- [x] 03-01: ship the v2.0 login endpoint ✅`)
|
|
72
|
+
* is prose about a phase, not a milestone verdict, and is ignored.
|
|
73
|
+
* - The version token is boundary-matched with `(?![\w.-])` (mirrors the #730
|
|
74
|
+
* sub-milestone boundary at `extractCurrentMilestone`), so `v2.0` does not
|
|
75
|
+
* match inside `v2.0.1` — `\b` alone would, since `.` is a non-word char.
|
|
76
|
+
* - Shipped/active classification reuses the same marker patterns the milestone
|
|
77
|
+
* sectioniser uses, so an in-progress marker on the line always wins.
|
|
78
|
+
*
|
|
79
|
+
* Both patterns are anchored and use only complementary character classes
|
|
80
|
+
* (`[^\n]`, `[^<]`, `[^>]`) with no overlapping alternation, so matching stays
|
|
81
|
+
* linear in the ROADMAP's length — an untrusted ROADMAP cannot drive backtracking.
|
|
47
82
|
*/
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
83
|
+
function isMilestoneShippedInRoadmap(content, version) {
|
|
84
|
+
const boundedVersion = `${(0, pattern_cjs_1.escapeRegex)(version)}(?![\\w.-])`;
|
|
85
|
+
const candidates = [
|
|
86
|
+
// A milestone heading: `## v2.0 Launch — ✅ SHIPPED`.
|
|
87
|
+
new RegExp(`^#{1,3}[^\\S\\n]+(?!Phase\\s+\\S)[^\\n]*${boundedVersion}[^\\n]*$`, 'gmi'),
|
|
88
|
+
// A collapsed shipped block's own summary: `<summary>✅ v2.0 … SHIPPED</summary>`.
|
|
89
|
+
new RegExp(`<summary[^>]*>[^<]*${boundedVersion}[^<]*<\\/summary>`, 'gi'),
|
|
90
|
+
];
|
|
91
|
+
for (const pattern of candidates) {
|
|
92
|
+
for (const match of content.matchAll(pattern)) {
|
|
93
|
+
if (isClosedMilestoneHeading(match[0]))
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* #3184 (epic #3180 Phase 2): the sole owner of "where does this milestone
|
|
101
|
+
* heading's section end". Lifted from `currentMilestoneRawRanges`'s prior
|
|
102
|
+
* inline copy — the only one of three byte-identical copies that carried a
|
|
103
|
+
* "keep in sync" comment (evidence the risk was known, not controlled).
|
|
104
|
+
* `extractCurrentMilestoneScoped`, `currentMilestoneRawRanges`, and
|
|
105
|
+
* `getMilestonePhaseFilter`'s versionOverride branch all call this instead of
|
|
106
|
+
* re-deriving it.
|
|
107
|
+
*/
|
|
108
|
+
function computeMilestoneSectionEnd(content, headingText, headingStart) {
|
|
109
|
+
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
110
|
+
const afterHeading = headingStart + headingText.length;
|
|
111
|
+
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
|
|
112
|
+
// the next stop boundary without re-implementing fence detection. T4 seam migration.
|
|
113
|
+
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
114
|
+
for (const h of headings) {
|
|
115
|
+
if (h.offset <= headingStart)
|
|
116
|
+
continue;
|
|
117
|
+
if (h.offset < afterHeading)
|
|
118
|
+
continue;
|
|
119
|
+
if (h.level > level)
|
|
120
|
+
continue;
|
|
121
|
+
// Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker
|
|
122
|
+
if (/^Phase\s+\S/i.test(h.text))
|
|
123
|
+
continue;
|
|
124
|
+
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
|
|
125
|
+
continue;
|
|
126
|
+
return h.offset;
|
|
127
|
+
}
|
|
128
|
+
return content.length;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* #3216 (epic #3180 §7.2 Scope amendment): the version-AGNOSTIC sibling of
|
|
132
|
+
* `locateMilestoneHeadings` — enumerates EVERY milestone heading in document
|
|
133
|
+
* order, carrying its own version token, curated name, and shipped/closed
|
|
134
|
+
* status. Consolidates the THIRD independent re-derivation the widened guard
|
|
135
|
+
* found at `roadmap.cts:454` (`cmdRoadmapAnalyze`'s inline
|
|
136
|
+
* `/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi`), which truncated names at a
|
|
137
|
+
* parenthetical and had no phase-heading exclusion.
|
|
138
|
+
*
|
|
139
|
+
* `MILESTONE_HEADING_LINE_SOURCE` immediately below is the ONE textual
|
|
140
|
+
* expression of the grammar `^#{1,3}\s+(?!Phase\s+\S)` in this file;
|
|
141
|
+
* `locateMilestoneHeadings` builds its own pattern from the SAME constant
|
|
142
|
+
* instead of re-typing the pattern text, so it is a version-FILTERED VIEW
|
|
143
|
+
* over this function's grammar, never a second expression of it.
|
|
144
|
+
*
|
|
145
|
+
* Name extraction follows the pinned rule (ADR-3180 §7.2 amendment, "Name
|
|
146
|
+
* extraction — pinned rule" via `extractMilestoneHeadingName`): strip
|
|
147
|
+
* everything through the heading's OWN version token — not necessarily one a
|
|
148
|
+
* caller is separately asking about — then ONE leading delimiter and
|
|
149
|
+
* surrounding whitespace via the shared `stripLeadingDelimiter`. `(` is an
|
|
150
|
+
* ordinary name character and is never a terminator (#3171). `getMilestoneInfo`
|
|
151
|
+
* shares this same extraction so the parenthetical rule has exactly one
|
|
152
|
+
* implementation.
|
|
153
|
+
*/
|
|
154
|
+
function listMilestoneHeadings(content) {
|
|
155
|
+
const pattern = new RegExp(MILESTONE_HEADING_LINE_SOURCE, 'gmi');
|
|
156
|
+
const out = [];
|
|
157
|
+
let m;
|
|
158
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
159
|
+
// #3216 review (Finding 4): the shared grammar's `[^\n]*` captures a
|
|
160
|
+
// trailing `\r` on a CRLF-encoded ROADMAP (the inline `cmdRoadmapAnalyze`
|
|
161
|
+
// regex this replaced called `.trim()`; this did not). `.trim()` here
|
|
162
|
+
// matches that prior behavior. `version` (digits/dots/letters only, via
|
|
163
|
+
// `extractMilestoneHeadingName`'s regex) and `name` (already run through
|
|
164
|
+
// `stripLeadingDelimiter`, which ends in `.trim()`) cannot carry a
|
|
165
|
+
// trailing `\r`, so only `heading` needs the fix.
|
|
166
|
+
//
|
|
167
|
+
// `heading` carries the heading text WITHOUT the leading `#{1,3}` run and
|
|
168
|
+
// its following whitespace — matching the inline `cmdRoadmapAnalyze`
|
|
169
|
+
// regex this function replaced (`/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi`,
|
|
170
|
+
// whose capture group 1 begins AFTER `##\s*`). `locateMilestoneHeadings`
|
|
171
|
+
// legitimately returns a DIFFERENT representation (`m[1]`, `#`s included)
|
|
172
|
+
// — the two owners agree on WHICH milestone headings are selected, not on
|
|
173
|
+
// raw heading text.
|
|
174
|
+
const heading = m[0].replace(/^#{1,3}\s+/, '').trim();
|
|
175
|
+
const extracted = extractMilestoneHeadingName(heading);
|
|
176
|
+
if (extracted === null)
|
|
177
|
+
continue; // no version token on this heading — not a milestone heading
|
|
178
|
+
out.push({
|
|
179
|
+
heading,
|
|
180
|
+
version: extracted.version,
|
|
181
|
+
name: extracted.name,
|
|
182
|
+
closed: isClosedMilestoneHeading(heading),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
// #3216: the ONE textual expression of "level-bounded (h1-h3), phase-excluded
|
|
188
|
+
// heading line" in this file. `listMilestoneHeadings` and
|
|
189
|
+
// `locateMilestoneHeadings` both build their pattern from this constant
|
|
190
|
+
// rather than typing `^#{1,3}\s+(?!Phase\s+\S)` a second time — the exact
|
|
191
|
+
// duplication class ADR-3180 §7.2's widened guard exists to catch.
|
|
192
|
+
const MILESTONE_HEADING_LINE_SOURCE = '^#{1,3}\\s+(?!Phase\\s+\\S)[^\\n]*';
|
|
193
|
+
/**
|
|
194
|
+
* #3184: the sole milestone-heading locator. Boundary-matched on the version
|
|
195
|
+
* token with `\b`, NOT the stricter `(?![\w.-])`: this function keeps `\b`
|
|
196
|
+
* because a milestone STATE legitimately selects its own sub-milestone
|
|
197
|
+
* heading (`v8.0` matching `## v8.0-B …` — `0` is a word char, `-` is not, so
|
|
198
|
+
* `\b` matches) — that is deliberate, load-bearing behavior (#730). The
|
|
199
|
+
* stricter `(?![\w.-])` boundary answers a DIFFERENT question — "is exactly
|
|
200
|
+
* this milestone shipped" (`isMilestoneShippedInRoadmap`) / "which Phase
|
|
201
|
+
* Details section belongs to exactly this one's version token"
|
|
202
|
+
* (`detailsVersionBoundary`) — and applying it here breaks #730 sub-milestone
|
|
203
|
+
* selection. `extractCurrentMilestoneScoped`, `currentMilestoneRawRanges`,
|
|
204
|
+
* and `getMilestonePhaseFilter`'s versionOverride branch all consume this
|
|
205
|
+
* instead of re-deriving their own heading-location regex.
|
|
206
|
+
*
|
|
207
|
+
* #3216: rewritten as a version-FILTERED VIEW over `MILESTONE_HEADING_LINE_SOURCE`
|
|
208
|
+
* — the SAME grammar `listMilestoneHeadings` enumerates — rather than a
|
|
209
|
+
* second expression of it. The returned `RegExpExecArray[]` contract
|
|
210
|
+
* (`m[0] === m[1]`, `m.index` at the heading's start) is byte-for-byte
|
|
211
|
+
* unchanged, so its 4 existing callers are unaffected.
|
|
212
|
+
*/
|
|
213
|
+
function locateMilestoneHeadings(content, version) {
|
|
214
|
+
const escapedVersion = (0, pattern_cjs_1.escapeRegex)(version);
|
|
215
|
+
// ADR-3180 §7.1 locks this boundary as `\b`, not the stricter
|
|
216
|
+
// `(?![\w.-])` — Amendment 2 tried the stricter boundary and reverted it.
|
|
217
|
+
// `\b` alone is what preserves the #730 sub-milestone selection this
|
|
218
|
+
// function owns: `v2.0` still matches inside `v2.0.1`, `v8.0` still
|
|
219
|
+
// matches `## v8.0-B …`.
|
|
220
|
+
const boundary = new RegExp(`${escapedVersion}\\b`, 'i');
|
|
221
|
+
const pattern = new RegExp(`(${MILESTONE_HEADING_LINE_SOURCE})`, 'gmi');
|
|
222
|
+
const matches = [];
|
|
223
|
+
let m;
|
|
224
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
225
|
+
if (boundary.test(m[1]))
|
|
226
|
+
matches.push(m);
|
|
227
|
+
}
|
|
228
|
+
return matches;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* #3184: named predicate replacing the two `state.cts` re-derivations
|
|
232
|
+
* (`buildStateFrontmatter`, `syncStateFrontmatter`) that each hand-rolled the
|
|
233
|
+
* same "is this version bounded to a versioned ROADMAP heading" regex. A
|
|
234
|
+
* straight consolidation of the two identical `state.cts` regexes onto the
|
|
235
|
+
* shared `locateMilestoneHeadings` owner — no behavior change.
|
|
236
|
+
*/
|
|
237
|
+
function isMilestoneBoundedInRoadmap(content, version) {
|
|
238
|
+
return locateMilestoneHeadings(content, version).length > 0;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* #3184: does this ROADMAP carry ANY versioned milestone heading (`v1.2`-style
|
|
242
|
+
* token on a level 1-3 non-Phase heading), independent of any particular
|
|
243
|
+
* version. `extractCurrentMilestoneScoped` (free-form-vs-versioned row 3/4
|
|
244
|
+
* classification) and `getMilestonePhaseFilter` (the deprecation warning +
|
|
245
|
+
* the same row 3/4 classification for its versionOverride branch) each
|
|
246
|
+
* hand-rolled this identically — the guard does not catch intra-owner-file
|
|
247
|
+
* copies by construction, so this was found by review instead.
|
|
248
|
+
*/
|
|
249
|
+
function hasVersionedMilestones(content) {
|
|
250
|
+
return /^#{1,3}\s+.*v\d+\.\d+/mi.test(content);
|
|
251
|
+
}
|
|
252
|
+
// This file's milestone-heading vocabulary: a version token (`v1.2`-style),
|
|
253
|
+
// a ✅/🚧/📋 status marker, or the word "Milestone". Tested against a
|
|
254
|
+
// non-Phase heading's own text by `hasMilestoneSectioning` below — this
|
|
255
|
+
// module's sole owner of "is this heading a milestone heading".
|
|
256
|
+
const MILESTONE_HEADING_SIGNAL_PATTERN = /v\d+\.\d+|✅|📋|🚧|\bMilestone\b/i;
|
|
257
|
+
/**
|
|
258
|
+
* #3184/#3204/#2828/#1761/#3185: could a WHOLE-DOCUMENT phase count conflate
|
|
259
|
+
* two different milestones? That is the only question `buildStateFrontmatter`
|
|
260
|
+
* (`state.cts`) asks its single caller of this predicate.
|
|
261
|
+
*
|
|
262
|
+
* Three prior models were tried, and all three tried to infer milestone-ness
|
|
263
|
+
* from POSITION — where a heading sits relative to other headings — and all
|
|
264
|
+
* three broke a real shape because position does not carry it:
|
|
265
|
+
*
|
|
266
|
+
* 1. "Is there ANY non-Phase level-2/3 heading" (pre-#3184). #3204: a FLAT
|
|
267
|
+
* roadmap carrying one ordinary structural heading (`## Progress`) was
|
|
268
|
+
* misclassified as milestone-sectioned, and `safeToUseRoadmapCount`
|
|
269
|
+
* clobbered a correct ROADMAP-declared count down to the on-disk directory
|
|
270
|
+
* count. Not-Phase-ness was never the right question.
|
|
271
|
+
* 2. "Do >=2 non-Phase headings EACH own a nested (STRICTLY DEEPER) Phase
|
|
272
|
+
* heading" (#3184's rewrite). Two independent review findings broke this:
|
|
273
|
+
* (a) #1761 regression — real sibling milestones are commonly at the SAME
|
|
274
|
+
* level as their own Phase headings (`## v1.0` / `## Phase 1:` / `## v2.0`
|
|
275
|
+
* / `## Phase 3:`), so "strictly deeper" never matches for either sibling
|
|
276
|
+
* and the predicate answers false, letting the whole-document count
|
|
277
|
+
* conflate them exactly as #1761 did. (b) #3204 reintroduced — the
|
|
278
|
+
* bundled greenfield template itself (`gsd-core/templates/roadmap.md:149-171`:
|
|
279
|
+
* `## Phases` -> `### 🚧 v1.1 — …` -> `#### Phase 5: …`) nests a Phase
|
|
280
|
+
* heading arbitrarily deep under EVERY ancestor in the chain, so a
|
|
281
|
+
* generic wrapper heading ("Phases") with no milestone meaning of its own
|
|
282
|
+
* counted as its own candidate section and single-milestone documents
|
|
283
|
+
* were misclassified as sectioned again.
|
|
284
|
+
* 3. "Immediate adjacency, at any level" (interim #3185 rewrite, never
|
|
285
|
+
* shipped past this file's own working tree). Fixed both #3184 defects
|
|
286
|
+
* above, but adjacency is STILL a positional signal, and #3185 reproduced
|
|
287
|
+
* a THIRD shape it cannot see: a flat roadmap where `## Overview` happens
|
|
288
|
+
* to sit immediately before `## Phase 1:` and, independently, `## Notes`
|
|
289
|
+
* sits immediately before `## Phase 4:` later in the same document. Two
|
|
290
|
+
* purely structural headings, zero milestone meaning, each "adjacent" to a
|
|
291
|
+
* Phase heading by coincidence of document layout — ≥2 owners, so the
|
|
292
|
+
* flat 6-phase roadmap was misclassified as sectioned and clobbered to the
|
|
293
|
+
* 2 on-disk phase directories. Same root defect as #3204's `## Progress`,
|
|
294
|
+
* wearing a different heading shape.
|
|
295
|
+
*
|
|
296
|
+
* The model that actually holds for every shape above abandons position
|
|
297
|
+
* entirely and asks about the heading's own text: is it a MILESTONE HEADING —
|
|
298
|
+
* a non-Phase heading at level 1-3 carrying a milestone VOCABULARY signal
|
|
299
|
+
* (a version token, a ✅/🚧/📋 status marker, or the word "Milestone")?
|
|
300
|
+
* Sectioning is present iff there are >=2 such headings — one or zero cannot
|
|
301
|
+
* conflate siblings by construction, no matter where they sit. This resolves
|
|
302
|
+
* every prior failure:
|
|
303
|
+
* - #3204 / this file's `## Progress`: no signal — 0 milestone headings.
|
|
304
|
+
* - #3185 `## Overview` / `## Notes` interleaved with flat phases: neither
|
|
305
|
+
* carries a signal — 0 milestone headings, regardless of adjacency.
|
|
306
|
+
* - #1761 same-level siblings (`## v1.0` / `## v2.0`): each carries a version
|
|
307
|
+
* token — 2 milestone headings, sectioned, no level or adjacency test
|
|
308
|
+
* needed.
|
|
309
|
+
* - #1761 unmarked prose siblings (`## Milestone 1: …` / `## Milestone 2: …`):
|
|
310
|
+
* each carries the word "Milestone" — 2 milestone headings, sectioned.
|
|
311
|
+
* - Bundled template wrapper (`## Phases` -> `### 🚧 v1.1` -> `#### Phase 5:`):
|
|
312
|
+
* `## Phases` carries no signal; `### 🚧 v1.1` carries a marker and a
|
|
313
|
+
* version token but is only ONE heading — 1 milestone heading, not
|
|
314
|
+
* sectioned.
|
|
315
|
+
*
|
|
316
|
+
* Deliberately NOT a denylist of heading names (fragile, unbounded) and NOT
|
|
317
|
+
* collapsed into `hasVersionedMilestones` (a non-versioned-but-marked or
|
|
318
|
+
* "Milestone"-named section still conflates siblings — see that function's
|
|
319
|
+
* own doc comment, which answers a narrower question: ANY version token
|
|
320
|
+
* anywhere, not "are there >=2 independently-signalled milestone headings").
|
|
321
|
+
* Routed through `tokenizeHeadings` (fence- and CRLF-aware, single owner of
|
|
322
|
+
* ATX heading tokenisation) rather than a second regex pass, so a heading
|
|
323
|
+
* inside a fenced code block is never tokenised in the first place and
|
|
324
|
+
* cannot flip this result. The Phase-heading test (`/^Phase\s+\S/i`) is the
|
|
325
|
+
* SAME literal reused by `computeMilestoneSectionEnd` / `locateMilestoneHeadings`
|
|
326
|
+
* above, not a fresh copy. `MILESTONE_HEADING_SIGNAL_PATTERN`'s version-token
|
|
327
|
+
* and marker alternatives mirror the literal fragments already used by
|
|
328
|
+
* `hasVersionedMilestones` (`v\d+\.\d+`) and `computeMilestoneSectionEnd`
|
|
329
|
+
* (`✅|📋|🚧`) rather than inventing a fourth independent copy of the same
|
|
330
|
+
* vocabulary; the "Milestone" word is the one signal none of those three
|
|
331
|
+
* needed and this predicate does.
|
|
332
|
+
*
|
|
333
|
+
* Honest limit: this is a NARROWER signal than any of the three position-based
|
|
334
|
+
* attempts — a heading is only a candidate if its OWN TEXT carries a version
|
|
335
|
+
* token, a status marker, or the word "Milestone". Two milestone sections that
|
|
336
|
+
* carry NONE of the three (e.g. `## First Chapter` / `## Second Chapter`, each
|
|
337
|
+
* with their own Phase headings, no version, no marker, no "Milestone" word)
|
|
338
|
+
* are not detected as sectioned, and the whole-document count is trusted even
|
|
339
|
+
* though it may still conflate them. No fixture in this repo's bundled
|
|
340
|
+
* template or the #3204/#1761/#3185 reports exercises that shape; it is
|
|
341
|
+
* recorded here rather than hidden.
|
|
342
|
+
*/
|
|
343
|
+
function hasMilestoneSectioning(content) {
|
|
344
|
+
const isPhaseHeading = (text) => /^Phase\s+\S/i.test(text);
|
|
345
|
+
let milestoneHeadingCount = 0;
|
|
346
|
+
for (const heading of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)) {
|
|
347
|
+
if (heading.level < 1 || heading.level > 3)
|
|
348
|
+
continue;
|
|
349
|
+
if (isPhaseHeading(heading.text))
|
|
350
|
+
continue;
|
|
351
|
+
if (!MILESTONE_HEADING_SIGNAL_PATTERN.test(heading.text))
|
|
352
|
+
continue;
|
|
353
|
+
milestoneHeadingCount++;
|
|
354
|
+
if (milestoneHeadingCount >= 2)
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* #3184: the sole "which heading is this milestone's" rule — locate the version's
|
|
361
|
+
* headings, prefer the first that is not marked CLOSED/SHIPPED, else fall back to the
|
|
362
|
+
* first match. Returns null when the version has no heading at all.
|
|
363
|
+
*
|
|
364
|
+
* Extracted because three sites had written this same two-line selection
|
|
365
|
+
* independently (sliceMilestoneWindow, extractCurrentMilestoneScoped,
|
|
366
|
+
* currentMilestoneRawRanges) — the composition-level divergence ADR-3180
|
|
367
|
+
* Decision 4(c) covers: calling the owner's primitives and re-assembling the
|
|
368
|
+
* result locally is indistinguishable from re-deriving it.
|
|
369
|
+
*/
|
|
370
|
+
function selectMilestoneHeading(content, version) {
|
|
371
|
+
const matches = locateMilestoneHeadings(content, version);
|
|
372
|
+
if (matches.length === 0)
|
|
373
|
+
return null;
|
|
374
|
+
return matches.find((m) => !isClosedMilestoneHeading(m[1])) ?? matches[0];
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* #3184: the sole "give me this version's window" composition. Delegates
|
|
378
|
+
* heading selection to `selectMilestoneHeading` (the sole selection owner)
|
|
379
|
+
* and then to `computeMilestoneSectionEnd` for the slice. Returns null when
|
|
380
|
+
* the version has no heading at all, so callers can distinguish "no such
|
|
381
|
+
* milestone section" from "empty section".
|
|
382
|
+
*
|
|
383
|
+
* Review finding (post-merge of this phase's first pass): `getMilestonePhaseFilter`'s
|
|
384
|
+
* versionOverride branch and `cmdMilestoneComplete`'s unstarted-phase guard
|
|
385
|
+
* had each independently composed `locateMilestoneHeadings` +
|
|
386
|
+
* `computeMilestoneSectionEnd` into a window — the SAME derivation written
|
|
387
|
+
* twice, and they disagreed (one skipped CLOSED headings, the other did not)
|
|
388
|
+
* — exactly the composition-level divergence ADR-3180 Decision 4(c) warns
|
|
389
|
+
* about: calling the owner and then re-assembling the result locally is
|
|
390
|
+
* indistinguishable from re-deriving it. Both sites now call this instead.
|
|
391
|
+
*/
|
|
392
|
+
function sliceMilestoneWindow(content, version) {
|
|
393
|
+
const selected = selectMilestoneHeading(content, version);
|
|
394
|
+
if (selected === null)
|
|
395
|
+
return null;
|
|
396
|
+
return content.slice(selected.index, computeMilestoneSectionEnd(content, selected[0], selected.index));
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* #3184: counts RAW phase references — a `#{2,4} Phase <id>:` heading
|
|
400
|
+
* (fence-aware via `tokenizeHeadings`) or a `#2199` bullet entry — BEFORE any
|
|
401
|
+
* sentinel filter. Used for BOTH sides of `classifyMilestoneWindow`'s row-8
|
|
402
|
+
* comparison (does the window contain phase entries; does the document).
|
|
403
|
+
* Deliberately does NOT filter `999.x`/Phase 0 sentinels: the question here
|
|
404
|
+
* is "did the window reach the phase region", not "how many real phases
|
|
405
|
+
* exist" — a window containing only sentinel phases still reached the
|
|
406
|
+
* region and must read COMPLETE, not TRUNCATED.
|
|
407
|
+
*/
|
|
408
|
+
function hasPhaseEntries(markdown) {
|
|
409
|
+
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
410
|
+
const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
|
|
411
|
+
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(markdown)) {
|
|
412
|
+
if (h.level < 2 || h.level > 4)
|
|
413
|
+
continue;
|
|
414
|
+
if (phaseHeadingPattern.test(h.text))
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
// #3184 review finding: the bullet fallback must be fence-aware too, or a
|
|
418
|
+
// FENCED markdown EXAMPLE of the `- [ ] **Phase N — Name**` syntax (e.g. a
|
|
419
|
+
// doc showing the convention) counts as a real phase entry. Strip fences
|
|
420
|
+
// through the canonical seam before testing, matching tokenizeHeadings'
|
|
421
|
+
// fence-awareness above.
|
|
422
|
+
if (BULLET_PHASE_LINE_PATTERN.test((0, markdown_sectionizer_cjs_1.stripFencedCode)(markdown).text))
|
|
423
|
+
return true;
|
|
424
|
+
// #3577: a markdown-table phase listing also declares phases.
|
|
425
|
+
return collectTablePhaseRows(markdown).length > 0;
|
|
426
|
+
}
|
|
427
|
+
// ─── #3577: markdown-table phase listings ─────────────────────────────────────
|
|
428
|
+
// #3577: a GFM table declares phases when its header's FIRST cell is the literal
|
|
429
|
+
// `Phase` (optionally `Phase #` / `Phase No.` / `Phase number`) and the header does
|
|
430
|
+
// NOT match a known non-listing schema — the canonical RoadmapProgress table
|
|
431
|
+
// (`| Phase | Plans Complete | Status | Completed |`) leads with `Phase` too, and
|
|
432
|
+
// its rows are progress markers, not declarations. Data rows carry the phase id in
|
|
433
|
+
// their first cell (digit-bearing canonical shape — `Phase`-word header cells and
|
|
434
|
+
// `---` delimiter rows are digit-free and excluded by construction). Fence-aware
|
|
435
|
+
// via stripFencedCode, matching the #3184 lesson: a fenced EXAMPLE of the table
|
|
436
|
+
// form is not a declared phase.
|
|
437
|
+
const PHASE_LISTING_HEADER_RE = /^\|?\s*phase(?:\s*(?:#|no\.?|number))?\s*\|/i;
|
|
438
|
+
const TABLE_PHASE_ID_RE = /^[A-Za-z]?\d[\w.-]*$/;
|
|
439
|
+
function collectTablePhaseRows(window) {
|
|
440
|
+
const unfenced = (0, markdown_sectionizer_cjs_1.stripFencedCode)(window).text;
|
|
441
|
+
const lines = unfenced.split(/\r?\n/);
|
|
442
|
+
const rows = [];
|
|
443
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
444
|
+
if (!PHASE_LISTING_HEADER_RE.test(lines[i]))
|
|
445
|
+
continue;
|
|
446
|
+
const headerCells = (0, markdown_table_cjs_1.splitTableRow)(lines[i]);
|
|
447
|
+
if ((0, markdown_table_cjs_1.matchTableSchema)(headerCells) !== null)
|
|
448
|
+
continue; // canonical non-listing schema
|
|
449
|
+
if (!(0, markdown_table_cjs_1.isDelimiterRow)((0, markdown_table_cjs_1.splitTableRow)(lines[i + 1])))
|
|
450
|
+
continue;
|
|
451
|
+
for (let j = i + 2; j < lines.length; j++) {
|
|
452
|
+
// GFM semantics: the table ENDS at the first line that is not a table
|
|
453
|
+
// row. Review finding: breaking only on blank lines let subsequent prose
|
|
454
|
+
// (e.g. a bare `2026-01-01` date line) be harvested as a phase id.
|
|
455
|
+
if (!/^\s*\|/.test(lines[j]))
|
|
456
|
+
break;
|
|
457
|
+
const cells = (0, markdown_table_cjs_1.splitTableRow)(lines[j]);
|
|
458
|
+
if (cells.length === 0 || cells.every((c) => c === ''))
|
|
459
|
+
break; // defensive: blank row
|
|
460
|
+
const first = cells[0] ?? '';
|
|
461
|
+
if (!TABLE_PHASE_ID_RE.test(first))
|
|
462
|
+
continue;
|
|
463
|
+
if (!/^999\b/.test(first)) {
|
|
464
|
+
rows.push({ id: first, name: cells[1] && cells[1] !== '' ? cells[1] : null, row: lines[j] });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return rows;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* #3262: the sole owner of "which phase ids does THIS milestone window
|
|
472
|
+
* declare". Extracted verbatim from `getMilestonePhaseFilter`'s former inline
|
|
473
|
+
* heading scan + bullet scan so the new `roadmap milestone-scope` probe (the
|
|
474
|
+
* write-time milestone-scope guard's capture/compare signal) reads the SAME
|
|
475
|
+
* derivation the phase filter builds its membership set from — never a second
|
|
476
|
+
* copy of either scan.
|
|
477
|
+
*
|
|
478
|
+
* Fence-aware on both scans (tokenizeHeadings + stripFencedCode), matching
|
|
479
|
+
* `hasPhaseEntries` above: a fenced markdown EXAMPLE of either syntax is not
|
|
480
|
+
* a declared phase.
|
|
481
|
+
*
|
|
482
|
+
* #3185: deliberately NOT isSentinelPhaseId here. That predicate treats a
|
|
483
|
+
* leading 0 as sentinel milestone 0, which would swallow the #2554 decimal
|
|
484
|
+
* phase ids ("00.1" is a real phase, not milestone 0). This scan asks a
|
|
485
|
+
* narrower question — "which phase ids does this window declare" — where only
|
|
486
|
+
* the 999 icebox range is excluded.
|
|
487
|
+
*/
|
|
488
|
+
function scanMilestonePhaseIds(window) {
|
|
489
|
+
const ids = new Set();
|
|
490
|
+
// Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
|
|
491
|
+
// T4 seam migration: phase headings inside fences are excluded automatically.
|
|
492
|
+
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
493
|
+
const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
|
|
494
|
+
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(window)) {
|
|
495
|
+
if (h.level < 2 || h.level > 4)
|
|
496
|
+
continue;
|
|
497
|
+
const pm = phaseHeadingPattern.exec(h.text);
|
|
498
|
+
if (pm && !/^999\b/.test(pm[1]))
|
|
499
|
+
ids.add(pm[1]);
|
|
500
|
+
}
|
|
501
|
+
// #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`)
|
|
502
|
+
// so a bullet-house-style ROADMAP populates the milestone phase set instead of
|
|
503
|
+
// collapsing to a zero-count pass-all filter.
|
|
504
|
+
let bm;
|
|
505
|
+
const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
|
|
506
|
+
const unfenced = (0, markdown_sectionizer_cjs_1.stripFencedCode)(window).text;
|
|
507
|
+
while ((bm = scanner.exec(unfenced)) !== null) {
|
|
508
|
+
if (!/^999\b/.test(bm[1]))
|
|
509
|
+
ids.add(bm[1]);
|
|
510
|
+
}
|
|
511
|
+
// #3577: table-declared ids join the same membership set — the milestone
|
|
512
|
+
// filter must not collapse a table-house-style window to zero-count.
|
|
513
|
+
for (const tr of collectTablePhaseRows(window))
|
|
514
|
+
ids.add(tr.id);
|
|
515
|
+
return ids;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* #3262 (write-time milestone-scope guard): does this free-text value contain
|
|
519
|
+
* a heading line that would TERMINATE the current milestone window if spliced
|
|
520
|
+
* into ROADMAP.md? Returns the offending heading texts (empty array = safe).
|
|
521
|
+
*
|
|
522
|
+
* Mirrors the parser's own terminator vocabulary (`computeMilestoneSectionEnd`):
|
|
523
|
+
* a heading terminates the window when it is level 1-3, is NOT a Phase heading
|
|
524
|
+
* (`/^Phase\s+\S/i` — the phase's OWN numbered heading is existing, correct,
|
|
525
|
+
* load-bearing behavior and is never a violation), and carries a milestone
|
|
526
|
+
* signal. The signal test is the union of `MILESTONE_HEADING_SIGNAL_PATTERN`
|
|
527
|
+
* (this module's "is this heading a milestone heading" vocabulary) and `🔄`
|
|
528
|
+
* (which terminates in `extractCurrentMilestoneScoped`'s own preamble pattern)
|
|
529
|
+
* — deliberately the CONSERVATIVE union: a field value whose line is a level
|
|
530
|
+
* 1-3 heading naming a version, a status marker, or the word "Milestone" is
|
|
531
|
+
* exactly the shape that silently narrows the window, so the guard rejects on
|
|
532
|
+
* any of them rather than re-deriving which specific marker a given roadmap's
|
|
533
|
+
* terminator would fire on.
|
|
534
|
+
*
|
|
535
|
+
* Two deliberate conservatisms, both one-directional (reject more, never less):
|
|
536
|
+
* - `computeMilestoneSectionEnd` also bounds by the milestone heading's own
|
|
537
|
+
* level (a `###` marker only terminates a `###`-level milestone heading);
|
|
538
|
+
* this predicate flags every level 1-3 marker regardless, because which
|
|
539
|
+
* level the active milestone heading uses is a property of the document at
|
|
540
|
+
* write time, not of the text being validated.
|
|
541
|
+
* - level 4+ headings never terminate any window and are not flagged.
|
|
542
|
+
*
|
|
543
|
+
* Fence-aware via `tokenizeHeadings`: a FENCED example of a milestone heading
|
|
544
|
+
* inside a field value does not terminate the real window, so it must not be
|
|
545
|
+
* a violation either — the parser and this guard must agree on fences.
|
|
546
|
+
*/
|
|
547
|
+
function findMilestoneScopeHeadingLines(text) {
|
|
548
|
+
const out = [];
|
|
549
|
+
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(text)) {
|
|
550
|
+
if (h.level > 3)
|
|
551
|
+
continue;
|
|
552
|
+
if (/^Phase\s+\S/i.test(h.text))
|
|
553
|
+
continue;
|
|
554
|
+
if (MILESTONE_HEADING_SIGNAL_PATTERN.test(h.text) || /🔄/.test(h.text)) {
|
|
555
|
+
out.push(h.text.trim());
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* #3184: pure decision table (no I/O, no regex construction from caller
|
|
562
|
+
* data) implementing the design's Behavior table rows 1-8 (the remaining
|
|
563
|
+
* rows 9-17 reduce to one of these six through how the caller constructs its
|
|
564
|
+
* input, not additional branches here). Kernighan's Law fired during design:
|
|
565
|
+
* `getMilestonePhaseFilter` is already cyclomatic 36, so this discriminator
|
|
566
|
+
* is extracted as its own named, separately-testable function rather than
|
|
567
|
+
* inlined.
|
|
568
|
+
*/
|
|
569
|
+
function classifyMilestoneWindow(input) {
|
|
570
|
+
const { readable, versionResolved, hasVersionedMilestones, headingFound, windowHasPhaseEntries, documentHasPhaseEntries } = input;
|
|
571
|
+
return (!readable ? SCOPE.UNREADABLE : // row 2
|
|
572
|
+
!versionResolved && !hasVersionedMilestones ? SCOPE.COMPLETE : // row 3: free-form legacy roadmap
|
|
573
|
+
!versionResolved && hasVersionedMilestones ? SCOPE.UNSCOPED : // row 4
|
|
574
|
+
versionResolved && !headingFound ? SCOPE.UNSCOPED : // row 5
|
|
575
|
+
headingFound && !windowHasPhaseEntries && documentHasPhaseEntries ? SCOPE.TRUNCATED : // row 8
|
|
576
|
+
SCOPE.COMPLETE // rows 6, 7
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Extract the current milestone section from ROADMAP.md by positive lookup,
|
|
581
|
+
* carrying a `scope` discriminator (ADR-3180 Decision 2) alongside the value.
|
|
582
|
+
*
|
|
583
|
+
* @param content - ROADMAP.md content.
|
|
584
|
+
* @param cwd - Project working directory, used to read the companion STATE.md
|
|
585
|
+
* for the current `milestone:` version.
|
|
586
|
+
* @param ws - #2562: workstream name, so the companion STATE.md is read from
|
|
587
|
+
* `.planning/workstreams/<ws>/` instead of the project root. Omitted (the
|
|
588
|
+
* default) preserves the prior `planningDir(cwd)` resolution exactly,
|
|
589
|
+
* including its `GSD_WORKSTREAM` env fallback.
|
|
590
|
+
*
|
|
591
|
+
* #3184: `extractCurrentMilestone`'s CRITICAL blast radius (200+ affected
|
|
592
|
+
* symbols, 20 direct callers) means its signature and return type do not
|
|
593
|
+
* change. This is the real owner; `extractCurrentMilestone` becomes a
|
|
594
|
+
* one-line wrapper returning `.value` so every existing caller is untouched.
|
|
595
|
+
*/
|
|
596
|
+
function extractCurrentMilestoneScoped(content, cwd, ws) {
|
|
597
|
+
if (!cwd) {
|
|
598
|
+
// Row 1: a deliberate unscoped read (no cwd supplied) is a real answer —
|
|
599
|
+
// the caller asked for no scoping, so whole-document is COMPLETE.
|
|
600
|
+
return { value: stripShippedMilestones(content), scope: SCOPE.COMPLETE };
|
|
601
|
+
}
|
|
51
602
|
let version = null;
|
|
52
603
|
try {
|
|
53
|
-
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
604
|
+
const statePath = node_path_1.default.join(planningDir(cwd, ws), 'STATE.md');
|
|
54
605
|
const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
|
|
55
606
|
if (stateRaw !== null) {
|
|
56
607
|
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
@@ -66,12 +617,28 @@ function extractCurrentMilestone(content, cwd) {
|
|
|
66
617
|
version = 'v' + inProgressMatch[1];
|
|
67
618
|
}
|
|
68
619
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
620
|
+
const versionResolved = version !== null;
|
|
621
|
+
// #3184: routed through the shared owner (was an inline copy — see the
|
|
622
|
+
// twin copy in `getMilestonePhaseFilter`, the intra-owner-file duplicate
|
|
623
|
+
// review caught since the drift guard exempts this file by construction).
|
|
624
|
+
const versionedMilestonesPresent = hasVersionedMilestones(content);
|
|
625
|
+
if (!version) {
|
|
626
|
+
const value = stripShippedMilestones(content);
|
|
627
|
+
return {
|
|
628
|
+
value,
|
|
629
|
+
scope: classifyMilestoneWindow({
|
|
630
|
+
readable: true,
|
|
631
|
+
versionResolved,
|
|
632
|
+
hasVersionedMilestones: versionedMilestonesPresent,
|
|
633
|
+
headingFound: false,
|
|
634
|
+
windowHasPhaseEntries: hasPhaseEntries(value),
|
|
635
|
+
documentHasPhaseEntries: hasPhaseEntries(value),
|
|
636
|
+
}),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(content));
|
|
640
|
+
const summaryPattern = new RegExp(`<summary[^>]*>([^<]*${(0, pattern_cjs_1.escapeRegex)(version)}[^<]*)<\\/summary>`, 'i');
|
|
641
|
+
const headingMatches = locateMilestoneHeadings(content, version);
|
|
75
642
|
if (headingMatches.length === 0) {
|
|
76
643
|
const summaryMatch = content.match(summaryPattern);
|
|
77
644
|
if (summaryMatch) {
|
|
@@ -91,41 +658,42 @@ function extractCurrentMilestone(content, cwd) {
|
|
|
91
658
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
92
659
|
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
|
93
660
|
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
|
94
|
-
|
|
661
|
+
const value = preamble + content.slice(detailsOpenIdx, detailsEnd);
|
|
662
|
+
return {
|
|
663
|
+
value,
|
|
664
|
+
scope: classifyMilestoneWindow({
|
|
665
|
+
readable: true,
|
|
666
|
+
versionResolved,
|
|
667
|
+
hasVersionedMilestones: versionedMilestonesPresent,
|
|
668
|
+
headingFound: true,
|
|
669
|
+
windowHasPhaseEntries: hasPhaseEntries(value),
|
|
670
|
+
documentHasPhaseEntries,
|
|
671
|
+
}),
|
|
672
|
+
};
|
|
95
673
|
}
|
|
96
674
|
}
|
|
97
|
-
|
|
675
|
+
const value = stripShippedMilestones(content);
|
|
676
|
+
return {
|
|
677
|
+
value,
|
|
678
|
+
scope: classifyMilestoneWindow({
|
|
679
|
+
readable: true,
|
|
680
|
+
versionResolved,
|
|
681
|
+
hasVersionedMilestones: versionedMilestonesPresent,
|
|
682
|
+
headingFound: false,
|
|
683
|
+
windowHasPhaseEntries: hasPhaseEntries(value),
|
|
684
|
+
documentHasPhaseEntries,
|
|
685
|
+
}),
|
|
686
|
+
};
|
|
98
687
|
}
|
|
99
688
|
const allMatches = headingMatches;
|
|
100
|
-
const
|
|
101
|
-
const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
|
|
102
|
-
const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
|
|
689
|
+
const isClosed = isClosedMilestoneHeading;
|
|
103
690
|
const firstMatch = allMatches[0];
|
|
104
|
-
|
|
691
|
+
// #3184: selection collapses to the sole owner; `allMatches` is still needed
|
|
692
|
+
// below (offsets, detailsMatch search), so only the selection itself routes
|
|
693
|
+
// through `selectMilestoneHeading` rather than the whole block.
|
|
694
|
+
const selected = selectMilestoneHeading(content, version);
|
|
105
695
|
const sectionStart = selected.index;
|
|
106
|
-
const
|
|
107
|
-
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
108
|
-
const afterHeading = headingStart + headingText.length;
|
|
109
|
-
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
|
|
110
|
-
// the next stop boundary without re-implementing fence detection. T4 seam migration.
|
|
111
|
-
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
112
|
-
for (const h of headings) {
|
|
113
|
-
if (h.offset <= headingStart)
|
|
114
|
-
continue;
|
|
115
|
-
if (h.offset < afterHeading)
|
|
116
|
-
continue;
|
|
117
|
-
if (h.level > level)
|
|
118
|
-
continue;
|
|
119
|
-
// Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker
|
|
120
|
-
if (/^Phase\s+\S/i.test(h.text))
|
|
121
|
-
continue;
|
|
122
|
-
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
|
|
123
|
-
continue;
|
|
124
|
-
return h.offset;
|
|
125
|
-
}
|
|
126
|
-
return content.length;
|
|
127
|
-
};
|
|
128
|
-
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
|
|
696
|
+
const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
|
|
129
697
|
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
|
|
130
698
|
const firstMilestoneMatch = content.match(anyMilestonePattern);
|
|
131
699
|
const preambleCutoff = firstMilestoneMatch
|
|
@@ -145,7 +713,7 @@ function extractCurrentMilestone(content, cwd) {
|
|
|
145
713
|
// that share a version prefix do not cross-pollinate. (#730)
|
|
146
714
|
const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
|
|
147
715
|
const detailsVersionBoundary = selectedVersionToken
|
|
148
|
-
? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
|
|
716
|
+
? new RegExp(`${(0, pattern_cjs_1.escapeRegex)(selectedVersionToken)}(?![\\w.-])`, 'i')
|
|
149
717
|
: null;
|
|
150
718
|
let detailsSection = '';
|
|
151
719
|
const detailsMatch = allMatches.find((m) => /\(Phase\s+Details\)/i.test(m[1]) &&
|
|
@@ -154,28 +722,66 @@ function extractCurrentMilestone(content, cwd) {
|
|
|
154
722
|
(m.index ?? 0) >= sectionEnd);
|
|
155
723
|
if (detailsMatch) {
|
|
156
724
|
const detailsStart = detailsMatch.index ?? 0;
|
|
157
|
-
detailsSection = content.slice(detailsStart,
|
|
725
|
+
detailsSection = content.slice(detailsStart, computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart));
|
|
158
726
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
727
|
+
// #2947: the preamble strip removes `### Phase N:` detail headings from the
|
|
728
|
+
// pre-milestone region so they don't duplicate the ones inside the selected
|
|
729
|
+
// milestone section. But when the phase list lives under a non-version-bearing
|
|
730
|
+
// `## Phases` heading (the shipped greenfield template's own shape) and the
|
|
731
|
+
// selected version-bearing heading is a LATER progress/notes sub-heading with
|
|
732
|
+
// NO phase details of its own, stripping the preamble phases silently drops
|
|
733
|
+
// every phase (phase_count: 0, exit 0). Only strip preamble phase details when
|
|
734
|
+
// the selected milestone section actually contains its own — otherwise the
|
|
735
|
+
// preamble phases ARE this milestone's phases and must be preserved.
|
|
736
|
+
const currentSectionHasPhaseDetails = /^#{2,4}\s*Phase\s+\S/im.test(currentSection);
|
|
737
|
+
const preambleBase = (0, markdown_sectionizer_cjs_1.stripTaggedBlocks)(beforeMilestones, 'details');
|
|
738
|
+
// #3235: the conditional wraps the REPLACE, not the pattern. This used to select between the
|
|
739
|
+
// strip regex and a `/$/` sentinel, which made the do-not-strip branch an identity replacement
|
|
740
|
+
// (CodeQL js/identity-replacement, alert 53) -- correct, but it left both branches sharing one
|
|
741
|
+
// replacement argument, so changing `''` would silently give the no-op branch a real effect.
|
|
742
|
+
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
743
|
+
const preambleWithoutPhaseDetails = currentSectionHasPhaseDetails
|
|
744
|
+
? preambleBase.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
|
745
|
+
: preambleBase;
|
|
746
|
+
// Unconditional in BOTH branches -- the #730 `Phase Details` heading strip is independent of
|
|
747
|
+
// whether the selected milestone section carries phase details of its own.
|
|
748
|
+
const preamble = preambleWithoutPhaseDetails.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
|
749
|
+
const value = detailsSection
|
|
164
750
|
? preamble + currentSection + '\n' + detailsSection
|
|
165
751
|
: preamble + currentSection;
|
|
752
|
+
return {
|
|
753
|
+
value,
|
|
754
|
+
scope: classifyMilestoneWindow({
|
|
755
|
+
readable: true,
|
|
756
|
+
versionResolved,
|
|
757
|
+
hasVersionedMilestones: versionedMilestonesPresent,
|
|
758
|
+
headingFound: true,
|
|
759
|
+
windowHasPhaseEntries: hasPhaseEntries(value),
|
|
760
|
+
documentHasPhaseEntries,
|
|
761
|
+
}),
|
|
762
|
+
};
|
|
166
763
|
}
|
|
167
764
|
/**
|
|
168
|
-
*
|
|
765
|
+
* #3184: thin wrapper preserving `extractCurrentMilestone`'s exact signature
|
|
766
|
+
* and return type — CRITICAL blast radius (20 direct callers), so the type
|
|
767
|
+
* stays `string`. `extractCurrentMilestoneScoped` is the real owner; callers
|
|
768
|
+
* that need to branch on scope opt in to it directly.
|
|
169
769
|
*/
|
|
770
|
+
function extractCurrentMilestone(content, cwd, ws) {
|
|
771
|
+
return extractCurrentMilestoneScoped(content, cwd, ws).value;
|
|
772
|
+
}
|
|
170
773
|
function replaceInCurrentMilestone(content, pattern, replacement) {
|
|
774
|
+
const apply = (src) => typeof replacement === 'function'
|
|
775
|
+
? src.replace(pattern, replacement)
|
|
776
|
+
: src.replace(pattern, replacement);
|
|
171
777
|
const lastDetailsClose = content.lastIndexOf('</details>');
|
|
172
778
|
if (lastDetailsClose === -1) {
|
|
173
|
-
return content
|
|
779
|
+
return apply(content);
|
|
174
780
|
}
|
|
175
781
|
const offset = lastDetailsClose + '</details>'.length;
|
|
176
782
|
const before = content.slice(0, offset);
|
|
177
783
|
const after = content.slice(offset);
|
|
178
|
-
return before + after
|
|
784
|
+
return before + apply(after);
|
|
179
785
|
}
|
|
180
786
|
/**
|
|
181
787
|
* Resolve a single phase's detail-section heading (`### Phase N: …`, any level
|
|
@@ -252,6 +858,25 @@ function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
|
|
|
252
858
|
section,
|
|
253
859
|
};
|
|
254
860
|
}
|
|
861
|
+
// #3577: markdown-table row fallback. Mirrors the #2199 bullet fallback's
|
|
862
|
+
// tier — used only AFTER heading and bullet lookups fail on scoped + full
|
|
863
|
+
// content, so a heading with a Requirements/Goal section always wins. The row
|
|
864
|
+
// itself is the section (single line), the name comes from column 2.
|
|
865
|
+
function findRoadmapTablePhaseInContent(content, phaseNum) {
|
|
866
|
+
const wanted = String(phaseNum).replace(/^0+(?=.)/, '');
|
|
867
|
+
for (const tr of collectTablePhaseRows(content)) {
|
|
868
|
+
if (tr.id.replace(/^0+(?=.)/, '') !== wanted)
|
|
869
|
+
continue;
|
|
870
|
+
return {
|
|
871
|
+
found: true,
|
|
872
|
+
phase_number: String(phaseNum),
|
|
873
|
+
phase_name: tr.name ?? `Phase ${tr.id}`,
|
|
874
|
+
goal: null,
|
|
875
|
+
section: tr.row.trim(),
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
255
880
|
function findRoadmapBulletPhaseInContent(content, phaseNum, phaseSource) {
|
|
256
881
|
// #2199: bullet/checkbox entry fallback (`- [ ] **Phase N — name**`). Returns
|
|
257
882
|
// the single bullet line as the section (no multi-line body) — used only as a
|
|
@@ -272,7 +897,8 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
|
272
897
|
if (!phaseNum)
|
|
273
898
|
return null;
|
|
274
899
|
const normalizedPhase = stripProjectCodePrefix(phaseNum);
|
|
275
|
-
|
|
900
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
901
|
+
if (isSentinelPhaseId(normalizedPhase))
|
|
276
902
|
return null;
|
|
277
903
|
// Resolved INSIDE the try for the same reason as getMilestoneInfo below: planningDir
|
|
278
904
|
// throws a plain Error for an invalid GSD_WORKSTREAM/GSD_PROJECT segment, and resolving
|
|
@@ -310,6 +936,13 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
|
310
936
|
if (fullBullet)
|
|
311
937
|
return fullBullet;
|
|
312
938
|
}
|
|
939
|
+
// #3577: last tier — a markdown-table row declaration.
|
|
940
|
+
const scopedTable = findRoadmapTablePhaseInContent(content, phaseNum);
|
|
941
|
+
if (scopedTable)
|
|
942
|
+
return scopedTable;
|
|
943
|
+
const fullTable = findRoadmapTablePhaseInContent(fullContent, phaseNum);
|
|
944
|
+
if (fullTable)
|
|
945
|
+
return fullTable;
|
|
313
946
|
return null;
|
|
314
947
|
}
|
|
315
948
|
catch (err) {
|
|
@@ -339,6 +972,39 @@ function reportUnreadableRoadmap(err, roadmapPath) {
|
|
|
339
972
|
return;
|
|
340
973
|
warnUnusableInput({ reason: UNUSABLE_REASON.ROADMAP_UNREADABLE, source: roadmapPath });
|
|
341
974
|
}
|
|
975
|
+
// ─── Roadmap progress table (#1956/#2012 decoy avoidance) ─────────────────────
|
|
976
|
+
/**
|
|
977
|
+
* Locate ROADMAP.md's "Progress" table — the sole owner of the #2012
|
|
978
|
+
* decoy-avoidance scope for the `drift-guard phase-status` CLI seam (#1956).
|
|
979
|
+
*
|
|
980
|
+
* Scopes to the `## Progress` heading first (level-2, exact case-insensitive
|
|
981
|
+
* text `'progress'`, `{ levelBounded: true }`) via `collectSection` — the
|
|
982
|
+
* same CRLF-safe seam `stateCurrentPositionSlice` (state-document.cts) uses
|
|
983
|
+
* to scope STATE.md's `## Current Position` — so a differently-headed table
|
|
984
|
+
* that happens to share the same column names (e.g. an "Archive Notes"
|
|
985
|
+
* table) is never picked up instead of the real one (#2012). Falls back to
|
|
986
|
+
* scanning the WHOLE document when no `## Progress` heading exists, so a
|
|
987
|
+
* headingless milestone slice (#1445) still resolves rather than going
|
|
988
|
+
* uncheckable — the same fallback `deriveProgressFromRoadmap`
|
|
989
|
+
* (phase-lifecycle.cts) deliberately preserves.
|
|
990
|
+
*
|
|
991
|
+
* `deriveProgressFromRoadmap` independently expresses this same "scope to
|
|
992
|
+
* `## Progress`, else whole document" rule via its own regex-based scope
|
|
993
|
+
* (kept there deliberately rather than refactored onto this function — its
|
|
994
|
+
* blast radius is large). The two locators are therefore separate
|
|
995
|
+
* implementations of the same scoping rule and must agree about WHICH table
|
|
996
|
+
* is the Progress table; a parity test in
|
|
997
|
+
* tests/adr-22-plan-drift-guard.test.cjs asserts they do, per the repo's
|
|
998
|
+
* generative-fix-divergence guard.
|
|
999
|
+
*
|
|
1000
|
+
* Returns the same shape `findTableWithColumns` returns (or `null`).
|
|
1001
|
+
*/
|
|
1002
|
+
function findRoadmapProgressTable(roadmapContent) {
|
|
1003
|
+
const isProgressHeading = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'progress';
|
|
1004
|
+
const section = (0, markdown_sectionizer_cjs_1.collectSection)(roadmapContent, isProgressHeading, { levelBounded: true });
|
|
1005
|
+
const scoped = section ? section.body : roadmapContent;
|
|
1006
|
+
return (0, markdown_table_cjs_1.findTableWithColumns)(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
|
|
1007
|
+
}
|
|
342
1008
|
/**
|
|
343
1009
|
* Strip a leading delimiter run (whitespace, em/en-dash, colon, hyphen) from a
|
|
344
1010
|
* milestone-name capture. Markdown headings commonly take the shape
|
|
@@ -351,6 +1017,89 @@ function reportUnreadableRoadmap(err, roadmapPath) {
|
|
|
351
1017
|
function stripLeadingDelimiter(s) {
|
|
352
1018
|
return s.replace(/^[\s—–:-]+/, '').trim();
|
|
353
1019
|
}
|
|
1020
|
+
/**
|
|
1021
|
+
* #3216 (ADR-3180 §7.2's "Name extraction — pinned rule"): the sole "milestone
|
|
1022
|
+
* heading text → version + curated name" rule. Strips everything through the
|
|
1023
|
+
* heading's OWN version token — NOT necessarily a version a caller is
|
|
1024
|
+
* separately asking about (a `v2.0` STATE selecting a `## v2.0.1 — Portability`
|
|
1025
|
+
* heading yields the name `Portability`, never `.1 — Portability`) — then ONE
|
|
1026
|
+
* leading delimiter and surrounding whitespace via `stripLeadingDelimiter`.
|
|
1027
|
+
* `(` is an ordinary name character and is never a terminator (#3171). Shared
|
|
1028
|
+
* by `listMilestoneHeadings` and `getMilestoneInfo` so this rule has exactly
|
|
1029
|
+
* one implementation. Returns `null` when `headingText` carries no version
|
|
1030
|
+
* token at all (e.g. a non-milestone heading reached this by mistake).
|
|
1031
|
+
*
|
|
1032
|
+
* @param expectedVersion - When the caller already knows the exact version it
|
|
1033
|
+
* is looking for (the STATE-anchored `getMilestoneInfo` path, which located
|
|
1034
|
+
* this heading via `selectMilestoneHeading(roadmap, stateVersion)`), pass it
|
|
1035
|
+
* here so the "own version token" is found by anchoring to that KNOWN
|
|
1036
|
+
* literal (escaped, then extended by the same dash/dot continuation grammar
|
|
1037
|
+
* for the row-16/17 sub-milestone cases) instead of independently
|
|
1038
|
+
* re-deriving a version-shaped pattern from scratch. `listMilestoneHeadings`
|
|
1039
|
+
* (version-agnostic enumeration — no target version exists) omits this and
|
|
1040
|
+
* keeps the generic re-derivation. Anchoring on the known literal is what
|
|
1041
|
+
* makes a hostile STATE `milestone:` value (regex metacharacters, single-
|
|
1042
|
+
* segment `vN`, a literal `$&`/`$1`) resolve correctly: the generic pattern
|
|
1043
|
+
* only recognizes the real GSD version grammar and stops early on anything
|
|
1044
|
+
* outside it, leaving hostile characters in the extracted "name".
|
|
1045
|
+
*/
|
|
1046
|
+
function extractMilestoneHeadingName(headingText, expectedVersion) {
|
|
1047
|
+
const versionMatch = expectedVersion
|
|
1048
|
+
// Anchor to the KNOWN literal version, then extend across any immediate
|
|
1049
|
+
// dash/dot continuation the heading's OWN token carries beyond it (e.g.
|
|
1050
|
+
// requested v8.0 -> heading's own v8.0-B; requested v2.0 -> v2.0.1).
|
|
1051
|
+
// `.match()` here — never `.replace()` — so a `$&`/`$1`-bearing version
|
|
1052
|
+
// is located as a literal substring and never interpreted as a
|
|
1053
|
+
// String.replace() substitution pattern.
|
|
1054
|
+
? headingText.match(new RegExp(`${(0, pattern_cjs_1.escapeRegex)(expectedVersion)}(?:[-.][A-Za-z0-9]+)*`, 'i'))
|
|
1055
|
+
// No known target: re-derive a version-shaped token generically. `v3` /
|
|
1056
|
+
// `v3.3` / `v3.3.3` must all resolve to themselves (§7.2), so the dotted
|
|
1057
|
+
// continuation is zero-or-more, not one-or-more.
|
|
1058
|
+
: headingText.match(/v\d+(?:\.\d+)*(?:[-.][A-Za-z0-9]+)*/i);
|
|
1059
|
+
if (!versionMatch)
|
|
1060
|
+
return null;
|
|
1061
|
+
const version = versionMatch[0];
|
|
1062
|
+
const afterVersion = headingText.slice((versionMatch.index ?? 0) + version.length);
|
|
1063
|
+
// Amendment (§7.2 pinned rule): after stripping the leading delimiter, also
|
|
1064
|
+
// strip a trailing run of status markers (✅ 📋 🚧) plus surrounding
|
|
1065
|
+
// whitespace — the marker is already carried structurally by `closed`, so
|
|
1066
|
+
// duplicating it inside `name` (e.g. "Old ✅") is redundant and wrong. Only
|
|
1067
|
+
// these three markers, only at the end; a marker inside a name is untouched.
|
|
1068
|
+
const name = stripLeadingDelimiter(afterVersion).replace(/\s*(?:[✅📋🚧]\s*)+$/, '') || null;
|
|
1069
|
+
return { version, name };
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* #3216 (epic #3180 §7.2, "Milestone identity"): which milestone is current,
|
|
1073
|
+
* and what is it called. Binds to the canonical `locateMilestoneHeadings` /
|
|
1074
|
+
* `listMilestoneHeadings` / `extractMilestoneHeadingName` owners and deletes
|
|
1075
|
+
* both hand-rolled heading regexes this function used to carry — the
|
|
1076
|
+
* level-blind STATE-version regex (#3197) and the unanchored fallback regex
|
|
1077
|
+
* (#3171) — so the class of defect they produced ("### Phase N: … v3.3 …"
|
|
1078
|
+
* read as milestone `v3.3`; a name truncated at `(`) is structurally
|
|
1079
|
+
* unrepresentable rather than merely fixed on this one copy.
|
|
1080
|
+
*
|
|
1081
|
+
* Never throws (#2245) — the outer try/catch returns `{value: null, scope:
|
|
1082
|
+
* UNREADABLE}` on any failure, preserving `state.cts:1663`'s "this wrapper
|
|
1083
|
+
* could never be triggered" invariant. Absence (ENOENT) is silent (#1881,
|
|
1084
|
+
* ADR-1411); a genuine read fault (e.g. EACCES) still reports via
|
|
1085
|
+
* `reportUnreadableRoadmap`, which discriminates on the errno exactly as
|
|
1086
|
+
* before.
|
|
1087
|
+
*
|
|
1088
|
+
* The `{version:'v1.0', name:'milestone'}` default this function used to
|
|
1089
|
+
* return on every unresolved path is DELETED per §7.2 rule 4 — it was
|
|
1090
|
+
* output-identical to a successful read of a genuine v1.0 project. Every
|
|
1091
|
+
* unresolved path now returns a `scope` other than `COMPLETE` instead.
|
|
1092
|
+
*/
|
|
1093
|
+
/**
|
|
1094
|
+
* #3216 review Finding 2: `getMilestoneInfo`'s `{ value, scope }` return shape
|
|
1095
|
+
* was hand-built as an inline object literal at every return point — factored
|
|
1096
|
+
* out once so the shape itself cannot drift between call sites. Purely a
|
|
1097
|
+
* literal-shape constructor: does not decide, validate, or alter any value or
|
|
1098
|
+
* scope — every per-branch rationale comment stays exactly where it was.
|
|
1099
|
+
*/
|
|
1100
|
+
function scoped(value, scope) {
|
|
1101
|
+
return { value, scope };
|
|
1102
|
+
}
|
|
354
1103
|
function getMilestoneInfo(cwd) {
|
|
355
1104
|
// Declared here but RESOLVED INSIDE the try, so the catch can name the file without
|
|
356
1105
|
// moving planningDir() out of the protected region. planningDir throws a plain Error
|
|
@@ -360,6 +1109,8 @@ function getMilestoneInfo(cwd) {
|
|
|
360
1109
|
// skipped and the default is returned exactly as before.
|
|
361
1110
|
let roadmapPath;
|
|
362
1111
|
try {
|
|
1112
|
+
if (!cwd)
|
|
1113
|
+
return scoped(null, SCOPE.UNREADABLE);
|
|
363
1114
|
roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
364
1115
|
const roadmap = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
365
1116
|
if (roadmap === null)
|
|
@@ -384,60 +1135,96 @@ function getMilestoneInfo(cwd) {
|
|
|
384
1135
|
}
|
|
385
1136
|
}
|
|
386
1137
|
if (stateVersion) {
|
|
387
|
-
const escapedVer = escapeRegex(stateVersion);
|
|
1138
|
+
const escapedVer = (0, pattern_cjs_1.escapeRegex)(stateVersion);
|
|
388
1139
|
// #2135: consult the 🚧 name-bearing marker FIRST. It is the only construct
|
|
389
1140
|
// guaranteed to carry the milestone's curated name adjacent to its version
|
|
390
1141
|
// (the active-milestone bullet). A `##` heading is often nameless
|
|
391
1142
|
// ("## vX.Y — Active Milestone") and, when unanchored, was matched
|
|
392
1143
|
// spuriously on a copy quoted inside backticks in this very bullet.
|
|
393
|
-
|
|
1144
|
+
// #3216 fix (progressMarkerBulletIsConsultedBeforeHeading): the version
|
|
1145
|
+
// is commonly wrapped in its OWN bold pair — `🚧 **v3.3** Name` — so a
|
|
1146
|
+
// trailing `\*?\*?` after the version (mirroring the leading one) is
|
|
1147
|
+
// required before the `\s+` that anchors the name capture; without it
|
|
1148
|
+
// the closing `**` sits between the version and the required whitespace
|
|
1149
|
+
// and the whole match fails, silently falling through to the heading.
|
|
1150
|
+
const listMatch = roadmap.match(new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\*?\\*?\\s+([^*\\n]+)`, 'i'));
|
|
394
1151
|
if (listMatch) {
|
|
395
1152
|
const name = stripLeadingDelimiter(listMatch[1]);
|
|
396
1153
|
if (name)
|
|
397
|
-
return { version: stateVersion, name };
|
|
1154
|
+
return scoped({ version: stateVersion, name }, SCOPE.COMPLETE);
|
|
398
1155
|
}
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
1156
|
+
// #3216: heading selection routes through the shared owner
|
|
1157
|
+
// (`selectMilestoneHeading` — locate → prefer-non-closed, mirroring
|
|
1158
|
+
// `sliceMilestoneWindow`), deleting the level-blind `^##…` regex (#3197)
|
|
1159
|
+
// and the unanchored `[:\s]+([^\n(]+)` name capture that truncated at a
|
|
1160
|
+
// parenthetical (#3171). A CLOSED/shipped heading is not "current" (row
|
|
1161
|
+
// 5) — it falls through to the TRUNCATED return below exactly as a
|
|
1162
|
+
// missing heading would.
|
|
1163
|
+
const selected = selectMilestoneHeading(roadmap, stateVersion);
|
|
1164
|
+
if (selected) {
|
|
1165
|
+
const headingText = selected[1].replace(/^#{1,3}\s+/, '');
|
|
1166
|
+
if (!isClosedMilestoneHeading(headingText)) {
|
|
1167
|
+
// #3216 fix: pass the KNOWN stateVersion so name extraction anchors
|
|
1168
|
+
// to it (see extractMilestoneHeadingName's `expectedVersion` doc) —
|
|
1169
|
+
// fixes single-segment versions (`v3`, no dot) and hostile STATE
|
|
1170
|
+
// values (regex metacharacters, literal `$&`/`$1`) that the generic
|
|
1171
|
+
// re-derivation used by listMilestoneHeadings cannot recognize.
|
|
1172
|
+
const extracted = extractMilestoneHeadingName(headingText, stateVersion);
|
|
1173
|
+
if (extracted && extracted.name) {
|
|
1174
|
+
return scoped({ version: stateVersion, name: extracted.name }, SCOPE.COMPLETE);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
409
1177
|
}
|
|
410
|
-
|
|
1178
|
+
// Version is known (STATE.md), but no name-bearing evidence resolved:
|
|
1179
|
+
// no 🚧 bullet, no usable heading (absent, phase-only-excluded, shipped,
|
|
1180
|
+
// or heading-but-nameless). §7.2 rule 4 — never fabricate a name.
|
|
1181
|
+
return scoped({ version: stateVersion, name: null }, SCOPE.TRUNCATED);
|
|
411
1182
|
}
|
|
1183
|
+
// No STATE.md version. The 🚧 in-progress bullet is still consulted first
|
|
1184
|
+
// (unchanged from the pre-#3216 fallback).
|
|
412
1185
|
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
|
413
1186
|
if (inProgressMatch) {
|
|
414
|
-
return {
|
|
415
|
-
version: 'v' + inProgressMatch[1],
|
|
416
|
-
name: inProgressMatch[2].trim(),
|
|
417
|
-
};
|
|
1187
|
+
return scoped({ version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim() }, SCOPE.COMPLETE);
|
|
418
1188
|
}
|
|
1189
|
+
// #3216: enumerate every OPEN (non-shipped) milestone heading via the
|
|
1190
|
+
// shared owner and take the first in document order — deletes the
|
|
1191
|
+
// unanchored `/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/` fallback
|
|
1192
|
+
// regex (#3171/#3197), whose `## ` prefix matched starting at the SECOND
|
|
1193
|
+
// `#` of a `### Phase N: …` heading.
|
|
419
1194
|
const cleaned = stripShippedMilestones(roadmap);
|
|
420
|
-
const
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
name:
|
|
425
|
-
}
|
|
1195
|
+
const openHeadings = listMilestoneHeadings(cleaned).filter((h) => !h.closed);
|
|
1196
|
+
if (openHeadings.length > 0) {
|
|
1197
|
+
const first = openHeadings[0];
|
|
1198
|
+
if (first.name) {
|
|
1199
|
+
return scoped({ version: first.version, name: first.name }, SCOPE.COMPLETE);
|
|
1200
|
+
}
|
|
1201
|
+
return scoped({ version: first.version, name: null }, SCOPE.TRUNCATED);
|
|
426
1202
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
1203
|
+
// No usable milestone heading anywhere. A version token mentioned ONLY
|
|
1204
|
+
// inside an excluded `### Phase N: … vX.Y …` heading is not evidence
|
|
1205
|
+
// (#3197) and must not be reported as if it were a real version — value
|
|
1206
|
+
// stays null, scope UNSCOPED. A version token mentioned OUTSIDE any Phase
|
|
1207
|
+
// heading (prose, a bullet, a non-milestone heading) is weak-but-real
|
|
1208
|
+
// evidence — version retained, name null, scope TRUNCATED.
|
|
1209
|
+
const withoutPhaseHeadingLines = cleaned.replace(/^#{1,4}\s*Phase\s+\S[^\n]*$/gim, '');
|
|
1210
|
+
const bareVersionMatch = withoutPhaseHeadingLines.match(/v\d+(?:\.\d+)+/i);
|
|
1211
|
+
if (bareVersionMatch) {
|
|
1212
|
+
return scoped({ version: bareVersionMatch[0], name: null }, SCOPE.TRUNCATED);
|
|
1213
|
+
}
|
|
1214
|
+
// Free-form legacy ROADMAP with no version anywhere reachable, OR the
|
|
1215
|
+
// only version-bearing heading was a `### Phase N` heading. §7.1's
|
|
1216
|
+
// "free-form is COMPLETE" governs WINDOWING (whole document is the
|
|
1217
|
+
// window); identity has no version to report and must not invent one.
|
|
1218
|
+
return scoped(null, SCOPE.UNSCOPED);
|
|
432
1219
|
}
|
|
433
1220
|
catch (err) {
|
|
434
1221
|
// This function has no existsSync guard, so an absent ROADMAP arrives here too, as a
|
|
435
|
-
// synthetic Error with no errno. Only a real read fault is reported;
|
|
436
|
-
//
|
|
437
|
-
//
|
|
1222
|
+
// synthetic Error with no errno. Only a real read fault is reported; `value: null` is
|
|
1223
|
+
// returned unchanged either way, and a plausible-looking default needs the diagnostic
|
|
1224
|
+
// more than an empty sentinel does, not less (ADR-1411).
|
|
438
1225
|
if (roadmapPath !== undefined)
|
|
439
1226
|
reportUnreadableRoadmap(err, roadmapPath);
|
|
440
|
-
return
|
|
1227
|
+
return scoped(null, SCOPE.UNREADABLE);
|
|
441
1228
|
}
|
|
442
1229
|
}
|
|
443
1230
|
/**
|
|
@@ -452,17 +1239,35 @@ function getMilestoneInfo(cwd) {
|
|
|
452
1239
|
* free-form ROADMAPs that lack versioned milestone headings. When absent or
|
|
453
1240
|
* any other value, the warning is suppressed — legacy/default projects must
|
|
454
1241
|
* never see spurious warnings.
|
|
1242
|
+
* @param ws - #2562: workstream name, so the ROADMAP/STATE pair is read from
|
|
1243
|
+
* `.planning/workstreams/<ws>/` instead of the project root. Required by any
|
|
1244
|
+
* caller that iterates workstreams (it cannot set `GSD_WORKSTREAM` per
|
|
1245
|
+
* iteration). Omitted (the default) preserves the prior `planningDir(cwd)`
|
|
1246
|
+
* resolution exactly, including its `GSD_WORKSTREAM` env fallback — every
|
|
1247
|
+
* pre-#2562 call site is unaffected.
|
|
455
1248
|
*/
|
|
456
|
-
function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
1249
|
+
function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention, ws) {
|
|
457
1250
|
const milestonePhaseNums = new Set();
|
|
458
1251
|
let missingExplicitVersion = false;
|
|
1252
|
+
let versionScoped = false;
|
|
1253
|
+
let versionSectionFound = false;
|
|
1254
|
+
let scope = SCOPE.UNREADABLE;
|
|
459
1255
|
try {
|
|
460
|
-
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1256
|
+
const roadmapPath = node_path_1.default.join(planningDir(cwd, ws), 'ROADMAP.md');
|
|
461
1257
|
const roadmapContent = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
462
1258
|
if (roadmapContent === null)
|
|
463
1259
|
throw new Error('missing');
|
|
464
|
-
|
|
465
|
-
|
|
1260
|
+
const scopedResult = extractCurrentMilestoneScoped(roadmapContent, cwd, ws);
|
|
1261
|
+
let roadmap = scopedResult.value;
|
|
1262
|
+
// Default: the filter's window IS extractCurrentMilestoneScoped's own
|
|
1263
|
+
// window (reused verbatim, not re-derived — ADR-3180 Decision 4c).
|
|
1264
|
+
// Overwritten below when `versionOverride` scopes to a DIFFERENT window.
|
|
1265
|
+
scope = scopedResult.scope;
|
|
1266
|
+
// #3184: routed through the shared owner (was an inline copy — see the
|
|
1267
|
+
// twin copy in `extractCurrentMilestoneScoped`, the intra-owner-file
|
|
1268
|
+
// duplicate review caught since the drift guard exempts this file by
|
|
1269
|
+
// construction).
|
|
1270
|
+
const hasVersionedMilestonesGlobal = hasVersionedMilestones(roadmapContent);
|
|
466
1271
|
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
|
|
467
1272
|
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
|
|
468
1273
|
console.warn('[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
|
|
@@ -470,74 +1275,52 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
470
1275
|
'ROADMAP does not use versioned milestone headings. Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate (dry-run by default).');
|
|
471
1276
|
}
|
|
472
1277
|
if (versionOverride) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
1278
|
+
// #3184: route the whole "locate headings -> pick the active one ->
|
|
1279
|
+
// section-end" composition through the single owner (sliceMilestoneWindow)
|
|
1280
|
+
// instead of assembling it here. This branch used to be a bare `.match()`
|
|
1281
|
+
// — first hit, no closed-heading skip, no version-token boundary — and a
|
|
1282
|
+
// review pass caught it independently re-composing the SAME primitives
|
|
1283
|
+
// `cmdMilestoneComplete`'s guard composed, disagreeing on closed-heading
|
|
1284
|
+
// skipping. Now both sites call one function. Boundary-matched
|
|
1285
|
+
// (`(?![\w.-])`) and closed-heading-skipping is a declared Tier-2 change
|
|
1286
|
+
// affecting every caller that passes `versionOverride`: `roadmap.analyze`
|
|
1287
|
+
// / `milestone complete` (this module, `cmdMilestoneComplete` in
|
|
1288
|
+
// milestone.cts), `inspectWorkstream` (workstream-inventory.cts:518,
|
|
1289
|
+
// via `currentVersion`), and `buildStateFrontmatter` (state.cts:1700,
|
|
1290
|
+
// via `storedMilestone`).
|
|
1291
|
+
const sliced = sliceMilestoneWindow(roadmapContent, versionOverride);
|
|
1292
|
+
const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(roadmapContent));
|
|
1293
|
+
if (sliced !== null) {
|
|
1294
|
+
versionScoped = true;
|
|
1295
|
+
versionSectionFound = true;
|
|
1296
|
+
roadmap = sliced;
|
|
486
1297
|
}
|
|
487
|
-
|
|
488
|
-
const
|
|
1298
|
+
else {
|
|
1299
|
+
const escapedVersion = (0, pattern_cjs_1.escapeRegex)(versionOverride);
|
|
489
1300
|
const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
|
|
490
|
-
if (
|
|
1301
|
+
if (hasVersionedMilestonesGlobal && !versionInSummary) {
|
|
491
1302
|
roadmap = '';
|
|
492
1303
|
missingExplicitVersion = true;
|
|
493
1304
|
}
|
|
1305
|
+
// else: version appears only inside a `<summary>`, or there are no
|
|
1306
|
+
// versioned milestones anywhere — `roadmap` keeps
|
|
1307
|
+
// extractCurrentMilestoneScoped's own (STATE-scoped) result, matching
|
|
1308
|
+
// the pre-existing summary-block / free-form fallback shape.
|
|
494
1309
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
for (const h of allHeadings) {
|
|
504
|
-
if (h.offset < afterHeading)
|
|
505
|
-
continue;
|
|
506
|
-
if (h.level > headingLevel)
|
|
507
|
-
continue;
|
|
508
|
-
if (/^Phase\s+\S/i.test(h.text))
|
|
509
|
-
continue;
|
|
510
|
-
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
|
|
511
|
-
continue;
|
|
512
|
-
sectionEnd = h.offset;
|
|
513
|
-
break;
|
|
514
|
-
}
|
|
515
|
-
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
|
|
516
|
-
roadmap = currentSection;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
// Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
|
|
520
|
-
// T4 seam migration: phase headings inside fences are excluded automatically.
|
|
521
|
-
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
522
|
-
const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
|
|
523
|
-
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmap)) {
|
|
524
|
-
if (h.level < 2 || h.level > 4)
|
|
525
|
-
continue;
|
|
526
|
-
const pm = phaseHeadingPattern.exec(h.text);
|
|
527
|
-
// Exclude 999.x backlog phases from milestone phase set. Mirrors init.cts filter.
|
|
528
|
-
if (pm && !/^999\b/.test(pm[1]))
|
|
529
|
-
milestonePhaseNums.add(pm[1]);
|
|
1310
|
+
scope = classifyMilestoneWindow({
|
|
1311
|
+
readable: true,
|
|
1312
|
+
versionResolved: true,
|
|
1313
|
+
hasVersionedMilestones: hasVersionedMilestonesGlobal,
|
|
1314
|
+
headingFound: sliced !== null,
|
|
1315
|
+
windowHasPhaseEntries: hasPhaseEntries(roadmap),
|
|
1316
|
+
documentHasPhaseEntries,
|
|
1317
|
+
});
|
|
530
1318
|
}
|
|
531
|
-
// #
|
|
532
|
-
// so
|
|
533
|
-
//
|
|
534
|
-
{
|
|
535
|
-
|
|
536
|
-
const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
|
|
537
|
-
while ((bm = scanner.exec(roadmap)) !== null) {
|
|
538
|
-
if (!/^999\b/.test(bm[1]))
|
|
539
|
-
milestonePhaseNums.add(bm[1]);
|
|
540
|
-
}
|
|
1319
|
+
// #3262: the set-building scan now lives in its own named owner
|
|
1320
|
+
// (`scanMilestonePhaseIds`) so the new `roadmap milestone-scope` probe
|
|
1321
|
+
// reads the SAME derivation this filter does — never a second copy.
|
|
1322
|
+
for (const id of scanMilestonePhaseIds(roadmap)) {
|
|
1323
|
+
milestonePhaseNums.add(id);
|
|
541
1324
|
}
|
|
542
1325
|
}
|
|
543
1326
|
catch {
|
|
@@ -546,19 +1329,37 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
546
1329
|
* any failure milestonePhaseNums stays empty, which below already
|
|
547
1330
|
* degrades to the same pass-all filter this function returns when a
|
|
548
1331
|
* ROADMAP genuinely has zero recognizable phase headings — a safe,
|
|
549
|
-
* non-corrupting (over-inclusive, never under-inclusive) degrade.
|
|
1332
|
+
* non-corrupting (over-inclusive, never under-inclusive) degrade.
|
|
1333
|
+
* #3184: `scope` was set to SCOPE.UNREADABLE before the try (row 2) and
|
|
1334
|
+
* is left as-is here — the read/parse fault IS the unreadable case. */
|
|
550
1335
|
}
|
|
551
1336
|
if (milestonePhaseNums.size === 0) {
|
|
552
1337
|
const passAll = (() => true);
|
|
553
1338
|
passAll.phaseCount = 0;
|
|
554
1339
|
passAll.missingExplicitVersion = missingExplicitVersion;
|
|
1340
|
+
passAll.versionScoped = false;
|
|
1341
|
+
// #2562: preserved through the pass-all degrade precisely BECAUSE
|
|
1342
|
+
// `versionScoped` is reset here — this is the only surviving evidence that
|
|
1343
|
+
// the current milestone exists in the ROADMAP and simply has no phases yet.
|
|
1344
|
+
passAll.versionSectionFound = versionSectionFound;
|
|
1345
|
+
// #3184: the filter's FUNCTION behavior is unchanged — pass-all still
|
|
1346
|
+
// passes all. `scope` is the decidable signal a destructive consumer
|
|
1347
|
+
// reads to refuse instead (ADR-3180 Decision 3's two-tier policy).
|
|
1348
|
+
passAll.scope = scope;
|
|
555
1349
|
return passAll;
|
|
556
1350
|
}
|
|
557
|
-
const normalized = new Set([...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase()));
|
|
558
1351
|
function normalizePhaseIdSegments(id) {
|
|
559
1352
|
return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
|
|
560
1353
|
}
|
|
1354
|
+
// #2562: derive BOTH sides of every membership comparison from
|
|
1355
|
+
// normalizePhaseIdSegments. This set previously inlined a byte-identical
|
|
1356
|
+
// second copy of that logic — the drift-prone shape this issue is about.
|
|
1357
|
+
const normalized = new Set([...milestonePhaseNums].map(n => normalizePhaseIdSegments(n).toLowerCase()));
|
|
561
1358
|
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
|
|
1359
|
+
// #3213: longest-first so a hyphenated declared ID (e.g. "proj-42") is tested
|
|
1360
|
+
// before a prefix of it (e.g. "proj") in the segment-boundary membership loop
|
|
1361
|
+
// below — otherwise the shorter id would admit a dir that belongs to the longer.
|
|
1362
|
+
const normalizedIdsLongestFirst = [...normalized].sort((a, b) => b.length - a.length);
|
|
562
1363
|
// #2043: milestone-prefixed sub-phase components must be zero-padded — so a
|
|
563
1364
|
// single-digit slug word after the phase
|
|
564
1365
|
// number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
|
|
@@ -567,26 +1368,63 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
567
1368
|
// "14-2026-photos-…") captures "14" and is not excluded as a bogus "14-2026" id.
|
|
568
1369
|
// Built via new RegExp (no /i — the [A-Za-z] letter class does real case handling).
|
|
569
1370
|
const numericRe = roadmapUsesHyphenedIds
|
|
570
|
-
? new RegExp(`^0*(\\d+(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}
|
|
1371
|
+
? new RegExp(`^0*(\\d+[A-Za-z]?(?:-${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?)*(?:\\.\\d+)*)(?=-|$)`)
|
|
571
1372
|
// phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
|
|
572
1373
|
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
|
|
573
1374
|
function isDirInMilestone(dirName) {
|
|
574
1375
|
const m2 = dirName.match(numericRe);
|
|
575
1376
|
if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase()))
|
|
576
1377
|
return true;
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1378
|
+
// #3213: segment-boundary membership test, scoped to LETTER-LEADING (custom-
|
|
1379
|
+
// ID) directories only. The prior greedy capture
|
|
1380
|
+
// `^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)` swallowed the WHOLE hyphenated
|
|
1381
|
+
// directory name (A-tool-output-contract was captured as
|
|
1382
|
+
// "A-tool-output-contract", not "A"), so every letter-named phase directory
|
|
1383
|
+
// (Phase A:..Phase L: — GSD's own convention, ADR-612 first-class non-numeric
|
|
1384
|
+
// IDs) fell out of the milestone and counts were silently fabricated over
|
|
1385
|
+
// whatever numeric directory survived. A letter-leading directory belongs if
|
|
1386
|
+
// its lowercased name EQUALS a declared phase ID, or BEGINS with that ID
|
|
1387
|
+
// followed by "-" (so "A-tool-output-contract" matches ID "a";
|
|
1388
|
+
// "PROJ-42-description" matches ID "proj-42"; "AB-combined" does NOT match
|
|
1389
|
+
// "a"). SCOPED TO LETTER-LEADING DIRS because numeric dirs are owned by
|
|
1390
|
+
// numericRe above, which respects the #2232 continuation grammar — a bare
|
|
1391
|
+
// startsWith here would wrongly admit "14-02-photos-…" to phase "14" when its
|
|
1392
|
+
// real token is "14-02" (continuation-absorbed, not declared).
|
|
1393
|
+
if (/^[A-Za-z]/.test(dirName)) {
|
|
1394
|
+
const lowerDir = dirName.toLowerCase();
|
|
1395
|
+
for (const id of normalizedIdsLongestFirst) {
|
|
1396
|
+
if (lowerDir === id || lowerDir.startsWith(id + '-'))
|
|
1397
|
+
return true;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
580
1400
|
const stripped = stripProjectCodePrefix(dirName);
|
|
581
1401
|
if (stripped !== dirName) {
|
|
582
1402
|
const sm = stripped.match(numericRe);
|
|
583
1403
|
if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase()))
|
|
584
1404
|
return true;
|
|
585
1405
|
}
|
|
1406
|
+
// #3185: last resort — ask the CANONICAL phase-id token extractor. The
|
|
1407
|
+
// three attempts above are all leading-DIGIT or bare-alnum shapes, so none
|
|
1408
|
+
// of them can match a #1324 letter-prefixed-DECIMAL directory
|
|
1409
|
+
// (`P0.0-foundation`) against its own `### Phase P0.0:` heading: numericRe
|
|
1410
|
+
// needs a leading digit, `customMatch` stops at the `.` and yields `P0`,
|
|
1411
|
+
// and stripProjectCodePrefix needs a dash before the digit. The observable
|
|
1412
|
+
// symptom was `stats` reporting such a phase with plans: 0 while its
|
|
1413
|
+
// directory held plan files, because the heading seeded the row but the
|
|
1414
|
+
// directory never folded in. extractPhaseToken is #2121's single owner of
|
|
1415
|
+
// "what is this directory's phase token", so this defers to it rather than
|
|
1416
|
+
// widening a fourth bespoke regex here. Additive: it can only ADMIT a
|
|
1417
|
+
// directory, never exclude one the attempts above already matched.
|
|
1418
|
+
const token = extractPhaseToken(dirName);
|
|
1419
|
+
if (token && normalized.has(normalizePhaseIdSegments(String(token)).toLowerCase()))
|
|
1420
|
+
return true;
|
|
586
1421
|
return false;
|
|
587
1422
|
}
|
|
588
1423
|
isDirInMilestone.phaseCount = milestonePhaseNums.size;
|
|
589
1424
|
isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
|
|
1425
|
+
isDirInMilestone.versionScoped = versionScoped;
|
|
1426
|
+
isDirInMilestone.versionSectionFound = versionSectionFound;
|
|
1427
|
+
isDirInMilestone.scope = scope;
|
|
590
1428
|
return isDirInMilestone;
|
|
591
1429
|
}
|
|
592
1430
|
/**
|
|
@@ -595,12 +1433,12 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
595
1433
|
* writer) so they cannot touch a backticked prose literal, a Backlog entry, or a
|
|
596
1434
|
* same-numbered phase in a shipped milestone.
|
|
597
1435
|
*
|
|
598
|
-
* Mirrors the region selection in `
|
|
599
|
-
* active heading → next milestone boundary → optional Phase
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
1436
|
+
* Mirrors the region selection in `extractCurrentMilestoneScoped` (version
|
|
1437
|
+
* detection → active heading → next milestone boundary → optional Phase
|
|
1438
|
+
* Details section) — both consume the same `locateMilestoneHeadings` /
|
|
1439
|
+
* `computeMilestoneSectionEnd` owner (#3184), so there is no separate copy to
|
|
1440
|
+
* keep in sync. Returns null when there is no versioned active milestone;
|
|
1441
|
+
* callers then fall back to whole-content mutation (the prior behaviour).
|
|
604
1442
|
*/
|
|
605
1443
|
function currentMilestoneRawRanges(content, cwd) {
|
|
606
1444
|
if (!cwd)
|
|
@@ -623,39 +1461,18 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
623
1461
|
}
|
|
624
1462
|
if (!version)
|
|
625
1463
|
return null;
|
|
626
|
-
const
|
|
627
|
-
const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`, 'gmi');
|
|
628
|
-
const headingMatches = [...content.matchAll(sectionPattern)];
|
|
1464
|
+
const headingMatches = locateMilestoneHeadings(content, version);
|
|
629
1465
|
if (headingMatches.length === 0)
|
|
630
1466
|
return null;
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const
|
|
635
|
-
const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
|
|
1467
|
+
const isClosed = isClosedMilestoneHeading;
|
|
1468
|
+
// #3184: selection collapses to the sole owner; `headingMatches` is still
|
|
1469
|
+
// needed below for the detailsMatch search over all headings.
|
|
1470
|
+
const selected = selectMilestoneHeading(content, version);
|
|
636
1471
|
const sectionStart = selected.index ?? 0;
|
|
637
|
-
const
|
|
638
|
-
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
639
|
-
const afterHeading = headingStart + headingText.length;
|
|
640
|
-
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content)) {
|
|
641
|
-
if (h.offset <= headingStart)
|
|
642
|
-
continue;
|
|
643
|
-
if (h.offset < afterHeading)
|
|
644
|
-
continue;
|
|
645
|
-
if (h.level > level)
|
|
646
|
-
continue;
|
|
647
|
-
if (/^Phase\s+\S/i.test(h.text))
|
|
648
|
-
continue;
|
|
649
|
-
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
|
|
650
|
-
continue;
|
|
651
|
-
return h.offset;
|
|
652
|
-
}
|
|
653
|
-
return content.length;
|
|
654
|
-
};
|
|
655
|
-
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
|
|
1472
|
+
const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
|
|
656
1473
|
const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
|
|
657
1474
|
const detailsVersionBoundary = selectedVersionToken
|
|
658
|
-
? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
|
|
1475
|
+
? new RegExp(`${(0, pattern_cjs_1.escapeRegex)(selectedVersionToken)}(?![\\w.-])`, 'i')
|
|
659
1476
|
: null;
|
|
660
1477
|
const detailsMatch = headingMatches.find((m) => /\(Phase\s+Details\)/i.test(m[1]) &&
|
|
661
1478
|
!isClosed(m[1]) &&
|
|
@@ -664,17 +1481,41 @@ function currentMilestoneRawRanges(content, cwd) {
|
|
|
664
1481
|
let details = null;
|
|
665
1482
|
if (detailsMatch) {
|
|
666
1483
|
const detailsStart = detailsMatch.index ?? 0;
|
|
667
|
-
details = { start: detailsStart, end:
|
|
1484
|
+
details = { start: detailsStart, end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart) };
|
|
668
1485
|
}
|
|
669
1486
|
return { primary: { start: sectionStart, end: sectionEnd }, details };
|
|
670
1487
|
}
|
|
671
1488
|
module.exports = {
|
|
672
1489
|
stripShippedMilestones,
|
|
673
1490
|
extractCurrentMilestone,
|
|
1491
|
+
extractCurrentMilestoneScoped,
|
|
1492
|
+
isMilestoneShippedInRoadmap,
|
|
1493
|
+
isMilestoneBoundedInRoadmap,
|
|
674
1494
|
replaceInCurrentMilestone,
|
|
675
1495
|
getRoadmapPhaseInternal,
|
|
676
1496
|
getMilestoneInfo,
|
|
677
1497
|
getMilestonePhaseFilter,
|
|
678
1498
|
currentMilestoneRawRanges,
|
|
679
1499
|
withPhaseSection,
|
|
1500
|
+
computeMilestoneSectionEnd,
|
|
1501
|
+
locateMilestoneHeadings,
|
|
1502
|
+
listMilestoneHeadings,
|
|
1503
|
+
selectMilestoneHeading,
|
|
1504
|
+
classifyMilestoneWindow,
|
|
1505
|
+
// #3184: the sole "give me this version's window" composition — see its
|
|
1506
|
+
// own doc comment. milestone.cts's destructive-consumer guard consumes
|
|
1507
|
+
// this instead of composing locate+select+section-end itself.
|
|
1508
|
+
sliceMilestoneWindow,
|
|
1509
|
+
hasVersionedMilestones,
|
|
1510
|
+
hasMilestoneSectioning,
|
|
1511
|
+
// #1956: sole owner of the #2012 decoy-avoidance scope for the
|
|
1512
|
+
// `drift-guard phase-status` CLI seam.
|
|
1513
|
+
findRoadmapProgressTable,
|
|
1514
|
+
// #3262 (write-time milestone-scope guard): the window phase-id scan owner
|
|
1515
|
+
// (consumed by getMilestonePhaseFilter above and the roadmap milestone-scope
|
|
1516
|
+
// CLI probe) and the free-text predicate the phase add/add-batch/insert
|
|
1517
|
+
// guards and the edit-phase workflow's pre/post capture are built on.
|
|
1518
|
+
scanMilestonePhaseIds,
|
|
1519
|
+
collectTablePhaseRows,
|
|
1520
|
+
findMilestoneScopeHeadingLines,
|
|
680
1521
|
};
|