@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.
- package/all/copy-overwrite/scripts/check-conflict-markers.mjs +173 -23
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +5 -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 +5 -2
- package/dist/core/upstream-evidence-manifest.js.map +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-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
|
-
|
|
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
|
-
* @
|
|
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
|
-
* @
|
|
1156
|
-
*
|
|
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
|
|
1168
|
-
|
|
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:
|
|
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
|
-
|
|
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) ??
|
|
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
|