@opengsd/gsd-core 1.15.0 → 1.17.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 -2
- package/.opencode/plugins/gsd-core.js +336 -6
- package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
- package/GEMINI.md +1 -1
- package/agents/gsd-code-fixer.compact.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.compact.md +5 -3
- package/agents/gsd-code-reviewer.md +8 -6
- package/agents/gsd-debug-session-manager.compact.md +7 -1
- package/agents/gsd-debug-session-manager.md +7 -1
- package/agents/gsd-debugger.md +2 -2
- package/agents/gsd-eval-auditor.compact.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +4 -4
- package/agents/gsd-intel-updater.compact.md +1 -1
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +2 -2
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-plan-checker.md +7 -2
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-project-researcher.compact.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-ui-auditor.compact.md +21 -30
- package/agents/gsd-ui-auditor.md +42 -51
- package/agents/gsd-ui-researcher.compact.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +31 -9
- package/bin/gsd-mcp-server.js +3 -1
- package/bin/install.js +487 -290
- package/commands/gsd/add-tests.md +6 -1
- package/commands/gsd/ai-integration-phase.md +6 -1
- package/commands/gsd/audit-fix.md +5 -0
- package/commands/gsd/audit-milestone.md +6 -1
- package/commands/gsd/autonomous.md +5 -0
- package/commands/gsd/capture.md +8 -4
- package/commands/gsd/code-review.md +7 -2
- package/commands/gsd/complete-milestone.md +4 -0
- package/commands/gsd/config.md +7 -3
- package/commands/gsd/debug.md +11 -7
- package/commands/gsd/discuss-phase.md +7 -3
- package/commands/gsd/docs-update.md +12 -7
- package/commands/gsd/eval-review.md +6 -1
- package/commands/gsd/execute-phase.md +12 -7
- package/commands/gsd/extract-learnings.md +5 -0
- package/commands/gsd/fast.md +4 -0
- package/commands/gsd/forensics.md +5 -1
- package/commands/gsd/graphify.md +10 -6
- package/commands/gsd/health.md +5 -0
- package/commands/gsd/help.md +7 -2
- package/commands/gsd/import.md +7 -3
- package/commands/gsd/inbox.md +5 -0
- package/commands/gsd/ingest-docs.md +5 -1
- package/commands/gsd/manager.md +6 -1
- package/commands/gsd/map-codebase.md +7 -3
- package/commands/gsd/mempalace-capture.md +5 -1
- package/commands/gsd/mempalace-recall.md +5 -1
- package/commands/gsd/milestone-summary.md +5 -1
- package/commands/gsd/mvp-phase.md +8 -3
- package/commands/gsd/new-milestone.md +6 -1
- package/commands/gsd/new-project.md +5 -0
- package/commands/gsd/next.md +6 -1
- package/commands/gsd/ns-context.md +4 -0
- package/commands/gsd/ns-ideate.md +4 -0
- package/commands/gsd/ns-manage.md +4 -0
- package/commands/gsd/ns-project.md +4 -0
- package/commands/gsd/ns-review.md +4 -0
- package/commands/gsd/ns-workflow.md +4 -0
- package/commands/gsd/onboard.md +6 -1
- package/commands/gsd/pause-work.md +5 -1
- package/commands/gsd/phase.md +8 -4
- package/commands/gsd/plan-phase.md +6 -1
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/commands/gsd/pr-branch.md +4 -0
- package/commands/gsd/profile-user.md +5 -1
- package/commands/gsd/progress.md +6 -1
- package/commands/gsd/quick-batch.md +20 -8
- package/commands/gsd/quick.md +12 -7
- package/commands/gsd/review.md +5 -1
- package/commands/gsd/secure-phase.md +6 -1
- package/commands/gsd/ship.md +5 -0
- package/commands/gsd/sketch.md +7 -2
- package/commands/gsd/spec-phase.md +5 -1
- package/commands/gsd/spike.md +8 -3
- package/commands/gsd/surface.md +5 -1
- package/commands/gsd/thread.md +4 -0
- package/commands/gsd/ui-phase.md +6 -1
- package/commands/gsd/ui-review.md +6 -1
- package/commands/gsd/ultraplan-phase.md +5 -1
- package/commands/gsd/undo.md +5 -1
- package/commands/gsd/update.md +6 -2
- package/commands/gsd/validate-phase.md +6 -1
- package/commands/gsd/verify-work.md +6 -1
- package/commands/gsd/workspace.md +7 -3
- package/gsd-core/bin/gsd-tools.cjs +159 -58
- package/gsd-core/bin/lib/active-workstream-store.cjs +15 -0
- package/gsd-core/bin/lib/adr-parser.cjs +37 -5
- package/gsd-core/bin/lib/agent-install-check.cjs +4 -1
- package/gsd-core/bin/lib/audit.cjs +78 -44
- package/gsd-core/bin/lib/broken-windows.cjs +13 -13
- package/gsd-core/bin/lib/capability-activation.cjs +9 -4
- package/gsd-core/bin/lib/capability-registry.cjs +249 -117
- package/gsd-core/bin/lib/capability-validator.cjs +18 -1
- package/gsd-core/bin/lib/capability-writer.cjs +2 -1
- package/gsd-core/bin/lib/check-auto-mode.cjs +35 -0
- package/gsd-core/bin/lib/check-command-router.cjs +164 -1712
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +13 -2
- package/gsd-core/bin/lib/cli-exit.cjs +12 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +11 -8
- package/gsd-core/bin/lib/command-aliases.cjs +7 -0
- package/gsd-core/bin/lib/command-routing-hub.cjs +48 -1
- package/gsd-core/bin/lib/commands.cjs +201 -178
- package/gsd-core/bin/lib/complexity-trigger.cjs +8 -7
- package/gsd-core/bin/lib/config.cjs +43 -12
- package/gsd-core/bin/lib/core-utils.cjs +6 -1
- package/gsd-core/bin/lib/coverage.cjs +4 -8
- package/gsd-core/bin/lib/decision-coverage-support.cjs +259 -0
- package/gsd-core/bin/lib/drift.cjs +177 -42
- package/gsd-core/bin/lib/frontmatter-fence.cjs +90 -0
- package/gsd-core/bin/lib/frontmatter-splice.cjs +494 -0
- package/gsd-core/bin/lib/frontmatter.cjs +413 -234
- package/gsd-core/bin/lib/gap-checker.cjs +72 -29
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +381 -0
- package/gsd-core/bin/lib/gate-args.cjs +53 -0
- package/gsd-core/bin/lib/gate-codebase-drift.cjs +285 -0
- package/gsd-core/bin/lib/gate-config.cjs +46 -0
- package/gsd-core/bin/lib/gate-context-drift.cjs +142 -0
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +169 -0
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +126 -0
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +555 -0
- package/gsd-core/bin/lib/gate-evidence.cjs +138 -0
- package/gsd-core/bin/lib/gate-exit.cjs +27 -0
- package/gsd-core/bin/lib/gate-gap-analysis-plan-post.cjs +61 -0
- package/gsd-core/bin/lib/gate-phase-context.cjs +170 -0
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +1 -1
- package/gsd-core/bin/lib/gate-predicate.cjs +165 -0
- package/gsd-core/bin/lib/gate-prohibition-enforcement.cjs +94 -0
- package/gsd-core/bin/lib/gate-schema-drift.cjs +165 -0
- package/gsd-core/bin/lib/gate-tdd-red-evidence.cjs +100 -0
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +182 -0
- package/gsd-core/bin/lib/gate-ui-plan.cjs +86 -0
- package/gsd-core/bin/lib/gate-ui-safety.cjs +81 -0
- package/gsd-core/bin/lib/gate-verdict.cjs +64 -0
- package/gsd-core/bin/lib/gate-verify-command-paths.cjs +78 -0
- package/gsd-core/bin/lib/gate-verify-failure-directions.cjs +41 -0
- package/gsd-core/bin/lib/graphify.cjs +3 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +43 -47
- package/gsd-core/bin/lib/health-diagnostic.cjs +45 -8
- package/gsd-core/bin/lib/init.cjs +194 -108
- package/gsd-core/bin/lib/install-engine.cjs +30 -35
- package/gsd-core/bin/lib/install-profiles.cjs +7 -4
- package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
- package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
- package/gsd-core/bin/lib/io.cjs +122 -3
- package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
- package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
- package/gsd-core/bin/lib/loop-resolver.cjs +95 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +75 -1
- package/gsd-core/bin/lib/mcp-server.cjs +5 -1
- package/gsd-core/bin/lib/milestone.cjs +19 -1
- package/gsd-core/bin/lib/model-resolver.cjs +18 -18
- package/gsd-core/bin/lib/observability/event.cjs +1 -1
- package/gsd-core/bin/lib/observability/logger.cjs +46 -1
- package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
- package/gsd-core/bin/lib/opencode-version.cjs +486 -0
- package/gsd-core/bin/lib/pattern.cjs +10 -0
- package/gsd-core/bin/lib/phase-command-router.cjs +11 -4
- package/gsd-core/bin/lib/phase-estimation.cjs +5 -4
- package/gsd-core/bin/lib/phase-id.cjs +1 -1
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -2
- package/gsd-core/bin/lib/phase-status.cjs +360 -0
- package/gsd-core/bin/lib/phase.cjs +301 -80
- package/gsd-core/bin/lib/plan-document.cjs +93 -19
- package/gsd-core/bin/lib/plan-drift-guard.cjs +5 -0
- package/gsd-core/bin/lib/planning-document.cjs +273 -40
- package/gsd-core/bin/lib/planning-inspect.cjs +42 -8
- package/gsd-core/bin/lib/planning-snapshot.cjs +18 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +76 -53
- package/gsd-core/bin/lib/pristine-baseline.cjs +10 -0
- package/gsd-core/bin/lib/profile-output.cjs +6 -3
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +0 -55
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +35 -9
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +13 -9
- package/gsd-core/bin/lib/real-home-guard.cjs +9 -1
- package/gsd-core/bin/lib/report-parser.cjs +269 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +13 -15
- package/gsd-core/bin/lib/roadmap-parser.cjs +82 -15
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +127 -65
- package/gsd-core/bin/lib/roadmap.cjs +169 -72
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +186 -161
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +57 -1
- package/gsd-core/bin/lib/runtime-homes.cjs +13 -10
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +365 -594
- package/gsd-core/bin/lib/runtime-name-policy.cjs +142 -27
- package/gsd-core/bin/lib/runtime-slash.cjs +60 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +37 -2
- package/gsd-core/bin/lib/smart-entry.cjs +111 -56
- package/gsd-core/bin/lib/stale-bake-guard.cjs +32 -48
- package/gsd-core/bin/lib/state-contract.cjs +15 -18
- package/gsd-core/bin/lib/state-document.cjs +100 -22
- package/gsd-core/bin/lib/state-transition.cjs +67 -9
- package/gsd-core/bin/lib/state.cjs +668 -171
- package/gsd-core/bin/lib/surface.cjs +2 -1
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +48 -152
- package/gsd-core/bin/lib/uat-predicate.cjs +313 -35
- package/gsd-core/bin/lib/uat.cjs +424 -7
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +94 -58
- package/gsd-core/bin/lib/update-context.cjs +18 -1
- package/gsd-core/bin/lib/vendor/README.md +31 -9
- package/gsd-core/bin/lib/vendor/saxes.cjs +1934 -0
- package/gsd-core/bin/lib/vendor/saxes.cjs.LICENSE.txt +92 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs +8927 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs.LICENSE.txt +152 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +1265 -324
- package/gsd-core/bin/lib/verify-command-router.cjs +18 -7
- package/gsd-core/bin/lib/verify.cjs +286 -567
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +14 -6
- package/gsd-core/bin/lib/workstream-inventory.cjs +31 -21
- package/gsd-core/bin/lib/workstream-name-policy.cjs +31 -1
- package/gsd-core/bin/lib/workstream.cjs +11 -2
- package/gsd-core/bin/shared/config-defaults.manifest.json +5 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -0
- package/gsd-core/references/autonomous-smart-discuss.md +2 -1
- package/gsd-core/references/autonomous-ui-design-contract.md +3 -3
- package/gsd-core/references/execute-mvp-tdd.md +5 -10
- package/gsd-core/references/execute-phase-between-wave-reset.md +3 -0
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +7 -1
- package/gsd-core/references/offer-next.md +1 -1
- package/gsd-core/references/planning-config.md +1 -1
- package/gsd-core/references/spidr-splitting.md +1 -1
- package/gsd-core/references/tdd.md +10 -4
- package/gsd-core/references/verifier-phase-gates.md +5 -2
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -3
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/UAT.md +17 -1
- package/gsd-core/templates/config.json +2 -11
- package/gsd-core/templates/verification-report.md +1 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +8 -7
- package/gsd-core/workflows/add-tests.md +3 -2
- package/gsd-core/workflows/add-todo.md +4 -3
- package/gsd-core/workflows/ai-integration-phase.md +3 -2
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +4 -3
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous.md +28 -18
- package/gsd-core/workflows/check-todos.md +6 -5
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +20 -13
- package/gsd-core/workflows/code-review-fix.md +5 -4
- package/gsd-core/workflows/code-review.md +100 -91
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +4 -3
- package/gsd-core/workflows/complete-milestone.md +12 -7
- package/gsd-core/workflows/debug.md +30 -5
- package/gsd-core/workflows/diagnose-issues.md +3 -2
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +4 -3
- package/gsd-core/workflows/do.md +1 -1
- package/gsd-core/workflows/docs-update.md +4 -3
- package/gsd-core/workflows/edit-phase.md +4 -3
- package/gsd-core/workflows/eval-review.md +5 -3
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +4 -2
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +15 -4
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +10 -7
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +3 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +187 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +2 -3
- package/gsd-core/workflows/execute-phase.md +81 -171
- package/gsd-core/workflows/execute-plan.md +13 -19
- package/gsd-core/workflows/explore.md +2 -2
- package/gsd-core/workflows/extract-learnings.md +3 -2
- package/gsd-core/workflows/fast.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +2 -1
- package/gsd-core/workflows/import.md +3 -2
- package/gsd-core/workflows/inbox.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +4 -3
- package/gsd-core/workflows/list-seeds.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +5 -3
- package/gsd-core/workflows/map-codebase.md +4 -3
- package/gsd-core/workflows/milestone-summary.md +3 -2
- package/gsd-core/workflows/mvp-phase.md +14 -14
- package/gsd-core/workflows/new-milestone.md +10 -10
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +1 -1
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/pause-work.md +2 -2
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +1 -1
- package/gsd-core/workflows/plan-phase.md +27 -18
- package/gsd-core/workflows/plan-review-convergence.md +1 -1
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +1 -1
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress.md +19 -49
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -2
- package/gsd-core/workflows/quick/steps/quick-verification.md +4 -4
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +30 -11
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +9 -3
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
- package/gsd-core/workflows/quick-batch.md +15 -10
- package/gsd-core/workflows/quick.md +42 -31
- package/gsd-core/workflows/remove-phase.md +3 -2
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +17 -12
- package/gsd-core/workflows/review.md +3 -2
- package/gsd-core/workflows/scan.md +3 -2
- package/gsd-core/workflows/secure-phase.md +11 -12
- package/gsd-core/workflows/settings-advanced.md +1 -1
- package/gsd-core/workflows/settings-integrations.md +1 -1
- package/gsd-core/workflows/settings.md +1 -1
- package/gsd-core/workflows/ship.md +12 -12
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/sync-skills.md +1 -1
- package/gsd-core/workflows/thread.md +2 -2
- package/gsd-core/workflows/transition.md +13 -23
- package/gsd-core/workflows/ui-phase.md +5 -4
- package/gsd-core/workflows/ui-review.md +4 -3
- package/gsd-core/workflows/ultraplan-phase.md +3 -2
- package/gsd-core/workflows/undo.md +1 -1
- package/gsd-core/workflows/validate-phase.md +10 -12
- package/gsd-core/workflows/verify-work/detail/elaboration.md +43 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -3
- package/gsd-core/workflows/verify-work.md +68 -59
- package/hooks/dist/gsd-agent-isolation-guard.js +8 -0
- package/hooks/dist/gsd-check-update-worker.js +8 -0
- package/hooks/dist/gsd-check-update.js +34 -20
- package/hooks/dist/gsd-context-monitor.js +45 -13
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -0
- package/hooks/dist/gsd-secret-read-guard.js +263 -16
- package/hooks/dist/gsd-statusline.js +336 -29
- package/hooks/dist/gsd-update-banner.js +8 -0
- package/hooks/dist/gsd-windsurf-pre-write.js +11 -2
- package/hooks/dist/gsd-workflow-guard.js +5 -4
- package/hooks/dist/gsd-worktree-path-guard.js +6 -2
- package/hooks/dist/lib/cli-exit.js +12 -0
- package/hooks/dist/lib/git-probe.js +17 -1
- package/hooks/dist/lib/isolation-sentinel.js +2 -2
- package/hooks/gsd-agent-isolation-guard.js +8 -0
- package/hooks/gsd-check-update-worker.js +8 -0
- package/hooks/gsd-check-update.js +34 -20
- package/hooks/gsd-context-monitor.js +45 -13
- package/hooks/gsd-cursor-subagent-start.js +8 -0
- package/hooks/gsd-secret-read-guard.js +263 -16
- package/hooks/gsd-statusline.js +336 -29
- package/hooks/gsd-update-banner.js +8 -0
- package/hooks/gsd-windsurf-pre-write.js +11 -2
- package/hooks/gsd-workflow-guard.js +5 -4
- package/hooks/gsd-worktree-path-guard.js +6 -2
- package/hooks/hooks.json +5 -5
- package/hooks/lib/cli-exit.js +12 -0
- package/hooks/lib/git-probe.js +17 -1
- package/hooks/lib/isolation-sentinel.js +2 -2
- package/package.json +21 -4
- package/scripts/changeset/parse.cjs +52 -4
- package/scripts/ci-timeout-report.cjs +770 -4
- package/scripts/command-contract-helpers.cjs +12 -8
- package/scripts/docs-guard-registry.cjs +6 -0
- package/scripts/gen-capability-registry.cjs +7 -1
- package/scripts/gen-features.cjs +13 -8
- package/scripts/gen-hooks-cli-exit.cjs +12 -28
- package/scripts/gen-loop-host-contract.cjs +14 -2
- package/scripts/gen-platform-conformance-tier.cjs +187 -1
- package/scripts/gen-plugin-skills.cjs +87 -1
- package/scripts/gen-research-agents.cjs +24 -31
- package/scripts/gen-scripts-cli-exit.cjs +30 -3
- package/scripts/gen-test-timings.cjs +32 -7
- package/scripts/lib/cli-exit.cjs +12 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +24 -2
- package/scripts/lib/ndjson-reporter.cjs +28 -3
- package/scripts/lib/platform-conformance-tier.generated.cjs +44 -5
- package/scripts/lib/registration-ledger-preload.cjs +155 -0
- package/scripts/lib/vendor-bundle.cjs +59 -0
- package/scripts/lib/vendor-licenses/saxes-6.0.0.txt +64 -0
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-completion-predicate-drift.cjs +18 -19
- package/scripts/lint-descriptions.cjs +7 -3
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +10 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +20 -0
- package/scripts/lint-frontmatter-fence-drift.cjs +313 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +122 -16
- package/scripts/lint-phase-enumeration-drift.cjs +12 -8
- package/scripts/lint-phase-id-drift.cjs +29 -0
- package/scripts/lint-planning-document-positive-control.cjs +329 -0
- package/scripts/lint-response-language-coverage.cjs +3 -0
- package/scripts/lint-skill-deps.cjs +7 -3
- package/scripts/lint-test-file-count.allowlist.json +11 -1
- package/scripts/lint-test-file-count.cjs +34 -1
- package/scripts/lint-vendored-deps.cjs +41 -5
- package/scripts/lint-workflow-shellcheck-baseline.json +10 -10
- package/scripts/mutation-matrix.cjs +53 -6
- package/scripts/release-tarball-smoke.cjs +14 -7
- package/scripts/require-issue-link-policy.cjs +6 -2
- package/scripts/sync-runtime-launcher.cjs +184 -2
- package/scripts/verify-npm-publish.cjs +76 -20
- package/skills/gsd-add-tests/SKILL.md +6 -1
- package/skills/gsd-ai-integration-phase/SKILL.md +6 -1
- package/skills/gsd-audit-fix/SKILL.md +5 -0
- package/skills/gsd-audit-milestone/SKILL.md +6 -1
- package/skills/gsd-autonomous/SKILL.md +5 -0
- package/skills/gsd-capture/SKILL.md +8 -4
- package/skills/gsd-code-review/SKILL.md +7 -2
- package/skills/gsd-complete-milestone/SKILL.md +4 -0
- package/skills/gsd-config/SKILL.md +8 -4
- package/skills/gsd-debug/SKILL.md +11 -7
- package/skills/gsd-discuss-phase/SKILL.md +7 -3
- package/skills/gsd-docs-update/SKILL.md +12 -7
- package/skills/gsd-eval-review/SKILL.md +6 -1
- package/skills/gsd-execute-phase/SKILL.md +12 -7
- package/skills/gsd-extract-learnings/SKILL.md +5 -0
- package/skills/gsd-fast/SKILL.md +4 -0
- package/skills/gsd-forensics/SKILL.md +5 -1
- package/skills/gsd-graphify/SKILL.md +10 -6
- package/skills/gsd-health/SKILL.md +5 -0
- package/skills/gsd-help/SKILL.md +7 -2
- package/skills/gsd-import/SKILL.md +7 -3
- package/skills/gsd-inbox/SKILL.md +5 -0
- package/skills/gsd-ingest-docs/SKILL.md +5 -1
- package/skills/gsd-manager/SKILL.md +6 -1
- package/skills/gsd-map-codebase/SKILL.md +7 -3
- package/skills/gsd-mempalace-capture/SKILL.md +5 -1
- package/skills/gsd-mempalace-recall/SKILL.md +5 -1
- package/skills/gsd-milestone-summary/SKILL.md +5 -1
- package/skills/gsd-mvp-phase/SKILL.md +8 -3
- package/skills/gsd-new-milestone/SKILL.md +6 -1
- package/skills/gsd-new-project/SKILL.md +5 -0
- package/skills/gsd-next/SKILL.md +6 -1
- package/skills/gsd-ns-context/SKILL.md +4 -0
- package/skills/gsd-ns-ideate/SKILL.md +4 -0
- package/skills/gsd-ns-manage/SKILL.md +4 -0
- package/skills/gsd-ns-project/SKILL.md +4 -0
- package/skills/gsd-ns-review/SKILL.md +4 -0
- package/skills/gsd-ns-workflow/SKILL.md +4 -0
- package/skills/gsd-onboard/SKILL.md +6 -1
- package/skills/gsd-pause-work/SKILL.md +5 -1
- package/skills/gsd-phase/SKILL.md +8 -4
- package/skills/gsd-plan-phase/SKILL.md +6 -1
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/skills/gsd-pr-branch/SKILL.md +4 -0
- package/skills/gsd-profile-user/SKILL.md +5 -1
- package/skills/gsd-progress/SKILL.md +6 -1
- package/skills/gsd-quick/SKILL.md +16 -10
- package/skills/gsd-quick-batch/SKILL.md +20 -8
- package/skills/gsd-review/SKILL.md +5 -1
- package/skills/gsd-review-backlog/SKILL.md +3 -2
- package/skills/gsd-secure-phase/SKILL.md +6 -1
- package/skills/gsd-ship/SKILL.md +5 -0
- package/skills/gsd-sketch/SKILL.md +7 -2
- package/skills/gsd-spec-phase/SKILL.md +5 -1
- package/skills/gsd-spike/SKILL.md +8 -3
- package/skills/gsd-surface/SKILL.md +5 -1
- package/skills/gsd-thread/SKILL.md +4 -0
- package/skills/gsd-ui-phase/SKILL.md +6 -1
- package/skills/gsd-ui-review/SKILL.md +6 -1
- package/skills/gsd-ultraplan-phase/SKILL.md +5 -1
- package/skills/gsd-undo/SKILL.md +5 -1
- package/skills/gsd-update/SKILL.md +6 -2
- package/skills/gsd-validate-phase/SKILL.md +6 -1
- package/skills/gsd-verify-work/SKILL.md +6 -1
- package/skills/gsd-workspace/SKILL.md +7 -3
- package/skills/gsd-workstreams/SKILL.md +6 -6
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +0 -24
|
@@ -49,92 +49,268 @@ const planningScopeMod = require("./planning-scope.cjs");
|
|
|
49
49
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
50
50
|
const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
|
|
51
51
|
const security_cjs_1 = require("./security.cjs");
|
|
52
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
53
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
54
|
+
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
55
|
+
const command_arg_projection_cjs_1 = require("./command-arg-projection.cjs");
|
|
52
56
|
const { output, error } = io;
|
|
53
57
|
const { extractPhaseToken, scopeToPhase } = phaseId;
|
|
54
58
|
const { extractFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatterMod;
|
|
55
59
|
const { normalizeLineEndings } = coreUtilsMod;
|
|
56
60
|
const { SCOPE } = planningScopeMod;
|
|
57
|
-
// ───
|
|
58
|
-
/** The set of status values that the gsd-verifier agent emits. */
|
|
59
|
-
const VERIFIER_STATUSES = ['passed', 'gaps_found', 'human_needed'];
|
|
61
|
+
// ─── The closed VerificationStatus enum (#5118, ADR-5057 §1 / Phase 4) ────────
|
|
60
62
|
/**
|
|
61
|
-
*
|
|
63
|
+
* The closed verification-status vocabulary. This module is its ONE owner:
|
|
64
|
+
* every other `src/` site imports `VERIFICATION_STATUS` / `VerificationStatus`
|
|
65
|
+
* (the ESLint rule `local/no-verification-status-literal` keeps a spelled
|
|
66
|
+
* literal out of `src/`), every workflow reads the owner's query fields, and
|
|
67
|
+
* the prose writer contract (`agents/gsd-verifier.md`,
|
|
68
|
+
* `templates/verification-report.md`) is parity-locked to `VERIFIER_STATUSES`.
|
|
62
69
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
70
|
+
* `unknown` is not a member (#5118): it existed only to pass an out-of-set
|
|
71
|
+
* report value along (#4817's `status: verified` → `/gsd-execute-phase`). An
|
|
72
|
+
* out-of-set report value is now a `VerificationStatusError`, thrown where it
|
|
73
|
+
* is read.
|
|
65
74
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
75
|
+
* Reader-only members — never valid in a report's frontmatter:
|
|
76
|
+
* - `stale` the covered inputs moved after the verifier ran
|
|
77
|
+
* - `missing` the phase directory exists and holds no report, or
|
|
78
|
+
* the report has no `status` (the verify step never ran)
|
|
79
|
+
* - `unparseable` the report exists but its frontmatter is not YAML
|
|
80
|
+
* (#4806) — this one meaning only
|
|
81
|
+
* - `phase_dir_not_found` there was no phase directory to look in (ADR-5057
|
|
82
|
+
* amendment 2, #4987) — a usage error, never
|
|
83
|
+
* `execute-phase`
|
|
71
84
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
85
|
+
* An out-of-set report status is NOT a member either: the reader throws, and
|
|
86
|
+
* `isPhaseComplete`'s no-throw projection of it is `status: null` with scope
|
|
87
|
+
* UNREADABLE and `statusError` set (never `unparseable`).
|
|
88
|
+
*/
|
|
89
|
+
const VERIFICATION_STATUS = Object.freeze({
|
|
90
|
+
PASSED: 'passed',
|
|
91
|
+
GAPS_FOUND: 'gaps_found',
|
|
92
|
+
HUMAN_NEEDED: 'human_needed',
|
|
93
|
+
STALE: 'stale',
|
|
94
|
+
MISSING: 'missing',
|
|
95
|
+
UNPARSEABLE: 'unparseable',
|
|
96
|
+
PHASE_DIR_NOT_FOUND: 'phase_dir_not_found',
|
|
97
|
+
});
|
|
98
|
+
/** The set of status values the gsd-verifier agent writes — a frozen subset of the enum. */
|
|
99
|
+
const VERIFIER_STATUSES = Object.freeze(new Set([
|
|
100
|
+
VERIFICATION_STATUS.PASSED,
|
|
101
|
+
VERIFICATION_STATUS.GAPS_FOUND,
|
|
102
|
+
VERIFICATION_STATUS.HUMAN_NEEDED,
|
|
103
|
+
]));
|
|
104
|
+
const VERIFICATION_STATUS_VALUES = new Set(Object.values(VERIFICATION_STATUS));
|
|
105
|
+
/** True exactly when `v` is a member of the closed enum (exact match, no case folding). */
|
|
106
|
+
function isVerificationStatus(v) {
|
|
107
|
+
return typeof v === 'string' && VERIFICATION_STATUS_VALUES.has(v);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Fail where the value is produced (the same rule as phase-status.cts's
|
|
111
|
+
* `assertPhaseStatus`): a non-member is a `TypeError` naming the call site.
|
|
112
|
+
*/
|
|
113
|
+
function assertVerificationStatus(v, where) {
|
|
114
|
+
if (!isVerificationStatus(v)) {
|
|
115
|
+
throw new TypeError(`${where}: ${describeRawStatus(v)} is not a VerificationStatus (expected one of ${[...VERIFICATION_STATUS_VALUES].join(', ')})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** The rendered raw-status token is cut to this many characters (#5118 security review). */
|
|
119
|
+
const RAW_STATUS_TOKEN_LIMIT = 120;
|
|
120
|
+
/**
|
|
121
|
+
* Render an untrusted status value as one quoted, control-free token
|
|
122
|
+
* (io.formatDiagnosticToken escapes C0/C1 controls, line/paragraph
|
|
123
|
+
* separators, zero-width and bidi-override characters and the BOM as
|
|
124
|
+
* `\uXXXX`), truncated to RAW_STATUS_TOKEN_LIMIT characters plus
|
|
125
|
+
* `…(N more)` — a report is agent-written text, and its echo reaches every
|
|
126
|
+
* workflow's LLM context through the error message.
|
|
127
|
+
*/
|
|
128
|
+
function describeRawStatus(raw) {
|
|
129
|
+
let text;
|
|
130
|
+
if (typeof raw === 'string') {
|
|
131
|
+
text = raw;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
let json;
|
|
135
|
+
try {
|
|
136
|
+
json = JSON.stringify(raw);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
json = undefined;
|
|
140
|
+
}
|
|
141
|
+
text = json ?? String(raw);
|
|
142
|
+
}
|
|
143
|
+
const rendered = io.formatDiagnosticToken(text);
|
|
144
|
+
if (rendered.length <= RAW_STATUS_TOKEN_LIMIT)
|
|
145
|
+
return rendered;
|
|
146
|
+
// The cut must land on a boundary: never inside a `\uXXXX` escape the
|
|
147
|
+
// formatter emitted (a fragment such as `\u00` would read as a different
|
|
148
|
+
// character) and never between the halves of a surrogate pair (a lone
|
|
149
|
+
// surrogate is not valid text). Back off to the start of either.
|
|
150
|
+
let cut = RAW_STATUS_TOKEN_LIMIT;
|
|
151
|
+
const partialEscape = /\\u[0-9a-fA-F]{0,3}$/.exec(rendered.slice(0, cut));
|
|
152
|
+
if (partialEscape) {
|
|
153
|
+
cut -= partialEscape[0].length;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const last = rendered.charCodeAt(cut - 1);
|
|
157
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
158
|
+
cut -= 1;
|
|
159
|
+
}
|
|
160
|
+
// JSON also emits two-character escapes (`\n`, `\t`, `\"`, `\\`): an odd
|
|
161
|
+
// trailing run of backslashes is the first half of one, so drop it too.
|
|
162
|
+
const trailingBackslashes = /\\+$/.exec(rendered.slice(0, cut));
|
|
163
|
+
if (trailingBackslashes && trailingBackslashes[0].length % 2 === 1)
|
|
164
|
+
cut -= 1;
|
|
165
|
+
return `${rendered.slice(0, cut)}…(${rendered.length - cut} more)`;
|
|
166
|
+
}
|
|
167
|
+
/** `VerificationStatusError.code` — import this constant wherever the code is matched. */
|
|
168
|
+
const VERIFICATION_STATUS_ERROR_CODE = 'ERR_VERIFICATION_STATUS_OUT_OF_SET';
|
|
169
|
+
/**
|
|
170
|
+
* A report whose frontmatter `status` is outside the writer set
|
|
171
|
+
* (`VERIFIER_STATUSES`) — a string that is not a member (`verified`, `Passed`,
|
|
172
|
+
* a reader-only member such as `stale`), or a non-string value (`5`, `true`,
|
|
173
|
+
* a list). Thrown by the reader, never folded into another status (#4817).
|
|
74
174
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
175
|
+
* It carries its own ERROR_REASON (`reason`, `verification_status_invalid`) —
|
|
176
|
+
* the one owner of that mapping: a CLI surface fails with `error(err.message,
|
|
177
|
+
* err.reason)`, and the command-routing hub returns it as a pure Result whose
|
|
178
|
+
* `kind` is that reason. `isPhaseComplete` maps it to its UNREADABLE scope
|
|
179
|
+
* (`status: null`, `statusError`) so its no-throw contract holds.
|
|
80
180
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
181
|
+
class VerificationStatusError extends Error {
|
|
182
|
+
code = VERIFICATION_STATUS_ERROR_CODE;
|
|
183
|
+
reason = io.ERROR_REASON.VERIFICATION_STATUS_INVALID;
|
|
184
|
+
rawStatus;
|
|
185
|
+
file;
|
|
186
|
+
accepted;
|
|
187
|
+
constructor(rawStatus, file) {
|
|
188
|
+
const accepted = [...VERIFIER_STATUSES];
|
|
189
|
+
super(`Verification report ${io.formatDiagnosticToken(file)} has status ${describeRawStatus(rawStatus)}, ` +
|
|
190
|
+
`which is outside the closed set — accepted values: ${accepted.join(' | ')}. ` +
|
|
191
|
+
`Recovery: set the report's frontmatter \`status:\` to one of ${accepted.join(' | ')}, ` +
|
|
192
|
+
"or delete the report and re-run the phase's verification.");
|
|
193
|
+
this.name = 'VerificationStatusError';
|
|
194
|
+
this.rawStatus = rawStatus;
|
|
195
|
+
this.file = file;
|
|
196
|
+
this.accepted = accepted;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The CLI projection of a VerificationStatusError — its own message and its
|
|
201
|
+
* own `.reason` (the one owner of the reason mapping). Every CLI surface that
|
|
202
|
+
* refuses a report (a thrown error at the entry seam, or an aggregate
|
|
203
|
+
* carrying one in its result) fails through here; nothing restates the reason.
|
|
204
|
+
*/
|
|
205
|
+
function failOnVerificationStatusError(err) {
|
|
206
|
+
return error(err.message, err.reason);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The carry rule every aggregate shares: the FIRST refused report is the one
|
|
210
|
+
* an aggregate fails with. Keeps `carried` once set; otherwise takes
|
|
211
|
+
* `candidate` (a later report never displaces an earlier one).
|
|
212
|
+
*/
|
|
213
|
+
function firstStatusError(carried, candidate) {
|
|
214
|
+
return carried ?? candidate ?? undefined;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The owner's frontmatter-only judgement of a report's `status` — the one
|
|
218
|
+
* place a VERIFICATION report's `status` scalar is read (#5118: phase.cts,
|
|
219
|
+
* audit.cts and uat-predicate.cts used to read it themselves). Returns the
|
|
220
|
+
* writer-set member, or `null` when the report carries no status (absent key,
|
|
221
|
+
* `null`, an empty string, or an unparseable block — the caller routes
|
|
222
|
+
* those). Throws `VerificationStatusError` for anything else, including a
|
|
223
|
+
* non-string value.
|
|
224
|
+
*
|
|
225
|
+
* `fm` is `extractFrontmatter`'s result for the report at `filePath`.
|
|
226
|
+
*/
|
|
227
|
+
function reportStatusOf(fm, filePath) {
|
|
228
|
+
if (fm[FRONTMATTER_UNPARSEABLE] === true)
|
|
229
|
+
return null;
|
|
230
|
+
const raw = fm['status'];
|
|
231
|
+
if (raw === undefined || raw === null)
|
|
232
|
+
return null;
|
|
233
|
+
if (typeof raw === 'string') {
|
|
234
|
+
const trimmed = raw.trim();
|
|
235
|
+
if (trimmed.length === 0)
|
|
236
|
+
return null;
|
|
237
|
+
if (VERIFIER_STATUSES.has(trimmed))
|
|
238
|
+
return trimmed;
|
|
239
|
+
throw new VerificationStatusError(trimmed, filePath);
|
|
240
|
+
}
|
|
241
|
+
throw new VerificationStatusError(raw, filePath);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* VERIFICATION_ROUTES — the one routing table (#5118). Keyed by exactly the
|
|
245
|
+
* enum (`Record<VerificationStatus, …>`: a missing or extra key is a compile
|
|
246
|
+
* error); the key IS the status, so entries carry no `status` field. Every
|
|
247
|
+
* result `readVerificationStatus` returns is projected from this table by
|
|
248
|
+
* `routeResult` — no return site hard-codes a command.
|
|
249
|
+
*
|
|
250
|
+
* #2617: `command` holds a BARE command name (`execute-phase`), never a
|
|
251
|
+
* prefixed one; `routeResult` projects it through `formatGsdSlash` with the
|
|
252
|
+
* caller's runtime, so Codex sees `$gsd-execute-phase` and slash-hyphen
|
|
253
|
+
* runtimes see `/gsd-execute-phase`.
|
|
254
|
+
*
|
|
255
|
+
* `stale` has ONE route: `execute-phase`. Its regenerating action is the
|
|
256
|
+
* shared step `gsd-core/workflows/execute-phase/steps/verify-phase-goal.md`
|
|
257
|
+
* (execute-phase's verify_phase_goal, which verify-work's stale arm includes
|
|
258
|
+
* too): it re-runs the verifier, which regenerates VERIFICATION.md and its
|
|
259
|
+
* digest (#4682, #4887).
|
|
260
|
+
*/
|
|
261
|
+
/**
|
|
262
|
+
* The one remedy for a stale report (#4682 / #5118), shared by the route table and by the more specific
|
|
263
|
+
* diagnostic below so the two cannot drift: only re-running the verifier regenerates VERIFICATION.md.
|
|
264
|
+
*/
|
|
265
|
+
const STALE_REMEDY = 'Re-run execute-phase for this phase: it resumes at the verification gates and re-runs the verifier, regenerating VERIFICATION.md and its digest. verify-work alone cannot refresh a stale report.';
|
|
266
|
+
const VERIFICATION_ROUTES = Object.freeze({
|
|
267
|
+
passed: Object.freeze({
|
|
84
268
|
next_action: 'Verification passed — continue.',
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
269
|
+
command: '',
|
|
270
|
+
tail: '',
|
|
271
|
+
}),
|
|
272
|
+
gaps_found: Object.freeze({
|
|
89
273
|
next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
},
|
|
93
|
-
human_needed: {
|
|
94
|
-
status: 'human_needed',
|
|
274
|
+
command: 'plan-phase',
|
|
275
|
+
tail: ' --gaps',
|
|
276
|
+
}),
|
|
277
|
+
human_needed: Object.freeze({
|
|
95
278
|
next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
|
|
96
|
-
// #2617:
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
next_action: 'Verification is stale — covered source files changed after the verifier last ran. Re-run execute-phase for this phase: it resumes at the verification gates and re-runs the verifier, regenerating VERIFICATION.md and its digest. verify-work alone cannot refresh a stale report.',
|
|
112
|
-
next_command: 'execute-phase',
|
|
113
|
-
},
|
|
114
|
-
// INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
|
|
115
|
-
// the file has no parseable frontmatter status. Never emitted by the verifier.
|
|
116
|
-
missing: {
|
|
117
|
-
status: 'missing',
|
|
279
|
+
// #2617: init's projector and this table used to disagree on whether a
|
|
280
|
+
// next command existed at all; `verify-work <N>` is the useful answer.
|
|
281
|
+
command: 'verify-work',
|
|
282
|
+
tail: '',
|
|
283
|
+
}),
|
|
284
|
+
stale: Object.freeze({
|
|
285
|
+
// #4682 / #5118: the only remedy for a stale report is re-running the
|
|
286
|
+
// verifier; /gsd-verify-work on its own never rewrites VERIFICATION.md.
|
|
287
|
+
next_action: `Verification is stale — covered source files changed after the verifier last ran. ${STALE_REMEDY}`,
|
|
288
|
+
command: 'execute-phase',
|
|
289
|
+
tail: '',
|
|
290
|
+
}),
|
|
291
|
+
// The phase directory exists and holds no report, or the report has no
|
|
292
|
+
// `status`: the verify step never completed (#2868).
|
|
293
|
+
missing: Object.freeze({
|
|
118
294
|
next_action: 'No verification report found — the verify step never completed. Running execute-phase is safe here: it resumes at the verification gates and does not re-run plans that already have a SUMMARY.md (see #2868).',
|
|
119
|
-
|
|
120
|
-
|
|
295
|
+
command: 'execute-phase',
|
|
296
|
+
tail: '',
|
|
297
|
+
}),
|
|
121
298
|
// #4806: the report EXISTS but its frontmatter is not parseable YAML —
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
// status as blocking, so this value fails safe while telling the truth.
|
|
125
|
-
unparseable: {
|
|
126
|
-
status: 'unparseable',
|
|
299
|
+
// re-running execute-phase cannot fix a YAML typo in an existing report.
|
|
300
|
+
unparseable: Object.freeze({
|
|
127
301
|
next_action: "The *-VERIFICATION.md frontmatter is not parseable YAML — fix the syntax error in the report itself. Re-running execute-phase cannot fix a YAML typo in an existing report.",
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
302
|
+
command: '',
|
|
303
|
+
tail: '',
|
|
304
|
+
}),
|
|
305
|
+
// ADR-5057 amendment 2 / #4987: there was nothing to look in — a usage
|
|
306
|
+
// error, never execute-phase (which would re-run a phase that may already be
|
|
307
|
+
// archived under .planning/milestones/).
|
|
308
|
+
phase_dir_not_found: Object.freeze({
|
|
309
|
+
next_action: 'Usage error: the phase directory does not exist — pass an existing phase directory (resolve it with find-phase; phases archived by complete-milestone live under .planning/milestones/v<X.Y>-phases/).',
|
|
310
|
+
command: '',
|
|
311
|
+
tail: '',
|
|
312
|
+
}),
|
|
313
|
+
});
|
|
138
314
|
/**
|
|
139
315
|
* Project a BARE command name (plus optional argument tail) into the surface the
|
|
140
316
|
* given runtime actually installs (#2617).
|
|
@@ -178,11 +354,231 @@ function toPosix(p) {
|
|
|
178
354
|
function normalizeRel(p) {
|
|
179
355
|
return node_path_1.default.posix.normalize(toPosix(p));
|
|
180
356
|
}
|
|
181
|
-
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
|
|
185
|
-
|
|
357
|
+
/**
|
|
358
|
+
* Canonicalize a covered-files list: normalize, de-duplicate, sort — the SAME
|
|
359
|
+
* transform computeCoveredDigest and cmdVerificationFingerprint both need
|
|
360
|
+
* (the digest's own key order; the CLI's own `covered_files` JSON output).
|
|
361
|
+
*
|
|
362
|
+
* #5095 (ADR-5057 Phase 2): `opts.version >= 3` additionally drops any
|
|
363
|
+
* report-shaped path (`isVerificationReportPath`) — a report is never an
|
|
364
|
+
* input to its own digest (#4857). Versionless callers (the default) keep
|
|
365
|
+
* the pre-#5095 behaviour: normalize/dedupe/sort only, no filtering.
|
|
366
|
+
*/
|
|
367
|
+
function canonicalizeCoveredFiles(files, opts = {}) {
|
|
368
|
+
const normalized = files.map(normalizeRel);
|
|
369
|
+
const filtered = opts.version !== undefined && opts.version >= 3
|
|
370
|
+
? normalized.filter((f) => !isVerificationReportPath(f))
|
|
371
|
+
: normalized;
|
|
372
|
+
return Array.from(new Set(filtered)).sort();
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* #5095 (ADR-5057 Phase 2, #4857): a covered-input path names a verification
|
|
376
|
+
* REPORT — basename `VERIFICATION.md` or ending `-VERIFICATION.md`
|
|
377
|
+
* (case-sensitive, as `resolveVerificationFile` is) — as opposed to any other
|
|
378
|
+
* evidence. `computeCoveredDigest(projectRoot, coveredFiles)` never receives
|
|
379
|
+
* the report's OWN path as a distinguished value — the report writes its own
|
|
380
|
+
* digest into its own frontmatter, so a comparison inside that function is
|
|
381
|
+
* structurally impossible (it would need to know, while computing a value,
|
|
382
|
+
* what that value is about to become). The filter therefore lives here, one
|
|
383
|
+
* function up from the digest, as a PATTERN match on shape, not a path
|
|
384
|
+
* identity check. Matches `docs/VERIFICATION.md` (same basename) but not
|
|
385
|
+
* `VERIFICATION-NOTES.md` or `07-VERIFICATION.md.bak` (different basename).
|
|
386
|
+
*/
|
|
387
|
+
function isVerificationReportPath(rel) {
|
|
388
|
+
const basename = node_path_1.default.posix.basename(toPosix(rel));
|
|
389
|
+
return basename === 'VERIFICATION.md' || basename.endsWith('-VERIFICATION.md');
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* #5095 (R7): enumerate every anchored planning scope the Planning Workspace
|
|
393
|
+
* Module's layout admits, by walking the REAL directory tree rooted at
|
|
394
|
+
* `<projectRoot>/.planning` — never the caller's `phaseDir` (the pre-R7 bug:
|
|
395
|
+
* admitting a scope root on `basename(phaseDir's parent) === 'phases'` alone
|
|
396
|
+
* made any directory named `phases` anywhere an admissible root). Only
|
|
397
|
+
* directories are admitted, and a project/workstream segment failing
|
|
398
|
+
* `planningDir`'s own `BAD_SEGMENT` rule (a path separator or `..`) is
|
|
399
|
+
* skipped — the identical validation `planningDir` itself enforces, so every
|
|
400
|
+
* scope this function admits is one `planningDir` could also resolve to.
|
|
401
|
+
* `workstreams` is reserved as a layout segment (never itself a project
|
|
402
|
+
* name), so the walk cannot derive the same scope twice under two labels.
|
|
403
|
+
* Each planning BASE contributes two scopes — the base itself, and its own
|
|
404
|
+
* `phases/` subdirectory — since `phases/` may be independently symlinked to
|
|
405
|
+
* a per-project/per-workstream store while the base directory stays real
|
|
406
|
+
* (the #4894 layout, generalized to every base shape); the longest-`lexRel`
|
|
407
|
+
* -wins rule in `mapRealPathToRootRelative` / `bestPlanningScopeForRel` then
|
|
408
|
+
* prefers the `phases` scope for anything living under it.
|
|
409
|
+
*
|
|
410
|
+
* Security: a scope whose realpath is the filesystem root, or an ANCESTOR of
|
|
411
|
+
* `realRoot` itself, is refused outright (`real: null`) — an
|
|
412
|
+
* attacker-controlled `.planning -> /` (or `-> ..`) symlink must never become
|
|
413
|
+
* an admissible containment root.
|
|
414
|
+
*/
|
|
415
|
+
function enumeratePlanningScopes(projectRoot, realRoot) {
|
|
416
|
+
const BAD_SEGMENT = /[/\\]|\.\./;
|
|
417
|
+
const listDirs = (dirAbs) => {
|
|
418
|
+
try {
|
|
419
|
+
return node_fs_1.default
|
|
420
|
+
.readdirSync(dirAbs, { withFileTypes: true })
|
|
421
|
+
.filter((e) => e.isDirectory() && !BAD_SEGMENT.test(e.name))
|
|
422
|
+
.map((e) => e.name);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
const realOf = (p) => {
|
|
429
|
+
let real;
|
|
430
|
+
try {
|
|
431
|
+
real = node_fs_1.default.realpathSync(p);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
if (node_path_1.default.dirname(real) === real)
|
|
437
|
+
return null; // filesystem root
|
|
438
|
+
if (realRoot !== null && real !== realRoot && (0, security_cjs_1.isContainedIn)(realRoot, real))
|
|
439
|
+
return null; // ancestor of realRoot
|
|
440
|
+
return real;
|
|
441
|
+
};
|
|
442
|
+
// Every planning BASE (a directory `planningDir` itself would resolve to)
|
|
443
|
+
// contributes TWO scopes: the base itself (for a direct child like
|
|
444
|
+
// `ROADMAP.md`/`config.json`) and its OWN `phases/` subdirectory (for phase
|
|
445
|
+
// artifacts) — kept separate because `phases/` may be independently
|
|
446
|
+
// symlinked to a per-project/per-workstream store while the base directory
|
|
447
|
+
// stays real (the #4894 layout, generalized to every base shape). The
|
|
448
|
+
// longest-`lexRel`-wins rule elsewhere then prefers the `phases` scope for
|
|
449
|
+
// anything under it.
|
|
450
|
+
const addBase = (lexRel, baseAbs) => {
|
|
451
|
+
scopes.push({ lexRel, real: realOf(baseAbs) });
|
|
452
|
+
scopes.push({ lexRel: `${lexRel}/phases`, real: realOf(node_path_1.default.join(baseAbs, 'phases')) });
|
|
453
|
+
};
|
|
454
|
+
const scopes = [];
|
|
455
|
+
const planningAbs = node_path_1.default.join(projectRoot, '.planning');
|
|
456
|
+
const planningBaseReal = realOf(planningAbs);
|
|
457
|
+
scopes.push({ lexRel: '.planning', real: planningBaseReal });
|
|
458
|
+
scopes.push({ lexRel: '.planning/phases', real: realOf(node_path_1.default.join(planningAbs, 'phases')) });
|
|
459
|
+
// #5095 (R7(c) follow-up, security): when `.planning` itself was refused as
|
|
460
|
+
// a dangerous root (filesystem root, or an ancestor of `realRoot` — see
|
|
461
|
+
// `realOf` above), NEVER walk its `workstreams`/project subdirectories to
|
|
462
|
+
// mint further scopes. `listDirs`/`path.join` operate on the LEXICAL,
|
|
463
|
+
// still-symlinked `planningAbs`, so with `.planning -> /` every entry of
|
|
464
|
+
// the real filesystem root (`/etc`, `/usr`, ...) would otherwise surface as
|
|
465
|
+
// an admissible `.planning/<project>` scope — legitimizing an attacker- or
|
|
466
|
+
// container-controlled root-filesystem directory as a containment root the
|
|
467
|
+
// instant it happens to share a name with something reachable from `/`.
|
|
468
|
+
// Each such child is realpath-resolved on its OWN merits (not an ancestor
|
|
469
|
+
// of `realRoot`, not the filesystem root itself), so it silently passes the
|
|
470
|
+
// per-scope refusal check even though its only claim to legitimacy is
|
|
471
|
+
// having been discovered by listing a root the outer check already
|
|
472
|
+
// rejected. Refusing to enumerate here is what makes that refusal actually
|
|
473
|
+
// stick.
|
|
474
|
+
if (planningBaseReal === null)
|
|
475
|
+
return scopes;
|
|
476
|
+
for (const ws of listDirs(node_path_1.default.join(planningAbs, 'workstreams'))) {
|
|
477
|
+
addBase(`.planning/workstreams/${ws}`, node_path_1.default.join(planningAbs, 'workstreams', ws));
|
|
478
|
+
}
|
|
479
|
+
for (const project of listDirs(planningAbs)) {
|
|
480
|
+
if (project === 'workstreams')
|
|
481
|
+
continue;
|
|
482
|
+
const projectAbs = node_path_1.default.join(planningAbs, project);
|
|
483
|
+
addBase(`.planning/${project}`, projectAbs);
|
|
484
|
+
for (const ws of listDirs(node_path_1.default.join(projectAbs, 'workstreams'))) {
|
|
485
|
+
addBase(`.planning/${project}/workstreams/${ws}`, node_path_1.default.join(projectAbs, 'workstreams', ws));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return scopes;
|
|
489
|
+
}
|
|
490
|
+
function resolveContainmentRoots(projectRoot) {
|
|
491
|
+
let realRoot;
|
|
492
|
+
try {
|
|
493
|
+
realRoot = node_fs_1.default.realpathSync(projectRoot);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
realRoot = null;
|
|
497
|
+
}
|
|
498
|
+
return { realRoot, planningScopes: enumeratePlanningScopes(projectRoot, realRoot) };
|
|
499
|
+
}
|
|
500
|
+
function bestPlanningScopeForRel(rel, planningScopes) {
|
|
501
|
+
let best = null;
|
|
502
|
+
for (const scope of planningScopes) {
|
|
503
|
+
if ((rel === scope.lexRel || rel.startsWith(`${scope.lexRel}/`)) && // allow-handrolled-containment: lexical PREFIX selection among candidate scope spellings (posix segment-boundary), not a resolved-path containment decision — the realpath check happens separately via isContainedIn on scope.real
|
|
504
|
+
(best === null || scope.lexRel.length > best.lexRel.length)) {
|
|
505
|
+
best = scope;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return best;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* #5095 (R7): map an already-`realpathSync`-resolved target back to a
|
|
512
|
+
* root-relative, posix-normalized spelling. The ANCHORED scope with the
|
|
513
|
+
* LONGEST `lexRel` whose `real` contains the target wins — so a nested scope
|
|
514
|
+
* (e.g. `.planning/workstreams/ws1`) is preferred over the top-level
|
|
515
|
+
* `.planning` scope for a target reachable through both, and a
|
|
516
|
+
* same-named-but-different-store phase under the nested scope is never
|
|
517
|
+
* mapped onto the root scope's spelling. Falls back to a bare
|
|
518
|
+
* project-root-relative path when the target lives inside `realRoot` but no
|
|
519
|
+
* scope's real claims it; else `null` (fail closed — the caller must not
|
|
520
|
+
* guess a spelling for a path it cannot place).
|
|
521
|
+
*/
|
|
522
|
+
function mapRealPathToRootRelative(real, realRoot, planningScopes) {
|
|
523
|
+
let best = null;
|
|
524
|
+
for (const scope of planningScopes) {
|
|
525
|
+
if (scope.real !== null &&
|
|
526
|
+
(0, security_cjs_1.isContainedIn)(real, scope.real) &&
|
|
527
|
+
(best === null || scope.lexRel.length > best.lexRel.length)) {
|
|
528
|
+
best = scope;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (best !== null) {
|
|
532
|
+
const rel = normalizeRel(node_path_1.default.relative(best.real, real));
|
|
533
|
+
return rel === '' || rel === '.' ? best.lexRel : `${best.lexRel}/${rel}`;
|
|
534
|
+
}
|
|
535
|
+
if ((0, security_cjs_1.isContainedIn)(real, realRoot)) {
|
|
536
|
+
return normalizeRel(node_path_1.default.relative(realRoot, real));
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
function phaseArtifactPaths(phaseDir, projectRoot) {
|
|
541
|
+
const roots = resolveContainmentRoots(projectRoot);
|
|
542
|
+
if (roots.realRoot === null) {
|
|
543
|
+
return { ok: false, reason: `project root is unreadable: ${projectRoot}` };
|
|
544
|
+
}
|
|
545
|
+
const scan = scanPhasePlans(phaseDir);
|
|
546
|
+
// Fail closed on a non-COMPLETE scan (unreadable phase dir, or an
|
|
547
|
+
// unreadable nested plans/ dir) — a partial scan's invisible contents can
|
|
548
|
+
// never be proven covered (mirrors uncoveredCurrentArtifacts's own
|
|
549
|
+
// fail-closed contract).
|
|
550
|
+
if (scan.scope !== SCOPE.COMPLETE) {
|
|
551
|
+
return { ok: false, reason: `phase directory scan is incomplete (scope: ${scan.scope}): ${phaseDir}` };
|
|
552
|
+
}
|
|
553
|
+
const candidates = [...scan.allPlanFiles, ...scan.summaryFiles].filter((f) => !isVerificationReportPath(f));
|
|
554
|
+
const paths = [];
|
|
555
|
+
for (const f of candidates) {
|
|
556
|
+
let real;
|
|
557
|
+
try {
|
|
558
|
+
real = node_fs_1.default.realpathSync(node_path_1.default.join(phaseDir, f));
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return { ok: false, reason: `phase artifact is unreadable: ${f}` };
|
|
562
|
+
}
|
|
563
|
+
const mapped = mapRealPathToRootRelative(real, roots.realRoot, roots.planningScopes);
|
|
564
|
+
if (mapped === null) {
|
|
565
|
+
return { ok: false, reason: `phase artifact escapes the project root: ${f}` };
|
|
566
|
+
}
|
|
567
|
+
paths.push(mapped);
|
|
568
|
+
}
|
|
569
|
+
// #5095 (R2): the phase dir's own mapped spelling, fed to `sharedRootsFor`
|
|
570
|
+
// so a workstream/project ROADMAP under an out-of-repo store is still
|
|
571
|
+
// recognized as a shared planning document even when `phaseDir` itself was
|
|
572
|
+
// addressed by its real (non-`.planning`-spelled) path.
|
|
573
|
+
let mappedPhaseDir = null;
|
|
574
|
+
try {
|
|
575
|
+
const realPhaseDir = node_fs_1.default.realpathSync(phaseDir);
|
|
576
|
+
mappedPhaseDir = mapRealPathToRootRelative(realPhaseDir, roots.realRoot, roots.planningScopes);
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
mappedPhaseDir = null;
|
|
580
|
+
}
|
|
581
|
+
return { ok: true, paths, scope: scan.scope, mappedPhaseDir };
|
|
186
582
|
}
|
|
187
583
|
// ─── #4155: covered-input fingerprint ──────────────────────────────────────────
|
|
188
584
|
/**
|
|
@@ -194,16 +590,34 @@ function canonicalizeCoveredFiles(files) {
|
|
|
194
590
|
* v1 (#4155) — every covered path's whole bytes, uniformly.
|
|
195
591
|
* v2 (#4623) — repo-wide planning documents (`isSharedPlanningDoc`) are
|
|
196
592
|
* excluded from the hash by construction.
|
|
593
|
+
* v3 (#5095, ADR-5057 Phase 2) — v2 PLUS: (a) report-shaped declared paths
|
|
594
|
+
* (`isVerificationReportPath`) are filtered before hashing — a
|
|
595
|
+
* report is never an input to its own digest (#4857); (b) the
|
|
596
|
+
* hashed set is the declared list UNIONED with the phase's own
|
|
597
|
+
* live `*-PLAN.md`/`*-SUMMARY.md` artifacts
|
|
598
|
+
* (`phaseArtifactPaths`), computed identically on both the
|
|
599
|
+
* emit (`cmdVerificationFingerprint`) and check
|
|
600
|
+
* (`readVerificationStatus`) sides — a plan/summary added
|
|
601
|
+
* after fingerprinting moves the digest itself, with no
|
|
602
|
+
* separate live-directory rescan needed (#4817); (c)
|
|
603
|
+
* containment additionally admits a `.planning`-spelled path
|
|
604
|
+
* whose realpath sits in one of the anchored planning scopes
|
|
605
|
+
* `resolveContainmentRoots` enumerates (`.planning`,
|
|
606
|
+
* `.planning/<project>`, `.planning/workstreams/<ws>`,
|
|
607
|
+
* `.planning/<project>/workstreams/<ws>`), covering a
|
|
608
|
+
* per-project/per-workstream store symlink (amendment 1,
|
|
609
|
+
* revised R7).
|
|
197
610
|
*
|
|
198
611
|
* A stored digest names its own version (`v<N>:sha256:…`), and
|
|
199
612
|
* `readVerificationStatus` recomputes under the STORED version rather than
|
|
200
613
|
* this constant — so bumping it does not flip every already-verified phase
|
|
201
|
-
* to `stale` on upgrade. A legacy v1 report keeps
|
|
202
|
-
* documents included
|
|
614
|
+
* to `stale` on upgrade. A legacy v1/v2 report keeps its own semantics
|
|
615
|
+
* (shared documents included for v1; no report filter or artifact union for
|
|
616
|
+
* either) until it is re-fingerprinted; only a version outside
|
|
203
617
|
* `KNOWN_FINGERPRINT_VERSIONS` is unrecomputable and fails closed.
|
|
204
618
|
*/
|
|
205
|
-
const FINGERPRINT_VERSION =
|
|
206
|
-
const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2]);
|
|
619
|
+
const FINGERPRINT_VERSION = 3;
|
|
620
|
+
const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2, 3]);
|
|
207
621
|
/**
|
|
208
622
|
* #4623: the planning roots whose DIRECT children are repo-wide planning
|
|
209
623
|
* documents, as project-root-relative posix paths. Always `.planning`; plus
|
|
@@ -265,6 +679,31 @@ function isSharedPlanningDoc(rel, roots = ['.planning']) {
|
|
|
265
679
|
return false;
|
|
266
680
|
return roots.includes(node_path_1.default.posix.dirname(rel));
|
|
267
681
|
}
|
|
682
|
+
/**
|
|
683
|
+
* #5095 (R2): `sharedPlanningRoots` extended with the phase's own MAPPED
|
|
684
|
+
* (root-relative, posix) directory when one is available —
|
|
685
|
+
* `phaseArtifactPaths`'s `mappedPhaseDir` — so a workstream/project ROADMAP
|
|
686
|
+
* under an out-of-repo store is still recognized as shared even when
|
|
687
|
+
* `phaseDir` was addressed by its real, non-`.planning`-spelled path (the
|
|
688
|
+
* #4894 `--project-dir` shape). Falls back to the existing absolute-path
|
|
689
|
+
* derivation (`sharedPlanningRoots(projectRoot, phaseDir)`) when no mapped
|
|
690
|
+
* spelling is available — byte-for-behaviour identical to the pre-#5095
|
|
691
|
+
* seam for every caller that has no artifact-derivation step of its own.
|
|
692
|
+
*/
|
|
693
|
+
function sharedRootsFor(projectRoot, phaseDir, mappedPhaseDir) {
|
|
694
|
+
const roots = sharedPlanningRoots(projectRoot, phaseDir);
|
|
695
|
+
if (mappedPhaseDir) {
|
|
696
|
+
const phasesDir = node_path_1.default.posix.dirname(mappedPhaseDir);
|
|
697
|
+
const planningDir = node_path_1.default.posix.dirname(phasesDir);
|
|
698
|
+
if (node_path_1.default.posix.basename(phasesDir) === 'phases' &&
|
|
699
|
+
(planningDir === '.planning' || planningDir.startsWith('.planning/')) &&
|
|
700
|
+
!planningDir.includes('/../') &&
|
|
701
|
+
!roots.includes(planningDir)) {
|
|
702
|
+
roots.push(planningDir);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return roots;
|
|
706
|
+
}
|
|
268
707
|
/**
|
|
269
708
|
* #4623: the fingerprint version a stored `covered_digest` was computed
|
|
270
709
|
* under, or `null` when the prefix is absent, malformed, or names a version
|
|
@@ -278,101 +717,90 @@ function parseFingerprintVersion(digest) {
|
|
|
278
717
|
const version = Number(m[1]);
|
|
279
718
|
return KNOWN_FINGERPRINT_VERSIONS.has(version) ? version : null;
|
|
280
719
|
}
|
|
281
|
-
|
|
282
|
-
* #4155: recompute the deterministic content fingerprint over a verifier's
|
|
283
|
-
* declared covered-input set (phase PLAN/SUMMARY, mapped requirements,
|
|
284
|
-
* implementation files in the change set) and return the versioned digest
|
|
285
|
-
* string, or `null` if the set cannot be resolved.
|
|
286
|
-
*
|
|
287
|
-
* Determinism: paths are de-duplicated and SORTED before hashing (directory
|
|
288
|
-
* enumeration order is irrelevant), each path is resolved relative to
|
|
289
|
-
* `projectRoot` (the absolute checkout path never enters the digest), and
|
|
290
|
-
* file BYTES are hashed (mtime never enters the digest).
|
|
291
|
-
*
|
|
292
|
-
* NOT normalized: line endings. Unlike the report-frontmatter read (which
|
|
293
|
-
* runs every VERIFICATION.md through `normalizeLineEndings`), covered-file
|
|
294
|
-
* bytes are hashed exactly as they sit on disk. A covered text file checked
|
|
295
|
-
* out with CRLF line endings (e.g. a Windows checkout without a `.gitattributes
|
|
296
|
-
* eol=lf` rule pinning it to LF) hashes differently than the same file on an
|
|
297
|
-
* LF checkout — a real cross-platform digest mismatch, not a bug, since GSD
|
|
298
|
-
* installs into arbitrary user projects with no guaranteed line-ending policy.
|
|
299
|
-
*
|
|
300
|
-
|
|
301
|
-
* Fail closed: a covered path that is empty, absolute, escapes
|
|
302
|
-
* `projectRoot` (`..` traversal), or cannot be read (missing, unreadable,
|
|
303
|
-
* not a regular file) makes the WHOLE fingerprint unresolvable — returns
|
|
304
|
-
* `null` — rather than silently hashing a partial set. Callers treat `null`
|
|
305
|
-
* as stale (#4155), the same fail-closed shape #3057 B3 established for the
|
|
306
|
-
* legacy mtime staleness check.
|
|
307
|
-
*
|
|
308
|
-
* Always reads through the REAL `node:fs`, never a caller-injected `FsLike`
|
|
309
|
-
* seam — same reasoning as the root canonicalization below, extended to
|
|
310
|
-
* every covered file: `covered_files` is expected to span the whole
|
|
311
|
-
* `projectRoot` (implementation files under `src/`, not just `.planning/`
|
|
312
|
-
* artifacts), so a caller-scoped containment wrapper narrower than
|
|
313
|
-
* `projectRoot` (e.g. `planning-inspect.cts`'s `containmentEnforcingVerificationFs`,
|
|
314
|
-
* confined to `.planning/`) would reject every implementation-file read and
|
|
315
|
-
* report EVERY fingerprinted phase permanently `stale` regardless of actual
|
|
316
|
-
* drift — the bug this comment now documents against regressing. The
|
|
317
|
-
* `realRel`-vs-`realRoot` re-check a few lines below already does the real
|
|
318
|
-
* confinement work (against `projectRoot`, the correct boundary for this
|
|
319
|
-
* data), so no security property is lost by bypassing a narrower seam here.
|
|
320
|
-
*/
|
|
321
|
-
function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
|
|
720
|
+
function deriveCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
|
|
322
721
|
// #4623: `version` selects the input shape to hash under — the CURRENT
|
|
323
722
|
// one for a fresh fingerprint (the CLI verb), or the STORED one when
|
|
324
723
|
// `readVerificationStatus` recomputes against a report's own digest.
|
|
325
|
-
// `opts.phaseDir` lets v2 recognise the phase's own planning root
|
|
724
|
+
// `opts.phaseDir` lets v2+ recognise the phase's own planning root
|
|
326
725
|
// (`sharedPlanningRoots`); without it only `.planning/` itself is shared.
|
|
327
726
|
if (!KNOWN_FINGERPRINT_VERSIONS.has(version))
|
|
328
|
-
return null;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
727
|
+
return { ok: true, digest: null, files: [], mappedPhaseDir: null };
|
|
728
|
+
// #5095 (R1, ADR-5057 Phase 2): for v3+ with a known phaseDir, the hashed
|
|
729
|
+
// set is the declared list UNIONED with the phase's own live artifacts —
|
|
730
|
+
// computed here, ONCE, on BOTH the emit and check sides, rather than
|
|
731
|
+
// separately in the CLI emitter and again in here (#5095 R7: the prior
|
|
732
|
+
// shape ran `phaseArtifactPaths` twice on the emit path — once in
|
|
733
|
+
// `cmdVerificationFingerprint` to build the emitted `covered_files`, again
|
|
734
|
+
// in here to hash — a race window where a plan/summary added between the
|
|
735
|
+
// two calls could make the emitted list and the hashed set disagree). A
|
|
736
|
+
// plan/summary added after fingerprinting therefore moves the digest
|
|
737
|
+
// directly; `--raw` / MCP callers that write their own declared list
|
|
738
|
+
// (without enumerating plans/summaries) still match, because the checker
|
|
739
|
+
// adds the identical union. An artifact-resolution failure makes the WHOLE
|
|
740
|
+
// fingerprint unresolvable (fail closed).
|
|
741
|
+
let declared = canonicalizeCoveredFiles(coveredFiles, { version });
|
|
742
|
+
let artifactMappedPhaseDir = null;
|
|
743
|
+
if (version >= 3 && opts.phaseDir) {
|
|
744
|
+
const artifacts = phaseArtifactPaths(opts.phaseDir, projectRoot);
|
|
745
|
+
if (!artifacts.ok)
|
|
746
|
+
return { ok: false, reason: artifacts.reason };
|
|
747
|
+
artifactMappedPhaseDir = artifacts.mappedPhaseDir;
|
|
748
|
+
declared = canonicalizeCoveredFiles([...declared, ...artifacts.paths], { version });
|
|
749
|
+
}
|
|
750
|
+
if (declared.length === 0)
|
|
751
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
752
|
+
const sharedRoots = version >= 2 ? sharedRootsFor(projectRoot, opts.phaseDir, artifactMappedPhaseDir) : [];
|
|
333
753
|
let hashed = 0;
|
|
334
|
-
// Canonicalize the
|
|
335
|
-
//
|
|
336
|
-
// the REAL fs, never fsImpl: `projectRoot`
|
|
337
|
-
// derived (resolveProjectRoot), not
|
|
338
|
-
// routing it through a
|
|
754
|
+
// Canonicalize the roots ONCE — every candidate's realpath is checked
|
|
755
|
+
// against these, not the possibly-symlinked `projectRoot`/`.planning`
|
|
756
|
+
// arguments themselves. Always via the REAL fs, never fsImpl: `projectRoot`
|
|
757
|
+
// is a trusted anchor the CALLER derived (resolveProjectRoot), not
|
|
758
|
+
// attacker-influenced covered-input data — routing it through a
|
|
759
|
+
// caller-scoped containment seam (e.g. #4155's
|
|
339
760
|
// containmentEnforcingVerificationFs, confined to `.planning/`, a proper
|
|
340
761
|
// SUBSET of `projectRoot`) would reject the root itself and fail every
|
|
341
762
|
// lookup regardless of whether the covered files are legitimate.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
catch {
|
|
347
|
-
return null;
|
|
348
|
-
}
|
|
763
|
+
const roots = resolveContainmentRoots(projectRoot);
|
|
764
|
+
if (roots.realRoot === null)
|
|
765
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
349
766
|
const parts = [];
|
|
350
|
-
for (const rel of
|
|
767
|
+
for (const rel of declared) {
|
|
351
768
|
// `normalizeRel` (already applied by `canonicalizeCoveredFiles` above)
|
|
352
769
|
// collapses internal `..` segments before `rel` ever reaches here
|
|
353
770
|
// (`a/../../b` → `../b`), so this start-of-string check is already the
|
|
354
771
|
// full lexical confinement test — no separate post-`path.resolve`
|
|
355
772
|
// re-check can observe a different answer.
|
|
356
|
-
if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel))
|
|
357
|
-
return null;
|
|
773
|
+
if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel)) {
|
|
774
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
775
|
+
}
|
|
776
|
+
// #5095 (R7): a `.planning`-spelled path may resolve inside the checkout
|
|
777
|
+
// root OR the LONGEST-prefix anchored planning scope that claims it
|
|
778
|
+
// (`bestPlanningScopeForRel`) — every other path is confined to the
|
|
779
|
+
// checkout root alone, exactly as before.
|
|
780
|
+
const firstSegment = rel.split('/')[0];
|
|
781
|
+
const admissibleRoots = firstSegment === '.planning'
|
|
782
|
+
? [roots.realRoot, bestPlanningScopeForRel(rel, roots.planningScopes)?.real ?? null].filter((r) => r !== null)
|
|
783
|
+
: [roots.realRoot];
|
|
358
784
|
const resolved = node_path_1.default.resolve(projectRoot, rel);
|
|
359
785
|
let bytes;
|
|
360
786
|
try {
|
|
361
|
-
// A regular file INSIDE
|
|
362
|
-
// escapes
|
|
363
|
-
// confinement check above is not enough.
|
|
364
|
-
// actual target; re-confining against
|
|
787
|
+
// A regular file INSIDE a known root can still be a symlink whose
|
|
788
|
+
// TARGET escapes every known root — statSync/readFileSync follow
|
|
789
|
+
// symlinks, so the lexical confinement check above is not enough.
|
|
790
|
+
// realpathSync resolves the actual target; re-confining against the
|
|
791
|
+
// admissible roots closes that gap.
|
|
365
792
|
const real = node_fs_1.default.realpathSync(resolved);
|
|
366
|
-
//
|
|
367
|
-
// above), so the shared containment comparison
|
|
368
|
-
// no re-resolution through
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
793
|
+
// Every operand is already realpath-resolved (this fn's own
|
|
794
|
+
// realpathSync calls above), so the shared containment comparison
|
|
795
|
+
// applies directly (ADR-4650) — no re-resolution through
|
|
796
|
+
// assertWithinRoot/tryWithinRoot, which would redo work this function
|
|
797
|
+
// already owns for its exists-vs-escaped tri-state.
|
|
798
|
+
if (!admissibleRoots.some((root) => (0, security_cjs_1.isContainedIn)(real, root))) {
|
|
799
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
372
800
|
}
|
|
373
801
|
const st = node_fs_1.default.statSync(real);
|
|
374
802
|
if (!st.isFile())
|
|
375
|
-
return null;
|
|
803
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
376
804
|
// #4623 (v2+): a repo-wide planning document is VALIDATED exactly as
|
|
377
805
|
// every other covered path — confined, present, a regular file; the
|
|
378
806
|
// fail-closed contract above is unchanged — but its bytes contribute
|
|
@@ -385,7 +813,7 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
|
|
|
385
813
|
bytes = node_fs_1.default.readFileSync(real);
|
|
386
814
|
}
|
|
387
815
|
catch {
|
|
388
|
-
return null;
|
|
816
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
389
817
|
}
|
|
390
818
|
const fileHash = node_crypto_1.default.createHash('sha256').update(bytes).digest('hex');
|
|
391
819
|
parts.push(`${rel}\n${fileHash}\n`);
|
|
@@ -395,13 +823,26 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
|
|
|
395
823
|
// evidence in it at all — a constant digest over the header would satisfy
|
|
396
824
|
// the fingerprint pair while grounding the verification in nothing. Fail
|
|
397
825
|
// closed, the same way an empty declaration does.
|
|
398
|
-
if (version >= 2 && hashed === 0)
|
|
399
|
-
return null;
|
|
826
|
+
if (version >= 2 && hashed === 0) {
|
|
827
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
828
|
+
}
|
|
400
829
|
const aggregate = node_crypto_1.default
|
|
401
830
|
.createHash('sha256')
|
|
402
831
|
.update(`v${version}\n${parts.join('')}`, 'utf-8')
|
|
403
832
|
.digest('hex');
|
|
404
|
-
return `v${version}:sha256:${aggregate}
|
|
833
|
+
return { ok: true, digest: `v${version}:sha256:${aggregate}`, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* #4155/#5095: recompute the deterministic content fingerprint over a
|
|
837
|
+
* verifier's declared covered-input set, returning JUST the digest string
|
|
838
|
+
* (or `null` if unresolvable) — the signature every existing caller
|
|
839
|
+
* (`readVerificationStatus`, tests) already depends on. Thin wrapper over
|
|
840
|
+
* `deriveCoveredDigest`, collapsing its `{ ok: false, reason }` arm to `null`
|
|
841
|
+
* exactly as the pre-#5095 single-function shape did.
|
|
842
|
+
*/
|
|
843
|
+
function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
|
|
844
|
+
const result = deriveCoveredDigest(projectRoot, coveredFiles, version, opts);
|
|
845
|
+
return result.ok ? result.digest : null;
|
|
405
846
|
}
|
|
406
847
|
/**
|
|
407
848
|
* #4155: the content fingerprint only recomputes digests for paths the
|
|
@@ -413,9 +854,9 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
|
|
|
413
854
|
* each is represented in `coveredFiles` — matched by suffix (mirrors
|
|
414
855
|
* `matchRequestedFile`'s convention) since `coveredFiles` holds
|
|
415
856
|
* project-root-relative paths while the scan returns phase-relative
|
|
416
|
-
* filenames. Returns
|
|
417
|
-
*
|
|
418
|
-
*
|
|
857
|
+
* filenames. Returns the current plans/summaries the report never declared
|
|
858
|
+
* (empty when every one is covered), so the stale diagnostic can name them (#4817); `null` when the
|
|
859
|
+
* scan itself was incomplete, which callers treat as stale.
|
|
419
860
|
*
|
|
420
861
|
* Fails CLOSED on an incomplete scan: `scanPhasePlans` never throws on a
|
|
421
862
|
* readdir failure — it reports it via `scope` (`SCOPE.UNREADABLE` for the
|
|
@@ -424,18 +865,18 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
|
|
|
424
865
|
* contract (`planning-scope.cts`): zero items under a non-`COMPLETE` scope
|
|
425
866
|
* is a NON-answer, never "this phase has no plans." Branching on `scope`
|
|
426
867
|
* here (rather than a try/catch, which this scan never triggers) is what
|
|
427
|
-
* makes an unreadable `plans/` dir report `
|
|
868
|
+
* makes an unreadable `plans/` dir report `null` (stale) instead of silently
|
|
428
869
|
* treating its invisible contents as vacuously covered — the same
|
|
429
870
|
* fail-open regression #3057 B3 fixed for the legacy path.
|
|
430
871
|
*/
|
|
431
|
-
function
|
|
872
|
+
function uncoveredCurrentArtifacts(phaseDir, coveredFiles) {
|
|
432
873
|
const scan = scanPhasePlans(phaseDir);
|
|
433
874
|
if (scan.scope !== SCOPE.COMPLETE)
|
|
434
|
-
return
|
|
875
|
+
return null;
|
|
435
876
|
const coveredPosix = canonicalizeCoveredFiles(coveredFiles);
|
|
436
|
-
return [...scan.allPlanFiles, ...scan.summaryFiles].
|
|
877
|
+
return [...scan.allPlanFiles, ...scan.summaryFiles].filter((artifact) => {
|
|
437
878
|
const artifactPosix = toPosix(artifact);
|
|
438
|
-
return coveredPosix.some((c) => c === artifactPosix || c.endsWith(`/${artifactPosix}`));
|
|
879
|
+
return !coveredPosix.some((c) => c === artifactPosix || c.endsWith(`/${artifactPosix}`));
|
|
439
880
|
});
|
|
440
881
|
}
|
|
441
882
|
/**
|
|
@@ -508,18 +949,232 @@ function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_comma
|
|
|
508
949
|
return commitTimes;
|
|
509
950
|
}
|
|
510
951
|
/**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
952
|
+
* #4857: which declared covered files changed since the report was written.
|
|
953
|
+
* `covered_digest` is one aggregate hash, so it can say THAT something moved
|
|
954
|
+
* but not what; this names the files by time instead — each covered file
|
|
955
|
+
* (report paths and, for v2+, shared planning documents excluded, exactly as
|
|
956
|
+
* the digest excludes them) whose clean commit time, or mtime when it is
|
|
957
|
+
* uncommitted or edited since, is newer than the report's own. A file that
|
|
958
|
+
* can no longer be statted counts as moved. Advisory only: it never decides
|
|
959
|
+
* staleness (the digest does), and an unreadable clock yields fewer names,
|
|
960
|
+
* never a wrong status. Timed through the same injectable resolver the
|
|
961
|
+
* legacy mtime check uses, rooted at the project so the covered paths
|
|
962
|
+
* (project-root-relative) resolve.
|
|
963
|
+
*/
|
|
964
|
+
function coveredFilesMovedSinceReport(projectRoot, phaseDir, reportPath, coveredFiles, version, fsImpl, resolver) {
|
|
965
|
+
const sharedRoots = version >= 2 ? sharedRootsFor(projectRoot, phaseDir, null) : [];
|
|
966
|
+
const files = canonicalizeCoveredFiles(coveredFiles, { version })
|
|
967
|
+
.filter((f) => !isVerificationReportPath(f) && !isSharedPlanningDoc(f, sharedRoots));
|
|
968
|
+
if (files.length === 0)
|
|
969
|
+
return [];
|
|
970
|
+
const reportRel = toPosix(node_path_1.default.relative(projectRoot, reportPath));
|
|
971
|
+
let commitTimes;
|
|
972
|
+
try {
|
|
973
|
+
commitTimes = resolver(projectRoot, [reportRel, ...files]);
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
commitTimes = new Map();
|
|
977
|
+
}
|
|
978
|
+
const timeOf = (rel) => {
|
|
979
|
+
const committed = commitTimes.get(rel);
|
|
980
|
+
if (committed !== undefined)
|
|
981
|
+
return committed;
|
|
982
|
+
try {
|
|
983
|
+
return fsImpl.statSync(node_path_1.default.join(projectRoot, rel)).mtimeMs;
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
const reportTime = timeOf(reportRel);
|
|
990
|
+
if (reportTime === null)
|
|
991
|
+
return [];
|
|
992
|
+
return files.filter((rel) => {
|
|
993
|
+
const t = timeOf(rel);
|
|
994
|
+
return t === null || t > reportTime;
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
/** At most this many uncovered artifact names are spelled out in `next_action`; the rest are counted. */
|
|
998
|
+
const STALE_NAMED_ARTIFACT_LIMIT = 5;
|
|
999
|
+
function staleNextAction(detail) {
|
|
1000
|
+
const name = (items) => {
|
|
1001
|
+
const shown = items.slice(0, STALE_NAMED_ARTIFACT_LIMIT).map((a) => io.formatDiagnosticToken(a));
|
|
1002
|
+
const more = items.length - shown.length;
|
|
1003
|
+
return `${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`;
|
|
1004
|
+
};
|
|
1005
|
+
const reasons = [];
|
|
1006
|
+
if (detail.moved.length > 0) {
|
|
1007
|
+
reasons.push(`covered source files changed after the verifier last ran: ${name(detail.moved)}`);
|
|
1008
|
+
}
|
|
1009
|
+
else if (detail.digestMoved) {
|
|
1010
|
+
reasons.push('covered source files changed after the verifier last ran');
|
|
1011
|
+
}
|
|
1012
|
+
if (detail.uncovered.length > 0) {
|
|
1013
|
+
reasons.push(`the report never covered ${name(detail.uncovered)} (a current phase plan or summary it did not declare in covered_files)`);
|
|
1014
|
+
}
|
|
1015
|
+
const tail = detail.digestMoved ? '' : '; no covered file changed';
|
|
1016
|
+
return `Verification is stale — ${reasons.join(', and ')}${tail}. ${STALE_REMEDY}`;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* The one result builder (#5118): every `VerificationStatusResult` is
|
|
1020
|
+
* projected from `VERIFICATION_ROUTES[status]` here — `route` (the bare
|
|
1021
|
+
* command) and `next_command` (its runtime projection, #2617) come from the
|
|
1022
|
+
* same table entry, so they cannot disagree.
|
|
514
1023
|
*/
|
|
515
|
-
function
|
|
516
|
-
const
|
|
1024
|
+
function routeResult(status, ctx) {
|
|
1025
|
+
const entry = VERIFICATION_ROUTES[status];
|
|
1026
|
+
const detail = status === VERIFICATION_STATUS.STALE && ctx.staleDetail
|
|
1027
|
+
&& (ctx.staleDetail.uncovered.length > 0 || ctx.staleDetail.moved.length > 0)
|
|
1028
|
+
? ctx.staleDetail
|
|
1029
|
+
: undefined;
|
|
517
1030
|
return {
|
|
518
|
-
status
|
|
519
|
-
next_action:
|
|
520
|
-
next_command: projectNextCommand(
|
|
1031
|
+
status,
|
|
1032
|
+
next_action: detail ? staleNextAction(detail) : entry.next_action,
|
|
1033
|
+
next_command: projectNextCommand(entry.command, ctx.runtime, `${ctx.phaseArg}${entry.tail}`),
|
|
1034
|
+
route: entry.command,
|
|
1035
|
+
...(ctx.message !== undefined ? { message: ctx.message } : {}),
|
|
1036
|
+
...(detail && detail.uncovered.length > 0 ? { uncovered_artifacts: detail.uncovered } : {}),
|
|
1037
|
+
...(detail && detail.moved.length > 0 ? { moved_files: detail.moved } : {}),
|
|
1038
|
+
...(ctx.staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
|
|
521
1039
|
};
|
|
522
1040
|
}
|
|
1041
|
+
/**
|
|
1042
|
+
* ADR-5057 amendment 2 (#4987): true only when there is no phase DIRECTORY at
|
|
1043
|
+
* `phaseDir` — `stat` fails with ENOENT / ENOTDIR (a dangling symlink stats as
|
|
1044
|
+
* ENOENT), or it succeeds on something that is not a directory (a regular
|
|
1045
|
+
* file). Any other stat failure — EACCES, or a code-less containment error
|
|
1046
|
+
* from an injected FsLike (planning-inspect's seam) — is NOT "not found": the
|
|
1047
|
+
* caller falls through to the existing readdir path (`missing`).
|
|
1048
|
+
*/
|
|
1049
|
+
function isPhaseDirNotFound(fsImpl, phaseDir) {
|
|
1050
|
+
let st;
|
|
1051
|
+
try {
|
|
1052
|
+
st = fsImpl.statSync(phaseDir);
|
|
1053
|
+
}
|
|
1054
|
+
catch (err) {
|
|
1055
|
+
const code = err?.code;
|
|
1056
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
1057
|
+
}
|
|
1058
|
+
return typeof st?.isDirectory === 'function' && !st.isDirectory();
|
|
1059
|
+
}
|
|
1060
|
+
function phaseDirNotFoundMessage(phaseDir) {
|
|
1061
|
+
return `Usage error: phase directory not found at ${io.formatDiagnosticToken(phaseDir)}`;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* The PHASES ROOT a phase directory must live under: the parent of `phaseDir`
|
|
1065
|
+
* in its own (unresolved) spelling. It is the fixed anchor of the containment
|
|
1066
|
+
* check — a phase directory symlinked outside the project resolves outside, so
|
|
1067
|
+
* containing a report against that directory's OWN realpath alone would admit
|
|
1068
|
+
* the escape (both resolve outside). A phases root that is itself a symlinked
|
|
1069
|
+
* per-scope store resolves consistently on both sides of the comparison.
|
|
1070
|
+
*/
|
|
1071
|
+
function planningContainmentRoot(phaseDir) {
|
|
1072
|
+
return node_path_1.default.dirname(node_path_1.default.resolve(phaseDir));
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* True when the phase directory really lives under its phases root AND
|
|
1076
|
+
* `filePath` really lives inside that phase directory (symlinks followed on
|
|
1077
|
+
* both). Either escape reads `missing`. Unresolvable → false.
|
|
1078
|
+
*/
|
|
1079
|
+
function isReportContained(phaseDir, filePath) {
|
|
1080
|
+
try {
|
|
1081
|
+
const realDir = node_fs_1.default.realpathSync(phaseDir);
|
|
1082
|
+
return (0, security_cjs_1.isContainedIn)(realDir, node_fs_1.default.realpathSync(planningContainmentRoot(phaseDir)))
|
|
1083
|
+
&& (0, security_cjs_1.isContainedIn)(node_fs_1.default.realpathSync(filePath), realDir);
|
|
1084
|
+
}
|
|
1085
|
+
catch {
|
|
1086
|
+
return false;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* #5118: steps 0-2 of `readVerificationStatus` — no phase directory
|
|
1091
|
+
* (`phase_dir_not_found`), no report or no `status` (`missing`), a report
|
|
1092
|
+
* whose frontmatter is not YAML (`unparseable`), or the report's writer-set
|
|
1093
|
+
* status. Reads only the report's frontmatter (no staleness check, no git).
|
|
1094
|
+
* THROWS `VerificationStatusError` for a status outside the writer set — the
|
|
1095
|
+
* one judgement (`reportStatusOf`) every reader shares.
|
|
1096
|
+
*/
|
|
1097
|
+
function locatePhaseReport(phaseDir, fsImpl, convention) {
|
|
1098
|
+
if (isPhaseDirNotFound(fsImpl, phaseDir))
|
|
1099
|
+
return { kind: 'phase_dir_not_found' };
|
|
1100
|
+
const baseName = node_path_1.default.basename(phaseDir);
|
|
1101
|
+
let verificationFile = null;
|
|
1102
|
+
try {
|
|
1103
|
+
const entries = fsImpl.readdirSync(phaseDir);
|
|
1104
|
+
// #3492: pin selection to THIS phase's own token so a stray cross-phase
|
|
1105
|
+
// or sentinel-numbered canonically-shaped file cannot outrank this phase's
|
|
1106
|
+
// own report. #612: bracket directories resolve with the convention-aware
|
|
1107
|
+
// token. #4187: keep the bare report tier aligned with every other reader.
|
|
1108
|
+
const resolutionToken = convention === 'bracket'
|
|
1109
|
+
? extractPhaseToken(baseName, convention)
|
|
1110
|
+
: extractPhaseToken(baseName);
|
|
1111
|
+
verificationFile = resolveVerificationFile(entries, {
|
|
1112
|
+
allowBare: true,
|
|
1113
|
+
phaseToken: resolutionToken,
|
|
1114
|
+
phaseDirName: baseName,
|
|
1115
|
+
convention,
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
// Directory unreadable → treat as missing
|
|
1120
|
+
verificationFile = null;
|
|
1121
|
+
}
|
|
1122
|
+
if (!verificationFile)
|
|
1123
|
+
return { kind: 'missing' };
|
|
1124
|
+
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
|
|
1125
|
+
const filePath = node_path_1.default.join(phaseDir, verificationFile);
|
|
1126
|
+
// #5118 security review (containment): a report whose real path escapes its
|
|
1127
|
+
// own phase directory (a symlink out of the project) is refused BEFORE a
|
|
1128
|
+
// byte of it is read — it reads `missing`, and no value from it can ever
|
|
1129
|
+
// reach a message (the out-of-set error echoes the report's `status`). An
|
|
1130
|
+
// injected FsLike (planning-inspect's containment seam) enforces its own
|
|
1131
|
+
// containment by throwing, which the read below folds to `missing` too.
|
|
1132
|
+
if (fsImpl === defaultFsImpl && !isReportContained(phaseDir, filePath))
|
|
1133
|
+
return { kind: 'missing' };
|
|
1134
|
+
let fm = {};
|
|
1135
|
+
try {
|
|
1136
|
+
// #3707-CR: normalize line endings at this read boundary so a lone-CR
|
|
1137
|
+
// report's `---\r…\r---` fence still matches extractFrontmatter's check.
|
|
1138
|
+
const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
|
|
1139
|
+
fm = extractFrontmatter(content, filePath);
|
|
1140
|
+
// #4806: an unparseable frontmatter block is NOT "missing" — the file
|
|
1141
|
+
// exists and verification ran; the caller is sent to fix the YAML.
|
|
1142
|
+
if (fm[FRONTMATTER_UNPARSEABLE] === true) {
|
|
1143
|
+
return { kind: 'unparseable' };
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
// An unreadable report reads as carrying no status (`missing`).
|
|
1148
|
+
fm = {};
|
|
1149
|
+
}
|
|
1150
|
+
// #5118: judged OUTSIDE the parse `try`, so an out-of-set value can never be
|
|
1151
|
+
// swallowed into `missing`.
|
|
1152
|
+
const status = reportStatusOf(fm, filePath);
|
|
1153
|
+
if (status === null)
|
|
1154
|
+
return { kind: 'missing' };
|
|
1155
|
+
return { kind: 'status', status, filePath, fm };
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* #5118 (ADR-5057 Phase 4, "no write before the error"): the first report
|
|
1159
|
+
* among `phaseDirs` whose `status` is outside the closed set, or `null`.
|
|
1160
|
+
* Frontmatter-only (the same locator `readVerificationStatus` runs), so a
|
|
1161
|
+
* command that WRITES validates every report it will read BEFORE its first
|
|
1162
|
+
* write and fails having written nothing.
|
|
1163
|
+
*/
|
|
1164
|
+
function findVerificationStatusError(phaseDirs, deps = {}) {
|
|
1165
|
+
const fsImpl = deps.fs ?? defaultFsImpl;
|
|
1166
|
+
for (const phaseDir of phaseDirs) {
|
|
1167
|
+
try {
|
|
1168
|
+
locatePhaseReport(phaseDir, fsImpl, deps.convention);
|
|
1169
|
+
}
|
|
1170
|
+
catch (err) {
|
|
1171
|
+
if (err instanceof VerificationStatusError)
|
|
1172
|
+
return err;
|
|
1173
|
+
throw err;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
523
1178
|
/**
|
|
524
1179
|
* #3518: the shared phase-pinned artifact-selection core BOTH single-pick
|
|
525
1180
|
* resolvers (`resolveVerificationFile` for `*-VERIFICATION.md`,
|
|
@@ -735,7 +1390,16 @@ function findStaleVerificationSummary(phaseDir, fsImpl = defaultFsImpl, phaseCle
|
|
|
735
1390
|
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
|
|
736
1391
|
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
|
|
737
1392
|
* If no frontmatter block or no `status` key → status 'missing'.
|
|
738
|
-
* 3.
|
|
1393
|
+
* 3. Route through VERIFICATION_ROUTES (`routeResult`). A status outside the
|
|
1394
|
+
* writer set (`VERIFIER_STATUSES`) — any other string, case variant,
|
|
1395
|
+
* reader-only member, or non-string value — THROWS `VerificationStatusError`
|
|
1396
|
+
* (#5118). The throw sits outside the parse `try`, and before the
|
|
1397
|
+
* `gaps_found` short-circuit and the staleness check, so it can neither be
|
|
1398
|
+
* folded into `missing` nor masked by `stale` (#4817 Part 2).
|
|
1399
|
+
*
|
|
1400
|
+
* #5118 / ADR-5057 amendment 2: a path with no phase DIRECTORY behind it
|
|
1401
|
+
* (ENOENT, ENOTDIR, a non-directory) reads `phase_dir_not_found` — a usage
|
|
1402
|
+
* error with no next command — not `missing` (#4987).
|
|
739
1403
|
*
|
|
740
1404
|
* The internal staleness check can itself fail (fs / scanPhasePlans / clock
|
|
741
1405
|
* error); when it does, `status` is routed as if nothing were stale (the
|
|
@@ -769,84 +1433,26 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
769
1433
|
// that already know the number (init) pass it explicitly and always get it.
|
|
770
1434
|
const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
|
|
771
1435
|
const phaseArg = phaseArgSource ? ` ${phaseArgSource}` : '';
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
const resolutionToken = opts.convention === 'bracket'
|
|
783
|
-
? extractPhaseToken(baseName, opts.convention)
|
|
784
|
-
: phaseToken;
|
|
785
|
-
verificationFile = resolveVerificationFile(entries, {
|
|
786
|
-
allowBare: true,
|
|
787
|
-
phaseToken: resolutionToken,
|
|
788
|
-
phaseDirName: baseName,
|
|
789
|
-
convention: opts.convention,
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
catch {
|
|
793
|
-
// Directory unreadable → treat as missing
|
|
794
|
-
verificationFile = null;
|
|
795
|
-
}
|
|
796
|
-
if (!verificationFile) {
|
|
797
|
-
return missingResult(runtime, phaseArg);
|
|
798
|
-
}
|
|
799
|
-
// 2. Read and parse frontmatter using the shared parser.
|
|
800
|
-
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
|
|
801
|
-
const filePath = node_path_1.default.join(phaseDir, verificationFile);
|
|
802
|
-
let rawStatus = null;
|
|
803
|
-
let fm = {};
|
|
804
|
-
try {
|
|
805
|
-
// #3707-CR follow-up MINOR 1: normalize line endings at this read
|
|
806
|
-
// boundary — this function's own `readFileSync` is the equivalent seam
|
|
807
|
-
// `planning.inspect`'s `buildUatRows`/`readDocument` route through for
|
|
808
|
-
// UAT/REQUIREMENTS documents, but `readVerificationStatus` had no such
|
|
809
|
-
// normalization of its own. A lone-CR VERIFICATION.md's `---\r...\r---`
|
|
810
|
-
// frontmatter fence never matched `extractFrontmatter`'s byte-0
|
|
811
|
-
// `---\n`/`---\r\n` check, so `status: passed` was read as absent and
|
|
812
|
-
// this function reported 'missing' — under-reporting a completed
|
|
813
|
-
// verification as if the step never ran, the fail-safe direction but the
|
|
814
|
-
// same root cause as the false-clean class fixed elsewhere in #3707-CR.
|
|
815
|
-
const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
|
|
816
|
-
fm = extractFrontmatter(content, filePath);
|
|
817
|
-
// #4806: an unparseable frontmatter block is NOT "missing" — the file
|
|
818
|
-
// exists and verification ran. Report a distinct status so the caller is
|
|
819
|
-
// sent to fix the YAML, not to re-run execute-phase.
|
|
820
|
-
if (fm[FRONTMATTER_UNPARSEABLE] === true) {
|
|
821
|
-
return {
|
|
822
|
-
status: 'unparseable',
|
|
823
|
-
next_action: "The *-VERIFICATION.md frontmatter is not parseable YAML — fix the syntax error in the report itself. Re-running execute-phase cannot fix a YAML typo in an existing report.",
|
|
824
|
-
next_command: '',
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
const statusVal = fm['status'];
|
|
828
|
-
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
|
|
829
|
-
// only accept string values — arrays and objects are not valid status values.
|
|
830
|
-
if (typeof statusVal === 'string') {
|
|
831
|
-
const trimmed = statusVal.trim();
|
|
832
|
-
rawStatus = trimmed.length > 0 ? trimmed : null;
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
catch {
|
|
836
|
-
rawStatus = null;
|
|
837
|
-
}
|
|
838
|
-
if (!rawStatus) {
|
|
839
|
-
return missingResult(runtime, phaseArg);
|
|
1436
|
+
const route = (status, extra = {}) => routeResult(status, { runtime, phaseArg, ...extra });
|
|
1437
|
+
// Steps 0-2 (no phase dir → usage error; find the report; parse its
|
|
1438
|
+
// frontmatter and judge `status`) are the frontmatter-only locator shared
|
|
1439
|
+
// with the pre-write validator (`findVerificationStatusError`). An
|
|
1440
|
+
// out-of-set status THROWS VerificationStatusError out of the locator —
|
|
1441
|
+
// before the gaps_found short-circuit and the staleness check below, so
|
|
1442
|
+
// `stale` can never mask it (#4817 Part 2).
|
|
1443
|
+
const located = locatePhaseReport(phaseDir, fsImpl, opts.convention);
|
|
1444
|
+
if (located.kind === 'phase_dir_not_found') {
|
|
1445
|
+
return route(VERIFICATION_STATUS.PHASE_DIR_NOT_FOUND, { message: phaseDirNotFoundMessage(phaseDir) });
|
|
840
1446
|
}
|
|
1447
|
+
if (located.kind === 'missing')
|
|
1448
|
+
return route(VERIFICATION_STATUS.MISSING);
|
|
1449
|
+
if (located.kind === 'unparseable')
|
|
1450
|
+
return route(VERIFICATION_STATUS.UNPARSEABLE);
|
|
1451
|
+
const { status: reportStatus, fm } = located;
|
|
841
1452
|
// gaps_found takes priority over stale — gap closure is the correct next
|
|
842
1453
|
// step regardless of whether summaries are newer than the verification file.
|
|
843
|
-
if (
|
|
844
|
-
|
|
845
|
-
return {
|
|
846
|
-
status: entry.status,
|
|
847
|
-
next_action: entry.next_action,
|
|
848
|
-
next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
|
|
849
|
-
};
|
|
1454
|
+
if (reportStatus === VERIFICATION_STATUS.GAPS_FOUND) {
|
|
1455
|
+
return route(VERIFICATION_STATUS.GAPS_FOUND);
|
|
850
1456
|
}
|
|
851
1457
|
// #4155: a report that declares a covered-input fingerprint is checked by
|
|
852
1458
|
// RECOMPUTING that fingerprint over current file content — strictly
|
|
@@ -868,30 +1474,57 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
868
1474
|
typeof coveredDigestVal === 'string' &&
|
|
869
1475
|
coveredDigestVal.trim().length > 0;
|
|
870
1476
|
let staleCheckIndeterminate = false;
|
|
1477
|
+
let staleDetail;
|
|
871
1478
|
let isStale;
|
|
872
1479
|
if (declaresFingerprint) {
|
|
873
1480
|
// Stated directly rather than relying on `null !== coveredDigestVal`
|
|
874
1481
|
// being true whenever the pair is malformed: `!hasWellFormedFingerprint`
|
|
875
1482
|
// fails closed explicitly, and its `||` short-circuit means
|
|
876
|
-
// computeCoveredDigest/
|
|
1483
|
+
// computeCoveredDigest/uncoveredCurrentArtifacts never run on a
|
|
877
1484
|
// malformed (wrong-shaped) `coveredFilesVal`. The two `||`s after it
|
|
878
1485
|
// short-circuit in turn: the live-directory re-scan (for a plan/summary
|
|
879
1486
|
// added AFTER verification and never declared in covered_files) only
|
|
880
1487
|
// runs once the digest itself has already matched.
|
|
881
1488
|
//
|
|
882
1489
|
// #4623: recompute under the STORED digest's own version, not the
|
|
883
|
-
// current constant — a v1 report written before
|
|
884
|
-
//
|
|
1490
|
+
// current constant — a v1/v2 report written before a later semantics
|
|
1491
|
+
// change keeps its own semantics rather than going stale on upgrade. An
|
|
885
1492
|
// unknown version parses to `null`, which `computeCoveredDigest`
|
|
886
1493
|
// refuses (returns `null`), so the compare below fails closed.
|
|
1494
|
+
const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
|
|
887
1495
|
const storedVersion = hasWellFormedFingerprint && typeof coveredDigestVal === 'string'
|
|
888
1496
|
? parseFingerprintVersion(coveredDigestVal)
|
|
889
1497
|
: null;
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1498
|
+
if (!hasWellFormedFingerprint || storedVersion === null) {
|
|
1499
|
+
isStale = true;
|
|
1500
|
+
}
|
|
1501
|
+
else if (storedVersion >= 3) {
|
|
1502
|
+
// #5095 (R1): v3's digest is computed over the declared set UNIONED
|
|
1503
|
+
// with the phase's own live artifacts — a plan/summary added after
|
|
1504
|
+
// fingerprinting already moves the digest itself, so the separate
|
|
1505
|
+
// live-directory re-scan (`uncoveredCurrentArtifacts`) is redundant
|
|
1506
|
+
// for v3 and is not run.
|
|
1507
|
+
isStale =
|
|
1508
|
+
computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal;
|
|
1509
|
+
if (isStale) {
|
|
1510
|
+
const moved = coveredFilesMovedSinceReport(projectRoot, phaseDir, located.filePath, coveredFilesVal, storedVersion, fsImpl, phaseCleanCommitTimesMs);
|
|
1511
|
+
if (moved.length > 0)
|
|
1512
|
+
staleDetail = { uncovered: [], moved, digestMoved: true };
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
else {
|
|
1516
|
+
// v1/v2: unchanged — the digest alone cannot see a plan/summary that
|
|
1517
|
+
// was never declared, so the live-directory re-scan still runs.
|
|
1518
|
+
const digestMoved = computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal;
|
|
1519
|
+
const uncovered = uncoveredCurrentArtifacts(phaseDir, coveredFilesVal);
|
|
1520
|
+
isStale = digestMoved || uncovered === null || uncovered.length > 0;
|
|
1521
|
+
const moved = digestMoved
|
|
1522
|
+
? coveredFilesMovedSinceReport(projectRoot, phaseDir, located.filePath, coveredFilesVal, storedVersion, fsImpl, phaseCleanCommitTimesMs)
|
|
1523
|
+
: [];
|
|
1524
|
+
if ((uncovered !== null && uncovered.length > 0) || moved.length > 0) {
|
|
1525
|
+
staleDetail = { uncovered: uncovered ? [...uncovered].sort() : [], moved, digestMoved };
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
895
1528
|
}
|
|
896
1529
|
else {
|
|
897
1530
|
const staleCheck = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs, opts.convention);
|
|
@@ -904,39 +1537,12 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
904
1537
|
staleCheckIndeterminate = !staleCheck.determined;
|
|
905
1538
|
}
|
|
906
1539
|
if (isStale) {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
// routing the `missing` sentinel has used since #2868.
|
|
914
|
-
next_command: projectNextCommand('execute-phase', runtime, phaseArg),
|
|
915
|
-
};
|
|
916
|
-
}
|
|
917
|
-
// 3. Route — exclude internal sentinels from raw-file lookup (they are
|
|
918
|
-
// constructed internally above, never written by the verifier).
|
|
919
|
-
if (rawStatus in VERIFICATION_ROUTING_TABLE &&
|
|
920
|
-
rawStatus !== 'missing' &&
|
|
921
|
-
rawStatus !== 'unknown' &&
|
|
922
|
-
rawStatus !== 'stale' &&
|
|
923
|
-
rawStatus !== 'gaps_found') {
|
|
924
|
-
const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
|
|
925
|
-
return {
|
|
926
|
-
status: entry.status,
|
|
927
|
-
next_action: entry.next_action,
|
|
928
|
-
next_command: projectNextCommand(entry.next_command, runtime, phaseArg),
|
|
929
|
-
...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
// Unknown value
|
|
933
|
-
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
|
|
934
|
-
return {
|
|
935
|
-
status: unknownRoute.status,
|
|
936
|
-
next_action: `Unexpected verification status '${rawStatus}'. If this is an intentional non-standard marker (e.g. a hand-set failed/superseded state), no action is needed. Otherwise, run execute-phase to regenerate verification — it will not re-run plans that already have a SUMMARY.md.`,
|
|
937
|
-
next_command: projectNextCommand(unknownRoute.next_command, runtime, phaseArg),
|
|
938
|
-
...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
|
|
939
|
-
};
|
|
1540
|
+
// #4682 / #5118: the one stale route — execute-phase's verify_phase_goal
|
|
1541
|
+
// step re-runs the verifier, regenerating VERIFICATION.md and its digest.
|
|
1542
|
+
return route(VERIFICATION_STATUS.STALE, staleDetail ? { staleDetail } : {});
|
|
1543
|
+
}
|
|
1544
|
+
// 3. Route the writer-set member through the one table.
|
|
1545
|
+
return route(reportStatus, { staleCheckIndeterminate });
|
|
940
1546
|
}
|
|
941
1547
|
/**
|
|
942
1548
|
* isPhaseComplete — the single canonical owner of "is phase P complete?"
|
|
@@ -949,11 +1555,18 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
949
1555
|
* `*-VERIFICATION.md` is complete (#3168). A ROADMAP checkbox has no machine
|
|
950
1556
|
* authority and is never consulted — this function never reads ROADMAP.md.
|
|
951
1557
|
*
|
|
952
|
-
* `complete` is exactly `verification.status ===
|
|
953
|
-
* carries the FULL routing result
|
|
954
|
-
* caller can distinguish a
|
|
955
|
-
* `stale
|
|
956
|
-
* complete", but they are not the same
|
|
1558
|
+
* `complete` is exactly `verification.status === VERIFICATION_STATUS.PASSED`.
|
|
1559
|
+
* `verification` carries the FULL routing result
|
|
1560
|
+
* (status/next_action/next_command/route), so a caller can distinguish a
|
|
1561
|
+
* failing verdict (`gaps_found`/`human_needed`/`stale`) from an absent one
|
|
1562
|
+
* (`missing`) — both are "not complete", but they are not the same
|
|
1563
|
+
* non-answer.
|
|
1564
|
+
*
|
|
1565
|
+
* #5118: a report whose `status` is outside the closed set does NOT throw
|
|
1566
|
+
* out of here (ADR-5057 :223 — the no-throw contract Phases 2–4 preserve): it
|
|
1567
|
+
* degrades to scope UNREADABLE, `verification.status` is `null` (route `''`,
|
|
1568
|
+
* the error's message as `next_action`), `complete` is false, and
|
|
1569
|
+
* `value.statusError` holds the typed error for the caller to carry.
|
|
957
1570
|
*
|
|
958
1571
|
* `scope` is UNREADABLE when `phaseDir` itself could not be listed — this is
|
|
959
1572
|
* INDEPENDENT of readVerificationStatus's own no-throw fail-open contract for
|
|
@@ -976,17 +1589,29 @@ function isPhaseComplete(phaseDir, deps = {}) {
|
|
|
976
1589
|
catch {
|
|
977
1590
|
readable = false;
|
|
978
1591
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1592
|
+
let verification;
|
|
1593
|
+
let statusError;
|
|
1594
|
+
try {
|
|
1595
|
+
verification = readVerificationStatus(phaseDir, {
|
|
1596
|
+
fs: deps.fs,
|
|
1597
|
+
phaseCleanCommitTimesMs: deps.phaseCleanCommitTimesMs,
|
|
1598
|
+
runtime: deps.runtime,
|
|
1599
|
+
phaseNumber: deps.phaseNumber,
|
|
1600
|
+
convention: deps.convention,
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
catch (err) {
|
|
1604
|
+
if (!(err instanceof VerificationStatusError))
|
|
1605
|
+
throw err;
|
|
1606
|
+
statusError = err;
|
|
1607
|
+
readable = false;
|
|
1608
|
+
verification = { status: null, next_action: err.message, next_command: '', route: '' };
|
|
1609
|
+
}
|
|
986
1610
|
return {
|
|
987
1611
|
value: {
|
|
988
|
-
complete: verification.status ===
|
|
1612
|
+
complete: verification.status === VERIFICATION_STATUS.PASSED,
|
|
989
1613
|
verification,
|
|
1614
|
+
...(statusError ? { statusError } : {}),
|
|
990
1615
|
},
|
|
991
1616
|
scope: readable ? SCOPE.COMPLETE : SCOPE.UNREADABLE,
|
|
992
1617
|
};
|
|
@@ -995,6 +1620,13 @@ function isPhaseComplete(phaseDir, deps = {}) {
|
|
|
995
1620
|
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
|
|
996
1621
|
* emit via io.output().
|
|
997
1622
|
*
|
|
1623
|
+
* #5118: an out-of-set report status throws `VerificationStatusError` out of
|
|
1624
|
+
* here with nothing on stdout; gsd-tools.cjs translates it (once, centrally)
|
|
1625
|
+
* into ERROR_REASON `verification_status_invalid`. A nonexistent phase
|
|
1626
|
+
* directory is an ANSWER (`phase_dir_not_found`, `route: ''`, a `message`,
|
|
1627
|
+
* no `error` field — so `--pick status` prints it and the run is not
|
|
1628
|
+
* DEGRADED), not a failure.
|
|
1629
|
+
*
|
|
998
1630
|
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
|
999
1631
|
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
|
1000
1632
|
* @param raw - Whether to emit raw (non-JSON) output.
|
|
@@ -1023,6 +1655,11 @@ function cmdVerificationStatus(cwd, phaseDirArg, raw) {
|
|
|
1023
1655
|
* bare path string (possibly empty) so `VAR=$(gsd_run query
|
|
1024
1656
|
* verification.resolve-file "$PHASE_DIR" --raw)` is directly assignable.
|
|
1025
1657
|
*
|
|
1658
|
+
* #5118 (#4987 item 2): a path with no phase directory behind it adds the
|
|
1659
|
+
* marker `status: "phase_dir_not_found"` and a `message` — never an `error`
|
|
1660
|
+
* field (that would declare DEGRADED) — so it is distinguishable from an
|
|
1661
|
+
* existing directory with no report, where `""` alone keeps its meaning.
|
|
1662
|
+
*
|
|
1026
1663
|
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
|
1027
1664
|
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
|
1028
1665
|
* @param raw - Whether to emit raw (non-JSON) output.
|
|
@@ -1033,6 +1670,14 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
|
|
|
1033
1670
|
return;
|
|
1034
1671
|
}
|
|
1035
1672
|
const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
|
|
1673
|
+
if (isPhaseDirNotFound(defaultFsImpl, phaseDir)) {
|
|
1674
|
+
output({
|
|
1675
|
+
verification_file: '',
|
|
1676
|
+
status: VERIFICATION_STATUS.PHASE_DIR_NOT_FOUND,
|
|
1677
|
+
message: phaseDirNotFoundMessage(phaseDir),
|
|
1678
|
+
}, raw, '');
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1036
1681
|
let verificationPath = '';
|
|
1037
1682
|
try {
|
|
1038
1683
|
const entries = node_fs_1.default.readdirSync(phaseDir);
|
|
@@ -1136,9 +1781,16 @@ function parseFingerprintFileArgs(tokens) {
|
|
|
1136
1781
|
* @param raw - Whether to emit raw (non-JSON) output: just the
|
|
1137
1782
|
* `covered_digest` string, so `VAR=$(gsd_run query
|
|
1138
1783
|
* verification.fingerprint "$PHASE_DIR" ... --raw)` is
|
|
1139
|
-
* directly assignable. `
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1784
|
+
* directly assignable. #5095 (ADR-5057 Phase 2): `raw`
|
|
1785
|
+
* returning only the digest is safe even though the
|
|
1786
|
+
* emitted `covered_files` is a SUPERSET of the caller's
|
|
1787
|
+
* declared list — the v3 digest is computed over
|
|
1788
|
+
* `canonicalize(declared ∪ phaseArtifactPaths)` on BOTH
|
|
1789
|
+
* the emit side (here) and the check side
|
|
1790
|
+
* (`readVerificationStatus`), so a `--raw` caller that
|
|
1791
|
+
* writes its OWN declared list (without enumerating
|
|
1792
|
+
* plans/summaries) alongside the raw digest still
|
|
1793
|
+
* matches — the checker adds the identical union.
|
|
1142
1794
|
*/
|
|
1143
1795
|
function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
|
|
1144
1796
|
if (!phaseDirArg) {
|
|
@@ -1168,37 +1820,322 @@ function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
|
|
|
1168
1820
|
return;
|
|
1169
1821
|
}
|
|
1170
1822
|
const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
|
|
1171
|
-
// canonicalizeCoveredFiles here is for
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1823
|
+
// canonicalizeCoveredFiles here is for `deriveCoveredDigest`'s
|
|
1824
|
+
// `coveredFiles` argument — it canonicalizes internally too (it must, for
|
|
1825
|
+
// callers like readVerificationStatus that pass raw, un-canonicalized
|
|
1826
|
+
// frontmatter values), so passing an already-canonical list keeps that
|
|
1827
|
+
// internal pass a cheap no-op rather than a second meaningfully different
|
|
1828
|
+
// canonicalization.
|
|
1829
|
+
const declaredCanonical = canonicalizeCoveredFiles(files, { version: FINGERPRINT_VERSION });
|
|
1830
|
+
// #5095 (R1/R2/R7, ADR-5057 Phase 2): ONE call derives both the emitted
|
|
1831
|
+
// `covered_files` (`.files`, the declared set unioned with the phase's own
|
|
1832
|
+
// live artifacts) and the hashed digest (`.digest`) — the CLI no longer
|
|
1833
|
+
// runs `phaseArtifactPaths` itself and again inside the digest computation,
|
|
1834
|
+
// which used to leave a race window where the two calls could see a
|
|
1835
|
+
// different phase directory and disagree. An artifact-resolution failure
|
|
1836
|
+
// fails the WHOLE command (fail closed: the emitter cannot vouch for a set
|
|
1837
|
+
// it could not fully see).
|
|
1838
|
+
const derivation = deriveCoveredDigest(projectRoot, declaredCanonical, FINGERPRINT_VERSION, { phaseDir });
|
|
1839
|
+
if (!derivation.ok) {
|
|
1840
|
+
error(`could not compute fingerprint — ${derivation.reason}`);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
const unionSorted = derivation.files;
|
|
1844
|
+
if (unionSorted.length === 0) {
|
|
1845
|
+
error('at least one covered file required for verification.fingerprint');
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
const digest = derivation.digest;
|
|
1179
1849
|
if (digest === null) {
|
|
1180
1850
|
// #4623: name the one null that is NOT a bad path — a declaration made
|
|
1181
|
-
// only of shared planning documents
|
|
1182
|
-
// generic message below would send the caller
|
|
1183
|
-
// that is not missing. Discriminated AFTER
|
|
1184
|
-
// v1 pass over the same list
|
|
1185
|
-
//
|
|
1186
|
-
// a
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1851
|
+
// only of shared planning documents (with no other evidence) hashes
|
|
1852
|
+
// nothing under v2+, and the generic message below would send the caller
|
|
1853
|
+
// looking for a missing file that is not missing. Discriminated AFTER
|
|
1854
|
+
// the versioned attempt, and only when a v1 pass over the same list
|
|
1855
|
+
// (which hashes, and therefore validates, every path) succeeds: an
|
|
1856
|
+
// all-shared list with a missing or directory member is a bad path
|
|
1857
|
+
// first, and gets the generic message. #5095 (R5): a phase WITH real
|
|
1858
|
+
// artifacts never reaches this branch — the union above already supplied
|
|
1859
|
+
// evidence — so this error is reachable only when every declared path is
|
|
1860
|
+
// a shared planning doc AND the phase has no plans/summaries of its own.
|
|
1861
|
+
const sharedRoots = sharedRootsFor(projectRoot, phaseDir, derivation.mappedPhaseDir);
|
|
1862
|
+
if (unionSorted.every((f) => isSharedPlanningDoc(f, sharedRoots)) &&
|
|
1863
|
+
computeCoveredDigest(projectRoot, unionSorted, 1) !== null) {
|
|
1190
1864
|
error(`could not compute fingerprint — every covered file is a repo-wide planning document (direct children of ${sharedRoots.join(', ')} never enter the digest); declare the phase's own artifacts and implementation files`);
|
|
1191
1865
|
return;
|
|
1192
1866
|
}
|
|
1193
1867
|
error('could not compute fingerprint — a covered file is missing, unreadable, or escapes the project root');
|
|
1194
1868
|
return;
|
|
1195
1869
|
}
|
|
1196
|
-
output({ covered_files:
|
|
1870
|
+
output({ covered_files: unionSorted, covered_digest: digest }, raw, digest);
|
|
1871
|
+
}
|
|
1872
|
+
// ─── verification.append-audit (#5105 R3) ──────────────────────────────────
|
|
1873
|
+
/**
|
|
1874
|
+
* Render a single `## <heading> <date>` block followed by a `| Metric |
|
|
1875
|
+
* Count |` table over `rows` — the exact shape `secure-phase.md` /
|
|
1876
|
+
* `validate-phase.md` compose by hand today (#4887 Defect 2, #4981).
|
|
1877
|
+
*/
|
|
1878
|
+
function renderAuditBlock(heading, date, rows) {
|
|
1879
|
+
const lines = [`## ${heading} ${date}`, '', '| Metric | Count |', '|---|---|'];
|
|
1880
|
+
for (const [k, v] of Object.entries(rows))
|
|
1881
|
+
lines.push(`| ${k} | ${String(v)} |`);
|
|
1882
|
+
return lines.join('\n') + '\n';
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* #5105 (S4): parse a block body's `| Metric | Count |`-shaped table into a
|
|
1886
|
+
* plain metric→count string map, addressed by the table's ACTUAL first/second
|
|
1887
|
+
* column (never a hard-coded `Metric`/`Count` name) so a legacy block with
|
|
1888
|
+
* different header text still compares. Whitespace/separator-width tolerant
|
|
1889
|
+
* by construction — `parseMarkdownTable` trims every cell and accepts any
|
|
1890
|
+
* `-{1,}` delimiter width. Returns `null` when the body carries no parseable
|
|
1891
|
+
* 2+-column table (no prior block to compare against).
|
|
1892
|
+
*/
|
|
1893
|
+
function parseAuditTableRows(bodyText) {
|
|
1894
|
+
const parsed = (0, markdown_table_cjs_1.parseMarkdownTable)(bodyText);
|
|
1895
|
+
if (!parsed.ok || parsed.value.columns.length < 2)
|
|
1896
|
+
return null;
|
|
1897
|
+
const [metricCol, countCol] = parsed.value.columns;
|
|
1898
|
+
const map = {};
|
|
1899
|
+
for (const row of parsed.value.rows) {
|
|
1900
|
+
map[row[metricCol]] = String(row[countCol]).trim();
|
|
1901
|
+
}
|
|
1902
|
+
return map;
|
|
1903
|
+
}
|
|
1904
|
+
/** Order-insensitive equality over two metric→count maps. */
|
|
1905
|
+
function auditRowsEqual(live, candidate) {
|
|
1906
|
+
if (!live)
|
|
1907
|
+
return false;
|
|
1908
|
+
const liveKeys = Object.keys(live);
|
|
1909
|
+
const candidateKeys = Object.keys(candidate);
|
|
1910
|
+
if (liveKeys.length !== candidateKeys.length)
|
|
1911
|
+
return false;
|
|
1912
|
+
return liveKeys.every((k) => Object.prototype.hasOwnProperty.call(candidate, k) && live[k] === candidate[k]);
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* #5105 R3 — pure core of `verification.append-audit`.
|
|
1916
|
+
*
|
|
1917
|
+
* Finds the LAST `## <heading> <date>` block in `content` — a level-2
|
|
1918
|
+
* heading whose text matches `^<heading> (\d{4}-\d{2}-\d{2})\b` — via the
|
|
1919
|
+
* shared, fence-aware `tokenizeHeadings`/`collectSection` primitives (#5105
|
|
1920
|
+
* S6) instead of a hand-rolled `^## ` scan: a `## <heading> <date>`-looking
|
|
1921
|
+
* line inside a fenced code block is not a heading and cannot be selected,
|
|
1922
|
+
* and a heading whose trailing word ISN'T a date (e.g. the template's bare
|
|
1923
|
+
* `## Security Audit Trail`) is not matched either (#5105 S4 — the anchored
|
|
1924
|
+
* heading date shape, not `(\S+)`, is what excludes it).
|
|
1925
|
+
*
|
|
1926
|
+
* Comparison (#5105 S4) is over the block's PARSED table rows
|
|
1927
|
+
* (`parseAuditTableRows`/`auditRowsEqual`) — whitespace/separator-width
|
|
1928
|
+
* insensitive, order-insensitive, and tolerant of an optional blank line
|
|
1929
|
+
* after the heading — never a byte-for-byte body string compare. Identical
|
|
1930
|
+
* rows on the last block → `{ appended: false }`, no write. Different rows
|
|
1931
|
+
* (or no prior block) → appends the new block at the end and returns
|
|
1932
|
+
* `{ appended: true }`.
|
|
1933
|
+
*
|
|
1934
|
+
* Deliberately compares against the LAST block only, never any earlier one —
|
|
1935
|
+
* a re-audit that regresses back to an earlier count must still append.
|
|
1936
|
+
*
|
|
1937
|
+
* `date` defaults through the `clock` seam (default: the global `Date`
|
|
1938
|
+
* constructor) rather than a bare `new Date()` call, so a caller can pin the
|
|
1939
|
+
* date deterministically — directly (pass `clock`) or via `node:test`
|
|
1940
|
+
* `mock.timers` (which replaces global `Date`, picked up automatically since
|
|
1941
|
+
* the default is evaluated per call).
|
|
1942
|
+
*/
|
|
1943
|
+
function planAuditAppend(content, { heading, rows, date, clock = Date }) {
|
|
1944
|
+
const resolvedDate = date ?? new clock().toISOString().slice(0, 10);
|
|
1945
|
+
const escapedHeading = (0, pattern_cjs_1.escapeRegex)(heading);
|
|
1946
|
+
const headingRe = new RegExp(`^${escapedHeading} (\\d{4}-\\d{2}-\\d{2})\\b`);
|
|
1947
|
+
const matchingHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).filter((h) => h.level === 2 && headingRe.test(h.text));
|
|
1948
|
+
const newBlock = renderAuditBlock(heading, resolvedDate, rows);
|
|
1949
|
+
const newRowsMap = {};
|
|
1950
|
+
for (const [k, v] of Object.entries(rows))
|
|
1951
|
+
newRowsMap[k] = String(v);
|
|
1952
|
+
if (matchingHeadings.length > 0) {
|
|
1953
|
+
const last = matchingHeadings[matchingHeadings.length - 1];
|
|
1954
|
+
const section = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.offset === last.offset);
|
|
1955
|
+
const liveRows = section ? parseAuditTableRows(section.body) : null;
|
|
1956
|
+
if (auditRowsEqual(liveRows, newRowsMap)) {
|
|
1957
|
+
return { appended: false, content };
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
const trimmed = content.replace(/\s+$/, '');
|
|
1961
|
+
const appendedContent = (trimmed.length > 0 ? trimmed + '\n\n' : '') + newBlock;
|
|
1962
|
+
return { appended: true, content: appendedContent };
|
|
1963
|
+
}
|
|
1964
|
+
/** Reject a `\r`, `\n`, or `|` — any of the three would corrupt the rendered heading/table shape. */
|
|
1965
|
+
function hasForbiddenAuditChar(s) {
|
|
1966
|
+
return /[\r\n|]/.test(s);
|
|
1967
|
+
}
|
|
1968
|
+
/** `rows` values must be a non-negative integer, as either a JSON number or an all-digit string. */
|
|
1969
|
+
function isNonNegativeIntegerValue(v) {
|
|
1970
|
+
if (typeof v === 'number')
|
|
1971
|
+
return Number.isInteger(v) && v >= 0;
|
|
1972
|
+
if (typeof v === 'string')
|
|
1973
|
+
return /^\d+$/.test(v);
|
|
1974
|
+
return false;
|
|
1975
|
+
}
|
|
1976
|
+
/** Reject a value carrying leading/trailing whitespace — a heading or row key
|
|
1977
|
+
* with padding would not match `parseMarkdownTable`'s trimmed reads on a
|
|
1978
|
+
* later append, so the same key would silently fail to be recognized as the
|
|
1979
|
+
* "already present" row (re-review finding 6). */
|
|
1980
|
+
function hasLeadingOrTrailingWhitespace(s) {
|
|
1981
|
+
return s !== s.trim();
|
|
1982
|
+
}
|
|
1983
|
+
/** True calendar-date check for `--date` (re-review finding 9): rejects an
|
|
1984
|
+
* out-of-range month/day (e.g. `2026-99-99`) or a day that does not exist in
|
|
1985
|
+
* that month (e.g. `2026-02-30`), which `/^\d{4}-\d{2}-\d{2}$/` alone lets
|
|
1986
|
+
* through — `Date.UTC` normalizes overflow instead of raising, so the parsed
|
|
1987
|
+
* fields must be compared back against the input. */
|
|
1988
|
+
function isRealCalendarDate(date) {
|
|
1989
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
1990
|
+
if (!m)
|
|
1991
|
+
return false;
|
|
1992
|
+
const year = Number(m[1]);
|
|
1993
|
+
const month = Number(m[2]);
|
|
1994
|
+
const day = Number(m[3]);
|
|
1995
|
+
const d = new Date(Date.UTC(year, month - 1, day));
|
|
1996
|
+
return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* #5105 S3 — validate `verification.append-audit` input shape before it ever
|
|
2000
|
+
* reaches `planAuditAppend`/the file. `--rows` being valid JSON (checked by
|
|
2001
|
+
* the caller before this runs) is necessary but not sufficient: a newline,
|
|
2002
|
+
* `\r`, or `|` in the heading or any row key/value would corrupt the
|
|
2003
|
+
* rendered `## heading date` line or `| key | value |` row, and a non-integer
|
|
2004
|
+
* or negative count is not a countable metric.
|
|
2005
|
+
*/
|
|
2006
|
+
function validateAuditAppendInput(heading, rows, date) {
|
|
2007
|
+
if (hasForbiddenAuditChar(heading)) {
|
|
2008
|
+
return { ok: false, reason: '--heading must not contain a newline, carriage return, or |' };
|
|
2009
|
+
}
|
|
2010
|
+
if (hasLeadingOrTrailingWhitespace(heading)) {
|
|
2011
|
+
return { ok: false, reason: '--heading must not have leading or trailing whitespace' };
|
|
2012
|
+
}
|
|
2013
|
+
for (const [key, value] of Object.entries(rows)) {
|
|
2014
|
+
if (hasForbiddenAuditChar(key)) {
|
|
2015
|
+
return { ok: false, reason: `--rows key ${JSON.stringify(key)} must not contain a newline, carriage return, or |` };
|
|
2016
|
+
}
|
|
2017
|
+
if (hasLeadingOrTrailingWhitespace(key)) {
|
|
2018
|
+
return { ok: false, reason: `--rows key ${JSON.stringify(key)} must not have leading or trailing whitespace` };
|
|
2019
|
+
}
|
|
2020
|
+
if (typeof value === 'string' && hasForbiddenAuditChar(value)) {
|
|
2021
|
+
return { ok: false, reason: `--rows value for ${JSON.stringify(key)} must not contain a newline, carriage return, or |` };
|
|
2022
|
+
}
|
|
2023
|
+
if (!isNonNegativeIntegerValue(value)) {
|
|
2024
|
+
return { ok: false, reason: `--rows value for ${JSON.stringify(key)} must be a non-negative integer` };
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
if (date !== undefined && !isRealCalendarDate(date)) {
|
|
2028
|
+
return { ok: false, reason: '--date must be a real calendar date in YYYY-MM-DD form' };
|
|
2029
|
+
}
|
|
2030
|
+
return { ok: true };
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* #5105 S2 — case-insensitive re-implementation of `isVerificationReportPath`'s
|
|
2034
|
+
* shape. That helper is deliberately case-SENSITIVE (matching
|
|
2035
|
+
* `resolveVerificationFile`'s own convention — see its doc comment), so it
|
|
2036
|
+
* cannot be reused directly for a containment refusal that must catch a
|
|
2037
|
+
* lowercase `07-verification.md` too.
|
|
2038
|
+
*/
|
|
2039
|
+
function isVerificationReportBasenameCI(basename) {
|
|
2040
|
+
const lower = basename.toLowerCase();
|
|
2041
|
+
return lower === 'verification.md' || lower.endsWith('-verification.md');
|
|
2042
|
+
}
|
|
2043
|
+
/** #5105 S2 — the only two shapes `verification.append-audit` may target. */
|
|
2044
|
+
function isAllowedAuditTargetBasenameCI(basename) {
|
|
2045
|
+
const lower = basename.toLowerCase();
|
|
2046
|
+
return lower.endsWith('-security.md') || lower.endsWith('-validation.md');
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
2049
|
+
* CLI command handler (#5105 R3): `verification.append-audit <file>
|
|
2050
|
+
* --heading <H> --rows '<json {metric:count}>' [--date <YYYY-MM-DD>]`.
|
|
2051
|
+
*
|
|
2052
|
+
* Never reads or writes `covered_files`/`covered_digest` (#4981 invariant
|
|
2053
|
+
* 5) — a genuinely changed count publishes and stales any report that covers
|
|
2054
|
+
* `file`; nothing here ever restamps it.
|
|
2055
|
+
*
|
|
2056
|
+
* #5105 S2: `file` is resolved through the same `requireSafePath(...,
|
|
2057
|
+
* PathAcceptance.AbsoluteInsideRoot)` seam `uat.complete-session` uses —
|
|
2058
|
+
* refusing an absolute-outside-root target or a `../` escape by throwing
|
|
2059
|
+
* before any read/write is attempted (uncaught here, matching every other
|
|
2060
|
+
* `requireSafePath` call site in this codebase — a top-level command
|
|
2061
|
+
* dispatcher turns the throw into a failed exit). The REAL (symlink-resolved)
|
|
2062
|
+
* basename is then checked twice, case-insensitively: it must not be a
|
|
2063
|
+
* verification report itself, and it must be a `*-SECURITY.md` or
|
|
2064
|
+
* `*-VALIDATION.md` file — the only two artifact kinds this command may
|
|
2065
|
+
* mutate.
|
|
2066
|
+
*/
|
|
2067
|
+
function cmdVerificationAppendAudit(cwd, fileArg, argTokens, raw) {
|
|
2068
|
+
if (!fileArg) {
|
|
2069
|
+
error('file required for verification.append-audit');
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
const { heading, rows: rowsArg, date: dateArg } = (0, command_arg_projection_cjs_1.parseNamedArgsOrExit)(argTokens, { valueFlags: ['heading', 'rows', 'date'], positionals: 0 }, error);
|
|
2073
|
+
if (!heading) {
|
|
2074
|
+
error('--heading required for verification.append-audit');
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
if (!rowsArg) {
|
|
2078
|
+
error('--rows required for verification.append-audit');
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
let rows;
|
|
2082
|
+
try {
|
|
2083
|
+
const parsedRows = JSON.parse(rowsArg);
|
|
2084
|
+
if (!parsedRows || typeof parsedRows !== 'object' || Array.isArray(parsedRows)) {
|
|
2085
|
+
throw new Error('not an object');
|
|
2086
|
+
}
|
|
2087
|
+
rows = parsedRows;
|
|
2088
|
+
}
|
|
2089
|
+
catch {
|
|
2090
|
+
error('--rows must be a JSON object for verification.append-audit');
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
const date = dateArg ?? undefined;
|
|
2094
|
+
const validation = validateAuditAppendInput(heading, rows, date);
|
|
2095
|
+
if (!validation.ok) {
|
|
2096
|
+
error(validation.reason);
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
const resolvedPath = (0, security_cjs_1.requireSafePath)(fileArg, cwd, 'verification.append-audit file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
|
|
2100
|
+
const realBasename = node_path_1.default.basename(resolvedPath);
|
|
2101
|
+
if (isVerificationReportBasenameCI(realBasename)) {
|
|
2102
|
+
error('verification.append-audit refuses a verification report path');
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
if (!isAllowedAuditTargetBasenameCI(realBasename)) {
|
|
2106
|
+
error('verification.append-audit target must be a *-SECURITY.md or *-VALIDATION.md file');
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
let content;
|
|
2110
|
+
try {
|
|
2111
|
+
content = node_fs_1.default.readFileSync(resolvedPath, 'utf-8');
|
|
2112
|
+
}
|
|
2113
|
+
catch {
|
|
2114
|
+
error(`file not found: ${fileArg}`);
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const result = planAuditAppend(content, { heading: heading, rows, date });
|
|
2118
|
+
if (result.appended) {
|
|
2119
|
+
node_fs_1.default.writeFileSync(resolvedPath, result.content);
|
|
2120
|
+
}
|
|
2121
|
+
output({ appended: result.appended }, raw);
|
|
1197
2122
|
}
|
|
1198
|
-
|
|
2123
|
+
const verificationModule = {
|
|
2124
|
+
VERIFICATION_STATUS,
|
|
1199
2125
|
VERIFIER_STATUSES,
|
|
1200
|
-
|
|
2126
|
+
VERIFICATION_ROUTES,
|
|
2127
|
+
isVerificationStatus,
|
|
2128
|
+
assertVerificationStatus,
|
|
2129
|
+
VerificationStatusError,
|
|
2130
|
+
VERIFICATION_STATUS_ERROR_CODE,
|
|
2131
|
+
failOnVerificationStatusError,
|
|
2132
|
+
firstStatusError,
|
|
2133
|
+
reportStatusOf,
|
|
2134
|
+
isReportContained,
|
|
2135
|
+
routeResult,
|
|
2136
|
+
findVerificationStatusError,
|
|
1201
2137
|
defaultPhaseCleanCommitTimesMs,
|
|
2138
|
+
resolvePhaseArtifactFile,
|
|
1202
2139
|
resolveVerificationFile,
|
|
1203
2140
|
resolveUatFile,
|
|
1204
2141
|
findStaleVerificationSummary,
|
|
@@ -1209,7 +2146,11 @@ module.exports = {
|
|
|
1209
2146
|
computeCoveredDigest,
|
|
1210
2147
|
sharedPlanningRoots,
|
|
1211
2148
|
isSharedPlanningDoc,
|
|
2149
|
+
isVerificationReportPath,
|
|
1212
2150
|
parseFingerprintVersion,
|
|
1213
2151
|
parseFingerprintFileArgs,
|
|
1214
2152
|
cmdVerificationFingerprint,
|
|
2153
|
+
planAuditAppend,
|
|
2154
|
+
cmdVerificationAppendAudit,
|
|
1215
2155
|
};
|
|
2156
|
+
module.exports = verificationModule;
|