@codyswann/lisa 4.1.4 → 4.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/all/copy-overwrite/scripts/check-conflict-markers.mjs +173 -23
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +5 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.js +5 -2
  7. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  8. package/package.json +1 -1
  9. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa-agy/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo-agy/plugin.json +1 -1
  22. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  42. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails-agy/plugin.json +1 -1
  47. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  52. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  57. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  59. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +464 -14
@@ -67,6 +67,26 @@
67
67
  * config declares none — a budget nobody chose is unactionable until somebody
68
68
  * is told that is what it was.
69
69
  *
70
+ * ## A killed run leaves a full second copy of the tree behind
71
+ *
72
+ * `cleanTempDir: "always"` is Stryker's OWN teardown, so it covers a pass and a
73
+ * fail and covers neither of the cases a busy machine actually produces: a
74
+ * SIGTERM, an OOM reap, a `maxBuffer` overflow, a Ctrl-C. One such kill left 72
75
+ * MB in `.stryker-tmp/`, and a leftover sandbox costs the next lint 1191 parse
76
+ * errors.
77
+ *
78
+ * An after-the-fact cleanup cannot run in exactly the case that creates the
79
+ * mess, so this gate does not add one. Each run gets its own
80
+ * `.stryker-tmp/run-<pid>-<epoch>` sandbox, and the NEXT run reclaims the ones
81
+ * whose owning process is gone — before it starts, while the sweeper owns what
82
+ * it is about to write. A live run's pid is alive, so its sandbox is skipped;
83
+ * the deleting-a-concurrent-run's-working-directory defect this obviously
84
+ * invites has already happened one directory over, and is designed out rather
85
+ * than warned about.
86
+ *
87
+ * The Stryker child also carries a deadline now — it had none, which in a git
88
+ * hook means a hung gate hangs the push forever — and a run killed at that
89
+ * deadline is reported as `child-deadline`, never as a score.
70
90
  * ## A timeout is not a kill either
71
91
  *
72
92
  * The other half of the same problem, and the one that survives a run
@@ -91,7 +111,8 @@
91
111
  *
92
112
  * `mutation.gate.json` (project-owned / create-only):
93
113
  * `{ "enabled": false, "since": "main" }`.
94
- * Overridable via env: `MUTATION_ENABLED=true|false`, `MUTATION_SINCE=<ref>`.
114
+ * Overridable via env: `MUTATION_ENABLED=true|false`, `MUTATION_SINCE=<ref>`,
115
+ * `MUTATION_CHILD_DEADLINE_MS=<ms>`.
95
116
  * `MUTATION_CAPTURE=0` turns the output capture off, trading the diagnosis
96
117
  * above for Stryker's TTY progress bar — and with it the timeout accounting,
97
118
  * which is read from the same transcript.
