@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.
Files changed (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -23,12 +23,14 @@
23
23
  * node scripts/gen-adr-index.cjs # print the index to stdout
24
24
  * node scripts/gen-adr-index.cjs --write # rewrite the index in README.md
25
25
  * node scripts/gen-adr-index.cjs --check # exit 1 if stale or invalid
26
+ * node scripts/gen-adr-index.cjs --json # --check semantics; JSON report on stdout
26
27
  */
27
28
 
28
29
  const fs = require('node:fs');
29
30
  const path = require('node:path');
30
31
 
31
32
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
33
+ const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
32
34
 
33
35
  const ROOT = path.resolve(__dirname, '..');
34
36
  const ADR_DIR = path.join(ROOT, 'docs', 'adr');
@@ -54,6 +56,54 @@ const END_MARKER = '<!-- ADR-INDEX:END -->';
54
56
  */
55
57
  const STATUSES = ['Accepted', 'Proposed', 'Superseded', 'Legacy', 'Retired'];
56
58
 
59
+ /**
60
+ * Stable reason codes for every lifecycle violation this gate can emit.
61
+ * Tests assert via `assert.equal(record.reason, REASON.X)` (or `.some(...)`
62
+ * over the `--json` `violations` array) rather than regex-matching stderr
63
+ * prose — see CONTRIBUTING.md "Prohibited: Raw Text Matching on Test
64
+ * Outputs" and the worked example in `bin/verify-reapply-patches.cjs`.
65
+ *
66
+ * Adding a reason is a deliberate three-part change: a new entry here, the
67
+ * emitting `add(...)` call site, and the corpus test that locks
68
+ * `Object.keys(REASON).sort()` — so a new violation class cannot ship
69
+ * without its own typed identity.
70
+ */
71
+ const REASON = Object.freeze({
72
+ FILENAME_INVALID: 'filename_invalid',
73
+ STATUS_MISSING: 'status_missing',
74
+ STATUS_INVALID: 'status_invalid',
75
+ STATUS_BRACKET_MISMATCH: 'status_bracket_mismatch',
76
+ ID_MISMATCH: 'id_mismatch',
77
+ SUPERSEDED_NO_SUCCESSOR: 'superseded_no_successor',
78
+ SUPERSEDED_BARE_ID: 'superseded_bare_id',
79
+ RELATION_LINK_MISSING: 'relation_link_missing',
80
+ RELATION_BARE_ID_MISSING: 'relation_bare_id_missing',
81
+ RELATION_BARE_ID_UNLINKED: 'relation_bare_id_unlinked',
82
+ RELATION_ASYMMETRIC: 'relation_asymmetric',
83
+ LINK_UNRESOLVED: 'link_unresolved',
84
+ LINK_ESCAPES_REPO: 'link_escapes_repo',
85
+ LINK_ESCAPES_REPO_SYMLINK: 'link_escapes_repo_symlink',
86
+ DIRENT_UNREADABLE: 'dirent_unreadable',
87
+ DIRENT_ESCAPES_REPO_SYMLINK: 'dirent_escapes_repo_symlink',
88
+ });
89
+
90
+ /**
91
+ * The H1 trailing-bracket vocabulary, derived from `STATUSES` — not a second
92
+ * hand-written literal. Before this PR, `parseAdr`'s title strip carried its
93
+ * own copy of these five tokens, and nothing asserted the two lists agreed:
94
+ * a textbook `DEFECT.GENERATIVE-FIX` instance (a generated surface and its
95
+ * hand-authored source drifting apart with no parity check). A 6th status
96
+ * added to `STATUSES` now covers the bracket for free, and the corpus's
97
+ * parity test iterates the real exported array rather than a copy.
98
+ */
99
+ // Escaped for defence-in-depth, not a live-bug fix: `STATUSES` is a static
100
+ // array literal today, so nothing in it can currently carry a regex
101
+ // metacharacter. But nothing enforces that it STAYS static — if a future
102
+ // change ever derives it from external input (a config file, a corpus scan),
103
+ // an unescaped `join('|')` would let a status token break out of the
104
+ // alternation it is meant to be one branch of.
105
+ const STATUS_BRACKET_RE = new RegExp(String.raw`\s*\[(${STATUSES.map(escapeRegExp).join('|')})\]\s*$`, 'i');
106
+
57
107
  /**
58
108
  * Header fields that assert a lifecycle relation.
59
109
  *
@@ -113,14 +163,116 @@ function canonicalId(raw) {
113
163
  /** The documented filename shape: `<issue#>-<kebab-slug>.md`. */
114
164
  const ADR_FILENAME_RE = /^[0-9]+-[a-z0-9-]+\.md$/;
115
165
 
116
- function adrFiles() {
117
- return fs
118
- .readdirSync(ADR_DIR)
119
- .filter((f) => f.endsWith('.md') && f !== 'README.md')
120
- .filter((f) => fs.statSync(path.join(ADR_DIR, f)).isFile())
166
+ /**
167
+ * Whole-segment containment test: true if `abs` is NOT inside `root`.
168
+ *
169
+ * The SINGLE copy of this predicate. Before this PR it was hand-written
170
+ * inline in two places (the lexical pre-stat check in `validateLinks` and the
171
+ * post-realpath escape check in `existsCaseExact`) with no shared name; a
172
+ * third copy for `markdownFilesInAdrDir`'s own symlink check would have made
173
+ * three. All three call sites now share this one function.
174
+ *
175
+ * `rel.startsWith('..')` alone would also match an in-repo path whose first
176
+ * segment merely BEGINS with two dots (`..hidden.md`), and a false "escapes
177
+ * the repository" on a valid path is a worse failure than a miss — hence the
178
+ * whole-segment `rel === '..' || rel.startsWith('..' + sep)` form.
179
+ */
180
+ function escapesRoot(abs, root) {
181
+ const rel = path.relative(root, abs);
182
+ return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
183
+ }
184
+
185
+ /**
186
+ * `fs.realpathSync(ROOT)`, tolerant of an unreadable/vanished ROOT (degrades
187
+ * to the lexical ROOT itself rather than throwing — callers still get a
188
+ * usable comparison root, just without symlink-normalization on hosts where
189
+ * ROOT itself sits under a symlinked ancestor, e.g. macOS's /var -> /private/var).
190
+ */
191
+ function realRootOrFallback() {
192
+ try {
193
+ return fs.realpathSync(ROOT);
194
+ } catch {
195
+ return ROOT;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Whether `joined` (a `*.md` dirent directly under docs/adr/) should be
201
+ * treated as an ADR file: a regular file, or a symlink that resolves to a
202
+ * regular file WITHOUT leaving the repository. Applies the same rule to which
203
+ * FILES are read as `validateLinks` already applies to which link TARGETS
204
+ * resolve — a symlink escaping the repo is never followed and its content is
205
+ * never touched (no `statSync`/`readFileSync` past the `lstatSync`/
206
+ * `realpathSync` calls below), because `parseAdr` and `validateLinks` both
207
+ * read the FULL body of every accepted file and echo fragments into stderr.
208
+ */
209
+ function isAcceptedAdrEntry(joined, realRoot) {
210
+ let lst;
211
+ try {
212
+ lst = fs.lstatSync(joined);
213
+ } catch {
214
+ return false; // vanished / unreadable
215
+ }
216
+ if (lst.isFile()) return true;
217
+ if (!lst.isSymbolicLink()) return false;
218
+
219
+ let real;
220
+ try {
221
+ real = fs.realpathSync(joined);
222
+ } catch {
223
+ return false; // broken symlink
224
+ }
225
+ if (escapesRoot(real, realRoot)) return false; // escapes the repository
226
+
227
+ try {
228
+ return fs.statSync(real).isFile();
229
+ } catch {
230
+ return false; // vanished between realpath and stat (TOCTOU)
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Every markdown file directly under docs/adr/, README.md included.
236
+ *
237
+ * Single source of the traversal rule: `adrFiles()` is this minus README.md
238
+ * (which is the index, not an ADR), and the link-resolution pass is this
239
+ * unfiltered (the generated index can point nowhere too). Two hand-copied
240
+ * readdir filters would drift the moment either grew a rule — the exact
241
+ * DEFECT.GENERATIVE-FIX shape this gate now enforces against the corpus.
242
+ */
243
+ function markdownFilesInAdrDir() {
244
+ let entries;
245
+ try {
246
+ entries = fs.readdirSync(ADR_DIR);
247
+ } catch {
248
+ // An unreadable docs/adr/ itself degrades to "no files" here — the caller
249
+ // (validate/validateLinks) surfaces the real problem elsewhere; this
250
+ // traversal helper must never throw a raw fs error up into `runMain`,
251
+ // which would print `err.stack` (absolute host paths) to public CI logs.
252
+ entries = [];
253
+ }
254
+ const realRoot = realRootOrFallback();
255
+ return entries
256
+ .filter((f) => f.endsWith('.md'))
257
+ .filter((f) => {
258
+ try {
259
+ return isAcceptedAdrEntry(path.join(ADR_DIR, f), realRoot);
260
+ } catch {
261
+ // A `*.md` dirent that cannot be classified — most commonly a broken
262
+ // symlink — is excluded here rather than crashing the caller. It is
263
+ // not silently dropped from the gate: `validateLinks` diffs this
264
+ // filtered list against the raw `readdirSync` listing and reports
265
+ // the exclusion as its own violation, naming the file.
266
+ return false;
267
+ }
268
+ })
121
269
  .sort();
122
270
  }
123
271
 
272
+ function adrFiles() {
273
+ return markdownFilesInAdrDir().filter((f) => f !== 'README.md');
274
+ }
275
+
124
276
  /**
125
277
  * Split the directory into files this tool can parse and files it cannot.
126
278
  *
@@ -192,6 +344,409 @@ function bareAdrRefs(text) {
192
344
  return out;
193
345
  }
194
346
 
347
+ /**
348
+ * Mask code (fenced blocks and inline spans) so link resolution never reads a
349
+ * `[…](…)` sequence that markdown does not render as a link. The corpus has
350
+ * two real examples of this: `mod[entry.router]({ args, cwd, raw, error })`
351
+ * inside a ``` fence, and `` `require(module)[router]()` `` inline — both
352
+ * ordinary JavaScript, neither a link.
353
+ *
354
+ * The output is the SAME LENGTH as the input, with every masked character
355
+ * replaced by a single space and every newline left untouched — so a finding
356
+ * computed against the masked text still names the correct 1-indexed line
357
+ * (Kernighan's Law: keep the debug surface honest rather than deleting text).
358
+ */
359
+ function maskCode(text) {
360
+ // Capturing split keeps the line terminators as their own array elements
361
+ // (even indices are line content, odd indices are the terminator that
362
+ // followed), so the rebuild below never has to guess LF vs CRLF.
363
+ const parts = String(text).split(/(\r?\n)/);
364
+
365
+ // null outside a fence; otherwise the marker char ('`' or '~') and the
366
+ // length of the run that opened it — both are load-bearing for closing:
367
+ // only the SAME char with a run length >= the opener's closes the fence.
368
+ let fence = null;
369
+
370
+ for (let i = 0; i < parts.length; i += 2) {
371
+ const line = parts[i];
372
+
373
+ if (fence) {
374
+ // Whichever way this line resolves, it is code: the closing fence line
375
+ // is still a fence delimiter, not prose.
376
+ const closeRe = fence.char === '`' ? /^ {0,3}(`{3,})\s*$/ : /^ {0,3}(~{3,})\s*$/;
377
+ const close = line.match(closeRe);
378
+ parts[i] = ' '.repeat(line.length);
379
+ if (close && close[1].length >= fence.len) fence = null;
380
+ continue;
381
+ }
382
+
383
+ const open = line.match(/^ {0,3}(`{3,}|~{3,})/);
384
+ if (open) {
385
+ fence = { char: open[1][0], len: open[1].length };
386
+ parts[i] = ' '.repeat(line.length);
387
+ continue;
388
+ }
389
+
390
+ parts[i] = maskInlineCodeSpans(line);
391
+ }
392
+
393
+ return parts.join('');
394
+ }
395
+
396
+ /**
397
+ * Mask backtick-delimited inline code spans within a single line (fences are
398
+ * handled by the caller, per-line, before this runs — a span never crosses a
399
+ * newline). CommonMark's rule: a run of N backticks opens a span, closed by
400
+ * the NEXT run of exactly N backticks; a run of any other length in between
401
+ * is part of the span's content, not a delimiter. An opening run with no
402
+ * matching close is literal text, not a span.
403
+ *
404
+ * LINEAR, not the naive per-opener rescan this replaced: the old
405
+ * implementation, for every backtick run, rescanned the entire remainder of
406
+ * the line looking for a same-length closer. A line of strictly-ascending-
407
+ * length backtick runs (nothing ever closes) forced a near-full rescan per
408
+ * run — measured ~O(n^1.6) and unbounded (34ms/50KB -> 220ms/200KB ->
409
+ * 1.76s/800KB on adversarial input). This version scans the line ONCE to
410
+ * collect every backtick run as `{start, end, len}`, then walks that run
411
+ * list left to right with a per-length cursor (`byLen`/`cursor` below) that
412
+ * only ever advances forward — so finding "the next run of equal length" is
413
+ * amortized O(1) per step and the whole pass is O(line length).
414
+ *
415
+ * Behavior is identical to the rescan version for every input: once an
416
+ * opener at run `r` is paired with the next same-length run `r'`, every run
417
+ * strictly between them is consumed as span content and is never
418
+ * reconsidered as its own delimiter — exactly what the old code did by
419
+ * jumping `i` straight to the close and never revisiting the interior.
420
+ */
421
+ function maskInlineCodeSpans(line) {
422
+ const runs = [];
423
+ let i = 0;
424
+ while (i < line.length) {
425
+ if (line[i] !== '`') {
426
+ i += 1;
427
+ continue;
428
+ }
429
+ const start = i;
430
+ while (i < line.length && line[i] === '`') i += 1;
431
+ runs.push({ start, end: i, len: i - start });
432
+ }
433
+ if (runs.length === 0) return line;
434
+
435
+ // Every run's index, grouped by length, in left-to-right order (already
436
+ // sorted — `runs` was built in scan order).
437
+ const byLen = new Map();
438
+ for (let idx = 0; idx < runs.length; idx += 1) {
439
+ const len = runs[idx].len;
440
+ if (!byLen.has(len)) byLen.set(len, []);
441
+ byLen.get(len).push(idx);
442
+ }
443
+ const cursor = new Map(); // len -> next unexamined index into byLen.get(len)
444
+
445
+ const spans = []; // [start, end) ranges to mask, in order, non-overlapping
446
+ let r = 0;
447
+ while (r < runs.length) {
448
+ const len = runs[r].len;
449
+ const candidates = byLen.get(len);
450
+ let c = cursor.get(len) || 0;
451
+ // Skip past any candidate at or before `r`: `r` itself, or an index
452
+ // already consumed as interior content of an earlier matched span (a run
453
+ // inside a completed span is never revisited — same as the rescan
454
+ // version never re-examining a delimiter it has already masked over).
455
+ while (c < candidates.length && candidates[c] <= r) c += 1;
456
+ if (c < candidates.length) {
457
+ const closeIdx = candidates[c];
458
+ spans.push([runs[r].start, runs[closeIdx].end]);
459
+ cursor.set(len, c + 1);
460
+ r = closeIdx + 1;
461
+ } else {
462
+ cursor.set(len, c);
463
+ r += 1; // no closer of equal length anywhere ahead — literal text
464
+ }
465
+ }
466
+
467
+ let out = '';
468
+ let pos = 0;
469
+ for (const [s, e] of spans) {
470
+ out += line.slice(pos, s) + ' '.repeat(e - s);
471
+ pos = e;
472
+ }
473
+ out += line.slice(pos);
474
+ return out;
475
+ }
476
+
477
+ /**
478
+ * Every inline `[text](dest)` / `![alt](dest)` link or image in `text`, with
479
+ * code masked out first so a code-shaped bracket/paren sequence is never
480
+ * misread as a link (see `maskCode`).
481
+ *
482
+ * `[^\][\n]*` for the link-text class deliberately excludes BOTH bracket
483
+ * characters, not just `]` — so `[see [1]](x.md)` does not match (nested
484
+ * brackets are out of the inline-links-only scope this gate supports) and a
485
+ * regex character class in prose like `[A-Z][A-Z0-9_]` cannot be misread as
486
+ * one either. Reference-style links (`[text][ref]`) are correspondingly not
487
+ * supported: the corpus has zero reference definitions to resolve against.
488
+ *
489
+ * Returns `{ line, target }` per match — `line` is 1-indexed, `target` is the
490
+ * RAW parenthesized capture, untrimmed and unresolved; callers normalize.
491
+ */
492
+ function extractLinks(text) {
493
+ const masked = maskCode(String(text));
494
+ const lines = masked.split(/\r?\n/);
495
+ const out = [];
496
+ const re = /!?\[[^\][\n]*\]\(([^()\n]*)\)/g;
497
+ for (let i = 0; i < lines.length; i += 1) {
498
+ re.lastIndex = 0;
499
+ let m;
500
+ while ((m = re.exec(lines[i])) !== null) {
501
+ out.push({ line: i + 1, target: m[1] });
502
+ }
503
+ }
504
+ return out;
505
+ }
506
+
507
+ /**
508
+ * Case-exact existence of `abs` (which MUST already be verified inside ROOT
509
+ * by the caller — LEXICALLY, via `path.relative`). Walks each path segment
510
+ * against a cached, real `readdirSync` listing of its parent rather than
511
+ * calling `fs.existsSync(abs)` directly: existsSync resolves through the OS's
512
+ * case-folding rules, which pass on macOS/Windows for a link that 404s on
513
+ * github.com and reds the Linux CI lane — every platform must agree, so
514
+ * resolution never trusts the filesystem's own case sensitivity (or lack
515
+ * of it).
516
+ *
517
+ * SYMLINK ESCAPE (the reason this function is more than a readdir loop): the
518
+ * caller's containment check is purely lexical string math on `abs` — it
519
+ * proves nothing about what is actually ON DISK at each segment. But
520
+ * `readdirSync` FOLLOWS symlinks at the OS level while walking further down a
521
+ * path. A contributor can commit `docs/adr/x -> /etc` (a symlink; Linux CI
522
+ * lanes, including fork PRs, preserve symlinks) plus an ADR linking
523
+ * `[t](x/passwd)`: the caller's lexical check sees `docs/adr/x/passwd`, which
524
+ * LOOKS repo-internal, and this walk would then list the real external
525
+ * directory — and the "Did you mean X?" hint below is built from exactly that
526
+ * listing, so a wrong-case probe (`[t](x/PASSWD)`) would echo a real filename
527
+ * from OUTSIDE the repo into PUBLIC CI LOGS on a fork PR. So every segment is
528
+ * lstat'd, and a symlink is realpath'd and re-checked against the REAL root,
529
+ * BEFORE this walk ever descends into or reads what it points at.
530
+ *
531
+ * `dirCache` is a Map<directory, Set<entryName>|null> (null = unreadable),
532
+ * built and owned by the caller so repeated links into the same directory
533
+ * cost one `readdirSync` total, not one per link.
534
+ *
535
+ * `realRoot` is `fs.realpathSync(ROOT)`, computed ONCE by the caller (never
536
+ * per-segment/per-link here) and passed in — ROOT itself may sit under a
537
+ * symlinked path (macOS's `/var` -> `/private/var`), so comparing a
538
+ * realpath'd descendant against a non-realpath'd ROOT would misclassify every
539
+ * legitimate path on such a host as an escape.
540
+ *
541
+ * Returns `{ exists, hint, escaped }`. `escaped: true` means a symlink
542
+ * resolved outside `realRoot`; in that case `exists` is `false` and `hint` is
543
+ * ALWAYS `null` — the caller must report a distinct "escapes" message and
544
+ * never fall back to the generic "does not resolve" wording or a hint, both
545
+ * of which would leak into the escape's own disclosure hazard.
546
+ */
547
+ function existsCaseExact(abs, dirCache, realRoot) {
548
+ const rel = path.relative(ROOT, abs);
549
+ if (rel === '') return { exists: true, hint: null, escaped: false }; // ROOT itself
550
+
551
+ const segments = rel.split(path.sep);
552
+ let dir = ROOT;
553
+ for (const seg of segments) {
554
+ let entries = dirCache.get(dir);
555
+ if (entries === undefined) {
556
+ try {
557
+ entries = new Set(fs.readdirSync(dir));
558
+ } catch {
559
+ entries = null;
560
+ }
561
+ dirCache.set(dir, entries);
562
+ }
563
+ if (!entries || !entries.has(seg)) {
564
+ const hint = entries ? [...entries].find((e) => e.toLowerCase() === seg.toLowerCase()) : null;
565
+ return { exists: false, hint: hint || null, escaped: false };
566
+ }
567
+
568
+ const joined = path.join(dir, seg);
569
+
570
+ // `entries.has(seg)` above proved a directory ENTRY named `seg` exists —
571
+ // it says nothing about what that entry IS. Check before descending.
572
+ let lst;
573
+ try {
574
+ lst = fs.lstatSync(joined);
575
+ } catch {
576
+ // Vanished between readdir and lstat (TOCTOU race) — degrade to "does
577
+ // not resolve", never throw.
578
+ return { exists: false, hint: null, escaped: false };
579
+ }
580
+
581
+ if (lst.isSymbolicLink()) {
582
+ let real;
583
+ try {
584
+ real = fs.realpathSync(joined);
585
+ } catch {
586
+ // Broken symlink — degrade to "does not resolve", never throw.
587
+ return { exists: false, hint: null, escaped: false };
588
+ }
589
+ if (escapesRoot(real, realRoot)) {
590
+ // No further readdirSync down this path, and no hint: both would
591
+ // disclose facts about a directory outside the repo.
592
+ return { exists: false, hint: null, escaped: true };
593
+ }
594
+ dir = real; // resolves inside the repo — continue the walk from there.
595
+ continue;
596
+ }
597
+
598
+ dir = joined;
599
+ }
600
+ return { exists: true, hint: null, escaped: false };
601
+ }
602
+
603
+ /**
604
+ * The link-resolution pass: every inline link/image target in every `*.md`
605
+ * file directly under `docs/adr/` — INCLUDING README.md (the generated index
606
+ * can point nowhere too) and files that fail the naming convention (their
607
+ * naming violation is reported separately by `partitionAdrFiles`, but a
608
+ * reader still follows their links). Non-recursive, matching `adrFiles()`.
609
+ *
610
+ * Errors are reported through the same `add(file, msg)` channel `validate`
611
+ * uses elsewhere, keeping the `${file}: ${msg}` prefix uniform — but the
612
+ * "file" half of that prefix is `${file}:${line}` here, so the emitted line
613
+ * reads `<file>:<line>: <prose>` (a literal colon immediately before the line
614
+ * number, compiler-diagnostic style) rather than `<file>: <line>: <prose>`.
615
+ */
616
+ function validateLinks(add) {
617
+ const files = markdownFilesInAdrDir();
618
+
619
+ // Computed ONCE per pass, never per-link/per-dirent: see existsCaseExact's
620
+ // doc comment for why comparing against the REAL root (not the lexical
621
+ // ROOT constant) is required to avoid false escapes when the repo checkout
622
+ // itself sits under a symlinked ancestor (e.g. macOS's /var -> /private/var).
623
+ const realRoot = realRootOrFallback();
624
+
625
+ // Report any `*.md` dirent that `markdownFilesInAdrDir` silently excluded —
626
+ // because it could not be stat'd (e.g. a broken symlink) OR because it IS a
627
+ // symlink that resolves outside the repository — so it surfaces as a gate
628
+ // finding instead of quietly vanishing from the index. Reading the
629
+ // directory again here (rather than threading a second return value
630
+ // through `markdownFilesInAdrDir`) keeps that function's contract
631
+ // (`string[]`) simple for its other callers. Wrapped in try/catch for the
632
+ // same reason as inside `markdownFilesInAdrDir`: an unreadable ADR_DIR
633
+ // degrades to "nothing more to report" here, never a crash.
634
+ let dirents;
635
+ try {
636
+ dirents = fs.readdirSync(ADR_DIR);
637
+ } catch {
638
+ dirents = [];
639
+ }
640
+ const included = new Set(files);
641
+ const BROKEN_MSG = 'could not be read (broken symlink?) and was excluded from the index. Remove it or fix its target.';
642
+ for (const f of dirents) {
643
+ if (!f.endsWith('.md') || included.has(f)) continue;
644
+ const joined = path.join(ADR_DIR, f);
645
+ let lst;
646
+ try {
647
+ lst = fs.lstatSync(joined);
648
+ } catch {
649
+ add(f, BROKEN_MSG, { reason: REASON.DIRENT_UNREADABLE, line: null });
650
+ continue;
651
+ }
652
+ if (!lst.isSymbolicLink()) {
653
+ // Not a symlink and still excluded — some other legitimate reason
654
+ // (e.g. it's a directory literally named `*.md`), not unreadable.
655
+ continue;
656
+ }
657
+ let real;
658
+ try {
659
+ real = fs.realpathSync(joined);
660
+ } catch {
661
+ add(f, BROKEN_MSG, { reason: REASON.DIRENT_UNREADABLE, line: null });
662
+ continue;
663
+ }
664
+ if (escapesRoot(real, realRoot)) {
665
+ // Distinct message from the broken-symlink one above, and — same
666
+ // discipline as the link-target escape below — no path or hint from
667
+ // outside the repo is ever included: only the in-repo dirent name.
668
+ add(
669
+ f,
670
+ 'is a symlink that escapes the repository and was excluded from the index. Point it at a file inside docs/adr/, or remove it.',
671
+ { reason: REASON.DIRENT_ESCAPES_REPO_SYMLINK, line: null },
672
+ );
673
+ continue;
674
+ }
675
+ // Resolves inside the repo but is not a regular file (e.g. a symlink to
676
+ // a directory) — a legitimate exclusion, not a disclosure hazard.
677
+ }
678
+
679
+ const dirCache = new Map();
680
+
681
+ for (const file of files) {
682
+ const text = fs.readFileSync(path.join(ADR_DIR, file), 'utf8');
683
+
684
+ for (const { line, target: rawTarget } of extractLinks(text)) {
685
+ let t = String(rawTarget).trim();
686
+
687
+ if (t.startsWith('<') && t.endsWith('>')) {
688
+ t = t.slice(1, -1).trim();
689
+ } else {
690
+ // A link title: `dest "Title"` / `dest 'Title'`. Drop it, keep dest.
691
+ const titled = t.match(/^(\S+)\s+(?:"[^"]*"|'[^']*')$/);
692
+ if (titled) t = titled[1];
693
+ }
694
+
695
+ if (t === '' || t.startsWith('#') || t.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(t)) {
696
+ continue; // empty, same-document anchor, protocol-relative, or any URI scheme — out of scope
697
+ }
698
+
699
+ t = t.split('#')[0];
700
+ if (t === '') continue; // was only a fragment
701
+
702
+ try {
703
+ t = decodeURIComponent(t);
704
+ } catch {
705
+ // Malformed escape (e.g. "%zz"): resolve the raw, non-decoded text
706
+ // rather than throwing — an unresolvable literal is still reportable.
707
+ }
708
+
709
+ const abs = t.startsWith('/') ? path.resolve(ROOT, t.slice(1)) : path.resolve(ADR_DIR, t);
710
+
711
+ // Containment BEFORE any filesystem call: never `stat` outside ROOT.
712
+ const rel = path.relative(ROOT, abs);
713
+ if (escapesRoot(abs, ROOT)) {
714
+ add(`${file}:${line}`, `link "${rawTarget}" escapes the repository. Link a path inside the repo.`, {
715
+ reason: REASON.LINK_ESCAPES_REPO,
716
+ file,
717
+ line,
718
+ target: rawTarget,
719
+ });
720
+ continue;
721
+ }
722
+
723
+ const { exists, hint, escaped } = existsCaseExact(abs, dirCache, realRoot);
724
+ if (escaped) {
725
+ // A symlink under the (lexically in-repo) target path resolves
726
+ // outside the repository. Distinct message from the generic
727
+ // "does not resolve" below, and — deliberately — no hint: the hint
728
+ // itself would be the disclosure (see existsCaseExact's doc comment).
729
+ add(`${file}:${line}`, `link "${rawTarget}" escapes the repository via a symlink. Link a path inside the repo.`, {
730
+ reason: REASON.LINK_ESCAPES_REPO_SYMLINK,
731
+ file,
732
+ line,
733
+ target: rawTarget,
734
+ });
735
+ continue;
736
+ }
737
+ if (!exists) {
738
+ const relFromRoot = rel.split(path.sep).join('/');
739
+ const hintSuffix = hint ? ` Did you mean ${hint}? — link targets are case-sensitive on github.com.` : '';
740
+ add(
741
+ `${file}:${line}`,
742
+ `link "${rawTarget}" does not resolve — no such file or directory at ${relFromRoot}.${hintSuffix}`,
743
+ { reason: REASON.LINK_UNRESOLVED, file, line, target: rawTarget, resolved: relFromRoot },
744
+ );
745
+ }
746
+ }
747
+ }
748
+ }
749
+
195
750
  function parseAdr(file) {
196
751
  const full = path.join(ADR_DIR, file);
197
752
  const text = fs.readFileSync(full, 'utf8');
@@ -205,12 +760,19 @@ function parseAdr(file) {
205
760
  const displayId = rawId;
206
761
 
207
762
  const h1 = (lines.find((l) => /^#\s/.test(l)) || '').replace(/^#\s+/, '').trim();
763
+
764
+ // Capture the trailing status bracket against the RAW h1, before the title
765
+ // strip below discards it. This is the value the H1-vs-Status comparison in
766
+ // `validate` checks against — the strip alone throws the information away.
767
+ const bracketMatch = h1.match(STATUS_BRACKET_RE);
768
+ const bracketStatus = bracketMatch ? bracketMatch[1] : null;
769
+
208
770
  // Title as displayed: drop a leading "ADR-123 — " / "ADR-123: " prefix and a
209
771
  // trailing "[Proposed]"-style status bracket, both of which the index renders
210
772
  // from structured fields instead.
211
773
  const title = h1
212
774
  .replace(/^ADR[-\s]?0*\d+\s*(?:[—:-]\s*)?/i, '')
213
- .replace(/\s*\[(?:Proposed|Accepted|Superseded|Legacy|Retired)\]\s*$/i, '')
775
+ .replace(STATUS_BRACKET_RE, '')
214
776
  .trim();
215
777
 
216
778
  const declaredIdMatch = h1.match(/^ADR[-\s]?0*(\d+)\b/i);
@@ -252,7 +814,7 @@ function parseAdr(file) {
252
814
  relations[kind].out.push({ field: `## ${kind === 'supersedes' ? 'Supersedes' : 'Subsumes'} section`, value: '', links: sections[kind], bare: [] });
253
815
  }
254
816
 
255
- return { file, fileId, displayId, title, declaredId, statusRaw, statusToken, relations, text };
817
+ return { file, fileId, displayId, title, declaredId, statusRaw, statusToken, bracketStatus, relations, text };
256
818
  }
257
819
 
258
820
  function buildCorpus() {
@@ -269,26 +831,64 @@ function buildCorpus() {
269
831
 
270
832
  function validate({ adrs, byFile, byId, nonConforming }) {
271
833
  const errors = [];
272
- const add = (file, msg) => errors.push(`${file}: ${msg}`);
834
+ const violations = [];
835
+ // `record` carries the STRUCTURED half of every violation — `reason` plus
836
+ // whatever typed fields a `--json` consumer needs (line, target, resolved,
837
+ // expected/actual, status, …) — kept alongside, never instead of, the
838
+ // human `${file}: ${msg}` line: a large pre-existing test suite asserts on
839
+ // that string verbatim and is out of scope to migrate. `record` may supply
840
+ // its own `file` (spread AFTER the outer `file`) so link-violation records
841
+ // carry the plain filename as a field distinct from the human message's
842
+ // `<file>:<line>` prefix string.
843
+ const add = (file, msg, record) => {
844
+ errors.push(`${file}: ${msg}`);
845
+ violations.push({ file, ...record });
846
+ };
273
847
 
274
848
  for (const f of nonConforming) {
275
849
  add(
276
850
  f,
277
851
  'filename does not match the `<issue#>-<kebab-slug>.md` convention, so it cannot appear in the index. ' +
278
852
  'Rename it (see docs/adr/README.md "Naming Convention"), or move it out of docs/adr/ if it is not an ADR.',
853
+ { reason: REASON.FILENAME_INVALID, line: null },
279
854
  );
280
855
  }
281
856
 
282
857
  for (const a of adrs) {
283
858
  if (!a.statusToken) {
284
- add(a.file, 'no `- **Status:** <Token>` field found in the header block.');
859
+ add(a.file, 'no `- **Status:** <Token>` field found in the header block.', {
860
+ reason: REASON.STATUS_MISSING,
861
+ line: null,
862
+ });
285
863
  continue;
286
864
  }
287
865
  if (!STATUSES.includes(a.statusToken)) {
288
- add(a.file, `status "${a.statusToken}" is not one of ${STATUSES.join(' | ')} (full line: "${a.statusRaw}").`);
866
+ add(a.file, `status "${a.statusToken}" is not one of ${STATUSES.join(' | ')} (full line: "${a.statusRaw}").`, {
867
+ reason: REASON.STATUS_INVALID,
868
+ line: null,
869
+ status: a.statusToken,
870
+ });
871
+ } else if (
872
+ // Only compare when the status token is itself valid — an already-invalid
873
+ // token gets its own report above, and piling a bracket-disagreement
874
+ // message on top of it would be a second complaint about the same defect.
875
+ a.bracketStatus &&
876
+ a.bracketStatus.toLowerCase() !== a.statusToken.toLowerCase()
877
+ ) {
878
+ add(
879
+ a.file,
880
+ `H1 status bracket [${a.bracketStatus}] contradicts the Status field (${a.statusToken}). ` +
881
+ 'Update the H1 bracket (or the Status field) so they agree — a stale bracket is the first thing a reader sees.',
882
+ { reason: REASON.STATUS_BRACKET_MISMATCH, line: null, expected: a.statusToken, actual: a.bracketStatus },
883
+ );
289
884
  }
290
885
  if (a.declaredId && a.declaredId !== a.fileId) {
291
- add(a.file, `H1 declares ADR-${a.declaredId} but the filename says ${a.fileId}. The id must match the filename.`);
886
+ add(a.file, `H1 declares ADR-${a.declaredId} but the filename says ${a.fileId}. The id must match the filename.`, {
887
+ reason: REASON.ID_MISMATCH,
888
+ line: null,
889
+ expected: a.fileId,
890
+ actual: a.declaredId,
891
+ });
292
892
  }
293
893
 
294
894
  // A Superseded ADR must point at its successor by FILE LINK.
@@ -296,13 +896,19 @@ function validate({ adrs, byFile, byId, nonConforming }) {
296
896
  const links = a.relations.supersedes.in.flatMap((r) => r.links);
297
897
  if (links.length === 0) {
298
898
  const bare = a.relations.supersedes.in.flatMap((r) => r.bare);
299
- add(
300
- a.file,
301
- bare.length
302
- ? `status is Superseded and mentions ADR-${bare.join('/')} but not as a markdown link to the file. ` +
303
- 'Bare ids are ambiguous (ADR-0010 and ADR-0011 each resolve to multiple files) — link the target file.'
304
- : 'status is Superseded but names no successor. Write `Superseded by [ADR-N](N-slug.md)`.',
305
- );
899
+ if (bare.length) {
900
+ add(
901
+ a.file,
902
+ `status is Superseded and mentions ADR-${bare.join('/')} but not as a markdown link to the file. ` +
903
+ 'Bare ids are ambiguous (ADR-0010 and ADR-0011 each resolve to multiple files) — link the target file.',
904
+ { reason: REASON.SUPERSEDED_BARE_ID, line: null, bare },
905
+ );
906
+ } else {
907
+ add(a.file, 'status is Superseded but names no successor. Write `Superseded by [ADR-N](N-slug.md)`.', {
908
+ reason: REASON.SUPERSEDED_NO_SUCCESSOR,
909
+ line: null,
910
+ });
911
+ }
306
912
  }
307
913
  }
308
914
 
@@ -311,7 +917,14 @@ function validate({ adrs, byFile, byId, nonConforming }) {
311
917
  for (const dir of ['out', 'in']) {
312
918
  for (const rel of a.relations[kind][dir]) {
313
919
  for (const l of rel.links) {
314
- if (!byFile.has(l)) add(a.file, `"${rel.field}" links "${l}", which does not exist in docs/adr/.`);
920
+ if (!byFile.has(l)) {
921
+ add(a.file, `"${rel.field}" links "${l}", which does not exist in docs/adr/.`, {
922
+ reason: REASON.RELATION_LINK_MISSING,
923
+ line: null,
924
+ field: rel.field,
925
+ target: l,
926
+ });
927
+ }
315
928
  }
316
929
  // The synthetic relation lifted out of the Status line is already covered by
317
930
  // the dedicated Superseded check above; reporting it again just duplicates.
@@ -328,13 +941,24 @@ function validate({ adrs, byFile, byId, nonConforming }) {
328
941
  if (linkedIds.has(b)) continue;
329
942
  const candidates = byId.get(b) || [];
330
943
  if (candidates.length === 0) {
331
- add(a.file, `"${rel.field}" names ADR-${b}, which does not exist in docs/adr/. If it is an ISSUE number, write "#${b}" — not "ADR-${b}".`);
944
+ add(
945
+ a.file,
946
+ `"${rel.field}" names ADR-${b}, which does not exist in docs/adr/. If it is an ISSUE number, write "#${b}" — not "ADR-${b}".`,
947
+ { reason: REASON.RELATION_BARE_ID_MISSING, line: null, field: rel.field, target: b },
948
+ );
332
949
  } else {
333
950
  add(
334
951
  a.file,
335
952
  `"${rel.field}" names ADR-${b} without a file link` +
336
953
  (candidates.length > 1 ? ` (ambiguous — resolves to ${candidates.length} files: ${candidates.map((c) => c.file).join(', ')})` : '') +
337
954
  '. Link the target file so the relation is checkable.',
955
+ {
956
+ reason: REASON.RELATION_BARE_ID_UNLINKED,
957
+ line: null,
958
+ field: rel.field,
959
+ target: b,
960
+ candidates: candidates.map((c) => c.file),
961
+ },
338
962
  );
339
963
  }
340
964
  }
@@ -376,13 +1000,19 @@ function validate({ adrs, byFile, byId, nonConforming }) {
376
1000
  target,
377
1001
  `${a.file} declares ${claim}, but this ADR does not record it. ` +
378
1002
  `Add \`- **${needed}:** [ADR-${a.displayId}](${a.file})\` so a reader of THIS file learns the decision moved on.`,
1003
+ { reason: REASON.RELATION_ASYMMETRIC, line: null, source: a.file, kind, neededField: needed },
379
1004
  );
380
1005
  }
381
1006
  }
382
1007
  }
383
1008
  }
384
1009
 
385
- return errors;
1010
+ // Link resolution reads the directory directly rather than the parsed
1011
+ // `adrs` list — it must ALSO cover README.md and naming-violation files,
1012
+ // neither of which is in `adrs` (see `validateLinks`'s own doc comment).
1013
+ validateLinks(add);
1014
+
1015
+ return { errors, violations };
386
1016
  }
387
1017
 
388
1018
  const GROUPS = [
@@ -442,7 +1072,17 @@ function renderIndex(corpus) {
442
1072
  const rows = corpus.adrs.filter(g.match).sort((x, y) => Number(x.fileId) - Number(y.fileId));
443
1073
  if (rows.length === 0) continue;
444
1074
 
445
- out.push(`### ${g.heading} (${rows.length})`, '', g.blurb, '');
1075
+ // No row count in the heading: it is a numeric cell inside the generated
1076
+ // region, shared by every ADR-adding PR. Two PRs that add different ADRs
1077
+ // touch different table rows and merge cleanly — but both rewrite this
1078
+ // same count line, so whichever lands second gets a stale local --check
1079
+ // pass and a red CI --check against the merged tree (#3251). Same failure
1080
+ // mode CHANGELOG.md and drift-acks already solved by moving to per-PR
1081
+ // fragment files (.changeset/, tests/emitted-drift-acks/); here the fix is
1082
+ // simpler still — the count carries no verification value (--check
1083
+ // regenerates and diffs the whole region regardless) and is trivially
1084
+ // derivable by counting the table rows. Do not add it back.
1085
+ out.push(`### ${g.heading}`, '', g.blurb, '');
446
1086
  const isHistorical = g.heading.startsWith('Superseded');
447
1087
  // "Read first" points at the broader ADR that now frames this one. It is how a
448
1088
  // reader of a still-Accepted component decision (e.g. the runtime descriptor)
@@ -464,8 +1104,12 @@ function renderIndex(corpus) {
464
1104
  out.push('');
465
1105
  }
466
1106
 
1107
+ // No total ADR count either, for the same reason as the per-group heading
1108
+ // count above: it is a second shared mutable cell in the generated region
1109
+ // that every ADR-adding PR would rewrite, guaranteeing the identical merge
1110
+ // race (#3251). Leave it out; the count is derivable by reading the table.
467
1111
  out.push(
468
- `_${corpus.adrs.length} ADRs. Generated by \`scripts/gen-adr-index.cjs\` — run \`--write\` after adding or restatusing an ADR._`,
1112
+ `_Generated by \`scripts/gen-adr-index.cjs\` — run \`--write\` after adding or restatusing an ADR._`,
469
1113
  '',
470
1114
  END_MARKER,
471
1115
  );
@@ -484,13 +1128,52 @@ function spliceIntoReadme(readme, index) {
484
1128
  return readme.slice(0, start) + index + readme.slice(end + END_MARKER.length);
485
1129
  }
486
1130
 
1131
+ /**
1132
+ * Parse CLI flags from `argv` (already sliced to just the flags, i.e.
1133
+ * `process.argv.slice(2)`). Supports `--write`, `--check`, `--json` in any
1134
+ * order. FAIL-CLOSED on an unrecognized flag: silently falling through to
1135
+ * the no-flags "print the index" behavior would mask a typo (e.g.
1136
+ * `--jsno`) as a clean run instead of failing loudly, so any argument that
1137
+ * is not one of the three recognized flags throws `ExitError(1, …)` naming
1138
+ * the offender rather than being ignored.
1139
+ */
1140
+ function parseArgs(argv) {
1141
+ const opts = { write: false, check: false, json: false };
1142
+ for (const arg of argv) {
1143
+ if (arg === '--write') opts.write = true;
1144
+ else if (arg === '--check') opts.check = true;
1145
+ else if (arg === '--json') opts.json = true;
1146
+ else throw new ExitError(1, `unknown flag: ${arg}\nRecognized flags: --write, --check, --json.`);
1147
+ }
1148
+ return opts;
1149
+ }
1150
+
487
1151
  function main() {
488
- const [, , flag] = process.argv;
1152
+ const { write, check, json } = parseArgs(process.argv.slice(2));
489
1153
 
490
1154
  const corpus = buildCorpus();
491
- const errors = validate(corpus);
1155
+ const { errors, violations } = validate(corpus);
1156
+ const index = renderIndex(corpus);
1157
+
1158
+ if (json) {
1159
+ // `--json` implies `--check` semantics (`--check --json` is identical to
1160
+ // `--json` alone) but emits a single JSON document to stdout instead of
1161
+ // the human stderr report, and writes nothing to stderr at all. Unlike
1162
+ // the human `--check` path below — which short-circuits on lifecycle
1163
+ // violations and never even reads README.md to check staleness — the
1164
+ // JSON report always computes BOTH facts (`violations` and
1165
+ // `indexStale`) independently, since a consumer parsing the document
1166
+ // needs the complete picture in one shot rather than one violation
1167
+ // class masking the other.
1168
+ const readme = fs.readFileSync(README_PATH, 'utf8');
1169
+ const expected = spliceIntoReadme(readme, index);
1170
+ const indexStale = expected !== readme;
1171
+ const ok = violations.length === 0 && !indexStale;
1172
+ process.stdout.write(JSON.stringify({ ok, adrCount: corpus.adrs.length, indexStale, violations }) + '\n');
1173
+ return ok ? 0 : 1;
1174
+ }
492
1175
 
493
- if (errors.length > 0 && flag !== '--write') {
1176
+ if (errors.length > 0 && !write) {
494
1177
  process.stderr.write(
495
1178
  `docs/adr/ has ${errors.length} lifecycle violation(s).\n` +
496
1179
  'See docs/adr/README.md "Lifecycle rules" for the contract.\n\n',
@@ -500,9 +1183,19 @@ function main() {
500
1183
  throw new ExitError(1);
501
1184
  }
502
1185
 
503
- const index = renderIndex(corpus);
504
-
505
- if (flag === '--check') {
1186
+ // `--write` takes precedence over a co-supplied `--check`: neither
1187
+ // combination is part of this CLI's documented contract (the flags exist
1188
+ // to be used one at a time, or as `--check --json`), so this is an
1189
+ // arbitrary-but-safe tiebreak rather than a specified behavior.
1190
+ if (write) {
1191
+ const readme = fs.readFileSync(README_PATH, 'utf8');
1192
+ fs.writeFileSync(README_PATH, spliceIntoReadme(readme, index));
1193
+ process.stdout.write(`Wrote ADR index into ${README_PATH} (${corpus.adrs.length} ADRs).\n`);
1194
+ if (errors.length > 0) {
1195
+ process.stderr.write(`\n${errors.length} lifecycle violation(s) remain — --check will fail:\n\n`);
1196
+ for (const e of errors) process.stderr.write(` ✗ ${e}\n`);
1197
+ }
1198
+ } else if (check) {
506
1199
  const readme = fs.readFileSync(README_PATH, 'utf8');
507
1200
  const expected = spliceIntoReadme(readme, index);
508
1201
  if (expected !== readme) {
@@ -512,17 +1205,14 @@ function main() {
512
1205
  throw new ExitError(1);
513
1206
  }
514
1207
  process.stdout.write(`docs/adr/README.md index is up to date (${corpus.adrs.length} ADRs).\n`);
515
- } else if (flag === '--write') {
516
- const readme = fs.readFileSync(README_PATH, 'utf8');
517
- fs.writeFileSync(README_PATH, spliceIntoReadme(readme, index));
518
- process.stdout.write(`Wrote ADR index into ${README_PATH} (${corpus.adrs.length} ADRs).\n`);
519
- if (errors.length > 0) {
520
- process.stderr.write(`\n${errors.length} lifecycle violation(s) remain — --check will fail:\n\n`);
521
- for (const e of errors) process.stderr.write(` ✗ ${e}\n`);
522
- }
523
1208
  } else {
524
1209
  process.stdout.write(index + '\n');
525
1210
  }
526
1211
  }
527
1212
 
528
- runMain(main);
1213
+ // Guarded: `require`-ing this module (the test suite imports STATUSES and
1214
+ // the pure scanner directly) must not also run the generator as a side
1215
+ // effect of loading it.
1216
+ if (require.main === module) runMain(main);
1217
+
1218
+ module.exports = { STATUSES, REASON, extractLinks, maskCode };