@codyswann/lisa 3.54.0 → 3.54.2
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/lisa-gates.mjs +12 -1
- package/dist/cli/ui-ci-quality-jobs-compute.d.ts.map +1 -1
- package/dist/cli/ui-ci-quality-jobs-compute.js +0 -1
- package/dist/cli/ui-ci-quality-jobs-compute.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +4 -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 +9 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/create-only/.github/required-checks.json +1 -1
- package/expo/create-only/stryker.conf.json +5 -0
- package/nestjs/create-only/.github/required-checks.json +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- 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-cursor/.claude-plugin/plugin.json +1 -1
- 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/typescript/copy-contents/.husky/pre-push +4 -1
- package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +431 -20
- package/typescript/create-only/stryker.conf.json +5 -0
- package/ui/index.html +0 -13
|
@@ -50,15 +50,35 @@
|
|
|
50
50
|
* `inert-mutate-config` and it FAILS, exit 1. Distinguishing the two costs
|
|
51
51
|
* one `git ls-files`.
|
|
52
52
|
*
|
|
53
|
+
* ## A timeout is not a score
|
|
54
|
+
*
|
|
55
|
+
* Stryker can end a run two ways that share one exit code: a mutation score
|
|
56
|
+
* under `thresholds.break`, and a wall-clock budget running out. Only the first
|
|
57
|
+
* is a fact about the tests. The second is a fact about the machine, and it is
|
|
58
|
+
* reached by owning slower hardware than whoever picked the budget — so
|
|
59
|
+
* reporting it as a score tells the person least able to argue with it that
|
|
60
|
+
* their tests are weak, when nothing was measured at all.
|
|
61
|
+
*
|
|
62
|
+
* Stryker's output is therefore kept as the run streams (see `runStryker`) and
|
|
63
|
+
* read on failure: `dry-run-timeout` names the budget that ended it and says no
|
|
64
|
+
* score exists, `score-below-break` names the two numbers, and `run-failed`
|
|
65
|
+
* quotes Stryker's last lines and claims nothing. The budgets are named from
|
|
66
|
+
* the project's Stryker config, or reported as Stryker's own defaults when the
|
|
67
|
+
* config declares none — a budget nobody chose is unactionable until somebody
|
|
68
|
+
* is told that is what it was.
|
|
69
|
+
*
|
|
53
70
|
* ## Configuration
|
|
54
71
|
*
|
|
55
72
|
* `mutation.gate.json` (project-owned / create-only):
|
|
56
73
|
* `{ "enabled": false, "since": "main" }`.
|
|
57
74
|
* Overridable via env: `MUTATION_ENABLED=true|false`, `MUTATION_SINCE=<ref>`.
|
|
75
|
+
* `MUTATION_CAPTURE=0` turns the output capture off, trading the diagnosis
|
|
76
|
+
* above for Stryker's TTY progress bar.
|
|
58
77
|
* @module scripts/lisa-mutation
|
|
59
78
|
*/
|
|
60
79
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
61
80
|
import fs from "node:fs";
|
|
81
|
+
import os from "node:os";
|
|
62
82
|
import path from "node:path";
|
|
63
83
|
import process from "node:process";
|
|
64
84
|
|
|
@@ -80,6 +100,9 @@ export const OUTCOMES = Object.freeze({
|
|
|
80
100
|
inertConfig: "mutation-gate: inert-mutate-config",
|
|
81
101
|
unrepresentablePath: "mutation-gate: unrepresentable-path",
|
|
82
102
|
scoped: "mutation-gate: scoped-run",
|
|
103
|
+
dryRunTimeout: "mutation-gate: dry-run-timeout",
|
|
104
|
+
scoreBelowBreak: "mutation-gate: score-below-break",
|
|
105
|
+
runFailed: "mutation-gate: run-failed",
|
|
83
106
|
});
|
|
84
107
|
|
|
85
108
|
/**
|
|
@@ -265,6 +288,263 @@ export const resolveMutateDeclaration = cwd => {
|
|
|
265
288
|
};
|
|
266
289
|
};
|
|
267
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Stryker's per-mutant budget when a config declares none, in milliseconds.
|
|
293
|
+
*
|
|
294
|
+
* Restated here rather than read out of Stryker because the message these feed
|
|
295
|
+
* has to NAME the number that ended the run, and a run ended by an inherited
|
|
296
|
+
* default has no number written down anywhere in the project to name. Telling
|
|
297
|
+
* an operator the budget was "whatever Stryker picked" is the same dead end as
|
|
298
|
+
* telling them nothing.
|
|
299
|
+
* @type {number}
|
|
300
|
+
*/
|
|
301
|
+
export const STRYKER_DEFAULT_TIMEOUT_MS = 5000;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Stryker's dry-run budget when a config declares none, in minutes.
|
|
305
|
+
* @type {number}
|
|
306
|
+
*/
|
|
307
|
+
export const STRYKER_DEFAULT_DRY_RUN_TIMEOUT_MINUTES = 5;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The timeout options this gate can name in a failure message.
|
|
311
|
+
* @type {readonly string[]}
|
|
312
|
+
*/
|
|
313
|
+
const TIMEOUT_KEYS = Object.freeze(["timeoutMS", "dryRunTimeoutMinutes"]);
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Stryker's defaults, keyed the way a Stryker config spells them.
|
|
317
|
+
* @type {Readonly<Record<string, number>>}
|
|
318
|
+
*/
|
|
319
|
+
const TIMEOUT_DEFAULTS = Object.freeze({
|
|
320
|
+
timeoutMS: STRYKER_DEFAULT_TIMEOUT_MS,
|
|
321
|
+
dryRunTimeoutMinutes: STRYKER_DEFAULT_DRY_RUN_TIMEOUT_MINUTES,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The project's Stryker JSON config, or null when there is none to read.
|
|
326
|
+
* @param {string} cwd - Project root.
|
|
327
|
+
* @returns {object|null} The parsed config.
|
|
328
|
+
*/
|
|
329
|
+
const readJsonConfig = cwd => {
|
|
330
|
+
const found = JSON_CONFIG_NAMES.find(name =>
|
|
331
|
+
fs.existsSync(path.join(cwd, name))
|
|
332
|
+
);
|
|
333
|
+
if (!found) return null;
|
|
334
|
+
try {
|
|
335
|
+
return JSON.parse(fs.readFileSync(path.join(cwd, found), "utf8"));
|
|
336
|
+
} catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The timeout budgets in force, and which of them nobody chose.
|
|
343
|
+
*
|
|
344
|
+
* `inherited` is the load-bearing half. A budget the project wrote down is a
|
|
345
|
+
* decision an operator can go and change; a budget that arrived by omission is
|
|
346
|
+
* Stryker's opinion about a machine it has never seen, and the failure it
|
|
347
|
+
* produces is unactionable until somebody is told that is what happened.
|
|
348
|
+
* @param {string} [cwd] - Project root; defaults to the process working dir.
|
|
349
|
+
* @returns {{timeoutMS: number, dryRunTimeoutMinutes: number,
|
|
350
|
+
* inherited: string[]}} The budgets, and the keys taken from Stryker.
|
|
351
|
+
*/
|
|
352
|
+
export const resolveTimeoutBudgets = (cwd = process.cwd()) => {
|
|
353
|
+
const conf = readJsonConfig(cwd);
|
|
354
|
+
const declared = key => {
|
|
355
|
+
const value = conf?.[key];
|
|
356
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
357
|
+
? value
|
|
358
|
+
: null;
|
|
359
|
+
};
|
|
360
|
+
return {
|
|
361
|
+
...Object.fromEntries(
|
|
362
|
+
TIMEOUT_KEYS.map(key => [key, declared(key) ?? TIMEOUT_DEFAULTS[key]])
|
|
363
|
+
),
|
|
364
|
+
inherited: TIMEOUT_KEYS.filter(key => declared(key) === null),
|
|
365
|
+
};
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Stryker's wording when the un-mutated run blows its wall-clock budget.
|
|
370
|
+
* @type {string}
|
|
371
|
+
*/
|
|
372
|
+
const DRY_RUN_TIMEOUT_SIGNATURE = "Initial test run timed out!";
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Stryker's wording when a completed run scored under `thresholds.break`.
|
|
376
|
+
* @type {RegExp}
|
|
377
|
+
*/
|
|
378
|
+
const BREAK_THRESHOLD_PATTERN =
|
|
379
|
+
/Final mutation score ([\d.]+) under breaking threshold ([\d.]+)/u;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The progress reporter's tally: `12/40 tested (3 survived, 2 timed out)`.
|
|
383
|
+
*
|
|
384
|
+
* Emitted every ten seconds by the append-only reporter, which is the one that
|
|
385
|
+
* runs whenever stdout is not a terminal — including under the capture below.
|
|
386
|
+
* @type {RegExp}
|
|
387
|
+
*/
|
|
388
|
+
const TIMED_OUT_MUTANTS_PATTERN = /\(\d+ survived, (\d+) timed out\)/gu;
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* How many of Stryker's last lines an unrecognised failure quotes back.
|
|
392
|
+
* @type {number}
|
|
393
|
+
*/
|
|
394
|
+
const MAX_TAIL_LINES = 5;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Longest tail line quoted back, so one enormous line cannot flood a hook.
|
|
398
|
+
* @type {number}
|
|
399
|
+
*/
|
|
400
|
+
const MAX_TAIL_WIDTH = 200;
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The last lines that carry anything, for a failure nothing else recognised.
|
|
404
|
+
* @param {string} output - Stryker's combined output.
|
|
405
|
+
* @returns {string[]} Up to `MAX_TAIL_LINES` quoted lines, oldest first.
|
|
406
|
+
*/
|
|
407
|
+
const tailOf = output => {
|
|
408
|
+
const lines = output
|
|
409
|
+
.split("\n")
|
|
410
|
+
.map(line => line.trim())
|
|
411
|
+
.filter(line => line.length > 0)
|
|
412
|
+
.slice(-MAX_TAIL_LINES)
|
|
413
|
+
.map(line => ` | ${line.slice(0, MAX_TAIL_WIDTH)}`);
|
|
414
|
+
return lines.length > 0 ? lines : [" | (Stryker printed nothing)"];
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* How many mutants the clock decided, from the last progress tally.
|
|
419
|
+
* @param {string} output - Stryker's combined output.
|
|
420
|
+
* @returns {number} The count, or 0 when no tally was printed.
|
|
421
|
+
*/
|
|
422
|
+
const timedOutMutants = output => {
|
|
423
|
+
const tallies = [...output.matchAll(TIMED_OUT_MUTANTS_PATTERN)];
|
|
424
|
+
return tallies.length === 0 ? 0 : Number(tallies[tallies.length - 1][1]);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* A clause saying a budget was nobody's decision, when that is true of it.
|
|
429
|
+
* @param {{inherited: string[]}} budgets - From `resolveTimeoutBudgets`.
|
|
430
|
+
* @param {string} key - The option to describe.
|
|
431
|
+
* @returns {string} The clause, or `""` when the project declared the value.
|
|
432
|
+
*/
|
|
433
|
+
const inheritedNote = (budgets, key) =>
|
|
434
|
+
budgets.inherited.includes(key)
|
|
435
|
+
? ` (Stryker's own default — no "${key}" in your Stryker config)`
|
|
436
|
+
: "";
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The block printed when a dry run ran out of wall clock.
|
|
440
|
+
* @param {{dryRunTimeoutMinutes: number, inherited: string[]}} budgets - The
|
|
441
|
+
* budgets in force.
|
|
442
|
+
* @returns {{outcome: string, message: string}} The marker and the block.
|
|
443
|
+
*/
|
|
444
|
+
const dryRunTimeoutVerdict = budgets => ({
|
|
445
|
+
outcome: OUTCOMES.dryRunTimeout,
|
|
446
|
+
message:
|
|
447
|
+
`❌ ${OUTCOMES.dryRunTimeout}\n` +
|
|
448
|
+
" Stryker's initial, UN-MUTATED test run exceeded its wall-clock budget of\n" +
|
|
449
|
+
` ${budgets.dryRunTimeoutMinutes} minute(s)` +
|
|
450
|
+
`${inheritedNote(budgets, "dryRunTimeoutMinutes")} and was killed.\n` +
|
|
451
|
+
" NO mutant was generated and NO score was computed. This is a TIMEOUT, not\n" +
|
|
452
|
+
" a mutation score below thresholds.break, and it says nothing at all about\n" +
|
|
453
|
+
" your tests.\n" +
|
|
454
|
+
' Raise "dryRunTimeoutMinutes" in your Stryker config if the suite simply\n' +
|
|
455
|
+
" needs longer on this machine; investigate a hang if it does not.",
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The block printed when a completed run scored under `thresholds.break`.
|
|
460
|
+
* @param {readonly string[]} broke - `[, score, threshold]` from Stryker.
|
|
461
|
+
* @param {string} output - Stryker's combined output.
|
|
462
|
+
* @param {{timeoutMS: number, inherited: string[]}} budgets - Budgets in force.
|
|
463
|
+
* @returns {{outcome: string, message: string}} The marker and the block.
|
|
464
|
+
*/
|
|
465
|
+
const scoreBelowBreakVerdict = (broke, output, budgets) => {
|
|
466
|
+
const timedOut = timedOutMutants(output);
|
|
467
|
+
const clockNote =
|
|
468
|
+
timedOut > 0
|
|
469
|
+
? `\n ${timedOut} mutant(s) also hit the per-mutant budget of ` +
|
|
470
|
+
`${budgets.timeoutMS}ms${inheritedNote(budgets, "timeoutMS")}.\n` +
|
|
471
|
+
" Stryker scores a timed-out mutant as KILLED, so that part of the score\n" +
|
|
472
|
+
" above was decided by the clock rather than by an assertion."
|
|
473
|
+
: "";
|
|
474
|
+
const verdict =
|
|
475
|
+
`❌ ${OUTCOMES.scoreBelowBreak}\n` +
|
|
476
|
+
` Stryker ran to completion and scored ${broke[1]} against a break\n` +
|
|
477
|
+
` threshold of ${broke[2]}. This one IS a verdict about your tests.`;
|
|
478
|
+
return {
|
|
479
|
+
outcome: OUTCOMES.scoreBelowBreak,
|
|
480
|
+
message: `${verdict}${clockNote}`,
|
|
481
|
+
};
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The block printed when nothing in the transcript was recognised.
|
|
486
|
+
* @param {string|null} output - Stryker's combined output, or null.
|
|
487
|
+
* @param {{timeoutMS: number, dryRunTimeoutMinutes: number,
|
|
488
|
+
* inherited: string[]}} budgets - The budgets in force.
|
|
489
|
+
* @returns {{outcome: string, message: string}} The marker and the block.
|
|
490
|
+
*/
|
|
491
|
+
const runFailedVerdict = (output, budgets) => {
|
|
492
|
+
if (output === null) {
|
|
493
|
+
return {
|
|
494
|
+
outcome: OUTCOMES.runFailed,
|
|
495
|
+
message:
|
|
496
|
+
`❌ ${OUTCOMES.runFailed}\n` +
|
|
497
|
+
" Stryker's output could not be captured on this machine, so this gate\n" +
|
|
498
|
+
" cannot say WHICH failure it was — read Stryker's own output above.\n" +
|
|
499
|
+
` Budgets in force: dryRunTimeoutMinutes=${budgets.dryRunTimeoutMinutes}` +
|
|
500
|
+
`${inheritedNote(budgets, "dryRunTimeoutMinutes")}, ` +
|
|
501
|
+
`timeoutMS=${budgets.timeoutMS}${inheritedNote(budgets, "timeoutMS")}.\n` +
|
|
502
|
+
" Nothing here claims your mutation score was below thresholds.break.",
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const tail = tailOf(output).join("\n");
|
|
506
|
+
return {
|
|
507
|
+
outcome: OUTCOMES.runFailed,
|
|
508
|
+
message:
|
|
509
|
+
`❌ ${OUTCOMES.runFailed}\n` +
|
|
510
|
+
" Stryker exited nonzero without reporting a timeout and without reporting a\n" +
|
|
511
|
+
" score under thresholds.break, so this gate does NOT claim your tests are\n" +
|
|
512
|
+
` weak. Its last lines were:\n${tail}`,
|
|
513
|
+
};
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Say WHY Stryker failed, from Stryker's own output.
|
|
518
|
+
*
|
|
519
|
+
* ## The defect this exists to close
|
|
520
|
+
*
|
|
521
|
+
* A dry run killed by its wall-clock budget and a suite whose tests are
|
|
522
|
+
* genuinely weak leave the gate in the same place: one nonzero exit. Reported
|
|
523
|
+
* as a mutation score, the first one is false twice over — no score was
|
|
524
|
+
* computed, and no test is weak — and it is told to the operator LEAST able to
|
|
525
|
+
* argue with it, because the way to hit it is to own a slower machine than the
|
|
526
|
+
* person who picked the budget.
|
|
527
|
+
*
|
|
528
|
+
* So a timeout is reported as a timeout with the budget that ended it named,
|
|
529
|
+
* and the word "score" appears only where a score was actually measured.
|
|
530
|
+
* @param {string|null|undefined} output - Stryker's combined output, or null
|
|
531
|
+
* when this machine could not capture it.
|
|
532
|
+
* @param {{timeoutMS: number, dryRunTimeoutMinutes: number,
|
|
533
|
+
* inherited: string[]}} budgets - From `resolveTimeoutBudgets`.
|
|
534
|
+
* @returns {{outcome: string, message: string}} The marker and the block.
|
|
535
|
+
*/
|
|
536
|
+
export const classifyStrykerFailure = (output, budgets) => {
|
|
537
|
+
if (typeof output !== "string" || output.length === 0) {
|
|
538
|
+
return runFailedVerdict(null, budgets);
|
|
539
|
+
}
|
|
540
|
+
if (output.includes(DRY_RUN_TIMEOUT_SIGNATURE)) {
|
|
541
|
+
return dryRunTimeoutVerdict(budgets);
|
|
542
|
+
}
|
|
543
|
+
const broke = BREAK_THRESHOLD_PATTERN.exec(output);
|
|
544
|
+
if (broke) return scoreBelowBreakVerdict(broke, output, budgets);
|
|
545
|
+
return runFailedVerdict(output, budgets);
|
|
546
|
+
};
|
|
547
|
+
|
|
268
548
|
/**
|
|
269
549
|
* Read the project-owned gate switch.
|
|
270
550
|
* @param {string} cwd - Project root.
|
|
@@ -394,36 +674,157 @@ export const selectChangedTargets = (cwd, base, patterns) => {
|
|
|
394
674
|
};
|
|
395
675
|
|
|
396
676
|
/**
|
|
397
|
-
*
|
|
677
|
+
* How much of Stryker's transcript is kept. Every signature read above is near
|
|
678
|
+
* the end of it, and a large mutation run prints megabytes.
|
|
679
|
+
* @type {number}
|
|
680
|
+
*/
|
|
681
|
+
const CAPTURE_TAIL_BYTES = 256 * 1024;
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Whether this machine can tee Stryker's output without changing its verdict.
|
|
685
|
+
*
|
|
686
|
+
* Probed rather than assumed: on a shell with no `tee` the wrapper below writes
|
|
687
|
+
* no status file, and a gate that cannot read a status file must not guess one.
|
|
688
|
+
* `MUTATION_CAPTURE=0` opts out and buys back Stryker's TTY progress bar, at
|
|
689
|
+
* the cost of the diagnosis.
|
|
690
|
+
* @returns {boolean} Whether to take the capturing path.
|
|
691
|
+
*/
|
|
692
|
+
const captureAvailable = () => {
|
|
693
|
+
if (process.env.MUTATION_CAPTURE === "0") return false;
|
|
694
|
+
if (process.platform === "win32") return false;
|
|
695
|
+
const probe = spawnSync("sh", ["-c", "command -v tee"], { stdio: "ignore" });
|
|
696
|
+
return !probe.error && probe.status === 0;
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* The Stryker entry point to run, local install preferred.
|
|
398
701
|
* @param {string} cwd - Project root.
|
|
399
|
-
* @
|
|
400
|
-
* @returns {number} Stryker's exit status.
|
|
702
|
+
* @returns {{file: string, args: string[]}} Program and its leading arguments.
|
|
401
703
|
*/
|
|
402
|
-
const
|
|
704
|
+
const strykerEntry = cwd => {
|
|
403
705
|
const bin = path.join(
|
|
404
706
|
cwd,
|
|
405
707
|
"node_modules",
|
|
406
708
|
".bin",
|
|
407
709
|
process.platform === "win32" ? "stryker.cmd" : "stryker"
|
|
408
710
|
);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
711
|
+
return fs.existsSync(bin)
|
|
712
|
+
? { file: bin, args: ["run"] }
|
|
713
|
+
: { file: "npx", args: ["--yes", "stryker", "run"] };
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Read back what the wrapper recorded, keeping only the tail of the log.
|
|
718
|
+
*
|
|
719
|
+
* Fail closed on the status: an unreadable status is not a zero. Turning "I do
|
|
720
|
+
* not know" into "it passed" is the one mistake a gate may never make.
|
|
721
|
+
* @param {string} statusPath - File the wrapper wrote the exit code into.
|
|
722
|
+
* @param {string} logPath - File the wrapper tee'd the output into.
|
|
723
|
+
* @returns {{code: number, output: string|null}} The recorded answer.
|
|
724
|
+
*/
|
|
725
|
+
const readCaptured = (statusPath, logPath) => {
|
|
726
|
+
let output = null;
|
|
727
|
+
try {
|
|
728
|
+
output = fs.readFileSync(logPath, "utf8").slice(-CAPTURE_TAIL_BYTES);
|
|
729
|
+
} catch {
|
|
730
|
+
output = null;
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
const code = Number.parseInt(
|
|
734
|
+
fs.readFileSync(statusPath, "utf8").trim(),
|
|
735
|
+
10
|
|
736
|
+
);
|
|
737
|
+
return { code: Number.isInteger(code) ? code : 1, output };
|
|
738
|
+
} catch {
|
|
739
|
+
return { code: 1, output };
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Run Stryker with stdio inherited, capturing nothing.
|
|
745
|
+
* @param {string} cwd - Project root.
|
|
746
|
+
* @param {{file: string, args: string[]}} entry - Program and arguments.
|
|
747
|
+
* @param {NodeJS.ProcessEnv} env - Environment for the child.
|
|
748
|
+
* @returns {{code: number, output: null}} Exit status.
|
|
749
|
+
*/
|
|
750
|
+
const runStrykerPlain = (cwd, entry, env) => {
|
|
751
|
+
const result = spawnSync(entry.file, entry.args, {
|
|
752
|
+
cwd,
|
|
753
|
+
stdio: "inherit",
|
|
754
|
+
shell: process.platform === "win32",
|
|
755
|
+
env,
|
|
756
|
+
});
|
|
757
|
+
return { code: result.status ?? 1, output: null };
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Run Stryker, streaming its output AND keeping a copy to diagnose it from.
|
|
762
|
+
*
|
|
763
|
+
* The program and every path argument travel as argv through `"$0" "$@"`
|
|
764
|
+
* rather than being interpolated into the script. Interpolating them would put
|
|
765
|
+
* a filename through the shell's word splitting, which is how a path with a
|
|
766
|
+
* space becomes two paths that do not exist — and Stryker would then mutate
|
|
767
|
+
* neither, find nothing, and exit 0.
|
|
768
|
+
*
|
|
769
|
+
* The exit code comes from a status file written INSIDE the pipeline, never
|
|
770
|
+
* from the pipeline itself: a pipeline reports `tee`'s status, which is
|
|
771
|
+
* essentially always zero, and reading it would report every failing gate as
|
|
772
|
+
* passing.
|
|
773
|
+
* @param {string} cwd - Project root.
|
|
774
|
+
* @param {{file: string, args: string[]}} entry - Program and arguments.
|
|
775
|
+
* @param {NodeJS.ProcessEnv} env - Environment for the child.
|
|
776
|
+
* @returns {{code: number, output: string|null}|null} The answer, or null when
|
|
777
|
+
* a scratch directory could not be made and the caller should fall back.
|
|
778
|
+
*/
|
|
779
|
+
const runStrykerCaptured = (cwd, entry, env) => {
|
|
780
|
+
let dir;
|
|
781
|
+
try {
|
|
782
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-mutation-"));
|
|
783
|
+
} catch {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
const logPath = path.join(dir, "stryker.log");
|
|
787
|
+
const statusPath = path.join(dir, "status");
|
|
788
|
+
const script =
|
|
789
|
+
'{ "$0" "$@"\n' +
|
|
790
|
+
`echo $? > '${statusPath}'\n` +
|
|
791
|
+
`} 2>&1 | tee '${logPath}'\n`;
|
|
792
|
+
try {
|
|
793
|
+
const child = spawnSync("sh", ["-c", script, entry.file, ...entry.args], {
|
|
416
794
|
cwd,
|
|
417
795
|
stdio: "inherit",
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
796
|
+
env,
|
|
797
|
+
});
|
|
798
|
+
if (child.error) return { code: 1, output: null };
|
|
799
|
+
return readCaptured(statusPath, logPath);
|
|
800
|
+
} finally {
|
|
801
|
+
fs.rmSync(dir, { force: true, recursive: true });
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Hand the selected files to Stryker.
|
|
807
|
+
* @param {string} cwd - Project root.
|
|
808
|
+
* @param {readonly string[]} selected - Repository-relative paths.
|
|
809
|
+
* @returns {{code: number, output: string|null}} Stryker's status, and its
|
|
810
|
+
* output when this machine could keep a copy.
|
|
811
|
+
*/
|
|
812
|
+
const runStryker = (cwd, selected) => {
|
|
813
|
+
const scope = selected.join(",");
|
|
814
|
+
const base = strykerEntry(cwd);
|
|
815
|
+
const entry = { file: base.file, args: [...base.args, "--mutate", scope] };
|
|
816
|
+
const env = {
|
|
817
|
+
...process.env,
|
|
818
|
+
// What the run was scoped to, for a test-runner config that wants to
|
|
819
|
+
// narrow with it. A project that ignores it loses nothing, and a project
|
|
820
|
+
// that reads it can only ever REMOVE suites — which removes kills and
|
|
821
|
+
// lowers the score — so no value of this can turn a failing gate green.
|
|
822
|
+
MUTATION_SCOPE: scope,
|
|
823
|
+
};
|
|
824
|
+
if (!captureAvailable()) return runStrykerPlain(cwd, entry, env);
|
|
825
|
+
return (
|
|
826
|
+
runStrykerCaptured(cwd, entry, env) ?? runStrykerPlain(cwd, entry, env)
|
|
425
827
|
);
|
|
426
|
-
return result.status ?? 1;
|
|
427
828
|
};
|
|
428
829
|
|
|
429
830
|
/**
|
|
@@ -511,7 +912,17 @@ export const runGate = (cwd = process.cwd()) => {
|
|
|
511
912
|
`${scope.changed} changed file(s), selected by ${declaration.source}:`
|
|
512
913
|
);
|
|
513
914
|
for (const file of scope.selected) console.log(` • ${file}`);
|
|
514
|
-
|
|
915
|
+
|
|
916
|
+
const result = runStryker(cwd, scope.selected);
|
|
917
|
+
if (result.code === 0) return 0;
|
|
918
|
+
// Stryker's own verdict stands; what is added is WHICH failure it was. The
|
|
919
|
+
// gate used to end here on a bare status, and the hook above it then had to
|
|
920
|
+
// guess — which it did, out loud, as "mutation score below threshold", for
|
|
921
|
+
// dry runs that never computed a score at all.
|
|
922
|
+
console.error(
|
|
923
|
+
classifyStrykerFailure(result.output, resolveTimeoutBudgets(cwd)).message
|
|
924
|
+
);
|
|
925
|
+
return result.code;
|
|
515
926
|
};
|
|
516
927
|
|
|
517
928
|
/**
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
"coverageAnalysis": "perTest",
|
|
6
6
|
"ignoreStatic": true,
|
|
7
7
|
"incremental": true,
|
|
8
|
+
"_timeoutsComment": "Both budgets below are LIVENESS bounds: they exist to end a run that is wedged, not to assert how fast your machine is. Left unset, Stryker supplies 5000ms per mutant and a 5-minute dry run — numbers nobody here chose, and on a machine slower than the one they were picked on they behave as performance assertions. Worse, they fail as a mutation SCORE, so a developer on smaller hardware is told their tests are weak when the truth is the run never finished. Stated explicitly so the values are a decision, not an inheritance.",
|
|
9
|
+
"_timeoutMSComment": "Per-mutant slack. Stryker's per-mutant budget is `netTime * timeoutFactor + timeoutMS`, so this is added ON TOP of each mutant's own measured runtime — raising it gives a slow machine room without slowing a fast one, because a fast machine's mutants finish long before their own netTime elapses again. At 60000 a mutant is only killed by the clock when it runs a full minute beyond what the same test took in the dry run, which is an infinite loop rather than a busy laptop. A timed-out mutant is scored as KILLED, so a tight value here silently turns machine speed into mutation score. Absolute BY DESIGN, not by omission: Stryker's budget is `netTime * timeoutFactor + timeoutMS`, and the SCALING term already exists — `timeoutFactor` (default 1.5) multiplies each mutant's own runtime as measured on the machine actually running it, so machine speed is adapted to before this constant is added. Stryker's own schema calls timeoutMS \"an absolute timeout deviation. Tweak this if you run Stryker on a busy machine and you need to wait longer to make sure that the code indeed entered an infinite loop.\" That is exactly the use here.",
|
|
10
|
+
"timeoutMS": 60000,
|
|
11
|
+
"_dryRunTimeoutMinutesComment": "Whole-suite liveness bound, set by dryRunTimeoutMinutes, for the single un-mutated run that measures every test before any mutant is generated. Stryker's 5-minute default is the one that fails first: a suite needing 159s on an idle box has under 2x margin, and none at all under load, and when it blows there is no score to report — the gate simply dies. 20 minutes is generous on purpose; a genuinely hung dry run still fails, just later, and CI's own job timeout still caps it. Absolute BY NECESSITY: this bounds the run that PRODUCES the timing measurement, so there is nothing yet measured to scale it against. It cannot be made adaptive the way a per-mutant budget can. The claim it makes is deliberately weak — not \"your dry run finishes in this long\" but \"a dry run still going at this point is wedged rather than slow\" — which is what lets one number hold across hardware nobody here has seen. It fails only where a HEALTHY dry run genuinely exceeds it, which takes a pathological environment rather than a slow one (a saturated temp directory can make a single mkdtemp cost seconds). In that case the environment is the bug and raising this number hides it — which is why the gate now reports a blown budget as `dry-run-timeout` naming this value, rather than as a mutation score.",
|
|
12
|
+
"dryRunTimeoutMinutes": 20,
|
|
8
13
|
"mutate": [
|
|
9
14
|
"src/**/*.ts",
|
|
10
15
|
"src/**/*.tsx",
|
package/ui/index.html
CHANGED
|
@@ -9685,19 +9685,6 @@
|
|
|
9685
9685
|
reason: "checking…",
|
|
9686
9686
|
},
|
|
9687
9687
|
],
|
|
9688
|
-
[
|
|
9689
|
-
{ t: "mono", v: "🧪 Run E2E Tests" },
|
|
9690
|
-
{
|
|
9691
|
-
t: "text",
|
|
9692
|
-
v: "Playwright end-to-end tests, auto-detected from playwright.config + a test:e2e script",
|
|
9693
|
-
},
|
|
9694
|
-
{
|
|
9695
|
-
t: "status",
|
|
9696
|
-
jobId: "test:e2e",
|
|
9697
|
-
v: null,
|
|
9698
|
-
reason: "checking…",
|
|
9699
|
-
},
|
|
9700
|
-
],
|
|
9701
9688
|
[
|
|
9702
9689
|
{ t: "mono", v: "🎭 Playwright E2E Tests" },
|
|
9703
9690
|
{
|