@codyswann/lisa 4.45.2 → 4.45.4

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 (98) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +523 -70
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +160 -2
  3. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +115 -12
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  8. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.js +19 -12
  10. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  11. package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +40 -1
  12. package/dist/opencode/plugin-templates/parity-safety-net.sh +115 -12
  13. package/package.json +4 -4
  14. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +16 -0
  17. package/plugins/lisa/hooks/block-blind-automerge.agy.sh +5 -4
  18. package/plugins/lisa/hooks/block-blind-automerge.sh +523 -70
  19. package/plugins/lisa/hooks/block-direct-issue-create.sh +160 -2
  20. package/plugins/lisa/hooks/parity-safety-net.sh +115 -12
  21. package/plugins/lisa/rules/eager/ready-role-filing.md +3 -2
  22. package/plugins/lisa/rules/reference/ready-role-filing.md +20 -2
  23. package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +16 -0
  24. package/plugins/lisa-agy/hooks/block-blind-automerge.agy.sh +5 -4
  25. package/plugins/lisa-agy/hooks/block-blind-automerge.sh +523 -70
  26. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +160 -2
  27. package/plugins/lisa-agy/hooks/parity-safety-net.sh +115 -12
  28. package/plugins/lisa-agy/plugin.json +1 -1
  29. package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +16 -0
  30. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  33. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +523 -70
  37. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +160 -2
  38. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +115 -12
  39. package/plugins/lisa-copilot/rules/eager/ready-role-filing.md +3 -2
  40. package/plugins/lisa-copilot/rules/reference/ready-role-filing.md +20 -2
  41. package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +16 -0
  42. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +523 -70
  44. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +160 -2
  45. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +115 -12
  46. package/plugins/lisa-cursor/rules/ready-role-filing-reference.mdc +20 -2
  47. package/plugins/lisa-cursor/rules/ready-role-filing.mdc +3 -2
  48. package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +16 -0
  49. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-expo-agy/plugin.json +1 -1
  52. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  57. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  62. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  67. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  72. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-rails-agy/plugin.json +1 -1
  77. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  82. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  86. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  87. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  89. package/plugins/src/base/hooks/block-blind-automerge.agy.sh +5 -4
  90. package/plugins/src/base/hooks/block-blind-automerge.sh +523 -70
  91. package/plugins/src/base/hooks/block-direct-issue-create.sh +160 -2
  92. package/plugins/src/base/hooks/parity-safety-net.sh +115 -12
  93. package/plugins/src/base/rules/eager/ready-role-filing.md +3 -2
  94. package/plugins/src/base/rules/reference/ready-role-filing.md +20 -2
  95. package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +16 -0
  96. package/scripts/check-typecheck-tests.mjs +229 -72
  97. package/scripts/lib/dependency-tree.mjs +136 -0
  98. package/typescript/copy-contents/.husky/pre-push +45 -0
@@ -27,6 +27,33 @@
27
27
  * threshold-ratchet as an `allow-list` family, so *adding* an entry is flagged
28
28
  * by a gate somebody already reads.
29
29
  *
