@codyswann/lisa 3.47.5 → 3.47.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.
- 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-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 +14 -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 +27 -13
- 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/scripts/automation-run-record.mjs +124 -4
- package/plugins/lisa/scripts/automation-status-claude-adapter.mjs +9 -1
- package/plugins/lisa/scripts/automation-status-codex-adapter.mjs +9 -1
- package/plugins/lisa/scripts/automation-status-run-history.mjs +34 -3
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/automation-run-record.mjs +124 -4
- package/plugins/lisa-agy/scripts/automation-status-claude-adapter.mjs +9 -1
- package/plugins/lisa-agy/scripts/automation-status-codex-adapter.mjs +9 -1
- package/plugins/lisa-agy/scripts/automation-status-run-history.mjs +34 -3
- 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-copilot/scripts/automation-run-record.mjs +124 -4
- package/plugins/lisa-copilot/scripts/automation-status-claude-adapter.mjs +9 -1
- package/plugins/lisa-copilot/scripts/automation-status-codex-adapter.mjs +9 -1
- package/plugins/lisa-copilot/scripts/automation-status-run-history.mjs +34 -3
- 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-cursor/scripts/automation-run-record.mjs +124 -4
- package/plugins/lisa-cursor/scripts/automation-status-claude-adapter.mjs +9 -1
- package/plugins/lisa-cursor/scripts/automation-status-codex-adapter.mjs +9 -1
- package/plugins/lisa-cursor/scripts/automation-status-run-history.mjs +34 -3
- 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/plugins/src/base/scripts/automation-run-record.mjs +124 -4
- package/plugins/src/base/scripts/automation-status-claude-adapter.mjs +9 -1
- package/plugins/src/base/scripts/automation-status-codex-adapter.mjs +9 -1
- package/plugins/src/base/scripts/automation-status-run-history.mjs +34 -3
- 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
|
/**
|
|
@@ -1856,9 +1856,10 @@ const SCOPE_ORDER = [OUTSIDE_THIS_PR, IN_THIS_PR];
|
|
|
1856
1856
|
/**
|
|
1857
1857
|
* Validate the commits, keeping a refusal rather than aborting the run.
|
|
1858
1858
|
*
|
|
1859
|
-
* The pull-request gate checks
|
|
1859
|
+
* The pull-request gate checks several separate things and used to stop at the
|
|
1860
1860
|
* first unmet one, so each CI cycle revealed exactly one requirement. It knew
|
|
1861
|
-
* the rest at the same moment; it was simply not saying.
|
|
1861
|
+
* the rest at the same moment; it was simply not saying. See `gateSummary` for
|
|
1862
|
+
* the full count, which is five and was itself miscounted as four.
|
|
1862
1863
|
* @param {string[]} commits Commits in the pull request range.
|
|
1863
1864
|
* @returns {{result?: object, error?: Error}} Outcome, never a throw.
|
|
1864
1865
|
*/
|
|
@@ -1914,37 +1915,82 @@ function backlinkAdvice(ref, prUrl, contract) {
|
|
|
1914
1915
|
}
|
|
1915
1916
|
|
|
1916
1917
|
/**
|
|
1917
|
-
* The
|
|
1918
|
-
*
|
|
1919
|
-
* They are
|
|
1920
|
-
* different moments — so clearing
|
|
1921
|
-
* reader has no way to learn the
|
|
1922
|
-
* agents in one day each satisfied three and were surprised by
|
|
1923
|
-
*
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
1918
|
+
* The FIVE gates a work item passes on its way to merged, and when each bites.
|
|
1919
|
+
*
|
|
1920
|
+
* They are five separate checks, enforced in five different places, at five
|
|
1921
|
+
* different moments — so clearing four says nothing about the fifth, and the
|
|
1922
|
+
* reader has no way to learn the fifth exists until it goes red. Measured: two
|
|
1923
|
+
* agents in one day each satisfied three of them and were surprised by another,
|
|
1924
|
+
* on a pull request that was otherwise finished.
|
|
1925
|
+
*
|
|
1926
|
+
* This said FOUR until #2681 counted them properly. Gates 3 and 4 shared a
|
|
1927
|
+
* line — "every commit AND the pull-request body carry ONE matching trailer" —
|
|
1928
|
+
* which reads as one requirement and is two: the commit-msg hook enforces the
|
|
1929
|
+
* message, and a separate check enforces the BODY. An operator whose commits
|
|
1930
|
+
* carried the trailer read that line as cleared. Measured: an agent went
|
|
1931
|
+
* BLOCKED on "No Work-Item trailer anywhere in the pull request body" holding a
|
|
1932
|
+
* commit that carried the trailer, an item in the claimed role, and a backlink
|
|
1933
|
+
* already posted — four of the five satisfied, and the fifth invisible until CI
|
|
1934
|
+
* said so, one cycle later.
|
|
1935
|
+
*
|
|
1936
|
+
* `Closes owner/repo#N` is called out by name because it is the substitution a
|
|
1937
|
+
* reader reaches for: it closes the item on merge and satisfies nothing here.
|
|
1938
|
+
*
|
|
1939
|
+
* Listing all five in the report an operator actually reads costs six lines and
|
|
1940
|
+
* turns a sequence of surprises into one checklist. The role NAMES come from
|
|
1941
|
+
* the resolved contract rather than from Lisa's own defaults, because a project
|
|
1942
|
+
* configures its own — lifecycle is a workflow STATE on Jira and Linear and a
|
|
1943
|
+
* LABEL only on GitHub — and a summary that confidently names the wrong label
|
|
1944
|
+
* is worse than none.
|
|
1930
1945
|
* @param {object} contract Resolved tracker contract.
|
|
1931
1946
|
* @returns {string} The checklist.
|
|
1932
1947
|
*/
|
|
1933
1948
|
function gateSummary(contract) {
|
|
1934
1949
|
const backlink =
|
|
1935
1950
|
contract.verify === "full"
|
|
1936
|
-
? `
|
|
1937
|
-
: `
|
|
1951
|
+
? `5. the item carries a managed \`${MARKER}\` backlink comment to this pull request — checked at push once a pull request exists, again at CI time, and only under workItem.verify "full"`
|
|
1952
|
+
: `5. the tracker backlink is NOT required here: workItem.verify is "trailer", so this run contacted no tracker`;
|
|
1938
1953
|
return [
|
|
1939
1954
|
"",
|
|
1940
|
-
"All
|
|
1955
|
+
"All five gates, and when each one bites:",
|
|
1941
1956
|
` 1. the item carries the ready role "${contract.lifecycle.ready}" — required before the work may be created or claimed`,
|
|
1942
1957
|
` 2. the item carries the claimed role "${contract.lifecycle.claimed}" — required by the commit-msg hook, on every single commit`,
|
|
1943
|
-
" 3. every commit
|
|
1958
|
+
" 3. every commit message carries ONE matching `Work-Item:` trailer — required by the commit-msg hook, on every single commit",
|
|
1959
|
+
" 4. the pull-request BODY carries that same `Work-Item:` trailer — a SEPARATE check from gate 3, run at push once a pull request exists and again at CI time; `Closes owner/repo#N` does NOT satisfy it",
|
|
1944
1960
|
` ${backlink}`,
|
|
1945
1961
|
].join("\n");
|
|
1946
1962
|
}
|
|
1947
1963
|
|
|
1964
|
+
/**
|
|
1965
|
+
* The same refusal, with the whole checklist appended.
|
|
1966
|
+
*
|
|
1967
|
+
* Used where a check knows only its OWN gate — the commit-msg hook, and a push
|
|
1968
|
+
* with no pull request yet — so that the gates it cannot check are still named
|
|
1969
|
+
* at the earliest moment they are known, rather than at the next moment they
|
|
1970
|
+
* happen to be enforced.
|
|
1971
|
+
*
|
|
1972
|
+
* Re-resolving the contract here is safe rather than lucky, and the reason is
|
|
1973
|
+
* worth writing down because the obvious defensive try/catch around it is DEAD
|
|
1974
|
+
* CODE. Every caller reaches this only after a refusal, and both refusal paths
|
|
1975
|
+
* — `validateMessage` and `validateCommits` — call `trackerContract()` as their
|
|
1976
|
+
* first real step. So either the contract resolves, and resolving it a second
|
|
1977
|
+
* time from the same files resolves again; or it does not, in which case the
|
|
1978
|
+
* refusal being decorated IS the configuration error and re-resolving rethrows
|
|
1979
|
+
* that identical error. There is no third case, and no input reaches a branch
|
|
1980
|
+
* where the original message could be lost.
|
|
1981
|
+
*
|
|
1982
|
+
* That was established by mutation: a guard clause was written here first, with
|
|
1983
|
+
* a test for it, and deleting the guard left the test passing. An unreachable
|
|
1984
|
+
* branch and a test that cannot fail are the same defect wearing two hats.
|
|
1985
|
+
* @param {Error} error The refusal as raised.
|
|
1986
|
+
* @returns {Error} The refusal, with the checklist appended.
|
|
1987
|
+
*/
|
|
1988
|
+
function withGateSummary(error) {
|
|
1989
|
+
return new TrackingError(
|
|
1990
|
+
`${error.message}\n${gateSummary(trackerContract())}`
|
|
1991
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1948
1994
|
/**
|
|
1949
1995
|
* One refusal naming every unmet requirement, unrecoverable ones first.
|
|
1950
1996
|
* @param {object[]} findings Unmet requirements.
|
|
@@ -2366,9 +2412,18 @@ function validateCommit(args) {
|
|
|
2366
2412
|
const file = args[0];
|
|
2367
2413
|
if (!file)
|
|
2368
2414
|
throw new TrackingError("validate-commit requires the commit message file");
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2415
|
+
let result;
|
|
2416
|
+
try {
|
|
2417
|
+
result = validateMessage(readFileSync(file, "utf8"), {
|
|
2418
|
+
allowInProgressMerge: true,
|
|
2419
|
+
});
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
// The commit-msg hook is the EARLIEST moment an operator meets any of this,
|
|
2422
|
+
// and all five gates are knowable here even though only two are enforced
|
|
2423
|
+
// here. Withholding the other three buys nothing and costs a CI cycle each.
|
|
2424
|
+
if (!(error instanceof TrackingError)) throw error;
|
|
2425
|
+
throw withGateSummary(error);
|
|
2426
|
+
}
|
|
2372
2427
|
console.log(`WORK_ITEM_TRACKING_OK ${result.exempt ?? result.ref}`);
|
|
2373
2428
|
}
|
|
2374
2429
|
|
|
@@ -2378,11 +2433,13 @@ function validatePush(args) {
|
|
|
2378
2433
|
const outcome = commitOutcome(parsePushLines(input, remote));
|
|
2379
2434
|
const pr = currentPullRequest();
|
|
2380
2435
|
if (!pr) {
|
|
2381
|
-
// No pull request means
|
|
2382
|
-
//
|
|
2383
|
-
|
|
2436
|
+
// No pull request means gates 4 and 5 cannot be CHECKED here. They are
|
|
2437
|
+
// still perfectly well KNOWN here, and this is the last local moment before
|
|
2438
|
+
// CI — so the checklist goes out either way. Saying nothing is precisely
|
|
2439
|
+
// what made those two gates separate CI-cycle surprises (#2681).
|
|
2440
|
+
if (outcome.error) throw withGateSummary(outcome.error);
|
|
2384
2441
|
console.log(
|
|
2385
|
-
`WORK_ITEM_TRACKING_OK ${outcome.result.relevant} commit(s); no pull request exists yet, CI will verify
|
|
2442
|
+
`WORK_ITEM_TRACKING_OK ${outcome.result.relevant} commit(s); no pull request exists yet, so gates 4 and 5 could not be checked here — CI will verify both${gateSummary(outcome.result.contract)}`
|
|
2386
2443
|
);
|
|
2387
2444
|
return;
|
|
2388
2445
|
}
|