@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +12 -0
- package/agents/gsd-executor.md +63 -35
- package/agents/gsd-plan-checker.md +76 -57
- package/agents/gsd-planner.md +14 -0
- package/agents/gsd-ui-checker.md +19 -3
- package/agents/gsd-ui-researcher.md +29 -0
- package/agents/gsd-verifier.md +23 -1
- package/bin/install.js +239 -67
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/phase.md +1 -1
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/surface.md +18 -8
- package/gsd-core/bin/gsd-tools.cjs +195 -50
- package/gsd-core/bin/lib/capability-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-registry.cjs +514 -114
- package/gsd-core/bin/lib/capability-state.cjs +7 -1
- package/gsd-core/bin/lib/capability-validator.cjs +120 -4
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +85 -2
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/command-aliases.cjs +16 -0
- package/gsd-core/bin/lib/commands.cjs +337 -13
- package/gsd-core/bin/lib/config-loader.cjs +3 -0
- package/gsd-core/bin/lib/core-utils.cjs +34 -7
- package/gsd-core/bin/lib/decisions.cjs +213 -1
- package/gsd-core/bin/lib/edge-probe.cjs +14 -1
- package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +137 -23
- package/gsd-core/bin/lib/gap-checker.cjs +22 -13
- package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
- package/gsd-core/bin/lib/host-integration.cjs +57 -5
- package/gsd-core/bin/lib/init-command-router.cjs +14 -0
- package/gsd-core/bin/lib/init.cjs +132 -15
- package/gsd-core/bin/lib/install-engine.cjs +184 -12
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
- package/gsd-core/bin/lib/install-profiles.cjs +22 -14
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/io.cjs +35 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
- package/gsd-core/bin/lib/markdown-table.cjs +123 -0
- package/gsd-core/bin/lib/milestone.cjs +22 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
- package/gsd-core/bin/lib/phase-id.cjs +251 -9
- package/gsd-core/bin/lib/phase.cjs +774 -35
- package/gsd-core/bin/lib/plan-document.cjs +10 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
- package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
- package/gsd-core/bin/lib/quick-batch.cjs +840 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
- package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
- package/gsd-core/bin/lib/roadmap.cjs +187 -58
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
- package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
- package/gsd-core/bin/lib/smart-entry.cjs +7 -9
- package/gsd-core/bin/lib/state-document.cjs +30 -5
- package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
- package/gsd-core/bin/lib/state-transition.cjs +333 -44
- package/gsd-core/bin/lib/state.cjs +684 -125
- package/gsd-core/bin/lib/surface.cjs +23 -8
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
- package/gsd-core/bin/lib/uat.cjs +1419 -515
- package/gsd-core/bin/lib/update-context.cjs +6 -2
- package/gsd-core/bin/lib/validate.cjs +230 -12
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +273 -12
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +346 -16
- package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
- package/gsd-core/references/agent-contracts.md +3 -3
- package/gsd-core/references/edge-probe.md +17 -13
- package/gsd-core/references/execute-mvp-tdd.md +18 -16
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- package/gsd-core/references/mvp-concepts.md +2 -2
- package/gsd-core/references/plan-checker-examples.md +41 -0
- package/gsd-core/references/planner-antipatterns.md +25 -0
- package/gsd-core/references/planner-chunked.md +5 -1
- package/gsd-core/references/planner-coupling.md +42 -0
- package/gsd-core/references/planner-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +75 -2
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/tdd.md +14 -9
- package/gsd-core/references/verifier-evidence-gate.md +160 -0
- package/gsd-core/templates/phase-prompt.md +4 -0
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/add-backlog.md +2 -0
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +2 -0
- package/gsd-core/workflows/audit-milestone.md +2 -0
- package/gsd-core/workflows/audit-uat.md +2 -0
- package/gsd-core/workflows/autonomous.md +2 -0
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
- package/gsd-core/workflows/code-review-fix.md +2 -0
- package/gsd-core/workflows/code-review.md +73 -31
- package/gsd-core/workflows/complete-milestone.md +13 -4
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +43 -13
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +2 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +32 -14
- package/gsd-core/workflows/execute-plan.md +8 -8
- package/gsd-core/workflows/explore.md +2 -0
- package/gsd-core/workflows/extract-learnings.md +2 -0
- package/gsd-core/workflows/fast.md +6 -0
- package/gsd-core/workflows/forensics.md +2 -0
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/help/modes/brief.md +2 -0
- package/gsd-core/workflows/help/modes/default.md +2 -0
- package/gsd-core/workflows/help/modes/full.md +12 -0
- package/gsd-core/workflows/help/modes/topic.md +2 -0
- package/gsd-core/workflows/help.md +2 -0
- package/gsd-core/workflows/import.md +3 -3
- package/gsd-core/workflows/inbox.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +2 -0
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +2 -0
- package/gsd-core/workflows/list-workspaces.md +2 -0
- package/gsd-core/workflows/manager.md +3 -3
- package/gsd-core/workflows/map-codebase.md +2 -0
- package/gsd-core/workflows/milestone-summary.md +2 -0
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +1 -1
- package/gsd-core/workflows/new-project.md +5 -3
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +2 -0
- package/gsd-core/workflows/node-repair.md +2 -0
- package/gsd-core/workflows/note.md +2 -0
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +19 -4
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
- package/gsd-core/workflows/plan-phase.md +130 -12
- package/gsd-core/workflows/plan-review-convergence.md +102 -10
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +11 -3
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +25 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
- package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
- package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
- package/gsd-core/workflows/quick-batch.md +203 -0
- package/gsd-core/workflows/quick.md +13 -3
- package/gsd-core/workflows/reapply-patches.md +2 -0
- package/gsd-core/workflows/remove-phase.md +2 -0
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +6 -2
- package/gsd-core/workflows/review.md +215 -10
- package/gsd-core/workflows/scan.md +2 -0
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/settings-integrations.md +9 -8
- package/gsd-core/workflows/settings.md +1 -1
- package/gsd-core/workflows/ship.md +10 -10
- package/gsd-core/workflows/sketch-wrap-up.md +2 -0
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/spec-phase.md +24 -19
- package/gsd-core/workflows/spike-wrap-up.md +2 -0
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +2 -0
- package/gsd-core/workflows/sync-skills.md +12 -4
- package/gsd-core/workflows/thread.md +2 -0
- package/gsd-core/workflows/transition.md +2 -0
- package/gsd-core/workflows/ui-phase.md +26 -5
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +2 -0
- package/gsd-core/workflows/undo.md +1 -1
- package/gsd-core/workflows/update.md +41 -38
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +49 -3
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-context-monitor.js +283 -12
- package/hooks/dist/gsd-node-runner.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +30 -5
- package/hooks/dist/gsd-read-guard.js +2 -0
- package/hooks/dist/gsd-read-injection-scanner.js +5 -5
- package/hooks/dist/gsd-secret-read-guard.js +1079 -0
- package/hooks/dist/gsd-statusline.js +7 -3
- package/hooks/dist/gsd-validate-commit.sh +444 -7
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-context-monitor.js +283 -12
- package/hooks/gsd-node-runner.sh +1 -0
- package/hooks/gsd-prompt-guard.js +30 -5
- package/hooks/gsd-read-guard.js +2 -0
- package/hooks/gsd-read-injection-scanner.js +5 -5
- package/hooks/gsd-secret-read-guard.js +1079 -0
- package/hooks/gsd-statusline.js +7 -3
- package/hooks/gsd-validate-commit.sh +444 -7
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/hooks.json +6 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +5 -5
- package/scripts/build-hooks.js +11 -4
- package/scripts/ci-test-scope.cjs +7 -0
- package/scripts/docs-guard-registry.cjs +10 -0
- package/scripts/gen-loop-host-contract.cjs +67 -15
- package/scripts/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
- package/scripts/lint-phase-enumeration-drift.cjs +24 -6
- package/scripts/lint-phase-id-drift.cjs +133 -8
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-response-language-coverage.cjs +524 -0
- package/scripts/lint-test-file-count.allowlist.json +3 -1
- package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +8 -0
- package/scripts/require-issue-link-policy.cjs +16 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -1
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- 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
|
+
};
|