30
+ * IT REFUSES TO ANSWER FROM AN UNRESOLVABLE TREE (CodySwannGT/lisa#3913). The
31
+ * second of those ratchet directions is the destructive one: it names files and
32
+ * tells a reader to delete their quarantine entries. With an empty
33
+ * `node_modules` the compiler cannot start, nothing is compiled, no diagnostic
34
+ * is parsed — and "no diagnostic for this file" is exactly the shape of "this
35
+ * file was fixed". The gate reported all 370 quarantined files as cleared and
36
+ * instructed their removal, which is a destructive edit ordered on the strength
37
+ * of a comparison whose inputs never resolved.
38
+ *
39
+ * So there are two ways this refuses instead of reporting, and the second is
40
+ * the general one:
41
+ *
42
+ * - the compiler is not present at the path this spawns it from; and
43
+ * - the compiler signalled failure and produced NOT ONE parseable
44
+ * diagnostic, which also covers a broken tsconfig, an OOM and a crash.
45
+ *
46
+ * Both exit non-zero with the denial first and NO file list, because a gate
47
+ * whose only surface is an exit code cannot invent a third one — see
48
+ * `scripts/lib/dependency-tree.mjs` for why the wording is a contract rather
49
+ * than a string.
50
+ *
51
+ * `--root` EXISTS SO THIS CAN BE POINTED AT A FIXTURE. Deriving the root solely
52
+ * from `import.meta.url` made the failure above reproducible only by copying
53
+ * this file somewhere else, and `check-empty-subject-guards.mjs` reads that
54
+ * token out of this header to decide the gate is probeable at all — without it
55
+ * this sits in that sweep's declared blind spot.
56
+ *
30
57
  * WHAT IS DELIBERATELY NOT DONE HERE: the declared `lib` is not widened. tsc
31
58
  * emits "Try changing the 'lib' compiler option to 'es2023' or later" on the
32
59
  * ES2023 array APIs, and taking that suggestion would have erased the
@@ -38,110 +65,240 @@
38
65
  import { spawnSync } from "node:child_process";
39
66
  import { readFileSync } from "node:fs";
40
67
  import * as path from "node:path";
68
+ import process from "node:process";
41
69
  import { fileURLToPath } from "node:url";
42
70
 
43
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
44
- const QUARANTINE = path.join(ROOT, "typecheck-quarantine.json");
45
- const PROJECT = path.join(ROOT, "tsconfig.tests.json");
71
+ import {
72
+ cannotMeasure,
73
+ INSTALL_COMMAND,
74
+ missingDependencies,
75
+ ranVacuously,
76
+ } from "./lib/dependency-tree.mjs";
77
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
78
+
79
+ const DEFAULT_ROOT = path.resolve(
80
+ path.dirname(fileURLToPath(import.meta.url)),
81
+ ".."
82
+ );
83
+
84
+ /** The gate's name, as it prints in both the report and the refusal. */
85
+ const GATE = "type-correctness (tests)";
86
+
87
+ /** Where the compiler is spawned from, relative to the root. */
88
+ const TSC = path.join("node_modules", "typescript", "bin", "tsc");
46
89
 
47
90
  /** Matches `path/to/file.ts(12,34): error TS1234: message`. */
48
91
  const ERROR_LINE = /^(?<file>[^(]+)\((?<line>\d+),\d+\): error (?<code>TS\d+):/;
49
92
 
50
93
  /**
51
94
  * Read the quarantined paths.
52
- * @returns {{files: string[], reason: string}} The parsed quarantine.
95
+ * @param {string} root - The directory holding the quarantine.
96
+ * @returns {{files: string[], reason: string} | null} The parsed quarantine, or
97
+ * `null` when it could not be read — an unreadable list is a refusal, not a
98
+ * pass, because that file is what makes this gate green.
53
99
  */
54
- function readQuarantine() {
100
+ function readQuarantine(root) {
101
+ const quarantine = path.join(root, "typecheck-quarantine.json");
55
102
  try {
56
- const parsed = JSON.parse(readFileSync(QUARANTINE, "utf-8"));
103
+ const parsed = JSON.parse(readFileSync(quarantine, "utf-8"));
57
104
  return { files: parsed.files ?? [], reason: parsed.reason ?? "" };
58
105
  } catch (error) {
59
106
  console.error(
60
- `check-typecheck-tests: cannot read ${path.relative(ROOT, QUARANTINE)} — ${error.message}`
107
+ `check-typecheck-tests: cannot read ${path.relative(root, quarantine)} — ${error.message}`
61
108
  );
62
109
  console.error(
63
110
  "That file is what makes this gate green, so an unreadable one is a refusal, not a pass."
64
111
  );
65
- process.exit(1);
112
+ return null;
66
113
  }
67
114
  }
68
115
 
69
- const { files: quarantined } = readQuarantine();
70
- const exempt = new Set(quarantined);
71
-
72
- const tsc = spawnSync(
73
- process.execPath,
74
- [
75
- path.join(ROOT, "node_modules", "typescript", "bin", "tsc"),
76
- "--noEmit",
77
- "-p",
78
- PROJECT,
79
- ],
80
- { cwd: ROOT, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }
81
- );
82
-
83
- if (tsc.error) {
84
- console.error(
85
- `check-typecheck-tests: could not run tsc — ${tsc.error.message}`
86
- );
87
- process.exit(1);
116
+ /**
117
+ * Parse tsc's output into a per-file error count.
118
+ * @param {string} output - Combined stdout and stderr.
119
+ * @returns {Map<string, number>} Errors keyed by forward-slashed path.
120
+ */
121
+ function parseDiagnostics(output) {
122
+ /** @type {Map<string, number>} */
123
+ const errorsByFile = new Map();
124
+ for (const line of output.split("\n")) {
125
+ const match = ERROR_LINE.exec(line.trim());
126
+ if (!match?.groups) continue;
127
+ const file = match.groups.file.replace(/\\/g, "/");
128
+ errorsByFile.set(file, (errorsByFile.get(file) ?? 0) + 1);
129
+ }
130
+ return errorsByFile;
88
131
  }
89
132
 
90
- const lines = `${tsc.stdout ?? ""}\n${tsc.stderr ?? ""}`.split("\n");
91
- /** @type {Map<string, number>} */
92
- const errorsByFile = new Map();
93
- for (const line of lines) {
94
- const match = ERROR_LINE.exec(line.trim());
95
- if (!match?.groups) continue;
96
- const file = match.groups.file.replace(/\\/g, "/");
97
- errorsByFile.set(file, (errorsByFile.get(file) ?? 0) + 1);
133
+ /**
134
+ * The refusal issued when nothing was compiled.
135
+ *
136
+ * The denial names the destructive remedy explicitly. "Nothing type-checked" is
137
+ * the inference a reader draws from an empty diagnostic set, and the gate's own
138
+ * cleared-entry message is what turns that inference into a deletion — so this
139
+ * has to deny both, not just the first.
140
+ *
141
+ * NO FILE IS NAMED HERE, deliberately. A list of paths is what makes the
142
+ * destructive message actionable, and a refusal that still printed 370 paths
143
+ * would be followed exactly as often as the finding it replaces.
144
+ *
145
+ * @param {string} because - Why the measurement could not happen.
146
+ * @param {number} quarantined - How many entries went unexamined.
147
+ * @param {string} fix - The action that repairs THIS cause. Named per call
148
+ * site rather than fixed, because telling someone to install dependencies
149
+ * they already have is how a refusal gets dismissed as noise.
150
+ * @returns {string} The refusal block.
151
+ */
152
+ function refuseUnmeasurable(because, quarantined, fix) {
153
+ return cannotMeasure({
154
+ gate: GATE,
155
+ denial: `that any of the ${quarantined} quarantined file(s) now type-check`,
156
+ because,
157
+ remedy:
158
+ `${fix} Do NOT edit typecheck-quarantine.json on the strength of ` +
159
+ `this — no file was compiled, so nothing was established about any ` +
160
+ `entry in it.`,
161
+ });
98
162
  }
99
163
 
100
- const offenders = [...errorsByFile.keys()]
101
- .filter(file => !exempt.has(file))
102
- .sort();
103
- const cleared = quarantined.filter(file => !errorsByFile.has(file)).sort();
164
+ /**
165
+ * Run the gate.
166
+ * @param {string} root - Directory to resolve the project and quarantine under.
167
+ * @returns {number} The process exit code.
168
+ */
169
+ export function main(root) {
170
+ const quarantine = readQuarantine(root);
171
+ if (quarantine === null) return 1;
172
+ const quarantined = quarantine.files;
173
+ const exempt = new Set(quarantined);
104
174
 
105
- const totalErrors = [...errorsByFile.values()].reduce((sum, n) => sum + n, 0);
106
- console.log(
107
- `type-correctness (tests): ${errorsByFile.size} file(s) with ${totalErrors} error(s); ` +
108
- `${quarantined.length} quarantined.`
109
- );
175
+ // PRECONDITION. Checked before the spawn rather than inferred from it: the
176
+ // spawn SUCCEEDS with an empty `node_modules` — node starts, and it is node
177
+ // that cannot find the module — so `tsc.error` never fires and the failure
178
+ // arrives as an empty diagnostic set indistinguishable from a clean run.
179
+ const missing = missingDependencies(root, [TSC]);
180
+ if (missing.length > 0) {
181
+ console.error(
182
+ refuseUnmeasurable(
183
+ `the TypeScript compiler is not installed — no file at ${missing[0]}.`,
184
+ quarantined.length,
185
+ `Run \`${INSTALL_COMMAND}\`, then re-run this gate.`
186
+ )
187
+ );
188
+ return 1;
189
+ }
110
190
 
111
- if (offenders.length > 0) {
112
- console.error("");
113
- console.error(
114
- `❌ ${offenders.length} file(s) outside the quarantine have type errors:`
191
+ const tsc = spawnSync(
192
+ process.execPath,
193
+ [
194
+ path.join(root, TSC),
195
+ "--noEmit",
196
+ "-p",
197
+ path.join(root, "tsconfig.tests.json"),
198
+ ],
199
+ { cwd: root, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }
115
200
  );
116
- for (const file of offenders) {
117
- console.error(` ${file} (${errorsByFile.get(file)})`);
201
+
202
+ if (tsc.error) {
203
+ console.error(
204
+ `check-typecheck-tests: could not run tsc — ${tsc.error.message}`
205
+ );
206
+ return 1;
118
207
  }
119
- console.error("");
120
- console.error(
121
- "Fix them. Do NOT add them to typecheck-quarantine.json — that list"
122
- );
123
- console.error(
124
- "only shrinks, and the threshold-ratchet flags additions to it."
125
- );
126
- }
127
208
 
128
- if (cleared.length > 0) {
129
- console.error("");
130
- console.error(
131
- `❌ ${cleared.length} quarantined file(s) now type-check and must leave the list:`
209
+ const errorsByFile = parseDiagnostics(
210
+ `${tsc.stdout ?? ""}\n${tsc.stderr ?? ""}`
132
211
  );
133
- for (const file of cleared) console.error(` ${file}`);
134
- console.error("");
135
- console.error(
136
- "Remove them from typecheck-quarantine.json. A list that keeps entries"
212
+
213
+ // POSTCONDITION, and the general form of the same defect. The compiler
214
+ // signalled failure and produced nothing this gate can read, so the
215
+ // comparison below would run against an empty set — the exact shape of "every
216
+ // quarantined file is fixed". A ZERO exit with no diagnostics is not this: it
217
+ // is a clean run, and it reports normally.
218
+ if (ranVacuously(tsc.status, errorsByFile.size)) {
219
+ const firstLine = `${tsc.stderr ?? ""}${tsc.stdout ?? ""}`
220
+ .split("\n")
221
+ .map(line => line.trim())
222
+ .find(Boolean);
223
+ console.error(
224
+ refuseUnmeasurable(
225
+ `tsc exited ${tsc.status} without emitting one diagnostic this gate ` +
226
+ `could parse, so the comparison had no subject.` +
227
+ `${firstLine ? ` First output line: ${firstLine}` : ""}`,
228
+ quarantined.length,
229
+ "Fix the cause named above, then re-run this gate."
230
+ )
231
+ );
232
+ return 1;
233
+ }
234
+
235
+ const offenders = [...errorsByFile.keys()]
236
+ .filter(file => !exempt.has(file))
237
+ .sort();
238
+ const cleared = quarantined.filter(file => !errorsByFile.has(file)).sort();
239
+
240
+ const totalErrors = [...errorsByFile.values()].reduce((sum, n) => sum + n, 0);
241
+ console.log(
242
+ `${GATE}: ${errorsByFile.size} file(s) with ${totalErrors} error(s); ` +
243
+ `${quarantined.length} quarantined.`
137
244
  );
138
- console.error(
139
- "after they are fixed would re-authorise them if they ever regressed."
245
+
246
+ if (offenders.length > 0) {
247
+ console.error("");
248
+ console.error(
249
+ `❌ ${offenders.length} file(s) outside the quarantine have type errors:`
250
+ );
251
+ for (const file of offenders) {
252
+ console.error(` ${file} (${errorsByFile.get(file)})`);
253
+ }
254
+ console.error("");
255
+ console.error(
256
+ "Fix them. Do NOT add them to typecheck-quarantine.json — that list"
257
+ );
258
+ console.error(
259
+ "only shrinks, and the threshold-ratchet flags additions to it."
260
+ );
261
+ }
262
+
263
+ if (cleared.length > 0) {
264
+ console.error("");
265
+ console.error(
266
+ `❌ ${cleared.length} quarantined file(s) now type-check and must leave the list:`
267
+ );
268
+ for (const file of cleared) console.error(` ${file}`);
269
+ console.error("");
270
+ console.error(
271
+ "Remove them from typecheck-quarantine.json. A list that keeps entries"
272
+ );
273
+ console.error(
274
+ "after they are fixed would re-authorise them if they ever regressed."
275
+ );
276
+ }
277
+
278
+ if (offenders.length > 0 || cleared.length > 0) return 1;
279
+
280
+ console.log(
281
+ "✅ No type errors outside the quarantine, and no stale quarantine entries."
140
282
  );
283
+ return 0;
141
284
  }
142
285
 
143
- if (offenders.length > 0 || cleared.length > 0) process.exit(1);
286
+ /**
287
+ * Resolve the root from `--root <dir>`, defaulting to the repository.
288
+ * @param {ReadonlyArray<string>} argv - Arguments after the script name.
289
+ * @returns {string} The absolute root.
290
+ */
291
+ export function rootFrom(argv) {
292
+ const flag = argv.indexOf("--root");
293
+ if (flag === -1) return DEFAULT_ROOT;
294
+ const value = argv[flag + 1];
295
+ if (value === undefined) {
296
+ console.error("check-typecheck-tests: --root requires a value");
297
+ process.exit(2);
298
+ }
299
+ return path.resolve(value);
300
+ }
144
301
 
145
- console.log(
146
- "✅ No type errors outside the quarantine, and no stale quarantine entries."
147
- );
302
+ if (invokedAsScript(import.meta.url)) {
303
+ process.exit(main(rootFrom(process.argv.slice(2))));
304
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * dependency-tree — decide whether a gate's dependency tree is resolvable, and
3
+ * say "cannot measure" in one voice when it is not (CodySwannGT/lisa#3913).
4
+ *
5
+ * @remarks
6
+ * ## The defect this exists for
7
+ *
8
+ * The `type-correctness` gate spawns the compiler by PATH:
9
+ * `node node_modules/typescript/bin/tsc`. With an empty `node_modules` the
10
+ * SPAWN still succeeds — node starts, and it is node that then cannot find the
11
+ * module — so the gate's `if (tsc.error)` guard does not fire. node's
12
+ * "Cannot find module" goes to stderr and does not match the gate's
13
+ * `file(line,col): error TSxxxx:` pattern, so the parsed-diagnostics map comes
14
+ * back EMPTY. Every quarantined file then satisfies "has no errors", and the
15
+ * gate reports:
16
+ *
17
+ * ```
18
+ * ❌ 370 quarantined file(s) now type-check and must leave the list:
19
+ * Remove them from typecheck-quarantine.json.
20
+ * ```
21
+ *
22
+ * Following that deletes 370 live quarantine entries on the word of a checker
23
+ * that compiled nothing. The comparison was not wrong — it was correct about an
24
+ * input it was never entitled to accept.
25
+ *
26
+ * ## Why "cannot measure" is a THIRD outcome, not a failure message
27
+ *
28
+ * This is the #3888 family: a control that could not establish its subject must
29
+ * not render identically to one that established it and found nothing. The
30
+ * sibling exemplar is the review gate's `UNDETERMINED` verdict in
31
+ * `check-skipped-required-checks.mjs`, whose own comment states the rule this
32
+ * module follows — resolving an unestablished subject to `success` "would be a
33
+ * new instance of exactly that".
34
+ *
35
+ * A gate whose only surface is an exit code cannot emit a third code that
36
+ * anything reads, so the fallback is fixed: **fail closed, with the denial as
37
+ * the first clause.** Never pass. A reader who skims one line must come away
38
+ * knowing the finding was NOT made, because the natural inference from silence
39
+ * about errors is that there were none.
40
+ *
41
+ * ## The message contract
42
+ *
43
+ * {@link cannotMeasure} takes the clauses rather than a finished string so that
44
+ * no call site can ship three of the four. Every message names:
45
+ *
46
+ * 1. the outcome, in a distinct word (`CANNOT MEASURE`);
47
+ * 2. the inference a reader would otherwise draw, DENIED in words;
48
+ * 3. why the measurement could not happen;
49
+ * 4. what to do — including, where one exists, the destructive remedy that
50
+ * must NOT be followed.
51
+ *
52
+ * Clause 2 is the one that is easy to drop and the only one that stops a
53
+ * destructive edit, so it is a required parameter.
54
+ *
55
+ * @module scripts/lib/dependency-tree
56
+ */
57
+ import { existsSync } from "node:fs";
58
+ import path from "node:path";
59
+
60
+ /** The install command that repairs an unresolvable tree. */
61
+ export const INSTALL_COMMAND = "bun install --frozen-lockfile";
62
+
63
+ /**
64
+ * The distinct outcome word. Deliberately not "ERROR" or "FAILED": those are
65
+ * what the gate prints when it DID measure and found something, which is the
66
+ * confusion this whole module exists to prevent.
67
+ */
68
+ export const CANNOT_MEASURE = "CANNOT MEASURE";
69
+
70
+ /**
71
+ * Names the dependencies a gate needs that are not present under `root`.
72
+ *
73
+ * Presence is tested at the exact path the gate will spawn or import, not by
74
+ * asking whether `node_modules` exists. A partially installed tree — the case
75
+ * that produced the `knip` sibling of this defect, where `node_modules` was
76
+ * present but two binaries were not — has a `node_modules` directory and still
77
+ * cannot answer the question.
78
+ *
79
+ * @param {string} root - Directory the gate resolves dependencies against.
80
+ * @param {ReadonlyArray<string>} relativePaths - Paths under `root` to require,
81
+ * each already spelled the way the gate will use it.
82
+ * @returns {string[]} The missing paths, in the order given.
83
+ */
84
+ export function missingDependencies(root, relativePaths) {
85
+ return relativePaths.filter(
86
+ relative => !existsSync(path.join(root, relative))
87
+ );
88
+ }
89
+
90
+ /**
91
+ * True when a child process signalled failure without producing anything the
92
+ * caller could read as a finding.
93
+ *
94
+ * This is the general form of the defect, and it catches more than an empty
95
+ * `node_modules`: a broken `tsconfig`, an out-of-memory compiler, a crash. The
96
+ * three cases are distinguishable in principle and identical in what they
97
+ * license — nothing.
98
+ *
99
+ * The one ambiguous input resolves the safe way. If a compiler ever emitted
100
+ * real diagnostics in a format the caller's pattern did not match, this reports
101
+ * "could not measure" rather than "no errors" — and "I could not read the
102
+ * output" is the honest account of that too.
103
+ *
104
+ * A zero exit with no diagnostics is NOT this: that is a clean run, and the
105
+ * caller is entitled to report it.
106
+ *
107
+ * @param {number | null} status - The child's exit status.
108
+ * @param {number} findingCount - How many findings the caller parsed out.
109
+ * @returns {boolean} Whether the run was vacuous.
110
+ */
111
+ export function ranVacuously(status, findingCount) {
112
+ return status !== 0 && findingCount === 0;
113
+ }
114
+
115
+ /**
116
+ * Builds the operator-readable `CANNOT MEASURE` block.
117
+ *
118
+ * Returned rather than printed so callers can route it to stderr, a report, or
119
+ * a test assertion without this module deciding.
120
+ *
121
+ * @param {object} clauses - The four required clauses.
122
+ * @param {string} clauses.gate - The gate's own name, as it prints elsewhere.
123
+ * @param {string} clauses.denial - What this is NOT reporting, phrased as the
124
+ * inference a reader would otherwise draw. Required: it is the clause that
125
+ * stops a destructive edit.
126
+ * @param {string} clauses.because - Why the measurement could not happen.
127
+ * @param {string} clauses.remedy - What the reader should do instead.
128
+ * @returns {string} The block, newline-separated, no trailing newline.
129
+ */
130
+ export function cannotMeasure({ gate, denial, because, remedy }) {
131
+ return [
132
+ `${CANNOT_MEASURE} — ${gate} did not run, and is NOT reporting ${denial}.`,
133
+ `Why: ${because}`,
134
+ `Do this: ${remedy}`,
135
+ ].join("\n");
136
+ }
@@ -126,6 +126,51 @@ if ! command -v jq >/dev/null 2>&1; then
126
126
  exit 1
127
127
  fi
128
128
 
129
+ # The DEPENDENCY TREE is proved here too, for the same reason and one worse
130
+ # consequence (CodySwannGT/lisa#3913).
131
+ #
132
+ # Every gate below resolves its tool out of `node_modules` — knip, vitest, tsc.
133
+ # An empty tree does not stop those gates; it makes them answer from nothing,
134
+ # and an empty answer is not silent. Upstream, the `type-correctness` gate is
135
+ # what showed this belongs here rather than inside each gate: with no compiler
136
+ # to run it parsed zero diagnostics, concluded from that emptiness that every
137
+ # quarantined file now compiles, and instructed a reader to delete the
138
+ # quarantine entries protecting them — a destructive edit ordered by a control
139
+ # whose inputs never resolved. Its sibling `knip:check` reports unlisted
140
+ # binaries from the identical cause. That one merely LOOKS odd and nobody acts
141
+ # on it, which is exactly why it went unfixed while the dangerous one hid
142
+ # behind it.
143
+ #
144
+ # So this is not a convenience check for a missing install. Absence of the
145
+ # dependencies and absence of findings must not produce the same observable. A
146
+ # check that cannot RUN is not a skip and not a pass — it blocks.
147
+ if [ -f package.json ]; then
148
+ LISA_DECLARED_DEPS="$(jq -r '((.dependencies // {}) + (.devDependencies // {})) | length' package.json 2>/dev/null)"
149
+ # A non-numeric or absent answer counts as zero: this guard may not be the
150
+ # thing that blocks a push over an unreadable package.json.
151
+ case "$LISA_DECLARED_DEPS" in
152
+ "" | *[!0-9]*) LISA_DECLARED_DEPS=0 ;;
153
+ esac
154
+ # `ls -A` covers absent and present-but-empty in one test, which is the
155
+ # distinction the gates themselves cannot make.
156
+ if [ "$LISA_DECLARED_DEPS" -gt 0 ] && [ -z "$(ls -A node_modules 2>/dev/null)" ]; then
157
+ echo "❌ Push blocked: CANNOT MEASURE — dependencies are not installed."
158
+ echo ""
159
+ echo " package.json declares $LISA_DECLARED_DEPS dependencies and node_modules is"
160
+ echo " absent or empty, so the gates below cannot resolve the tools they"
161
+ echo " run: tsc, knip and vitest."
162
+ echo ""
163
+ echo " This is NOT a finding about your code, and nothing below it is"
164
+ echo " either."
165
+ echo ""
166
+ echo "To fix:"
167
+ echo " bun install --frozen-lockfile # or your package manager's equivalent"
168
+ echo ""
169
+ exit 1
170
+ fi
171
+ unset LISA_DECLARED_DEPS
172
+ fi
173
+
129
174
  # BEGIN: push destination guard
130
175
  # ---------------------------------------------------------------------------
131
176
  # Refuse a push whose RESOLVED destination is a deploy branch it was not aimed