@opengsd/gsd-core 1.9.1 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -3
- package/.opencode/plugins/gsd-core.js +8 -1
- package/agents/gsd-code-fixer.md +27 -3
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-debugger.md +12 -246
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +12 -10
- package/agents/gsd-integration-checker.md +3 -0
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +46 -0
- package/agents/gsd-planner.md +49 -54
- package/agents/gsd-roadmapper.md +21 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +26 -73
- package/bin/install.js +1272 -1238
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +2 -2
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/plan-phase.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1009 -115
- package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
- package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
- package/gsd-core/bin/lib/api-coverage.cjs +123 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +926 -202
- package/gsd-core/bin/lib/broken-windows.cjs +36 -6
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +608 -148
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +507 -24
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +114 -38
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +94 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +665 -99
- package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +76 -0
- package/gsd-core/bin/lib/config.cjs +22 -2
- package/gsd-core/bin/lib/context-composer.cjs +278 -0
- package/gsd-core/bin/lib/context-predicates.cjs +506 -0
- package/gsd-core/bin/lib/core-utils.cjs +217 -40
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/docs.cjs +3 -2
- package/gsd-core/bin/lib/external-job.cjs +19 -4
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +239 -32
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
- package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
- package/gsd-core/bin/lib/graphify.cjs +142 -27
- package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-integration.cjs +13 -1
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +83 -8
- package/gsd-core/bin/lib/init.cjs +1325 -169
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +805 -264
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +160 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
- package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
- package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
- package/gsd-core/bin/lib/io.cjs +38 -3
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
- package/gsd-core/bin/lib/mcp-server.cjs +135 -3
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +821 -109
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +507 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +258 -58
- package/gsd-core/bin/lib/phase.cjs +891 -156
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +405 -84
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/section-manifest.cjs +209 -0
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
- package/gsd-core/bin/lib/smart-entry.cjs +154 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +424 -105
- package/gsd-core/bin/lib/state.cjs +1927 -401
- package/gsd-core/bin/lib/surface.cjs +35 -10
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
- package/gsd-core/bin/lib/uat.cjs +706 -64
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +33 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +287 -20
- package/gsd-core/bin/lib/verify.cjs +368 -880
- package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
- package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
- package/gsd-core/bin/lib/workstream.cjs +8 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/artifact-types.md +10 -3
- package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/debugger-techniques.md +255 -0
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/research-documentation-lookup.md +5 -3
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +8 -7
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verifier-wiring-patterns.md +100 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/references/worktree-branch-check.md +2 -2
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/summary-complex.md +2 -0
- package/gsd-core/templates/summary-minimal.md +2 -0
- package/gsd-core/templates/summary-standard.md +2 -0
- package/gsd-core/templates/summary.md +2 -0
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/audit-milestone.md +3 -0
- package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
- package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
- package/gsd-core/workflows/autonomous.md +33 -70
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +74 -166
- package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
- package/gsd-core/workflows/complete-milestone.md +160 -95
- package/gsd-core/workflows/debug.md +16 -17
- package/gsd-core/workflows/diagnose-issues.md +56 -8
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
- package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
- package/gsd-core/workflows/docs-update.md +8 -51
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
- package/gsd-core/workflows/execute-phase.md +103 -187
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/help/modes/full.md +6 -1
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +7 -6
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
- package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
- package/gsd-core/workflows/new-milestone.md +35 -47
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
- package/gsd-core/workflows/new-project.md +27 -240
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
- package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
- package/gsd-core/workflows/plan-phase.md +89 -209
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
- package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
- package/gsd-core/workflows/progress.md +45 -159
- package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
- package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
- package/gsd-core/workflows/quick.md +55 -405
- package/gsd-core/workflows/resume-project.md +3 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
- package/gsd-core/workflows/review.md +41 -13
- package/gsd-core/workflows/section-manifest.json +219 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +41 -20
- package/gsd-core/workflows/spike-wrap-up.md +20 -5
- package/gsd-core/workflows/spike.md +50 -16
- package/gsd-core/workflows/sync-skills.md +106 -13
- package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
- package/gsd-core/workflows/transition.md +53 -31
- package/gsd-core/workflows/ui-phase.md +13 -12
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
- package/gsd-core/workflows/update.md +19 -8
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
- package/gsd-core/workflows/verify-work.md +17 -65
- package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
- package/hooks/dist/gsd-check-update-worker.js +64 -12
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +45 -24
- package/hooks/dist/gsd-statusline.js +90 -6
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/gsd-worktree-path-guard.js +2 -1
- package/hooks/dist/gsd-write-guard.js +359 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +277 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-agent-isolation-guard.js +517 -0
- package/hooks/gsd-check-update-worker.js +64 -12
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +607 -26
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +45 -24
- package/hooks/gsd-statusline.js +90 -6
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/gsd-worktree-path-guard.js +2 -1
- package/hooks/gsd-write-guard.js +359 -0
- package/hooks/hooks.json +12 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +277 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +31 -10
- package/pi/gsd.cjs +71 -12
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/build-hooks.js +9 -0
- package/scripts/changeset/lint.cjs +68 -6
- package/scripts/changeset/serialize.cjs +5 -1
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-matrix.cjs +1 -1
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +439 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +150 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-section-manifest.cjs +638 -0
- package/scripts/generate-package-identity.cjs +4 -2
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-compiled-artifact-sync.cjs +6 -1
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-docs-command-form.cjs +195 -0
- package/scripts/lint-docs-required.cjs +9 -1
- package/scripts/lint-emitted-drift-ack.cjs +215 -20
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-example-parser-parity.cjs +395 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +40 -3
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/mutation-matrix.cjs +13 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +52 -6
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +2 -2
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +2 -2
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +1 -2
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -577
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/gen-emitted-baseline.cjs +0 -145
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1050
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Anti-divergence drift guard for the STATE.md WRITE PATH — epic #3408, issue
|
|
6
|
+
* #3468, ADR-3408 Decision 5, contract §8.1/§8.2/§8.3
|
|
7
|
+
* (`docs/adr/3408-state-write-path-preservation.md` is the contract this
|
|
8
|
+
* guard enforces; read it first).
|
|
9
|
+
*
|
|
10
|
+
* TWO AXES, ONE GUARD, because they fail together — a table that is
|
|
11
|
+
* bypassed on dispatch and a seam that is bypassed on write are the same
|
|
12
|
+
* failure mode ("policy declared, enforcement hand-rolled") applied to two
|
|
13
|
+
* different call shapes:
|
|
14
|
+
*
|
|
15
|
+
* AXIS 1 — POLICY DISPATCH (§8.1). `applyStatePreservation` must select
|
|
16
|
+
* its branch from a `FIELD_CLASSIFICATION` row's `preservation` value,
|
|
17
|
+
* never from a field NAME. Every `getFieldClassification('<string
|
|
18
|
+
* literal>')` inside `src/state-transition.cts` is a field-name-keyed
|
|
19
|
+
* branch — the shape that let four declared rows go unimplemented until
|
|
20
|
+
* #3258, and that leaves `derive` and `clear` with no executor today. A
|
|
21
|
+
* VARIABLE argument (`getFieldClassification(field)`) is the CORRECT
|
|
22
|
+
* table-driven shape and is deliberately NOT matched. Also matched: a
|
|
23
|
+
* direct `field === '<literal>'` / `field !== '<literal>'` / `'<literal>'
|
|
24
|
+
* === field` comparison of the dispatch loop's own `field` variable — the
|
|
25
|
+
* same prohibited shape routed AROUND `getFieldClassification` instead of
|
|
26
|
+
* through it (#3468 found this exact form live in `applyPreserveIfPlaceholder`,
|
|
27
|
+
* undetected by the call-shape check alone). Scoped to the identifier
|
|
28
|
+
* `field` only; see `FIELD_VAR_EQ_LITERAL_RE`'s own comment for why.
|
|
29
|
+
*
|
|
30
|
+
* AXIS 2 — WRITE SEAM (§8.3), RATCHETED. `readModifyWriteStateMd` is the
|
|
31
|
+
* only path meant to write STATE.md. Every direct `writeStateMd(` or
|
|
32
|
+
* `syncStateFrontmatter(` call outside the owner's own definitions is a
|
|
33
|
+
* bypass that skips preservation and the #948 no-op guard — how #3374 and
|
|
34
|
+
* #3350 reproduce. This axis ships RATCHETED (see below), never a bare
|
|
35
|
+
* 0-or-fail check.
|
|
36
|
+
*
|
|
37
|
+
* DESIGN CONSTRAINTS (ADR-3180 Decision 4, adopted verbatim by ADR-3408):
|
|
38
|
+
* - 4(a) whole-repo scan, never an allowlist. ADR-3180's own phases found
|
|
39
|
+
* 26/5/54 copies where their epics scoped 3/3/4 — a scoped guard earns
|
|
40
|
+
* nothing.
|
|
41
|
+
* - 4(d) the scan surface is DECLARED and is NOT just `src/` — `src/`
|
|
42
|
+
* alone is itself an allowlist one directory wide; #1762 traced a wrong
|
|
43
|
+
* count to a shell snippet in `gsd-core/workflows/progress.md`. And
|
|
44
|
+
* inward: the OWNER FILE (`src/state.cts`) is not exempt, only its
|
|
45
|
+
* named canonical FUNCTIONS are (`SEAM_OWNER_EXEMPT_FUNCTIONS` below).
|
|
46
|
+
* - 4(e) Axis 2 ships ratcheted because Phase 1 (this file) cannot
|
|
47
|
+
* consolidate the write seam — that is Phase 2 (#3469). Landing a
|
|
48
|
+
* guard later, against an already-clean tree, is the "found it, wrote
|
|
49
|
+
* it down, moved on" posture the epic removes.
|
|
50
|
+
*
|
|
51
|
+
* GOODHART, PER ADR-3408 DECISION 5: "0 bypasses" is a LAGGING metric — a
|
|
52
|
+
* measure about to become a target. This guard's own `_comment` and its
|
|
53
|
+
* human-readable success message both say so: the zero this guard reports
|
|
54
|
+
* must NEVER be quoted alone; it is only meaningful beside the behavioral
|
|
55
|
+
* identity test's result (the consumer-output assertion Decision 5's gaming
|
|
56
|
+
* table names as the actual defense).
|
|
57
|
+
*
|
|
58
|
+
* String literals are matched, never parsed as an AST — deliberately, per
|
|
59
|
+
* `scripts/lint-state-field-drift.cjs`'s own precedent: over-reporting
|
|
60
|
+
* (flagging a comment or a string that merely looks like a call) is safe;
|
|
61
|
+
* under-reporting (missing a real bypass) is the failure this guard exists
|
|
62
|
+
* to prevent. `stripComments` does not track quoted strings for exactly
|
|
63
|
+
* this reason — see its own header.
|
|
64
|
+
*
|
|
65
|
+
* AXIS 3 — FRONTMATTER-SHAPED WRITE (§8.3(b)), CLOSED IN PHASE 2 (#3469).
|
|
66
|
+
* Phase 1 left this as a DECLARED KNOWN GAP: `patchCore` ran
|
|
67
|
+
* `stateReplaceField(` over the WHOLE document (body + frontmatter) instead
|
|
68
|
+
* of stripping frontmatter first, the way `updateCore` does, and a naive
|
|
69
|
+
* co-occurrence approximation ("does the enclosing function also call
|
|
70
|
+
* `stripFrontmatter(`?") measured at 33 occurrences of `stateReplaceField(`,
|
|
71
|
+
* of which only 4 were genuine write-seam bypasses and 29 were noise — the
|
|
72
|
+
* definition of `stateReplaceField` itself, ~20 calls on `sectionBody` (a
|
|
73
|
+
* body slice that is frontmatter-free by construction), and several calls
|
|
74
|
+
* inside `readModifyWriteStateMd` callbacks. 29 false positives to 1 true
|
|
75
|
+
* positive would have buried the signal.
|
|
76
|
+
*
|
|
77
|
+
* Phase 2 fixes `patchCore` (it now strips frontmatter first, matching
|
|
78
|
+
* `updateCore`) AND closes the gap, using a narrower, two-factor shape that
|
|
79
|
+
* does not reproduce that ratio: `findUnstrippedContentWrites` below flags a
|
|
80
|
+
* `stateReplaceField(` call only when BOTH (a) its field-name argument is a
|
|
81
|
+
* VARIABLE, not a fixed string literal — every OTHER call site in
|
|
82
|
+
* `EXECUTOR_FILE` passes a fixed Title-Case literal (`'Phase'`, `'Total
|
|
83
|
+
* Plans in Phase'`, ...) that can never collide with a lowercase/snake_case
|
|
84
|
+
* YAML frontmatter key, so a literal field name is never a candidate
|
|
85
|
+
* regardless of whether its content argument is stripped — and (b) its
|
|
86
|
+
* content argument has not been run through `stripFrontmatter` first,
|
|
87
|
+
* checked by a simple backward scan (within the same function) for the
|
|
88
|
+
* nearest preceding assignment to that argument's variable name. This is
|
|
89
|
+
* deliberately NOT full alias/dataflow tracking — see the function's own
|
|
90
|
+
* docstring for the narrow, documented limitation this trades for
|
|
91
|
+
* tractability.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
const fs = require('node:fs');
|
|
95
|
+
const path = require('node:path');
|
|
96
|
+
const { scanTree, sanitizeForReport } = require('./lib/drift-scan.cjs');
|
|
97
|
+
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
|
98
|
+
|
|
99
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
100
|
+
const BASELINE_PATH = path.join(__dirname, 'state-write-path-drift-baseline.json');
|
|
101
|
+
|
|
102
|
+
// Frozen REASON enum — mirrors `lint-state-field-drift.cjs`'s house style of
|
|
103
|
+
// naming every failure shape explicitly rather than reusing one generic
|
|
104
|
+
// "violation" string, so a reader can `grep` a reason string straight back
|
|
105
|
+
// to the paragraph of this header (or of the ADR) that explains it.
|
|
106
|
+
const REASON = Object.freeze({
|
|
107
|
+
FIELD_NAME_DISPATCH: 'field_name_dispatch',
|
|
108
|
+
UNIMPLEMENTED_POLICY: 'unimplemented_policy',
|
|
109
|
+
// Axis 3 (§8.3(b), closed Phase 2 / #3469): a `stateReplaceField(` call
|
|
110
|
+
// with a variable field-name argument whose content argument was not run
|
|
111
|
+
// through `stripFrontmatter` first — see `findUnstrippedContentWrites`.
|
|
112
|
+
UNSTRIPPED_CONTENT_WRITE: 'unstripped_content_write',
|
|
113
|
+
SEAM_BYPASS_UNRECORDED: 'seam_bypass_unrecorded',
|
|
114
|
+
SEAM_BYPASS_COUNT_GREW: 'seam_bypass_count_grew',
|
|
115
|
+
SEAM_BYPASS_COUNT_SHRANK: 'seam_bypass_count_shrank',
|
|
116
|
+
BASELINE_ENTRY_STALE: 'baseline_entry_stale',
|
|
117
|
+
BASELINE_UNREADABLE: 'baseline_unreadable',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Scan surface — declared, per Decision 4(d), never inferred from `src/`
|
|
121
|
+
// alone. `src/` covers the executor and the write-seam owner; the prompt
|
|
122
|
+
// layer covers markdown that can shell out to `state.patch` / `phase.complete`
|
|
123
|
+
// and post-process the result outside any TypeScript this guard could see.
|
|
124
|
+
const SRC_DIRS = ['src'];
|
|
125
|
+
const SRC_EXT = new Set(['.cts']);
|
|
126
|
+
const PROMPT_DIRS = ['gsd-core/workflows', 'commands', 'agents', 'skills'];
|
|
127
|
+
const PROMPT_EXT = new Set(['.md']);
|
|
128
|
+
|
|
129
|
+
// The executor (Axis 1) and the write-seam owner (Axis 2). Forward-slash
|
|
130
|
+
// literals: every `rel` this guard compares against them is unconditionally
|
|
131
|
+
// POSIX-normalized first (`toPosixRel` below) — never gated on
|
|
132
|
+
// `process.platform`.
|
|
133
|
+
const EXECUTOR_FILE = 'src/state-transition.cts';
|
|
134
|
+
const SEAM_OWNER_FILE = 'src/state.cts';
|
|
135
|
+
|
|
136
|
+
// Per Decision 4(d)'s "owner FILE is not exempt, only its named canonical
|
|
137
|
+
// FUNCTIONS are": a `writeStateMd(`/`syncStateFrontmatter(`/
|
|
138
|
+
// `applyPostSyncPreservation(` call inside one of these two functions, in
|
|
139
|
+
// `SEAM_OWNER_FILE` only, is the seam's own internal plumbing, not a bypass.
|
|
140
|
+
// `writeStateMd` is the `cmdStateSync`/`REGENERATE_STATE` path's own I/O
|
|
141
|
+
// wrapper calling `syncStateFrontmatter` directly (no preservation, by
|
|
142
|
+
// design — §8.3's closed exception list). `syncAndPreserveStateMd` (#3469)
|
|
143
|
+
// is the ONE write-seam composition — `syncStateFrontmatter` then
|
|
144
|
+
// `applyPostSyncPreservation` — every OTHER caller needing a non-standard
|
|
145
|
+
// I/O envelope routes through. Every OTHER function in `state.cts` — and
|
|
146
|
+
// every function in every OTHER file — is still scanned and still flagged;
|
|
147
|
+
// in particular, `readModifyWriteStateMd` is NOT exempt: after #3469 it no
|
|
148
|
+
// longer contains a direct `syncStateFrontmatter(`/`applyPostSyncPreservation(`
|
|
149
|
+
// call at all (it calls `syncAndPreserveStateMd` like everyone else), so if
|
|
150
|
+
// one reappeared there it would be exactly the re-assembly shape this axis
|
|
151
|
+
// exists to catch.
|
|
152
|
+
const SEAM_OWNER_EXEMPT_FUNCTIONS = ['writeStateMd', 'syncAndPreserveStateMd'];
|
|
153
|
+
|
|
154
|
+
// Unconditional path-separator normalization (never gated on
|
|
155
|
+
// `process.platform` — a Windows-authored fork PR must be judged by the
|
|
156
|
+
// same POSIX-relative rule as everything else this guard reads).
|
|
157
|
+
function toPosixRel(rel) {
|
|
158
|
+
return rel.split(path.sep).join('/');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Strip `//` line comments and `/* ... *\/` block comments from `text`,
|
|
163
|
+
* returning one entry PER INPUT LINE so line numbers computed against the
|
|
164
|
+
* result stay correct against the original file. Block comments are tracked
|
|
165
|
+
* across lines (`inBlock`); line comments only ever affect their own line.
|
|
166
|
+
*
|
|
167
|
+
* Deliberately does NOT parse string/template literal contents — a `//` or
|
|
168
|
+
* `/*` embedded inside a quoted string is treated exactly like real source,
|
|
169
|
+
* which can occasionally UNDER-strip (leaving a would-be-comment's text
|
|
170
|
+
* live) but never OVER-strips real code into invisibility. Per this guard's
|
|
171
|
+
* header and `lint-state-field-drift.cjs`'s own precedent: over-reporting a
|
|
172
|
+
* documentation paragraph that merely DESCRIBES a call (ADR-3180 Amendment
|
|
173
|
+
* 3's exact false positive) is the failure this exists to prevent; a rare
|
|
174
|
+
* miss on an adversarial one-line string is an accepted, narrower risk in
|
|
175
|
+
* the opposite (safe) direction — under-reporting, never over-reporting.
|
|
176
|
+
*/
|
|
177
|
+
function stripComments(text) {
|
|
178
|
+
const lines = text.split('\n');
|
|
179
|
+
const out = new Array(lines.length);
|
|
180
|
+
let inBlock = false;
|
|
181
|
+
for (let i = 0; i < lines.length; i++) {
|
|
182
|
+
const line = lines[i];
|
|
183
|
+
let result = '';
|
|
184
|
+
let j = 0;
|
|
185
|
+
while (j < line.length) {
|
|
186
|
+
if (inBlock) {
|
|
187
|
+
const close = line.indexOf('*/', j);
|
|
188
|
+
if (close === -1) {
|
|
189
|
+
j = line.length;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
j = close + 2;
|
|
193
|
+
inBlock = false;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (line[j] === '/' && line[j + 1] === '/') {
|
|
197
|
+
j = line.length; // rest of line is a line comment
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
if (line[j] === '/' && line[j + 1] === '*') {
|
|
201
|
+
inBlock = true;
|
|
202
|
+
j += 2;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
result += line[j];
|
|
206
|
+
j++;
|
|
207
|
+
}
|
|
208
|
+
out[i] = result;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A named function declaration, tolerating `export`/`async` prefixes — the
|
|
214
|
+
// SAME shape `enclosingFunction` looks backward for and `findSeamBypasses`
|
|
215
|
+
// uses to recognise (and skip) the seam functions' own definitions.
|
|
216
|
+
const FUNCTION_DECL_LINE_RE = /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Nearest preceding named-function declaration, scanning `lines` BACKWARD
|
|
220
|
+
* from `index`. Scopes an exemption to a FUNCTION, never a FILE — a whole-
|
|
221
|
+
* file owner exemption is precisely how `getMilestoneInfo` stayed invisible
|
|
222
|
+
* to an earlier drift guard (ADR-3180 Decision 4(d)'s own cautionary case,
|
|
223
|
+
* cross-referenced by this guard's header).
|
|
224
|
+
*/
|
|
225
|
+
function enclosingFunction(lines, index) {
|
|
226
|
+
for (let i = index; i >= 0; i--) {
|
|
227
|
+
const m = FUNCTION_DECL_LINE_RE.exec(lines[i]);
|
|
228
|
+
if (m) return m[1];
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// One member of the `FieldPreservation` union, e.g. `'preserve-always'` —
|
|
234
|
+
// lowercase-with-dashes, single-quoted.
|
|
235
|
+
const POLICY_UNION_START_RE = /export\s+type\s+FieldPreservation\s*=/;
|
|
236
|
+
const POLICY_UNION_MEMBER_RE = /'([a-z][a-z-]*)'/g;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Parse the members of `export type FieldPreservation = 'a' | 'b' | ...;`
|
|
240
|
+
* straight out of the executor's own source, so this guard cannot drift
|
|
241
|
+
* from the type it polices (a hardcoded copy of the union would be exactly
|
|
242
|
+
* the "declared here, enforced somewhere else" shape ADR-3408 exists to
|
|
243
|
+
* remove — this time inside the GUARD). In `src/state-transition.cts` the
|
|
244
|
+
* union is declared across several lines, each shaped like
|
|
245
|
+
* ` | 'clear'; // remove the field entirely`. Scans from the declaration
|
|
246
|
+
* line forward, collecting every quoted token on each line, and stops at
|
|
247
|
+
* the first line whose (comment-INCLUDING) text still carries a `;` — the
|
|
248
|
+
* statement terminator ends the union regardless of any trailing comment.
|
|
249
|
+
*/
|
|
250
|
+
function readPolicyUnion(text) {
|
|
251
|
+
const lines = text.split('\n');
|
|
252
|
+
const members = [];
|
|
253
|
+
let inUnion = false;
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
if (!inUnion) {
|
|
256
|
+
if (!POLICY_UNION_START_RE.test(line)) continue;
|
|
257
|
+
inUnion = true;
|
|
258
|
+
}
|
|
259
|
+
POLICY_UNION_MEMBER_RE.lastIndex = 0;
|
|
260
|
+
let m;
|
|
261
|
+
while ((m = POLICY_UNION_MEMBER_RE.exec(line)) !== null) {
|
|
262
|
+
members.push(m[1]);
|
|
263
|
+
}
|
|
264
|
+
if (line.includes(';')) break;
|
|
265
|
+
}
|
|
266
|
+
return members;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// `getFieldClassification(` called with a quoted string-literal argument —
|
|
270
|
+
// the field-name-keyed dispatch shape. `getFieldClassification(variable)`
|
|
271
|
+
// (a bare identifier, no quote) never matches this pattern, by construction
|
|
272
|
+
// (the quote-character backreference requires an opening quote immediately
|
|
273
|
+
// inside the parens) — the correct, table-driven shape is silent here.
|
|
274
|
+
const FIELD_NAME_DISPATCH_RE = /getFieldClassification\s*\(\s*(['"`])([^'"`]+)\1\s*\)/g;
|
|
275
|
+
|
|
276
|
+
// `field === '<literal>'` / `field !== '<literal>'`, and the reversed
|
|
277
|
+
// `'<literal>' === field` — the field-name-keyed BRANCH shape (as opposed to
|
|
278
|
+
// `FIELD_NAME_DISPATCH_RE`'s field-name-keyed CALL shape above; both report
|
|
279
|
+
// the same `REASON.FIELD_NAME_DISPATCH`, since both are "a branch selected
|
|
280
|
+
// by field name", ADR-3408 §8.1's exact prohibition). This is the shape a
|
|
281
|
+
// bypass takes when it routes AROUND `getFieldClassification` entirely
|
|
282
|
+
// rather than through it — ADR-3408 Decision 5's "route the bypass through
|
|
283
|
+
// a wrapper or a differently-named local" gaming route.
|
|
284
|
+
//
|
|
285
|
+
// Deliberately scoped to ONLY an identifier literally named `field` — the
|
|
286
|
+
// dispatch loop's own loop variable declared at
|
|
287
|
+
// `for (const field of Object.keys(FIELD_CLASSIFICATION))` a few dozen lines
|
|
288
|
+
// below in this same file. This is a DECLARED, narrow limitation, not a
|
|
289
|
+
// silent one: a rename of the loop variable would evade this detector
|
|
290
|
+
// entirely, and an unrelated local elsewhere in this file that happens to
|
|
291
|
+
// also be named `field` would false-positive. Both risks are accepted
|
|
292
|
+
// in trade for avoiding a name-agnostic match, which would flag every
|
|
293
|
+
// unrelated `===`/`!==` string comparison in the file (there are many —
|
|
294
|
+
// e.g. `derivedName !== MILESTONE_PLACEHOLDER`-shaped guards) and bury the
|
|
295
|
+
// real signal in noise; per this guard's own header, over-reporting a
|
|
296
|
+
// comment is an accepted risk but over-reporting live code this broadly is
|
|
297
|
+
// not.
|
|
298
|
+
//
|
|
299
|
+
// The reversed `!==` form (`'<literal>' !== field`) is deliberately NOT
|
|
300
|
+
// matched — not observed anywhere in this codebase, and left out rather
|
|
301
|
+
// than speculatively widened past what was found in practice.
|
|
302
|
+
const FIELD_VAR_EQ_LITERAL_RE = /\bfield\s*(?:===|!==)\s*(['"`])([^'"`]+)\1/g;
|
|
303
|
+
const LITERAL_EQ_FIELD_VAR_RE = /(['"`])([^'"`]+)\1\s*===\s*\bfield\b/g;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* AXIS 1a: every `getFieldClassification('<literal>')` CALL, and every
|
|
307
|
+
* `field === '<literal>'` / `field !== '<literal>'` / `'<literal>' ===
|
|
308
|
+
* field` BRANCH, inside the executor is a field-name-keyed branch (§8.1).
|
|
309
|
+
* Only ever called against `EXECUTOR_FILE` — `collect()` gates the call
|
|
310
|
+
* site, mirroring `findPolicyDispatchDrift`'s own "only when rel ===
|
|
311
|
+
* EXECUTOR_FILE" rule from the spec this guard was authored against.
|
|
312
|
+
* `preservation === '<member>'` comparisons — the CORRECT policy-dispatch
|
|
313
|
+
* shape `findUnimplementedPolicies` requires to exist — are unaffected: the
|
|
314
|
+
* identifier compared there is `preservation`, never `field`, so
|
|
315
|
+
* `FIELD_VAR_EQ_LITERAL_RE`'s `\bfield\b` anchor does not reach them.
|
|
316
|
+
*/
|
|
317
|
+
function findPolicyDispatchDrift(rel, text) {
|
|
318
|
+
const out = [];
|
|
319
|
+
const stripped = stripComments(text);
|
|
320
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
321
|
+
const line = stripped[i];
|
|
322
|
+
if (!line.trim()) continue;
|
|
323
|
+
// `file` is sanitized here, at construction, not just at the human
|
|
324
|
+
// formatter: `rel` is exactly as attacker-controlled as `source` on a
|
|
325
|
+
// fork PR (a tracked filename can legally carry C1 bytes or bidi
|
|
326
|
+
// overrides), and it reaches the committed baseline and `--json` stdout
|
|
327
|
+
// unfiltered otherwise — see `sanitizeForReport`'s own header.
|
|
328
|
+
FIELD_NAME_DISPATCH_RE.lastIndex = 0;
|
|
329
|
+
let m;
|
|
330
|
+
while ((m = FIELD_NAME_DISPATCH_RE.exec(line)) !== null) {
|
|
331
|
+
out.push({
|
|
332
|
+
reason: REASON.FIELD_NAME_DISPATCH,
|
|
333
|
+
axis: 'policy-dispatch',
|
|
334
|
+
file: sanitizeForReport(rel),
|
|
335
|
+
line: i + 1,
|
|
336
|
+
// `field` is captured straight out of a quoted string literal in
|
|
337
|
+
// repo source — attacker-controlled on the same fork-PR basis as
|
|
338
|
+
// `file`/`source`, so sanitize it too rather than let it reach
|
|
339
|
+
// `--json` stdout / the baseline raw.
|
|
340
|
+
field: sanitizeForReport(m[2]),
|
|
341
|
+
source: sanitizeForReport(line.trim()),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
FIELD_VAR_EQ_LITERAL_RE.lastIndex = 0;
|
|
345
|
+
while ((m = FIELD_VAR_EQ_LITERAL_RE.exec(line)) !== null) {
|
|
346
|
+
out.push({
|
|
347
|
+
reason: REASON.FIELD_NAME_DISPATCH,
|
|
348
|
+
axis: 'policy-dispatch',
|
|
349
|
+
file: sanitizeForReport(rel),
|
|
350
|
+
line: i + 1,
|
|
351
|
+
// `field` is captured straight out of a quoted string literal in
|
|
352
|
+
// repo source — attacker-controlled on the same fork-PR basis as
|
|
353
|
+
// `file`/`source`, so sanitize it too rather than let it reach
|
|
354
|
+
// `--json` stdout / the baseline raw.
|
|
355
|
+
field: sanitizeForReport(m[2]),
|
|
356
|
+
source: sanitizeForReport(line.trim()),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
LITERAL_EQ_FIELD_VAR_RE.lastIndex = 0;
|
|
360
|
+
while ((m = LITERAL_EQ_FIELD_VAR_RE.exec(line)) !== null) {
|
|
361
|
+
out.push({
|
|
362
|
+
reason: REASON.FIELD_NAME_DISPATCH,
|
|
363
|
+
axis: 'policy-dispatch',
|
|
364
|
+
file: sanitizeForReport(rel),
|
|
365
|
+
line: i + 1,
|
|
366
|
+
// `field` is captured straight out of a quoted string literal in
|
|
367
|
+
// repo source — attacker-controlled on the same fork-PR basis as
|
|
368
|
+
// `file`/`source`, so sanitize it too rather than let it reach
|
|
369
|
+
// `--json` stdout / the baseline raw.
|
|
370
|
+
field: sanitizeForReport(m[2]),
|
|
371
|
+
source: sanitizeForReport(line.trim()),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* AXIS 1b: every `FieldPreservation` member (read from `text` via
|
|
380
|
+
* `readPolicyUnion`, so the check cannot itself drift from the union) that
|
|
381
|
+
* has no `preservation === '<member>'` comparison anywhere in the
|
|
382
|
+
* comment-stripped executor source is a declared policy with no executor —
|
|
383
|
+
* §8.1's mirror defect, one level up (a whole MEMBER unimplemented, not just
|
|
384
|
+
* one dispatch call keyed on a field name). `derive` and `clear` are the
|
|
385
|
+
* live instances ADR-3408 §8.6 names.
|
|
386
|
+
*/
|
|
387
|
+
function findUnimplementedPolicies(text, rel) {
|
|
388
|
+
const members = readPolicyUnion(text);
|
|
389
|
+
const strippedText = stripComments(text).join('\n');
|
|
390
|
+
const out = [];
|
|
391
|
+
for (const member of members) {
|
|
392
|
+
const memberRe = new RegExp(`preservation\\s*===\\s*'${escapeRegex(member)}'`);
|
|
393
|
+
if (memberRe.test(strippedText)) continue;
|
|
394
|
+
// `file` and `policy` are sanitized here for the same reason as
|
|
395
|
+
// `findPolicyDispatchDrift` above: both `rel` and a `FieldPreservation`
|
|
396
|
+
// union member are attacker-controlled on a fork PR, exactly like
|
|
397
|
+
// `source`.
|
|
398
|
+
out.push({
|
|
399
|
+
reason: REASON.UNIMPLEMENTED_POLICY,
|
|
400
|
+
axis: 'policy-dispatch',
|
|
401
|
+
file: sanitizeForReport(rel),
|
|
402
|
+
line: 0,
|
|
403
|
+
policy: sanitizeForReport(member),
|
|
404
|
+
source: sanitizeForReport(`FieldPreservation member '${member}' has no executor`),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// AXIS 3 (§8.3(b), closed Phase 2 / #3469): `stateReplaceField(<contentArg>,
|
|
411
|
+
// <fieldArg>, ...)` on a single line, capturing both argument expressions.
|
|
412
|
+
// `contentArg` must be a bare identifier (a call expression or property
|
|
413
|
+
// access as the first argument is not matched — silently out of scope, per
|
|
414
|
+
// this axis's own narrow-limitation note below) so its assignments can be
|
|
415
|
+
// tracked; `fieldArg` is everything up to the next comma, trimmed, so its
|
|
416
|
+
// literal-vs-variable shape can be read off directly.
|
|
417
|
+
const STATE_REPLACE_FIELD_CALL_RE = /\bstateReplaceField\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*([^,()]+),/g;
|
|
418
|
+
|
|
419
|
+
// True when `arg` (already trimmed) is a fixed string/template literal —
|
|
420
|
+
// the safe shape, since every literal field name this codebase actually
|
|
421
|
+
// uses is a Title-Case body label that cannot collide with a lowercase/
|
|
422
|
+
// snake_case YAML frontmatter key.
|
|
423
|
+
function isQuotedLiteralArg(arg) {
|
|
424
|
+
const t = arg.trim();
|
|
425
|
+
return t.startsWith("'") || t.startsWith('"') || t.startsWith('`');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The nearest assignment to `varName` (`varName = <expr>` or
|
|
430
|
+
* `const|let|var varName = <expr>`), scanning `lines` BACKWARD from `index`
|
|
431
|
+
* (inclusive) and stopping at the nearest preceding named-function
|
|
432
|
+
* declaration (mirrors `enclosingFunction`'s own boundary, so the scan
|
|
433
|
+
* cannot walk into an unrelated function above the one containing the
|
|
434
|
+
* call). Returns the assigned expression's trimmed text, or `null` when no
|
|
435
|
+
* such assignment is found before the boundary — meaning `varName` is the
|
|
436
|
+
* enclosing function's own untouched parameter.
|
|
437
|
+
*
|
|
438
|
+
* Deliberately single-hop: this reports whatever the NEAREST assignment's
|
|
439
|
+
* right-hand side literally is, and does not itself follow a further alias
|
|
440
|
+
* (`let body = someOtherVar;` is reported as `"someOtherVar"`, not resolved
|
|
441
|
+
* further). Every real call site in this file assigns its body variable
|
|
442
|
+
* directly from `stripFrontmatter(content)` with no intermediate alias
|
|
443
|
+
* (`updateCore`, `patchCore`, `beginPhaseCore`'s `tryField` helper) — a
|
|
444
|
+
* future call site that introduces one extra hop of aliasing would evade
|
|
445
|
+
* this check. A declared, narrow limitation, not a silent one — mirrors
|
|
446
|
+
* this file's existing precedent (`FIELD_VAR_EQ_LITERAL_RE`'s own
|
|
447
|
+
* documented scope) of accepting a bounded risk in trade for not chasing
|
|
448
|
+
* full dataflow, which is exactly what made the Phase 1 approximation
|
|
449
|
+
* unusable (29 false positives to 1 true positive).
|
|
450
|
+
*/
|
|
451
|
+
function nearestPrecedingAssignment(lines, index, varName) {
|
|
452
|
+
const assignRe = new RegExp(`(?:^|[^.\\w$])(?:const|let|var)?\\s*${escapeRegex(varName)}\\s*=\\s*([^=].*)$`);
|
|
453
|
+
for (let i = index; i >= 0; i--) {
|
|
454
|
+
if (FUNCTION_DECL_LINE_RE.test(lines[i])) return null;
|
|
455
|
+
const m = assignRe.exec(lines[i]);
|
|
456
|
+
if (m) return m[1].trim();
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* AXIS 3: every `stateReplaceField(` call in `EXECUTOR_FILE` whose field-name
|
|
463
|
+
* argument is a VARIABLE (not a quoted literal) — the only shape that can
|
|
464
|
+
* ever rewrite YAML frontmatter, since `stateReplaceField`'s `^field:` line
|
|
465
|
+
* pattern is case-insensitive and matches any line starting with that name,
|
|
466
|
+
* literal or not — AND whose content argument was not assigned from
|
|
467
|
+
* `stripFrontmatter(` at the nearest preceding assignment. A literal
|
|
468
|
+
* field-name argument is never flagged regardless of stripping: every fixed
|
|
469
|
+
* string this file's `stateReplaceField` calls use is a Title-Case body
|
|
470
|
+
* label (`'Phase'`, `'Total Plans in Phase'`, ...) that cannot collide with
|
|
471
|
+
* a lowercase/snake_case frontmatter key by construction, so checking its
|
|
472
|
+
* content argument would only add false positives on the ~20 already-safe
|
|
473
|
+
* `sectionBody`-scoped calls this axis must NOT report (mirrors
|
|
474
|
+
* `updateCore`'s strip-then-replace shape, and `beginPhaseCore`'s
|
|
475
|
+
* `stateReplaceField(body, name, value)`, both legitimately unflagged).
|
|
476
|
+
*/
|
|
477
|
+
function findUnstrippedContentWrites(rel, text) {
|
|
478
|
+
const rawLines = text.split('\n');
|
|
479
|
+
const stripped = stripComments(text);
|
|
480
|
+
const out = [];
|
|
481
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
482
|
+
const line = stripped[i];
|
|
483
|
+
if (!line.trim()) continue;
|
|
484
|
+
STATE_REPLACE_FIELD_CALL_RE.lastIndex = 0;
|
|
485
|
+
let m;
|
|
486
|
+
while ((m = STATE_REPLACE_FIELD_CALL_RE.exec(line)) !== null) {
|
|
487
|
+
const contentArg = m[1];
|
|
488
|
+
const fieldArg = m[2];
|
|
489
|
+
if (isQuotedLiteralArg(fieldArg)) continue;
|
|
490
|
+
const assignment = nearestPrecedingAssignment(stripped, i - 1, contentArg);
|
|
491
|
+
const isStripped = assignment !== null && /^stripFrontmatter\s*\(/.test(assignment);
|
|
492
|
+
if (isStripped) continue;
|
|
493
|
+
// `file`/`source` sanitized for the same fork-PR reason as every other
|
|
494
|
+
// finding in this guard; `contentArg` is captured out of repo source
|
|
495
|
+
// (an identifier name), attacker-controlled on the same basis.
|
|
496
|
+
out.push({
|
|
497
|
+
reason: REASON.UNSTRIPPED_CONTENT_WRITE,
|
|
498
|
+
axis: 'frontmatter-write',
|
|
499
|
+
file: sanitizeForReport(rel),
|
|
500
|
+
line: i + 1,
|
|
501
|
+
field: sanitizeForReport(contentArg),
|
|
502
|
+
source: sanitizeForReport(rawLines[i].trim()),
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// The three write-seam functions, matched only as CALLS (`\(` immediately
|
|
510
|
+
// after, modulo whitespace) — never as bare mentions of the name.
|
|
511
|
+
// `applyPostSyncPreservation` (#3469) is included alongside
|
|
512
|
+
// `writeStateMd`/`syncStateFrontmatter`: after Phase 2, it is ONLY ever
|
|
513
|
+
// legitimately called from inside `syncAndPreserveStateMd` (the seam
|
|
514
|
+
// composition), so any OTHER call to it is either a re-assembly of the pair
|
|
515
|
+
// (Phase 2's Finding 3 shape — a call site invoking both
|
|
516
|
+
// `syncStateFrontmatter` and `applyPostSyncPreservation` itself instead of
|
|
517
|
+
// the composition) or a bypass calling it alone; either way it belongs on
|
|
518
|
+
// this axis.
|
|
519
|
+
const SEAM_CALL_RE = /\b(writeStateMd|syncStateFrontmatter|applyPostSyncPreservation)\s*\(/g;
|
|
520
|
+
// A line that IS one of the three seam functions' own definitions — skipped
|
|
521
|
+
// outright, never counted as a call to itself.
|
|
522
|
+
const SEAM_DEF_LINE_RE = /^\s*(?:export\s+)?(?:async\s+)?function\s+(?:writeStateMd|syncStateFrontmatter|applyPostSyncPreservation)\b/;
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* AXIS 2a: every direct `writeStateMd(`/`syncStateFrontmatter(`/
|
|
526
|
+
* `applyPostSyncPreservation(` call in `text`, outside the three functions'
|
|
527
|
+
* own definitions and (only inside `SEAM_OWNER_FILE`) outside
|
|
528
|
+
* `SEAM_OWNER_EXEMPT_FUNCTIONS`'s own bodies. No `reason` on these
|
|
529
|
+
* findings — `applyRatchet` assigns one, since the same observed call site
|
|
530
|
+
* is a different failure shape depending on whether the baseline already
|
|
531
|
+
* knows about it.
|
|
532
|
+
*/
|
|
533
|
+
function findSeamBypasses(rel, text) {
|
|
534
|
+
const rawLines = text.split('\n');
|
|
535
|
+
const stripped = stripComments(text);
|
|
536
|
+
const out = [];
|
|
537
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
538
|
+
const line = stripped[i];
|
|
539
|
+
if (!line.trim()) continue;
|
|
540
|
+
if (SEAM_DEF_LINE_RE.test(line)) continue;
|
|
541
|
+
SEAM_CALL_RE.lastIndex = 0;
|
|
542
|
+
let m;
|
|
543
|
+
while ((m = SEAM_CALL_RE.exec(line)) !== null) {
|
|
544
|
+
if (rel === SEAM_OWNER_FILE) {
|
|
545
|
+
const fn = enclosingFunction(stripped, i);
|
|
546
|
+
if (fn && SEAM_OWNER_EXEMPT_FUNCTIONS.includes(fn)) continue;
|
|
547
|
+
}
|
|
548
|
+
// `file` is sanitized here, at construction, not just at the human
|
|
549
|
+
// formatter: `rel` is exactly as attacker-controlled as `source` on a
|
|
550
|
+
// fork PR (a tracked filename can legally carry C1 bytes or bidi
|
|
551
|
+
// overrides), and it reaches the committed baseline and `--json`
|
|
552
|
+
// stdout unfiltered otherwise — see `sanitizeForReport`'s own header.
|
|
553
|
+
out.push({
|
|
554
|
+
axis: 'write-seam',
|
|
555
|
+
file: sanitizeForReport(rel),
|
|
556
|
+
line: i + 1,
|
|
557
|
+
symbol: m[1],
|
|
558
|
+
source: sanitizeForReport(rawLines[i].trim()),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Prose in the prompt layer shelling out to a write-side `gsd-tools`
|
|
566
|
+
// subcommand — the SAME write seam, expressed as markdown instructing an
|
|
567
|
+
// agent to run a command, rather than TypeScript calling a function
|
|
568
|
+
// directly (Decision 4(d)'s "the scan surface is declared, and is not just
|
|
569
|
+
// `src/`"). Matched on RAW lines — no comment stripping — because markdown
|
|
570
|
+
// carries no comment syntax this guard should be stripping in the first
|
|
571
|
+
// place. `g`-flagged so multiple candidate occurrences on one line are all
|
|
572
|
+
// checked against backtick spans below, rather than only the first.
|
|
573
|
+
const PROMPT_SEAM_RE = /gsd[-_]?tools[^\n]*\b(state\.patch|state\.planned-phase|state\.sync|phase\.complete)\b/g;
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Every `` `...` `` inline-code span on `line`, as `[start, end)` character
|
|
577
|
+
* ranges (end exclusive). Handles multiple spans on one line correctly by
|
|
578
|
+
* repeated `exec` over a global, non-overlapping backtick-pair pattern —
|
|
579
|
+
* unlike a naive "count backticks before the match" parity check, this does
|
|
580
|
+
* not get confused by a line that mixes code spans with unrelated literal
|
|
581
|
+
* backticks (e.g. an unmatched one in prose).
|
|
582
|
+
*/
|
|
583
|
+
const CODE_SPAN_RE = /`[^`\n]+`/g;
|
|
584
|
+
function codeSpanRanges(line) {
|
|
585
|
+
const ranges = [];
|
|
586
|
+
CODE_SPAN_RE.lastIndex = 0;
|
|
587
|
+
let m;
|
|
588
|
+
while ((m = CODE_SPAN_RE.exec(line)) !== null) {
|
|
589
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
590
|
+
}
|
|
591
|
+
return ranges;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* True when character offset `index` of `line` falls inside one of `line`'s
|
|
596
|
+
* inline-code spans.
|
|
597
|
+
*/
|
|
598
|
+
function isInsideCodeSpan(line, index) {
|
|
599
|
+
return codeSpanRanges(line).some(([start, end]) => index >= start && index < end);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* AXIS 2b: every prompt-layer line instructing an agent to shell out to a
|
|
604
|
+
* write-side `gsd-tools` subcommand. Same finding shape as
|
|
605
|
+
* `findSeamBypasses` (no `reason` — the ratchet assigns it), with a fixed
|
|
606
|
+
* `symbol` since there is no single function name to report for prose.
|
|
607
|
+
*
|
|
608
|
+
* A candidate occurrence enclosed in backticks is a MENTION, not an
|
|
609
|
+
* invocation, and is deliberately not reported — CONTRIBUTING.md's "Every
|
|
610
|
+
* `commit` invocation in shipped content must declare `--files`" section
|
|
611
|
+
* states the repo's settled convention verbatim: "Write the command
|
|
612
|
+
* reference in backticks — the repo's own convention — and it is correctly
|
|
613
|
+
* read as a mention." ADR-3180 Amendment 3 records the cost of getting this
|
|
614
|
+
* wrong: the first `lint-phase-enumeration-drift.cjs` flagged JSDoc that
|
|
615
|
+
* merely documented the canonical owner as drift, which trains readers to
|
|
616
|
+
* reflexively exempt documentation instead of trusting the guard — the
|
|
617
|
+
* opposite of Decision 4(a)'s intent. All 5 of this guard's original
|
|
618
|
+
* prompt-layer baseline entries were exactly this false positive.
|
|
619
|
+
*/
|
|
620
|
+
function findPromptSeamUses(rel, text) {
|
|
621
|
+
const lines = text.split('\n');
|
|
622
|
+
const out = [];
|
|
623
|
+
for (let i = 0; i < lines.length; i++) {
|
|
624
|
+
const line = lines[i];
|
|
625
|
+
PROMPT_SEAM_RE.lastIndex = 0;
|
|
626
|
+
let m;
|
|
627
|
+
while ((m = PROMPT_SEAM_RE.exec(line)) !== null) {
|
|
628
|
+
if (isInsideCodeSpan(line, m.index)) continue;
|
|
629
|
+
// `file` is sanitized here for the same reason as `findSeamBypasses`
|
|
630
|
+
// above: `rel` is attacker-controlled on a fork PR, exactly like
|
|
631
|
+
// `source`.
|
|
632
|
+
out.push({
|
|
633
|
+
axis: 'write-seam',
|
|
634
|
+
file: sanitizeForReport(rel),
|
|
635
|
+
line: i + 1,
|
|
636
|
+
symbol: 'prompt-layer-state-write',
|
|
637
|
+
source: sanitizeForReport(line.trim()),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Ratchet key for one write-seam finding — `(file, TRIMMED source text)`,
|
|
646
|
+
* NEVER a line number, which churns on any unrelated edit to the same file
|
|
647
|
+
* (mirrors `qa-smell-ratchet.cjs`'s own key shape). `v.source` is already
|
|
648
|
+
* the trimmed, sanitized line text by the time it reaches this function.
|
|
649
|
+
*/
|
|
650
|
+
function ratchetKey(v) {
|
|
651
|
+
return `${v.file} ${v.source}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Read `BASELINE_PATH`. Returns `{ entries: [] }` when the file is ABSENT
|
|
656
|
+
* (`ENOENT` — first run, or a fully-shrunk Phase 4 baseline that deleted the
|
|
657
|
+
* file — ADR-3408 §8.3's roster foresees exactly this end state); returns
|
|
658
|
+
* `{ entries: null, code }` when the file is present but could not be read
|
|
659
|
+
* OR could not be parsed/shaped (missing/malformed `entries` array) — `code`
|
|
660
|
+
* carries the underlying `fs` error code (e.g. `'EACCES'`) when the failure
|
|
661
|
+
* happened at the read step, `null` when it happened at the parse/shape
|
|
662
|
+
* step, so the caller can fail closed rather than silently ratcheting
|
|
663
|
+
* against nothing. Returns the parsed object when the read+parse succeed.
|
|
664
|
+
*
|
|
665
|
+
* Absent-vs-unreadable is deliberately NOT collapsed into one arm. This
|
|
666
|
+
* guard exists to catch write paths whose failure and success are
|
|
667
|
+
* output-identical (ADR-3180 / ADR-3408, "The failure mode that hides all
|
|
668
|
+
* of it") — a `catch { return { entries: [] } }` around the read would
|
|
669
|
+
* reproduce exactly that shape in the tool built to detect it: an
|
|
670
|
+
* unreadable baseline (EACCES, EISDIR, EIO, ...) would be silently
|
|
671
|
+
* indistinguishable from a legitimate absent one. Do not simplify this back
|
|
672
|
+
* into a single catch arm.
|
|
673
|
+
*/
|
|
674
|
+
function loadBaseline() {
|
|
675
|
+
let raw;
|
|
676
|
+
try {
|
|
677
|
+
raw = fs.readFileSync(BASELINE_PATH, 'utf8');
|
|
678
|
+
} catch (err) {
|
|
679
|
+
if (err && err.code === 'ENOENT') return { entries: [] };
|
|
680
|
+
return { entries: null, code: err && err.code ? err.code : 'UNKNOWN' };
|
|
681
|
+
}
|
|
682
|
+
let doc;
|
|
683
|
+
try {
|
|
684
|
+
doc = JSON.parse(raw);
|
|
685
|
+
} catch {
|
|
686
|
+
return { entries: null, code: null };
|
|
687
|
+
}
|
|
688
|
+
if (!doc || typeof doc !== 'object' || !Array.isArray(doc.entries)) return { entries: null, code: null };
|
|
689
|
+
return doc;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The ratchet — mirrors `scripts/qa-smell-ratchet.cjs`'s four invariants,
|
|
694
|
+
* applied to write-seam bypass COUNTS instead of QA-smell fingerprints:
|
|
695
|
+
*
|
|
696
|
+
* 1. An observed key absent from the baseline is a brand-new,
|
|
697
|
+
* unacknowledged bypass — `SEAM_BYPASS_UNRECORDED`.
|
|
698
|
+
* 2. An observed count greater than the acknowledged count is a NEW copy
|
|
699
|
+
* landing beside an already-acknowledged one — `SEAM_BYPASS_COUNT_GREW`.
|
|
700
|
+
* 3. An observed count less than the acknowledged count is a PARTIAL
|
|
701
|
+
* migration — some but not all call sites at this exact key were
|
|
702
|
+
* removed, and the baseline still claims the old, larger number —
|
|
703
|
+
* `SEAM_BYPASS_COUNT_SHRANK`.
|
|
704
|
+
* 4. A baseline key with zero current observations is a STALE
|
|
705
|
+
* acknowledgment: an entry may never outlive what it describes, and
|
|
706
|
+
* the baseline may only shrink (via `--baseline`, regenerated) —
|
|
707
|
+
* `BASELINE_ENTRY_STALE`.
|
|
708
|
+
*
|
|
709
|
+
* The occurrence COUNT (not just key presence) is what makes a partial
|
|
710
|
+
* migration visible at all: two byte-identical call sites in one file are
|
|
711
|
+
* otherwise a single indistinguishable key, so removing one of the two
|
|
712
|
+
* would silently vanish from a presence-only check while the acknowledgment
|
|
713
|
+
* still describes two.
|
|
714
|
+
*
|
|
715
|
+
* Every returned finding carries both `observed` and `acknowledged` counts.
|
|
716
|
+
*/
|
|
717
|
+
function applyRatchet(observed, baseline) {
|
|
718
|
+
const observedByKey = new Map();
|
|
719
|
+
for (const finding of observed) {
|
|
720
|
+
const key = ratchetKey(finding);
|
|
721
|
+
let group = observedByKey.get(key);
|
|
722
|
+
if (!group) {
|
|
723
|
+
group = { count: 0, sample: finding };
|
|
724
|
+
observedByKey.set(key, group);
|
|
725
|
+
}
|
|
726
|
+
group.count += 1;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const baselineByKey = new Map();
|
|
730
|
+
for (const entry of baseline.entries) {
|
|
731
|
+
baselineByKey.set(`${entry.file} ${entry.source}`, entry);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const out = [];
|
|
735
|
+
for (const [key, group] of observedByKey) {
|
|
736
|
+
const entry = baselineByKey.get(key);
|
|
737
|
+
const acknowledged = entry && typeof entry.count === 'number' ? entry.count : 0;
|
|
738
|
+
let reason = null;
|
|
739
|
+
if (!entry) {
|
|
740
|
+
reason = REASON.SEAM_BYPASS_UNRECORDED;
|
|
741
|
+
} else if (group.count > acknowledged) {
|
|
742
|
+
reason = REASON.SEAM_BYPASS_COUNT_GREW;
|
|
743
|
+
} else if (group.count < acknowledged) {
|
|
744
|
+
reason = REASON.SEAM_BYPASS_COUNT_SHRANK;
|
|
745
|
+
}
|
|
746
|
+
if (!reason) continue;
|
|
747
|
+
out.push({
|
|
748
|
+
reason,
|
|
749
|
+
axis: 'write-seam',
|
|
750
|
+
file: group.sample.file,
|
|
751
|
+
line: group.sample.line,
|
|
752
|
+
symbol: group.sample.symbol,
|
|
753
|
+
source: group.sample.source,
|
|
754
|
+
observed: group.count,
|
|
755
|
+
acknowledged,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
for (const [key, entry] of baselineByKey) {
|
|
760
|
+
if (observedByKey.has(key)) continue;
|
|
761
|
+
out.push({
|
|
762
|
+
reason: REASON.BASELINE_ENTRY_STALE,
|
|
763
|
+
axis: 'write-seam',
|
|
764
|
+
file: entry.file,
|
|
765
|
+
line: 0,
|
|
766
|
+
symbol: entry.symbol,
|
|
767
|
+
source: entry.source,
|
|
768
|
+
observed: 0,
|
|
769
|
+
acknowledged: typeof entry.count === 'number' ? entry.count : 0,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return out;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Run both scan passes (the `src/` tree for Axis 1 + Axis 2a + Axis 3, the
|
|
778
|
+
* prompt layer for Axis 2b) and split the combined findings by `axis` into
|
|
779
|
+
* `{ policyFindings, seamFindings }`. `policyFindings` are already terminal
|
|
780
|
+
* (each carries its own `reason`) — this bucket is every axis EXCEPT
|
|
781
|
+
* `write-seam` (Axis 2), which alone is ratcheted; `seamFindings` are raw
|
|
782
|
+
* write-seam observations — `applyRatchet` is what turns them into (or
|
|
783
|
+
* clears them of) a finding.
|
|
784
|
+
*/
|
|
785
|
+
function collect() {
|
|
786
|
+
const srcFindings = scanTree({
|
|
787
|
+
root: REPO_ROOT,
|
|
788
|
+
scanDirs: SRC_DIRS,
|
|
789
|
+
scanExt: SRC_EXT,
|
|
790
|
+
onFile(rel, text) {
|
|
791
|
+
const relPosix = toPosixRel(rel);
|
|
792
|
+
const found = [];
|
|
793
|
+
if (relPosix === EXECUTOR_FILE) {
|
|
794
|
+
found.push(...findPolicyDispatchDrift(relPosix, text));
|
|
795
|
+
found.push(...findUnimplementedPolicies(text, relPosix));
|
|
796
|
+
found.push(...findUnstrippedContentWrites(relPosix, text));
|
|
797
|
+
}
|
|
798
|
+
found.push(...findSeamBypasses(relPosix, text));
|
|
799
|
+
return found;
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
const promptFindings = scanTree({
|
|
804
|
+
root: REPO_ROOT,
|
|
805
|
+
scanDirs: PROMPT_DIRS,
|
|
806
|
+
scanExt: PROMPT_EXT,
|
|
807
|
+
onFile(rel, text) {
|
|
808
|
+
return findPromptSeamUses(toPosixRel(rel), text);
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
const all = [...srcFindings, ...promptFindings];
|
|
813
|
+
return {
|
|
814
|
+
policyFindings: all.filter((f) => f.axis !== 'write-seam'),
|
|
815
|
+
seamFindings: all.filter((f) => f.axis === 'write-seam'),
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Group `seamFindings` by `ratchetKey` into the baseline entry shape
|
|
821
|
+
* (`{file, source, symbol, count, owner}`), sorted by `file+source`.
|
|
822
|
+
*
|
|
823
|
+
* `owner` is NEVER invented by this mechanical regeneration — the guard can
|
|
824
|
+
* observe WHERE a bypass is and HOW MANY there are, but not which issue owns
|
|
825
|
+
* removing it; inventing one would violate the same "never guess" discipline
|
|
826
|
+
* `qa-smell-ratchet.cjs` applies to its own `issue` field (its `--update`
|
|
827
|
+
* never invents an issue number either). ADR-3408 §8.3 requires every
|
|
828
|
+
* shipped entry to carry "a named ratchet entry carrying the issue that owns
|
|
829
|
+
* its removal, never an unrecorded pass" — that owner is HUMAN-CURATED and
|
|
830
|
+
* must be recorded before the entry ships.
|
|
831
|
+
*
|
|
832
|
+
* Because `--baseline` overwrites `BASELINE_PATH` wholesale, a naive
|
|
833
|
+
* mechanical regeneration would silently re-null every curated `owner` on
|
|
834
|
+
* each run. To avoid that, `existingEntries` (the baseline as it stood
|
|
835
|
+
* BEFORE this regeneration, i.e. `loadBaseline().entries`) is optional and,
|
|
836
|
+
* when supplied, its `owner` values are merged forward onto matching new
|
|
837
|
+
* entries keyed on `(file, source)` — the same key `ratchetKey` /
|
|
838
|
+
* `applyRatchet` use to identify a bypass. A key with no prior entry (a
|
|
839
|
+
* brand-new bypass) still gets `owner: null`, exactly as before; only
|
|
840
|
+
* already-curated owners survive the regeneration. Re-running `--baseline`
|
|
841
|
+
* twice in a row is therefore idempotent with respect to `owner`.
|
|
842
|
+
*/
|
|
843
|
+
function buildBaselineEntries(seamFindings, existingEntries) {
|
|
844
|
+
const priorOwnerByKey = new Map();
|
|
845
|
+
if (Array.isArray(existingEntries)) {
|
|
846
|
+
for (const entry of existingEntries) {
|
|
847
|
+
priorOwnerByKey.set(ratchetKey(entry), entry.owner);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const groups = new Map();
|
|
852
|
+
for (const finding of seamFindings) {
|
|
853
|
+
const key = ratchetKey(finding);
|
|
854
|
+
let group = groups.get(key);
|
|
855
|
+
if (!group) {
|
|
856
|
+
group = {
|
|
857
|
+
file: finding.file,
|
|
858
|
+
source: finding.source,
|
|
859
|
+
symbol: finding.symbol,
|
|
860
|
+
count: 0,
|
|
861
|
+
owner: priorOwnerByKey.has(key) ? priorOwnerByKey.get(key) : null,
|
|
862
|
+
};
|
|
863
|
+
groups.set(key, group);
|
|
864
|
+
}
|
|
865
|
+
group.count += 1;
|
|
866
|
+
}
|
|
867
|
+
return [...groups.values()].sort((a, b) => ratchetKey(a).localeCompare(ratchetKey(b)));
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const BASELINE_COMMENT =
|
|
871
|
+
'ADR-3408 Decision 5 write-seam ratchet baseline (issue #3468, Phase 1; Phase 2 / #3469 lands the ' +
|
|
872
|
+
'single write seam and Amendment 2). Every entry here is a `writeStateMd(`/`syncStateFrontmatter(`/' +
|
|
873
|
+
'`applyPostSyncPreservation(` bypass this guard found by a whole-repo scan (Decision 4(a)) — it is ' +
|
|
874
|
+
'ACKNOWLEDGED, not endorsed: acknowledgment is in writing (this file), with the issue owning its ' +
|
|
875
|
+
'removal recorded in the entry\'s "owner" field. This baseline is SHRINK-ONLY — an entry that stops ' +
|
|
876
|
+
'firing goes STALE and fails the plain run until `--baseline` is re-run to drop it (ADR-3180 ' +
|
|
877
|
+
'Decision 4(e)\'s "the baseline may only shrink", adopted verbatim by ADR-3408). Phase 2 (#3469) ' +
|
|
878
|
+
'removed the `cmdPhaseComplete` (`src/phase.cts`) and `cmdMilestoneComplete` (`src/milestone.cts`) ' +
|
|
879
|
+
'entries by routing both through the single write-seam composition (`syncAndPreserveStateMd`, ' +
|
|
880
|
+
'`src/state.cts`). ADR-3408 Amendment 2: "0 bypasses" was never this baseline\'s target — TWO ' +
|
|
881
|
+
'entries are SANCTIONED PERMANENT, not debt, and Phase 4 (#3471) does NOT drive this file to empty: ' +
|
|
882
|
+
'`cmdStateSync` (`src/state.cts`) exists precisely to let the body win (#905 — `state sync` ' +
|
|
883
|
+
're-derives frontmatter FROM the body), so routing it through preservation would invert the command ' +
|
|
884
|
+
'rather than fix a bug; `REGENERATE_STATE` (`src/health-diagnostic.cts`) is `/gsd-health --repair`\'s ' +
|
|
885
|
+
'factory reset, which rebuilds STATE.md from scratch, so preservation would restore exactly the ' +
|
|
886
|
+
'values it was invoked to discard. Neither entry may be "consolidated" away — a guard reporting them ' +
|
|
887
|
+
'is reporting correctly, and a change that removes one is a regression, not progress.';
|
|
888
|
+
|
|
889
|
+
function writeBaseline(seamFindings) {
|
|
890
|
+
const priorBaseline = loadBaseline();
|
|
891
|
+
const entries = buildBaselineEntries(
|
|
892
|
+
seamFindings,
|
|
893
|
+
Array.isArray(priorBaseline.entries) ? priorBaseline.entries : null,
|
|
894
|
+
);
|
|
895
|
+
const doc = { _comment: BASELINE_COMMENT, entries };
|
|
896
|
+
fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
897
|
+
return entries;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const GOODHART_NOTE =
|
|
901
|
+
'Goodhart note (ADR-3408 Decision 5): this "0 write-path bypasses" is a LAGGING metric — report ' +
|
|
902
|
+
'it only alongside the behavioral identity test\'s result (asserted at the consumer\'s output), ' +
|
|
903
|
+
'never alone.';
|
|
904
|
+
|
|
905
|
+
function printFindings(findings) {
|
|
906
|
+
for (const f of findings) {
|
|
907
|
+
process.stderr.write(`[${f.reason}] ${sanitizeForReport(f.file)}:${f.line}\n`);
|
|
908
|
+
process.stderr.write(` ${sanitizeForReport(f.source)}\n`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* `argv`: `--baseline` regenerates `BASELINE_PATH` from a fresh scan and
|
|
914
|
+
* exits 0; `--json` (check mode only) prints the machine-readable finding
|
|
915
|
+
* set instead of the human-readable report. Exit codes: 0 clean, 1 drift
|
|
916
|
+
* (policy-dispatch violation, ratchet violation, or an unreadable
|
|
917
|
+
* baseline), 2 usage error.
|
|
918
|
+
*/
|
|
919
|
+
function main(argv) {
|
|
920
|
+
const args = argv || [];
|
|
921
|
+
const recognized = new Set(['--baseline', '--json']);
|
|
922
|
+
const unknown = args.filter((a) => !recognized.has(a));
|
|
923
|
+
if (unknown.length > 0) {
|
|
924
|
+
process.stderr.write(
|
|
925
|
+
`lint-state-write-path-drift: unrecognized argument(s): ${unknown.map((a) => sanitizeForReport(a)).join(', ')} ` +
|
|
926
|
+
'(expected --baseline and/or --json)\n',
|
|
927
|
+
);
|
|
928
|
+
process.exitCode = 2;
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (args.includes('--baseline')) {
|
|
933
|
+
const { seamFindings } = collect();
|
|
934
|
+
const entries = writeBaseline(seamFindings);
|
|
935
|
+
process.stdout.write(`lint-state-write-path-drift: wrote ${entries.length} entries to ${BASELINE_PATH}\n`);
|
|
936
|
+
process.exitCode = 0;
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const wantJson = args.includes('--json');
|
|
941
|
+
const baseline = loadBaseline();
|
|
942
|
+
|
|
943
|
+
if (baseline.entries === null) {
|
|
944
|
+
const finding = {
|
|
945
|
+
reason: REASON.BASELINE_UNREADABLE,
|
|
946
|
+
axis: 'write-seam',
|
|
947
|
+
file: path.relative(REPO_ROOT, BASELINE_PATH),
|
|
948
|
+
line: 0,
|
|
949
|
+
symbol: null,
|
|
950
|
+
code: baseline.code,
|
|
951
|
+
source: sanitizeForReport(
|
|
952
|
+
baseline.code
|
|
953
|
+
? `${BASELINE_PATH} is present but could not be read (${baseline.code}) — run \`node ${__filename} --baseline\` to regenerate it`
|
|
954
|
+
: `${BASELINE_PATH} is present but unparseable — run \`node ${__filename} --baseline\` to regenerate it`,
|
|
955
|
+
),
|
|
956
|
+
};
|
|
957
|
+
if (wantJson) {
|
|
958
|
+
process.stdout.write(
|
|
959
|
+
`${JSON.stringify(
|
|
960
|
+
{
|
|
961
|
+
ok: false,
|
|
962
|
+
findings: [finding],
|
|
963
|
+
summary: { policyDispatchViolations: 0, seamBypassesObserved: 0, seamBypassesAcknowledged: 0, ratchetViolations: 0 },
|
|
964
|
+
},
|
|
965
|
+
null,
|
|
966
|
+
2,
|
|
967
|
+
)}\n`,
|
|
968
|
+
);
|
|
969
|
+
} else {
|
|
970
|
+
printFindings([finding]);
|
|
971
|
+
}
|
|
972
|
+
process.exitCode = 1;
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const { policyFindings, seamFindings } = collect();
|
|
977
|
+
const ratchetFindings = applyRatchet(seamFindings, baseline);
|
|
978
|
+
const findings = [...policyFindings, ...ratchetFindings];
|
|
979
|
+
const acknowledgedTotal = baseline.entries.reduce(
|
|
980
|
+
(sum, e) => sum + (typeof e.count === 'number' ? e.count : 0),
|
|
981
|
+
0,
|
|
982
|
+
);
|
|
983
|
+
const summary = {
|
|
984
|
+
policyDispatchViolations: policyFindings.length,
|
|
985
|
+
seamBypassesObserved: seamFindings.length,
|
|
986
|
+
seamBypassesAcknowledged: acknowledgedTotal,
|
|
987
|
+
ratchetViolations: ratchetFindings.length,
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
if (wantJson) {
|
|
991
|
+
process.stdout.write(`${JSON.stringify({ ok: findings.length === 0, findings, summary }, null, 2)}\n`);
|
|
992
|
+
process.exitCode = findings.length === 0 ? 0 : 1;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (findings.length === 0) {
|
|
997
|
+
process.stdout.write(
|
|
998
|
+
'ok state-write-path-drift: no policy-dispatch violations, no unrecorded/grown/shrunk/stale ' +
|
|
999
|
+
'write-seam entries against the acknowledged baseline\n',
|
|
1000
|
+
);
|
|
1001
|
+
process.stdout.write(`${GOODHART_NOTE}\n`);
|
|
1002
|
+
process.exitCode = 0;
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
process.stderr.write(
|
|
1007
|
+
'state-write-path-drift: policy-dispatch and/or write-seam divergence found (ADR-3408 §8.1/§8.3). ' +
|
|
1008
|
+
'See docs/adr/3408-state-write-path-preservation.md for the contract:\n',
|
|
1009
|
+
);
|
|
1010
|
+
printFindings(findings);
|
|
1011
|
+
process.exitCode = 1;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (require.main === module) main(process.argv.slice(2));
|
|
1015
|
+
|
|
1016
|
+
module.exports = {
|
|
1017
|
+
REASON,
|
|
1018
|
+
REPO_ROOT,
|
|
1019
|
+
BASELINE_PATH,
|
|
1020
|
+
SRC_DIRS,
|
|
1021
|
+
SRC_EXT,
|
|
1022
|
+
PROMPT_DIRS,
|
|
1023
|
+
PROMPT_EXT,
|
|
1024
|
+
EXECUTOR_FILE,
|
|
1025
|
+
SEAM_OWNER_FILE,
|
|
1026
|
+
SEAM_OWNER_EXEMPT_FUNCTIONS,
|
|
1027
|
+
toPosixRel,
|
|
1028
|
+
stripComments,
|
|
1029
|
+
enclosingFunction,
|
|
1030
|
+
readPolicyUnion,
|
|
1031
|
+
findPolicyDispatchDrift,
|
|
1032
|
+
findUnimplementedPolicies,
|
|
1033
|
+
findUnstrippedContentWrites,
|
|
1034
|
+
isQuotedLiteralArg,
|
|
1035
|
+
nearestPrecedingAssignment,
|
|
1036
|
+
findSeamBypasses,
|
|
1037
|
+
findPromptSeamUses,
|
|
1038
|
+
isInsideCodeSpan,
|
|
1039
|
+
ratchetKey,
|
|
1040
|
+
loadBaseline,
|
|
1041
|
+
applyRatchet,
|
|
1042
|
+
collect,
|
|
1043
|
+
buildBaselineEntries,
|
|
1044
|
+
main,
|
|
1045
|
+
};
|