@codyswann/lisa 4.4.14 → 4.4.16

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 (108) hide show
  1. package/all/copy-overwrite/scripts/lib/placeholder-expiry.mjs +79 -7
  2. package/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +46 -9
  3. package/all/copy-overwrite/scripts/lisa-postinstall.mjs +10 -1
  4. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +260 -41
  5. package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +12 -1
  6. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.js +17 -0
  8. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.js +19 -14
  11. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  12. package/package.json +2 -1
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/skills/lisa-improve-test-coverage/SKILL.md +4 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-improve-tests/SKILL.md +4 -1
  17. package/plugins/lisa/.codex-plugin/skills/lisa-nightly-lower-code-complexity/SKILL.md +10 -2
  18. package/plugins/lisa/rules/eager/falsifiable-checks.md +16 -1
  19. package/plugins/lisa/rules/reference/falsifiable-checks.md +74 -1
  20. package/plugins/lisa/skills/lisa-improve-test-coverage/SKILL.md +4 -1
  21. package/plugins/lisa/skills/lisa-improve-tests/SKILL.md +4 -1
  22. package/plugins/lisa/skills/lisa-nightly-lower-code-complexity/SKILL.md +10 -2
  23. package/plugins/lisa-agy/plugin.json +1 -1
  24. package/plugins/lisa-agy/skills/lisa-improve-test-coverage/SKILL.md +4 -1
  25. package/plugins/lisa-agy/skills/lisa-improve-tests/SKILL.md +4 -1
  26. package/plugins/lisa-agy/skills/lisa-nightly-lower-code-complexity/SKILL.md +10 -2
  27. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  30. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +16 -1
  34. package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +74 -1
  35. package/plugins/lisa-copilot/skills/lisa-improve-test-coverage/SKILL.md +4 -1
  36. package/plugins/lisa-copilot/skills/lisa-improve-tests/SKILL.md +4 -1
  37. package/plugins/lisa-copilot/skills/lisa-nightly-lower-code-complexity/SKILL.md +10 -2
  38. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +74 -1
  40. package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +16 -1
  41. package/plugins/lisa-cursor/skills/lisa-improve-test-coverage/SKILL.md +4 -1
  42. package/plugins/lisa-cursor/skills/lisa-improve-tests/SKILL.md +4 -1
  43. package/plugins/lisa-cursor/skills/lisa-nightly-lower-code-complexity/SKILL.md +10 -2
  44. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-expo-agy/plugin.json +1 -1
  47. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  67. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.codex-plugin/skills/improve-code-complexity/SKILL.md +4 -1
  72. package/plugins/lisa-rails/.codex-plugin/skills/improve-test-coverage/SKILL.md +4 -1
  73. package/plugins/lisa-rails/.codex-plugin/skills/ops-verify-telemetry/SKILL.md +30 -1
  74. package/plugins/lisa-rails/skills/improve-code-complexity/SKILL.md +4 -1
  75. package/plugins/lisa-rails/skills/improve-test-coverage/SKILL.md +4 -1
  76. package/plugins/lisa-rails/skills/ops-verify-telemetry/SKILL.md +30 -1
  77. package/plugins/lisa-rails-agy/plugin.json +1 -1
  78. package/plugins/lisa-rails-agy/skills/improve-code-complexity/SKILL.md +4 -1
  79. package/plugins/lisa-rails-agy/skills/improve-test-coverage/SKILL.md +4 -1
  80. package/plugins/lisa-rails-agy/skills/ops-verify-telemetry/SKILL.md +30 -1
  81. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-rails-copilot/skills/improve-code-complexity/SKILL.md +4 -1
  83. package/plugins/lisa-rails-copilot/skills/improve-test-coverage/SKILL.md +4 -1
  84. package/plugins/lisa-rails-copilot/skills/ops-verify-telemetry/SKILL.md +30 -1
  85. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-rails-cursor/skills/improve-code-complexity/SKILL.md +4 -1
  87. package/plugins/lisa-rails-cursor/skills/improve-test-coverage/SKILL.md +4 -1
  88. package/plugins/lisa-rails-cursor/skills/ops-verify-telemetry/SKILL.md +30 -1
  89. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  91. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  92. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  96. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  97. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  99. package/plugins/src/base/rules/eager/falsifiable-checks.md +16 -1
  100. package/plugins/src/base/rules/reference/falsifiable-checks.md +74 -1
  101. package/plugins/src/base/skills/lisa-improve-test-coverage/SKILL.md +4 -1
  102. package/plugins/src/base/skills/lisa-improve-tests/SKILL.md +4 -1
  103. package/plugins/src/base/skills/lisa-nightly-lower-code-complexity/SKILL.md +10 -2
  104. package/plugins/src/rails/skills/improve-code-complexity/SKILL.md +4 -1
  105. package/plugins/src/rails/skills/improve-test-coverage/SKILL.md +4 -1
  106. package/plugins/src/rails/skills/ops-verify-telemetry/SKILL.md +30 -1
  107. package/scripts/check-pipeline-status-reads.mjs +684 -0
  108. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +75 -25
