@opengsd/gsd-core 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +12 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +22 -14
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +5 -3
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +23 -2
- package/agents/gsd-plan-checker.md +50 -53
- package/agents/gsd-planner.md +50 -50
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +2 -2
- package/agents/gsd-roadmapper.md +15 -11
- package/agents/gsd-ui-checker.md +63 -4
- package/agents/gsd-ui-researcher.md +41 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +13 -4
- package/bin/install.js +1448 -1103
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +9 -5
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1035 -138
- package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
- package/gsd-core/bin/lib/api-coverage.cjs +33 -14
- package/gsd-core/bin/lib/artifacts.cjs +5 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +1026 -268
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- 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-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +845 -130
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-state.cjs +18 -3
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +700 -40
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +216 -42
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +1172 -108
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
- package/gsd-core/bin/lib/config-loader.cjs +187 -23
- package/gsd-core/bin/lib/config.cjs +102 -3
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +208 -33
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/edge-probe.cjs +9 -1
- package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
- package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +899 -229
- package/gsd-core/bin/lib/gap-checker.cjs +95 -10
- package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- 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 +149 -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 +268 -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 +187 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
- package/gsd-core/bin/lib/host-integration.cjs +39 -6
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +118 -21
- package/gsd-core/bin/lib/init.cjs +439 -168
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +811 -259
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
- package/gsd-core/bin/lib/install-profiles.cjs +212 -61
- 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-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +170 -15
- package/gsd-core/bin/lib/learnings.cjs +85 -14
- package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +183 -22
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +842 -73
- package/gsd-core/bin/lib/model-catalog.cjs +232 -16
- package/gsd-core/bin/lib/model-resolver.cjs +193 -68
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
- package/gsd-core/bin/lib/phase-id.cjs +514 -40
- package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
- package/gsd-core/bin/lib/phase-locator.cjs +262 -34
- package/gsd-core/bin/lib/phase.cjs +1038 -214
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-document.cjs +263 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +98 -3
- package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
- package/gsd-core/bin/lib/probe-core.cjs +5 -2
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
- package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
- package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
- package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +442 -96
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
- package/gsd-core/bin/lib/smart-entry.cjs +133 -23
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +52 -19
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +338 -8
- package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
- package/gsd-core/bin/lib/state-transition.cjs +846 -176
- package/gsd-core/bin/lib/state.cjs +2589 -369
- package/gsd-core/bin/lib/surface.cjs +33 -11
- package/gsd-core/bin/lib/task-command-router.cjs +111 -1
- package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
- package/gsd-core/bin/lib/teams-status.cjs +4 -1
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
- package/gsd-core/bin/lib/uat.cjs +1761 -167
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
- package/gsd-core/bin/lib/unusable-input.cjs +37 -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-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +75 -0
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -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 +272 -9
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify.cjs +453 -918
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
- package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
- package/gsd-core/bin/shared/exit-codes.json +8 -0
- package/gsd-core/bin/shared/exit-codes.sh +20 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -1
- package/gsd-core/references/agent-contracts.md +44 -26
- package/gsd-core/references/api-coverage.md +24 -2
- package/gsd-core/references/autonomous-smart-discuss.md +3 -3
- package/gsd-core/references/checkpoints.md +39 -21
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/edge-probe.md +8 -0
- package/gsd-core/references/execute-mvp-tdd.md +4 -6
- package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
- 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 +17 -11
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-integration.md +5 -5
- package/gsd-core/references/git-planning-commit.md +5 -4
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +61 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/nyquist-compliance.md +74 -0
- package/gsd-core/references/offer-next.md +3 -5
- package/gsd-core/references/phase-argument-parsing.md +3 -3
- package/gsd-core/references/planner-failing-direction.md +53 -0
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-human-verify-mode.md +15 -1
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planner-revision.md +1 -1
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +44 -13
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/tdd.md +1 -3
- package/gsd-core/references/ui-brand.md +65 -21
- package/gsd-core/references/ui-consideration-probe.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +5 -5
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -17
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/SECURITY.md +3 -3
- package/gsd-core/templates/UI-SPEC.md +25 -3
- package/gsd-core/templates/VALIDATION.md +3 -3
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +5 -4
- package/gsd-core/templates/state.md +11 -4
- package/gsd-core/templates/verification-report.md +9 -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 +3 -3
- package/gsd-core/workflows/add-tests.md +3 -8
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +13 -20
- package/gsd-core/workflows/audit-fix.md +12 -3
- package/gsd-core/workflows/audit-milestone.md +9 -9
- package/gsd-core/workflows/audit-uat.md +17 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +11 -27
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +64 -5
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
- package/gsd-core/workflows/code-review-fix.md +38 -11
- package/gsd-core/workflows/code-review.md +159 -52
- package/gsd-core/workflows/complete-milestone.md +151 -23
- package/gsd-core/workflows/debug.md +12 -8
- package/gsd-core/workflows/diagnose-issues.md +47 -15
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +3 -6
- package/gsd-core/workflows/docs-update.md +5 -4
- package/gsd-core/workflows/edit-phase.md +27 -2
- package/gsd-core/workflows/eval-review.md +7 -14
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase.md +72 -100
- package/gsd-core/workflows/execute-plan.md +52 -15
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +5 -5
- package/gsd-core/workflows/health.md +76 -10
- package/gsd-core/workflows/import.md +18 -15
- package/gsd-core/workflows/inbox.md +4 -5
- package/gsd-core/workflows/ingest-docs.md +49 -16
- package/gsd-core/workflows/insert-phase.md +5 -5
- package/gsd-core/workflows/list-seeds.md +5 -3
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +12 -23
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +8 -5
- package/gsd-core/workflows/new-milestone.md +22 -29
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +26 -40
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +14 -2
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
- package/gsd-core/workflows/plan-phase.md +162 -59
- package/gsd-core/workflows/plan-review-convergence.md +96 -11
- package/gsd-core/workflows/plant-seed.md +2 -2
- package/gsd-core/workflows/pr-branch.md +187 -51
- package/gsd-core/workflows/profile-user.md +16 -14
- package/gsd-core/workflows/progress.md +61 -18
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
- package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
- package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick.md +55 -44
- package/gsd-core/workflows/remove-phase.md +4 -4
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +8 -12
- package/gsd-core/workflows/review.md +219 -20
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings-advanced.md +7 -9
- package/gsd-core/workflows/settings-integrations.md +64 -31
- package/gsd-core/workflows/settings.md +69 -7
- package/gsd-core/workflows/ship.md +116 -50
- package/gsd-core/workflows/sketch-wrap-up.md +11 -17
- package/gsd-core/workflows/sketch.md +12 -18
- package/gsd-core/workflows/smart-entry.md +3 -5
- package/gsd-core/workflows/spec-phase.md +53 -13
- package/gsd-core/workflows/spike-wrap-up.md +7 -11
- package/gsd-core/workflows/spike.md +20 -31
- package/gsd-core/workflows/stats.md +2 -2
- package/gsd-core/workflows/sync-skills.md +64 -9
- package/gsd-core/workflows/thread.md +11 -7
- package/gsd-core/workflows/transition.md +49 -14
- package/gsd-core/workflows/ui-phase.md +15 -21
- package/gsd-core/workflows/ui-review.md +8 -12
- package/gsd-core/workflows/ultraplan-phase.md +5 -13
- package/gsd-core/workflows/undo.md +8 -16
- package/gsd-core/workflows/update.md +7 -11
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +66 -25
- package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +19 -10
- package/hooks/dist/gsd-cursor-post-tool.js +3 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
- package/hooks/dist/gsd-cursor-session-start.js +2 -1
- package/hooks/dist/gsd-cursor-stop.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
- package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
- package/hooks/dist/gsd-graphify-update.sh +22 -18
- package/hooks/dist/gsd-node-runner.sh +76 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +37 -27
- package/hooks/dist/gsd-read-guard.js +16 -7
- package/hooks/dist/gsd-read-injection-scanner.js +55 -32
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +231 -24
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-validate-commit.sh +80 -6
- package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
- package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
- package/hooks/dist/gsd-workflow-guard.js +162 -46
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- 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 +9 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-agent-isolation-guard.js +158 -30
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +19 -10
- package/hooks/gsd-cursor-post-tool.js +3 -1
- package/hooks/gsd-cursor-pre-tool.js +2 -3
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +83 -3
- package/hooks/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/gsd-ensure-canonical-path.js +2 -1
- package/hooks/gsd-graphify-update.sh +22 -18
- package/hooks/gsd-node-runner.sh +76 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +37 -27
- package/hooks/gsd-read-guard.js +16 -7
- package/hooks/gsd-read-injection-scanner.js +55 -32
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +231 -24
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-validate-commit.sh +80 -6
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +162 -46
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +28 -11
- package/pi/gsd.cjs +19 -5
- package/scripts/base64-scan.sh +74 -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 +5 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/check-glossary-refs.cjs +77 -15
- package/scripts/check-mutation-score-ratchet.cjs +156 -0
- package/scripts/ci-check-job-near-cap.cjs +49 -0
- package/scripts/ci-pr-mergeability.cjs +262 -0
- package/scripts/ci-test-scope.cjs +64 -14
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/docs-guard-registry.cjs +396 -0
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +11 -21
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-exit-code-docs.cjs +318 -0
- package/scripts/gen-exit-code-registry.cjs +891 -0
- package/scripts/gen-features.cjs +836 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +138 -25
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-scripts-cli-exit.cjs +185 -0
- package/scripts/gen-state-md-docs.cjs +727 -0
- package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +308 -0
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- 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-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-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +492 -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 +471 -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 +488 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +950 -0
- package/scripts/lint-test-file-count.allowlist.json +137 -8
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +830 -0
- package/scripts/lint-vendored-deps.cjs +297 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +100 -14
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +24 -7
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +9 -5
- 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/bin/lib/ui-safety-gate.cjs +0 -107
- 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 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generates `docs/FEATURES.md` — BOTH its table of contents and every feature
|
|
6
|
+
* section body — from per-feature fragments under `docs/features/`.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS (#3840). `docs/FEATURES.md` was hand-maintained, and every
|
|
9
|
+
* feature PR had to write into TWO shared mutable cells in it: the `### N.`
|
|
10
|
+
* heading (whose integer was hand-allocated at authoring time, so concurrent
|
|
11
|
+
* PRs all picked the same next number) and the hand-maintained table of
|
|
12
|
+
* contents (which collides even between PRs that picked DIFFERENT numbers).
|
|
13
|
+
* #3831 was renumbered 165 -> 166 -> 167 -> 168 across successive rebases, the
|
|
14
|
+
* last collision landing mid-verification; because every rebase invalidates the
|
|
15
|
+
* sha-keyed pass marker, each collision also cost a full remote matrix run.
|
|
16
|
+
*
|
|
17
|
+
* The fix is the pattern this repo already uses twice for exactly this problem
|
|
18
|
+
* (`.changeset/` for CHANGELOG.md, `tests/emitted-drift-acks/` for #2914): one
|
|
19
|
+
* file per contribution, consolidated by a generator. A contributor adds ONE
|
|
20
|
+
* new file under `docs/features/` and touches no shared file, so there is
|
|
21
|
+
* nothing to collide on. `docs/FEATURES.md` itself becomes a DERIVED artifact:
|
|
22
|
+
* when two branches both regenerate it the conflict is resolved by re-running
|
|
23
|
+
* `--write`, not by hand-renumbering and re-editing a TOC.
|
|
24
|
+
*
|
|
25
|
+
* NUMBER ALLOCATION. `id` is declared in the fragment's own frontmatter and is
|
|
26
|
+
* FROZEN once merged, so inbound `#N-slug` anchors from other docs keep
|
|
27
|
+
* resolving. It does NOT have to be contiguous or maximal — the corpus already
|
|
28
|
+
* skips 58, 113 and 131 and carries the non-integer ids `6.5`, `27a` and `27b`.
|
|
29
|
+
* The only rule is uniqueness, which `--check` enforces with a typed violation
|
|
30
|
+
* rather than leaving a human to notice. Because any unique id is legal, an
|
|
31
|
+
* author can use their issue number and never revisit the choice after a
|
|
32
|
+
* rebase: the number-chase is gone, not merely serialized.
|
|
33
|
+
*
|
|
34
|
+
* GROUPS ARE DERIVED TOO. A fragment names its `group` (the `##` heading) and
|
|
35
|
+
* groups are ordered by their lowest-ordered member, so there is no shared
|
|
36
|
+
* group list to edit either. Optional per-group prose lives in
|
|
37
|
+
* `docs/features/_groups/<slug>.md`, which a feature-adding PR never touches.
|
|
38
|
+
*
|
|
39
|
+
* Invariants enforced (see CONTRIBUTING.md "Adding a feature to
|
|
40
|
+
* `docs/FEATURES.md`"):
|
|
41
|
+
* 1. Every fragment declares `id`, `title` and `group` in its frontmatter.
|
|
42
|
+
* 2. Ids are unique across the corpus, and so are the anchors they generate.
|
|
43
|
+
* 3. A fragment body never opens a heading at depth <= 3 — `##` would forge a
|
|
44
|
+
* group and `###` would forge a sibling section, both silently.
|
|
45
|
+
* 4. Every `_groups/` note names a group that some fragment is a member of.
|
|
46
|
+
* 5. Every inbound `FEATURES.md#anchor` link from the repo's own markdown
|
|
47
|
+
* resolves to a heading this generator emits.
|
|
48
|
+
* 6. The committed `docs/FEATURES.md` equals the generated one.
|
|
49
|
+
*
|
|
50
|
+
* Invariant 5 is what makes the number freeze REAL rather than a promise. This
|
|
51
|
+
* repo has no link checker, so broken anchors shipped silently: the migration
|
|
52
|
+
* found `FEATURES.md#runtime-identity` (never a heading) and
|
|
53
|
+
* `#143-spec-phase-edge-completeness-probe` (off by one) already live on `next`.
|
|
54
|
+
* Since a fragment can now change its own `id` in a one-line edit, an unchecked
|
|
55
|
+
* anchor would be a much easier thing to break than it was to break by hand.
|
|
56
|
+
*
|
|
57
|
+
* Usage:
|
|
58
|
+
* node scripts/gen-features.cjs # print the generated region to stdout
|
|
59
|
+
* node scripts/gen-features.cjs --write # rewrite the region in docs/FEATURES.md
|
|
60
|
+
* node scripts/gen-features.cjs --check # exit 1 if stale or invalid
|
|
61
|
+
* node scripts/gen-features.cjs --json # --check semantics; JSON report on stdout
|
|
62
|
+
* node scripts/gen-features.cjs --write --force # write despite violations
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
const fs = require('node:fs');
|
|
66
|
+
const path = require('node:path');
|
|
67
|
+
|
|
68
|
+
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
69
|
+
|
|
70
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
71
|
+
const FEATURES_DIR = path.join(ROOT, 'docs', 'features');
|
|
72
|
+
const GROUP_NOTES_DIR = path.join(FEATURES_DIR, '_groups');
|
|
73
|
+
const FEATURES_PATH = path.join(ROOT, 'docs', 'FEATURES.md');
|
|
74
|
+
|
|
75
|
+
const START_MARKER =
|
|
76
|
+
'<!-- FEATURES:START — generated by scripts/gen-features.cjs; do not edit by hand -->';
|
|
77
|
+
const END_MARKER = '<!-- FEATURES:END -->';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The shallowest heading depth a fragment BODY may open.
|
|
81
|
+
*
|
|
82
|
+
* `##` and `###` are structural in the rendered document: the generator emits
|
|
83
|
+
* `## <group>` and `### <id>. <title>` itself, so a body line at either depth
|
|
84
|
+
* would forge a group or a sibling section that has no fragment, no id and no
|
|
85
|
+
* TOC entry — and would do it silently, because markdown renders it fine.
|
|
86
|
+
* `####` and deeper nest INSIDE a section and are always legal.
|
|
87
|
+
*/
|
|
88
|
+
const MIN_BODY_HEADING_DEPTH = 4;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Stable reason codes for every violation this gate can emit.
|
|
92
|
+
*
|
|
93
|
+
* Tests assert `assert.equal(v.reason, REASON.X)` over the `--json`
|
|
94
|
+
* `violations` array rather than regex-matching stderr prose — see
|
|
95
|
+
* CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs".
|
|
96
|
+
*/
|
|
97
|
+
const REASON = Object.freeze({
|
|
98
|
+
FILENAME_INVALID: 'filename_invalid',
|
|
99
|
+
FRONTMATTER_MISSING: 'frontmatter_missing',
|
|
100
|
+
FIELD_MISSING: 'field_missing',
|
|
101
|
+
FIELD_UNKNOWN: 'field_unknown',
|
|
102
|
+
ID_INVALID: 'id_invalid',
|
|
103
|
+
ID_DUPLICATE: 'id_duplicate',
|
|
104
|
+
ORDER_INVALID: 'order_invalid',
|
|
105
|
+
ANCHOR_DUPLICATE: 'anchor_duplicate',
|
|
106
|
+
BODY_EMPTY: 'body_empty',
|
|
107
|
+
BODY_HEADING_TOO_SHALLOW: 'body_heading_too_shallow',
|
|
108
|
+
GROUP_NOTE_ORPHAN: 'group_note_orphan',
|
|
109
|
+
GROUP_NOTE_DUPLICATE: 'group_note_duplicate',
|
|
110
|
+
INBOUND_ANCHOR_UNRESOLVED: 'inbound_anchor_unresolved',
|
|
111
|
+
BODY_FORGES_REGION_MARKER: 'body_forges_region_marker',
|
|
112
|
+
DIRENT_NOT_REGULAR_FILE: 'dirent_not_regular_file',
|
|
113
|
+
DIRENT_UNREADABLE: 'dirent_unreadable',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Substrings a fragment body may never contain.
|
|
118
|
+
*
|
|
119
|
+
* `spliceIntoFeatures` finds the region boundaries by scanning `docs/FEATURES.md`
|
|
120
|
+
* for these markers. A fragment that PLANTS one gets it rendered verbatim into
|
|
121
|
+
* the generated region, where it becomes an earlier (or later) match than the
|
|
122
|
+
* real boundary on the NEXT run — so a subsequent `--write` splices against the
|
|
123
|
+
* forged boundary and silently freezes everything past it as "hand-authored",
|
|
124
|
+
* a corruption that survives deleting the offending fragment. Fragments arrive
|
|
125
|
+
* through fork PRs, so this is attacker-reachable input, not a typo class.
|
|
126
|
+
*
|
|
127
|
+
* Defence is in depth: this rejects the forgery at the source, and
|
|
128
|
+
* `spliceIntoFeatures` independently anchors on the LAST end marker so a
|
|
129
|
+
* marker that reaches the document some other way still cannot shrink the
|
|
130
|
+
* region it governs.
|
|
131
|
+
*/
|
|
132
|
+
const FORBIDDEN_BODY_SUBSTRINGS = Object.freeze(['<!-- FEATURES:START', '<!-- FEATURES:END']);
|
|
133
|
+
|
|
134
|
+
/** Which forbidden marker (if any) `body` contains. */
|
|
135
|
+
function forgedRegionMarker(body) {
|
|
136
|
+
return FORBIDDEN_BODY_SUBSTRINGS.find((marker) => String(body).includes(marker)) || null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Directories the inbound-anchor scan walks, relative to the repo root.
|
|
141
|
+
*
|
|
142
|
+
* Deliberately does NOT include `.planning/`, `node_modules/`, or anything
|
|
143
|
+
* outside version control. Locale subtrees under `docs/` ARE walked, but their
|
|
144
|
+
* links are filtered by RESOLVED TARGET (below), so `docs/ja-JP/x.md` pointing
|
|
145
|
+
* at its own sibling `docs/ja-JP/FEATURES.md` is correctly ignored — those
|
|
146
|
+
* translations carry different section counts and are not in this gate's scope.
|
|
147
|
+
*/
|
|
148
|
+
const LINK_SCAN_DIRS = Object.freeze(['docs']);
|
|
149
|
+
const LINK_SCAN_ROOT_FILES = Object.freeze(['README.md', 'CONTRIBUTING.md', 'CONTEXT.md']);
|
|
150
|
+
|
|
151
|
+
/** `[text](path/FEATURES.md#anchor)` — the only inbound form this gate checks. */
|
|
152
|
+
const INBOUND_LINK_RE = /\(([^()\s]*FEATURES\.md)#([A-Za-z0-9._-]+)\)/g;
|
|
153
|
+
|
|
154
|
+
/** Frontmatter fields a feature fragment may declare. */
|
|
155
|
+
const FRAGMENT_FIELDS = Object.freeze(['id', 'title', 'group', 'order']);
|
|
156
|
+
/** Frontmatter fields a group-note fragment may declare. */
|
|
157
|
+
const GROUP_NOTE_FIELDS = Object.freeze(['group']);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A legal feature id: an integer, optionally followed by a single lowercase
|
|
161
|
+
* letter (`27a`) or a single decimal part (`6.5`). All three shapes are LIVE in
|
|
162
|
+
* the corpus today — freezing the migrated numbers required accepting them, and
|
|
163
|
+
* the letter/decimal forms are exactly how a feature gets inserted between two
|
|
164
|
+
* already-published numbers without renumbering either.
|
|
165
|
+
*/
|
|
166
|
+
const ID_RE = /^(?:0|[1-9][0-9]*)(?:\.[0-9]+|[a-z])?$/;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A legal explicit `order`: an optionally-signed decimal literal.
|
|
170
|
+
*
|
|
171
|
+
* `order` was the only field validated by COERCION rather than by shape, and
|
|
172
|
+
* `Number()` is far more liberal than a docs ordering field has reason to be:
|
|
173
|
+
* `Number('')` and `Number(' ')` are 0, and `0x10`, `0b11`, `0o17`, `1e3`, `1.`
|
|
174
|
+
* and `.5` all coerce to finite numbers. A fragment declaring `order:` with
|
|
175
|
+
* nothing after it therefore sorted to position 0 — ahead of every real
|
|
176
|
+
* feature — with no violation and exit 0, in a gate whose whole contract is a
|
|
177
|
+
* typed violation rather than a silent guess. Shape first, then coerce,
|
|
178
|
+
* mirroring how ID_RE guards `id`.
|
|
179
|
+
*/
|
|
180
|
+
const ORDER_RE = /^[+-]?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/;
|
|
181
|
+
|
|
182
|
+
/** The fragment filename shape: a kebab slug, mirroring `docs/adr/`'s rule. */
|
|
183
|
+
const FRAGMENT_FILENAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
|
|
184
|
+
|
|
185
|
+
const FRONTMATTER_KEY_RE = /^[a-z][a-z0-9_-]*$/;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* GitHub's heading-anchor algorithm, as applied to the FULL rendered heading
|
|
189
|
+
* text (`27b. Existing Codebase Onboarding` -> `27b-existing-codebase-onboarding`).
|
|
190
|
+
*
|
|
191
|
+
* Deriving the anchor from the same string that is rendered is the whole point:
|
|
192
|
+
* a hand-written TOC could disagree with its heading and nothing would notice,
|
|
193
|
+
* which is how `docs/FEATURES.md` came to be missing TOC entries for `6.5`,
|
|
194
|
+
* `27a` and every section from 163 up. Here the two cannot diverge.
|
|
195
|
+
*
|
|
196
|
+
* Reproduces GitHub's rule: lowercase, drop everything that is not a word
|
|
197
|
+
* character, a space or a hyphen, then map spaces to hyphens. Note the drops
|
|
198
|
+
* happen BEFORE the space->hyphen mapping, which is why `Token Count & Git`
|
|
199
|
+
* yields the double hyphen in `#152-statusline-token-count--git-segment`.
|
|
200
|
+
*/
|
|
201
|
+
function slugify(headingText) {
|
|
202
|
+
return String(headingText)
|
|
203
|
+
.toLowerCase()
|
|
204
|
+
.replace(/[^\w\s-]/g, '')
|
|
205
|
+
.replace(/\s/g, '-');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render a frontmatter value.
|
|
210
|
+
*
|
|
211
|
+
* JSON-quoted only when a bare form would not survive the round trip: an empty
|
|
212
|
+
* value, one with significant leading/trailing whitespace, one that would be
|
|
213
|
+
* mistaken for a quoted value, or one carrying a newline (which would forge a
|
|
214
|
+
* second frontmatter line). Everything else stays bare so the common case
|
|
215
|
+
* reads as ordinary YAML.
|
|
216
|
+
*/
|
|
217
|
+
function renderScalar(value) {
|
|
218
|
+
const s = String(value);
|
|
219
|
+
if (s === '' || s !== s.trim() || s.startsWith('"') || /[\r\n]/.test(s)) return JSON.stringify(s);
|
|
220
|
+
return s;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Inverse of `renderScalar`. */
|
|
224
|
+
function parseScalar(raw) {
|
|
225
|
+
if (raw.startsWith('"')) {
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(raw);
|
|
228
|
+
if (typeof parsed === 'string') return parsed;
|
|
229
|
+
} catch {
|
|
230
|
+
// Fall through: an unparseable quoted-looking value is taken literally
|
|
231
|
+
// rather than crashing the whole corpus scan on one bad fragment.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return raw;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Serialize `{key: string}` into a fragment's `---`-delimited frontmatter block
|
|
239
|
+
* plus body. The exact inverse of `parseFrontmatter`; the pair is property
|
|
240
|
+
* tested for bijectivity.
|
|
241
|
+
*/
|
|
242
|
+
function renderFrontmatter(data, body) {
|
|
243
|
+
const lines = ['---'];
|
|
244
|
+
for (const [key, value] of Object.entries(data)) lines.push(`${key}: ${renderScalar(value)}`);
|
|
245
|
+
lines.push('---', '');
|
|
246
|
+
return `${lines.join('\n')}\n${body}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Split fragment text into `{data, body}`.
|
|
251
|
+
*
|
|
252
|
+
* Returns `data: null` when the document does not open with a `---` fence — the
|
|
253
|
+
* caller turns that into a typed FRONTMATTER_MISSING violation rather than
|
|
254
|
+
* guessing at a body-only fragment.
|
|
255
|
+
*/
|
|
256
|
+
function parseFrontmatter(text) {
|
|
257
|
+
const normalized = String(text).replace(/\r\n/g, '\n');
|
|
258
|
+
if (!normalized.startsWith('---\n')) return { data: null, body: normalized };
|
|
259
|
+
|
|
260
|
+
const end = normalized.indexOf('\n---\n', 3);
|
|
261
|
+
if (end === -1) return { data: null, body: normalized };
|
|
262
|
+
|
|
263
|
+
const data = {};
|
|
264
|
+
for (const line of normalized.slice(4, end + 1).split('\n')) {
|
|
265
|
+
if (line.trim() === '') continue;
|
|
266
|
+
const sep = line.indexOf(':');
|
|
267
|
+
if (sep === -1) continue;
|
|
268
|
+
const key = line.slice(0, sep).trim();
|
|
269
|
+
if (!FRONTMATTER_KEY_RE.test(key)) continue;
|
|
270
|
+
data[key] = parseScalar(line.slice(sep + 1).trim());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// `+5` clears "\n---\n"; the blank line renderFrontmatter emits after the
|
|
274
|
+
// closing fence is consumed here so body text starts at its first real line.
|
|
275
|
+
let body = normalized.slice(end + 5);
|
|
276
|
+
if (body.startsWith('\n')) body = body.slice(1);
|
|
277
|
+
return { data, body };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Line numbers (1-based, relative to `body`) of every heading opened at a depth
|
|
282
|
+
* shallower than `MIN_BODY_HEADING_DEPTH`, ignoring fenced code blocks.
|
|
283
|
+
*
|
|
284
|
+
* Fence tracking is not decoration: several migrated bodies embed shell and
|
|
285
|
+
* markdown samples, and a `## ` inside a fenced sample is content, not a
|
|
286
|
+
* forged group heading. Both ``` and ~~~ fences are honored, and a fence only
|
|
287
|
+
* closes on a marker of the same character at least as long as the opener —
|
|
288
|
+
* the CommonMark rule, so a ```` ``` ```` inside a ```` ```` ```` block does
|
|
289
|
+
* not end it.
|
|
290
|
+
*/
|
|
291
|
+
function shallowBodyHeadings(body) {
|
|
292
|
+
const hits = [];
|
|
293
|
+
let fenceChar = null;
|
|
294
|
+
let fenceLen = 0;
|
|
295
|
+
|
|
296
|
+
String(body)
|
|
297
|
+
.split('\n')
|
|
298
|
+
.forEach((line, i) => {
|
|
299
|
+
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
300
|
+
if (fence) {
|
|
301
|
+
const [char, len] = [fence[1][0], fence[1].length];
|
|
302
|
+
if (fenceChar === null) {
|
|
303
|
+
fenceChar = char;
|
|
304
|
+
fenceLen = len;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (char === fenceChar && len >= fenceLen && line.slice(fence[0].length).trim() === '') {
|
|
308
|
+
fenceChar = null;
|
|
309
|
+
fenceLen = 0;
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (fenceChar !== null) return;
|
|
314
|
+
const heading = /^(#{1,6})\s/.exec(line);
|
|
315
|
+
if (heading && heading[1].length < MIN_BODY_HEADING_DEPTH) {
|
|
316
|
+
hits.push({ line: i + 1, depth: heading[1].length });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return hits;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Default sort key for a fragment that declares no explicit `order`: the id's
|
|
325
|
+
* numeric part. `27a` and `6.5` therefore land where a reader expects without
|
|
326
|
+
* anyone writing an `order`; only a fragment whose frozen position CONTRADICTS
|
|
327
|
+
* its number (`27b` precedes `27a` in the published document) needs one.
|
|
328
|
+
*/
|
|
329
|
+
function defaultOrder(id) {
|
|
330
|
+
return Number.parseFloat(id);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Every `*.md` directly under a directory, sorted, tolerating a missing dir. */
|
|
334
|
+
function markdownFilesIn(dir) {
|
|
335
|
+
let entries;
|
|
336
|
+
try {
|
|
337
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
338
|
+
} catch {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
return entries
|
|
342
|
+
.filter((e) => e.name.endsWith('.md'))
|
|
343
|
+
.map((e) => ({ name: e.name, regular: e.isFile() && !e.isSymbolicLink() }))
|
|
344
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Whether a `docs/features/` entry may be READ at all.
|
|
349
|
+
*
|
|
350
|
+
* `readdirSync(..., {withFileTypes:true})` reports dirent types WITHOUT
|
|
351
|
+
* following symlinks (it is `lstat`-shaped), so `isFile()` is already false for
|
|
352
|
+
* a symlink — but relying on that alone reads as an accident. This states the
|
|
353
|
+
* rule outright: only a regular file is a fragment.
|
|
354
|
+
*
|
|
355
|
+
* The threat is concrete. A fork PR can commit `docs/features/evil.md` as a
|
|
356
|
+
* symlink to any path the process can read (`~/.ssh/id_rsa`, a CI secret file,
|
|
357
|
+
* anything outside the repo). The generator INLINES a fragment's bytes into the
|
|
358
|
+
* committed `docs/FEATURES.md`, so following one link would exfiltrate that
|
|
359
|
+
* file's contents into a public document on the next `regen:derived`. Refusing
|
|
360
|
+
* non-regular entries is what keeps the fragment corpus to files a reviewer can
|
|
361
|
+
* actually see in the diff.
|
|
362
|
+
*/
|
|
363
|
+
function refuseNonRegular(entry, rel, add) {
|
|
364
|
+
if (entry.regular) return false;
|
|
365
|
+
add(REASON.DIRENT_NOT_REGULAR_FILE, rel, {});
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Read every fragment and group note off disk into the intermediate
|
|
371
|
+
* representation the renderer and the validator both consume.
|
|
372
|
+
*
|
|
373
|
+
* `--json` exposes this IR's violations, and the test suite asserts against the
|
|
374
|
+
* IR rather than against rendered markdown text (CONTRIBUTING.md forbids
|
|
375
|
+
* grepping generated output): the parse result is the contract, the markdown is
|
|
376
|
+
* one projection of it.
|
|
377
|
+
*/
|
|
378
|
+
function readCorpus() {
|
|
379
|
+
const violations = [];
|
|
380
|
+
const add = (reason, file, detail) => violations.push({ reason, file, ...detail });
|
|
381
|
+
|
|
382
|
+
const fragments = [];
|
|
383
|
+
for (const entry of markdownFilesIn(FEATURES_DIR)) {
|
|
384
|
+
const name = entry.name;
|
|
385
|
+
const rel = path.posix.join('docs/features', name);
|
|
386
|
+
if (!FRAGMENT_FILENAME_RE.test(name)) {
|
|
387
|
+
add(REASON.FILENAME_INVALID, rel, {});
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (refuseNonRegular(entry, rel, add)) continue;
|
|
391
|
+
let text;
|
|
392
|
+
try {
|
|
393
|
+
text = fs.readFileSync(path.join(FEATURES_DIR, name), 'utf8');
|
|
394
|
+
} catch {
|
|
395
|
+
add(REASON.DIRENT_UNREADABLE, rel, {});
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const { data, body } = parseFrontmatter(text);
|
|
400
|
+
if (data === null) {
|
|
401
|
+
add(REASON.FRONTMATTER_MISSING, rel, {});
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
for (const key of Object.keys(data)) {
|
|
405
|
+
if (!FRAGMENT_FIELDS.includes(key)) add(REASON.FIELD_UNKNOWN, rel, { field: key });
|
|
406
|
+
}
|
|
407
|
+
let missing = false;
|
|
408
|
+
for (const field of ['id', 'title', 'group']) {
|
|
409
|
+
if (!data[field]) {
|
|
410
|
+
add(REASON.FIELD_MISSING, rel, { field });
|
|
411
|
+
missing = true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (missing) continue;
|
|
415
|
+
|
|
416
|
+
if (!ID_RE.test(data.id)) {
|
|
417
|
+
add(REASON.ID_INVALID, rel, { id: data.id });
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let order = defaultOrder(data.id);
|
|
422
|
+
if (data.order !== undefined) {
|
|
423
|
+
order = Number(data.order);
|
|
424
|
+
// Both guards are load-bearing: the regex rejects the shapes `Number`
|
|
425
|
+
// would silently accept, and `isFinite` still catches a well-shaped
|
|
426
|
+
// literal long enough to overflow to Infinity.
|
|
427
|
+
if (!ORDER_RE.test(data.order) || !Number.isFinite(order)) {
|
|
428
|
+
add(REASON.ORDER_INVALID, rel, { order: data.order });
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const trimmed = body.replace(/\s+$/, '');
|
|
434
|
+
if (trimmed === '') {
|
|
435
|
+
add(REASON.BODY_EMPTY, rel, {});
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
for (const hit of shallowBodyHeadings(trimmed)) {
|
|
439
|
+
add(REASON.BODY_HEADING_TOO_SHALLOW, rel, { line: hit.line, depth: hit.depth });
|
|
440
|
+
}
|
|
441
|
+
const forged = forgedRegionMarker(trimmed);
|
|
442
|
+
if (forged !== null) {
|
|
443
|
+
add(REASON.BODY_FORGES_REGION_MARKER, rel, { marker: forged });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
fragments.push({
|
|
448
|
+
file: rel,
|
|
449
|
+
id: data.id,
|
|
450
|
+
title: data.title,
|
|
451
|
+
group: data.group,
|
|
452
|
+
order,
|
|
453
|
+
explicitOrder: data.order !== undefined,
|
|
454
|
+
body: trimmed,
|
|
455
|
+
anchor: slugify(`${data.id}. ${data.title}`),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const notes = new Map();
|
|
460
|
+
for (const entry of markdownFilesIn(GROUP_NOTES_DIR)) {
|
|
461
|
+
const name = entry.name;
|
|
462
|
+
const rel = path.posix.join('docs/features/_groups', name);
|
|
463
|
+
if (refuseNonRegular(entry, rel, add)) continue;
|
|
464
|
+
let text;
|
|
465
|
+
try {
|
|
466
|
+
text = fs.readFileSync(path.join(GROUP_NOTES_DIR, name), 'utf8');
|
|
467
|
+
} catch {
|
|
468
|
+
add(REASON.DIRENT_UNREADABLE, rel, {});
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const { data, body } = parseFrontmatter(text);
|
|
472
|
+
if (data === null) {
|
|
473
|
+
add(REASON.FRONTMATTER_MISSING, rel, {});
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
for (const key of Object.keys(data)) {
|
|
477
|
+
if (!GROUP_NOTE_FIELDS.includes(key)) add(REASON.FIELD_UNKNOWN, rel, { field: key });
|
|
478
|
+
}
|
|
479
|
+
if (!data.group) {
|
|
480
|
+
add(REASON.FIELD_MISSING, rel, { field: 'group' });
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (notes.has(data.group)) {
|
|
484
|
+
add(REASON.GROUP_NOTE_DUPLICATE, rel, { group: data.group });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const trimmed = body.replace(/\s+$/, '');
|
|
488
|
+
if (trimmed === '') {
|
|
489
|
+
add(REASON.BODY_EMPTY, rel, {});
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const forgedNote = forgedRegionMarker(trimmed);
|
|
493
|
+
if (forgedNote !== null) {
|
|
494
|
+
add(REASON.BODY_FORGES_REGION_MARKER, rel, { marker: forgedNote });
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
notes.set(data.group, { file: rel, group: data.group, body: trimmed });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return { fragments, notes, violations };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Every `*.md` under `dir`, recursively, as repo-relative POSIX paths. */
|
|
504
|
+
function markdownFilesUnder(dir) {
|
|
505
|
+
const found = [];
|
|
506
|
+
const walk = (abs) => {
|
|
507
|
+
let entries;
|
|
508
|
+
try {
|
|
509
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
510
|
+
} catch {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
for (const e of entries) {
|
|
514
|
+
const joined = path.join(abs, e.name);
|
|
515
|
+
if (e.isDirectory()) walk(joined);
|
|
516
|
+
else if (e.isFile() && e.name.endsWith('.md')) {
|
|
517
|
+
found.push(path.relative(ROOT, joined).split(path.sep).join('/'));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
walk(path.join(ROOT, dir));
|
|
522
|
+
return found.sort();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Every inbound `docs/FEATURES.md#anchor` reference in the repo's own markdown,
|
|
527
|
+
* with the anchors this generator actually emits subtracted.
|
|
528
|
+
*
|
|
529
|
+
* Links are matched by RESOLVED TARGET, not by textual prefix: a link is only
|
|
530
|
+
* checked when the path it names resolves to `docs/FEATURES.md` itself. That is
|
|
531
|
+
* what keeps the locale trees out of scope without a hardcoded skip list —
|
|
532
|
+
* `docs/ja-JP/README.md` linking `FEATURES.md#…` resolves to
|
|
533
|
+
* `docs/ja-JP/FEATURES.md` and is left alone.
|
|
534
|
+
*/
|
|
535
|
+
function checkInboundAnchors(emittedAnchors) {
|
|
536
|
+
const violations = [];
|
|
537
|
+
const files = [
|
|
538
|
+
...LINK_SCAN_DIRS.flatMap((d) => markdownFilesUnder(d)),
|
|
539
|
+
...LINK_SCAN_ROOT_FILES.filter((f) => fs.existsSync(path.join(ROOT, f))),
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
for (const rel of files) {
|
|
543
|
+
let text;
|
|
544
|
+
try {
|
|
545
|
+
text = fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
546
|
+
} catch {
|
|
547
|
+
violations.push({ reason: REASON.DIRENT_UNREADABLE, file: rel });
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
const dir = path.posix.dirname(rel);
|
|
551
|
+
for (const m of text.matchAll(INBOUND_LINK_RE)) {
|
|
552
|
+
const target = path.posix.normalize(path.posix.join(dir, m[1]));
|
|
553
|
+
if (target !== 'docs/FEATURES.md') continue;
|
|
554
|
+
if (!emittedAnchors.has(m[2])) {
|
|
555
|
+
violations.push({ reason: REASON.INBOUND_ANCHOR_UNRESOLVED, file: rel, anchor: m[2] });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return violations;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Corpus-wide checks that need every fragment in hand, plus the assembled
|
|
564
|
+
* group/section ordering the renderer walks.
|
|
565
|
+
*
|
|
566
|
+
* Sections sort by `order`, ties broken by id string, so the arrangement is a
|
|
567
|
+
* total order that does not depend on readdir sequence. Groups sort by their
|
|
568
|
+
* lowest-ordered member — which is what removes the last shared list: nobody
|
|
569
|
+
* has to edit a group registry to add a release bucket.
|
|
570
|
+
*/
|
|
571
|
+
function buildCorpus() {
|
|
572
|
+
const { fragments, notes, violations } = readCorpus();
|
|
573
|
+
|
|
574
|
+
const byId = new Map();
|
|
575
|
+
for (const f of fragments) {
|
|
576
|
+
const prior = byId.get(f.id);
|
|
577
|
+
if (prior) violations.push({ reason: REASON.ID_DUPLICATE, file: f.file, id: f.id, first: prior.file });
|
|
578
|
+
else byId.set(f.id, f);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const byAnchor = new Map();
|
|
582
|
+
for (const f of fragments) {
|
|
583
|
+
const prior = byAnchor.get(f.anchor);
|
|
584
|
+
if (prior && prior.id !== f.id) {
|
|
585
|
+
violations.push({ reason: REASON.ANCHOR_DUPLICATE, file: f.file, anchor: f.anchor, first: prior.file });
|
|
586
|
+
} else if (!prior) {
|
|
587
|
+
byAnchor.set(f.anchor, f);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const grouped = new Map();
|
|
592
|
+
for (const f of fragments) {
|
|
593
|
+
if (!grouped.has(f.group)) grouped.set(f.group, []);
|
|
594
|
+
grouped.get(f.group).push(f);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
for (const note of notes.values()) {
|
|
598
|
+
if (!grouped.has(note.group)) {
|
|
599
|
+
violations.push({ reason: REASON.GROUP_NOTE_ORPHAN, file: note.file, group: note.group });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const bySection = (a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
604
|
+
const groups = [...grouped.entries()]
|
|
605
|
+
.map(([title, sections]) => {
|
|
606
|
+
sections.sort(bySection);
|
|
607
|
+
const note = notes.get(title);
|
|
608
|
+
return {
|
|
609
|
+
title,
|
|
610
|
+
anchor: slugify(title),
|
|
611
|
+
order: sections[0].order,
|
|
612
|
+
note: note ? note.body : null,
|
|
613
|
+
sections,
|
|
614
|
+
};
|
|
615
|
+
})
|
|
616
|
+
.sort((a, b) => a.order - b.order || (a.title < b.title ? -1 : a.title > b.title ? 1 : 0));
|
|
617
|
+
|
|
618
|
+
const emitted = new Set([...groups.map((g) => g.anchor), ...fragments.map((f) => f.anchor)]);
|
|
619
|
+
violations.push(...checkInboundAnchors(emitted));
|
|
620
|
+
|
|
621
|
+
return { fragments, groups, notes, anchors: emitted, violations };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Human one-liners for the `--check` stderr report, keyed off the typed reason. */
|
|
625
|
+
function describeViolation(v) {
|
|
626
|
+
switch (v.reason) {
|
|
627
|
+
case REASON.FILENAME_INVALID:
|
|
628
|
+
return `${v.file}: filename must be a kebab-case slug, e.g. runtime-identity.md`;
|
|
629
|
+
case REASON.FRONTMATTER_MISSING:
|
|
630
|
+
return `${v.file}: missing the '---' frontmatter block`;
|
|
631
|
+
case REASON.FIELD_MISSING:
|
|
632
|
+
return `${v.file}: frontmatter is missing required field '${v.field}'`;
|
|
633
|
+
case REASON.FIELD_UNKNOWN:
|
|
634
|
+
return `${v.file}: unknown frontmatter field '${v.field}'`;
|
|
635
|
+
case REASON.ID_INVALID:
|
|
636
|
+
return `${v.file}: id '${v.id}' is not an integer, integer+letter (27a) or decimal (6.5)`;
|
|
637
|
+
case REASON.ID_DUPLICATE:
|
|
638
|
+
return `${v.file}: id '${v.id}' is already used by ${v.first} — pick another (any unique id is legal)`;
|
|
639
|
+
case REASON.ORDER_INVALID:
|
|
640
|
+
return `${v.file}: order '${v.order}' is not a decimal number (an optionally-signed integer or decimal)`;
|
|
641
|
+
case REASON.ANCHOR_DUPLICATE:
|
|
642
|
+
return `${v.file}: anchor '#${v.anchor}' collides with ${v.first}`;
|
|
643
|
+
case REASON.BODY_EMPTY:
|
|
644
|
+
return `${v.file}: body is empty`;
|
|
645
|
+
case REASON.BODY_HEADING_TOO_SHALLOW:
|
|
646
|
+
return `${v.file}: body line ${v.line} opens an h${v.depth}; use h${MIN_BODY_HEADING_DEPTH} or deeper inside a feature`;
|
|
647
|
+
case REASON.GROUP_NOTE_ORPHAN:
|
|
648
|
+
return `${v.file}: names group '${v.group}', which no fragment belongs to`;
|
|
649
|
+
case REASON.GROUP_NOTE_DUPLICATE:
|
|
650
|
+
return `${v.file}: a second note for group '${v.group}'`;
|
|
651
|
+
case REASON.INBOUND_ANCHOR_UNRESOLVED:
|
|
652
|
+
return `${v.file}: links docs/FEATURES.md#${v.anchor}, which no heading provides`;
|
|
653
|
+
case REASON.BODY_FORGES_REGION_MARKER:
|
|
654
|
+
return `${v.file}: body contains the generated-region marker '${v.marker}'`;
|
|
655
|
+
case REASON.DIRENT_NOT_REGULAR_FILE:
|
|
656
|
+
return `${v.file}: not a regular file (a symlink is never followed)`;
|
|
657
|
+
case REASON.DIRENT_UNREADABLE:
|
|
658
|
+
return `${v.file}: unreadable`;
|
|
659
|
+
default:
|
|
660
|
+
return `${v.file}: ${v.reason}`;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Render the generated region: the table of contents, then every section. */
|
|
665
|
+
function renderFeatures(corpus) {
|
|
666
|
+
const out = [START_MARKER, '', '## Table of Contents', ''];
|
|
667
|
+
|
|
668
|
+
for (const g of corpus.groups) {
|
|
669
|
+
out.push(`- [${g.title}](#${g.anchor})`);
|
|
670
|
+
for (const s of g.sections) out.push(` - [${s.title}](#${s.anchor})`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
for (const g of corpus.groups) {
|
|
674
|
+
out.push('', '---', '', `## ${g.title}`, '');
|
|
675
|
+
if (g.note) out.push(g.note, '');
|
|
676
|
+
g.sections.forEach((s, i) => {
|
|
677
|
+
if (i > 0) out.push('---', '');
|
|
678
|
+
out.push(`### ${s.id}. ${s.title}`, '', s.body, '');
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
out.push(
|
|
683
|
+
'---',
|
|
684
|
+
'',
|
|
685
|
+
'_Generated by `scripts/gen-features.cjs` — add a fragment under `docs/features/` and run `--write`._',
|
|
686
|
+
'',
|
|
687
|
+
END_MARKER,
|
|
688
|
+
);
|
|
689
|
+
return out.join('\n');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Replace the generated region of `doc` with `region`.
|
|
694
|
+
*
|
|
695
|
+
* The end boundary is the LAST occurrence, not the first. With `indexOf`, a
|
|
696
|
+
* forged `<!-- FEATURES:END -->` anywhere inside the region would become the de
|
|
697
|
+
* facto boundary and everything after it would be preserved as if hand-authored
|
|
698
|
+
* — permanently, since the next run splices against the same forged marker.
|
|
699
|
+
* `lastIndexOf` makes the true trailing marker win, so the generated region can
|
|
700
|
+
* only ever GROW to swallow a forgery, never shrink to be governed by one.
|
|
701
|
+
* `FORBIDDEN_BODY_SUBSTRINGS` rejects the forgery upstream; this is the second
|
|
702
|
+
* layer, and it also covers a marker that reached the document by hand.
|
|
703
|
+
*/
|
|
704
|
+
function spliceIntoFeatures(doc, region) {
|
|
705
|
+
const start = doc.indexOf(START_MARKER);
|
|
706
|
+
const end = doc.lastIndexOf(END_MARKER);
|
|
707
|
+
if (start === -1 || end === -1) {
|
|
708
|
+
throw new ExitError(
|
|
709
|
+
1,
|
|
710
|
+
`docs/FEATURES.md is missing the generated-region markers.\nExpected:\n ${START_MARKER}\n ${END_MARKER}\n`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
return doc.slice(0, start) + region + doc.slice(end + END_MARKER.length);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Parse CLI flags. FAIL-CLOSED on an unrecognized flag: falling through to the
|
|
718
|
+
* no-flags "print the region" behavior would mask a typo (`--wirte`) as a clean
|
|
719
|
+
* run. Mirrors `scripts/gen-adr-index.cjs`.
|
|
720
|
+
*/
|
|
721
|
+
function parseArgs(argv) {
|
|
722
|
+
const opts = { write: false, check: false, json: false, force: false };
|
|
723
|
+
for (const arg of argv) {
|
|
724
|
+
if (arg === '--write') opts.write = true;
|
|
725
|
+
else if (arg === '--check') opts.check = true;
|
|
726
|
+
else if (arg === '--json') opts.json = true;
|
|
727
|
+
else if (arg === '--force') opts.force = true;
|
|
728
|
+
else {
|
|
729
|
+
throw new ExitError(
|
|
730
|
+
1,
|
|
731
|
+
`unknown flag: ${arg}\nRecognized flags: --write, --check, --json, --force.`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return opts;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function main() {
|
|
739
|
+
const { write, check, json, force } = parseArgs(process.argv.slice(2));
|
|
740
|
+
|
|
741
|
+
const corpus = buildCorpus();
|
|
742
|
+
const { violations } = corpus;
|
|
743
|
+
const region = renderFeatures(corpus);
|
|
744
|
+
|
|
745
|
+
if (json) {
|
|
746
|
+
// `--json` implies `--check` semantics but always computes BOTH facts
|
|
747
|
+
// (violations AND staleness) rather than short-circuiting, so a consumer
|
|
748
|
+
// gets the complete picture in one shot.
|
|
749
|
+
const doc = fs.readFileSync(FEATURES_PATH, 'utf8');
|
|
750
|
+
const stale = spliceIntoFeatures(doc, region) !== doc;
|
|
751
|
+
const ok = violations.length === 0 && !stale;
|
|
752
|
+
process.stdout.write(
|
|
753
|
+
JSON.stringify({
|
|
754
|
+
ok,
|
|
755
|
+
featureCount: corpus.fragments.length,
|
|
756
|
+
groupCount: corpus.groups.length,
|
|
757
|
+
indexStale: stale,
|
|
758
|
+
violations,
|
|
759
|
+
}) + '\n',
|
|
760
|
+
);
|
|
761
|
+
return ok ? 0 : 1;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// FAIL-CLOSED. `--write` used to render the region regardless, warning only
|
|
765
|
+
// on stderr and exiting 0 — so a `--write && git commit` chain would happily
|
|
766
|
+
// commit a docs/FEATURES.md carrying two colliding `### 7.` sections. Every
|
|
767
|
+
// other gate in this repo refuses rather than degrades, and a generator that
|
|
768
|
+
// emits a known-corrupt artifact is worse than one that emits none: the
|
|
769
|
+
// corruption is what gets reviewed. `--force` remains for the deliberate
|
|
770
|
+
// "write it anyway so I can see what it looks like" case, and says so.
|
|
771
|
+
if (violations.length > 0 && !(write && force)) {
|
|
772
|
+
process.stderr.write(
|
|
773
|
+
`docs/features/ has ${violations.length} fragment violation(s).\n` +
|
|
774
|
+
'See CONTRIBUTING.md "Adding a feature to `docs/FEATURES.md`" for the contract.\n' +
|
|
775
|
+
(write ? 'Refusing to write a corrupt docs/FEATURES.md; pass --force to override.\n' : '') +
|
|
776
|
+
'\n',
|
|
777
|
+
);
|
|
778
|
+
for (const v of violations) process.stderr.write(` ✗ ${describeViolation(v)}\n`);
|
|
779
|
+
process.stderr.write('\n');
|
|
780
|
+
throw new ExitError(1);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// `--write` takes precedence over a co-supplied `--check`, matching
|
|
784
|
+
// gen-adr-index.cjs: the flags are documented to be used one at a time.
|
|
785
|
+
if (write) {
|
|
786
|
+
const doc = fs.readFileSync(FEATURES_PATH, 'utf8');
|
|
787
|
+
fs.writeFileSync(FEATURES_PATH, spliceIntoFeatures(doc, region));
|
|
788
|
+
process.stdout.write(
|
|
789
|
+
`Wrote ${corpus.fragments.length} features in ${corpus.groups.length} groups into ${FEATURES_PATH}.\n`,
|
|
790
|
+
);
|
|
791
|
+
if (violations.length > 0) {
|
|
792
|
+
// Only reachable under --force; the guard above rejects otherwise.
|
|
793
|
+
process.stderr.write(
|
|
794
|
+
`\n${violations.length} violation(s) written anyway under --force — --check will fail:\n\n`,
|
|
795
|
+
);
|
|
796
|
+
for (const v of violations) process.stderr.write(` ✗ ${describeViolation(v)}\n`);
|
|
797
|
+
}
|
|
798
|
+
} else if (check) {
|
|
799
|
+
const doc = fs.readFileSync(FEATURES_PATH, 'utf8');
|
|
800
|
+
if (spliceIntoFeatures(doc, region) !== doc) {
|
|
801
|
+
process.stderr.write(
|
|
802
|
+
'docs/FEATURES.md is stale. Run:\n node scripts/gen-features.cjs --write\n\n',
|
|
803
|
+
);
|
|
804
|
+
throw new ExitError(1);
|
|
805
|
+
}
|
|
806
|
+
process.stdout.write(
|
|
807
|
+
`docs/FEATURES.md is up to date (${corpus.fragments.length} features, ${corpus.groups.length} groups).\n`,
|
|
808
|
+
);
|
|
809
|
+
} else {
|
|
810
|
+
process.stdout.write(region + '\n');
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Guarded: the test suite requires this module for its pure parser/renderer, so
|
|
815
|
+
// loading it must not also run the generator.
|
|
816
|
+
if (require.main === module) runMain(main);
|
|
817
|
+
|
|
818
|
+
module.exports = {
|
|
819
|
+
REASON,
|
|
820
|
+
MIN_BODY_HEADING_DEPTH,
|
|
821
|
+
FRAGMENT_FIELDS,
|
|
822
|
+
START_MARKER,
|
|
823
|
+
END_MARKER,
|
|
824
|
+
slugify,
|
|
825
|
+
parseFrontmatter,
|
|
826
|
+
renderFrontmatter,
|
|
827
|
+
shallowBodyHeadings,
|
|
828
|
+
forgedRegionMarker,
|
|
829
|
+
FORBIDDEN_BODY_SUBSTRINGS,
|
|
830
|
+
defaultOrder,
|
|
831
|
+
checkInboundAnchors,
|
|
832
|
+
buildCorpus,
|
|
833
|
+
renderFeatures,
|
|
834
|
+
spliceIntoFeatures,
|
|
835
|
+
describeViolation,
|
|
836
|
+
};
|