@codyswann/lisa 3.47.4 → 3.47.6

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 (86) hide show
  1. package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +270 -0
  2. package/all/copy-overwrite/scripts/lisa-gates.mjs +30 -0
  3. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +294 -34
  4. package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +126 -32
  5. package/all/copy-overwrite/scripts/lisa-work-item.mjs +83 -26
  6. package/dist/configs/eslint/expo.d.ts +50 -1
  7. package/dist/configs/eslint/expo.d.ts.map +1 -1
  8. package/dist/configs/eslint/expo.js +86 -5
  9. package/dist/configs/eslint/expo.js.map +1 -1
  10. package/dist/core/downstream-names.d.ts +83 -0
  11. package/dist/core/downstream-names.d.ts.map +1 -0
  12. package/dist/core/downstream-names.js +289 -0
  13. package/dist/core/downstream-names.js.map +1 -0
  14. package/dist/core/downstream-references.d.ts +21 -6
  15. package/dist/core/downstream-references.d.ts.map +1 -1
  16. package/dist/core/downstream-references.js +19 -16
  17. package/dist/core/downstream-references.js.map +1 -1
  18. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  19. package/dist/core/lisa-owned-hash-ledger.js +16 -0
  20. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  21. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  22. package/dist/core/upstream-evidence-manifest.js +23 -10
  23. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  24. package/dist/migrations/ensure-tsconfig-local-includes.d.ts +1 -1
  25. package/dist/migrations/ensure-tsconfig-local-includes.js +1 -1
  26. package/expo/copy-overwrite/eslint.config.ts +9 -0
  27. package/expo/copy-overwrite/eslint.expo.ts +92 -4
  28. package/package.json +5 -2
  29. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa/rules/reference/design-value-binding.md +4 -0
  32. package/plugins/lisa-agy/plugin.json +1 -1
  33. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  36. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-copilot/rules/reference/design-value-binding.md +4 -0
  40. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +4 -0
  42. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-expo-agy/plugin.json +1 -1
  45. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  60. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  65. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-rails-agy/plugin.json +1 -1
  70. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  75. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  80. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/src/base/rules/reference/design-value-binding.md +4 -0
  83. package/scripts/lisa-mutation.mjs +13 -0
  84. package/typescript/copy-contents/.husky/pre-push +7 -1
  85. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +496 -122
  86. package/typescript/package-lisa/package.lisa.json +1 -0
@@ -18,11 +18,19 @@
18
18
  *
19
19
  * - An `optional` gate that FAILS prints as FAILED. It does not block, and it
20
20
  * is not swallowed. "Optional" governs the response, never the verdict.
21
- * - When a required gate fails we stop, and every gate after it prints as
22
- * NOT RUN. Fail-fast is what the hardcoded hooks always did; what would be
23
- * new — and wrong — is letting an unrun gate look proved.
21
+ * - When a required gate fails, the cheap gates behind it still run and the
22
+ * COSTLY ones print as NOT RUN with their verdict called UNKNOWN. What would
23
+ * be new — and wrong — is letting an unrun gate look proved, or letting it
24
+ * look like a gate that had nothing to run.
24
25
  * - A gate that resolves to no command is UNPROVABLE, not passing. Nothing
25
26
  * executed, so nothing was proved, whatever its level says.
27
+ * - A FAILED gate says WHICH failure it was, and WHOSE it was. `exit 1` is the
28
+ * same sentence for a coverage regression and for a subprocess starved past
29
+ * its timeout, and when two opposite facts share a sentence the cheaper
30
+ * response — re-run — is the rational one for both, so the real regression is
31
+ * never looked at. A gate that merely SHARES the failing prover reports
32
+ * UNPROVABLE rather than FAILED: it blocks, and it stops claiming a
33
+ * measurement that never happened.
26
34
  *
27
35
  * ## Exit codes are the hook's control flow
28
36
  *
@@ -34,9 +42,17 @@
34
42
  */
35
43
 
36
44
  import { spawnSync } from "node:child_process";
37
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
45
+ import {
46
+ existsSync,
47
+ mkdtempSync,
48
+ readFileSync,
49
+ rmSync,
50
+ writeFileSync,
51
+ } from "node:fs";
52
+ import { tmpdir } from "node:os";
38
53
  import { join } from "node:path";
39
54
 
55
+ import { diagnoseFailure } from "./lib/gate-failure-diagnosis.mjs";
40
56
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
41
57
  import { readGates, resolveMoment } from "./lisa-gates.mjs";
