@codyswann/lisa 4.42.5 → 4.42.7

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 (157) hide show
  1. package/README.md +4 -0
  2. package/all/copy-overwrite/scripts/check-orphaned-branches.mjs +7 -0
  3. package/all/copy-overwrite/scripts/lib/process-tree-runner.mjs +12 -2
  4. package/all/copy-overwrite/scripts/lisa-cross-worktree-guard.mjs +4 -0
  5. package/all/copy-overwrite/scripts/lisa-environment-prepare.mjs +2 -0
  6. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +1 -1
  7. package/all/copy-overwrite/scripts/lisa-hooks/worktree-binding-guard.mjs +31 -3
  8. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +4 -0
  9. package/all/copy-overwrite/scripts/lisa-work-item.mjs +13 -0
  10. package/dist/configs/vitest/base.d.ts +1 -1
  11. package/dist/configs/vitest/base.d.ts.map +1 -1
  12. package/dist/configs/vitest/base.js +1 -0
  13. package/dist/configs/vitest/base.js.map +1 -1
  14. package/dist/configs/vitest/cdk.d.ts +2 -2
  15. package/dist/configs/vitest/cdk.d.ts.map +1 -1
  16. package/dist/configs/vitest/cdk.js +5 -2
  17. package/dist/configs/vitest/cdk.js.map +1 -1
  18. package/dist/configs/vitest/coverage-reports-directory.d.ts +47 -0
  19. package/dist/configs/vitest/coverage-reports-directory.d.ts.map +1 -0
  20. package/dist/configs/vitest/coverage-reports-directory.js +131 -0
  21. package/dist/configs/vitest/coverage-reports-directory.js.map +1 -0
  22. package/dist/configs/vitest/harper-fabric.d.ts +2 -2
  23. package/dist/configs/vitest/harper-fabric.d.ts.map +1 -1
  24. package/dist/configs/vitest/harper-fabric.js +5 -2
  25. package/dist/configs/vitest/harper-fabric.js.map +1 -1
  26. package/dist/configs/vitest/nestjs.d.ts +2 -2
  27. package/dist/configs/vitest/nestjs.d.ts.map +1 -1
  28. package/dist/configs/vitest/nestjs.js +5 -2
  29. package/dist/configs/vitest/nestjs.js.map +1 -1
  30. package/dist/configs/vitest/phaser.d.ts +2 -2
  31. package/dist/configs/vitest/phaser.d.ts.map +1 -1
  32. package/dist/configs/vitest/phaser.js +5 -2
  33. package/dist/configs/vitest/phaser.js.map +1 -1
  34. package/dist/configs/vitest/typescript.d.ts +2 -2
  35. package/dist/configs/vitest/typescript.d.ts.map +1 -1
  36. package/dist/configs/vitest/typescript.js +5 -2
  37. package/dist/configs/vitest/typescript.js.map +1 -1
  38. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  39. package/dist/core/lisa-owned-hash-ledger.js +30 -0
  40. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  41. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  42. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  43. package/dist/core/upstream-evidence-manifest.js +32 -25
  44. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  45. package/dist/opencode/plugin-templates/parity-safety-net.sh +1 -1
  46. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +26 -7
  47. package/package.json +5 -4
  48. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +11 -0
  51. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +3 -0
  52. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +3 -0
  53. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/providers.mjs +4 -0
  54. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -0
  55. package/plugins/lisa/hooks/parity-safety-net.sh +1 -1
  56. package/plugins/lisa/hooks/threshold-ratchet.mjs +6 -0
  57. package/plugins/lisa/hooks/worktree-binding-guard.mjs +31 -3
  58. package/plugins/lisa/scripts/design-variable-ids.mjs +2 -0
  59. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +11 -0
  60. package/plugins/lisa/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +3 -0
  61. package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +3 -0
  62. package/plugins/lisa/skills/lisa-secrets-access/scripts/providers.mjs +4 -0
  63. package/plugins/lisa/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -0
  64. package/plugins/lisa-agy/hooks/parity-safety-net.sh +1 -1
  65. package/plugins/lisa-agy/plugin.json +1 -1
  66. package/plugins/lisa-agy/scripts/design-variable-ids.mjs +2 -0
  67. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +11 -0
  68. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +3 -0
  69. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +3 -0
  70. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/providers.mjs +4 -0
  71. package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -0
  72. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  75. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +1 -1
  79. package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +6 -0
  80. package/plugins/lisa-copilot/hooks/worktree-binding-guard.mjs +31 -3
  81. package/plugins/lisa-copilot/scripts/design-variable-ids.mjs +2 -0
  82. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +11 -0
  83. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +3 -0
  84. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +3 -0
  85. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/providers.mjs +4 -0
  86. package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -0
  87. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +1 -1
  89. package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +6 -0
  90. package/plugins/lisa-cursor/hooks/worktree-binding-guard.mjs +31 -3
  91. package/plugins/lisa-cursor/scripts/design-variable-ids.mjs +2 -0
  92. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +11 -0
  93. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +3 -0
  94. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +3 -0
  95. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/providers.mjs +4 -0
  96. package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -0
  97. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-expo-agy/plugin.json +1 -1
  100. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  105. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  109. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  110. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  114. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  115. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  119. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  120. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  122. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  123. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  124. package/plugins/lisa-rails-agy/plugin.json +1 -1
  125. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  126. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  127. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  128. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  129. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  130. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  131. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  132. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  133. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  134. package/plugins/lisa-wiki/scripts/ingest-git.mjs +2 -0
  135. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  136. package/plugins/lisa-wiki-agy/scripts/ingest-git.mjs +2 -0
  137. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  138. package/plugins/lisa-wiki-copilot/scripts/ingest-git.mjs +2 -0
  139. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  140. package/plugins/lisa-wiki-cursor/scripts/ingest-git.mjs +2 -0
  141. package/plugins/src/base/hooks/parity-safety-net.sh +1 -1
  142. package/plugins/src/base/hooks/threshold-ratchet.mjs +6 -0
  143. package/plugins/src/base/hooks/worktree-binding-guard.mjs +31 -3
  144. package/plugins/src/base/scripts/design-variable-ids.mjs +2 -0
  145. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +11 -0
  146. package/plugins/src/base/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +3 -0
  147. package/plugins/src/base/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +3 -0
  148. package/plugins/src/base/skills/lisa-secrets-access/scripts/providers.mjs +4 -0
  149. package/plugins/src/base/skills/lisa-setup-workstation/scripts/workstation.mjs +3 -0
  150. package/plugins/src/wiki/scripts/ingest-git.mjs +2 -0
  151. package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +6 -0
  152. package/scripts/check-merge-driver-registration.mjs +2 -0
  153. package/scripts/check-probe-absence-direction.mjs +611 -0
  154. package/scripts/install-generated-artifact-merge-driver.mjs +7 -1
  155. package/scripts/lib/shell-guard-refusal-coverage.mjs +18 -19
  156. package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +6 -0
  157. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +4 -0
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-probe-absence-direction — refuse a probe that spends a FAILURE as an
4
+ * ABSENCE without saying which way the absence pushes the gate
5
+ * (CodySwannGT/lisa#3848).
6
+ *
7
+ * ## The defect
8
+ *
9
+ * `gh pr view --repo OWNER/NAME --json url,state` with no positional argument
10
+ * is a USAGE ERROR — `argument required when using the --repo flag`, exit 1 —
11
+ * not a lookup that returned no rows. The work-item validator's pull-request
12
+ * lookup passed exactly that combination and read the exit 1 as "this branch
13
+ * has no pull request", so the two gates that need the pull request (the
14
+ * `Work-Item:` line in its body, and the `[lisa-pr-link]` backlink) were never
15
+ * checked at push on any branch. The concrete call was fixed in #3833; the
16
+ * shape it is an instance of is what this file refuses.
17
+ *
18
+ * It was the fourth instrument in one working session to fail in the shape of a
19
+ * fact about its subject — a search hitting its `--limit` read as a complete
20
+ * result set, a jobs endpoint answering from the wrong attempt, an audit
21
+ * printing nothing read as clean, and this. A fifth arrived inside the
22
+ * diagnostic built to catch the other four.
23
+ *
24
+ * ## Direction is the property, not shape
25
+ *
26
+ * `if (result.status !== 0) return undefined;` is the same nine tokens whether
27
+ * it is correct or catastrophic. What separates them is which way the failure
28
+ * pushes the outcome:
29
+ *
30
+ * fail-closed the failure makes the gate STRICTER — no exemption granted,
31
+ * no range excluded, no allowance read. Losing the answer costs
32
+ * a false positive, never a missed one. A legitimate design.
33
+ * neutral the failure changes no gate outcome — the value is reported,
34
+ * formatted, or logged, and no enforcement reads it.
35
+ * fail-open the failure makes the gate LOOSER. This is the defect. There
36
+ * is no rationale that makes it acceptable, so the marker is
37
+ * accepted by the grammar and REFUSED by the check: writing it
38
+ * is how you say "I looked, and it is wrong", not how you
39
+ * register an exemption.
40
+ *
41
+ * Grepping for the pattern and filing every hit is therefore useless — the
42
+ * population is large and mostly correct. What is missing is the direction, and
43
+ * it has to be recorded AT THE LINE, because that is the only place a reviewer
44
+ * later can check it against the code rather than against a table that has gone
45
+ * stale. (One went stale inside #3848's own description: a call site classified
46
+ * "neutral" was neutral only because #3833 had already landed. A classification
47
+ * of call sites looks timeless and is not.)
48
+ *
49
+ * ## What counts as a finding
50
+ *
51
+ * A site is INSPECTED when all three hold:
52
+ *
53
+ * 1. A failure branch: `if (<failure condition>) return <absence>;` (on one
54
+ * line or two), or `catch { return <absence>; }`. A failure condition
55
+ * reads a child process's verdict — `.status !== 0`, `.error`, `!x.ok` —
56
+ * or is a `catch` clause, which is the same fact arriving as a throw.
57
+ * 2. An ABSENCE value: `undefined`, `null`, `[]`, `{}`, `""`, `false`, `0`,
58
+ * `new Map()`, `new Set()`. These are the values a caller reads as a
59
+ * legitimate negative answer.
60
+ * 3. The enclosing function actually ASKS SOMETHING EXTERNAL — it spawns a
61
+ * child, shells out, or fetches. A `JSON.parse` that throws is a malformed
62
+ * input, not a lookup that could not be performed, and including it would
63
+ * bury the findings that matter under hundreds that do not.
64
+ *
65
+ * An inspected site is a FINDING when it carries no direction marker, when its
66
+ * marker has no rationale, or when its marker says `fail-open`.
67
+ *
68
+ * ## The marker
69
+ *
70
+ * probe-direction: fail-closed — losing the ref skips the exclusion, so more
71
+ * commits are examined, never fewer.
72
+ *
73
+ * It may sit in the comment block above the failure branch, as a trailing
74
+ * comment on the branch or its return, or in the enclosing function's JSDoc —
75
+ * whichever is where a reader of that site would look. The rationale is not
76
+ * decoration: `fail-closed` with no sentence saying what gets stricter is the
77
+ * same unchecked assertion the marker exists to replace, so it is refused.
78
+ *
79
+ * ## Why it fails at zero inspected
80
+ *
81
+ * An empty inspection and a clean tree print the same tick, and this repository
82
+ * has shipped guards that reported success while inert often enough to have a
83
+ * rule about it. The count of sites actually parsed is part of the report and a
84
+ * count of zero is exit 2, not exit 0 — a glob that matches nothing, a root
85
+ * that does not exist, and a parser that silently stopped all reach that
86
+ * branch. That is this check declining to commit its own defect.
87
+ *
88
+ * ## Declared blind spots
89
+ *
90
+ * Named rather than hidden, because "the sweep found nothing" and "the sweep
91
+ * does not look there" are the two facts this whole file exists to keep apart.
92
+ * They are printed on every run; see `BLIND_SPOTS`.
93
+ *
94
+ * Determinism: Node built-ins only, no network, no clock, no `Math.random`.
95
+ * The scanned root is a parameter so the suite can point it at a fixture tree
96
+ * holding a known offender.
97
+ *
98
+ * CLI:
99
+ * node scripts/check-probe-absence-direction.mjs [--json] [root]
100
+ *
101
+ * Exit codes (mirroring the sibling check-* scripts):
102
+ * 0 — sites were inspected and every one declares a safe direction.
103
+ * 1 — >=1 finding.
104
+ * 2 — operational error: unknown flag, unreadable root, or ZERO sites
105
+ * inspected.
106
+ *
107
+ * @module scripts/check-probe-absence-direction
108
+ */
109
+ import { readFileSync, readdirSync, statSync } from "node:fs";
110
+ import path from "node:path";
111
+ import process from "node:process";
112
+
113
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
114
+
115
+ /**
116
+ * One refused site.
117
+ * @typedef {object} ProbeFinding
118
+ * @property {string} file - Repo-relative path.
119
+ * @property {number} line - One-based line of the failure branch.
120
+ * @property {string} shape - `catch` or `guarded return`.
121
+ * @property {string} statement - The branch as written.
122
+ * @property {string | undefined} direction - The marker's direction, if any.
123
+ * @property {string} reason - Why the site is refused.
124
+ */
125
+
126
+ /**
127
+ * A completed sweep.
128
+ * @typedef {object} ProbeReport
129
+ * @property {number} inspected - Absence-on-failure sites parsed.
130
+ * @property {number} files - Files read.
131
+ * @property {ProbeFinding[]} findings - Sites that are refused.
132
+ */
133
+
134
+ /** Directories whose JavaScript Lisa authors, ships, or runs as enforcement. */
135
+ export const SCANNED_ROOTS = Object.freeze([
136
+ "all",
137
+ "cdk",
138
+ "expo",
139
+ "harper-fabric",
140
+ "nestjs",
141
+ "npm-package",
142
+ "phaser",
143
+ "plugins/src",
144
+ "rails",
145
+ "scripts",
146
+ "typescript",
147
+ ]);
148
+
149
+ /**
150
+ * What this sweep does NOT look at, and why.
151
+ *
152
+ * Printed on every run. A reader who needs to know whether a surface is covered
153
+ * must not have to infer it from an empty finding list — that inference is the
154
+ * defect being refused, one level up.
155
+ */
156
+ export const BLIND_SPOTS = Object.freeze([
157
+ "src/** and tests/** — Lisa's own TypeScript. It is compiled and unit-tested here rather than executed inside a host's git hook, so a probe there fails in front of a suite, not in front of a gate.",
158
+ "*.sh and workflow `run:` blocks — a shell probe conflating failure with absence is `check:pipeline-status-reads`' subject (#3090); duplicating it here would report one defect twice.",
159
+ "generated plugin payloads (plugins/lisa*) — copies of plugins/src, where the annotation belongs; flagging both would ask for the same marker twice and let the copy drift into being the one that is fixed.",
160
+ "absences delivered by ASSIGNMENT rather than `return` (`let x = null; try { x = probe(); } catch {}`) — detectable only with a real parser, so the sweep declares it rather than pretending the shape does not exist.",
161
+ ]);
162
+
163
+ /** Directory names never descended into: nothing inside is authored here. */
164
+ const SKIPPED_DIRECTORIES = Object.freeze([
165
+ "node_modules",
166
+ "dist",
167
+ ".git",
168
+ "coverage",
169
+ ]);
170
+
171
+ /** File extensions read as JavaScript. */
172
+ const JAVASCRIPT_EXTENSIONS = Object.freeze([".mjs", ".cjs", ".js"]);
173
+
174
+ /**
175
+ * Values a caller reads as a legitimate negative answer.
176
+ *
177
+ * `false` and `0` are here for the same reason `null` is: `runProbe()` handing
178
+ * back `false` because the command could not be started is indistinguishable,
179
+ * at the call site, from it handing back `false` because the probe genuinely
180
+ * said no.
181
+ */
182
+ const ABSENCE_VALUES = Object.freeze([
183
+ "undefined",
184
+ "null",
185
+ "[]",
186
+ "{}",
187
+ '""',
188
+ "''",
189
+ "false",
190
+ "0",
191
+ "new Map()",
192
+ "new Set()",
193
+ ]);
194
+
195
+ /** A `return` of an absence value and nothing else. */
196
+ const ABSENCE_RETURN =
197
+ /^\s*return\s+(?<value>undefined|null|\[\]|\{\}|""|''|false|0|new Map\(\)|new Set\(\))\s*;?\s*$/u;
198
+
199
+ /**
200
+ * Conditions that read a child process's verdict.
201
+ *
202
+ * Deliberately narrow. `if (!rows.length)` is an empty RESULT, which is the
203
+ * honest absence this check exists to protect; only a verdict about whether the
204
+ * command ran belongs here.
205
+ */
206
+ const FAILURE_CONDITIONS = Object.freeze([
207
+ /\.status\s*!==?\s*0/u,
208
+ /\bstatus\s*!==?\s*0/u,
209
+ /\.error\b/u,
210
+ /!\s*\w+(?:\.\w+)*\.ok\b/u,
211
+ /\.ok\s*===?\s*false/u,
212
+ ]);
213
+
214
+ /** Evidence that the enclosing function asks something outside this process. */
215
+ const EXTERNAL_PROBES = Object.freeze([
216
+ /\b(?:spawnSync|spawn|execSync|execFileSync|execFile)\s*\(/u,
217
+ /\b(?:boundedSpawnSync|boundedChildOutput)\s*\(/u,
218
+ /\bfetch\w*\s*\(/u,
219
+ /\ballowFailure\b/u,
220
+ /\b(?:run|git|gh|probe|capture|exec)\s*\(/u,
221
+ ]);
222
+
223
+ /** Where a function whose body holds the branch may begin. */
224
+ const FUNCTION_STARTS = Object.freeze([
225
+ /^\s*(?:export\s+)?(?:async\s+)?function\s+\w+/u,
226
+ /^\s*(?:export\s+)?(?:const|let)\s+\w+\s*=\s*(?:async\s*)?(?:function\b|\()/u,
227
+ /^\s{0,4}(?:async\s+)?\w+\s*\([^)]*\)\s*\{/u,
228
+ ]);
229
+
230
+ /**
231
+ * Whether any pattern in a set matches.
232
+ *
233
+ * The sets are written as several small regexes rather than one alternation
234
+ * because a 58-point regex is unreviewable, and an unreviewable matcher in a
235
+ * check about unreviewable assumptions would be its own joke.
236
+ * @param {readonly RegExp[]} patterns - Patterns to try.
237
+ * @param {string} text - Text to match.
238
+ * @returns {boolean} True when one matches.
239
+ */
240
+ function matchesAny(patterns, text) {
241
+ return patterns.some(pattern => pattern.test(text));
242
+ }
243
+
244
+ /** The marker, its direction, and the rationale that has to follow it. */
245
+ const DIRECTION_MARKER =
246
+ /probe-direction:\s*(?<direction>fail-closed|fail-open|neutral)\s*(?<rationale>.*)$/u;
247
+
248
+ /** Directions that may stand. `fail-open` is the defect, so it is not here. */
249
+ export const PERMITTED_DIRECTIONS = Object.freeze(["fail-closed", "neutral"]);
250
+
251
+ /** Shortest rationale accepted. A word is not a reason. */
252
+ const MINIMUM_RATIONALE = 12;
253
+
254
+ /**
255
+ * Whether a line is comment-only.
256
+ * @param {string} line - Source line.
257
+ * @returns {boolean} True for `//`, `/*`, `*` and `*\/` lines.
258
+ */
259
+ function isCommentLine(line) {
260
+ const trimmed = line.trim();
261
+ return (
262
+ trimmed.startsWith("//") ||
263
+ trimmed.startsWith("/*") ||
264
+ trimmed.startsWith("*")
265
+ );
266
+ }
267
+
268
+ /**
269
+ * The contiguous comment block immediately above a line.
270
+ * @param {readonly string[]} lines - File lines.
271
+ * @param {number} index - Zero-based line the block sits above.
272
+ * @returns {string[]} The block, or an empty array.
273
+ */
274
+ function commentBlockAbove(lines, index) {
275
+ const block = [];
276
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
277
+ const line = lines[cursor];
278
+ if (line.trim() === "") continue;
279
+ if (!isCommentLine(line)) break;
280
+ block.push(line);
281
+ }
282
+ return block;
283
+ }
284
+
285
+ /**
286
+ * The line index where the function holding a branch begins.
287
+ * @param {readonly string[]} lines - File lines.
288
+ * @param {number} index - Zero-based branch line.
289
+ * @returns {number} Zero-based start line, or 0 when none is recognised.
290
+ */
291
+ export function enclosingFunctionStart(lines, index) {
292
+ for (let cursor = index; cursor >= 0; cursor -= 1) {
293
+ if (matchesAny(FUNCTION_STARTS, lines[cursor])) return cursor;
294
+ }
295
+ return 0;
296
+ }
297
+
298
+ /**
299
+ * Whether the code leading up to a branch asks something external.
300
+ * @param {readonly string[]} lines - File lines.
301
+ * @param {number} start - Zero-based function start.
302
+ * @param {number} index - Zero-based branch line.
303
+ * @returns {boolean} True when the branch reports on an external lookup.
304
+ */
305
+ export function probesSomethingExternal(lines, start, index) {
306
+ return matchesAny(EXTERNAL_PROBES, lines.slice(start, index + 1).join("\n"));
307
+ }
308
+
309
+ /**
310
+ * Every line a direction marker for this branch may legitimately appear on.
311
+ *
312
+ * Three places, because three different shapes read naturally in three
313
+ * different spots: a one-line guard wants a trailing or preceding comment, a
314
+ * `catch` inside a small helper wants the helper's JSDoc, and a branch buried
315
+ * in a long function wants the comment right above it.
316
+ * @param {readonly string[]} lines - File lines.
317
+ * @param {{branch: number, end: number}} site - Zero-based branch and last line.
318
+ * @returns {string[]} Candidate lines holding the marker.
319
+ */
320
+ export function annotationWindow(lines, site) {
321
+ const start = enclosingFunctionStart(lines, site.branch);
322
+ return [
323
+ ...commentBlockAbove(lines, site.branch),
324
+ ...commentBlockAbove(lines, start),
325
+ ...lines.slice(site.branch, site.end + 1),
326
+ ];
327
+ }
328
+
329
+ /**
330
+ * Read the direction marker out of a window.
331
+ * @param {readonly string[]} window - Candidate lines.
332
+ * @returns {{direction: string, rationale: string} | undefined} The marker.
333
+ */
334
+ export function readDirection(window) {
335
+ for (const line of window) {
336
+ const match = DIRECTION_MARKER.exec(line);
337
+ if (!match) continue;
338
+ return {
339
+ direction: match.groups.direction,
340
+ rationale: match.groups.rationale.replace(/^[\s—–-]+/u, "").trim(),
341
+ };
342
+ }
343
+ return undefined;
344
+ }
345
+
346
+ /**
347
+ * Why a marker is unacceptable, or undefined when it stands.
348
+ * @param {{direction: string, rationale: string} | undefined} marker - Marker.
349
+ * @returns {string | undefined} The refusal reason.
350
+ */
351
+ export function refusalFor(marker) {
352
+ if (!marker) {
353
+ return "no `probe-direction:` marker — a failure spent as an absence has to say which way the absence pushes the gate";
354
+ }
355
+ if (!PERMITTED_DIRECTIONS.includes(marker.direction)) {
356
+ return "marked `fail-open` — a failure that makes a gate LOOSER is the defect itself; restructure the probe so a caller cannot spend the failure as an answer";
357
+ }
358
+ if (marker.rationale.length < MINIMUM_RATIONALE) {
359
+ return `marked \`${marker.direction}\` with no rationale — say what gets stricter (or what reads the value) so the claim can be checked against the code`;
360
+ }
361
+ return undefined;
362
+ }
363
+
364
+ /**
365
+ * The next line that is neither blank nor comment-only.
366
+ *
367
+ * A `catch` whose return is separated from it by the very marker this check
368
+ * demands must still be RECOGNISED as a catch. Requiring the return on the
369
+ * literally-next line would mean annotating a site removed it from the
370
+ * population — the check would go green because it had stopped looking, which
371
+ * is the defect it exists to refuse, committed by the remedy.
372
+ * @param {readonly string[]} lines - File lines.
373
+ * @param {number} index - Zero-based line to search after.
374
+ * @returns {number} Zero-based index, or -1 at end of file.
375
+ */
376
+ export function nextCodeLine(lines, index) {
377
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
378
+ if (lines[cursor].trim() === "") continue;
379
+ if (isCommentLine(lines[cursor])) continue;
380
+ return cursor;
381
+ }
382
+ return -1;
383
+ }
384
+
385
+ /**
386
+ * Locate the absence-yielding failure branches in one file's lines.
387
+ * @param {readonly string[]} lines - File lines.
388
+ * @returns {{branch: number, end: number, shape: string, statement: string}[]} Sites.
389
+ */
390
+ export function absenceBranches(lines) {
391
+ const sites = [];
392
+ lines.forEach((line, index) => {
393
+ // A comment that QUOTES the shape is not the shape. This check's own
394
+ // header documents `catch { return null; }` in prose, and reading that as a
395
+ // call site is the same category error the check exists to refuse — a
396
+ // string about a thing taken for the thing.
397
+ if (isCommentLine(line)) return;
398
+ const inline =
399
+ /^\s*(?:\}\s*)?if\s*\((?<condition>.+)\)\s*return\s+\S+/u.exec(line);
400
+ if (inline && matchesAny(FAILURE_CONDITIONS, inline.groups.condition)) {
401
+ const value = /return\s+(?<value>.+?)\s*;?\s*$/u.exec(line)?.groups.value;
402
+ if (ABSENCE_VALUES.includes(value)) {
403
+ sites.push({
404
+ branch: index,
405
+ end: index,
406
+ shape: "guarded return",
407
+ statement: line.trim(),
408
+ });
409
+ }
410
+ return;
411
+ }
412
+ const returnAt = nextCodeLine(lines, index);
413
+ const next = returnAt === -1 ? "" : lines[returnAt];
414
+ if (!ABSENCE_RETURN.test(next)) return;
415
+ if (
416
+ /^\s*if\s*\(.+\)\s*$/u.test(line) &&
417
+ matchesAny(FAILURE_CONDITIONS, line)
418
+ ) {
419
+ sites.push({
420
+ branch: index,
421
+ end: returnAt,
422
+ shape: "guarded return",
423
+ statement: `${line.trim()} ${next.trim()}`,
424
+ });
425
+ return;
426
+ }
427
+ if (/catch\s*(?:\([^)]*\))?\s*\{\s*$/u.test(line)) {
428
+ sites.push({
429
+ branch: index,
430
+ end: returnAt,
431
+ shape: "catch",
432
+ statement: `${line.trim()} ${next.trim()}`,
433
+ });
434
+ }
435
+ });
436
+ return [...sites, ...inlineCatchBranches(lines)];
437
+ }
438
+
439
+ /**
440
+ * The `catch { return null; }` form, whose return shares the catch's line.
441
+ * @param {readonly string[]} lines - File lines.
442
+ * @returns {{branch: number, end: number, shape: string, statement: string}[]} Sites.
443
+ */
444
+ function inlineCatchBranches(lines) {
445
+ const sites = [];
446
+ lines.forEach((line, index) => {
447
+ if (isCommentLine(line)) return;
448
+ const match =
449
+ /catch\s*(?:\([^)]*\))?\s*\{\s*return\s+(?<value>.+?)\s*;?\s*\}/u.exec(
450
+ line
451
+ );
452
+ if (!match || !ABSENCE_VALUES.includes(match.groups.value)) return;
453
+ sites.push({
454
+ branch: index,
455
+ end: index,
456
+ shape: "catch",
457
+ statement: line.trim(),
458
+ });
459
+ });
460
+ return sites;
461
+ }
462
+
463
+ /**
464
+ * Inspect one file.
465
+ * @param {string} text - File contents.
466
+ * @param {string} file - Repo-relative path, for reporting.
467
+ * @returns {ProbeReport} Result for this file.
468
+ */
469
+ export function inspectSource(text, file) {
470
+ const lines = text.split("\n");
471
+ const result = { inspected: 0, findings: [] };
472
+ for (const site of absenceBranches(lines)) {
473
+ const start = enclosingFunctionStart(lines, site.branch);
474
+ if (!probesSomethingExternal(lines, start, site.branch)) continue;
475
+ result.inspected += 1;
476
+ const marker = readDirection(annotationWindow(lines, site));
477
+ const refusal = refusalFor(marker);
478
+ if (!refusal) continue;
479
+ result.findings.push({
480
+ file,
481
+ line: site.branch + 1,
482
+ shape: site.shape,
483
+ statement: site.statement,
484
+ direction: marker?.direction,
485
+ reason: refusal,
486
+ });
487
+ }
488
+ return result;
489
+ }
490
+
491
+ /**
492
+ * Every JavaScript file under a directory.
493
+ * @param {string} absolute - Directory to walk.
494
+ * @param {string} repoRoot - Root the reported paths are relative to.
495
+ * @returns {{absolute: string, relative: string}[]} Files in walk order.
496
+ */
497
+ export function collectFiles(absolute, repoRoot) {
498
+ const files = [];
499
+ for (const entry of readdirSync(absolute, { withFileTypes: true })) {
500
+ if (SKIPPED_DIRECTORIES.includes(entry.name)) continue;
501
+ const child = path.join(absolute, entry.name);
502
+ if (entry.isDirectory()) {
503
+ files.push(...collectFiles(child, repoRoot));
504
+ continue;
505
+ }
506
+ if (!JAVASCRIPT_EXTENSIONS.includes(path.extname(entry.name))) continue;
507
+ files.push({ absolute: child, relative: path.relative(repoRoot, child) });
508
+ }
509
+ return files;
510
+ }
511
+
512
+ /**
513
+ * Run the sweep over a tree.
514
+ * @param {string} repoRoot - Absolute path of the tree to inspect.
515
+ * @param {readonly string[]} [roots] - Sub-directories to scan.
516
+ * @returns {ProbeReport} Report.
517
+ */
518
+ export function sweep(repoRoot, roots = SCANNED_ROOTS) {
519
+ const report = { inspected: 0, files: 0, findings: [] };
520
+ for (const root of roots) {
521
+ const absolute = path.join(repoRoot, root);
522
+ try {
523
+ if (!statSync(absolute).isDirectory()) continue;
524
+ } catch {
525
+ continue;
526
+ }
527
+ for (const file of collectFiles(absolute, repoRoot)) {
528
+ report.files += 1;
529
+ const result = inspectSource(
530
+ readFileSync(file.absolute, "utf8"),
531
+ file.relative
532
+ );
533
+ report.inspected += result.inspected;
534
+ report.findings.push(...result.findings);
535
+ }
536
+ }
537
+ report.findings.sort(
538
+ (left, right) =>
539
+ left.file.localeCompare(right.file) || left.line - right.line
540
+ );
541
+ return report;
542
+ }
543
+
544
+ /**
545
+ * Render the human-readable report.
546
+ * @param {ProbeReport} report - Result.
547
+ * @returns {string} The report text.
548
+ */
549
+ export function formatReport(report) {
550
+ const lines = [
551
+ `check:probe-absence-direction — inspected ${report.inspected} absence-on-failure site(s) across ${report.files} file(s).`,
552
+ " Not audited by this sweep:",
553
+ ...BLIND_SPOTS.map(spot => ` · ${spot}`),
554
+ ];
555
+ if (report.inspected === 0) {
556
+ lines.push(
557
+ " ✖ ZERO sites inspected. A sweep that parsed nothing cannot report a clean tree; treating this as a failure, not an all-clear."
558
+ );
559
+ return lines.join("\n");
560
+ }
561
+ for (const finding of report.findings) {
562
+ lines.push(
563
+ ` ✖ ${finding.file}:${finding.line} (${finding.shape})`,
564
+ ` ${finding.statement}`,
565
+ ` ${finding.reason}.`
566
+ );
567
+ }
568
+ if (report.findings.length === 0) {
569
+ lines.push(
570
+ " ✔ Every probe that spends a failure as an absence declares which way the absence pushes the gate."
571
+ );
572
+ return lines.join("\n");
573
+ }
574
+ lines.push(
575
+ "",
576
+ "Fix: record the direction at the line, in the comment above the branch, on the branch itself, or in the enclosing function's JSDoc —",
577
+ " // probe-direction: fail-closed — an unresolvable ref excludes nothing, so the range examined grows rather than shrinks.",
578
+ " // probe-direction: neutral — the value is printed in the report; no gate reads it.",
579
+ "`fail-open` is not an exemption. If the failure makes a gate looser, the probe has to stop being able to hand a failure back as an answer:",
580
+ "return a three-state result (found / absent / could-not-ask), or let the failure throw."
581
+ );
582
+ return lines.join("\n");
583
+ }
584
+
585
+ /**
586
+ * CLI entry point.
587
+ * @returns {void}
588
+ */
589
+ export function main() {
590
+ const args = process.argv.slice(2);
591
+ const unknown = args.find(arg => arg.startsWith("--") && arg !== "--json");
592
+ if (unknown) {
593
+ console.error(`check:probe-absence-direction: unknown flag ${unknown}`);
594
+ process.exitCode = 2;
595
+ return;
596
+ }
597
+ const json = args.includes("--json");
598
+ const repoRoot = path.resolve(args.find(arg => !arg.startsWith("--")) ?? ".");
599
+ const report = sweep(repoRoot);
600
+ console.log(
601
+ json
602
+ ? JSON.stringify({ ...report, blindSpots: BLIND_SPOTS }, null, 2)
603
+ : formatReport(report)
604
+ );
605
+ if (report.inspected === 0) process.exitCode = 2;
606
+ else if (report.findings.length > 0) process.exitCode = 1;
607
+ }
608
+
609
+ if (invokedAsScript(import.meta.url)) {
610
+ main();
611
+ }
@@ -116,7 +116,13 @@ function mappedPaths(root) {
116
116
  }
117
117
  if (patterns.length === 0) return [];
118
118
  const attr = git(["check-attr", "merge", "--", ...patterns], root);
119
- if (!attr.ok) return [];
119
+ // probe-direction: fail-closed — when `check-attr` cannot be asked, fall back
120
+ // to the patterns `.gitattributes` actually declares. Returning [] instead
121
+ // made the caller print "nothing in this working tree maps to the driver —
122
+ // nothing to register" and exit 0, so a git that failed was reported as a
123
+ // checkout that needs nothing. The fallback costs a redundant registration;
124
+ // the empty list cost a silently skipped one.
125
+ if (!attr.ok) return patterns;
120
126
  return attr.stdout
121
127
  .split("\n")
122
128
  .filter(line => line.endsWith(`merge: ${GENERATED_ARTIFACT_DRIVER}`))
@@ -117,26 +117,25 @@ export function guardPopulation(root) {
117
117
  .filter(file => file.length > 0)
118
118
  .filter(file => !NOT_GUARDS.some(prefix => file.startsWith(prefix)))
119
119
  .sort((left, right) => left.localeCompare(right));
120
- return tracked.flatMap(file => {
121
- let bytes;
122
- try {
123
- bytes = readFileSync(path.join(root, file));
124
- } catch {
125
- return [];
126
- }
120
+ return tracked.map(file => {
121
+ // probe-direction: fail-closed — an unreadable TRACKED guard RAISES rather
122
+ // than dropping out of the roster. `git ls-files` has already proved the
123
+ // path is tracked, so a read failure is "could not ask", and the previous
124
+ // `catch { return []; }` spent it as "this guard does not exist" — which
125
+ // silently shrinks the population the coverage check is measured against,
126
+ // so a guard stops being covered with nothing saying so. This is the
127
+ // population, not a sample: it must be all of it or an error.
128
+ const bytes = readFileSync(path.join(root, file));
127
129
  const source = bytes.toString("utf8");
128
- return [
129
- {
130
- path: file,
131
- sha256: createHash("sha256").update(bytes).digest("hex"),
132
- size: bytes.length,
133
- canRefuse: /\bexit[ \t]+[1-9]\d*\b/u.test(source),
134
- toolBoundary:
135
- (file.includes("/hooks/") ||
136
- /tool_input|"file_path"/u.test(source)) &&
137
- /\bexit[ \t]+2\b/u.test(source),
138
- },
139
- ];
130
+ return {
131
+ path: file,
132
+ sha256: createHash("sha256").update(bytes).digest("hex"),
133
+ size: bytes.length,
134
+ canRefuse: /\bexit[ \t]+[1-9]\d*\b/u.test(source),
135
+ toolBoundary:
136
+ (file.includes("/hooks/") || /tool_input|"file_path"/u.test(source)) &&
137
+ /\bexit[ \t]+2\b/u.test(source),
138
+ };
140
139
  });
141
140
  }
142
141
 
@@ -124,6 +124,10 @@ function git(args, cwd) {
124
124
  timeout: 30_000,
125
125
  });
126
126
  } catch {
127
+ // probe-direction: fail-closed — null never reaches a verdict as "no
128
+ // weakening found". `changePlan` turns it into `undeterminable`, which
129
+ // REFUSES, and `isPromotion` falls to the strict reading that keeps the
130
+ // allow list pinned to the baseline.
127
131
  return null;
128
132
  }
129
133
  }
@@ -169,6 +173,8 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
169
173
  try {
170
174
  return fs.readFileSync(path.join(root, f), "utf-8");
171
175
  } catch {
176
+ // probe-direction: fail-closed — an unreadable current file compares as
177
+ // absent against the baseline, which is the strict side of the ratchet.
172
178
  return null;
173
179
  }
174
180
  },