@opengsd/gsd-core 1.12.0 → 1.13.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 (286) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -0,0 +1,614 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * lint-workflow-shellcheck.cjs
6
+ *
7
+ * Systemic prevention for the zsh/bash word-splitting bug class (#4109):
8
+ * every ```bash fenced block embedded in gsd-core/workflows/*.md (and the
9
+ * nested gsd-core/workflows/<workflow>/steps/*.md / modes/*.md / etc. layer)
10
+ * is extracted and run through the real ShellCheck binary. Any finding fails
11
+ * the lint with a non-zero exit — this is what stops the SC2086-class bug
12
+ * (unquoted variable expansion, word-split/glob differently under zsh vs
13
+ * bash) from landing undetected a second time (it already landed 4 times in
14
+ * this repo's workflow templates before #4109's fix).
15
+ *
16
+ * ShellCheck source: scripts/lib/shellcheck-fetch.cjs, a small dependency-
17
+ * free downloader that fetches a PINNED koalaman/shellcheck release directly
18
+ * from GitHub releases and caches the extracted binary under
19
+ * node_modules/.cache/shellcheck/<version>/. This replaces the `shellcheck`
20
+ * npm package (gunar/shellcheck) originally used here (#4109) — removed in
21
+ * #4120 because its extraction dependency, `decompress@4.2.1`, carries an
22
+ * unpatched CRITICAL zip-slip vulnerability (GHSA-mp2f-45pm-3cg9, CVSS 9.1)
23
+ * with no patched version available upstream. See shellcheck-fetch.cjs's own
24
+ * header comment for the extraction implementation and its zip-slip defense.
25
+ *
26
+ * Extraction: reuses scanFencedBlocks from markdown-sectionizer.cts (the
27
+ * canonical fence-scanning engine — see tests/review-plan-coverage-manifest
28
+ * .test.cjs's extractAllBashBlocks for the precedent this follows) rather
29
+ * than a bespoke regex.
30
+ *
31
+ * Placeholder handling: workflow blocks reference template placeholders —
32
+ * both single-token (`{run_dir}`, `{N}`) and multi-word prose (`{discovered
33
+ * test command}`, `{each unique directory from resolved paths}`) — that are
34
+ * not valid shell and would misparse as ShellCheck syntax errors unrelated to
35
+ * the word-splitting class this lint targets. Every such placeholder (NOT
36
+ * `${identifier}`, which is a real parameter expansion, and NOT real brace
37
+ * syntax like `{1..5}`/`{a,b,c}`/`{ cmd; }` — see `substitutePlaceholders`'s
38
+ * own comment for the exact discriminating rule) is substituted with a
39
+ * shell-safe bareword before staging, generalizing the test harness's
40
+ * single-placeholder `body.split('{run_dir}').join(runDir)` substitution to
41
+ * the general case.
42
+ *
43
+ * Rule selection (documented per the brief's requirement to justify the
44
+ * include/exclude choice):
45
+ * - SC2086 (double-quote to prevent globbing/word splitting) is the exact
46
+ * bug class #4109 fixes and MUST be enabled — it is ShellCheck's default
47
+ * behavior and is never excluded here.
48
+ * - The rest of ShellCheck's DEFAULT rule set is also left enabled: most of
49
+ * it (SC2046, SC2068, SC2145, SC2206, SC2207, etc.) is the SAME
50
+ * quoting/word-splitting/array-expansion family SC2086 belongs to, and is
51
+ * exactly the kind of finding this lint exists to catch.
52
+ * - Three codes are explicitly EXCLUDED because they produce structural
53
+ * false positives in this templated, cross-block, agent-populated
54
+ * context rather than real defects:
55
+ * SC1091 — "not following sourced file": blocks `source`/`.` files
56
+ * that exist only at run time in the calling agent's real RUN_DIR, not
57
+ * in this lint's throwaway single-block temp file.
58
+ * SC2154 — "var is referenced but not assigned": workflow blocks
59
+ * routinely reference variables the CALLING AGENT exports as env vars,
60
+ * or that a DIFFERENT fenced block earlier in the same workflow
61
+ * assigned — invisible to a scan of one isolated block.
62
+ * SC2034 — "var appears unused": the mirror image of SC2154 — a var
63
+ * assigned in this block is frequently consumed by a LATER block in
64
+ * the same workflow, again invisible to a single-block scan.
65
+ * SC2148 ("shell directive missing") is not in this exclude list because
66
+ * passing `--shell=bash` to ShellCheck (all these blocks are already
67
+ * fenced ```bash, i.e. self-declared) prevents it from firing at all.
68
+ *
69
+ * Exit 0 with no output on a clean tree (or a tree whose only findings are
70
+ * already accepted in the baseline, see below); exit 1 with every NEW
71
+ * finding (file, line, ShellCheck code, message) printed to stderr otherwise.
72
+ *
73
+ * Baseline (pre-existing findings, #4109 follow-up):
74
+ * Landing this lint against the real repo surfaced ~212 pre-existing
75
+ * ShellCheck findings across ~60 files that are unrelated to #4109's actual
76
+ * fix (a zsh word-splitting bug already fixed at its 6 sites). Requiring all
77
+ * 212 to be fixed in the same PR that adds the lint would block CI for
78
+ * reasons orthogonal to the issue. Instead, `scripts/lint-workflow-
79
+ * shellcheck-baseline.json` records the *accepted* pre-existing findings as
80
+ * of the baseline's generation, and this script only fails on findings NOT
81
+ * present in that baseline ("new" findings) — a standard ratchet: today's
82
+ * findings can never silently grow, but paying down the backlog is a
83
+ * separate, incremental effort.
84
+ *
85
+ * Baseline shape: a flat JSON array of `{file, code, message}` triples (see
86
+ * BASELINE_PATH below). `file` is the workflow-relative path (matches a
87
+ * finding's mapped `block.file`), `code` is the bare ShellCheck code number
88
+ * (e.g. `"2086"`, matches `f.code`), `message` is ShellCheck's finding text
89
+ * verbatim (matches `f.message`).
90
+ *
91
+ * Matching strategy — deliberately EXCLUDES line/column: matching on exact
92
+ * line number would make the baseline brittle to totally unrelated edits.
93
+ * E.g. inserting one line near the top of a large workflow file shifts every
94
+ * subsequent line number, which would make every already-accepted finding
95
+ * below that point look "new" on the next lint run — a spurious CI failure
96
+ * with no relationship to any real regression. `{file, code, message}` is
97
+ * stable under such reflow: the finding's identity (what rule fired, what it
98
+ * says, which file) doesn't move just because line numbers shift.
99
+ *
100
+ * This does mean two textually-identical findings in the same file (same
101
+ * code, same message) are indistinguishable by key alone. Findings are
102
+ * matched as a MULTISET, not a set: the baseline is loaded into a
103
+ * `key -> count` map, and each current finding consumes one count of its key
104
+ * if available (marking it "baselined") or is reported "new" once the
105
+ * baseline's count for that key is exhausted. This preserves ratchet
106
+ * semantics per-file-per-rule-per-message (a THIRD occurrence of a message
107
+ * that only had two accepted instances IS reported as new) without being
108
+ * sensitive to which physical line within the file each occurrence sits on.
109
+ */
110
+
111
+ const fs = require('node:fs');
112
+ const os = require('node:os');
113
+ const path = require('node:path');
114
+ const childProcess = require('node:child_process');
115
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
116
+ const { resolveShellcheckBin } = require('./lib/shellcheck-fetch.cjs');
117
+
118
+ // Hard bound on the ShellCheck binary's run time, matching this repo's
119
+ // npm-subprocess timeout convention (5-30s git, 60s npm — same "external
120
+ // process that could hang" hazard class). Applied directly to runShellcheck's
121
+ // own spawnSync call below.
122
+ const SHELLCHECK_TIMEOUT_MS = 60_000;
123
+
124
+ const ROOT = path.join(__dirname, '..');
125
+ const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows');
126
+ const SECTIONIZER_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'markdown-sectionizer.cjs');
127
+ const BASELINE_PATH = path.join(__dirname, 'lint-workflow-shellcheck-baseline.json');
128
+
129
+ // Codes excluded for structural reasons documented in the module header above.
130
+ const EXCLUDED_CODES = ['SC1091', 'SC2154', 'SC2034'];
131
+
132
+ /** Every bare `{identifier}` (not `${identifier}`) → a shell-safe bareword. */
133
+ function substitutePlaceholders(body) {
134
+ // Only matches content that is ALREADY known-safe to be workflow-template
135
+ // prose: starts with a letter, then nothing but letters/digits/underscore/
136
+ // hyphen/space. This deliberately excludes every real shell use of `{...}`
137
+ // that could otherwise collide with a placeholder-shaped token:
138
+ // - `${var}` parameter expansion — excluded by the `(?<!\$)` lookbehind.
139
+ // - `{1..5}` / `{01..10}` numeric ranges — digit-first or contain `.`.
140
+ // - `{a,b,c}` brace-expansion lists — contain `,`.
141
+ // - `{ cmd; }` / `{ cmd1; cmd2; }` compound-command grouping — POSIX
142
+ // requires whitespace immediately after the opening `{` (it is only a
143
+ // reserved word when blank-separated), and the body always carries a
144
+ // `;`/pipe/redirect/quote — none of which this charset admits, so a
145
+ // real command group can never match this regex.
146
+ // - JSON-shaped literals like `{"key": "value"}` — contain `"`/`:`.
147
+ // Everything workflow authors actually use as a template placeholder in
148
+ // this repo (`{run_dir}`, `{N}`, `{discovered test command}`, `{scenario
149
+ // keyword}`, `{expected}`, `{implementation file}`, …) is pure prose text
150
+ // and matches; nothing else does.
151
+ return body.replace(/(?<!\$)\{([A-Za-z][A-Za-z0-9_ -]*)\}/g, (match, inner) => {
152
+ const safe = inner.trim().replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'X';
153
+ return `PLACEHOLDER_${safe}`;
154
+ });
155
+ }
156
+
157
+ /** Recursively collect every `.md` file under `dir`. */
158
+ function collectMarkdownFiles(dir) {
159
+ const out = [];
160
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) {
161
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
162
+ // Node's recursive readdir sets entry.parentPath (>=20.12) / entry.path (older).
163
+ const parent = entry.parentPath ?? entry.path;
164
+ out.push(path.join(parent, entry.name));
165
+ }
166
+ return out.sort();
167
+ }
168
+
169
+ /**
170
+ * Every ```bash fenced block across every workflow .md file, with enough
171
+ * metadata to map a ShellCheck finding back to its original source location.
172
+ */
173
+ function extractBashBlocks(sectionizer) {
174
+ const files = collectMarkdownFiles(WORKFLOWS_DIR);
175
+ const blocks = [];
176
+ for (const file of files) {
177
+ const content = fs.readFileSync(file, 'utf8');
178
+ const lines = content.split(/\r?\n/);
179
+ const relFile = path.relative(ROOT, file);
180
+ const fenced = sectionizer.scanFencedBlocks(lines);
181
+ let blockIdx = 0;
182
+ for (const b of fenced) {
183
+ if (b.closeLineIdx === -1) continue; // unterminated fence — nothing well-defined to check
184
+ if ((b.infoString || '').trim() !== 'bash') continue;
185
+ const body = lines.slice(b.openLineIdx + 1, b.closeLineIdx).join('\n');
186
+ blocks.push({
187
+ file: relFile,
188
+ blockIdx: blockIdx++,
189
+ // 1-based source line of the FIRST body line — a JSON finding's own
190
+ // `line` (1-based, relative to the staged single-block temp file) is
191
+ // added to this minus 1 to recover the real workflow-file line.
192
+ firstBodyLine: b.openLineIdx + 2,
193
+ body,
194
+ });
195
+ }
196
+ }
197
+ return blocks;
198
+ }
199
+
200
+ /** Stable identity key for a mapped finding — see the "Matching strategy" note above. */
201
+ function findingKey(f) {
202
+ return `${f.file} ${f.code} ${f.message}`;
203
+ }
204
+
205
+ /**
206
+ * Structural check (separate from the ShellCheck pass above): catches the
207
+ * exact #4109 bug shape — `for x in $VAR; do` / `for x in ${VAR}; do` with a
208
+ * BARE, unquoted scalar variable reference in the for-list position.
209
+ *
210
+ * ShellCheck does NOT flag this pattern under any ruleset, confirmed
211
+ * empirically by reintroducing the exact bug and running this script's own
212
+ * ShellCheck invocation (including `--enable=all`): a bare `$VAR` directly in
213
+ * a for-list is a deliberately-accepted, common bash idiom to ShellCheck, so
214
+ * SC2086 and friends never fire on it. That idiom is exactly what silently
215
+ * diverges between bash (word-splits it) and zsh (does not) — the root cause
216
+ * of #4109. Hence this dedicated structural pass, run in the SAME invocation
217
+ * as the ShellCheck pass, over the SAME extracted ```bash blocks.
218
+ *
219
+ * Algorithm per for-loop found in a block body:
220
+ * 1. Locate `for <ident> in <list-expr>` and capture <list-expr> up to the
221
+ * first `;` or newline that is NOT nested inside a `$( ... )` span (a
222
+ * paren-depth scan, not a naive `[^;]*` regex slice) — a for-list that
223
+ * itself contains a `;` inside a command substitution must not have its
224
+ * capture truncated early.
225
+ * 2. Strip every `$( ... )` command-substitution span out of <list-expr>.
226
+ * Command substitution ALWAYS word-splits its result in both bash AND
227
+ * zsh — that is the actual #4109 fix pattern applied at every known
228
+ * site (`$(printf '%s' "$VAR")`), so a bare `$VAR` INSIDE a `$(...)`
229
+ * span is safe and must never be flagged.
230
+ * 3. Search what remains for a bare `$IDENT` / `${IDENT}` that is NOT
231
+ * immediately preceded by `"` — a `"$VAR"` reference is a different,
232
+ * also-safe idiom (single-token literal-list iteration), not the
233
+ * splitting bug.
234
+ *
235
+ * Findings from this pass are NEVER baselined (unlike the ShellCheck pass) —
236
+ * this check is new-by-construction and every workflow site known to be
237
+ * vulnerable was already swept as part of #4109's fix, so any finding here
238
+ * is a genuinely new/missed site worth surfacing distinctly rather than
239
+ * silently absorbing into scripts/lint-workflow-shellcheck-baseline.json.
240
+ */
241
+
242
+ /** Strip every balanced `$( ... )` span from `text`, preserving everything else. */
243
+ function stripCommandSubstitutions(text) {
244
+ let out = '';
245
+ let i = 0;
246
+ while (i < text.length) {
247
+ if (text[i] === '$' && text[i + 1] === '(') {
248
+ let depth = 1;
249
+ let j = i + 2;
250
+ while (j < text.length && depth > 0) {
251
+ if (text[j] === '(') depth++;
252
+ else if (text[j] === ')') depth--;
253
+ j++;
254
+ }
255
+ i = j;
256
+ continue;
257
+ }
258
+ out += text[i];
259
+ i++;
260
+ }
261
+ return out;
262
+ }
263
+
264
+ // A bare `$IDENT` / `${IDENT}` not immediately preceded by `"`.
265
+ const BARE_VAR_RE = /(^|[^"])\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/;
266
+
267
+ /**
268
+ * Blank out `# ...` shell comments (to end of line), preserving every other
269
+ * character's position 1:1 (comment text is replaced with spaces, newlines
270
+ * are kept) so downstream character-offset -> line-number mapping stays
271
+ * valid without needing a second pass. A `#` only starts a comment when it
272
+ * is the first character of a "word" (start of line, or preceded by
273
+ * whitespace) — matching real shell comment semantics and, deliberately,
274
+ * NOT stripping `${VAR#pattern}` parameter-expansion `#`s (always preceded
275
+ * by a non-whitespace identifier character, e.g. `${sm_raw#./}`). Prose
276
+ * inside a `#` comment (e.g. a changelog note quoting `for x in $VAR` as an
277
+ * example of a PAST bug) must never be mistaken for live code — this is
278
+ * what stops that false positive.
279
+ */
280
+ function stripShellComments(body) {
281
+ let out = '';
282
+ let inSingle = false;
283
+ let inDouble = false;
284
+ let i = 0;
285
+ while (i < body.length) {
286
+ const ch = body[i];
287
+ if (inSingle) {
288
+ out += ch;
289
+ if (ch === "'") inSingle = false;
290
+ i++;
291
+ continue;
292
+ }
293
+ if (inDouble) {
294
+ out += ch;
295
+ if (ch === '"') inDouble = false;
296
+ i++;
297
+ continue;
298
+ }
299
+ if (ch === "'") {
300
+ inSingle = true;
301
+ out += ch;
302
+ i++;
303
+ continue;
304
+ }
305
+ if (ch === '"') {
306
+ inDouble = true;
307
+ out += ch;
308
+ i++;
309
+ continue;
310
+ }
311
+ const prev = i === 0 ? '\n' : body[i - 1];
312
+ if (ch === '#' && /\s/.test(prev)) {
313
+ while (i < body.length && body[i] !== '\n') {
314
+ out += ' ';
315
+ i++;
316
+ }
317
+ continue; // the '\n' itself (if any) is handled by the next loop iteration
318
+ }
319
+ out += ch;
320
+ i++;
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /**
326
+ * Every `for <ident> in <list-expr>` for-loop header in `body`, with the raw
327
+ * list-expression text and the 0-based character offset of the `for` keyword
328
+ * (used by the caller to recover a line number).
329
+ */
330
+ function extractForLoops(body) {
331
+ const results = [];
332
+ const headerRe = /\bfor\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+/g;
333
+ let m;
334
+ while ((m = headerRe.exec(body)) !== null) {
335
+ const start = headerRe.lastIndex;
336
+ let i = start;
337
+ let depth = 0;
338
+ while (i < body.length) {
339
+ const ch = body[i];
340
+ if (ch === '(') depth++;
341
+ else if (ch === ')') depth--;
342
+ else if (depth === 0 && (ch === ';' || ch === '\n')) break;
343
+ i++;
344
+ }
345
+ results.push({ loopVar: m[1], listExpr: body.slice(start, i), matchIndex: m.index });
346
+ headerRe.lastIndex = i;
347
+ }
348
+ return results;
349
+ }
350
+
351
+ /** 1-based line number of `charIndex` within `body` (0-based first line = 1). */
352
+ function lineOffsetOf(body, charIndex) {
353
+ let line = 1;
354
+ for (let i = 0; i < charIndex && i < body.length; i++) {
355
+ if (body[i] === '\n') line++;
356
+ }
357
+ return line;
358
+ }
359
+
360
+ /**
361
+ * Scan every extracted block for the bare unquoted `for x in $VAR` shape.
362
+ * Returns mapped findings (`{file, line, loopVar, varName, listExpr,
363
+ * blockIdx}`), analogous in shape to the ShellCheck findings above but never
364
+ * baselined — see this section's header note.
365
+ */
366
+ function findBareForLoopSplits(blocks) {
367
+ const findings = [];
368
+ for (const block of blocks) {
369
+ const codeOnly = stripShellComments(block.body);
370
+ for (const loop of extractForLoops(codeOnly)) {
371
+ const stripped = stripCommandSubstitutions(loop.listExpr);
372
+ const bare = BARE_VAR_RE.exec(stripped);
373
+ if (!bare) continue;
374
+ findings.push({
375
+ file: block.file,
376
+ line: block.firstBodyLine + lineOffsetOf(block.body, loop.matchIndex) - 1,
377
+ blockIdx: block.blockIdx,
378
+ loopVar: loop.loopVar,
379
+ varName: bare[2],
380
+ listExpr: loop.listExpr.trim(),
381
+ });
382
+ }
383
+ }
384
+ return findings;
385
+ }
386
+
387
+ /** Load the baseline array (empty if the file does not exist yet). */
388
+ function loadBaseline() {
389
+ if (!fs.existsSync(BASELINE_PATH)) return [];
390
+ const raw = fs.readFileSync(BASELINE_PATH, 'utf8');
391
+ const parsed = JSON.parse(raw);
392
+ if (!Array.isArray(parsed)) {
393
+ throw new ExitError(
394
+ 1,
395
+ `lint-workflow-shellcheck: ${path.relative(ROOT, BASELINE_PATH)} must be a JSON array of ` +
396
+ `{file, code, message} objects.`,
397
+ );
398
+ }
399
+ return parsed;
400
+ }
401
+
402
+ /**
403
+ * Partition `mappedFindings` (each `{file, code, message, ...}`) into
404
+ * `{newFindings, baselinedFindings}` against the baseline multiset. See the
405
+ * "Matching strategy" note in the module header for why this is a
406
+ * key -> count multiset match rather than exact-line matching.
407
+ */
408
+ function partitionAgainstBaseline(mappedFindings, baseline) {
409
+ const remaining = new Map();
410
+ for (const entry of baseline) {
411
+ const key = findingKey(entry);
412
+ remaining.set(key, (remaining.get(key) || 0) + 1);
413
+ }
414
+ const newFindings = [];
415
+ const baselinedFindings = [];
416
+ for (const f of mappedFindings) {
417
+ const key = findingKey(f);
418
+ const count = remaining.get(key) || 0;
419
+ if (count > 0) {
420
+ remaining.set(key, count - 1);
421
+ baselinedFindings.push(f);
422
+ } else {
423
+ newFindings.push(f);
424
+ }
425
+ }
426
+ return { newFindings, baselinedFindings };
427
+ }
428
+
429
+ function loadSectionizer() {
430
+ try {
431
+ return require(SECTIONIZER_PATH);
432
+ } catch (e) {
433
+ throw new ExitError(
434
+ 1,
435
+ `lint-workflow-shellcheck: cannot load the markdown-sectionizer seam at ` +
436
+ `${path.relative(ROOT, SECTIONIZER_PATH)} — run 'npm run build:lib' first (${e.message})`,
437
+ );
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Run ShellCheck (json1 output) over every staged temp file in one invocation,
443
+ * bounded by SHELLCHECK_TIMEOUT_MS.
444
+ *
445
+ * `bin` is resolved by the caller via scripts/lib/shellcheck-fetch.cjs's
446
+ * `resolveShellcheckBin()` (downloading and caching the pinned release on
447
+ * first use, per that module's own header comment). This invokes
448
+ * `child_process.spawnSync` directly with a native `timeout` so a hung
449
+ * ShellCheck binary is killed (Node sets `result.error.code === 'ETIMEDOUT'`
450
+ * and `result.signal` on expiry) rather than hanging this lint — and,
451
+ * transitively, CI — indefinitely.
452
+ */
453
+ function runShellcheck(bin, filePaths) {
454
+ const args = [
455
+ '--shell=bash',
456
+ '--format=json1',
457
+ `--exclude=${EXCLUDED_CODES.join(',')}`,
458
+ ...filePaths,
459
+ ];
460
+ const result = childProcess.spawnSync(bin, args, { stdio: 'pipe', timeout: SHELLCHECK_TIMEOUT_MS });
461
+ if (result.error) {
462
+ const timedOut = result.error.code === 'ETIMEDOUT';
463
+ throw new ExitError(
464
+ 1,
465
+ `lint-workflow-shellcheck: ShellCheck invocation ${
466
+ timedOut ? `timed out after ${SHELLCHECK_TIMEOUT_MS}ms` : 'failed'
467
+ }: ${result.error.message}`,
468
+ );
469
+ }
470
+ const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString('utf8') : (result.stdout || '');
471
+ if (stdout.trim() === '') {
472
+ // ShellCheck produced no output at all — genuine infra failure (crash,
473
+ // bad binary, etc.), not "zero findings" (which is `{"comments":[]}`).
474
+ const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8') : (result.stderr || '');
475
+ throw new ExitError(
476
+ 1,
477
+ `lint-workflow-shellcheck: ShellCheck produced no output (exit ${result.status}). stderr: ${stderr}`,
478
+ );
479
+ }
480
+ try {
481
+ return JSON.parse(stdout).comments || [];
482
+ } catch (e) {
483
+ throw new ExitError(
484
+ 1,
485
+ `lint-workflow-shellcheck: could not parse ShellCheck json1 output: ${e.message}\n${stdout}`,
486
+ );
487
+ }
488
+ }
489
+
490
+ async function main() {
491
+ const sectionizer = loadSectionizer();
492
+ const blocks = extractBashBlocks(sectionizer);
493
+
494
+ if (blocks.length === 0) {
495
+ process.stdout.write('ok lint-workflow-shellcheck: no ```bash blocks found under gsd-core/workflows/\n');
496
+ return 0;
497
+ }
498
+
499
+ // Structural pass (see findBareForLoopSplits's header comment) — runs
500
+ // independently of ShellCheck. As of the #4109 sweep, every previously
501
+ // KNOWN site is fixed (0 structural findings on a clean tree), so this now
502
+ // GATES the exit code exactly like the ShellCheck-baseline-diff check
503
+ // below: a non-empty structuralFindings fails main() even if ShellCheck
504
+ // itself reports nothing new. Every finding is printed prominently below
505
+ // regardless of outcome; the two checks are combined into one final exit
506
+ // decision so a run with both kinds of findings reports both.
507
+ const structuralFindings = findBareForLoopSplits(blocks);
508
+ if (structuralFindings.length > 0) {
509
+ process.stdout.write(
510
+ `\nSTRUCTURAL FINDING (not ShellCheck, not baselined) lint-workflow-shellcheck: ` +
511
+ `${structuralFindings.length} bare unquoted \`for x in $VAR\` for-loop(s) — the #4109 bash/zsh ` +
512
+ `word-splitting bug shape ShellCheck itself does not detect:\n\n`,
513
+ );
514
+ for (const f of structuralFindings) {
515
+ process.stdout.write(
516
+ ` ${f.file}:${f.line} (block #${f.blockIdx}) — ` +
517
+ `for ${f.loopVar} in ${f.listExpr} — bare $${f.varName} is unquoted and not inside $(...); ` +
518
+ `wrap in $(printf '%s' "$${f.varName}") to split identically under bash and zsh.\n`,
519
+ );
520
+ }
521
+ process.stdout.write('\n');
522
+ }
523
+ const structuralFailed = structuralFindings.length > 0;
524
+
525
+ const shellcheckBin = await resolveShellcheckBin();
526
+
527
+ const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-workflow-shellcheck-'));
528
+ try {
529
+ const stagedPaths = [];
530
+ const byPath = new Map();
531
+ blocks.forEach((block, i) => {
532
+ const scriptPath = path.join(stageDir, `block-${i}.sh`);
533
+ fs.writeFileSync(scriptPath, substitutePlaceholders(block.body));
534
+ stagedPaths.push(scriptPath);
535
+ byPath.set(scriptPath, block);
536
+ });
537
+
538
+ const findings = runShellcheck(shellcheckBin, stagedPaths);
539
+
540
+ if (findings.length === 0) {
541
+ process.stdout.write(
542
+ `ok lint-workflow-shellcheck: ${blocks.length} \`\`\`bash block(s) across ` +
543
+ `${new Set(blocks.map((b) => b.file)).size} workflow file(s) checked, 0 ShellCheck findings\n`,
544
+ );
545
+ return structuralFailed ? 1 : 0;
546
+ }
547
+
548
+ const mappedFindings = findings.map((f) => {
549
+ const block = byPath.get(f.file);
550
+ return {
551
+ file: block ? block.file : f.file,
552
+ line: block ? block.firstBodyLine + f.line - 1 : f.line,
553
+ column: f.column,
554
+ code: String(f.code),
555
+ level: f.level,
556
+ message: f.message,
557
+ blockIdx: block ? block.blockIdx : undefined,
558
+ };
559
+ });
560
+
561
+ const baseline = loadBaseline();
562
+ const { newFindings, baselinedFindings } = partitionAgainstBaseline(mappedFindings, baseline);
563
+
564
+ if (newFindings.length === 0) {
565
+ process.stdout.write(
566
+ `ok lint-workflow-shellcheck: ${baselinedFindings.length} pre-existing finding(s) from baseline, ` +
567
+ `0 new\n`,
568
+ );
569
+ return structuralFailed ? 1 : 0;
570
+ }
571
+
572
+ process.stderr.write(
573
+ `\nERROR lint-workflow-shellcheck: ${newFindings.length} NEW ShellCheck finding(s) in ` +
574
+ `gsd-core/workflows/ \`\`\`bash block(s) (#4109 word-splitting/quoting prevention) not present in ` +
575
+ `${path.relative(ROOT, BASELINE_PATH)}.\n\n`,
576
+ );
577
+ for (const f of newFindings) {
578
+ const loc = f.blockIdx !== undefined
579
+ ? `${f.file} (block #${f.blockIdx}, line ${f.line}, col ${f.column})`
580
+ : `${f.file}:${f.line}:${f.column}`;
581
+ process.stderr.write(` ${loc} — SC${f.code} (${f.level}): ${f.message}\n`);
582
+ }
583
+ if (baselinedFindings.length > 0) {
584
+ process.stderr.write(`\n(${baselinedFindings.length} other pre-existing finding(s) from baseline, not shown.)\n`);
585
+ }
586
+ process.stderr.write('\n');
587
+ return 1;
588
+ } finally {
589
+ fs.rmSync(stageDir, { recursive: true, force: true });
590
+ }
591
+ }
592
+
593
+ // Guarded so requiring this module (e.g. from tests/lint-workflow-shellcheck
594
+ // .test.cjs, to exercise the exported pure parser/logic functions) does not
595
+ // ALSO trigger a full ShellCheck run as an unwanted side effect of require()
596
+ // — matches the established convention in this repo's other dual-purpose
597
+ // script+module lint scripts, e.g. scripts/lint-docs-required.cjs's own
598
+ // `if (require.main === module) runMain(main);`.
599
+ if (require.main === module) runMain(main);
600
+
601
+ module.exports = {
602
+ substitutePlaceholders,
603
+ collectMarkdownFiles,
604
+ extractBashBlocks,
605
+ EXCLUDED_CODES,
606
+ findingKey,
607
+ loadBaseline,
608
+ partitionAgainstBaseline,
609
+ BASELINE_PATH,
610
+ stripCommandSubstitutions,
611
+ stripShellComments,
612
+ extractForLoops,
613
+ findBareForLoopSplits,
614
+ };