42
58
 
@@ -170,6 +186,8 @@ export const CONDITIONAL_FLOOR = Object.freeze({
170
186
  * @property {string} state One of `STATE`.
171
187
  * @property {string} detail The command, the skip reason, or the failure note.
172
188
  * @property {number|null} code Exit code; null when nothing ran or was killed.
189
+ * @property {string|null} [diagnosis] Which failure this was, from `DIAGNOSIS`.
190
+ * @property {string[]} [evidence] Concrete lines backing the diagnosis.
173
191
  */
174
192
 
175
193
  /**
@@ -177,10 +195,12 @@ export const CONDITIONAL_FLOOR = Object.freeze({
177
195
  * @typedef {object} GateRun
178
196
  * @property {string} moment The moment that was run.
179
197
  * @property {boolean} blocked Whether a required gate went unproved.
198
+ * @property {string|null} blockedBy The first required gate that went unproved.
180
199
  * @property {number} total Gates declared at this moment.
181
200
  * @property {GateOutcome[]} results Every gate, in execution order.
182
201
  * @property {GateOutcome[]} passed Gates that ran and exited zero.
183
202
  * @property {GateOutcome[]} failed Gates that failed or could not be proved.
203
+ * @property {GateOutcome[]} unprovable Gates that ran and proved nothing.
184
204
  * @property {GateOutcome[]} skipped Gates with nothing to run locally.
185
205
  * @property {GateOutcome[]} notRun Gates queued behind a blocking failure.
186
206
  */
@@ -232,26 +252,102 @@ function formatLine(state, gate, detail) {
232
252
  return ` ${token}${gate.level.padEnd(9)}${id}${detail}`;
233
253
  }
234
254
 
255
+ /**
256
+ * Read an executor's answer, whichever of the two shapes it returned.
257
+ *
258
+ * An executor may answer with a bare exit code or with `{code, output}`. Both
259
+ * are supported on purpose: the exit code is the whole verdict, and the output
260
+ * is only ever used to EXPLAIN a verdict already reached. An executor that
261
+ * cannot capture output — a stub, a Windows shell, a run with capture switched
262
+ * off — therefore loses the diagnosis and nothing else.
263
+ * @param {number|null|undefined|{code: number|null, output: string|null}} raw
264
+ * Whatever the executor returned.
265
+ * @returns {{code: number|null, output: string|null}} The normalised answer.
266
+ */
267
+ function normaliseExec(raw) {
268
+ if (raw !== null && typeof raw === "object") {
269
+ return {
270
+ code: typeof raw.code === "number" ? raw.code : null,
271
+ output: typeof raw.output === "string" ? raw.output : null,
272
+ };
273
+ }
274
+ return { code: typeof raw === "number" ? raw : null, output: null };
275
+ }
276
+
235
277
  /**
236
278
  * Execute one gate and classify the result.
237
279
  *
238
280
  * A `null` or `undefined` exit code — which `spawnSync` produces when a child
239
281
  * is killed by a signal — is a failure. It is emphatically not a pass: the
240
282
  * command was terminated, so whatever it was proving went unproved.
283
+ *
284
+ * A failure carries WHICH failure it was. `exit 1` alone cannot distinguish a
285
+ * coverage regression from a starved subprocess, so the two render identically
286
+ * and an operator's only rational response to either is to re-run — which is
287
+ * how a real regression hides behind a flake.
241
288
  * @param {GateOutcome} gate The resolved gate.
242
- * @param {function(string, GateOutcome): (number|null)} exec Command executor.
243
- * @returns {{state: string, detail: string, code: number|null}} The outcome.
289
+ * @param {function(string, GateOutcome): (number|null|object)} exec Executor.
290
+ * @returns {{state: string, detail: string, code: number|null,
291
+ * diagnosis: string|null, evidence: string[]}} The outcome.
244
292
  */
245
293
  function execute(gate, exec) {
246
- const code = exec(gate.command, gate);
294
+ const { code, output } = normaliseExec(exec(gate.command, gate));
247
295
  if (code === 0) {
248
- return { state: STATE.PASSED, detail: gate.command, code: 0 };
296
+ return {
297
+ state: STATE.PASSED,
298
+ detail: gate.command,
299
+ code: 0,
300
+ diagnosis: null,
301
+ evidence: [],
302
+ };
249
303
  }
250
304
  const shown = typeof code === "number" ? code : "terminated";
305
+ const diagnosis = diagnoseFailure(output);
251
306
  return {
252
307
  state: STATE.FAILED,
253
- detail: `${gate.command} (exit ${shown})`,
308
+ detail: `${gate.command} (exit ${shown}) — ${diagnosis.summary}`,
254
309
  code: typeof code === "number" ? code : null,
310
+ diagnosis: diagnosis.kind,
311
+ evidence: diagnosis.evidence,
312
+ proves: diagnosis.proves,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Re-attribute one command's failure to the property it actually belongs to.
318
+ *
319
+ * Saying WHICH failure it was leaves half the defect standing. Two gates
320
+ * legitimately share one prover, so when that prover exits nonzero the runner
321
+ * still reports the failure against BOTH — and one of the two properties was
322
+ * never measured at all. A starved suite therefore still printed
323
+ * `coverage-adequacy FAILED`, now with a sentence attached saying it was not a
324
+ * coverage problem: better, and still a gate claiming a verdict it does not
325
+ * have.
326
+ *
327
+ * So a gate that shares a failing prover with the property the transcript
328
+ * actually indicts reports UNPROVABLE instead. It still blocks — an unmeasured
329
+ * required property is not a pass — but it stops asserting a measurement that
330
+ * never happened, which is what made a real coverage regression indistinguishable
331
+ * from a busy machine.
332
+ *
333
+ * `siblings` is the guard that keeps this honest: an attribution may only ever
334
+ * move a verdict onto a gate that is itself declared on this same command at
335
+ * this same moment. A phrase in some unrelated tool's output cannot invent one.
336
+ * @param {GateOutcome} gate The gate the verdict is being reported for.
337
+ * @param {object} outcome The outcome the command produced.
338
+ * @param {Set<string>} siblings Gate ids sharing this command at this moment.
339
+ * @returns {object} The outcome as it should read for THIS gate.
340
+ */
341
+ function attributed(gate, outcome, siblings) {
342
+ if (outcome.state !== STATE.FAILED) return outcome;
343
+ const owner = outcome.proves;
344
+ if (!owner || owner === gate.id || !siblings.has(owner)) return outcome;
345
+ return {
346
+ ...outcome,
347
+ state: STATE.UNPROVABLE,
348
+ detail:
349
+ `${outcome.detail}. That is a ${owner} failure, so ${gate.id} was ` +
350
+ `never measured by this run`,
255
351
  };
256
352
  }
257
353
 
@@ -284,7 +380,8 @@ function classifyStatic(gate) {
284
380
  */
285
381
  function summarise(result) {
286
382
  const lines = [];
287
- const byLevel = level => result.failed.filter(entry => entry.level === level);
383
+ const truly = result.failed.filter(entry => entry.state === STATE.FAILED);
384
+ const byLevel = level => truly.filter(entry => entry.level === level);
288
385
  const failedOptional = byLevel("optional");
289
386
 
290
387
  for (const entry of byLevel("required")) {
@@ -296,19 +393,44 @@ function summarise(result) {
296
393
  `(reported, not blocking)`
297
394
  );
298
395
  }
396
+ // Its own headline, and deliberately not the word "failed". A property
397
+ // nobody measured has not been found wanting, and saying it failed sends the
398
+ // reader hunting a regression that may not exist — which is exactly what six
399
+ // sightings of a starved suite each cost.
400
+ for (const entry of result.unprovable) {
401
+ lines.push(
402
+ `❓ ${entry.level} gate NOT PROVED: ${entry.id} — ${entry.detail}`
403
+ );
404
+ }
405
+ // Two different sentences on purpose. Rendering these as one "skipped"
406
+ // bucket is what makes an early failure produce a report whose later gates
407
+ // are silently unknown, with nothing telling the reader that they are.
299
408
  if (result.notRun.length) {
300
409
  lines.push(
301
- `⏭️ ${result.notRun.length} gate(s) NOT RUN after the blocking failure: ` +
302
- `${result.notRun.map(entry => entry.id).join(", ")}`
410
+ `❓ ${result.notRun.length} gate(s) UNKNOWN never ran, verdict not ` +
411
+ `established: ${result.notRun.map(entry => entry.id).join(", ")}`
412
+ );
413
+ lines.push(
414
+ ` Each of those may pass or fail; this run does not say which. ` +
415
+ `${result.blockedBy} failed first and stopped them.`
303
416
  );
304
417
  }
305
- if (result.blocked) return lines;
418
+ if (result.blocked) {
419
+ if (result.skipped.length) {
420
+ lines.push(
421
+ `⏭️ ${result.skipped.length} gate(s) NOT APPLICABLE here — their ` +
422
+ `verdict IS established, there was nothing to run: ` +
423
+ `${result.skipped.map(entry => entry.id).join(", ")}`
424
+ );
425
+ }
426
+ return lines;
427
+ }
306
428
 
307
429
  // Every count is stated, including the ones that are zero, so the headline
308
430
  // can never imply more was proved than actually ran.
309
431
  const counts =
310
432
  `${result.passed.length} proved, ${failedOptional.length} failed ` +
311
- `(optional), ${result.skipped.length} not provable here`;
433
+ `(optional), ${result.skipped.length} not applicable here`;
312
434
  lines.push(
313
435
  `${failedOptional.length ? "⚠️ " : "✅"} ${result.moment}: ${counts}, ` +
314
436
  `of ${result.total} gate(s) declared.${
@@ -332,33 +454,62 @@ function summarise(result) {
332
454
  * would understate what was proved — the mirror image of overstating it, and
333
455
  * just as untrue.
334
456
  * @param {GateOutcome} gate The resolved gate.
335
- * @param {{proved: Map<string, object>, blocked: boolean, exec: Function}} ctx Run state.
457
+ * @param {{proved: Map<string, object>, blockedBy: string|null, exec: Function}} ctx Run state.
336
458
  * @returns {{outcome: object, shared: object|undefined, ran: boolean}} The verdict.
337
459
  */
338
- function verdictFor(gate, { proved, blocked, exec }) {
460
+ function verdictFor(gate, { proved, blockedBy, exec, siblings }) {
339
461
  const own = classifyStatic(gate);
340
462
  if (own) return { outcome: own, shared: undefined, ran: false };
341
463
 
342
464
  const shared = gate.command ? proved.get(gate.command) : undefined;
343
465
  if (shared) {
466
+ // The RAW outcome is what is shared, and each gate reads it for itself.
467
+ // Sharing the already-attributed view would hand the second gate the first
468
+ // gate's reading of whose failure it was, which is how one exit code came
469
+ // to be reported as two failures in the first place.
470
+ const view = attributed(gate, shared.outcome, siblings);
344
471
  return {
345
472
  outcome: {
346
- ...shared.outcome,
347
- detail: `${shared.outcome.detail} (proved by the ${shared.id} run)`,
473
+ ...view,
474
+ detail: `${view.detail} (proved by the ${shared.id} run)`,
348
475
  },
349
476
  shared,
350
477
  ran: false,
351
478
  };
352
479
  }
353
480
 
354
- if (blocked) {
481
+ // "Not run" and "not applicable" are opposite facts and must never render
482
+ // as one another. A SKIPPED gate has a known verdict — there was nothing to
483
+ // run here, and that is the answer. This one has NO verdict: it might pass,
484
+ // it might fail, and this run does not say. Naming the blocker is what lets
485
+ // a reader tell which of the two they are looking at without guessing.
486
+ //
487
+ // Only COSTLY gates stop, though. Short-circuiting everything is what let an
488
+ // intermittent test failure take the work-item check and the type check with
489
+ // it — two checks that finish in under a minute and answer questions a test
490
+ // suite says nothing about. Those now run, so one attempt reports everything
491
+ // that is wrong. A second full suite still does not: paying minutes for
492
+ // information about a push that cannot land is a bad trade.
493
+ if (blockedBy && gate.costly) {
355
494
  return {
356
- outcome: { state: STATE.NOT_RUN, detail: "not run", code: null },
495
+ outcome: {
496
+ state: STATE.NOT_RUN,
497
+ detail:
498
+ `verdict UNKNOWN — never ran; ${blockedBy} failed first and this ` +
499
+ `gate is too expensive to run for information alone`,
500
+ code: null,
501
+ },
357
502
  shared: undefined,
358
503
  ran: false,
359
504
  };
360
505
  }
361
- return { outcome: execute(gate, exec), shared: undefined, ran: true };
506
+ const raw = execute(gate, exec);
507
+ return {
508
+ outcome: attributed(gate, raw, siblings),
509
+ raw,
510
+ shared: undefined,
511
+ ran: true,
512
+ };
362
513
  }
363
514
 
364
515
  /**
@@ -384,7 +535,24 @@ export function runGates({
384
535
  }) {
385
536
  const resolved = resolveMoment({ gates, moment, runner });
386
537
  const results = [];
387
- let blocked = false;
538
+ // The id of the first required gate to go unproved, not merely a boolean:
539
+ // every gate queued behind it has to be able to say WHAT stopped it, or its
540
+ // line is indistinguishable from a gate that had nothing to run.
541
+ let blockedBy = null;
542
+
543
+ // Which gates name each command, built before anything runs. Attribution
544
+ // needs the answer on the FIRST execution: gates run alphabetically, so the
545
+ // gate that executes a shared prover is usually not the gate whose property
546
+ // the failure belongs to — `coverage-adequacy` runs `test:cov` and
547
+ // `test-correctness` is the one the timeout indicts.
548
+ const sharers = new Map();
549
+ for (const gate of resolved) {
550
+ if (!gate.command) continue;
551
+ sharers.set(
552
+ gate.command,
553
+ (sharers.get(gate.command) ?? new Set()).add(gate.id)
554
+ );
555
+ }
388
556
 
389
557
  // One command proves every gate that names it, so it runs once.
390
558
  //
@@ -401,10 +569,11 @@ export function runGates({
401
569
  // gate can run for minutes, and an operator watching a hook needs to know
402
570
  // which gate is being proved right now, not only what the tally was.
403
571
  for (const gate of resolved) {
404
- const { outcome, shared, ran } = verdictFor(gate, {
572
+ const { outcome, shared, ran, raw } = verdictFor(gate, {
405
573
  proved,
406
- blocked,
574
+ blockedBy,
407
575
  exec,
576
+ siblings: sharers.get(gate.command) ?? new Set(),
408
577
  });
409
578
 
410
579
  // Only what this iteration actually executed is shareable, and `ran` says
@@ -414,28 +583,32 @@ export function runGates({
414
583
  // next gate to run it again — re-running a whole suite an operator has
415
584
  // just interrupted.
416
585
  if (ran && gate.command) {
417
- proved.set(gate.command, { id: gate.id, outcome });
586
+ proved.set(gate.command, { id: gate.id, outcome: raw ?? outcome });
418
587
  }
419
588
 
420
589
  results.push({ ...gate, ...outcome, provedBy: shared?.id ?? null });
421
590
  out(formatLine(outcome.state, gate, outcome.detail));
591
+ for (const line of outcome.evidence ?? []) out(`${" ".repeat(6)}↳ ${line}`);
422
592
  const unproved =
423
593
  outcome.state === STATE.FAILED || outcome.state === STATE.UNPROVABLE;
424
594
  // The strictest level wins when gates share a prover. Letting a required
425
595
  // gate inherit only the pass, or letting a failure count only against the
426
596
  // optional gate that ran first, would be the original defect again: a
427
597
  // required gate satisfied by a run that failed.
428
- if (unproved && gate.level === "required") blocked = true;
598
+ if (unproved && gate.level === "required" && !blockedBy)
599
+ blockedBy = gate.id;
429
600
  }
430
601
 
431
602
  const bucket = state => results.filter(entry => entry.state === state);
432
603
  const result = {
433
604
  moment,
434
- blocked,
605
+ blocked: blockedBy !== null,
606
+ blockedBy,
435
607
  total: resolved.length,
436
608
  results,
437
609
  passed: bucket(STATE.PASSED),
438
610
  failed: [...bucket(STATE.FAILED), ...bucket(STATE.UNPROVABLE)],
611
+ unprovable: bucket(STATE.UNPROVABLE),
439
612
  skipped: bucket(STATE.SKIPPED),
440
613
  notRun: bucket(STATE.NOT_RUN),
441
614
  };
@@ -522,18 +695,105 @@ function readPackageScripts(cwd) {
522
695
  }
523
696
 
524
697
  /**
525
- * The default executor: run the command in a shell, inheriting stdio.
698
+ * How much of a gate's output is kept for diagnosis. A suite of 14,000 tests
699
+ * prints megabytes; the signatures that matter are all near the end.
700
+ */
701
+ const CAPTURE_TAIL_BYTES = 512 * 1024;
702
+
703
+ /**
704
+ * Run the command in a shell with stdio inherited, capturing nothing.
705
+ *
706
+ * This is the original executor, kept verbatim as the fallback, so that
707
+ * everything about capture is additive: if capture is unavailable or refused,
708
+ * the exit code is produced by exactly the code path that produced it before.
709
+ * @param {string} command The command line to run.
710
+ * @returns {{code: number|null, output: null}} Exit code; null when killed.
711
+ */
712
+ function plainExec(command) {
713
+ const child = spawnSync(command, { shell: true, stdio: "inherit" });
714
+ if (child.error) return { code: null, output: null };
715
+ return { code: child.status, output: null };
716
+ }
717
+
718
+ /**
719
+ * Whether this machine can tee a gate's output without changing its verdict.
720
+ *
721
+ * Probed rather than assumed: on a shell without `tee` — Windows `cmd`, a
722
+ * stripped container — the wrapper below would produce no status file, and the
723
+ * runner would report every gate as terminated. A capability the runner cannot
724
+ * confirm is one it does not use.
725
+ * @returns {boolean} Whether to take the capturing path.
726
+ */
727
+ function captureAvailable() {
728
+ if (process.env.LISA_GATES_CAPTURE === "0") return false;
729
+ const probe = spawnSync("sh", ["-c", "command -v tee"], { stdio: "ignore" });
730
+ return !probe.error && probe.status === 0;
731
+ }
732
+
733
+ /**
734
+ * Read back what the wrapper recorded, keeping only the tail of the log.
735
+ * @param {string} statusPath File the wrapper wrote the exit code into.
736
+ * @param {string} logPath File the wrapper tee'd the output into.
737
+ * @returns {{code: number|null, output: string|null}} The recorded answer.
738
+ */
739
+ function readCaptured(statusPath, logPath) {
740
+ let output = null;
741
+ try {
742
+ output = readFileSync(logPath, "utf8").slice(-CAPTURE_TAIL_BYTES);
743
+ } catch {
744
+ output = null;
745
+ }
746
+ try {
747
+ const code = Number.parseInt(readFileSync(statusPath, "utf8").trim(), 10);
748
+ // Fail closed. An unreadable status is not a zero: the one thing this
749
+ // runner may never do is turn "I do not know" into "it passed".
750
+ return { code: Number.isInteger(code) ? code : null, output };
751
+ } catch {
752
+ return { code: null, output };
753
+ }
754
+ }
755
+
756
+ /**
757
+ * The default executor: run the command in a shell, streaming AND recording.
526
758
  *
527
759
  * stdio is inherited so a failing gate's own output reaches the operator
528
- * unaltered. Swallowing it and reprinting a summary is how a failure becomes
529
- * unactionable.
760
+ * unaltered and as it happens a push gate runs for minutes and an operator
761
+ * needs to watch it, not receive it in a lump at the end. `tee` is what lets
762
+ * both be true: the operator sees the stream, and the runner keeps a copy to
763
+ * say WHICH failure it was rather than only that there was one.
764
+ *
765
+ * The price is that the command's stdout is a pipe rather than a terminal, so
766
+ * tools that colourise or animate for a TTY print their plain form. That is a
767
+ * deliberate trade: a plain, diagnosable failure beats a coloured, mute one.
768
+ * `LISA_GATES_CAPTURE=0` buys the colour back at the cost of the diagnosis.
769
+ *
770
+ * The exit code comes from a status file written INSIDE the pipeline, never
771
+ * from the pipeline itself — a pipeline reports `tee`'s status, which is
772
+ * almost always zero, and reading it would report every failing gate as
773
+ * passing. That is the single most dangerous mistake available here.
530
774
  * @param {string} command The command line to run.
531
- * @returns {number|null} Exit code, or null when killed by a signal.
775
+ * @returns {{code: number|null, output: string|null}} Exit code and output.
532
776
  */
533
777
  function spawnExec(command) {
534
- const child = spawnSync(command, { shell: true, stdio: "inherit" });
535
- if (child.error) return null;
536
- return child.status;
778
+ if (!captureAvailable()) return plainExec(command);
779
+ let dir;
780
+ try {
781
+ dir = mkdtempSync(join(tmpdir(), "lisa-gate-run-"));
782
+ } catch {
783
+ return plainExec(command);
784
+ }
785
+ const logPath = join(dir, "output.log");
786
+ const statusPath = join(dir, "status");
787
+ // Newline-separated inside a group, so a command ending in a trailing
788
+ // comment or redirection still has `echo` run as its own statement.
789
+ const script = `{\n${command}\necho $? > '${statusPath}'\n} 2>&1 | tee '${logPath}'\n`;
790
+ try {
791
+ const child = spawnSync("sh", ["-c", script], { stdio: "inherit" });
792
+ if (child.error) return { code: null, output: null };
793
+ return readCaptured(statusPath, logPath);
794
+ } finally {
795
+ rmSync(dir, { force: true, recursive: true });
796
+ }
537
797
  }
538
798
 
539
799
  /**