@opengsd/gsd-core 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-debug-session-manager.md +11 -0
  4. package/agents/gsd-doc-synthesizer.md +2 -4
  5. package/agents/gsd-executor.md +5 -5
  6. package/agents/gsd-mempalace-curator.md +5 -2
  7. package/agents/gsd-phase-researcher.md +20 -1
  8. package/agents/gsd-plan-checker.md +37 -0
  9. package/agents/gsd-planner.md +44 -46
  10. package/agents/gsd-user-profiler.md +3 -0
  11. package/agents/gsd-verifier.md +12 -3
  12. package/bin/install.js +841 -971
  13. package/bin/lib/ui-safety-gate.cjs +2 -0
  14. package/commands/gsd/code-review.md +1 -1
  15. package/commands/gsd/execute-phase.md +1 -1
  16. package/commands/gsd/map-codebase.md +1 -1
  17. package/commands/gsd/mempalace-capture.md +1 -1
  18. package/commands/gsd/mempalace-recall.md +1 -1
  19. package/commands/gsd/new-milestone.md +1 -1
  20. package/commands/gsd/quick.md +1 -1
  21. package/commands/gsd/review-backlog.md +2 -1
  22. package/commands/gsd/verify-work.md +1 -1
  23. package/gsd-core/bin/gsd-tools.cjs +469 -88
  24. package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
  25. package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
  26. package/gsd-core/bin/lib/api-coverage.cjs +3 -5
  27. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  28. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  29. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  30. package/gsd-core/bin/lib/audit.cjs +876 -240
  31. package/gsd-core/bin/lib/broken-windows.cjs +1 -1
  32. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  33. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +575 -101
  35. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  36. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  37. package/gsd-core/bin/lib/capability-validator.cjs +495 -22
  38. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  39. package/gsd-core/bin/lib/check-command-router.cjs +71 -37
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  41. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  43. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  44. package/gsd-core/bin/lib/commands.cjs +651 -86
  45. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  47. package/gsd-core/bin/lib/config-loader.cjs +75 -0
  48. package/gsd-core/bin/lib/config.cjs +10 -1
  49. package/gsd-core/bin/lib/core-utils.cjs +127 -29
  50. package/gsd-core/bin/lib/decisions.cjs +23 -0
  51. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  52. package/gsd-core/bin/lib/frontmatter.cjs +155 -20
  53. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  54. package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
  55. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  56. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  57. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  58. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  59. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  60. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  61. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  62. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  63. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  64. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  65. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  66. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  67. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  68. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  69. package/gsd-core/bin/lib/init.cjs +321 -129
  70. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  71. package/gsd-core/bin/lib/install-engine.cjs +745 -258
  72. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  73. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  74. package/gsd-core/bin/lib/install-profiles.cjs +134 -57
  75. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  76. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  77. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  78. package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
  79. package/gsd-core/bin/lib/io.cjs +10 -0
  80. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  81. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  82. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  83. package/gsd-core/bin/lib/milestone.cjs +754 -70
  84. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  85. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  86. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  87. package/gsd-core/bin/lib/pattern.cjs +122 -0
  88. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +444 -36
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  91. package/gsd-core/bin/lib/phase-locator.cjs +125 -18
  92. package/gsd-core/bin/lib/phase.cjs +646 -143
  93. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  94. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  95. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  96. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  97. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  98. package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
  99. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  100. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  101. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  102. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  103. package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
  104. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  105. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  106. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  107. package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
  108. package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
  109. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  110. package/gsd-core/bin/lib/roadmap.cjs +385 -94
  111. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
  112. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  113. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
  114. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  115. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  116. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
  117. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  118. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  119. package/gsd-core/bin/lib/security.cjs +104 -5
  120. package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
  121. package/gsd-core/bin/lib/smart-entry.cjs +142 -22
  122. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  123. package/gsd-core/bin/lib/state-document.cjs +152 -8
  124. package/gsd-core/bin/lib/state-transition.cjs +371 -117
  125. package/gsd-core/bin/lib/state.cjs +1794 -357
  126. package/gsd-core/bin/lib/surface.cjs +23 -9
  127. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  128. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  129. package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
  130. package/gsd-core/bin/lib/uat.cjs +399 -56
  131. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  132. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  133. package/gsd-core/bin/lib/unusable-input.cjs +24 -0
  134. package/gsd-core/bin/lib/update-context.cjs +8 -2
  135. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  136. package/gsd-core/bin/lib/validate.cjs +20 -6
  137. package/gsd-core/bin/lib/vendor/README.md +37 -0
  138. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  139. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  140. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  141. package/gsd-core/bin/lib/verification.cjs +258 -8
  142. package/gsd-core/bin/lib/verify.cjs +368 -888
  143. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  144. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  145. package/gsd-core/bin/lib/workstream.cjs +2 -2
  146. package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
  147. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  148. package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
  149. package/gsd-core/references/agent-contracts.md +43 -26
  150. package/gsd-core/references/checkpoints.md +2 -2
  151. package/gsd-core/references/context-budget.md +1 -1
  152. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  153. package/gsd-core/references/doc-conflict-engine.md +1 -1
  154. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  155. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  156. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  157. package/gsd-core/references/execute-phase-response-language.md +1 -1
  158. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  159. package/gsd-core/references/gate-prompts.md +1 -1
  160. package/gsd-core/references/git-planning-commit.md +2 -1
  161. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  162. package/gsd-core/references/model-profiles.md +12 -4
  163. package/gsd-core/references/mvp-concepts.md +9 -9
  164. package/gsd-core/references/planner-guidance.md +3 -9
  165. package/gsd-core/references/planner-preconditions.md +1 -1
  166. package/gsd-core/references/planner-reviews.md +1 -1
  167. package/gsd-core/references/planning-config.md +8 -6
  168. package/gsd-core/references/revision-loop.md +1 -1
  169. package/gsd-core/references/specless-probe-fallback.md +1 -1
  170. package/gsd-core/references/universal-anti-patterns.md +3 -3
  171. package/gsd-core/references/verifier-phase-gates.md +192 -0
  172. package/gsd-core/references/verify-mvp-mode.md +1 -1
  173. package/gsd-core/references/workstream-flag.md +22 -6
  174. package/gsd-core/templates/discussion-log.md +1 -1
  175. package/gsd-core/templates/phase-prompt.md +2 -4
  176. package/gsd-core/templates/state.md +4 -4
  177. package/gsd-core/templates/verification-report.md +9 -1
  178. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  179. package/gsd-core/workflows/autonomous.md +1 -1
  180. package/gsd-core/workflows/cleanup.md +62 -3
  181. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
  182. package/gsd-core/workflows/code-review-fix.md +37 -10
  183. package/gsd-core/workflows/code-review.md +38 -12
  184. package/gsd-core/workflows/complete-milestone.md +141 -18
  185. package/gsd-core/workflows/debug.md +7 -5
  186. package/gsd-core/workflows/diagnose-issues.md +35 -9
  187. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  188. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  189. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
  190. package/gsd-core/workflows/edit-phase.md +26 -1
  191. package/gsd-core/workflows/eval-review.md +3 -5
  192. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
  193. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  194. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
  195. package/gsd-core/workflows/execute-phase.md +38 -50
  196. package/gsd-core/workflows/execute-plan.md +36 -4
  197. package/gsd-core/workflows/explore.md +131 -4
  198. package/gsd-core/workflows/fast.md +10 -2
  199. package/gsd-core/workflows/health.md +73 -4
  200. package/gsd-core/workflows/import.md +4 -4
  201. package/gsd-core/workflows/ingest-docs.md +5 -5
  202. package/gsd-core/workflows/mvp-phase.md +6 -3
  203. package/gsd-core/workflows/new-milestone.md +14 -9
  204. package/gsd-core/workflows/new-project.md +14 -14
  205. package/gsd-core/workflows/next.md +12 -0
  206. package/gsd-core/workflows/plan-phase.md +41 -17
  207. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  208. package/gsd-core/workflows/progress.md +34 -6
  209. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
  210. package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
  211. package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
  212. package/gsd-core/workflows/quick.md +35 -15
  213. package/gsd-core/workflows/review.md +26 -5
  214. package/gsd-core/workflows/secure-phase.md +1 -1
  215. package/gsd-core/workflows/session-report.md +2 -1
  216. package/gsd-core/workflows/settings.md +66 -2
  217. package/gsd-core/workflows/ship.md +104 -44
  218. package/gsd-core/workflows/spec-phase.md +30 -12
  219. package/gsd-core/workflows/sync-skills.md +63 -8
  220. package/gsd-core/workflows/transition.md +46 -11
  221. package/gsd-core/workflows/ui-phase.md +5 -5
  222. package/gsd-core/workflows/ui-review.md +2 -2
  223. package/gsd-core/workflows/update.md +1 -1
  224. package/gsd-core/workflows/validate-phase.md +1 -1
  225. package/gsd-core/workflows/verify-work.md +9 -7
  226. package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
  227. package/hooks/dist/gsd-check-update-worker.js +56 -13
  228. package/hooks/dist/gsd-check-update.js +19 -1
  229. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  230. package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
  231. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  232. package/hooks/dist/gsd-prompt-guard.js +21 -20
  233. package/hooks/dist/gsd-read-injection-scanner.js +38 -24
  234. package/hooks/dist/gsd-statusline.js +18 -0
  235. package/hooks/dist/gsd-update-banner.js +22 -1
  236. package/hooks/dist/gsd-workflow-guard.js +134 -36
  237. package/hooks/dist/lib/git-cmd.js +92 -59
  238. package/hooks/dist/lib/injection-patterns.js +45 -0
  239. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  240. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  241. package/hooks/gsd-agent-isolation-guard.js +103 -14
  242. package/hooks/gsd-check-update-worker.js +56 -13
  243. package/hooks/gsd-check-update.js +19 -1
  244. package/hooks/gsd-cursor-pre-tool.js +0 -3
  245. package/hooks/gsd-cursor-subagent-start.js +77 -2
  246. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  247. package/hooks/gsd-prompt-guard.js +21 -20
  248. package/hooks/gsd-read-injection-scanner.js +38 -24
  249. package/hooks/gsd-statusline.js +18 -0
  250. package/hooks/gsd-update-banner.js +22 -1
  251. package/hooks/gsd-workflow-guard.js +134 -36
  252. package/hooks/lib/git-cmd.js +92 -59
  253. package/hooks/lib/injection-patterns.js +45 -0
  254. package/hooks/lib/isolation-deny-reason.js +39 -0
  255. package/hooks/lib/isolation-sentinel.js +9 -0
  256. package/package.json +21 -9
  257. package/pi/gsd.cjs +19 -5
  258. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  259. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  260. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  261. package/scripts/changeset/lint.cjs +60 -5
  262. package/scripts/check-alias-drift.cjs +7 -43
  263. package/scripts/check-contract-drift.cjs +297 -0
  264. package/scripts/ci-test-scope.cjs +19 -2
  265. package/scripts/command-contract-helpers.cjs +903 -1
  266. package/scripts/gen-adr-index.cjs +728 -38
  267. package/scripts/gen-capability-registry.cjs +3 -15
  268. package/scripts/gen-context-index.cjs +2 -11
  269. package/scripts/gen-health-docs.cjs +390 -0
  270. package/scripts/gen-inventory-manifest.cjs +50 -4
  271. package/scripts/gen-loop-host-contract.cjs +4 -24
  272. package/scripts/gen-registry.cjs +3 -14
  273. package/scripts/lib/alias-drift-families.cjs +46 -0
  274. package/scripts/lib/drift-scan.cjs +278 -0
  275. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  276. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  277. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  278. package/scripts/lint-canary-version-leak.cjs +73 -0
  279. package/scripts/lint-command-contract.cjs +96 -13
  280. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  281. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  282. package/scripts/lint-default-flip-documentation.cjs +193 -0
  283. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  284. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  285. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  286. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  287. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  288. package/scripts/lint-milestone-window-drift.cjs +468 -0
  289. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  290. package/scripts/lint-plan-count-drift.cjs +318 -0
  291. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  292. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  293. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  294. package/scripts/lint-regression-test-names.cjs +15 -13
  295. package/scripts/lint-removed-but-needed.cjs +320 -0
  296. package/scripts/lint-state-field-drift.cjs +805 -0
  297. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  298. package/scripts/lint-test-file-count.allowlist.json +21 -10
  299. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  300. package/scripts/lint-vendored-deps.cjs +124 -0
  301. package/scripts/pr-changed-files.cjs +63 -0
  302. package/scripts/pr-template-policy.cjs +14 -4
  303. package/scripts/prompt-injection-scan.sh +25 -0
  304. package/scripts/require-issue-link-policy.cjs +192 -0
  305. package/scripts/state-write-path-drift-baseline.json +19 -0
  306. package/scripts/sync-runtime-launcher.cjs +2 -4
  307. package/skills/gsd-autonomous/SKILL.md +0 -1
  308. package/skills/gsd-code-review/SKILL.md +1 -1
  309. package/skills/gsd-execute-phase/SKILL.md +1 -2
  310. package/skills/gsd-map-codebase/SKILL.md +1 -1
  311. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  312. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  313. package/skills/gsd-new-milestone/SKILL.md +1 -1
  314. package/skills/gsd-next/SKILL.md +0 -1
  315. package/skills/gsd-plan-phase/SKILL.md +0 -1
  316. package/skills/gsd-progress/SKILL.md +0 -1
  317. package/skills/gsd-quick/SKILL.md +1 -1
  318. package/skills/gsd-review-backlog/SKILL.md +2 -1
  319. package/skills/gsd-stats/SKILL.md +0 -1
  320. package/skills/gsd-verify-work/SKILL.md +1 -1
  321. package/vscode/package.json +1 -1
  322. package/gsd-core/workflows/discovery-phase.md +0 -298
  323. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  324. package/gsd-core/workflows/verify-phase.md +0 -574
  325. package/scripts/affected-tests-lib.cjs +0 -554
  326. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  327. package/scripts/run-affected-tests.cjs +0 -7
  328. package/scripts/run-tests.cjs +0 -1051
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * lint-eslint-glob-coverage.cjs — ESLint `files:` glob coverage drift guard (#3059).
6
+ *
7
+ * ## What this enforces
8
+ *
9
+ * `eslint.config.mjs` is a flat config: a tracked source file is only linted
10
+ * if it matches at least one config object's `files:` glob (or a global,
11
+ * files-less block). A file matching NO glob resolves to zero reachable
12
+ * rules and `eslint .` silently exits 0 on it — the #3059 defect class (62
13
+ * tracked source files found with this shape). This guard walks every
14
+ * tracked `.cjs/.cts/.js/.mjs` source file, resolves its real ESLint config,
15
+ * and fails when a file resolves to zero rules and isn't a deliberately
16
+ * exempted, reasoned allowlist entry.
17
+ *
18
+ * This checks rule REACHABILITY (does at least one rule apply to this file
19
+ * at all, any severity), not rule SEVERITY (whether an applicable rule is
20
+ * `error` vs `warn` vs `off`) — severity coverage is a separate gate, #1885
21
+ * F17. A file with one `off` rule reachable still counts as "covered" here:
22
+ * it proves the file was deliberately targeted by a `files:` glob, which is
23
+ * the thing #3059 is about.
24
+ *
25
+ * ## Goodhart rationale
26
+ *
27
+ * A coverage metric is trivially gameable by padding the allowlist instead
28
+ * of fixing the glob, so every knob here is built to resist that:
29
+ * - Every allowlist entry MUST carry a non-empty `reason` — an unreasoned
30
+ * entry is indistinguishable from "quietly made the metric look better".
31
+ * - The allowlist only ratchets DOWN: an entry whose path now resolves to
32
+ * >=1 rule (`allowlist_stale`) is a failure, forcing prompt removal
33
+ * rather than letting stale exemptions accumulate as free cover for
34
+ * future accidental escapes at the same path.
35
+ * - A tracked-file-count floor (`tracked_count_below_floor`) exists so a
36
+ * broken or empty `git ls-files` (e.g. wrong cwd, detached worktree)
37
+ * can't report a vacuous "0 escapes out of 0 checked" clean run.
38
+ *
39
+ * ## Ignored vs. unmatched (the discrimination this script makes)
40
+ *
41
+ * ESLint's `ignores:` blocks are the one legitimate "this file is not meant
42
+ * to be linted" decision (e.g. the ADR-457 tsc-emitted `.cjs` artifacts
43
+ * under `gsd-core/bin/lib/`) and must NOT be reported as uncovered. But
44
+ * `ESLint#isPathIgnored` cannot be trusted uniformly across extensions:
45
+ * for ESLint's default-lintable extensions (`.js`/`.mjs`/`.cjs`), it
46
+ * reports `true` only when the path matches an explicit `ignores:` glob —
47
+ * verified empirically: an unmatched top-level `.cjs` probe file reports
48
+ * `isPathIgnored() === false` with an empty resolved rule set, not `true`.
49
+ * For `.cts` (and any other non-default extension), flat config requires an
50
+ * EXPLICIT `files:` glob match to be linted at all; a `.cts` file matching
51
+ * no `files:` glob ALSO reports `isPathIgnored() === true` — indistinguishable,
52
+ * via this API, from a genuine `ignores:` entry (verified with a scratch
53
+ * `.cts` fixture under `tests/fixtures/`). This repo's `ignores:` list never
54
+ * contains a `.cts` path (ADR-457 retires only the emitted `.cjs`; the
55
+ * `.cts` source is always meant to stay linted), so for `.cts` specifically
56
+ * an `isPathIgnored() === true` verdict can only mean "matches no `files:`
57
+ * glob" and is therefore treated as UNCOVERED, not ignored. See
58
+ * `resolveFileCoverage` below.
59
+ *
60
+ * ## Why `bin/install.js` is NOT in the allowlist
61
+ *
62
+ * The allowlist below is exclusively for files that resolve to ZERO rules —
63
+ * it is a registry of accepted escapes, not a general-purpose "reasons for
64
+ * how a file is configured" log. The `bin/install.js` / `bin/gsd-mcp-server.js`
65
+ * / `scripts/build-hooks.js` family is deliberately covered by a minimal,
66
+ * 2-rule block in `eslint.config.mjs` per ADR-1703 (targeting only the
67
+ * portability defect surface, not a full style sweep of ~12k lines of
68
+ * generated code) — see the comment at that block in `eslint.config.mjs`.
69
+ * Because 2 rules is non-empty, that family already passes this guard
70
+ * without needing an allowlist entry, and adding one anyway would itself be
71
+ * flagged `allowlist_stale` (see the ratchet above). The allowlist exemption
72
+ * surface deliberately cannot be used to re-state a decision that is already
73
+ * recorded in the config; #3059 is the guard, ADR-1703 is the decision.
74
+ */
75
+
76
+ const fs = require('fs');
77
+ const path = require('path');
78
+
79
+ const ROOT = path.join(__dirname, '..');
80
+ const ALLOWLIST_PATH = path.join(__dirname, 'lint-eslint-glob-coverage.allowlist.json');
81
+
82
+ const SOURCE_EXT_RE = /\.(cjs|cts|js|mjs)$/;
83
+
84
+ // ESLint's flat-config default-lintable extensions: a file with one of these
85
+ // extensions that matches no `files:` glob still reports `isPathIgnored()
86
+ // === false` (with an empty resolved rule set) — so for these extensions,
87
+ // `isPathIgnored() === true` reliably means an explicit `ignores:` match.
88
+ const DEFAULT_LINTABLE_EXT_RE = /\.(js|mjs|cjs)$/;
89
+
90
+ const MIN_TRACKED_SOURCE_FILES = 500;
91
+
92
+ /**
93
+ * Runs `git ls-files` and returns the tracked source files (repo-relative,
94
+ * POSIX-normalized, filtered to SOURCE_EXT_RE). Never throws: a git failure
95
+ * (non-zero exit or timeout) produces a degraded `{ ok: false }` result so
96
+ * callers can turn it into a `git_failed` violation instead of crashing.
97
+ *
98
+ * @param {object} [opts]
99
+ * @param {Function} [opts.execFile] - injectable sync exec function with the
100
+ * `execFileSync(cmd, args, options)` signature; defaults to
101
+ * `child_process.execFileSync`.
102
+ */
103
+ function listTrackedSourceFiles({ execFile } = {}) {
104
+ const run = execFile || require('child_process').execFileSync;
105
+ let stdout;
106
+ try {
107
+ // The test runner and CI execute inside a container where this repo is
108
+ // owned by a different UID than the running user; bare `git ls-files`
109
+ // then refuses with "detected dubious ownership" unless the path is in
110
+ // `safe.directory`, and this guard degrades to a `git_failed` violation
111
+ // — exactly where it must run to be a useful gate (#3059). `-c
112
+ // safe.directory=*` scopes the override to THIS invocation only (it
113
+ // never writes to any config file, global or local), and the wildcard
114
+ // is fine here because this command only ever enumerates tracked paths
115
+ // in the repo the guard is already executing inside.
116
+ stdout = run('git', ['-c', 'safe.directory=*', 'ls-files'], {
117
+ cwd: ROOT,
118
+ encoding: 'utf8',
119
+ // 30s — `git ls-files` on this tree returns ~1175 paths in <1s; 30s is
120
+ // the CLAUDE.md git ceiling, generous for a cold index.
121
+ timeout: 30000,
122
+ });
123
+ } catch (err) {
124
+ return { ok: false, files: [], error: (err && err.message) || String(err) };
125
+ }
126
+
127
+ const files = String(stdout)
128
+ // CRLF-safe: `git ls-files` output may carry \r\n on a Windows checkout
129
+ // or a git config with core.autocrlf set (DEFECT.CRLF class).
130
+ .split(/\r?\n/)
131
+ .filter((line) => line.length > 0)
132
+ // Unconditional backslash normalization (never path.sep-gated): the
133
+ // guard's own process may run on any platform regardless of what
134
+ // produced the tracked-file listing.
135
+ .map((line) => line.replace(/\\/g, '/'))
136
+ .filter((line) => SOURCE_EXT_RE.test(line));
137
+
138
+ return { ok: true, files };
139
+ }
140
+
141
+ /** Loads and parses the allowlist JSON file. */
142
+ function loadAllowlist() {
143
+ const raw = fs.readFileSync(ALLOWLIST_PATH, 'utf8');
144
+ return JSON.parse(raw);
145
+ }
146
+
147
+ /**
148
+ * Resolves the ESLint coverage verdict for a single tracked file. See the
149
+ * "Ignored vs. unmatched" header section for the extension-dependent
150
+ * discrimination rationale.
151
+ *
152
+ * @param {import('eslint').ESLint} eslint
153
+ * @param {string} relPath - repo-relative POSIX path
154
+ * @returns {Promise<{ ignored: boolean, ruleCount: number }>}
155
+ */
156
+ async function resolveFileCoverage(eslint, relPath) {
157
+ const absPath = path.join(ROOT, relPath);
158
+ const ignored = await eslint.isPathIgnored(absPath);
159
+
160
+ if (ignored) {
161
+ if (DEFAULT_LINTABLE_EXT_RE.test(relPath)) {
162
+ // .js/.mjs/.cjs: isPathIgnored() only reports true for an explicit
163
+ // `ignores:` glob match — a recorded decision.
164
+ return { ignored: true, ruleCount: 0 };
165
+ }
166
+ // .cts (or any non-default extension): isPathIgnored() can't
167
+ // distinguish "explicit ignores: match" from "no files: glob matched
168
+ // it at all" — and this repo's ignores: list never targets .cts, so
169
+ // treat it as the latter (uncovered), never as ignored.
170
+ return { ignored: false, ruleCount: 0 };
171
+ }
172
+
173
+ const config = await eslint.calculateConfigForFile(absPath);
174
+ const ruleCount = config && config.rules ? Object.keys(config.rules).length : 0;
175
+ return { ignored: false, ruleCount };
176
+ }
177
+
178
+ /** Builds the default real-ESLint-backed resolveConfig function. */
179
+ function createDefaultResolveConfig() {
180
+ const { ESLint } = require('eslint');
181
+ const eslint = new ESLint({ cwd: ROOT });
182
+ return (relPath) => resolveFileCoverage(eslint, relPath);
183
+ }
184
+
185
+ /**
186
+ * The pure coverage predicate. All I/O is injectable via `deps` so tests
187
+ * never need a temp repo, a real subprocess, or a real ESLint instance:
188
+ *
189
+ * @param {object} [deps]
190
+ * @param {string[] | { ok: boolean, files?: string[], error?: string }} [deps.trackedFiles]
191
+ * Either a plain array of tracked source paths (success shorthand), or a
192
+ * `listTrackedSourceFiles()`-shaped result object (so a git failure can be
193
+ * injected directly). Defaults to a real `listTrackedSourceFiles()` call.
194
+ * @param {(relPath: string) => Promise<{ignored:boolean,ruleCount:number}> | {ignored:boolean,ruleCount:number}} [deps.resolveConfig]
195
+ * Per-file coverage resolver. Defaults to a real ESLint instance.
196
+ * @param {Array<{path:string,reason?:string}>} [deps.allowlist] - defaults to
197
+ * the real `loadAllowlist()`.
198
+ * @param {number} [deps.minTrackedFiles] - defaults to MIN_TRACKED_SOURCE_FILES.
199
+ * @returns {Promise<{ ok: boolean, escapes: Array<{path:string}>, violations: Array<{kind:string,path:string|null,detail:string}>, checked: number }>}
200
+ */
201
+ async function checkGlobCoverage(deps = {}) {
202
+ const minTrackedFiles =
203
+ typeof deps.minTrackedFiles === 'number' ? deps.minTrackedFiles : MIN_TRACKED_SOURCE_FILES;
204
+
205
+ const violations = [];
206
+
207
+ let trackedResult;
208
+ if (deps.trackedFiles === undefined) {
209
+ trackedResult = listTrackedSourceFiles();
210
+ } else if (Array.isArray(deps.trackedFiles)) {
211
+ trackedResult = { ok: true, files: deps.trackedFiles };
212
+ } else {
213
+ trackedResult = deps.trackedFiles;
214
+ }
215
+
216
+ if (!trackedResult.ok) {
217
+ violations.push({
218
+ kind: 'git_failed',
219
+ path: null,
220
+ detail: trackedResult.error || 'listTrackedSourceFiles() failed',
221
+ });
222
+ return { ok: false, escapes: [], violations, checked: 0 };
223
+ }
224
+
225
+ const trackedFiles = trackedResult.files;
226
+
227
+ if (trackedFiles.length < minTrackedFiles) {
228
+ violations.push({
229
+ kind: 'tracked_count_below_floor',
230
+ path: null,
231
+ detail: `tracked source file count ${trackedFiles.length} is below the floor of ${minTrackedFiles} — a broken or empty git ls-files must never report a vacuous clean run`,
232
+ });
233
+ }
234
+
235
+ const trackedSet = new Set(trackedFiles);
236
+ const allowlist = deps.allowlist === undefined ? loadAllowlist() : deps.allowlist;
237
+
238
+ const seenAllowlistPaths = new Set();
239
+ const allowlistPathSet = new Set();
240
+ for (const entry of allowlist) {
241
+ const entryPath = entry && entry.path;
242
+
243
+ if (seenAllowlistPaths.has(entryPath)) {
244
+ violations.push({
245
+ kind: 'allowlist_duplicate',
246
+ path: entryPath,
247
+ detail: 'path appears more than once in the allowlist',
248
+ });
249
+ } else {
250
+ seenAllowlistPaths.add(entryPath);
251
+ }
252
+ allowlistPathSet.add(entryPath);
253
+
254
+ if (!Object.prototype.hasOwnProperty.call(entry, 'reason')) {
255
+ violations.push({
256
+ kind: 'allowlist_missing_reason',
257
+ path: entryPath,
258
+ detail: 'allowlist entry is missing a "reason" key',
259
+ });
260
+ } else if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
261
+ violations.push({
262
+ kind: 'allowlist_empty_reason',
263
+ path: entryPath,
264
+ detail: 'allowlist entry "reason" is empty or whitespace-only',
265
+ });
266
+ }
267
+
268
+ if (!trackedSet.has(entryPath)) {
269
+ violations.push({
270
+ kind: 'allowlist_missing_path',
271
+ path: entryPath,
272
+ detail: 'allowlisted path is not a tracked source file',
273
+ });
274
+ }
275
+ }
276
+
277
+ const resolveConfig = deps.resolveConfig || createDefaultResolveConfig();
278
+
279
+ const escapes = [];
280
+ let checked = 0;
281
+
282
+ for (const file of trackedFiles) {
283
+ checked += 1;
284
+ const result = await resolveConfig(file);
285
+ const isAllowlisted = allowlistPathSet.has(file);
286
+
287
+ if (result.ignored) {
288
+ // A recorded ESLint `ignores:` decision — never an escape, regardless
289
+ // of allowlist membership.
290
+ continue;
291
+ }
292
+
293
+ if (result.ruleCount === 0) {
294
+ if (isAllowlisted) continue;
295
+ escapes.push({ path: file });
296
+ violations.push({
297
+ kind: 'uncovered',
298
+ path: file,
299
+ detail: 'resolves to 0 reachable ESLint rules and is not allowlisted',
300
+ });
301
+ } else if (isAllowlisted) {
302
+ violations.push({
303
+ kind: 'allowlist_stale',
304
+ path: file,
305
+ detail: 'allowlisted path now resolves to >=1 ESLint rule — prune the entry, the allowlist only ratchets down',
306
+ });
307
+ }
308
+ }
309
+
310
+ return { ok: violations.length === 0, escapes, violations, checked };
311
+ }
312
+
313
+ if (require.main === module) {
314
+ checkGlobCoverage()
315
+ .then((result) => {
316
+ if (result.violations.length > 0) {
317
+ console.error(
318
+ `lint-eslint-glob-coverage: ${result.violations.length} violation(s) across ${result.checked} tracked source file(s) (${result.escapes.length} uncovered escape(s))`
319
+ );
320
+ for (const v of result.violations) {
321
+ console.error(` [${v.kind}] ${v.path === null ? '(n/a)' : v.path}${v.detail ? ' — ' + v.detail : ''}`);
322
+ }
323
+ process.exitCode = 1;
324
+ } else {
325
+ console.log(`ok lint-eslint-glob-coverage: ${result.checked} tracked source file(s), 0 escapes`);
326
+ }
327
+ })
328
+ .catch((err) => {
329
+ console.error(err && err.stack ? err.stack : String(err));
330
+ process.exitCode = 1;
331
+ });
332
+ }
333
+
334
+ module.exports = {
335
+ checkGlobCoverage,
336
+ listTrackedSourceFiles,
337
+ loadAllowlist,
338
+ SOURCE_EXT_RE,
339
+ MIN_TRACKED_SOURCE_FILES,
340
+ };
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * lint-frontmatter-scalar-broad-grep.cjs — DEFECT.FRONTMATTER-SCALAR-BROAD-GREP
6
+ * (CONTEXT.md).
7
+ *
8
+ * ## Why
9
+ *
10
+ * A YAML-frontmatter scalar (e.g. VERIFICATION.md `status:`) read with
11
+ * `grep "^key:"` over the WHOLE markdown report instead of the frontmatter
12
+ * block returns extra matches whenever a `key:` line also appears in the
13
+ * body (a code block, a copied artifact, an example). Piped into
14
+ * `cut`/`tr`, those extra matches concatenate into a value that matches no
15
+ * expected token, silently misrouting a valid state (#586/PR #650:
16
+ * `grep "^status:"` also matched body `status:` lines, yielding
17
+ * `passed+gaps_found+human_needed` instead of `passed` and blocking a
18
+ * passed phase).
19
+ *
20
+ * The fix-forward is to scope the grep to the leading frontmatter block and
21
+ * take only the first match:
22
+ * sed -n '/^---$/,/^---$/p' "$f" | grep -m1 "^<key>:" | cut -d: -f2 | tr -d ' '
23
+ *
24
+ * ## What this scans
25
+ *
26
+ * Every fenced ```bash / ```sh code block in `gsd-core/workflows/*.md`,
27
+ * `agents/*.md`, and `commands/**\/*.md`. Within each block, flags a
28
+ * `grep "^key:"` / `grep '^key:'` invocation that:
29
+ * - is NOT preceded (earlier in the SAME block) by a frontmatter-scoping
30
+ * idiom (`sed -n '/^---$/,/^---$/p'`, a JS `/^---\n([\s\S]*?)\n---/`
31
+ * extraction, or an equivalent range over the `---` delimiter), AND
32
+ * - does NOT carry a `-m1` (or `-m 1`) flag, and is NOT immediately piped
33
+ * into `head -1`/`head -n 1` (frontmatter always precedes the body in
34
+ * these generated reports, so `head -1` on the whole file is the same
35
+ * single-match guarantee as `-m1`), AND
36
+ * - is used for exact-token comparison: piped (same line) into
37
+ * `cut`/`tr`, or captured into a shell variable that is later compared
38
+ * via `==`/`case` elsewhere in the same block.
39
+ *
40
+ * ## False-positive risk (moderate-to-high, per audit)
41
+ *
42
+ * Some `grep "^key:"` uses are intentionally whole-body (scanning multiple
43
+ * report files at once, not one frontmatter block) and are not a bug. Add
44
+ * `# lint-allow: frontmatter-scalar-broad-grep — <reason>` on the same line
45
+ * (or the line immediately above) to suppress a specific invocation.
46
+ */
47
+
48
+ const fs = require('fs');
49
+ const path = require('path');
50
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
51
+
52
+ const ROOT = path.join(__dirname, '..');
53
+ const DEFAULT_ROOTS = ['gsd-core/workflows', 'agents', 'commands'];
54
+
55
+ const FENCE_RE = /^```(bash|sh)\s*$/;
56
+ const FENCE_END_RE = /^```\s*$/;
57
+
58
+ // A `grep "^key:"` / `grep '^key:'` invocation. Captures the key name and the
59
+ // full option string preceding the pattern (so callers can check for -m1).
60
+ const GREP_KEY_RE = /grep\s+((?:-\S+\s+)*)(["'])\^([A-Za-z_][\w-]*):\2/;
61
+
62
+ // A frontmatter-scoping idiom: a delimiter-range extraction anchored on the
63
+ // `---` frontmatter fence, opened by `^---` (sed/awk `/^---$/,/^---$/p`, or a
64
+ // JS regex like `/^---\n([\s\S]*?)\n---/`) and closed by a second `---`
65
+ // within a short window. Matches both idioms without caring which language
66
+ // wrote the delimiter.
67
+ const FRONTMATTER_SCOPE_RE = /\^---[\s\S]{0,300}?---/;
68
+
69
+ const ALLOW_RE = /#\s*lint-allow:\s*frontmatter-scalar-broad-grep/;
70
+
71
+ // `| head -1` / `| head -n 1` immediately after the grep is functionally
72
+ // equivalent to `-m1` for this check: frontmatter always precedes the body
73
+ // in these generated reports, so the first grep match is always the
74
+ // frontmatter's, and `head -1` discards every later (body) match exactly
75
+ // like `-m1` would.
76
+ function hasSingleMatchGuard(line, optionString) {
77
+ if (/(^|\s)-m\s*1(\s|$)/.test(optionString) || /(^|\s)--max-count[= ]1(\s|$)/.test(optionString)) return true;
78
+ return /\|\s*head\s+(-1|-n\s*1)\b/.test(line);
79
+ }
80
+
81
+ function isSuppressed(lines, idx) {
82
+ if (ALLOW_RE.test(lines[idx])) return true;
83
+ if (idx > 0 && ALLOW_RE.test(lines[idx - 1])) return true;
84
+ return false;
85
+ }
86
+
87
+ /**
88
+ * Extract fenced ```bash/```sh code blocks from markdown text.
89
+ * @param {string} text
90
+ * @returns {{ startLine: number, lines: string[] }[]}
91
+ */
92
+ function extractBashBlocks(text) {
93
+ const allLines = text.split(/\r?\n/);
94
+ const blocks = [];
95
+ let inBlock = false;
96
+ let blockLines = [];
97
+ let blockStart = 0;
98
+ for (let i = 0; i < allLines.length; i += 1) {
99
+ const line = allLines[i];
100
+ if (!inBlock && FENCE_RE.test(line.trim())) {
101
+ inBlock = true;
102
+ blockLines = [];
103
+ blockStart = i + 2; // first line INSIDE the block is 1-indexed i+2
104
+ continue;
105
+ }
106
+ if (inBlock && FENCE_END_RE.test(line.trim())) {
107
+ blocks.push({ startLine: blockStart, lines: blockLines });
108
+ inBlock = false;
109
+ continue;
110
+ }
111
+ if (inBlock) blockLines.push(line);
112
+ }
113
+ return blocks;
114
+ }
115
+
116
+ /**
117
+ * Pure: find every un-scoped, token-comparison `grep "^key:"` invocation in a
118
+ * single fenced bash/sh block's lines. Returns `{ line, key, snippet }[]`
119
+ * (line numbers relative to the block's startLine, already offset by caller).
120
+ * @param {string[]} lines
121
+ * @returns {{ lineIndex: number, key: string, snippet: string }[]}
122
+ */
123
+ function findBroadGrepsInBlock(lines) {
124
+ const findings = [];
125
+ // Variables assigned from a grep-key capture on this block, so a later
126
+ // `==`/`case` use of that variable (without an intervening scope/-m1) also
127
+ // counts as "used for exact-token comparison".
128
+ const capturedVars = new Set();
129
+ let scopeSeenAt = -1;
130
+
131
+ for (let i = 0; i < lines.length; i += 1) {
132
+ const line = lines[i];
133
+
134
+ if (FRONTMATTER_SCOPE_RE.test(line)) {
135
+ scopeSeenAt = i;
136
+ }
137
+
138
+ const m = line.match(GREP_KEY_RE);
139
+ if (!m) continue;
140
+ const [, options, , key] = m;
141
+ if (hasSingleMatchGuard(line, options)) continue;
142
+ if (isSuppressed(lines, i)) continue;
143
+ // Scoping must appear strictly before this grep line in the same block.
144
+ const scoped = scopeSeenAt !== -1 && scopeSeenAt <= i;
145
+ if (scoped) continue;
146
+
147
+ const pipedToTokenTool = /\|\s*(cut|tr)\b/.test(line);
148
+ const assignMatch = line.match(/^\s*(?:export\s+)?([A-Za-z_][\w]*)=\$\(/);
149
+ if (assignMatch) capturedVars.add(assignMatch[1]);
150
+
151
+ let comparedLater = false;
152
+ if (assignMatch) {
153
+ const varName = assignMatch[1];
154
+ for (let j = i + 1; j < lines.length; j += 1) {
155
+ if (
156
+ new RegExp(`\\$\\{?${varName}\\}?"?\\s*(==|!=)`).test(lines[j])
157
+ || new RegExp(`case\\s+"?\\$\\{?${varName}\\}?"?\\s+in`).test(lines[j])
158
+ ) {
159
+ comparedLater = true;
160
+ break;
161
+ }
162
+ }
163
+ }
164
+
165
+ if (pipedToTokenTool || comparedLater) {
166
+ findings.push({ lineIndex: i, key, snippet: line.trim() });
167
+ }
168
+ }
169
+ return findings;
170
+ }
171
+
172
+ function walkMarkdown(dir) {
173
+ const out = [];
174
+ let entries;
175
+ try {
176
+ entries = fs.readdirSync(dir, { withFileTypes: true });
177
+ } catch {
178
+ return out; // a missing root is not an error — some surfaces are optional
179
+ }
180
+ for (const entry of entries) {
181
+ const full = path.join(dir, entry.name);
182
+ if (entry.isDirectory()) out.push(...walkMarkdown(full));
183
+ else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Scan the given roots (repo-relative) for un-scoped frontmatter-scalar
190
+ * broad-greps.
191
+ * @param {string[]} roots
192
+ * @returns {{ file: string, line: number, key: string, snippet: string }[]}
193
+ */
194
+ function scan(roots = DEFAULT_ROOTS) {
195
+ const offenders = [];
196
+ for (const rel of roots) {
197
+ const abs = path.isAbsolute(rel) ? rel : path.join(ROOT, rel);
198
+ for (const file of walkMarkdown(abs)) {
199
+ const blocks = extractBashBlocks(fs.readFileSync(file, 'utf8'));
200
+ for (const block of blocks) {
201
+ for (const finding of findBroadGrepsInBlock(block.lines)) {
202
+ offenders.push({
203
+ file: path.relative(ROOT, file),
204
+ line: block.startLine + finding.lineIndex,
205
+ key: finding.key,
206
+ snippet: finding.snippet,
207
+ });
208
+ }
209
+ }
210
+ }
211
+ }
212
+ return offenders;
213
+ }
214
+
215
+ function main() {
216
+ const rootsEnv = process.env.GSD_LINT_FRONTMATTER_SCALAR_ROOTS;
217
+ const roots = rootsEnv ? rootsEnv.split(path.delimiter).filter(Boolean) : DEFAULT_ROOTS;
218
+ const offenders = scan(roots);
219
+ if (offenders.length > 0) {
220
+ const detail = offenders.map((o) => ` ${o.file}:${o.line} ${o.snippet}`).join('\n');
221
+ throw new ExitError(
222
+ 1,
223
+ 'lint-frontmatter-scalar-broad-grep: `grep "^key:"` over the whole file, compared to an\n'
224
+ + 'exact token, with no frontmatter scoping and no -m1 (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP).\n'
225
+ + 'A body line beginning `key:` is enough to break this. Scope to the frontmatter block:\n'
226
+ + ' sed -n \'/^---$/,/^---$/p\' "$f" | grep -m1 "^<key>:" | cut -d: -f2 | tr -d \' \'\n'
227
+ + 'or add `# lint-allow: frontmatter-scalar-broad-grep — <reason>` if this is a genuine\n'
228
+ + 'whole-body scan:\n'
229
+ + detail,
230
+ );
231
+ }
232
+ console.log(`ok lint-frontmatter-scalar-broad-grep: no un-scoped frontmatter-scalar greps in ${roots.length} root(s)`);
233
+ }
234
+
235
+ module.exports = { findBroadGrepsInBlock, extractBashBlocks, scan, DEFAULT_ROOTS };
236
+
237
+ if (require.main === module) runMain(main);