@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.
- package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +270 -0
- package/all/copy-overwrite/scripts/lisa-gates.mjs +30 -0
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +294 -34
- package/all/copy-overwrite/scripts/lisa-schema-validate.mjs +126 -32
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +83 -26
- package/dist/configs/eslint/expo.d.ts +50 -1
- package/dist/configs/eslint/expo.d.ts.map +1 -1
- package/dist/configs/eslint/expo.js +86 -5
- package/dist/configs/eslint/expo.js.map +1 -1
- package/dist/core/downstream-names.d.ts +83 -0
- package/dist/core/downstream-names.d.ts.map +1 -0
- package/dist/core/downstream-names.js +289 -0
- package/dist/core/downstream-names.js.map +1 -0
- package/dist/core/downstream-references.d.ts +21 -6
- package/dist/core/downstream-references.d.ts.map +1 -1
- package/dist/core/downstream-references.js +19 -16
- package/dist/core/downstream-references.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +16 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +23 -10
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/migrations/ensure-tsconfig-local-includes.d.ts +1 -1
- package/dist/migrations/ensure-tsconfig-local-includes.js +1 -1
- package/expo/copy-overwrite/eslint.config.ts +9 -0
- package/expo/copy-overwrite/eslint.expo.ts +92 -4
- package/package.json +5 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/rules/reference/design-value-binding.md +4 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/reference/design-value-binding.md +4 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +4 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/reference/design-value-binding.md +4 -0
- package/scripts/lisa-mutation.mjs +13 -0
- package/typescript/copy-contents/.husky/pre-push +7 -1
- package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +496 -122
- 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
|
|
22
|
-
*
|
|
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 {
|
|
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
|
|
243
|
-
* @returns {{state: string, detail: string, code: number|null
|
|
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 {
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
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)
|
|
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
|
|
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>,
|
|
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,
|
|
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
|
-
...
|
|
347
|
-
detail: `${
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
*
|
|
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
|
|
529
|
-
*
|
|
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
|
|
775
|
+
* @returns {{code: number|null, output: string|null}} Exit code and output.
|
|
532
776
|
*/
|
|
533
777
|
function spawnExec(command) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
/**
|