@opengsd/gsd-core 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +1 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +17 -9
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +0 -1
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +3 -1
- package/agents/gsd-plan-checker.md +15 -55
- package/agents/gsd-planner.md +6 -4
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +2 -2
- package/agents/gsd-roadmapper.md +15 -11
- package/agents/gsd-ui-checker.md +63 -4
- package/agents/gsd-ui-researcher.md +41 -3
- package/agents/gsd-verifier.md +1 -1
- package/bin/install.js +609 -134
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/quick.md +8 -4
- package/gsd-core/bin/gsd-tools.cjs +567 -51
- package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
- package/gsd-core/bin/lib/api-coverage.cjs +30 -9
- package/gsd-core/bin/lib/artifacts.cjs +2 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
- package/gsd-core/bin/lib/audit.cjs +163 -41
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +336 -95
- package/gsd-core/bin/lib/capability-state.cjs +18 -3
- package/gsd-core/bin/lib/capability-validator.cjs +205 -18
- package/gsd-core/bin/lib/check-command-router.cjs +145 -5
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +543 -44
- package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
- package/gsd-core/bin/lib/config-loader.cjs +118 -29
- package/gsd-core/bin/lib/config.cjs +92 -2
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +84 -7
- package/gsd-core/bin/lib/edge-probe.cjs +9 -1
- package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
- package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
- package/gsd-core/bin/lib/frontmatter.cjs +840 -305
- package/gsd-core/bin/lib/gap-checker.cjs +27 -3
- package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
- package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
- package/gsd-core/bin/lib/host-integration.cjs +39 -6
- package/gsd-core/bin/lib/init-command-router.cjs +118 -21
- package/gsd-core/bin/lib/init.cjs +120 -41
- package/gsd-core/bin/lib/install-engine.cjs +68 -3
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
- package/gsd-core/bin/lib/install-profiles.cjs +78 -4
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +160 -15
- package/gsd-core/bin/lib/learnings.cjs +85 -14
- package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
- package/gsd-core/bin/lib/markdown-table.cjs +52 -4
- package/gsd-core/bin/lib/milestone.cjs +90 -5
- package/gsd-core/bin/lib/model-catalog.cjs +177 -19
- package/gsd-core/bin/lib/model-resolver.cjs +10 -28
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
- package/gsd-core/bin/lib/phase-id.cjs +70 -4
- package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
- package/gsd-core/bin/lib/phase-locator.cjs +138 -17
- package/gsd-core/bin/lib/phase.cjs +405 -84
- package/gsd-core/bin/lib/plan-document.cjs +263 -0
- package/gsd-core/bin/lib/plan-scan.cjs +13 -2
- package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +18 -14
- package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
- package/gsd-core/bin/lib/probe-core.cjs +4 -1
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
- package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
- package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
- package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
- package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
- package/gsd-core/bin/lib/roadmap.cjs +74 -19
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
- package/gsd-core/bin/lib/smart-entry.cjs +12 -22
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +47 -18
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +186 -0
- package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
- package/gsd-core/bin/lib/state-transition.cjs +517 -101
- package/gsd-core/bin/lib/state.cjs +946 -163
- package/gsd-core/bin/lib/surface.cjs +10 -2
- package/gsd-core/bin/lib/task-command-router.cjs +111 -1
- package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
- package/gsd-core/bin/lib/teams-status.cjs +4 -1
- package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
- package/gsd-core/bin/lib/uat.cjs +1376 -125
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
- package/gsd-core/bin/lib/unusable-input.cjs +13 -0
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/vendor/README.md +43 -5
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
- package/gsd-core/bin/lib/verification.cjs +14 -1
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify.cjs +95 -40
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
- package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
- package/gsd-core/bin/shared/exit-codes.json +8 -0
- package/gsd-core/bin/shared/exit-codes.sh +20 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -1
- package/gsd-core/references/agent-contracts.md +3 -2
- package/gsd-core/references/api-coverage.md +24 -2
- package/gsd-core/references/autonomous-smart-discuss.md +3 -3
- package/gsd-core/references/checkpoints.md +37 -19
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/edge-probe.md +8 -0
- package/gsd-core/references/execute-mvp-tdd.md +1 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
- package/gsd-core/references/execute-phase-wave-guard.md +11 -9
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-integration.md +5 -5
- package/gsd-core/references/git-planning-commit.md +3 -3
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +22 -0
- package/gsd-core/references/model-profiles.md +1 -1
- package/gsd-core/references/nyquist-compliance.md +74 -0
- package/gsd-core/references/offer-next.md +3 -5
- package/gsd-core/references/phase-argument-parsing.md +3 -3
- package/gsd-core/references/planner-failing-direction.md +53 -0
- package/gsd-core/references/planner-human-verify-mode.md +15 -1
- package/gsd-core/references/planner-revision.md +1 -1
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +37 -8
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/tdd.md +1 -3
- package/gsd-core/references/ui-brand.md +65 -21
- package/gsd-core/references/ui-consideration-probe.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +2 -2
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +11 -11
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/SECURITY.md +3 -3
- package/gsd-core/templates/UI-SPEC.md +25 -3
- package/gsd-core/templates/VALIDATION.md +3 -3
- package/gsd-core/templates/phase-prompt.md +3 -0
- package/gsd-core/templates/state.md +7 -0
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +3 -3
- package/gsd-core/workflows/add-tests.md +3 -8
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +4 -9
- package/gsd-core/workflows/audit-fix.md +12 -3
- package/gsd-core/workflows/audit-milestone.md +9 -9
- package/gsd-core/workflows/audit-uat.md +17 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +10 -26
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +2 -2
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +1 -1
- package/gsd-core/workflows/code-review.md +121 -40
- package/gsd-core/workflows/complete-milestone.md +15 -10
- package/gsd-core/workflows/debug.md +5 -3
- package/gsd-core/workflows/diagnose-issues.md +12 -6
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
- package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +2 -2
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +3 -6
- package/gsd-core/workflows/docs-update.md +5 -4
- package/gsd-core/workflows/edit-phase.md +1 -1
- package/gsd-core/workflows/eval-review.md +4 -9
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -4
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase.md +38 -54
- package/gsd-core/workflows/execute-plan.md +17 -12
- package/gsd-core/workflows/explore.md +1 -1
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/fast.md +2 -2
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +5 -5
- package/gsd-core/workflows/health.md +3 -6
- package/gsd-core/workflows/import.md +14 -11
- package/gsd-core/workflows/inbox.md +4 -5
- package/gsd-core/workflows/ingest-docs.md +44 -11
- package/gsd-core/workflows/insert-phase.md +5 -5
- package/gsd-core/workflows/list-seeds.md +5 -3
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +12 -23
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +2 -2
- package/gsd-core/workflows/new-milestone.md +9 -21
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +12 -26
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +2 -2
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
- package/gsd-core/workflows/plan-phase.md +121 -42
- package/gsd-core/workflows/plan-review-convergence.md +46 -9
- package/gsd-core/workflows/plant-seed.md +2 -2
- package/gsd-core/workflows/pr-branch.md +187 -51
- package/gsd-core/workflows/profile-user.md +16 -14
- package/gsd-core/workflows/progress.md +27 -12
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
- package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
- package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick.md +20 -29
- package/gsd-core/workflows/remove-phase.md +4 -4
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +8 -12
- package/gsd-core/workflows/review.md +193 -15
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +2 -2
- package/gsd-core/workflows/settings-advanced.md +7 -9
- package/gsd-core/workflows/settings-integrations.md +64 -31
- package/gsd-core/workflows/settings.md +3 -5
- package/gsd-core/workflows/ship.md +12 -6
- package/gsd-core/workflows/sketch-wrap-up.md +11 -17
- package/gsd-core/workflows/sketch.md +12 -18
- package/gsd-core/workflows/smart-entry.md +3 -5
- package/gsd-core/workflows/spec-phase.md +23 -1
- package/gsd-core/workflows/spike-wrap-up.md +7 -11
- package/gsd-core/workflows/spike.md +20 -31
- package/gsd-core/workflows/stats.md +2 -2
- package/gsd-core/workflows/sync-skills.md +1 -1
- package/gsd-core/workflows/thread.md +11 -7
- package/gsd-core/workflows/transition.md +5 -5
- package/gsd-core/workflows/ui-phase.md +10 -16
- package/gsd-core/workflows/ui-review.md +6 -10
- package/gsd-core/workflows/ultraplan-phase.md +5 -13
- package/gsd-core/workflows/undo.md +8 -16
- package/gsd-core/workflows/update.md +6 -10
- package/gsd-core/workflows/validate-phase.md +2 -2
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +57 -18
- package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +19 -10
- package/hooks/dist/gsd-cursor-post-tool.js +3 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
- package/hooks/dist/gsd-cursor-session-start.js +2 -1
- package/hooks/dist/gsd-cursor-stop.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
- package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
- package/hooks/dist/gsd-graphify-update.sh +22 -18
- package/hooks/dist/gsd-node-runner.sh +76 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +16 -7
- package/hooks/dist/gsd-read-guard.js +16 -7
- package/hooks/dist/gsd-read-injection-scanner.js +17 -8
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +215 -26
- package/hooks/dist/gsd-validate-commit.sh +80 -6
- package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
- package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
- package/hooks/dist/gsd-workflow-guard.js +34 -16
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-agent-isolation-guard.js +77 -38
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +19 -10
- package/hooks/gsd-cursor-post-tool.js +3 -1
- package/hooks/gsd-cursor-pre-tool.js +3 -1
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +28 -23
- package/hooks/gsd-cursor-subagent-stop.js +3 -1
- package/hooks/gsd-ensure-canonical-path.js +2 -1
- package/hooks/gsd-graphify-update.sh +22 -18
- package/hooks/gsd-node-runner.sh +76 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +16 -7
- package/hooks/gsd-read-guard.js +16 -7
- package/hooks/gsd-read-injection-scanner.js +17 -8
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +215 -26
- package/hooks/gsd-validate-commit.sh +80 -6
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +34 -16
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +12 -7
- package/scripts/base64-scan.sh +74 -12
- package/scripts/build-hooks.js +5 -0
- package/scripts/check-glossary-refs.cjs +77 -15
- package/scripts/check-mutation-score-ratchet.cjs +156 -0
- package/scripts/ci-check-job-near-cap.cjs +49 -0
- package/scripts/ci-pr-mergeability.cjs +262 -0
- package/scripts/ci-test-scope.cjs +45 -12
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/docs-guard-registry.cjs +396 -0
- package/scripts/gen-capability-registry.cjs +8 -6
- package/scripts/gen-exit-code-docs.cjs +318 -0
- package/scripts/gen-exit-code-registry.cjs +891 -0
- package/scripts/gen-features.cjs +836 -0
- package/scripts/gen-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-loop-host-contract.cjs +134 -1
- package/scripts/gen-scripts-cli-exit.cjs +185 -0
- package/scripts/gen-state-md-docs.cjs +727 -0
- package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
- package/scripts/lib/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +32 -2
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-docs-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +21 -8
- package/scripts/lint-planning-prompt-drift.cjs +38 -1
- package/scripts/lint-removed-but-needed.cjs +184 -16
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-write-path-drift.cjs +337 -432
- package/scripts/lint-test-file-count.allowlist.json +122 -4
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +51 -64
- package/scripts/lint-vendored-deps.cjs +208 -35
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/prompt-injection-scan.sh +75 -14
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +22 -3
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-quick/SKILL.md +8 -4
- package/vscode/package.json +1 -1
- package/bin/lib/ui-safety-gate.cjs +0 -109
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/state-write-path-drift-baseline.json +0 -19
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Planning Inspect Module — the schema-v1 canonical planning snapshot (#2790).
|
|
4
|
+
*
|
|
5
|
+
* `planning.inspect` is READ-ONLY: it opens `.planning/` documents and writes
|
|
6
|
+
* nothing, anywhere, ever. Downstream harness UIs (gsd-code Phase 12, plan
|
|
7
|
+
* mission-control) consume it instead of parsing ROADMAP/REQUIREMENTS/PLAN/
|
|
8
|
+
* SUMMARY markdown a second time — gsd-core is the single source of `.planning/`
|
|
9
|
+
* truth.
|
|
10
|
+
*
|
|
11
|
+
* COMPOSED, NOT RE-DERIVED. Every ADR-3180 §7 derivation arrives from its
|
|
12
|
+
* declared owner: milestone identity and windowing from `getMilestoneInfo`
|
|
13
|
+
* (§7.2) and phase enumeration from `listMilestonePhaseDirs` (§7.3), both via
|
|
14
|
+
* `buildPlanningSnapshot`; phase completion from `isPhaseComplete` (§7.4,
|
|
15
|
+
* disk-strict); live-plan counting from `scanPhasePlans` (§7.5); the
|
|
16
|
+
* fraction→percent arithmetic from `clampPercent` (§7.6). Plan bodies come from
|
|
17
|
+
* `parsePlanDocument` (`src/plan-document.cts`), requirement IDs from
|
|
18
|
+
* `parseRequirements` (`src/gap-checker.cts`), UAT items from `parseUatItems`
|
|
19
|
+
* (`src/uat.cts`). This module introduces no second answer to any of those
|
|
20
|
+
* questions.
|
|
21
|
+
*
|
|
22
|
+
* WHY THIS DOES NOT SERIALIZE `PlanningSnapshot` DIRECTLY. `PlanningSnapshot`
|
|
23
|
+
* is the §8.1 *diagnostic-rule subject* — explicitly additive and still growing
|
|
24
|
+
* (4 fields at Phase 10, 20+ by Phase 12). schema-v1 is a frozen EXTERNAL
|
|
25
|
+
* contract. Handing an internal, churning shape to external consumers is a
|
|
26
|
+
* Hyrum's-Law break waiting to happen, so this module declares its own flat
|
|
27
|
+
* schema and maps into it. Adding a field to `PlanningSnapshot` must never
|
|
28
|
+
* change what `planning.inspect` emits.
|
|
29
|
+
*
|
|
30
|
+
* NEVER INFERS. Where evidence is absent or two sources disagree, the value is
|
|
31
|
+
* `null` / `unknown` and a diagnostic names why. It is never reconciled, never
|
|
32
|
+
* guessed, and never filled from a plausible default. Keys are ALWAYS present —
|
|
33
|
+
* omitting a key on a non-answer is itself an observable a consumer would bind
|
|
34
|
+
* to.
|
|
35
|
+
*
|
|
36
|
+
* NOT a diagnostic rule, and deliberately NOT registered in
|
|
37
|
+
* `scripts/lint-planning-snapshot-bypass-drift.cjs`: that guard is
|
|
38
|
+
* `DIAGNOSTIC_RULE_FUNCTIONS`-scoped and must be prunable to zero when #3309
|
|
39
|
+
* lands. This is a query command.
|
|
40
|
+
*
|
|
41
|
+
* ADR-457 build-at-publish: source in src/planning-inspect.cts, compiled to
|
|
42
|
+
* gsd-core/bin/lib/planning-inspect.cjs (gitignored).
|
|
43
|
+
*/
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
48
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
50
|
+
const planningSnapshotMod = require("./planning-snapshot.cjs");
|
|
51
|
+
const { buildPlanningSnapshot, worstScope } = planningSnapshotMod;
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
53
|
+
const planningWorkspaceMod = require("./planning-workspace.cjs");
|
|
54
|
+
const { planningPaths } = planningWorkspaceMod;
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
56
|
+
const planScan = require("./plan-scan.cjs");
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
58
|
+
const planDocumentMod = require("./plan-document.cjs");
|
|
59
|
+
const { parsePlanDocument, TASK_KIND, planIdFromFile } = planDocumentMod;
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
61
|
+
const gapCheckerMod = require("./gap-checker.cjs");
|
|
62
|
+
const { parseRequirements } = gapCheckerMod;
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
64
|
+
const uatMod = require("./uat.cjs");
|
|
65
|
+
const { parseUatItemsWithStats, selectPhaseUatFiles } = uatMod;
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
67
|
+
const phaseLifecycleMod = require("./phase-lifecycle.cjs");
|
|
68
|
+
const { clampPercent } = phaseLifecycleMod;
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
70
|
+
const planningScopeMod = require("./planning-scope.cjs");
|
|
71
|
+
const { SCOPE } = planningScopeMod;
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
73
|
+
const verificationMod = require("./verification.cjs");
|
|
74
|
+
const { readVerificationStatus } = verificationMod;
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
76
|
+
const phaseIdMod = require("./phase-id.cjs");
|
|
77
|
+
const { phaseKeyFromDir, phaseKeyFromToken, phaseMarkdownRegexSource } = phaseIdMod;
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
79
|
+
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
80
|
+
const { extractCurrentMilestone } = roadmapParserMod;
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
82
|
+
const io = require("./io.cjs");
|
|
83
|
+
const { output } = io;
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
|
+
const frontmatterMod = require("./frontmatter.cjs");
|
|
86
|
+
const { extractFrontmatter, stripFrontmatter } = frontmatterMod;
|
|
87
|
+
const state_document_cjs_1 = require("./state-document.cjs");
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
89
|
+
const markdownSectionizer = require("./markdown-sectionizer.cjs");
|
|
90
|
+
const { collectSection, iterateBullets } = markdownSectionizer;
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
92
|
+
const markdownTable = require("./markdown-table.cjs");
|
|
93
|
+
const { parseMarkdownTable, matchTableSchema } = markdownTable;
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
95
|
+
const coreUtilsMod = require("./core-utils.cjs");
|
|
96
|
+
const { normalizeLineEndings } = coreUtilsMod;
|
|
97
|
+
/**
|
|
98
|
+
* The wire schema version. A consumer MUST reject any value other than this
|
|
99
|
+
* one rather than best-effort-parsing an unknown shape.
|
|
100
|
+
*/
|
|
101
|
+
const PLANNING_INSPECT_SCHEMA_VERSION = 1;
|
|
102
|
+
/**
|
|
103
|
+
* Frozen diagnostic vocabulary. Adding a member is the repo's standard three
|
|
104
|
+
* coordinated changes: this enum, the emitting site, and the test that locks
|
|
105
|
+
* `Object.keys(INSPECT_DIAGNOSTIC).sort()`.
|
|
106
|
+
*/
|
|
107
|
+
const INSPECT_DIAGNOSTIC = Object.freeze({
|
|
108
|
+
PLANNING_ROOT_ABSENT: 'planning_root_absent',
|
|
109
|
+
ROADMAP_UNSCOPED: 'roadmap_unscoped',
|
|
110
|
+
REQUIREMENTS_ABSENT: 'requirements_absent',
|
|
111
|
+
REQUIREMENTS_UNREADABLE: 'requirements_unreadable',
|
|
112
|
+
REQUIREMENT_DUPLICATE: 'requirement_duplicate',
|
|
113
|
+
REQUIREMENT_UNMAPPED: 'requirement_unmapped',
|
|
114
|
+
REQUIREMENT_PHASE_UNKNOWN: 'requirement_phase_unknown',
|
|
115
|
+
REQUIREMENT_COMPLETION_UNKNOWN: 'requirement_completion_unknown',
|
|
116
|
+
ORPHAN_PHASE_DIR: 'orphan_phase_dir',
|
|
117
|
+
PHASE_SCOPE_DEGRADED: 'phase_scope_degraded',
|
|
118
|
+
PLAN_UNREADABLE: 'plan_unreadable',
|
|
119
|
+
SUMMARY_UNREADABLE: 'summary_unreadable',
|
|
120
|
+
TASK_SHAPE_CHECKPOINT: 'task_shape_checkpoint',
|
|
121
|
+
TASK_CHANGED_FILES_PLAN_SCOPED: 'task_changed_files_plan_scoped',
|
|
122
|
+
TASK_CHANGED_FILES_CONFLICTING: 'task_changed_files_conflicting',
|
|
123
|
+
UAT_ABSENT: 'uat_absent',
|
|
124
|
+
UAT_UNREADABLE: 'uat_unreadable',
|
|
125
|
+
PERCENT_WITHHELD: 'percent_withheld',
|
|
126
|
+
});
|
|
127
|
+
/** Whether a task has a completion record. `unknown` is a first-class answer. */
|
|
128
|
+
const TASK_STATUS = Object.freeze({
|
|
129
|
+
DONE: 'done',
|
|
130
|
+
PENDING: 'pending',
|
|
131
|
+
UNKNOWN: 'unknown',
|
|
132
|
+
});
|
|
133
|
+
/**
|
|
134
|
+
* How precisely a changed-file list is attributed. `plan_scoped` is the common
|
|
135
|
+
* case and is NOT an error — SUMMARY.md's `## Files Created/Modified` is a
|
|
136
|
+
* plan-level section, so spreading it across a plan's tasks would be inference.
|
|
137
|
+
*/
|
|
138
|
+
const PROVENANCE = Object.freeze({
|
|
139
|
+
TASK_SCOPED: 'task_scoped',
|
|
140
|
+
PLAN_SCOPED: 'plan_scoped',
|
|
141
|
+
ABSENT: 'absent',
|
|
142
|
+
});
|
|
143
|
+
/** Whether planned and changed file sets agree. Never reconciled. */
|
|
144
|
+
const AGREEMENT = Object.freeze({
|
|
145
|
+
AGREED: 'agreed',
|
|
146
|
+
CONFLICTING: 'conflicting',
|
|
147
|
+
UNKNOWN: 'unknown',
|
|
148
|
+
});
|
|
149
|
+
// ─── Small shared helpers ─────────────────────────────────────────────────────
|
|
150
|
+
/**
|
|
151
|
+
* Path separators are normalised UNCONDITIONALLY, never via `path.sep` — a
|
|
152
|
+
* backslash-bearing path can arrive on Linux too, so a platform-gated
|
|
153
|
+
* normaliser leaves the non-Windows case untested and broken.
|
|
154
|
+
*/
|
|
155
|
+
function toPosix(value) {
|
|
156
|
+
return value.replace(/\\/g, '/');
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Read a UTF-8 file, distinguishing absent from unreadable.
|
|
160
|
+
*
|
|
161
|
+
* `root` is a containment boundary, not a convenience default: every
|
|
162
|
+
* document this module reads arrives as UNTRUSTED input — a clone, a PR
|
|
163
|
+
* branch, a teammate's working tree — and the assembled payload is handed
|
|
164
|
+
* to downstream tooling verbatim (see module doc). A `*-PLAN.md` (or any
|
|
165
|
+
* other document under `.planning/`) that is a SYMLINK resolving outside
|
|
166
|
+
* `root` is therefore an exfiltration path, not a convenience: reading it
|
|
167
|
+
* would let a planted symlink smuggle arbitrary readable file content into
|
|
168
|
+
* the emitted payload. Both `filePath` and `root` are resolved with
|
|
169
|
+
* `fs.realpathSync` — never a raw string prefix check on the unresolved
|
|
170
|
+
* path — so a project that legitimately symlinks its whole `.planning/`
|
|
171
|
+
* directory, or a single phase directory, elsewhere on disk keeps working;
|
|
172
|
+
* only a resolved target that ends up OUTSIDE the resolved root is
|
|
173
|
+
* rejected. An escape is reported as unreadable (same shape as any other
|
|
174
|
+
* unreadable document) so existing per-document degradation and
|
|
175
|
+
* diagnostics apply unchanged.
|
|
176
|
+
*/
|
|
177
|
+
/**
|
|
178
|
+
* Path-boundary-safe comparison of two ALREADY-RESOLVED absolute paths — the
|
|
179
|
+
* ONE containment comparison every containment check in this module shares
|
|
180
|
+
* (`readDocument`'s file-level guard, `isPathContained` below for
|
|
181
|
+
* directories and the verification-status `fs` seam), so none of them can
|
|
182
|
+
* drift apart. `target` must equal `root`, or begin with `root` plus a path
|
|
183
|
+
* separator; a bare `startsWith(root)` would wrongly accept a sibling
|
|
184
|
+
* directory like `.planning-evil/` that merely shares a string prefix. Pure
|
|
185
|
+
* string comparison, no I/O — callers own their own `fs.realpathSync` call
|
|
186
|
+
* (and its own not-found/broken-symlink handling).
|
|
187
|
+
*/
|
|
188
|
+
function isWithinRoot(resolvedTarget, resolvedRoot) {
|
|
189
|
+
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + node_path_1.default.sep);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Containment check for a path (file OR directory) that resolves its own
|
|
193
|
+
* `fs.realpathSync`, then delegates the actual boundary comparison to
|
|
194
|
+
* `isWithinRoot`. Used where the caller does not need to distinguish "target
|
|
195
|
+
* vanished / broken symlink" from "target resolved but escapes root" — both
|
|
196
|
+
* degrade the same way at every call site that uses this (an escaped or
|
|
197
|
+
* unresolvable phase directory is treated identically to an unreadable one).
|
|
198
|
+
* `readDocument` below needs that distinction for its own exists/readable
|
|
199
|
+
* tri-state, so it keeps its own inline `realpathSync` calls and calls
|
|
200
|
+
* `isWithinRoot` directly instead of this wrapper.
|
|
201
|
+
*/
|
|
202
|
+
function isPathContained(target, root) {
|
|
203
|
+
let realTarget;
|
|
204
|
+
let realRoot;
|
|
205
|
+
try {
|
|
206
|
+
realTarget = node_fs_1.default.realpathSync(target);
|
|
207
|
+
realRoot = node_fs_1.default.realpathSync(root);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
return isWithinRoot(realTarget, realRoot);
|
|
213
|
+
}
|
|
214
|
+
function readDocument(filePath, root) {
|
|
215
|
+
let stat;
|
|
216
|
+
try {
|
|
217
|
+
stat = node_fs_1.default.statSync(filePath);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return { text: null, exists: false, readable: false };
|
|
221
|
+
}
|
|
222
|
+
// A directory, socket, or symlink resolving to a device in a document
|
|
223
|
+
// position is not a document. Reject before any open (cf. #2378/#2383).
|
|
224
|
+
if (!stat.isFile())
|
|
225
|
+
return { text: null, exists: true, readable: false };
|
|
226
|
+
let realTarget;
|
|
227
|
+
let realRoot;
|
|
228
|
+
try {
|
|
229
|
+
realTarget = node_fs_1.default.realpathSync(filePath);
|
|
230
|
+
realRoot = node_fs_1.default.realpathSync(root);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// A broken symlink, or the path vanished between the stat above and
|
|
234
|
+
// here — the same non-answer `readDocument` already gives "not exists".
|
|
235
|
+
return { text: null, exists: false, readable: false };
|
|
236
|
+
}
|
|
237
|
+
if (!isWithinRoot(realTarget, realRoot)) {
|
|
238
|
+
return { text: null, exists: true, readable: false };
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
// #3707-CR follow-up MAJOR: normalize line endings HERE, at this module's
|
|
242
|
+
// one shared document-read seam, so a lone-CR document (CommonMark line
|
|
243
|
+
// ending; a document using it renders as separate lines to a human
|
|
244
|
+
// reader) is normalized by construction for every current and future
|
|
245
|
+
// caller of `readDocument` (`buildRequirements`, `buildUatRows`, the
|
|
246
|
+
// ROADMAP.md read above) — not only the ones a parser author remembered
|
|
247
|
+
// to fix individually. Mirrors `src/uat.cts`'s `readNormalizedDocument`,
|
|
248
|
+
// the equivalent boundary for `cmdAuditUat`; both now delegate to the
|
|
249
|
+
// same `normalizeLineEndings` in `core-utils.cts`.
|
|
250
|
+
return { text: normalizeLineEndings(node_fs_1.default.readFileSync(filePath, 'utf-8')), exists: true, readable: true };
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return { text: null, exists: true, readable: false };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Containment-enforcing `fs` seam for `readVerificationStatus`
|
|
258
|
+
* (`src/verification.cts`), passed through that function's existing
|
|
259
|
+
* `opts.fs` injection point — GAP 2 of the #2790 follow-up security review.
|
|
260
|
+
*
|
|
261
|
+
* `readVerificationStatus` is a SHARED owner consumed by many commands
|
|
262
|
+
* (init, roadmap, phase, ship, …), so it must not gain a containment
|
|
263
|
+
* parameter of its own; the boundary is enforced HERE, at this consumer's
|
|
264
|
+
* call site, instead. Each method below delegates to the real `node:fs`
|
|
265
|
+
* ONLY after the given path passes `isPathContained` against
|
|
266
|
+
* `planningRoot` — otherwise it throws. `readVerificationStatus` already
|
|
267
|
+
* treats every one of these calls' failures as its no-throw "missing"
|
|
268
|
+
* degradation (see its own try/catch around `readdirSync`/`readFileSync`,
|
|
269
|
+
* and `findStaleVerificationSummary`'s around `readdirSync`/`statSync`), so
|
|
270
|
+
* a `*-VERIFICATION.md` symlinked outside the planning root — or a phase
|
|
271
|
+
* directory that is itself such a symlink — now degrades to the ordinary
|
|
272
|
+
* 'missing' status instead of leaking frontmatter content (or the escaped
|
|
273
|
+
* directory's filenames) into the payload.
|
|
274
|
+
*/
|
|
275
|
+
function containmentEnforcingVerificationFs(planningRoot) {
|
|
276
|
+
function assertContained(target) {
|
|
277
|
+
if (!isPathContained(target, planningRoot)) {
|
|
278
|
+
throw new Error(`planning-inspect: path escapes planning root: ${toPosix(target)}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
readdirSync(dir) {
|
|
283
|
+
assertContained(dir);
|
|
284
|
+
return node_fs_1.default.readdirSync(dir);
|
|
285
|
+
},
|
|
286
|
+
readFileSync(filePath, encoding) {
|
|
287
|
+
assertContained(filePath);
|
|
288
|
+
return node_fs_1.default.readFileSync(filePath, encoding);
|
|
289
|
+
},
|
|
290
|
+
statSync(filePath) {
|
|
291
|
+
assertContained(filePath);
|
|
292
|
+
return node_fs_1.default.statSync(filePath);
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function sortedUnique(values) {
|
|
297
|
+
return [...new Set(values)].sort();
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Prefix-agnostic requirement-ID format shared with `parseRequirements`
|
|
301
|
+
* (`src/gap-checker.cts`) — REQ-01, TST-01, BACK-07, INSP-04, etc. Bold
|
|
302
|
+
* markers (`**ID**`) are optional decoration, matching how `gap-checker.cts`
|
|
303
|
+
* pulls the ID out of a checkbox bullet's text.
|
|
304
|
+
*/
|
|
305
|
+
const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+';
|
|
306
|
+
/**
|
|
307
|
+
* A Traceability table's `Requirement` cell holds ONLY the ID (mod optional
|
|
308
|
+
* bold + surrounding whitespace) — full-match, mirroring the pipe-bounded
|
|
309
|
+
* anchoring the prior hand-rolled `ID_CELL` regex enforced.
|
|
310
|
+
*/
|
|
311
|
+
const CELL_ID_RE = new RegExp(`^\\*{0,2}(${ID_PATTERN})\\*{0,2}$`);
|
|
312
|
+
/**
|
|
313
|
+
* A checkbox bullet's leading `**ID**` — prefix match only (no end anchor):
|
|
314
|
+
* trailing prose (`: description`, ` some text`) is not required to match,
|
|
315
|
+
* mirroring the prior hand-rolled `BULLET` regex.
|
|
316
|
+
*/
|
|
317
|
+
const BULLET_ID_RE = new RegExp(`^\\*{0,2}(${ID_PATTERN})\\*{0,2}`);
|
|
318
|
+
/**
|
|
319
|
+
* Parse the `## Traceability` table's `Requirement | Phase | Status` rows via
|
|
320
|
+
* the canonical `markdown-table` seam (ADR-2143) — never a hand-rolled
|
|
321
|
+
* table/row regex. A missing/malformed section or table, or a header that
|
|
322
|
+
* doesn't match the `RequirementsTraceability` schema, yields an EMPTY map: a
|
|
323
|
+
* malformed table is a non-answer, and every requirement then falls through
|
|
324
|
+
* to the caller's own `REQUIREMENT_UNMAPPED` diagnostic.
|
|
325
|
+
*/
|
|
326
|
+
function parseTraceability(reqMd) {
|
|
327
|
+
const byId = new Map();
|
|
328
|
+
const section = collectSection(reqMd, (h) => /^traceability$/i.test(h.text.trim()));
|
|
329
|
+
if (!section)
|
|
330
|
+
return byId;
|
|
331
|
+
const parsed = parseMarkdownTable(section.body);
|
|
332
|
+
if (!parsed.ok)
|
|
333
|
+
return byId;
|
|
334
|
+
const schema = matchTableSchema(parsed.value.columns);
|
|
335
|
+
if (!schema || schema.id !== 'RequirementsTraceability')
|
|
336
|
+
return byId;
|
|
337
|
+
for (const row of parsed.value.rows) {
|
|
338
|
+
const idMatch = CELL_ID_RE.exec((row.Requirement ?? '').trim());
|
|
339
|
+
if (!idMatch)
|
|
340
|
+
continue;
|
|
341
|
+
const id = idMatch[1];
|
|
342
|
+
// `Phase 1`, `1`, `Phase 1, Phase 2` — the token is what a consumer can
|
|
343
|
+
// match against a phase id; the surrounding word is decoration. This is
|
|
344
|
+
// value-level parsing of ONE already-addressed cell, not markdown parsing.
|
|
345
|
+
const tokens = [...(row.Phase ?? '').matchAll(/\d+(?:\.\d+)*/g)].map((t) => t[0]);
|
|
346
|
+
const existing = byId.get(id);
|
|
347
|
+
if (existing)
|
|
348
|
+
existing.push(...tokens);
|
|
349
|
+
else
|
|
350
|
+
byId.set(id, tokens);
|
|
351
|
+
}
|
|
352
|
+
return byId;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Checkbox completion state per requirement ID, from the `- [x] **ID**`
|
|
356
|
+
* bullets — driven by the canonical `iterateBullets` seam, same bold-ID
|
|
357
|
+
* extraction style as `parseRequirements` (`src/gap-checker.cts`). A
|
|
358
|
+
* requirement with no bullet has no checkbox answer — reported as `unknown`,
|
|
359
|
+
* never defaulted to `false`.
|
|
360
|
+
*/
|
|
361
|
+
function parseCheckboxStates(reqMd) {
|
|
362
|
+
const states = new Map();
|
|
363
|
+
for (const bullet of iterateBullets(reqMd)) {
|
|
364
|
+
if (bullet.marker !== 'checkbox-checked' && bullet.marker !== 'checkbox-unchecked')
|
|
365
|
+
continue;
|
|
366
|
+
const m = BULLET_ID_RE.exec(bullet.text);
|
|
367
|
+
if (!m)
|
|
368
|
+
continue;
|
|
369
|
+
// First occurrence wins, matching parseRequirements' own `seen` set.
|
|
370
|
+
if (!states.has(m[1]))
|
|
371
|
+
states.set(m[1], bullet.marker === 'checkbox-checked');
|
|
372
|
+
}
|
|
373
|
+
return states;
|
|
374
|
+
}
|
|
375
|
+
/** IDs appearing more than once in the checkbox bullets, in document order. */
|
|
376
|
+
function findDuplicateIds(reqMd) {
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
const dupes = [];
|
|
379
|
+
for (const bullet of iterateBullets(reqMd)) {
|
|
380
|
+
if (bullet.marker !== 'checkbox-checked' && bullet.marker !== 'checkbox-unchecked')
|
|
381
|
+
continue;
|
|
382
|
+
const m = BULLET_ID_RE.exec(bullet.text);
|
|
383
|
+
if (!m)
|
|
384
|
+
continue;
|
|
385
|
+
if (seen.has(m[1]))
|
|
386
|
+
dupes.push(m[1]);
|
|
387
|
+
else
|
|
388
|
+
seen.add(m[1]);
|
|
389
|
+
}
|
|
390
|
+
return dupes;
|
|
391
|
+
}
|
|
392
|
+
function buildRequirements(requirementsPath, knownPhaseKeys, diagnostics, planningRoot) {
|
|
393
|
+
const doc = readDocument(requirementsPath, planningRoot);
|
|
394
|
+
if (!doc.exists) {
|
|
395
|
+
diagnostics.push({
|
|
396
|
+
code: INSPECT_DIAGNOSTIC.REQUIREMENTS_ABSENT,
|
|
397
|
+
subject: toPosix(requirementsPath),
|
|
398
|
+
detail: 'REQUIREMENTS.md does not exist; no requirement rows are available.',
|
|
399
|
+
});
|
|
400
|
+
return { rows: [], scope: SCOPE.UNSCOPED };
|
|
401
|
+
}
|
|
402
|
+
if (!doc.readable || doc.text === null) {
|
|
403
|
+
diagnostics.push({
|
|
404
|
+
code: INSPECT_DIAGNOSTIC.REQUIREMENTS_UNREADABLE,
|
|
405
|
+
subject: toPosix(requirementsPath),
|
|
406
|
+
detail: 'REQUIREMENTS.md exists but could not be read; zero rows is not a reliable answer.',
|
|
407
|
+
});
|
|
408
|
+
return { rows: [], scope: SCOPE.UNREADABLE };
|
|
409
|
+
}
|
|
410
|
+
const reqMd = doc.text;
|
|
411
|
+
const items = parseRequirements(reqMd);
|
|
412
|
+
const traceability = parseTraceability(reqMd);
|
|
413
|
+
const checkboxes = parseCheckboxStates(reqMd);
|
|
414
|
+
const dupeIds = findDuplicateIds(reqMd);
|
|
415
|
+
for (const dupe of dupeIds) {
|
|
416
|
+
diagnostics.push({
|
|
417
|
+
code: INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE,
|
|
418
|
+
subject: dupe,
|
|
419
|
+
detail: 'Requirement ID appears more than once; the first occurrence is authoritative.',
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const duplicateIdSet = new Set(dupeIds);
|
|
423
|
+
const rows = items.map((item) => {
|
|
424
|
+
const mappedPhases = sortedUnique(traceability.get(item.id) ?? []);
|
|
425
|
+
const hasCheckbox = checkboxes.has(item.id);
|
|
426
|
+
const complete = hasCheckbox ? checkboxes.get(item.id) : 'unknown';
|
|
427
|
+
const rowDiagnostics = [];
|
|
428
|
+
if (duplicateIdSet.has(item.id)) {
|
|
429
|
+
rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE);
|
|
430
|
+
}
|
|
431
|
+
if (mappedPhases.length === 0) {
|
|
432
|
+
diagnostics.push({
|
|
433
|
+
code: INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED,
|
|
434
|
+
subject: item.id,
|
|
435
|
+
detail: 'No Traceability row maps this requirement to a phase.',
|
|
436
|
+
});
|
|
437
|
+
rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED);
|
|
438
|
+
}
|
|
439
|
+
for (const token of mappedPhases) {
|
|
440
|
+
// `phaseKeyFromDir` and `phaseKeyFromToken` are the canonical pair for
|
|
441
|
+
// phase-IDENTITY equality: both map a directory name and a document
|
|
442
|
+
// token onto the same canonical zero-padded key, so `01-auth` /
|
|
443
|
+
// `1-auth` / `Phase 1` / `Phase 01` are recognized as one phase, and
|
|
444
|
+
// decimal phases (`1.1`) compare correctly. A raw string compare gets
|
|
445
|
+
// the padding case wrong; `phaseTokenMatches` is the wrong primitive
|
|
446
|
+
// here too — it is a FILE-MEMBERSHIP predicate (#3511) for aggregate
|
|
447
|
+
// `*-UAT.md` / `*-VERIFICATION.md` scans, not a phase-identity equality
|
|
448
|
+
// test, and is unreliable for decimal phases.
|
|
449
|
+
if (!knownPhaseKeys.has(phaseKeyFromToken(token))) {
|
|
450
|
+
diagnostics.push({
|
|
451
|
+
code: INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN,
|
|
452
|
+
subject: `${item.id}->${token}`,
|
|
453
|
+
detail: 'Traceability maps this requirement to a phase that is not present on disk.',
|
|
454
|
+
});
|
|
455
|
+
rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (complete === 'unknown') {
|
|
459
|
+
diagnostics.push({
|
|
460
|
+
code: INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN,
|
|
461
|
+
subject: item.id,
|
|
462
|
+
detail: 'Requirement has no checkbox bullet; completion is unknown, not incomplete.',
|
|
463
|
+
});
|
|
464
|
+
rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN);
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
id: item.id,
|
|
468
|
+
text: item.text && item.text.length > 0 ? item.text : null,
|
|
469
|
+
complete,
|
|
470
|
+
mappedPhases,
|
|
471
|
+
diagnostics: rowDiagnostics,
|
|
472
|
+
scope: SCOPE.COMPLETE,
|
|
473
|
+
};
|
|
474
|
+
});
|
|
475
|
+
return { rows, scope: SCOPE.COMPLETE };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Parse a SUMMARY.md body for file provenance.
|
|
479
|
+
*
|
|
480
|
+
* Two DIFFERENT scopes live in this document and conflating them is the whole
|
|
481
|
+
* hazard: `## Files Created/Modified` describes the PLAN, while a deviation
|
|
482
|
+
* block's `**Files modified:**` is attributed to the task its `**Found during:**
|
|
483
|
+
* Task N` line names. Only the latter is task-scoped.
|
|
484
|
+
*/
|
|
485
|
+
function parseSummaryProvenance(content) {
|
|
486
|
+
const planFiles = [];
|
|
487
|
+
const byTask = new Map();
|
|
488
|
+
// `## Files Created/Modified` — a PLAN-level bullet list. Bounded to the
|
|
489
|
+
// section body via collectSection + iterateBullets; absent is not an error.
|
|
490
|
+
const filesSection = collectSection(content, (h) => /^files\s+created\/modified\s*$/i.test(h.text.trim()));
|
|
491
|
+
if (filesSection) {
|
|
492
|
+
for (const bullet of iterateBullets(filesSection.body)) {
|
|
493
|
+
// `- \`path/to/file.ts\` - What it does`
|
|
494
|
+
const m = /^`([^`]+)`/.exec(bullet.text);
|
|
495
|
+
if (m)
|
|
496
|
+
planFiles.push(m[1].trim());
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// `## Deviations from Plan` (also matches the `(Auto-fixed)` variant) — the
|
|
500
|
+
// `**Found during:** Task N` / `**Files modified:**` scan is a regex
|
|
501
|
+
// CONFINED to this already-collected section body (ADR-2143 §4 sanctioned
|
|
502
|
+
// pattern), never a document-wide heading walk.
|
|
503
|
+
const deviationsSection = collectSection(content, (h) => /^deviations\s+from\s+plan/i.test(h.text.trim()));
|
|
504
|
+
if (deviationsSection) {
|
|
505
|
+
let currentTask = null;
|
|
506
|
+
for (const line of deviationsSection.body.split(/\r?\n/)) {
|
|
507
|
+
const foundDuring = /\*\*Found during:\*\*\s*Task\s*(\d+)/i.exec(line);
|
|
508
|
+
if (foundDuring) {
|
|
509
|
+
currentTask = parseInt(foundDuring[1], 10);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const filesModified = /\*\*Files modified:\*\*\s*(.+)$/i.exec(line);
|
|
513
|
+
if (filesModified && currentTask !== null) {
|
|
514
|
+
const files = filesModified[1]
|
|
515
|
+
.split(',')
|
|
516
|
+
.map((f) => f.trim().replace(/^`|`$/g, ''))
|
|
517
|
+
.filter((f) => f.length > 0);
|
|
518
|
+
const existing = byTask.get(currentTask);
|
|
519
|
+
if (existing)
|
|
520
|
+
existing.push(...files);
|
|
521
|
+
else
|
|
522
|
+
byTask.set(currentTask, files);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return { planFiles: sortedUnique(planFiles), byTask };
|
|
527
|
+
}
|
|
528
|
+
/** Pair a plan file with its SUMMARY by the canonical id embedded in the name. */
|
|
529
|
+
function summaryForPlan(planFile, summaryFiles) {
|
|
530
|
+
const base = node_path_1.default.basename(planFile);
|
|
531
|
+
const key = base.replace(/-?PLAN/i, '').replace(/\.md$/i, '');
|
|
532
|
+
const dir = planFile.includes('/') ? planFile.slice(0, planFile.lastIndexOf('/') + 1) : '';
|
|
533
|
+
for (const candidate of summaryFiles) {
|
|
534
|
+
if (!candidate.startsWith(dir))
|
|
535
|
+
continue;
|
|
536
|
+
const candidateKey = node_path_1.default.basename(candidate).replace(/-?SUMMARY/i, '').replace(/\.md$/i, '');
|
|
537
|
+
if (candidateKey === key)
|
|
538
|
+
return candidate;
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
function buildTaskRows(planFile, parsed, provenance, diagnostics) {
|
|
543
|
+
return parsed.tasks.map((task) => {
|
|
544
|
+
if (task.kind === TASK_KIND.CHECKPOINT) {
|
|
545
|
+
diagnostics.push({
|
|
546
|
+
code: INSPECT_DIAGNOSTIC.TASK_SHAPE_CHECKPOINT,
|
|
547
|
+
subject: `${toPosix(planFile)}#${task.index}`,
|
|
548
|
+
detail: 'Checkpoint task: the grammar carries no name/files/acceptance elements.',
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
if (provenance === null) {
|
|
552
|
+
return {
|
|
553
|
+
index: task.index,
|
|
554
|
+
kind: task.kind,
|
|
555
|
+
type: task.type,
|
|
556
|
+
name: task.name,
|
|
557
|
+
plannedFiles: task.plannedFiles,
|
|
558
|
+
acceptanceCriteria: task.acceptanceCriteria,
|
|
559
|
+
done: task.done,
|
|
560
|
+
changedFiles: null,
|
|
561
|
+
provenance: PROVENANCE.ABSENT,
|
|
562
|
+
agreement: AGREEMENT.UNKNOWN,
|
|
563
|
+
status: TASK_STATUS.PENDING,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const attributed = provenance.byTask.get(task.index);
|
|
567
|
+
if (attributed === undefined) {
|
|
568
|
+
// A SUMMARY exists but says nothing about THIS task. The plan-level file
|
|
569
|
+
// list is not evidence about a task — attributing it would be inference.
|
|
570
|
+
diagnostics.push({
|
|
571
|
+
code: INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_PLAN_SCOPED,
|
|
572
|
+
subject: `${toPosix(planFile)}#${task.index}`,
|
|
573
|
+
detail: 'SUMMARY carries only a plan-level file list; task-scoped changed files are unknown.',
|
|
574
|
+
});
|
|
575
|
+
return {
|
|
576
|
+
index: task.index,
|
|
577
|
+
kind: task.kind,
|
|
578
|
+
type: task.type,
|
|
579
|
+
name: task.name,
|
|
580
|
+
plannedFiles: task.plannedFiles,
|
|
581
|
+
acceptanceCriteria: task.acceptanceCriteria,
|
|
582
|
+
done: task.done,
|
|
583
|
+
changedFiles: null,
|
|
584
|
+
provenance: PROVENANCE.PLAN_SCOPED,
|
|
585
|
+
agreement: AGREEMENT.UNKNOWN,
|
|
586
|
+
status: TASK_STATUS.UNKNOWN,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const changed = sortedUnique(attributed);
|
|
590
|
+
const planned = sortedUnique(task.plannedFiles);
|
|
591
|
+
let agreement = AGREEMENT.UNKNOWN;
|
|
592
|
+
if (planned.length > 0) {
|
|
593
|
+
const same = planned.length === changed.length && planned.every((f, i) => f === changed[i]);
|
|
594
|
+
agreement = same ? AGREEMENT.AGREED : AGREEMENT.CONFLICTING;
|
|
595
|
+
if (!same) {
|
|
596
|
+
diagnostics.push({
|
|
597
|
+
code: INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_CONFLICTING,
|
|
598
|
+
subject: `${toPosix(planFile)}#${task.index}`,
|
|
599
|
+
detail: 'Planned and changed file sets disagree; both are reported verbatim, unreconciled.',
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
index: task.index,
|
|
605
|
+
kind: task.kind,
|
|
606
|
+
type: task.type,
|
|
607
|
+
name: task.name,
|
|
608
|
+
plannedFiles: task.plannedFiles,
|
|
609
|
+
acceptanceCriteria: task.acceptanceCriteria,
|
|
610
|
+
done: task.done,
|
|
611
|
+
changedFiles: changed,
|
|
612
|
+
provenance: PROVENANCE.TASK_SCOPED,
|
|
613
|
+
agreement,
|
|
614
|
+
status: TASK_STATUS.DONE,
|
|
615
|
+
};
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
function buildPlanRows(phaseDir, diagnostics, planningRoot) {
|
|
619
|
+
// GAP 1 (#2790 follow-up security review): `scanPhasePlans` (`plan-scan.cjs`)
|
|
620
|
+
// does its own `readdirSync(phaseDir)`, which FOLLOWS a directory symlink —
|
|
621
|
+
// so a phase directory that is itself a symlink escaping `planningRoot`
|
|
622
|
+
// would have its EXTERNAL filenames enumerated and surfaced via `file:
|
|
623
|
+
// toPosix(planFile)` below, even though the per-file `readDocument` guard
|
|
624
|
+
// already rejects the CONTENT. Contained before any enumeration happens, so
|
|
625
|
+
// an escaped phase directory contributes zero rows and zero filenames — the
|
|
626
|
+
// same degraded shape (`scope: unreadable`, empty `rows`) `scanPhasePlans`
|
|
627
|
+
// already returns when the directory cannot be listed at all, via the same
|
|
628
|
+
// `isPathContained` comparison `readDocument` uses for files (never a
|
|
629
|
+
// second, hand-rolled boundary check).
|
|
630
|
+
if (!isPathContained(phaseDir, planningRoot)) {
|
|
631
|
+
return { rows: [], scope: SCOPE.UNREADABLE };
|
|
632
|
+
}
|
|
633
|
+
const scan = planScan(phaseDir);
|
|
634
|
+
const supersededSet = new Set(scan.allPlanFiles.filter((f) => !scan.planFiles.includes(f)));
|
|
635
|
+
const rows = scan.allPlanFiles.map((planFile) => {
|
|
636
|
+
const doc = readDocument(node_path_1.default.join(phaseDir, planFile), planningRoot);
|
|
637
|
+
if (doc.text === null) {
|
|
638
|
+
diagnostics.push({
|
|
639
|
+
code: INSPECT_DIAGNOSTIC.PLAN_UNREADABLE,
|
|
640
|
+
subject: toPosix(planFile),
|
|
641
|
+
detail: 'Plan file could not be read; its body is unknown. Sibling plans are unaffected.',
|
|
642
|
+
});
|
|
643
|
+
return {
|
|
644
|
+
id: planIdFromFile(planFile),
|
|
645
|
+
file: toPosix(planFile),
|
|
646
|
+
superseded: supersededSet.has(planFile),
|
|
647
|
+
objective: null,
|
|
648
|
+
wave: null,
|
|
649
|
+
dependsOn: [],
|
|
650
|
+
autonomous: true,
|
|
651
|
+
agentHint: null,
|
|
652
|
+
plannedFiles: [],
|
|
653
|
+
changedFiles: null,
|
|
654
|
+
hasSummary: false,
|
|
655
|
+
tasks: [],
|
|
656
|
+
scope: SCOPE.UNREADABLE,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
const parsed = parsePlanDocument(doc.text);
|
|
660
|
+
const summaryFile = summaryForPlan(planFile, scan.summaryFiles);
|
|
661
|
+
let provenance = null;
|
|
662
|
+
if (summaryFile !== null) {
|
|
663
|
+
const summaryDoc = readDocument(node_path_1.default.join(phaseDir, summaryFile), planningRoot);
|
|
664
|
+
if (summaryDoc.text === null) {
|
|
665
|
+
diagnostics.push({
|
|
666
|
+
code: INSPECT_DIAGNOSTIC.SUMMARY_UNREADABLE,
|
|
667
|
+
subject: toPosix(summaryFile),
|
|
668
|
+
detail: 'Summary file could not be read; file provenance for this plan is unknown.',
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
provenance = parseSummaryProvenance(summaryDoc.text);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
id: planIdFromFile(planFile),
|
|
677
|
+
file: toPosix(planFile),
|
|
678
|
+
superseded: supersededSet.has(planFile),
|
|
679
|
+
objective: parsed.objective,
|
|
680
|
+
wave: parsed.declaredWave,
|
|
681
|
+
dependsOn: parsed.dependsOn,
|
|
682
|
+
autonomous: parsed.autonomous,
|
|
683
|
+
agentHint: parsed.agentHint,
|
|
684
|
+
plannedFiles: parsed.filesModified,
|
|
685
|
+
changedFiles: provenance === null ? null : provenance.planFiles,
|
|
686
|
+
hasSummary: summaryFile !== null,
|
|
687
|
+
tasks: buildTaskRows(planFile, parsed, provenance, diagnostics),
|
|
688
|
+
scope: SCOPE.COMPLETE,
|
|
689
|
+
};
|
|
690
|
+
});
|
|
691
|
+
return { rows, scope: scan.scope };
|
|
692
|
+
}
|
|
693
|
+
// ─── UAT ──────────────────────────────────────────────────────────────────────
|
|
694
|
+
// `scope` and `foldScope` are, by decision, identical at every return site in
|
|
695
|
+
// this function as of #3078 round-8 — they are not accidentally in sync. The
|
|
696
|
+
// two-field shape is kept anyway because it lets `scope` (the row's own
|
|
697
|
+
// honest answer) and `foldScope` (what the caller folds into the milestone)
|
|
698
|
+
// diverge again later without a signature change, should some future gap
|
|
699
|
+
// class need to be reported on the row but exempted from the fold, or vice
|
|
700
|
+
// versa. If you find yourself "simplifying" this to one field, don't.
|
|
701
|
+
function buildUatRows(phasesDir, phaseDirName, diagnostics, planningRoot) {
|
|
702
|
+
const phaseDir = node_path_1.default.join(phasesDir, phaseDirName);
|
|
703
|
+
// GAP 1 (#2790 follow-up security review): same rationale as
|
|
704
|
+
// `buildPlanRows` above — `readdirSync` below FOLLOWS a directory symlink,
|
|
705
|
+
// so an escaped phase directory must be rejected before enumeration, not
|
|
706
|
+
// after. Reuses the existing `UAT_UNREADABLE` diagnostic and degraded
|
|
707
|
+
// shape (the pre-existing "directory could not be listed" path below),
|
|
708
|
+
// rather than inventing a new diagnostic code for what is, from a
|
|
709
|
+
// consumer's perspective, the same non-answer.
|
|
710
|
+
if (!isPathContained(phaseDir, planningRoot)) {
|
|
711
|
+
diagnostics.push({
|
|
712
|
+
code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE,
|
|
713
|
+
subject: phaseDirName,
|
|
714
|
+
detail: 'Phase directory could not be listed; UAT presence is unknown.',
|
|
715
|
+
});
|
|
716
|
+
return { items: [], scope: SCOPE.UNREADABLE, foldScope: SCOPE.UNREADABLE };
|
|
717
|
+
}
|
|
718
|
+
let entries;
|
|
719
|
+
try {
|
|
720
|
+
entries = node_fs_1.default.readdirSync(phaseDir);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
diagnostics.push({
|
|
724
|
+
code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE,
|
|
725
|
+
subject: phaseDirName,
|
|
726
|
+
detail: 'Phase directory could not be listed; UAT presence is unknown.',
|
|
727
|
+
});
|
|
728
|
+
return { items: [], scope: SCOPE.UNREADABLE, foldScope: SCOPE.UNREADABLE };
|
|
729
|
+
}
|
|
730
|
+
const uatFiles = selectPhaseUatFiles(entries, phaseDirName);
|
|
731
|
+
if (uatFiles.length === 0) {
|
|
732
|
+
diagnostics.push({
|
|
733
|
+
code: INSPECT_DIAGNOSTIC.UAT_ABSENT,
|
|
734
|
+
subject: phaseDirName,
|
|
735
|
+
detail: 'No UAT document for this phase. This does not affect phase acceptance.',
|
|
736
|
+
});
|
|
737
|
+
return { items: [], scope: SCOPE.COMPLETE, foldScope: SCOPE.COMPLETE };
|
|
738
|
+
}
|
|
739
|
+
const items = [];
|
|
740
|
+
let scope = SCOPE.COMPLETE;
|
|
741
|
+
let foldScope = SCOPE.COMPLETE;
|
|
742
|
+
for (const file of uatFiles) {
|
|
743
|
+
const doc = readDocument(node_path_1.default.join(phaseDir, file), planningRoot);
|
|
744
|
+
if (doc.text === null) {
|
|
745
|
+
diagnostics.push({
|
|
746
|
+
code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE,
|
|
747
|
+
subject: `${phaseDirName}/${file}`,
|
|
748
|
+
detail: 'UAT document exists but could not be read.',
|
|
749
|
+
});
|
|
750
|
+
scope = SCOPE.TRUNCATED;
|
|
751
|
+
foldScope = SCOPE.TRUNCATED;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
// #3707-class false-clean, second surface (security review finding 1):
|
|
755
|
+
// `parseUatItemsWithStats`'s `headingsSeen` counts `### N.` test blocks
|
|
756
|
+
// that yielded ZERO items (a row missing its `result:` line, or otherwise
|
|
757
|
+
// unrecognised) — the audit-uat side (`cmdAuditUat`, above) already flags
|
|
758
|
+
// this as `parse_gap`. Reading the file successfully is not the same as
|
|
759
|
+
// deriving every row from it, so the gap is ALWAYS REPORTED.
|
|
760
|
+
//
|
|
761
|
+
// #3078 round-8 HIGH — NO FRONTMATTER KILL SWITCH. There is deliberately
|
|
762
|
+
// no `status !== 'complete'` term here. `cmdAuditUat` dropped its own such
|
|
763
|
+
// guard (see the long rationale at src/uat.cts, above `headingsSeen > 0`):
|
|
764
|
+
// a terminal status is an ASSERTION BY THE AUTHOR, and an assertion must
|
|
765
|
+
// not be able to switch off the detector that would contradict it. A guard
|
|
766
|
+
// here let one word of frontmatter turn a fence-straddled `result: blocked`
|
|
767
|
+
// into an affirmative `scope: "complete"` with ZERO diagnostics. The
|
|
768
|
+
// condition is `headingsSeen > 0` alone, matching src/uat.cts's own check
|
|
769
|
+
// line for line. Do not re-add a status term to "align the surfaces" — the
|
|
770
|
+
// surfaces are aligned precisely BY its absence.
|
|
771
|
+
//
|
|
772
|
+
// #3078 round-8 — REPORTING THE GAP AND WITHHOLDING THE PERCENTAGE ARE TWO
|
|
773
|
+
// DIFFERENT DECISIONS, so they get two different fields. `scope` is what
|
|
774
|
+
// this phase's own `uat.scope` reports: it stays honest and goes TRUNCATED
|
|
775
|
+
// for every gap, because a document that did not yield all its rows must
|
|
776
|
+
// never carry an affirmative `"complete"`. `foldScope` is what is handed to
|
|
777
|
+
// `worstScope` in the caller, and it is the one with teeth — a non-COMPLETE
|
|
778
|
+
// fold raises `phase_scope_degraded` AND, via `phaseScope`/`makeFraction`,
|
|
779
|
+
// withholds BOTH progress percentages for the WHOLE milestone.
|
|
780
|
+
//
|
|
781
|
+
// The two now agree: EVERY gap class degrades both, including the
|
|
782
|
+
// fence-suppression shortfall. `shortfallBlocks` is not exempted here
|
|
783
|
+
// because it is a single tally incremented at ONE site in the scan and
|
|
784
|
+
// spans BOTH a harmless closed-fence documentation sample AND a genuinely
|
|
785
|
+
// fence-straddled `result: blocked` row — exempting the tally cannot
|
|
786
|
+
// exempt only the harmless case, it also publishes a milestone percentage
|
|
787
|
+
// over a real unread outstanding row. `SCOPE.TRUNCATED` means the scan
|
|
788
|
+
// could not SEE part of the evidence (src/planning-scope.cts), which is
|
|
789
|
+
// exactly the fence-straddled case. The accepted over-report itself is
|
|
790
|
+
// unchanged and still documented at src/uat.cts; what changed is only
|
|
791
|
+
// that it no longer buys an exemption from the fold.
|
|
792
|
+
const { items: fileItems, headingsSeen } = parseUatItemsWithStats(doc.text);
|
|
793
|
+
items.push(...fileItems);
|
|
794
|
+
if (headingsSeen > 0) {
|
|
795
|
+
diagnostics.push({
|
|
796
|
+
code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE,
|
|
797
|
+
subject: `${phaseDirName}/${file}`,
|
|
798
|
+
detail: `UAT document has ${headingsSeen} test block(s) with no parseable result; unresolved is not a complete answer.`,
|
|
799
|
+
});
|
|
800
|
+
scope = SCOPE.TRUNCATED;
|
|
801
|
+
foldScope = SCOPE.TRUNCATED;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return { items, scope, foldScope };
|
|
805
|
+
}
|
|
806
|
+
// ─── Progress ─────────────────────────────────────────────────────────────────
|
|
807
|
+
/**
|
|
808
|
+
* ADR-3180 §7.6: the arithmetic is `clampPercent`'s alone (rule 1), a
|
|
809
|
+
* non-positive denominator is 0 not 100 (rule 2), numerator and denominator
|
|
810
|
+
* come from one scoped set (rule 3), and a scope other than COMPLETE renders NO
|
|
811
|
+
* percentage at all (rule 4).
|
|
812
|
+
*/
|
|
813
|
+
function makeFraction(completed, total, scope, subject, diagnostics) {
|
|
814
|
+
if (scope !== SCOPE.COMPLETE) {
|
|
815
|
+
diagnostics.push({
|
|
816
|
+
code: INSPECT_DIAGNOSTIC.PERCENT_WITHHELD,
|
|
817
|
+
subject,
|
|
818
|
+
detail: `Scope is "${scope}"; a percentage derived from an incomplete read would be a confident wrong answer.`,
|
|
819
|
+
});
|
|
820
|
+
return { completed, total, percent: null, scope };
|
|
821
|
+
}
|
|
822
|
+
return { completed, total, percent: clampPercent(completed, total), scope };
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* The active plan position, from STATE.md's `## Current Position` block.
|
|
826
|
+
*
|
|
827
|
+
* ADR-3180 §7.7: `stateFieldValue` owns the #1760 frontmatter-then-body
|
|
828
|
+
* fallback ladder — this composes it, it does not re-derive it. The section
|
|
829
|
+
* slice is load-bearing: `Plan:` canonically lives under `## Current Position`,
|
|
830
|
+
* and a whole-document search would match a historical `Plan:` line in an
|
|
831
|
+
* archive section instead (#2956). A missing section is therefore reported as
|
|
832
|
+
* UNSCOPED rather than silently widened to the whole body.
|
|
833
|
+
*/
|
|
834
|
+
function buildActivePlan(statePath, planningRoot) {
|
|
835
|
+
const doc = readDocument(statePath, planningRoot);
|
|
836
|
+
if (!doc.exists)
|
|
837
|
+
return { value: null, scope: SCOPE.UNSCOPED };
|
|
838
|
+
if (!doc.readable || doc.text === null)
|
|
839
|
+
return { value: null, scope: SCOPE.UNREADABLE };
|
|
840
|
+
const fm = extractFrontmatter(doc.text, statePath);
|
|
841
|
+
const body = stripFrontmatter(doc.text);
|
|
842
|
+
const slice = (0, state_document_cjs_1.stateCurrentPositionSlice)(body);
|
|
843
|
+
return (0, state_document_cjs_1.stateFieldValue)(fm, slice ?? body, 'plan', 'Plan', {
|
|
844
|
+
scope: slice === null ? SCOPE.UNSCOPED : SCOPE.COMPLETE,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* The same `**Depends on:**` line `src/phase.cts`'s phase-insert path writes
|
|
849
|
+
* (`\n**Depends on:** Phase ${afterPhase}`) and `init.cts`'s own
|
|
850
|
+
* phase-listing scan reads (`init.cts:2359`) — mirrored verbatim here rather
|
|
851
|
+
* than re-derived.
|
|
852
|
+
*/
|
|
853
|
+
const DEPENDS_ON_LINE_RE = /\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i;
|
|
854
|
+
/**
|
|
855
|
+
* A bold-annotation line — `**Label:** …` (colon inside the bold run) or
|
|
856
|
+
* `**Label**: …` (colon immediately after it). Matches `**Depends on:**`,
|
|
857
|
+
* `**Plans**:`, `**Goal:**`, and `**Cross-cutting constraints:**` alike.
|
|
858
|
+
*/
|
|
859
|
+
const BOLD_ANNOTATION_LINE_RE = /^\*\*(?:[^*\n]*:[^*\n]*\*\*|[^*\n]*\*\*:)/;
|
|
860
|
+
/** The bare `Plans:` checklist header `cmdRoadmapAnnotateDependencies` emits. */
|
|
861
|
+
const PLANS_CHECKLIST_HEADER_RE = /^plans:$/i;
|
|
862
|
+
/**
|
|
863
|
+
* The prose immediately under a `### Phase N: Name` heading, stopping at the
|
|
864
|
+
* first line that is METADATA rather than prose — issue #2790's own
|
|
865
|
+
* definition of per-phase "goal". The boundary matters because this payload
|
|
866
|
+
* already surfaces every one of those metadata lines in its own typed field
|
|
867
|
+
* (`**Depends on:**` -> `dependencies`, `**Plans**:` / `Plans:` + `- [ ]`
|
|
868
|
+
* rows -> `plans`, wave headers and `**Cross-cutting constraints:**` -> the
|
|
869
|
+
* per-plan rows) — folding them into `goal` too would both duplicate the
|
|
870
|
+
* data and hand a consumer raw Markdown to render. A sub-heading boundary is
|
|
871
|
+
* belt-and-braces: `collectSection` already bounds the body at the next
|
|
872
|
+
* heading.
|
|
873
|
+
*
|
|
874
|
+
* `null` when the section carries no such prose before hitting metadata (a
|
|
875
|
+
* real "no goal", not a failure).
|
|
876
|
+
*/
|
|
877
|
+
function extractGoalProse(sectionBody) {
|
|
878
|
+
const proseLines = [];
|
|
879
|
+
for (const line of sectionBody.split(/\r?\n/)) {
|
|
880
|
+
const trimmed = line.trim();
|
|
881
|
+
if (/^\s{0,3}#{1,6}\s/.test(line))
|
|
882
|
+
break;
|
|
883
|
+
if (/^\s*(?:[-*+]|\d+[.)])\s/.test(line))
|
|
884
|
+
break;
|
|
885
|
+
if (BOLD_ANNOTATION_LINE_RE.test(trimmed))
|
|
886
|
+
break;
|
|
887
|
+
if (PLANS_CHECKLIST_HEADER_RE.test(trimmed))
|
|
888
|
+
break;
|
|
889
|
+
proseLines.push(line);
|
|
890
|
+
}
|
|
891
|
+
const text = proseLines.join('\n').trim();
|
|
892
|
+
return text.length > 0 ? text : null;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Phase tokens off a `**Depends on:**` line — value-level token extraction
|
|
896
|
+
* of ONE already-addressed line (mirrors `parseTraceability`'s Phase-cell
|
|
897
|
+
* token scan above), never a document-wide scan. `[]` when the line is
|
|
898
|
+
* absent.
|
|
899
|
+
*/
|
|
900
|
+
function extractDependencyTokens(sectionBody) {
|
|
901
|
+
const m = DEPENDS_ON_LINE_RE.exec(sectionBody);
|
|
902
|
+
if (!m)
|
|
903
|
+
return [];
|
|
904
|
+
return sortedUnique([...m[1].matchAll(/\d+(?:\.\d+)*/g)].map((t) => t[0]));
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* This phase's own ROADMAP.md section body — milestone-scoped via the SAME
|
|
908
|
+
* `extractCurrentMilestone` seam `planning-snapshot.cts`'s own ROADMAP
|
|
909
|
+
* consumers use (`planning-snapshot.cts:918`), never a document-wide walk.
|
|
910
|
+
* `collectSection` (ADR-2143) owns the heading walk; the predicate is built
|
|
911
|
+
* from the canonical `phaseMarkdownRegexSource` (`phase-id.cts`) anchor —
|
|
912
|
+
* the same start-of-heading anchor `roadmap-parser.cts`'s own phase-section
|
|
913
|
+
* lookups (`withPhaseSection`, `findRoadmapPhaseInContent`) use, so a
|
|
914
|
+
* sibling phase whose TITLE merely mentions this phase's number is never
|
|
915
|
+
* hijacked.
|
|
916
|
+
*/
|
|
917
|
+
function findPhaseRoadmapSection(cwd, roadmapText, phaseId) {
|
|
918
|
+
const scoped = extractCurrentMilestone(roadmapText, cwd);
|
|
919
|
+
const headingRe = new RegExp(`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseId)}(?=[\\s:(]|$)`, 'i');
|
|
920
|
+
const section = collectSection(scoped, (h) => headingRe.test(h.text));
|
|
921
|
+
return section ? section.body : null;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Issue #2790's Summary names "per-phase goal/dependency" as spec elements
|
|
925
|
+
* distinct from the STATUS evidence (`verification` / `roadmap_acceptance` /
|
|
926
|
+
* `uat`) phase rows already carry. Both fields fold the SAME three-way scope
|
|
927
|
+
* every other value in this module carries: `complete` when the section was
|
|
928
|
+
* found and parsed, `unscoped` when ROADMAP.md has no section for this
|
|
929
|
+
* phase, `unreadable` when ROADMAP.md itself could not be read. Never
|
|
930
|
+
* inferred — an absent `**Depends on:**` line is `[]`, not a degraded scope.
|
|
931
|
+
*
|
|
932
|
+
* `getRoadmapPhaseWithFallback` (`roadmap.cts`) and `findRoadmapPhaseInContent`
|
|
933
|
+
* (`roadmap-parser.cts`) were considered first (per this module's own
|
|
934
|
+
* "COMPOSED, NOT RE-DERIVED" rule) but both perform their OWN internal file
|
|
935
|
+
* read, bypassing this module's `readDocument` tri-state (exists/readable/
|
|
936
|
+
* text) — the exact distinction `unreadable` vs `unscoped` needs here, and
|
|
937
|
+
* the one every other section of this module gets via the same seam. Their
|
|
938
|
+
* `goal` extraction also targets an explicit `**Goal:**` bold line, not the
|
|
939
|
+
* free prose immediately under the heading the issue's own fixture expects.
|
|
940
|
+
* `collectSection` + `extractCurrentMilestone`, fed by this module's own
|
|
941
|
+
* `readDocument(paths.roadmap)` read, keeps both the read path and the
|
|
942
|
+
* "found vs missing vs unreadable" distinction singular.
|
|
943
|
+
*/
|
|
944
|
+
function buildPhaseGoalAndDependencies(cwd, roadmapDoc, phaseId, phaseDirLabel, diagnostics) {
|
|
945
|
+
if (!roadmapDoc.readable || roadmapDoc.text === null) {
|
|
946
|
+
diagnostics.push({
|
|
947
|
+
code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
|
|
948
|
+
subject: phaseDirLabel,
|
|
949
|
+
detail: 'ROADMAP.md could not be read; phase goal and dependencies are unknown, not empty.',
|
|
950
|
+
});
|
|
951
|
+
return {
|
|
952
|
+
goal: { value: null, scope: SCOPE.UNREADABLE },
|
|
953
|
+
dependencies: { value: [], scope: SCOPE.UNREADABLE },
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
if (phaseId === null) {
|
|
957
|
+
diagnostics.push({
|
|
958
|
+
code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
|
|
959
|
+
subject: phaseDirLabel,
|
|
960
|
+
detail: 'Phase directory name carries no recognizable phase number; ROADMAP section cannot be located.',
|
|
961
|
+
});
|
|
962
|
+
return {
|
|
963
|
+
goal: { value: null, scope: SCOPE.UNSCOPED },
|
|
964
|
+
dependencies: { value: [], scope: SCOPE.UNSCOPED },
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
const sectionBody = findPhaseRoadmapSection(cwd, roadmapDoc.text, phaseId);
|
|
968
|
+
if (sectionBody === null) {
|
|
969
|
+
diagnostics.push({
|
|
970
|
+
code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
|
|
971
|
+
subject: phaseDirLabel,
|
|
972
|
+
detail: 'ROADMAP.md has no section for this phase; goal and dependencies are non-answers, not empty.',
|
|
973
|
+
});
|
|
974
|
+
return {
|
|
975
|
+
goal: { value: null, scope: SCOPE.UNSCOPED },
|
|
976
|
+
dependencies: { value: [], scope: SCOPE.UNSCOPED },
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
goal: { value: extractGoalProse(sectionBody), scope: SCOPE.COMPLETE },
|
|
981
|
+
dependencies: { value: extractDependencyTokens(sectionBody), scope: SCOPE.COMPLETE },
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
// ─── Entry points ─────────────────────────────────────────────────────────────
|
|
985
|
+
function buildPlanningInspect(cwd) {
|
|
986
|
+
const diagnostics = [];
|
|
987
|
+
const paths = planningPaths(cwd);
|
|
988
|
+
const planningExists = node_fs_1.default.existsSync(paths.planning);
|
|
989
|
+
if (!planningExists) {
|
|
990
|
+
diagnostics.push({
|
|
991
|
+
code: INSPECT_DIAGNOSTIC.PLANNING_ROOT_ABSENT,
|
|
992
|
+
subject: toPosix(paths.planning),
|
|
993
|
+
detail: 'No .planning/ directory; every section below is an empty non-answer, not an empty project.',
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
const snapshot = buildPlanningSnapshot(cwd);
|
|
997
|
+
if (snapshot.milestone.scope !== SCOPE.COMPLETE) {
|
|
998
|
+
diagnostics.push({
|
|
999
|
+
code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED,
|
|
1000
|
+
subject: toPosix(paths.roadmap),
|
|
1001
|
+
detail: `Milestone identity scope is "${snapshot.milestone.scope}"; no version is invented to stand in for it.`,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
const milestoneValue = snapshot.milestone.value;
|
|
1005
|
+
// Phase rows come from the WINDOWED set (this milestone's phases). A dir on
|
|
1006
|
+
// disk that the roadmap never declares is an orphan, reported separately —
|
|
1007
|
+
// it is not silently promoted into `phases`, and it is not silently dropped.
|
|
1008
|
+
const windowed = snapshot.phaseDirs.value;
|
|
1009
|
+
const windowedSet = new Set(windowed);
|
|
1010
|
+
const orphans = snapshot.allPhaseDirNames.value
|
|
1011
|
+
.filter((dir) => !windowedSet.has(dir))
|
|
1012
|
+
.sort();
|
|
1013
|
+
for (const orphan of orphans) {
|
|
1014
|
+
diagnostics.push({
|
|
1015
|
+
code: INSPECT_DIAGNOSTIC.ORPHAN_PHASE_DIR,
|
|
1016
|
+
subject: orphan,
|
|
1017
|
+
detail: 'Phase directory exists on disk but is not declared in the current milestone window.',
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
const checkboxes = snapshot.roadmapPhaseCheckboxes.value;
|
|
1021
|
+
// ROADMAP checkboxes arrive keyed by the BARE phase token the ROADMAP prose
|
|
1022
|
+
// carries ("1"), while phase rows are keyed by on-disk directory name
|
|
1023
|
+
// ("01-auth"). Comparing them raw makes this field null for every
|
|
1024
|
+
// real-world directory — an evidence channel that silently never fires.
|
|
1025
|
+
// Both sides go through the phase-id owners so "1", "01" and "01-auth" are
|
|
1026
|
+
// one phase.
|
|
1027
|
+
const checkboxByPhaseKey = new Map();
|
|
1028
|
+
for (const [token, ticked] of Object.entries(checkboxes)) {
|
|
1029
|
+
checkboxByPhaseKey.set(phaseKeyFromToken(token), ticked);
|
|
1030
|
+
}
|
|
1031
|
+
const phaseSnapshots = snapshot.phases.value;
|
|
1032
|
+
// Canonical per-directory identity keys (`phase-id.cts`'s
|
|
1033
|
+
// `phaseKeyFromDir`), not a raw regex scrape — this is the set
|
|
1034
|
+
// `buildRequirements` below matches Traceability tokens against via
|
|
1035
|
+
// `phaseKeyFromToken`.
|
|
1036
|
+
const knownPhaseKeys = new Set(windowed.map((dir) => phaseKeyFromDir(dir)));
|
|
1037
|
+
// Read once, shared across every phase row's goal/dependencies lookup —
|
|
1038
|
+
// the same `readDocument` seam every other document read in this module
|
|
1039
|
+
// uses, never a second file-reading path.
|
|
1040
|
+
const roadmapDoc = readDocument(paths.roadmap, paths.planning);
|
|
1041
|
+
const phaseRows = phaseSnapshots.map((phase) => {
|
|
1042
|
+
const phaseDir = node_path_1.default.join(paths.phases, phase.dir);
|
|
1043
|
+
const plans = buildPlanRows(phaseDir, diagnostics, paths.planning);
|
|
1044
|
+
const uat = buildUatRows(paths.phases, phase.dir, diagnostics, paths.planning);
|
|
1045
|
+
// GAP 2 (#2790 follow-up security review): `readVerificationStatus`
|
|
1046
|
+
// (`src/verification.cts`) is a shared owner with its own unguarded
|
|
1047
|
+
// `readFileSync` — a `*-VERIFICATION.md` symlinked outside the planning
|
|
1048
|
+
// root would leak an unrecognized `status:` value verbatim via its
|
|
1049
|
+
// "Unexpected verification status '<value>'" `next_action` string. Fixed
|
|
1050
|
+
// from THIS consumer's side via the injectable `opts.fs` seam that
|
|
1051
|
+
// function already exposes, never by touching its signature — see
|
|
1052
|
+
// `containmentEnforcingVerificationFs`'s doc comment. This same seam's
|
|
1053
|
+
// `readdirSync(phaseDir)` guard also independently covers the
|
|
1054
|
+
// escaped-phase-DIRECTORY case for this call site (GAP 1 above only
|
|
1055
|
+
// gates `buildPlanRows`/`buildUatRows`, not this one).
|
|
1056
|
+
//
|
|
1057
|
+
// `src/plan-scan.cts`'s `isPlanSuperseded` similarly reads
|
|
1058
|
+
// symlink-followed content with no containment of its own, but it is
|
|
1059
|
+
// reached only via `scanPhasePlans(phaseDir)` inside `buildPlanRows`
|
|
1060
|
+
// above (never touched here), leaks only a derived boolean
|
|
1061
|
+
// (`superseded`) rather than document text, and GAP 1's directory
|
|
1062
|
+
// containment check already covers the escaped-DIRECTORY case for it —
|
|
1063
|
+
// so it needs no fix of its own.
|
|
1064
|
+
const verification = readVerificationStatus(phaseDir, {
|
|
1065
|
+
fs: containmentEnforcingVerificationFs(paths.planning),
|
|
1066
|
+
});
|
|
1067
|
+
const token = /^(\d+(?:\.\d+)*)/.exec(phase.dir);
|
|
1068
|
+
const phaseId = token ? token[1] : null;
|
|
1069
|
+
const { goal, dependencies } = buildPhaseGoalAndDependencies(cwd, roadmapDoc, phaseId, phase.dir, diagnostics);
|
|
1070
|
+
// `uat.foldScope`, NOT `uat.scope` (#3078 round-8). The two currently
|
|
1071
|
+
// agree at every call site — see `buildUatRows` for why the field is
|
|
1072
|
+
// still kept separate — so folding either one here produces the same
|
|
1073
|
+
// result today. `foldScope` is used because it is the field with teeth:
|
|
1074
|
+
// it is what `worstScope` folds into the phase's overall scope, and a
|
|
1075
|
+
// non-COMPLETE result here raises `phase_scope_degraded` and, via
|
|
1076
|
+
// `makeFraction`, withholds the milestone's percentages. A phase whose
|
|
1077
|
+
// UAT document could not be fully read must not contribute an
|
|
1078
|
+
// affirmative completion to the milestone.
|
|
1079
|
+
const folded = worstScope(phase.scope, plans.scope, uat.foldScope, goal.scope, dependencies.scope);
|
|
1080
|
+
if (folded !== SCOPE.COMPLETE) {
|
|
1081
|
+
diagnostics.push({
|
|
1082
|
+
code: INSPECT_DIAGNOSTIC.PHASE_SCOPE_DEGRADED,
|
|
1083
|
+
subject: phase.dir,
|
|
1084
|
+
detail: `Phase evidence is incomplete (scope "${folded}").`,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
return {
|
|
1088
|
+
dir: phase.dir,
|
|
1089
|
+
phase_id: phaseId,
|
|
1090
|
+
complete: phase.complete,
|
|
1091
|
+
goal,
|
|
1092
|
+
dependencies,
|
|
1093
|
+
// The three evidence sources are reported SIDE BY SIDE and never folded
|
|
1094
|
+
// into one verdict — folding them is precisely the confidently-wrong
|
|
1095
|
+
// composite ADR-3180 exists to remove.
|
|
1096
|
+
verification: {
|
|
1097
|
+
status: verification.status,
|
|
1098
|
+
next_action: verification.next_action ?? null,
|
|
1099
|
+
},
|
|
1100
|
+
roadmap_acceptance: {
|
|
1101
|
+
checkbox: checkboxByPhaseKey.has(phaseKeyFromDir(phase.dir))
|
|
1102
|
+
? checkboxByPhaseKey.get(phaseKeyFromDir(phase.dir))
|
|
1103
|
+
: null,
|
|
1104
|
+
// ADR-3180 §7.4: a ticked checkbox is a human annotation with no
|
|
1105
|
+
// machine authority. Stated in the payload so a consumer cannot
|
|
1106
|
+
// mistake it for a completion signal.
|
|
1107
|
+
authoritative: false,
|
|
1108
|
+
},
|
|
1109
|
+
uat: { unresolved: uat.items, scope: uat.scope },
|
|
1110
|
+
plan_count: phase.planCount,
|
|
1111
|
+
summary_count: phase.summaryCount,
|
|
1112
|
+
plans: plans.rows,
|
|
1113
|
+
scope: folded,
|
|
1114
|
+
};
|
|
1115
|
+
});
|
|
1116
|
+
const requirements = buildRequirements(paths.requirements, knownPhaseKeys, diagnostics, paths.planning);
|
|
1117
|
+
const phaseScope = worstScope(snapshot.phaseDirs.scope, snapshot.phases.scope, ...phaseRows.map((p) => p.scope));
|
|
1118
|
+
const acceptedPhases = makeFraction(phaseRows.filter((p) => p.complete).length, phaseRows.length, phaseScope, 'progress.accepted_phases', diagnostics);
|
|
1119
|
+
const completedPlans = makeFraction(phaseRows.reduce((sum, p) => sum + p.summary_count, 0), phaseRows.reduce((sum, p) => sum + p.plan_count, 0), phaseScope, 'progress.completed_plans', diagnostics);
|
|
1120
|
+
return {
|
|
1121
|
+
schema_version: PLANNING_INSPECT_SCHEMA_VERSION,
|
|
1122
|
+
generated_from: {
|
|
1123
|
+
cwd: toPosix(cwd),
|
|
1124
|
+
planning_root: planningExists ? toPosix(paths.planning) : null,
|
|
1125
|
+
},
|
|
1126
|
+
milestone: {
|
|
1127
|
+
version: milestoneValue && milestoneValue.version ? milestoneValue.version : null,
|
|
1128
|
+
name: milestoneValue && milestoneValue.name ? milestoneValue.name : null,
|
|
1129
|
+
scope: snapshot.milestone.scope,
|
|
1130
|
+
},
|
|
1131
|
+
// Three DISTINCT STATE.md facts, each from its own source. Collapsing any
|
|
1132
|
+
// two of them is the confidently-wrong composite this schema exists to
|
|
1133
|
+
// avoid: `Status:` is a lifecycle label, `Plan:` is a position.
|
|
1134
|
+
active: {
|
|
1135
|
+
phase: { value: snapshot.currentPhaseLabel.value, scope: snapshot.currentPhaseLabel.scope },
|
|
1136
|
+
plan: buildActivePlan(paths.state, paths.planning),
|
|
1137
|
+
status: { value: snapshot.stateStatus.value, scope: snapshot.stateStatus.scope },
|
|
1138
|
+
},
|
|
1139
|
+
phases: phaseRows,
|
|
1140
|
+
orphan_phase_dirs: orphans,
|
|
1141
|
+
requirements: requirements.rows,
|
|
1142
|
+
progress: {
|
|
1143
|
+
accepted_phases: acceptedPhases,
|
|
1144
|
+
completed_plans: completedPlans,
|
|
1145
|
+
},
|
|
1146
|
+
diagnostics,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* `planning inspect` — emit the schema-v1 snapshot.
|
|
1151
|
+
*
|
|
1152
|
+
* `output()` is the spill seam: a payload over 50 KB is written to a tmpfile and
|
|
1153
|
+
* returned as `@file:<path>`, which `gsd-tools`' `resolveAtFileOutput` resolves
|
|
1154
|
+
* transparently on stdout. Bypassing `output()` would lose that for free.
|
|
1155
|
+
*/
|
|
1156
|
+
function cmdPlanningInspect(cwd, raw) {
|
|
1157
|
+
output(buildPlanningInspect(cwd), raw);
|
|
1158
|
+
}
|
|
1159
|
+
const planningInspect = {
|
|
1160
|
+
PLANNING_INSPECT_SCHEMA_VERSION,
|
|
1161
|
+
INSPECT_DIAGNOSTIC,
|
|
1162
|
+
TASK_STATUS,
|
|
1163
|
+
PROVENANCE,
|
|
1164
|
+
AGREEMENT,
|
|
1165
|
+
buildPlanningInspect,
|
|
1166
|
+
cmdPlanningInspect,
|
|
1167
|
+
};
|
|
1168
|
+
module.exports = planningInspect;
|