@@ -131,6 +152,8 @@ export const OUTCOMES = Object.freeze({
131
152
  dryRunTimeout: "mutation-gate: dry-run-timeout",
132
153
  scoreBelowBreak: "mutation-gate: score-below-break",
133
154
  runFailed: "mutation-gate: run-failed",
155
+ childDeadline: "mutation-gate: child-deadline",
156
+ sandboxReclaimed: "mutation-gate: sandbox-reclaimed",
134
157
  timeoutAccounting: "mutation-gate: timeout-accounting",
135
158
  timeoutUnmeasured: "mutation-gate: timeout-share-unmeasured",
136
159
  timeoutShareExceeded: "mutation-gate: timeout-share-exceeded",
@@ -965,6 +988,19 @@ export const envFlag = name => {
965
988
  return value === "true" || value === "1";
966
989
  };
967
990
 
991
+ /**
992
+ * How long a git probe may take before this gate gives up on it, in ms.
993
+ *
994
+ * Deliberately generous rather than tight. These are `merge-base`, `ls-files`
995
+ * and `diff` — normally milliseconds — but this repository has measured
996
+ * `/usr/bin/git` at 20,727 ms against a median of 24 on a contended box, so a
997
+ * small multiple of the median is a flake generator rather than a detector. Two
998
+ * minutes claims only that git is WEDGED, which in a git hook is usually an
999
+ * index lock somebody else is holding.
1000
+ * @type {number}
1001
+ */
1002
+ const GIT_DEADLINE_MS = 120_000;
1003
+
968
1004
  /**
969
1005
  * Run git, returning trimmed stdout.
970
1006
  *
@@ -980,6 +1016,8 @@ const git = (cwd, args) =>
980
1016
  cwd,
981
1017
  encoding: "utf8",
982
1018
  stdio: ["ignore", "pipe", "ignore"],
1019
+ killSignal: "SIGKILL",
1020
+ timeout: GIT_DEADLINE_MS,
983
1021
  }).trim();
984
1022
 
985
1023
  /**
@@ -1059,6 +1097,232 @@ export const selectChangedTargets = (cwd, base, patterns) => {
1059
1097
  */
1060
1098
  const CAPTURE_TAIL_BYTES = 256 * 1024;
1061
1099
 
1100
+ /**
1101
+ * Stryker's sandbox directory when a project declares no `tempDirName`.
1102
+ * @type {string}
1103
+ */
1104
+ export const DEFAULT_TEMP_DIR_NAME = ".stryker-tmp";
1105
+
1106
+ /**
1107
+ * The prefix every run-scoped sandbox carries.
1108
+ *
1109
+ * `run-<pid>-<epoch-ms>`. Both halves earn their place: the pid is what makes a
1110
+ * leftover ATTRIBUTABLE — the sweep can ask whether its owner is still alive
1111
+ * rather than guessing from a timestamp — and the epoch keeps two runs of the
1112
+ * same recycled pid apart.
1113
+ * @type {string}
1114
+ */
1115
+ const RUN_SANDBOX_PREFIX = "run-";
1116
+
1117
+ /** A run-scoped sandbox name's two numeric fields. */
1118
+ const RUN_SANDBOX_PATTERN = /^run-(\d+)-(\d+)$/u;
1119
+
1120
+ /**
1121
+ * The sandbox root this project uses.
1122
+ *
1123
+ * Read from the project's own `tempDirName` rather than hardcoded, for the same
1124
+ * reason `mutate` is: a gate that assumes a path the project did not choose
1125
+ * sweeps the wrong directory, and sweeping the wrong directory is worse than
1126
+ * not sweeping at all.
1127
+ * @param {string} cwd - Project root.
1128
+ * @returns {string} The configured sandbox root, project-relative.
1129
+ */
1130
+ export const resolveSandboxRoot = cwd => {
1131
+ const declared = readJsonConfig(cwd)?.tempDirName;
1132
+ return typeof declared === "string" && declared.length > 0
1133
+ ? declared
1134
+ : DEFAULT_TEMP_DIR_NAME;
1135
+ };
1136
+
1137
+ /**
1138
+ * A sandbox path this run owns and no other run can collide with.
1139
+ *
1140
+ * ## Why the path is per-run and not the configured one
1141
+ *
1142
+ * Two gate runs in one project shared a sandbox path, so the obvious repair for
1143
+ * a leftover — remove the sandbox — would have deleted the other run's working
1144
+ * directory out from under it. That defect has already happened one directory
1145
+ * over (CodySwannGT/lisa#2961): it surfaced as a bare `ENOENT` reported as a
1146
+ * coverage-gate failure and cost a day of controls to identify.
1147
+ *
1148
+ * A per-run path removes the collision at the source, and the sweep below is
1149
+ * what stops it becoming N sandboxes nobody reclaims.
1150
+ * @param {string} root - The configured sandbox root.
1151
+ * @param {number} [pid] - Owning process id.
1152
+ * @param {number} [startedAt] - Epoch milliseconds.
1153
+ * @returns {string} The sandbox path, project-relative, POSIX separators.
1154
+ */
1155
+ export const runSandboxName = (
1156
+ root,
1157
+ pid = process.pid,
1158
+ startedAt = Date.now()
1159
+ ) => `${normalizePath(root)}/${RUN_SANDBOX_PREFIX}${pid}-${startedAt}`;
1160
+
1161
+ /**
1162
+ * Whether a process id still names a running process.
1163
+ *
1164
+ * `EPERM` means it is alive and owned by somebody else, which is emphatically
1165
+ * NOT permission to delete its sandbox. Only `ESRCH` — no such process — is
1166
+ * evidence of abandonment.
1167
+ * @param {number} pid - Process id.
1168
+ * @returns {boolean} Whether it is alive.
1169
+ */
1170
+ export const processIsAlive = pid => {
1171
+ try {
1172
+ process.kill(pid, 0);
1173
+ return true;
1174
+ } catch (error) {
1175
+ return error.code === "EPERM";
1176
+ }
1177
+ };
1178
+
1179
+ /**
1180
+ * Read a directory name as a run-scoped sandbox, or null when it is not one.
1181
+ * @param {string} name - A directory name under the sandbox root.
1182
+ * @returns {{pid: number, startedAt: number}|null} Its owner.
1183
+ */
1184
+ export const parseSandboxOwner = name => {
1185
+ const match = RUN_SANDBOX_PATTERN.exec(name);
1186
+ if (!match) return null;
1187
+ return { pid: Number(match[1]), startedAt: Number(match[2]) };
1188
+ };
1189
+
1190
+ /**
1191
+ * Remove the sandboxes of gate runs that are no longer running.
1192
+ *
1193
+ * ## Sweep before, never after
1194
+ *
1195
+ * `cleanTempDir: "always"` is Stryker's OWN teardown, so it covers a pass and a
1196
+ * fail and covers neither of the cases that matter: a SIGTERM from a saturated
1197
+ * box, an OOM reap, a `maxBuffer` overflow, a Ctrl-C. Those are the runs a busy
1198
+ * machine actually produces, and each leaves a full second copy of the tree
1199
+ * behind — one measured at 72 MB, and a leftover sandbox costs the next lint
1200
+ * 1191 parse errors.
1201
+ *
1202
+ * An after-the-fact cleanup cannot run in exactly the case that creates the
1203
+ * mess. So the reclamation happens at the START of a run, while the sweeper is
1204
+ * the one that owns what it is about to write.
1205
+ *
1206
+ * ## Why this cannot delete a live run's files
1207
+ *
1208
+ * Only directories named `run-<pid>-<epoch>` are candidates, and only when
1209
+ * their pid is gone. A concurrent run's pid is alive, so its sandbox is
1210
+ * skipped; if a dead run's pid has since been RECYCLED, its sandbox is skipped
1211
+ * too and reclaimed on a later pass. The error is always in the direction of
1212
+ * leaving a directory alone.
1213
+ *
1214
+ * Anything under the root that is not a run-scoped sandbox is left untouched.
1215
+ * That includes the sandbox of a direct `stryker run` — the bite tests use
1216
+ * named ones — which this gate did not create and has no standing to remove.
1217
+ * @param {string} cwd - Project root.
1218
+ * @param {string} root - The configured sandbox root, project-relative.
1219
+ * @returns {{reclaimed: string[], live: string[]}} What went and what stayed.
1220
+ */
1221
+ export const reclaimAbandonedSandboxes = (cwd, root) => {
1222
+ const absolute = path.join(cwd, root);
1223
+ let entries;
1224
+ try {
1225
+ entries = fs.readdirSync(absolute, { withFileTypes: true });
1226
+ } catch {
1227
+ // No sandbox root yet, or one this process cannot read. Neither is a
1228
+ // reason to fail a gate, and neither is evidence about the tests.
1229
+ return { reclaimed: [], live: [] };
1230
+ }
1231
+ const reclaimed = [];
1232
+ const live = [];
1233
+ for (const entry of entries) {
1234
+ if (!entry.isDirectory()) continue;
1235
+ const owner = parseSandboxOwner(entry.name);
1236
+ if (owner === null) continue;
1237
+ if (processIsAlive(owner.pid)) {
1238
+ live.push(entry.name);
1239
+ continue;
1240
+ }
1241
+ try {
1242
+ fs.rmSync(path.join(absolute, entry.name), {
1243
+ force: true,
1244
+ recursive: true,
1245
+ });
1246
+ reclaimed.push(entry.name);
1247
+ } catch {
1248
+ // A sandbox that will not delete is a disk problem, not a test problem.
1249
+ live.push(entry.name);
1250
+ }
1251
+ }
1252
+ return { reclaimed, live };
1253
+ };
1254
+
1255
+ /**
1256
+ * How long the gate lets Stryker run before killing it, in milliseconds.
1257
+ *
1258
+ * The child had NO deadline. In CI that is bounded by the job timeout; in a git
1259
+ * hook it is bounded by nothing at all, so a hung gate hangs the push for as
1260
+ * long as the developer is willing to wait.
1261
+ *
1262
+ * Two hours, and deliberately nowhere near the work: the longest legitimate run
1263
+ * here is a whole-list mutation pass measured at 38-59 minutes, so this is
1264
+ * about 2x the worst of those. That margin is the point. A bound with 5%
1265
+ * headroom fails on jitter forever, and this claims only that a gate still
1266
+ * running after two hours is WEDGED rather than slow — a claim that holds on
1267
+ * hardware nobody here has seen.
1268
+ *
1269
+ * Absolute rather than scaled, for the same reason Stryker's own `timeoutMS`
1270
+ * is: a machine multiplier over a ~50-minute base produces a seven-hour
1271
+ * "bound", which is not one.
1272
+ * @type {number}
1273
+ */
1274
+ export const DEFAULT_CHILD_DEADLINE_MS = 7_200_000;
1275
+
1276
+ /**
1277
+ * How a killed run is reported back from `runStryker`.
1278
+ *
1279
+ * A token rather than a boolean, so the caller can grow other kinds of kill
1280
+ * without every reader having to be re-read.
1281
+ * @type {string}
1282
+ */
1283
+ const CHILD_DEADLINE = "child-deadline";
1284
+
1285
+ /**
1286
+ * The child deadline in force.
1287
+ * @returns {number} Milliseconds.
1288
+ */
1289
+ export const resolveChildDeadline = () => {
1290
+ const raw = process.env.MUTATION_CHILD_DEADLINE_MS;
1291
+ if (raw === undefined) return DEFAULT_CHILD_DEADLINE_MS;
1292
+ const parsed = Number(raw);
1293
+ return Number.isFinite(parsed) && parsed > 0
1294
+ ? parsed
1295
+ : DEFAULT_CHILD_DEADLINE_MS;
1296
+ };
1297
+
1298
+ /**
1299
+ * The block printed when THIS gate killed Stryker at its own deadline.
1300
+ *
1301
+ * The distinction it exists to make: a gate that ran and failed measured
1302
+ * something, and a gate that was killed measured nothing. Both used to arrive
1303
+ * as one nonzero status, and the second was then described — by the hook above
1304
+ * it — as a mutation score.
1305
+ * @param {number} deadlineMs - The deadline that fired.
1306
+ * @returns {string} The block.
1307
+ */
1308
+ export const childDeadlineBlock = deadlineMs =>
1309
+ `
1310
+ ❌ ${OUTCOMES.childDeadline}
1311
+ THIS GATE killed Stryker after ${deadlineMs}ms, its own child deadline. The run
1312
+ did not fail — it did not FINISH, and its output stops wherever the kill
1313
+ landed. NO score was computed, so nothing here is a verdict about your tests.
1314
+ Raise MUTATION_CHILD_DEADLINE_MS if this machine simply needs longer;
1315
+ investigate a hang if it does not.`.trimStart();
1316
+
1317
+ /**
1318
+ * How long the capture probe may take, in ms.
1319
+ *
1320
+ * A `command -v` is instantaneous; 30 seconds is a liveness bound, not a
1321
+ * performance assertion.
1322
+ * @type {number}
1323
+ */
1324
+ const PROBE_DEADLINE_MS = 30_000;
1325
+
1062
1326
  /**
1063
1327
  * Whether this machine can tee Stryker's output without changing its verdict.
1064
1328
  *
@@ -1071,7 +1335,18 @@ const CAPTURE_TAIL_BYTES = 256 * 1024;
1071
1335
  const captureAvailable = () => {
1072
1336
  if (process.env.MUTATION_CAPTURE === "0") return false;
1073
1337
  if (process.platform === "win32") return false;
1074
- const probe = spawnSync("sh", ["-c", "command -v tee"], { stdio: "ignore" });
1338
+ // `awk` as well as `tee`: the wrapper's deadline reaps Stryker's descendants
1339
+ // through a `ps` walk, and a wrapper whose reap cannot run is a deadline that
1340
+ // kills the shell and leaves the run going. The plain path below has Node's
1341
+ // own timeout, which is a weaker bound but an honest one.
1342
+ const probe = spawnSync("sh", ["-c", "command -v tee && command -v awk"], {
1343
+ stdio: "ignore",
1344
+ killSignal: "SIGKILL",
1345
+ // A `command -v` that hangs would hang the whole gate before Stryker even
1346
+ // starts — the same unbounded-child shape, one step earlier, and with no
1347
+ // Stryker output to diagnose it from.
1348
+ timeout: PROBE_DEADLINE_MS,
1349
+ });
1075
1350
  return !probe.error && probe.status === 0;
1076
1351
  };
1077
1352
 
@@ -1119,20 +1394,133 @@ const readCaptured = (statusPath, logPath) => {
1119
1394
  }
1120
1395
  };
1121
1396
 
1397
+ /**
1398
+ * How long the outer `spawnSync` waits past the wrapper's own watchdog, in ms.
1399
+ *
1400
+ * The wrapper is the bound and this is the backstop, in that order and never
1401
+ * the other way round. If the outer timeout fired first it would kill the SHELL
1402
+ * while the thing it is bounding kept running — a bound that fires while its
1403
+ * subject is still going, which is the defect rather than the fix.
1404
+ *
1405
+ * Ten seconds covers the reap, the `wait`, the status write and `tee` draining.
1406
+ * @type {number}
1407
+ */
1408
+ const WATCHDOG_GRACE_MS = 10_000;
1409
+
1410
+ /**
1411
+ * Kill a process and everything under it, portably.
1412
+ *
1413
+ * ## Why a `ps` walk and not a process-group kill
1414
+ *
1415
+ * A group kill is the obvious answer and it works on exactly one of the two
1416
+ * shells this runs under. Measured 2026-08-24, the same wrapper, a child that
1417
+ * spawns a grandchild, deadline 2s:
1418
+ *
1419
+ * | `/bin/sh` | grandchild | pipeline |
1420
+ * |---|---|---|
1421
+ * | bash-as-sh (macOS) | reaped | closes at 2s |
1422
+ * | dash (what `ubuntu-latest` links `/bin/sh` to) | **SURVIVED** | closes at 7s |
1423
+ *
1424
+ * dash's job control does not put the background job in its own process group,
1425
+ * so `kill -9 -"$pid"` finds nothing and only the direct child dies. That is
1426
+ * not merely untidy: the orphan still holds the pipe, so `tee` waits for IT —
1427
+ * and for a genuinely hung Stryker that wait is unbounded again. **The deadline
1428
+ * would have been defeated on the platform CI runs on.**
1429
+ *
1430
+ * So the group kill is attempted first, because on the shell where it works it
1431
+ * is atomic, and the recursive walk follows as the portable answer. Children
1432
+ * are reaped before their parent: killing the parent first reparents them and
1433
+ * loses the `ppid` link that finds them.
1434
+ * @type {string}
1435
+ */
1436
+ const REAP_FUNCTION = `lisa_gate_reap() {
1437
+ for lisa_gate_kid in $(ps -A -o pid=,ppid= | awk -v p="$1" '$2 == p { print $1 }'); do
1438
+ lisa_gate_reap "$lisa_gate_kid"
1439
+ done
1440
+ kill -9 "$1" 2>/dev/null || true
1441
+ }
1442
+ `;
1443
+
1444
+ /**
1445
+ * The shell wrapper: run Stryker, tee its output, and kill it if it hangs.
1446
+ *
1447
+ * ## Why the deadline is enforced in the shell and not by `spawnSync`
1448
+ *
1449
+ * The direct child here is `sh`, not Stryker. `spawnSync`'s own `timeout` kills
1450
+ * `sh`; Stryker survives it and only notices when its next write finds a broken
1451
+ * pipe — and the case this deadline exists for is a run that has stopped
1452
+ * writing. So the wrapper backgrounds Stryker, holds its pid, and kills THAT,
1453
+ * along with everything under it (see {@link REAP_FUNCTION}).
1454
+ *
1455
+ * `set -m` before the background starts and `set +m` after is deliberate on
1456
+ * both halves. On is what gives the job its own process group where the shell
1457
+ * supports it; off again is what keeps `[1]- Done` job-control notices out of
1458
+ * the transcript of every successful run.
1459
+ *
1460
+ * The kill leaves a marker FILE rather than relying on an exit code. A SIGKILLed
1461
+ * child reports 137, and 137 is a number a run could in principle reach on its
1462
+ * own; a file this wrapper wrote cannot be counterfeited by the child.
1463
+ *
1464
+ * The watchdog's own stdio is detached from the pipeline, and that line is
1465
+ * load-bearing rather than tidy. `tee` ends when every writer closes the pipe,
1466
+ * and `sleep` inherits it from the subshell — so a watchdog left on the pipe
1467
+ * holds `tee` open for the WHOLE deadline after Stryker has finished, turning
1468
+ * every successful run into a two-hour hang. Measured the hard way.
1469
+ *
1470
+ * The program and every path argument still travel as argv through `"$0" "$@"`.
1471
+ * Interpolating them would put a filename through the shell's word splitting,
1472
+ * which is how a path with a space becomes two paths that do not exist.
1473
+ * @param {string} statusPath - File the wrapper writes the exit code into.
1474
+ * @param {string} logPath - File the wrapper tees the output into.
1475
+ * @param {string} killedPath - File the watchdog writes when it fires.
1476
+ * @param {number} deadlineMs - How long Stryker may run.
1477
+ * @returns {string} The script.
1478
+ */
1479
+ export const watchdogScript = (statusPath, logPath, killedPath, deadlineMs) => {
1480
+ const seconds = Math.max(1, Math.ceil(deadlineMs / 1000));
1481
+ return `${REAP_FUNCTION}{ set -m 2>/dev/null || true
1482
+ "$0" "$@" &
1483
+ lisa_gate_child=$!
1484
+ ( sleep ${seconds}
1485
+ : > '${killedPath}'
1486
+ kill -9 -"$lisa_gate_child" 2>/dev/null
1487
+ lisa_gate_reap "$lisa_gate_child"
1488
+ ) >/dev/null 2>&1 </dev/null &
1489
+ lisa_gate_watchdog=$!
1490
+ set +m 2>/dev/null || true
1491
+ wait "$lisa_gate_child"
1492
+ echo $? > '${statusPath}'
1493
+ kill -9 "$lisa_gate_watchdog" 2>/dev/null || true
1494
+ } 2>&1 | tee '${logPath}'
1495
+ `;
1496
+ };
1497
+
1122
1498
  /**
1123
1499
  * Run Stryker with stdio inherited, capturing nothing.
1500
+ *
1501
+ * `killSignal: "SIGKILL"` rather than the default SIGTERM, deliberately.
1502
+ * Stryker installs a SIGTERM handler and calls `process.exit(128 + 15)` itself,
1503
+ * so a TERMed child comes back with a real numeric `143` and no `signal` field
1504
+ * — a corpse wearing a number, which every check that asks "is the status
1505
+ * missing?" waves through as a verdict.
1124
1506
  * @param {string} cwd - Project root.
1125
1507
  * @param {{file: string, args: string[]}} entry - Program and arguments.
1126
1508
  * @param {NodeJS.ProcessEnv} env - Environment for the child.
1127
- * @returns {{code: number, output: null}} Exit status.
1509
+ * @param {number} deadlineMs - When this gate kills the child.
1510
+ * @returns {{code: number, output: null, killedBy?: string}} Exit status.
1128
1511
  */
1129
- const runStrykerPlain = (cwd, entry, env) => {
1512
+ const runStrykerPlain = (cwd, entry, env, deadlineMs) => {
1130
1513
  const result = spawnSync(entry.file, entry.args, {
1131
1514
  cwd,
1132
1515
  stdio: "inherit",
1133
1516
  shell: process.platform === "win32",
1134
1517
  env,
1518
+ killSignal: "SIGKILL",
1519
+ timeout: deadlineMs,
1135
1520
  });
1521
+ if (result.error?.code === "ETIMEDOUT") {
1522
+ return { code: 1, output: null, killedBy: CHILD_DEADLINE };
1523
+ }
1136
1524
  return { code: result.status ?? 1, output: null };
1137
1525
  };
1138
1526
 
@@ -1152,10 +1540,12 @@ const runStrykerPlain = (cwd, entry, env) => {
1152
1540
  * @param {string} cwd - Project root.
1153
1541
  * @param {{file: string, args: string[]}} entry - Program and arguments.
1154
1542
  * @param {NodeJS.ProcessEnv} env - Environment for the child.
1155
- * @returns {{code: number, output: string|null}|null} The answer, or null when
1156
- * a scratch directory could not be made and the caller should fall back.
1543
+ * @param {number} deadlineMs - When the wrapper kills Stryker.
1544
+ * @returns {{code: number, output: string|null, killedBy?: string}|null} The
1545
+ * answer, or null when a scratch directory could not be made and the caller
1546
+ * should fall back.
1157
1547
  */
1158
- const runStrykerCaptured = (cwd, entry, env) => {
1548
+ const runStrykerCaptured = (cwd, entry, env, deadlineMs) => {
1159
1549
  let dir;
1160
1550
  try {
1161
1551
  dir = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-mutation-"));
@@ -1164,16 +1554,28 @@ const runStrykerCaptured = (cwd, entry, env) => {
1164
1554
  }
1165
1555
  const logPath = path.join(dir, "stryker.log");
1166
1556
  const statusPath = path.join(dir, "status");
1167
- const script =
1168
- '{ "$0" "$@"\n' +
1169
- `echo $? > '${statusPath}'\n` +
1170
- `} 2>&1 | tee '${logPath}'\n`;
1557
+ const killedPath = path.join(dir, "killed");
1558
+ const script = watchdogScript(statusPath, logPath, killedPath, deadlineMs);
1171
1559
  try {
1172
1560
  const child = spawnSync("sh", ["-c", script, entry.file, ...entry.args], {
1173
1561
  cwd,
1174
1562
  stdio: "inherit",
1175
1563
  env,
1564
+ killSignal: "SIGKILL",
1565
+ // A backstop under the wrapper's own watchdog, not the bound. The
1566
+ // wrapper kills STRYKER; this kills the shell, which leaves Stryker
1567
+ // running until its next write hits a broken pipe — and a hung run has
1568
+ // no next write. The grace is what keeps the two from racing, so the
1569
+ // failure that gets reported is the one that names the real child.
1570
+ timeout: deadlineMs + WATCHDOG_GRACE_MS,
1176
1571
  });
1572
+ if (fs.existsSync(killedPath) || child.error?.code === "ETIMEDOUT") {
1573
+ return {
1574
+ code: 1,
1575
+ output: readCaptured(statusPath, logPath).output,
1576
+ killedBy: CHILD_DEADLINE,
1577
+ };
1578
+ }
1177
1579
  if (child.error) return { code: 1, output: null };
1178
1580
  return readCaptured(statusPath, logPath);
1179
1581
  } finally {
@@ -1197,9 +1599,19 @@ const runStrykerCaptured = (cwd, entry, env) => {
1197
1599
  const runStryker = (cwd, selected) => {
1198
1600
  const scope = selected.join(",");
1199
1601
  const base = strykerEntry(cwd);
1602
+ const sandbox = runSandboxName(resolveSandboxRoot(cwd));
1603
+ const deadlineMs = resolveChildDeadline();
1200
1604
  const entry = {
1201
1605
  file: base.file,
1202
- args: scope === "" ? base.args : [...base.args, "--mutate", scope],
1606
+ args: [
1607
+ ...base.args,
1608
+ // Empty means the WHOLE LIST, so `--mutate` is omitted entirely and the
1609
+ // project's committed patterns stand. Passing one would narrow an `--all`
1610
+ // run to whatever was passed.
1611
+ ...(scope === "" ? [] : ["--mutate", scope]),
1612
+ "--tempDirName",
1613
+ sandbox,
1614
+ ],
1203
1615
  };
1204
1616
  const env = {
1205
1617
  ...process.env,
@@ -1209,9 +1621,38 @@ const runStryker = (cwd, selected) => {
1209
1621
  // lowers the score — so no value of this can turn a failing gate green.
1210
1622
  MUTATION_SCOPE: scope,
1211
1623
  };
1212
- if (!captureAvailable()) return runStrykerPlain(cwd, entry, env);
1624
+ // Reclaim before the run, never after — see `sweepSandboxes`. Here rather
1625
+ // than at either call site so the `--all` path cannot be given a different
1626
+ // answer from the diff path by omission.
1627
+ sweepSandboxes(cwd);
1628
+ if (!captureAvailable()) return runStrykerPlain(cwd, entry, env, deadlineMs);
1213
1629
  return (
1214
- runStrykerCaptured(cwd, entry, env) ?? runStrykerPlain(cwd, entry, env)
1630
+ runStrykerCaptured(cwd, entry, env, deadlineMs) ??
1631
+ runStrykerPlain(cwd, entry, env, deadlineMs)
1632
+ );
1633
+ };
1634
+
1635
+ /**
1636
+ * Reclaim abandoned sandboxes and say what was reclaimed.
1637
+ *
1638
+ * Printed rather than done in silence. A gate that quietly deletes 72 MB it
1639
+ * did not create in this run is indistinguishable from one that deleted
1640
+ * something it should not have, and the line is the only place a reader ever
1641
+ * finds out a previous run was killed.
1642
+ * @param {string} cwd - Project root.
1643
+ * @returns {void}
1644
+ */
1645
+ const sweepSandboxes = cwd => {
1646
+ const root = resolveSandboxRoot(cwd);
1647
+ const swept = reclaimAbandonedSandboxes(cwd, root);
1648
+ if (swept.reclaimed.length === 0) return;
1649
+ const listed = swept.reclaimed.map(name => ` • ${name}`).join("\n");
1650
+ const stayed =
1651
+ swept.live.length === 0
1652
+ ? ""
1653
+ : `\n ${swept.live.length} sandbox(es) left alone: their run is still going.`;
1654
+ console.log(
1655
+ `🧹 ${OUTCOMES.sandboxReclaimed} — removed ${swept.reclaimed.length} sandbox(es) under ${root} whose gate run is no longer alive:\n${listed}${stayed}`
1215
1656
  );
1216
1657
  };
1217
1658
 
@@ -1243,6 +1684,15 @@ export const WHOLE_LIST_FLAG = "--all";
1243
1684
  */
1244
1685
  const reportRun = (cwd, result) => {
1245
1686
  const accounting = accountForTimeouts(result.output, cwd);
1687
+ if (result.killedBy === CHILD_DEADLINE) {
1688
+ // A gate that ran and failed measured something; a gate that was KILLED
1689
+ // measured nothing. Both used to arrive as one nonzero status, and the
1690
+ // second was then described by the hook above as a mutation score. This
1691
+ // arm comes before the classification below because that reads Stryker's
1692
+ // transcript, and a killed run's transcript stops wherever the kill landed.
1693
+ console.error(childDeadlineBlock(resolveChildDeadline()));
1694
+ return result.code;
1695
+ }
1246
1696
  if (result.code !== 0) {
1247
1697
  // Stryker's own verdict stands; what is added is WHICH failure it was. The
1248
1698
  // gate used to end here on a bare status, and the hook above it then had to