@@ -0,0 +1,684 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-pipeline-status-reads — refuse a gate whose result is read through a
4
+ * pipe (CodySwannGT/lisa#3090).
5
+ *
6
+ * ## The defect
7
+ *
8
+ * A shell pipeline's exit status is its LAST stage's. So
9
+ *
10
+ * node scripts/some-gate.mjs 2>&1 | tail -4; echo "exit=$?"
11
+ *
12
+ * reports whether `tail` succeeded, which it essentially always does. The gate
13
+ * can have failed and the reading says `exit=0`. This is ordinary shell
14
+ * semantics and not a bug in anything Lisa ships — it is filed because of WHERE
15
+ * it bites: piping into `tail`/`head` to keep output short is exactly what one
16
+ * does while checking whether a gate passed, and a quiet gate, or one whose
17
+ * failure line falls outside the window the pipe exists to create, produces
18
+ * `exit=0` plus a plausible-looking tail.
19
+ *
20
+ * It has already bitten this repository in shipped CI. `security-floors.yml`
21
+ * ran `node scripts/check-security-floors.mjs --strict | tee -a
22
+ * "$GITHUB_STEP_SUMMARY"` with no `pipefail`, so every failure `--strict`
23
+ * exists to raise — a dependency floor below a live advisory, a rate-limited
24
+ * inconclusive run, an unresolved `$name` — was discarded and the job went
25
+ * green. That was fixed by hand, and the reasoning was written down as a
26
+ * comment inside the one file that had been fixed, which is why this exists as
27
+ * a control instead.
28
+ *
29
+ * ## What counts as a finding
30
+ *
31
+ * A pipeline is reported when ALL of these hold:
32
+ *
33
+ * 1. Its last stage is a PRESENTATION command (`tail`, `head`, `tee`, `cat`,
34
+ * `less`, `more`, `nl`, `column`, `fold`). These report on their own
35
+ * writing, never on the command upstream. Deliberately status-bearing
36
+ * filters — `grep -q`, `jq -e` — are NOT in the set, because there the
37
+ * last stage's status is the one you meant to read.
38
+ * 2. Something acts on the pipeline's status: it is an `if`/`elif`/`while`/
39
+ * `until` condition, it is joined with `&&`/`||`, the very next statement
40
+ * reads `$?`, `set -e` is in force, or it sits in a workflow `run:` block
41
+ * (where GitHub makes the step's status the job's).
42
+ * 3. `pipefail` is NOT in force at that line. With `set -o pipefail` the
43
+ * pipeline reports the first failing stage and the defect cannot occur, so
44
+ * a protected pipeline is inspected and passed, never reported.
45
+ *
46
+ * ## Why it fails at zero inspected
47
+ *
48
+ * An empty inspection and a clean tree print the same tick. This repository has
49
+ * shipped guards that reported success while inert often enough to have a rule
50
+ * about it (`falsifiable-checks`), so the count of pipelines actually parsed is
51
+ * part of the report and a count of zero is exit 2, not exit 0. A glob that
52
+ * matches nothing, a root that does not exist, and a parser that silently
53
+ * stopped all reach that branch.
54
+ *
55
+ * ## What it reads
56
+ *
57
+ * Shell scripts (`.sh`/`.bash`), workflow `run:` blocks, and FENCED SHELL
58
+ * BLOCKS in markdown. The last of those matters most: a skill document is where
59
+ * an agent reads how to check a gate, so an unsafe spelling there is not one
60
+ * defect, it is one per agent that follows the instruction. Six such copies of
61
+ * `docker compose logs otel-collector 2>/dev/null | tail -20 || echo "No
62
+ * otel-collector service"` shipped — where the `||` fallback can never fire,
63
+ * because `tail` succeeds whether or not the service exists.
64
+ *
65
+ * Determinism: Node built-ins plus `js-yaml`, no network, no clock, no
66
+ * `Math.random`. The scanned root is a parameter so the suite can point it at a
67
+ * fixture tree holding a known offender.
68
+ *
69
+ * CLI:
70
+ * node scripts/check-pipeline-status-reads.mjs [--json] [root]
71
+ *
72
+ * Exit codes (mirroring the sibling check-* scripts):
73
+ * 0 — pipelines were inspected and none reads a gate through a pipe.
74
+ * 1 — >=1 finding.
75
+ * 2 — operational error: unknown flag, unreadable root, or ZERO pipelines
76
+ * inspected.
77
+ *
78
+ * @module scripts/check-pipeline-status-reads
79
+ */
80
+ import { readFileSync, readdirSync, statSync } from "node:fs";
81
+ import path from "node:path";
82
+ import process from "node:process";
83
+ import yaml from "js-yaml";
84
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
85
+
86
+ /**
87
+ * Last-stage commands that report on their own writing rather than on the
88
+ * command upstream of them. A pipeline ending in one of these has thrown the
89
+ * interesting status away.
90
+ *
91
+ * `grep`, `jq`, `sed` and `awk` are deliberately absent: each is routinely the
92
+ * stage whose status you actually meant to read (`grep -q`, `jq -e`), so
93
+ * including them would turn a correct idiom into a finding. That is the sweep's
94
+ * declared blind spot — one of them used purely as a pager is not detected.
95
+ */
96
+ export const PRESENTATION_COMMANDS = Object.freeze([
97
+ "tail",
98
+ "head",
99
+ "tee",
100
+ "cat",
101
+ "less",
102
+ "more",
103
+ "nl",
104
+ "column",
105
+ "fold",
106
+ ]);
107
+
108
+ /**
109
+ * First-stage commands that produce text rather than a verdict. A pipeline
110
+ * starting with one of these is formatting something already in hand, so its
111
+ * discarded exit status is not a gate result and reporting it would bury the
112
+ * findings that are. `{`/`(` open a group or subshell used the same way.
113
+ *
114
+ * This is the sweep's precision boundary, stated rather than hidden: it looks
115
+ * for a PROGRAM whose status was the answer being piped into a pager, which is
116
+ * the shape the ticket describes. `echo "$msg" | head -5` is not that shape.
117
+ */
118
+ export const PURE_OUTPUT_COMMANDS = Object.freeze([
119
+ "echo",
120
+ "printf",
121
+ "cat",
122
+ "true",
123
+ "false",
124
+ "yes",
125
+ "seq",
126
+ "{",
127
+ "}",
128
+ "(",
129
+ ]);
130
+
131
+ /** Directories whose shell and workflow sources are shipped or run by Lisa. */
132
+ export const SCANNED_ROOTS = Object.freeze([
133
+ ".github/workflows",
134
+ "all",
135
+ "cdk",
136
+ "expo",
137
+ "harper-fabric",
138
+ "nestjs",
139
+ "npm-package",
140
+ "phaser",
141
+ "plugins",
142
+ "rails",
143
+ "scripts",
144
+ "typescript",
145
+ ]);
146
+
147
+ /**
148
+ * Directory names never descended into: nothing here is authored in this
149
+ * repository, so a finding inside one names somebody else's code.
150
+ *
151
+ * `fixtures` is deliberately NOT on this list. Adding it looks harmless — no
152
+ * scanned root holds such a directory today, so it would exclude exactly
153
+ * nothing — and that is what makes it the worse choice: it is a name-shaped
154
+ * bypass sitting dormant, waiting for the first real script that happens to be
155
+ * parked under one. This suite's own deliberately-broken trees are built in
156
+ * `os.tmpdir()` and are never inside a scanned root, so the sweep does not need
157
+ * protecting from its own test data.
158
+ */
159
+ const SKIPPED_DIRECTORIES = Object.freeze(["node_modules", "dist", ".git"]);
160
+
161
+ /** Fence languages read as shell. Anything else, including a bare fence, is skipped. */
162
+ export const SHELL_FENCE_LANGUAGES = Object.freeze([
163
+ "sh",
164
+ "bash",
165
+ "shell",
166
+ "zsh",
167
+ "console",
168
+ ]);
169
+
170
+ /** Keywords whose following pipeline has its status read as a condition. */
171
+ const CONDITION_KEYWORDS = Object.freeze(["if", "elif", "while", "until"]);
172
+
173
+ /** Statement separators, longest first so `&&` is never read as `&`. */
174
+ const STATEMENT_OPERATORS = Object.freeze(["&&", "||", ";;", ";", "&"]);
175
+
176
+ /**
177
+ * Split shell text on operators that are outside quotes, command substitution
178
+ * and subshells.
179
+ *
180
+ * Written as one scanner rather than a regex because the distinction that
181
+ * matters — a `|` that pipes versus a `|` inside `'...'`, `"..."`, `$(...)` or
182
+ * a comment — is exactly the one a regex cannot make.
183
+ * @param {string} text - One logical line of shell.
184
+ * @param {readonly string[]} operators - Separators to split on, longest first.
185
+ * @returns {{ text: string, operator: string }[]} Segments in source order.
186
+ */
187
+ export function splitTopLevel(text, operators) {
188
+ const segments = [];
189
+ const state = { buffer: "", quote: "", depth: 0, escaped: false };
190
+ const flush = operator => {
191
+ segments.push({ text: state.buffer, operator });
192
+ state.buffer = "";
193
+ };
194
+ for (let index = 0; index < text.length; index += 1) {
195
+ const char = text[index];
196
+ if (state.escaped) {
197
+ state.buffer += char;
198
+ state.escaped = false;
199
+ continue;
200
+ }
201
+ if (char === "\\") {
202
+ state.buffer += char;
203
+ state.escaped = true;
204
+ continue;
205
+ }
206
+ if (state.quote) {
207
+ state.buffer += char;
208
+ if (char === state.quote) state.quote = "";
209
+ continue;
210
+ }
211
+ if (char === "'" || char === '"' || char === "`") {
212
+ state.buffer += char;
213
+ state.quote = char;
214
+ continue;
215
+ }
216
+ // A `#` only opens a comment at the start of a word.
217
+ if (char === "#" && (state.buffer === "" || /\s$/.test(state.buffer)))
218
+ break;
219
+ if (char === "(") {
220
+ state.buffer += char;
221
+ state.depth += 1;
222
+ continue;
223
+ }
224
+ if (char === ")") {
225
+ state.buffer += char;
226
+ state.depth = Math.max(0, state.depth - 1);
227
+ continue;
228
+ }
229
+ if (state.depth > 0) {
230
+ state.buffer += char;
231
+ continue;
232
+ }
233
+ const operator = operators.find(
234
+ candidate => text.slice(index, index + candidate.length) === candidate
235
+ );
236
+ // `2>&1`, `>&2`, `<&0` and `&>log` all contain a bare `&` that is part of a
237
+ // REDIRECTION, not a background separator. Splitting there tore
238
+ // `node gate.mjs 2>&1 | tail -4` into `node gate.mjs 2>` and `1 | tail -4`,
239
+ // which lost the leading `if`/`while` keyword the reason-finder reads and
240
+ // made every report name a fragment instead of the pipeline. Caught by
241
+ // running the sweep against a fixture and reading what it printed.
242
+ const redirected =
243
+ operator === "&" &&
244
+ (/[<>]\s*$/.test(state.buffer) || text[index + 1] === ">");
245
+ if (operator && !redirected) {
246
+ flush(operator);
247
+ index += operator.length - 1;
248
+ continue;
249
+ }
250
+ state.buffer += char;
251
+ }
252
+ flush("");
253
+ return segments;
254
+ }
255
+
256
+ /**
257
+ * The command word a pipeline stage runs, with any path and any leading
258
+ * environment assignments or redirections stripped.
259
+ * @param {string} stage - One pipeline stage's source text.
260
+ * @returns {string} The bare command name, or `""` when there is none.
261
+ */
262
+ export function stageCommand(stage) {
263
+ const words = stage.trim().split(/\s+/).filter(Boolean);
264
+ for (const word of words) {
265
+ if (/^[A-Za-z_]\w*=/.test(word)) continue;
266
+ if (/^\d*[<>]/.test(word)) continue;
267
+ if (word === "!" || word === "command" || word === "exec") continue;
268
+ return word.replace(/^.*\//, "").replace(/^['"]|['"]$/g, "");
269
+ }
270
+ return "";
271
+ }
272
+
273
+ /**
274
+ * Split one statement into its pipeline stages.
275
+ * @param {string} statement - A statement with no top-level `&&`/`||`/`;`.
276
+ * @returns {string[]} The stages, in source order.
277
+ */
278
+ export function pipelineStages(statement) {
279
+ return splitTopLevel(statement, ["|"]).map(segment => segment.text);
280
+ }
281
+
282
+ /**
283
+ * Whether a `set` builtin on this line turns `pipefail` on or off.
284
+ *
285
+ * Word-wise rather than by regex over the whole line, because the overwhelmingly
286
+ * common spelling in this repository is the COMBINED form `set -euo pipefail`.
287
+ * A pattern looking for a literal `-o` does not match it — `-euo` has no `-`
288
+ * immediately before its `o` — so a regex written the obvious way reports every
289
+ * `set -euo pipefail` script as unprotected. Measured while building this
290
+ * sweep: it flagged `security-floors.yml`, whose `run:` block sets
291
+ * `-euo pipefail` on the line directly above the pipeline.
292
+ * @param {string} line - One source line.
293
+ * @returns {boolean | undefined} `true`/`false` when it changes, else undefined.
294
+ */
295
+ export function pipefailChange(line) {
296
+ const words = line.trim().split(/\s+/);
297
+ if (words[0] !== "set") return undefined;
298
+ for (let index = 0; index < words.length - 1; index += 1) {
299
+ if (words[index + 1] !== "pipefail") continue;
300
+ if (/^-[a-zA-Z]*o$/.test(words[index])) return true;
301
+ if (/^\+[a-zA-Z]*o$/.test(words[index])) return false;
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /**
307
+ * Net change in `$(`/`(` nesting a line makes, ignoring quoted text.
308
+ * @param {string} line - One source line.
309
+ * @returns {number} Opened minus closed, at the top level of the line.
310
+ */
311
+ export function netDepth(line) {
312
+ const state = { quote: "", escaped: false, depth: 0 };
313
+ for (const char of line) {
314
+ if (state.escaped) {
315
+ state.escaped = false;
316
+ continue;
317
+ }
318
+ if (char === "\\") {
319
+ state.escaped = true;
320
+ continue;
321
+ }
322
+ if (state.quote) {
323
+ if (char === state.quote) state.quote = "";
324
+ continue;
325
+ }
326
+ if (char === "'" || char === '"' || char === "`") {
327
+ state.quote = char;
328
+ continue;
329
+ }
330
+ if (char === "#") break;
331
+ if (char === "(") state.depth += 1;
332
+ if (char === ")") state.depth -= 1;
333
+ }
334
+ return state.depth;
335
+ }
336
+
337
+ /** Whether a `set` builtin on this line turns `-e` on. */
338
+ const errexitOn = line => /^\s*set\s+-[a-zA-Z]*e/.test(line);
339
+
340
+ /**
341
+ * Inspect one block of shell source.
342
+ *
343
+ * @param {object} source - The block to inspect.
344
+ * @param {string} source.text - Its shell source.
345
+ * @param {string} source.file - Repository-relative path, for reporting.
346
+ * @param {string} source.location - Where in that file, for reporting.
347
+ * @param {boolean} source.statusAlwaysRead - True for a workflow `run:` block,
348
+ * where GitHub makes the block's status the step's and the step's the job's,
349
+ * so every failing statement is acted upon whether or not `-e` is set.
350
+ * @param {boolean} source.pipefail - Whether `pipefail` is already in force
351
+ * from outside the block (GitHub's `shell: bash` sets `-eo pipefail`).
352
+ * @returns {{ inspected: number, findings: object[] }} Count and findings.
353
+ */
354
+ export function inspectShellSource(source) {
355
+ const lines = source.text.split("\n");
356
+ const state = {
357
+ pipefail: source.pipefail,
358
+ errexit: source.statusAlwaysRead,
359
+ inspected: 0,
360
+ // Depth carried over from an unclosed `$(` or `(` on an earlier line. A
361
+ // line-at-a-time scanner otherwise reads the tail of a multi-line command
362
+ // substitution as a statement of its own, and reports a fragment that
363
+ // starts mid-pipeline as though it were a whole one.
364
+ carry: 0,
365
+ };
366
+ const findings = [];
367
+ for (let index = 0; index < lines.length; index += 1) {
368
+ const line = lines[index];
369
+ const opening = state.carry;
370
+ state.carry = Math.max(0, state.carry + netDepth(line));
371
+ if (opening > 0) continue;
372
+ const change = pipefailChange(line);
373
+ if (change !== undefined) state.pipefail = change;
374
+ if (errexitOn(line)) state.errexit = true;
375
+ const statements = splitTopLevel(line, STATEMENT_OPERATORS);
376
+ for (let position = 0; position < statements.length; position += 1) {
377
+ const statement = statements[position];
378
+ const stages = pipelineStages(statement.text);
379
+ if (stages.length < 2) continue;
380
+ state.inspected += 1;
381
+ if (state.pipefail) continue;
382
+ const last = stageCommand(stages[stages.length - 1]);
383
+ if (!PRESENTATION_COMMANDS.includes(last)) continue;
384
+ const first = stageCommand(
385
+ stages[0].replace(/^\s*(if|elif|while|until)\s+/, "")
386
+ );
387
+ if (first === "" || first.startsWith("$")) continue;
388
+ if (PURE_OUTPUT_COMMANDS.includes(first)) continue;
389
+ const reason = statusReadReason({
390
+ statement,
391
+ next: statements[position + 1],
392
+ followingLine: lines[index + 1] ?? "",
393
+ errexit: state.errexit,
394
+ statusAlwaysRead: source.statusAlwaysRead,
395
+ });
396
+ if (!reason) continue;
397
+ findings.push({
398
+ file: source.file,
399
+ location: source.location,
400
+ line: index + 1,
401
+ statement: statement.text.trim(),
402
+ lastStage: last,
403
+ reason,
404
+ });
405
+ }
406
+ }
407
+ return { inspected: state.inspected, findings };
408
+ }
409
+
410
+ /**
411
+ * Why this pipeline's status is acted upon, or `""` when nothing reads it.
412
+ *
413
+ * Ordered from most specific to least so the report names the tightest true
414
+ * reason: an `if` condition is more useful to read than "`set -e` is on".
415
+ * @param {object} context - The statement and its immediate surroundings.
416
+ * @param {{ text: string, operator: string }} context.statement - The pipeline.
417
+ * @param {{ text: string } | undefined} context.next - The next statement on
418
+ * the same line, if any.
419
+ * @param {string} context.followingLine - The next source line.
420
+ * @param {boolean} context.errexit - Whether `set -e` is in force.
421
+ * @param {boolean} context.statusAlwaysRead - Workflow `run:` block.
422
+ * @returns {string} A reason, or `""`.
423
+ */
424
+ export function statusReadReason(context) {
425
+ const leading = context.statement.text.trim().split(/\s+/)[0] ?? "";
426
+ if (CONDITION_KEYWORDS.includes(leading))
427
+ return `\`${leading}\` condition reads the pipeline's status`;
428
+ if (
429
+ context.statement.operator === "&&" ||
430
+ context.statement.operator === "||"
431
+ )
432
+ return `\`${context.statement.operator}\` branches on the pipeline's status`;
433
+ const nextText = context.next?.text ?? context.followingLine;
434
+ if (/\$\?/.test(nextText) && !/PIPESTATUS|pipestatus/.test(nextText))
435
+ return "the next statement reads `$?`";
436
+ if (context.statusAlwaysRead)
437
+ return "a workflow `run:` step's status is the job's result";
438
+ if (context.errexit) return "`set -e` acts on the pipeline's status";
439
+ return "";
440
+ }
441
+
442
+ /**
443
+ * Every workflow `run:` block in a parsed workflow, with the shell resolved.
444
+ *
445
+ * GitHub's default shell for `run:` is `bash -e {0}` — `-e` but NO `pipefail`.
446
+ * Declaring `shell: bash` explicitly changes it to
447
+ * `bash --noprofile --norc -eo pipefail {0}`, which is why the declared shell,
448
+ * and the workflow- and job-level `defaults.run.shell` it inherits, decide
449
+ * whether a block is protected.
450
+ *
451
+ * `bash` is the ONLY protected spelling. `pwsh` is not: GitHub runs it as
452
+ * `pwsh -command ". '{0}'"` with `$ErrorActionPreference = 'stop'` prepended
453
+ * and `exit $LASTEXITCODE` appended, and PowerShell has no `pipefail` — there
454
+ * is no option to set. `$LASTEXITCODE` is written by the most recently
455
+ * finished NATIVE command, and in `gate | tail` that is `tail`, so the exact
456
+ * defect this sweep exists for survives the `$LASTEXITCODE` fix-up unchanged.
457
+ * The other spellings (`sh` -> `sh -e {0}`, `python`, `cmd`, `powershell`)
458
+ * have no pipefail either, and fall out of the `=== "bash"` test.
459
+ * @param {unknown} document - The parsed workflow.
460
+ * @param {string} file - Repository-relative path, for reporting.
461
+ * @returns {object[]} Sources ready for `inspectShellSource`.
462
+ */
463
+ export function workflowRunSources(document, file) {
464
+ if (!document || typeof document !== "object") return [];
465
+ const workflowShell = document.defaults?.run?.shell;
466
+ const jobs = document.jobs ?? {};
467
+ const sources = [];
468
+ for (const [jobId, job] of Object.entries(jobs)) {
469
+ if (!job || typeof job !== "object" || !Array.isArray(job.steps)) continue;
470
+ const jobShell = job.defaults?.run?.shell ?? workflowShell;
471
+ for (let index = 0; index < job.steps.length; index += 1) {
472
+ const step = job.steps[index];
473
+ if (!step || typeof step.run !== "string") continue;
474
+ const shell = step.shell ?? jobShell;
475
+ const named = step.name ? ` (${step.name})` : "";
476
+ sources.push({
477
+ text: step.run,
478
+ file,
479
+ location: `jobs.${jobId}.steps[${index}]${named}`,
480
+ statusAlwaysRead: true,
481
+ // Only the explicit `bash` spelling gets pipefail from GitHub. The
482
+ // DEFAULT (no `shell:` key) does not, which is the whole reason a
483
+ // `run:` block can swallow a gate's failure — and neither does `pwsh`,
484
+ // which has no such option at all (see above).
485
+ pipefail: shell === "bash",
486
+ });
487
+ }
488
+ }
489
+ return sources;
490
+ }
491
+
492
+ /**
493
+ * Walk a directory tree, yielding files the sweep can read.
494
+ * @param {string} root - Absolute directory to walk.
495
+ * @param {string} repoRoot - Absolute repository root, for relative paths.
496
+ * @returns {{ absolute: string, relative: string }[]} Files, sorted.
497
+ */
498
+ export function collectFiles(root, repoRoot) {
499
+ const found = [];
500
+ const walk = directory => {
501
+ const entries = readdirSync(directory, { withFileTypes: true });
502
+ for (const entry of [...entries].sort((a, b) =>
503
+ a.name < b.name ? -1 : 1
504
+ )) {
505
+ if (SKIPPED_DIRECTORIES.includes(entry.name)) continue;
506
+ const absolute = path.join(directory, entry.name);
507
+ if (entry.isDirectory()) {
508
+ walk(absolute);
509
+ continue;
510
+ }
511
+ if (!entry.isFile()) continue;
512
+ if (!/\.(sh|bash|ya?ml|md)$/.test(entry.name)) continue;
513
+ found.push({ absolute, relative: path.relative(repoRoot, absolute) });
514
+ }
515
+ };
516
+ walk(root);
517
+ return found;
518
+ }
519
+
520
+ /**
521
+ * Every fenced shell block in a markdown document.
522
+ *
523
+ * Skill and rule documents are where an agent READS how to check a gate, so a
524
+ * command spelled unsafely in one is the defect at its source: the pipeline
525
+ * never appears in a script, it appears in the transcript of every agent that
526
+ * followed the instruction. Only `sh`/`bash`/`shell`/`console` fences are read;
527
+ * a fence with no language, or one tagged for another language, is skipped
528
+ * rather than guessed at.
529
+ * @param {string} text - The markdown source.
530
+ * @param {string} file - Repository-relative path, for reporting.
531
+ * @returns {object[]} Sources ready for `inspectShellSource`.
532
+ */
533
+ export function markdownShellSources(text, file) {
534
+ const lines = text.split("\n");
535
+ const sources = [];
536
+ const state = { open: false, start: 0, body: [] };
537
+ for (let index = 0; index < lines.length; index += 1) {
538
+ const fence = /^\s*```+\s*([A-Za-z0-9_+-]*)\s*$/.exec(lines[index]);
539
+ if (!fence) {
540
+ if (state.open) state.body.push(lines[index]);
541
+ continue;
542
+ }
543
+ if (state.open) {
544
+ sources.push({
545
+ // Blank-pad so a reported line number is the line in the FILE, not the
546
+ // line in the fence. A finding nobody can jump to is a finding nobody
547
+ // fixes.
548
+ text: [...Array(state.start).fill(""), ...state.body].join("\n"),
549
+ file,
550
+ location: `fenced shell block starting at line ${state.start + 1}`,
551
+ statusAlwaysRead: false,
552
+ pipefail: false,
553
+ });
554
+ state.open = false;
555
+ state.body = [];
556
+ continue;
557
+ }
558
+ if (!SHELL_FENCE_LANGUAGES.includes(fence[1].toLowerCase())) continue;
559
+ state.open = true;
560
+ state.start = index + 1;
561
+ state.body = [];
562
+ }
563
+ return sources;
564
+ }
565
+
566
+ /**
567
+ * Turn one file into the shell blocks the sweep inspects.
568
+ * @param {{ absolute: string, relative: string }} file - The file to read.
569
+ * @returns {object[]} Zero or more sources.
570
+ */
571
+ export function fileSources(file) {
572
+ const text = readFileSync(file.absolute, "utf8");
573
+ if (file.absolute.endsWith(".md")) {
574
+ return markdownShellSources(text, file.relative);
575
+ }
576
+ if (/\.(sh|bash)$/.test(file.absolute)) {
577
+ return [
578
+ {
579
+ text,
580
+ file: file.relative,
581
+ location: "script body",
582
+ statusAlwaysRead: false,
583
+ pipefail: false,
584
+ },
585
+ ];
586
+ }
587
+ // A YAML file that is not a workflow parses fine and simply yields no `run:`
588
+ // blocks; one that does not parse is skipped rather than failing the sweep,
589
+ // because a template may deliberately hold non-YAML placeholders.
590
+ try {
591
+ return workflowRunSources(yaml.load(text), file.relative);
592
+ } catch {
593
+ return [];
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Run the sweep over a tree.
599
+ * @param {string} repoRoot - Absolute path of the tree to inspect.
600
+ * @param {readonly string[]} [roots] - Sub-directories to scan.
601
+ * @returns {{ inspected: number, files: number, findings: object[] }} Report.
602
+ */
603
+ export function sweep(repoRoot, roots = SCANNED_ROOTS) {
604
+ const report = { inspected: 0, files: 0, findings: [] };
605
+ for (const root of roots) {
606
+ const absolute = path.join(repoRoot, root);
607
+ try {
608
+ if (!statSync(absolute).isDirectory()) continue;
609
+ } catch {
610
+ continue;
611
+ }
612
+ for (const file of collectFiles(absolute, repoRoot)) {
613
+ report.files += 1;
614
+ for (const source of fileSources(file)) {
615
+ const result = inspectShellSource(source);
616
+ report.inspected += result.inspected;
617
+ report.findings.push(...result.findings);
618
+ }
619
+ }
620
+ }
621
+ return report;
622
+ }
623
+
624
+ /**
625
+ * Render the human-readable report.
626
+ * @param {{ inspected: number, files: number, findings: object[] }} report - Result.
627
+ * @returns {string} The report text.
628
+ */
629
+ export function formatReport(report) {
630
+ const lines = [
631
+ `check:pipeline-status-reads — inspected ${report.inspected} pipeline(s) across ${report.files} file(s).`,
632
+ ];
633
+ if (report.inspected === 0) {
634
+ lines.push(
635
+ " ✖ ZERO pipelines inspected. A sweep that parsed nothing cannot report a clean tree; treating this as a failure, not an all-clear."
636
+ );
637
+ return lines.join("\n");
638
+ }
639
+ for (const finding of report.findings) {
640
+ lines.push(
641
+ ` ✖ ${finding.file}:${finding.line} (${finding.location})`,
642
+ ` ${finding.statement}`,
643
+ ` status is \`${finding.lastStage}\`'s, not the command's — ${finding.reason}.`
644
+ );
645
+ }
646
+ if (report.findings.length === 0) {
647
+ lines.push(" ✔ No pipeline hides a command's exit status behind a pager.");
648
+ return lines.join("\n");
649
+ }
650
+ lines.push(
651
+ "",
652
+ "Fix: capture the status before truncating —",
653
+ " status=0",
654
+ ' cmd >"$log" 2>&1 || status=$?',
655
+ ' tail -n 20 "$log"; [ "$status" -eq 0 ] || exit "$status"',
656
+ "`|| status=$?` and not `; status=$?`: under `set -e` the `;` form exits before the assignment, so the branch that reports the failure never runs.",
657
+ "Or put `set -o pipefail` in force where the shell supports it (bash/zsh/ksh, not POSIX `sh`, and not PowerShell, which has no such option)."
658
+ );
659
+ return lines.join("\n");
660
+ }
661
+
662
+ /**
663
+ * CLI entry point.
664
+ * @returns {void}
665
+ */
666
+ export function main() {
667
+ const args = process.argv.slice(2);
668
+ const unknown = args.find(arg => arg.startsWith("--") && arg !== "--json");
669
+ if (unknown) {
670
+ console.error(`check:pipeline-status-reads: unknown flag ${unknown}`);
671
+ process.exitCode = 2;
672
+ return;
673
+ }
674
+ const json = args.includes("--json");
675
+ const repoRoot = path.resolve(args.find(arg => !arg.startsWith("--")) ?? ".");
676
+ const report = sweep(repoRoot);
677
+ console.log(json ? JSON.stringify(report, null, 2) : formatReport(report));
678
+ if (report.inspected === 0) process.exitCode = 2;
679
+ else if (report.findings.length > 0) process.exitCode = 1;
680
+ }
681
+
682
+ if (invokedAsScript(import.meta.url)) {
683
+ main();
684
+ }