@codyswann/lisa 4.27.8 → 4.28.0

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 (70) hide show
  1. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  2. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  3. package/dist/core/upstream-evidence-manifest.js +36 -2
  4. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  5. package/package.json +5 -4
  6. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/skills/lisa-validate-tracker-mapping/SKILL.md +134 -3
  9. package/plugins/lisa/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  10. package/plugins/lisa/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  11. package/plugins/lisa-agy/plugin.json +1 -1
  12. package/plugins/lisa-agy/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  13. package/plugins/lisa-agy/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  14. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  17. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-copilot/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  21. package/plugins/lisa-copilot/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  22. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cursor/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  24. package/plugins/lisa-cursor/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  25. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo-agy/plugin.json +1 -1
  28. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  48. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails-agy/plugin.json +1 -1
  53. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  58. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  63. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/src/base/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  66. package/plugins/src/base/skills/lisa-validate-tracker-mapping/SKILL.md +135 -4
  67. package/scripts/lib/mutation-performance-reporter.mjs +135 -0
  68. package/scripts/mutation-performance-measure.mjs +1794 -0
  69. package/scripts/mutation-performance.schema.json +103 -0
  70. package/typescript/create-only/.github/workflows/review-evidence.yml +18 -10
@@ -0,0 +1,1794 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Neutral mutation-performance evidence harness for CodySwannGT/lisa#3304.
4
+ *
5
+ * Stage 1 observes the existing public runner. It never changes production
6
+ * workflow wiring, mutation selection, thresholds, timeout accounting,
7
+ * concurrency, or cache trust. Hosted evidence is deliberately labelled
8
+ * `measurement-only`; choosing an optimization is a later, evidence-gated
9
+ * step.
10
+ * @module scripts/mutation-performance-measure
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { execFile, spawn } from "node:child_process";
14
+ import {
15
+ chmodSync,
16
+ cpSync,
17
+ createWriteStream,
18
+ existsSync,
19
+ lstatSync,
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ readFileSync,
23
+ readdirSync,
24
+ realpathSync,
25
+ renameSync,
26
+ rmSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import { availableParallelism, cpus, tmpdir } from "node:os";
30
+ import * as path from "node:path";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
32
+ import { promisify } from "node:util";
33
+
34
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
35
+
36
+ export const MEASUREMENT_SCHEMA_VERSION = "lisa.mutation-measurement/v1";
37
+ export const BASELINE_DRAWS = Object.freeze([1, 2, 3]);
38
+ export const MEASURED_STACKS = Object.freeze(["vitest", "jest"]);
39
+ export const EXPECTED_STACKS = Object.freeze([
40
+ "typescript",
41
+ "nestjs",
42
+ "cdk",
43
+ "npm-package",
44
+ "harper-fabric",
45
+ "phaser",
46
+ "expo",
47
+ "rails",
48
+ ]);
49
+ export const ARM_DEADLINE_MS = 20 * 60 * 1000;
50
+ export const MEASUREMENT_JOB_MINUTES = 50;
51
+ export const PREPARE_JOB_MINUTES = 30;
52
+ export const AGGREGATE_JOB_MINUTES = 10;
53
+ export const MAX_PARALLEL = 2;
54
+ export const HARD_BILLED_CEILING_MINUTES =
55
+ MEASURED_STACKS.length * BASELINE_DRAWS.length * MEASUREMENT_JOB_MINUTES +
56
+ PREPARE_JOB_MINUTES +
57
+ AGGREGATE_JOB_MINUTES;
58
+
59
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
60
+ const MEASUREMENT_ONLY = "measurement-only";
61
+ const COLD_MODE = "cold";
62
+ const WARM_MODE = "warm-local";
63
+ const PACKAGE_JSON = "package.json";
64
+ const RUNNER_PATH = "scripts/lisa-mutation.mjs";
65
+ const STRYKER_CONFIG = "stryker.conf.json";
66
+ const HOST_MANIFEST = "host-manifest.json";
67
+ const SUMMARY_JSON = "summary.json";
68
+ const PHASE_EVENTS = "phase-events.json";
69
+ const STRYKER_REPORT = "stryker-report.json";
70
+ const EFFECTIVE_CONFIG = "effective-stryker.conf.json";
71
+ const INCREMENTAL_DEFAULT = "reports/stryker-incremental.json";
72
+ const SOURCE_PATH = "src/grade.ts";
73
+ const TEST_PATH = "tests/grade.test.ts";
74
+ const execFileAsync = promisify(execFile);
75
+ const SHA40 = /^[0-9a-f]{40}$/u;
76
+ const SHA256 = /^[0-9a-f]{64}$/u;
77
+ const FINAL_STATUSES = Object.freeze([
78
+ "Killed",
79
+ "Survived",
80
+ "NoCoverage",
81
+ "Timeout",
82
+ "Ignored",
83
+ "CompileError",
84
+ "RuntimeError",
85
+ "Pending",
86
+ ]);
87
+ const PHASE_NAMES = Object.freeze([
88
+ "install",
89
+ "runner_setup",
90
+ "instrumentation",
91
+ "dry_run",
92
+ "plan",
93
+ "mutation",
94
+ "reporting",
95
+ "teardown",
96
+ ]);
97
+ const TOP_LEVEL_KEYS = Object.freeze([
98
+ "schema_version",
99
+ "conclusion",
100
+ "identity",
101
+ "protocol",
102
+ "selection",
103
+ "phases_ms",
104
+ "resources",
105
+ "results",
106
+ "cache",
107
+ "cleanup",
108
+ "digests",
109
+ ]);
110
+
111
+ /** Return a SHA-256 digest for a string or buffer. */
112
+ export const sha256 = value => createHash("sha256").update(value).digest("hex");
113
+
114
+ /** Return the digest of a regular, non-symlink file. */
115
+ export function sha256File(file) {
116
+ const info = lstatSync(file);
117
+ if (!info.isFile() || info.isSymbolicLink()) {
118
+ throw new Error(`evidence path is not a regular file: ${file}`);
119
+ }
120
+ return sha256(readFileSync(file));
121
+ }
122
+
123
+ /** Stable JSON: recursively sorted object keys, array order retained. */
124
+ export function canonicalJson(value) {
125
+ if (Array.isArray(value)) return value.map(canonicalJson);
126
+ if (value && typeof value === "object") {
127
+ return Object.fromEntries(
128
+ Object.entries(value)
129
+ .sort(([left], [right]) => left.localeCompare(right))
130
+ .map(([key, child]) => [key, canonicalJson(child)])
131
+ );
132
+ }
133
+ return value;
134
+ }
135
+
136
+ const position = point =>
137
+ `${Number(point?.line ?? 0)}:${Number(point?.column ?? 0)}`;
138
+
139
+ /**
140
+ * Canonical mutant identity independent of Stryker's ephemeral numeric ID.
141
+ */
142
+ export function canonicalMutantIdentity(mutant, fileName = mutant.fileName) {
143
+ const file = String(fileName ?? mutant.file ?? "");
144
+ const mutator = String(mutant.mutatorName ?? mutant.mutator ?? "");
145
+ const start = position(mutant.location?.start ?? mutant.start);
146
+ const end = position(mutant.location?.end ?? mutant.end);
147
+ const replacement = String(mutant.replacement ?? "");
148
+ return [file, mutator, start, end, sha256(replacement)].join("\u0000");
149
+ }
150
+
151
+ /** Produce the closed-schema mutant identity object. */
152
+ export function mutantIdentity(mutant, fileName) {
153
+ const [file, mutator, start, end, replacement_sha256] =
154
+ canonicalMutantIdentity(mutant, fileName).split("\u0000");
155
+ return { file, mutator, start, end, replacement_sha256 };
156
+ }
157
+
158
+ /**
159
+ * Add only measurement reporters to a temporary throwaway-host config.
160
+ */
161
+ export function overlayMeasurementReporters(source, reporterPath, reportPath) {
162
+ const reporters = [
163
+ ...new Set([
164
+ ...(Array.isArray(source.reporters)
165
+ ? source.reporters.filter(
166
+ name => name !== "html" && name !== "progress"
167
+ )
168
+ : []),
169
+ "json",
170
+ "lisa-measurement",
171
+ ]),
172
+ ];
173
+ return {
174
+ ...structuredClone(source),
175
+ reporters,
176
+ jsonReporter: { fileName: reportPath },
177
+ appendPlugins: [
178
+ ...(Array.isArray(source.appendPlugins) ? source.appendPlugins : []),
179
+ reporterPath,
180
+ ],
181
+ };
182
+ }
183
+
184
+ /** Resolve an artifact path beneath its root and refuse links/escapes. */
185
+ export function resolveArtifactPath(root, relative) {
186
+ if (!relative || path.isAbsolute(relative)) {
187
+ throw new Error("artifact path is outside its root");
188
+ }
189
+ const resolvedRoot = path.resolve(root);
190
+ const resolved = path.resolve(resolvedRoot, relative);
191
+ if (
192
+ resolved !== resolvedRoot &&
193
+ !resolved.startsWith(`${resolvedRoot}${path.sep}`)
194
+ ) {
195
+ throw new Error("artifact path is outside its root");
196
+ }
197
+ const parent = path.dirname(resolved);
198
+ if (
199
+ existsSync(parent) &&
200
+ !realpathSync(parent).startsWith(realpathSync(resolvedRoot))
201
+ ) {
202
+ throw new Error("artifact parent resolves outside its root");
203
+ }
204
+ if (existsSync(resolved) && lstatSync(resolved).isSymbolicLink()) {
205
+ throw new Error("artifact path must not be a symlink");
206
+ }
207
+ return resolved;
208
+ }
209
+
210
+ /** Hash raw evidence without recursively including the digest file itself. */
211
+ export function createDigestManifest(root, relativePaths) {
212
+ const unique = [...new Set(relativePaths)].sort();
213
+ if (unique.includes("evidence-digests.json")) {
214
+ throw new Error("digest manifest must not include itself");
215
+ }
216
+ if (unique.length > 32) throw new Error("too many evidence files");
217
+ return Object.fromEntries(
218
+ unique.map(relative => [
219
+ relative,
220
+ sha256File(resolveArtifactPath(root, relative)),
221
+ ])
222
+ );
223
+ }
224
+
225
+ const exactKeys = (value, expected, label, reasons) => {
226
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
227
+ reasons.push(`${label} must be an object`);
228
+ return false;
229
+ }
230
+ const actual = Object.keys(value).sort();
231
+ const wanted = [...expected].sort();
232
+ if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
233
+ reasons.push(`${label} has missing or unknown fields`);
234
+ return false;
235
+ }
236
+ return true;
237
+ };
238
+
239
+ const finiteNonnegative = value =>
240
+ typeof value === "number" && Number.isFinite(value) && value >= 0;
241
+
242
+ const uniqueBy = (rows, key) => new Set(rows.map(key)).size === rows.length;
243
+
244
+ const scoreWithoutTimeouts = statuses => {
245
+ const denominator =
246
+ statuses.Killed +
247
+ statuses.Timeout +
248
+ statuses.Survived +
249
+ statuses.NoCoverage;
250
+ return denominator === 0 ? Number.NaN : (statuses.Killed / denominator) * 100;
251
+ };
252
+
253
+ /**
254
+ * Fail-closed semantic validator used before any draw is accepted.
255
+ * The committed JSON schema is the exchange contract; these cross-field checks
256
+ * cover invariants JSON Schema cannot express.
257
+ */
258
+ export function validateMeasurement(value) {
259
+ const reasons = [];
260
+ if (!exactKeys(value, TOP_LEVEL_KEYS, "measurement", reasons)) {
261
+ return { valid: false, reasons };
262
+ }
263
+ if (value.schema_version !== MEASUREMENT_SCHEMA_VERSION)
264
+ reasons.push("wrong schema version");
265
+ if (value.conclusion !== MEASUREMENT_ONLY) reasons.push("wrong conclusion");
266
+
267
+ const identityKeys = [
268
+ "subject_sha",
269
+ "tree_sha",
270
+ "package_sha256",
271
+ "package_version",
272
+ "stack",
273
+ "runner_family",
274
+ "fixture_schema",
275
+ "fixture_sha256",
276
+ "host_base_sha",
277
+ "host_head_sha",
278
+ "run_id",
279
+ "job_id",
280
+ "run_attempt",
281
+ "ubuntu_image",
282
+ "node_version",
283
+ "bun_version",
284
+ "npm_version",
285
+ "stryker_version",
286
+ "runner_version",
287
+ "lock_sha256",
288
+ "package_json_sha256",
289
+ "runner_sha256",
290
+ "raw_config_sha256",
291
+ "effective_config_sha256",
292
+ "gate_sha256",
293
+ "workflow_sha256",
294
+ "source_sha256",
295
+ "test_sha256",
296
+ ];
297
+ if (exactKeys(value.identity, identityKeys, "identity", reasons)) {
298
+ for (const key of [
299
+ "subject_sha",
300
+ "tree_sha",
301
+ "host_base_sha",
302
+ "host_head_sha",
303
+ ])
304
+ if (!SHA40.test(value.identity[key]))
305
+ reasons.push(`identity.${key} is not a 40-hex SHA`);
306
+ for (const key of identityKeys.filter(key => key.endsWith("sha256")))
307
+ if (!SHA256.test(value.identity[key]))
308
+ reasons.push(`identity.${key} is not SHA-256`);
309
+ if (
310
+ !MEASURED_STACKS.includes(value.identity.stack) ||
311
+ value.identity.runner_family !== value.identity.stack
312
+ )
313
+ reasons.push("stack and runner family disagree");
314
+ }
315
+
316
+ const protocolKeys = [
317
+ "role",
318
+ "draw",
319
+ "mode",
320
+ "started_at",
321
+ "finished_at",
322
+ "invalid_reasons",
323
+ "cold_parent_summary_sha256",
324
+ "cold_parent_incremental_sha256",
325
+ ];
326
+ if (exactKeys(value.protocol, protocolKeys, "protocol", reasons)) {
327
+ if (!BASELINE_DRAWS.includes(value.protocol.draw))
328
+ reasons.push("draw is outside 1..3");
329
+ if (value.protocol.invalid_reasons?.length)
330
+ reasons.push("draw declares invalid reasons");
331
+ if (![COLD_MODE, WARM_MODE].includes(value.protocol.mode))
332
+ reasons.push("unknown protocol mode");
333
+ const warm = value.protocol.mode === WARM_MODE;
334
+ for (const key of [
335
+ "cold_parent_summary_sha256",
336
+ "cold_parent_incremental_sha256",
337
+ ]) {
338
+ const child = value.protocol[key];
339
+ if (warm ? !SHA256.test(child ?? "") : child !== null)
340
+ reasons.push(`${key} does not match protocol mode`);
341
+ }
342
+ }
343
+
344
+ const selectionKeys = [
345
+ "merge_base",
346
+ "head",
347
+ "changed_paths",
348
+ "mutate_files",
349
+ "tests",
350
+ "mutants",
351
+ ];
352
+ if (exactKeys(value.selection, selectionKeys, "selection", reasons)) {
353
+ if (value.selection.head !== value.identity?.host_head_sha)
354
+ reasons.push("selection head differs from host head");
355
+ if (value.selection.merge_base !== value.identity?.host_base_sha)
356
+ reasons.push("selection base differs from host base");
357
+ if (
358
+ !value.selection.changed_paths?.length ||
359
+ !value.selection.mutate_files?.length
360
+ )
361
+ reasons.push("selection is empty");
362
+ if (
363
+ !value.selection.tests?.length ||
364
+ !uniqueBy(
365
+ value.selection.tests,
366
+ row => `${row.file}\0${row.name}\0${row.location}`
367
+ )
368
+ )
369
+ reasons.push("tests are empty or duplicated");
370
+ if (
371
+ !value.selection.mutants?.length ||
372
+ !uniqueBy(
373
+ value.selection.mutants,
374
+ row =>
375
+ `${row.file}\0${row.mutator}\0${row.start}\0${row.end}\0${row.replacement_sha256}`
376
+ )
377
+ )
378
+ reasons.push("mutants are empty or duplicated");
379
+ }
380
+
381
+ const phaseKeys = [...PHASE_NAMES, "end_to_end", "clock_tolerance"];
382
+ if (exactKeys(value.phases_ms, phaseKeys, "phases_ms", reasons)) {
383
+ if (!phaseKeys.every(key => finiteNonnegative(value.phases_ms[key])))
384
+ reasons.push("phase values must be finite and nonnegative");
385
+ const sum = PHASE_NAMES.reduce(
386
+ (total, key) => total + value.phases_ms[key],
387
+ 0
388
+ );
389
+ if (
390
+ Math.abs(sum - value.phases_ms.end_to_end) >
391
+ value.phases_ms.clock_tolerance
392
+ )
393
+ reasons.push("phase sum differs from end-to-end");
394
+ }
395
+
396
+ const resourceKeys = [
397
+ "detected_cpus",
398
+ "peak_cpu_pct",
399
+ "median_cpu_pct",
400
+ "peak_rss_bytes",
401
+ "peak_process_count",
402
+ "sample_count",
403
+ ];
404
+ if (exactKeys(value.resources, resourceKeys, "resources", reasons)) {
405
+ if (
406
+ !Number.isInteger(value.resources.sample_count) ||
407
+ value.resources.sample_count < 2
408
+ )
409
+ reasons.push("insufficient resource samples");
410
+ if (
411
+ !Number.isInteger(value.resources.peak_process_count) ||
412
+ value.resources.peak_process_count < 1
413
+ )
414
+ reasons.push("invalid process count");
415
+ }
416
+
417
+ const resultKeys = [
418
+ "statuses",
419
+ "expected_total",
420
+ "observed_total",
421
+ "thresholds",
422
+ "reported_score",
423
+ "timeout_recomputed_score",
424
+ "exit_code",
425
+ "signal",
426
+ "verdict",
427
+ "named_survivors",
428
+ ];
429
+ if (exactKeys(value.results, resultKeys, "results", reasons)) {
430
+ if (
431
+ exactKeys(value.results.statuses, FINAL_STATUSES, "statuses", reasons)
432
+ ) {
433
+ if (value.results.statuses.Pending !== 0)
434
+ reasons.push("Pending is not a final status");
435
+ const total = FINAL_STATUSES.reduce(
436
+ (count, status) => count + value.results.statuses[status],
437
+ 0
438
+ );
439
+ if (
440
+ total !== value.results.expected_total ||
441
+ total !== value.results.observed_total ||
442
+ total !== value.selection?.mutants?.length
443
+ )
444
+ reasons.push("mutant totals do not reconcile");
445
+ const honest = scoreWithoutTimeouts(value.results.statuses);
446
+ if (
447
+ Number.isFinite(honest) &&
448
+ Math.abs(honest - value.results.timeout_recomputed_score) > 0.01
449
+ )
450
+ reasons.push("timeout-recomputed score disagrees");
451
+ }
452
+ if (value.results.signal !== null) reasons.push("signaled arm is invalid");
453
+ const thresholdRed =
454
+ value.results.timeout_recomputed_score < value.results.thresholds?.break;
455
+ if ((thresholdRed ? "threshold-red" : "passed") !== value.results.verdict)
456
+ reasons.push("threshold verdict disagrees");
457
+ if (
458
+ thresholdRed
459
+ ? value.results.exit_code === 0
460
+ : value.results.exit_code !== 0
461
+ )
462
+ reasons.push("exit code disagrees with threshold verdict");
463
+ }
464
+
465
+ const cacheKeys = [
466
+ "incremental_configured",
467
+ "before_exists",
468
+ "before_sha256",
469
+ "after_exists",
470
+ "after_sha256",
471
+ "cold_parent_identity",
472
+ "disposition",
473
+ "remote_read",
474
+ "remote_write",
475
+ ];
476
+ if (exactKeys(value.cache, cacheKeys, "cache", reasons)) {
477
+ if (value.cache.remote_read || value.cache.remote_write)
478
+ reasons.push("remote cache is forbidden in Stage 1");
479
+ if (
480
+ value.protocol?.mode === COLD_MODE &&
481
+ (value.cache.before_exists || value.cache.before_sha256 !== null)
482
+ )
483
+ reasons.push("cold draw had incremental residue");
484
+ if (
485
+ value.protocol?.mode === WARM_MODE &&
486
+ (!value.cache.before_exists ||
487
+ !SHA256.test(value.cache.cold_parent_identity ?? ""))
488
+ )
489
+ reasons.push("warm draw is not bound to its cold parent");
490
+ }
491
+
492
+ const cleanupKeys = [
493
+ "root_pid",
494
+ "root_birth_tick",
495
+ "signal_forwarded",
496
+ "kill_reason",
497
+ "descendant_survivors",
498
+ "sandbox_entries_before",
499
+ "sandbox_entries_after",
500
+ "sampler_healthy",
501
+ ];
502
+ if (exactKeys(value.cleanup, cleanupKeys, "cleanup", reasons)) {
503
+ if (!value.cleanup.sampler_healthy) reasons.push("sampler died");
504
+ if (value.cleanup.descendant_survivors !== 0)
505
+ reasons.push("descendant survived cleanup");
506
+ if (value.cleanup.sandbox_entries_after !== 0)
507
+ reasons.push("sandbox residue remains");
508
+ if (value.cleanup.kill_reason !== null)
509
+ reasons.push("harness killed the arm");
510
+ }
511
+
512
+ const digestKeys = [
513
+ "phase_events_sha256",
514
+ "process_samples_sha256",
515
+ "selection_sha256",
516
+ "stryker_report_sha256",
517
+ "runner_log_sha256",
518
+ "host_manifest_sha256",
519
+ ];
520
+ if (exactKeys(value.digests, digestKeys, "digests", reasons)) {
521
+ for (const key of digestKeys)
522
+ if (!SHA256.test(value.digests[key]))
523
+ reasons.push(`digests.${key} is not SHA-256`);
524
+ }
525
+ return { valid: reasons.length === 0, reasons };
526
+ }
527
+
528
+ const median = values => {
529
+ const ordered = [...values].sort((a, b) => a - b);
530
+ return ordered[Math.floor(ordered.length / 2)];
531
+ };
532
+
533
+ /** Aggregate exactly three comparable cold draws per measured runner family. */
534
+ export function aggregateMeasurements(rows) {
535
+ const cold = rows.filter(row => row.protocol?.mode === COLD_MODE);
536
+ const seen = new Set();
537
+ const byStack = {};
538
+ for (const row of cold) {
539
+ const validation = validateMeasurement(row);
540
+ if (!validation.valid)
541
+ throw new Error(`invalid draw: ${validation.reasons.join("; ")}`);
542
+ const key = `${row.identity.stack}:${row.protocol.draw}`;
543
+ if (seen.has(key)) throw new Error(`duplicate stack/draw evidence: ${key}`);
544
+ seen.add(key);
545
+ const stackRows = byStack[row.identity.stack] ?? [];
546
+ stackRows.push(row);
547
+ byStack[row.identity.stack] = stackRows;
548
+ }
549
+ for (const stack of MEASURED_STACKS) {
550
+ const stackRows = byStack[stack] ?? [];
551
+ if (stackRows.length !== BASELINE_DRAWS.length)
552
+ throw new Error(`${stack} needs three valid cold draws`);
553
+ const subjects = new Set(stackRows.map(row => row.identity.subject_sha));
554
+ const selections = new Set(
555
+ stackRows.map(row => sha256(JSON.stringify(canonicalJson(row.selection))))
556
+ );
557
+ const outcomes = new Set(
558
+ stackRows.map(row => JSON.stringify(row.results.statuses))
559
+ );
560
+ if (subjects.size !== 1 || selections.size !== 1 || outcomes.size !== 1)
561
+ throw new Error(`${stack} draws are not comparable`);
562
+ }
563
+ return {
564
+ schema_version: "lisa.mutation-measurement-aggregate/v1",
565
+ conclusion: MEASUREMENT_ONLY,
566
+ dominance_established: false,
567
+ improvement_established: false,
568
+ hard_billed_ceiling_minutes: HARD_BILLED_CEILING_MINUTES,
569
+ stacks: Object.fromEntries(
570
+ MEASURED_STACKS.map(stack => [
571
+ stack,
572
+ {
573
+ draws: BASELINE_DRAWS.length,
574
+ phase_medians_ms: Object.fromEntries(
575
+ [...PHASE_NAMES, "end_to_end"].map(phase => [
576
+ phase,
577
+ median(byStack[stack].map(row => row.phases_ms[phase])),
578
+ ])
579
+ ),
580
+ },
581
+ ])
582
+ ),
583
+ };
584
+ }
585
+
586
+ /** Validate the prepare manifest before expensive measurement jobs start. */
587
+ export function verifyPreparedManifest(manifest) {
588
+ const reasons = [];
589
+ if (manifest?.schema_version !== "lisa.mutation-prepared-hosts/v1")
590
+ reasons.push("wrong prepare schema");
591
+ if (!SHA40.test(manifest?.subject_sha ?? ""))
592
+ reasons.push("invalid subject SHA");
593
+ if (
594
+ !Array.isArray(manifest?.stacks) ||
595
+ manifest.stacks.map(row => row.stack).join("\0") !==
596
+ EXPECTED_STACKS.join("\0")
597
+ )
598
+ reasons.push("stack roster differs");
599
+ for (const row of manifest?.stacks ?? []) {
600
+ if (!row.second_apply_idempotent)
601
+ reasons.push(`${row.stack} second apply changed governed bytes`);
602
+ if (row.stack === "rails" ? row.stryker_present : !row.stryker_present)
603
+ reasons.push(`${row.stack} mutation engine parity differs`);
604
+ if (!SHA256.test(row.governed_digest ?? ""))
605
+ reasons.push(`${row.stack} governed digest missing`);
606
+ }
607
+ return { valid: reasons.length === 0, reasons };
608
+ }
609
+
610
+ const copyFixture = (name, destination) => {
611
+ cpSync(
612
+ path.join(ROOT, "tests/fixtures/mutation-performance", name),
613
+ destination,
614
+ {
615
+ recursive: true,
616
+ errorOnExist: false,
617
+ }
618
+ );
619
+ };
620
+
621
+ const readJson = file => JSON.parse(readFileSync(file, "utf8"));
622
+
623
+ const writeJson = (file, value) => {
624
+ mkdirSync(path.dirname(file), { recursive: true });
625
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
626
+ };
627
+
628
+ const seedStack = (stack, host) => {
629
+ mkdirSync(host, { recursive: true });
630
+ if (stack === "rails") {
631
+ copyFixture("rails", host);
632
+ chmodSync(path.join(host, "bin/rails"), 0o755);
633
+ return;
634
+ }
635
+ copyFixture(stack === "expo" ? "jest" : "vitest", host);
636
+ const manifestPath = path.join(host, PACKAGE_JSON);
637
+ const manifest = readJson(manifestPath);
638
+ manifest.name = `lisa-mutation-measurement-${stack}`;
639
+ if (stack === "nestjs") writeJson(path.join(host, "nest-cli.json"), {});
640
+ if (stack === "cdk")
641
+ writeJson(path.join(host, "cdk.json"), { app: "node src/grade.ts" });
642
+ if (stack === "npm-package") {
643
+ manifest.private = false;
644
+ manifest.exports = "./src/grade.ts";
645
+ }
646
+ if (stack === "harper-fabric") {
647
+ mkdirSync(path.join(host, "harper-app"), { recursive: true });
648
+ writeFileSync(
649
+ path.join(host, "harper-app/config.yaml"),
650
+ "graphqlSchema: schema.graphql\njsResource: ../src\nstatic: ../public\n"
651
+ );
652
+ writeFileSync(
653
+ path.join(host, "harper-app/schema.graphql"),
654
+ "type Query { health: String! }\n"
655
+ );
656
+ }
657
+ if (stack === "phaser") {
658
+ manifest.dependencies = { ...manifest.dependencies, phaser: "^3.90.0" };
659
+ }
660
+ writeJson(manifestPath, manifest);
661
+ };
662
+
663
+ const git = async (host, args) =>
664
+ commandOutput("git", ["-c", "commit.gpgsign=false", ...args], { cwd: host });
665
+
666
+ const initializeGit = async host => {
667
+ await git(host, ["init", "--initial-branch=main"]);
668
+ await git(host, ["config", "user.email", "measurement@example.invalid"]);
669
+ await git(host, ["config", "user.name", "Lisa measurement"]);
670
+ await git(host, ["add", "-A"]);
671
+ await git(host, ["commit", "-m", "fixture: initial detector seed"]);
672
+ };
673
+
674
+ const applyExactPackage = async ({ entry, host, fakeBin, marker }) => {
675
+ await execFileAsync(
676
+ process.execPath,
677
+ [entry, "apply", host, "--yes", "--no-update-check", "--harness=cursor"],
678
+ {
679
+ cwd: host,
680
+ env: {
681
+ ...process.env,
682
+ PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`,
683
+ LISA_MEASURE_GH_MARKER: marker,
684
+ LISA_BOOTSTRAP: "1",
685
+ CI: "1",
686
+ },
687
+ encoding: "utf8",
688
+ maxBuffer: 64 * 1024 * 1024,
689
+ }
690
+ );
691
+ };
692
+
693
+ const GOVERNED_PATHS = Object.freeze([
694
+ PACKAGE_JSON,
695
+ ".github/workflows/ci.yml",
696
+ RUNNER_PATH,
697
+ "scripts/lisa-mutation.sh",
698
+ STRYKER_CONFIG,
699
+ "mutation.gate.json",
700
+ "mutation.gate.yml",
701
+ ]);
702
+
703
+ const governedSnapshot = host =>
704
+ Object.fromEntries(
705
+ GOVERNED_PATHS.filter(relative =>
706
+ existsSync(path.join(host, relative))
707
+ ).map(relative => [relative, sha256File(path.join(host, relative))])
708
+ );
709
+
710
+ const governedDigest = snapshot =>
711
+ sha256(JSON.stringify(canonicalJson(snapshot)));
712
+
713
+ const expectedDirectType = stack => stack;
714
+
715
+ const expectedExpandedTypes = stack => {
716
+ if (stack === "rails") return ["rails"];
717
+ if (stack === "typescript") return ["typescript"];
718
+ return ["typescript", stack];
719
+ };
720
+
721
+ const detectTypes = async (detectionEntry, host) => {
722
+ const { DetectorRegistry } = await import(pathToFileURL(detectionEntry).href);
723
+ const registry = new DetectorRegistry();
724
+ const direct = await registry.detectAll(host);
725
+ return { direct, expanded: registry.expandAndOrderTypes(direct) };
726
+ };
727
+
728
+ const removeMeasurementResidue = host => {
729
+ for (const relative of [
730
+ "node_modules",
731
+ "reports",
732
+ ".stryker-tmp",
733
+ "coverage",
734
+ "dist",
735
+ ]) {
736
+ rmSync(path.join(host, relative), { recursive: true, force: true });
737
+ }
738
+ };
739
+
740
+ const wait = milliseconds =>
741
+ new Promise(resolve => setTimeout(resolve, milliseconds));
742
+
743
+ const parseProcStat = pid => {
744
+ // `/proc/<pid>/stat` ends with a newline. `$` is not newline-tolerant
745
+ // without the `m` flag, so the trailing byte must go before matching or
746
+ // every row fails to parse and the sampler goes silently blind.
747
+ const body = readFileSync(`/proc/${pid}/stat`, "utf8").trimEnd();
748
+ const match = body.match(/^(\d+) \((.*)\) (\S) (.*)$/u);
749
+ if (!match) throw new Error(`unparseable /proc stat for ${pid}`);
750
+ const fields = match[4].split(" ");
751
+ return {
752
+ pid: Number(match[1]),
753
+ comm: match[2],
754
+ ppid: Number(fields[0]),
755
+ cpu_ticks: Number(fields[10]) + Number(fields[11]),
756
+ start_tick: fields[18],
757
+ rss_bytes: Math.max(0, Number(fields[20])) * 4096,
758
+ };
759
+ };
760
+
761
+ const allProcRows = () => {
762
+ if (process.platform !== "linux" || !existsSync("/proc")) {
763
+ throw new Error(
764
+ "mutation measurement process sampling requires ubuntu/Linux /proc"
765
+ );
766
+ }
767
+ const rows = [];
768
+ for (const entry of readdirSync("/proc")) {
769
+ if (!/^\d+$/u.test(entry)) continue;
770
+ try {
771
+ rows.push(parseProcStat(Number(entry)));
772
+ } catch {
773
+ /* process exited */
774
+ }
775
+ }
776
+ return rows;
777
+ };
778
+
779
+ const descendantsOf = (rootPid, all) => {
780
+ const selected = new Map();
781
+ let frontier = [{ pid: rootPid, depth: 0 }];
782
+ const byParent = new Map();
783
+ for (const row of all)
784
+ (byParent.get(row.ppid) ?? byParent.set(row.ppid, []).get(row.ppid)).push(
785
+ row
786
+ );
787
+ const byPid = new Map(all.map(row => [row.pid, row]));
788
+ while (frontier.length) {
789
+ const next = [];
790
+ for (const item of frontier) {
791
+ const row = byPid.get(item.pid);
792
+ if (row && !selected.has(row.pid))
793
+ selected.set(row.pid, { ...row, depth: item.depth });
794
+ for (const child of byParent.get(item.pid) ?? []) {
795
+ if (!selected.has(child.pid))
796
+ next.push({ pid: child.pid, depth: item.depth + 1 });
797
+ }
798
+ }
799
+ frontier = next;
800
+ }
801
+ return [...selected.values()];
802
+ };
803
+
804
+ const liveTracked = tracked => {
805
+ const live = [];
806
+ for (const saved of tracked.values()) {
807
+ try {
808
+ const current = parseProcStat(saved.pid);
809
+ if (current.start_tick === saved.start_tick)
810
+ live.push({ ...current, depth: saved.depth });
811
+ } catch {
812
+ /* exited */
813
+ }
814
+ }
815
+ return live;
816
+ };
817
+
818
+ const signalTracked = (rows, signal) => {
819
+ for (const row of [...rows].sort((left, right) => right.depth - left.depth)) {
820
+ try {
821
+ process.kill(row.pid, signal);
822
+ } catch {
823
+ /* exited */
824
+ }
825
+ }
826
+ };
827
+
828
+ /**
829
+ * Run the public mutation entrypoint asynchronously while sampling and reaping
830
+ * only its PID-birth-bound process tree.
831
+ */
832
+ export async function runObservedCommand({
833
+ cwd,
834
+ args,
835
+ output,
836
+ env = {},
837
+ deadlineMs = ARM_DEADLINE_MS,
838
+ }) {
839
+ if (process.platform !== "linux")
840
+ throw new Error("hosted measurement must run on ubuntu/Linux");
841
+ mkdirSync(output, { recursive: true });
842
+ const logPath = path.join(output, "runner.log");
843
+ const samplesPath = path.join(output, "process-samples.jsonl");
844
+ const log = createWriteStream(logPath, { flags: "wx", mode: 0o600 });
845
+ const started = Date.now();
846
+ const child = spawn(process.execPath, args, {
847
+ cwd,
848
+ detached: true,
849
+ env: { ...process.env, ...env },
850
+ stdio: ["ignore", "pipe", "pipe"],
851
+ });
852
+ child.stdout.pipe(log, { end: false });
853
+ child.stderr.pipe(log, { end: false });
854
+ const tracked = new Map();
855
+ const samples = [];
856
+ let samplerHealthy = true;
857
+ let samplerError = null;
858
+ let killReason = null;
859
+ let signalForwarded = null;
860
+ let previous = new Map();
861
+ const clockTicks = Number(await commandOutput("getconf", ["CLK_TCK"]));
862
+ const capture = () => {
863
+ try {
864
+ const rows = descendantsOf(child.pid, allProcRows());
865
+ if (rows.length > 64)
866
+ throw new Error("process tree exceeded 64 descendants");
867
+ for (const row of rows) tracked.set(row.pid, row);
868
+ const now = Date.now();
869
+ const elapsed = Math.max(
870
+ 1,
871
+ now - (samples.at(-1)?.timestamp_ms ?? started)
872
+ );
873
+ const processes = rows.map(row => {
874
+ const prior = previous.get(row.pid);
875
+ const ticks =
876
+ prior?.start_tick === row.start_tick
877
+ ? Math.max(0, row.cpu_ticks - prior.cpu_ticks)
878
+ : 0;
879
+ return {
880
+ pid: row.pid,
881
+ ppid: row.ppid,
882
+ start_tick: row.start_tick,
883
+ comm: row.comm,
884
+ cpu_pct: (ticks / clockTicks / (elapsed / 1000)) * 100,
885
+ rss_bytes: row.rss_bytes,
886
+ };
887
+ });
888
+ previous = new Map(rows.map(row => [row.pid, row]));
889
+ if (processes.length)
890
+ samples.push({
891
+ timestamp_ms: now,
892
+ elapsed_ms: now - started,
893
+ processes,
894
+ });
895
+ if (samples.length > 2400)
896
+ throw new Error("process sampler exceeded its row bound");
897
+ } catch (error) {
898
+ samplerHealthy = false;
899
+ samplerError = error;
900
+ }
901
+ };
902
+ capture();
903
+ const interval = setInterval(capture, 1000);
904
+ const forward = signal => {
905
+ signalForwarded = signal;
906
+ try {
907
+ process.kill(-child.pid, signal);
908
+ } catch {
909
+ /* exited */
910
+ }
911
+ };
912
+ const signalHandlers = Object.fromEntries(
913
+ ["SIGINT", "SIGTERM", "SIGHUP"].map(signal => [
914
+ signal,
915
+ () => forward(signal),
916
+ ])
917
+ );
918
+ for (const [signal, handler] of Object.entries(signalHandlers))
919
+ process.once(signal, handler);
920
+ const deadline = setTimeout(() => {
921
+ killReason = "harness-deadline";
922
+ try {
923
+ process.kill(-child.pid, "SIGTERM");
924
+ } catch {
925
+ /* exited */
926
+ }
927
+ }, deadlineMs);
928
+ let exitCode = null;
929
+ let exitSignal = null;
930
+ try {
931
+ ({ code: exitCode, signal: exitSignal } = await new Promise(
932
+ (resolve, reject) => {
933
+ child.once("error", reject);
934
+ child.once("close", (code, signal) => resolve({ code, signal }));
935
+ }
936
+ ));
937
+ } finally {
938
+ clearInterval(interval);
939
+ clearTimeout(deadline);
940
+ for (const [signal, handler] of Object.entries(signalHandlers))
941
+ process.removeListener(signal, handler);
942
+ capture();
943
+ const stillLive = liveTracked(tracked);
944
+ if (stillLive.length) {
945
+ signalTracked(stillLive, "SIGTERM");
946
+ await wait(5000);
947
+ signalTracked(liveTracked(tracked), "SIGKILL");
948
+ await wait(100);
949
+ }
950
+ await new Promise(resolve => log.end(resolve));
951
+ }
952
+ const survivors = liveTracked(tracked);
953
+ const processRows = samples.flatMap(sample => sample.processes);
954
+ writeFileSync(
955
+ samplesPath,
956
+ `${samples.map(sample => JSON.stringify(sample)).join("\n")}\n`,
957
+ { flag: "wx", mode: 0o600 }
958
+ );
959
+ if (samplerError) {
960
+ writeFileSync(
961
+ path.join(output, "sampler-error.txt"),
962
+ `${String(samplerError)}\n`,
963
+ { mode: 0o600 }
964
+ );
965
+ }
966
+ const cpuTotals = samples.map(sample =>
967
+ sample.processes.reduce((total, row) => total + row.cpu_pct, 0)
968
+ );
969
+ const rssTotals = samples.map(sample =>
970
+ sample.processes.reduce((total, row) => total + row.rss_bytes, 0)
971
+ );
972
+ const counts = samples.map(sample => sample.processes.length);
973
+ const root = tracked.get(child.pid);
974
+ return {
975
+ started_at_ms: started,
976
+ finished_at_ms: Date.now(),
977
+ exit_code: exitCode,
978
+ signal: exitSignal,
979
+ resources: {
980
+ detected_cpus: availableParallelism?.() ?? cpus().length,
981
+ peak_cpu_pct: Math.max(0, ...cpuTotals),
982
+ median_cpu_pct: cpuTotals.length ? median(cpuTotals) : 0,
983
+ peak_rss_bytes: Math.max(0, ...rssTotals),
984
+ peak_process_count: Math.max(0, ...counts),
985
+ sample_count: samples.length,
986
+ },
987
+ cleanup: {
988
+ root_pid: child.pid,
989
+ root_birth_tick: root?.start_tick ?? "missing",
990
+ signal_forwarded: signalForwarded,
991
+ kill_reason: killReason,
992
+ descendant_survivors: survivors.length,
993
+ sampler_healthy: samplerHealthy,
994
+ },
995
+ log_path: logPath,
996
+ samples_path: samplesPath,
997
+ tracked_process_rows: processRows.length,
998
+ };
999
+ }
1000
+
1001
+ const prepareMeasuredHistory = async host => {
1002
+ await commandOutput("bun", ["install", "--ignore-scripts"], {
1003
+ cwd: host,
1004
+ env: { ...process.env, CI: "1" },
1005
+ timeout: 12 * 60 * 1000,
1006
+ });
1007
+ removeMeasurementResidue(host);
1008
+ await git(host, ["add", "-A"]);
1009
+ const pending = await git(host, ["status", "--porcelain"]);
1010
+ if (pending)
1011
+ await git(host, ["commit", "-m", "fixture: lock exact measurement inputs"]);
1012
+ const base = await git(host, ["rev-parse", "HEAD"]);
1013
+ const source = path.join(host, SOURCE_PATH);
1014
+ writeFileSync(
1015
+ source,
1016
+ `${readFileSync(source, "utf8").trimEnd()}\n// measurement head selects this governed source\n`
1017
+ );
1018
+ await git(host, ["add", SOURCE_PATH]);
1019
+ await git(host, [
1020
+ "commit",
1021
+ "-m",
1022
+ "fixture: select the mutation-eligible source",
1023
+ ]);
1024
+ return { base, head: await git(host, ["rev-parse", "HEAD"]) };
1025
+ };
1026
+
1027
+ /** Materialize and twice-apply exact packed hosts for the hosted protocol. */
1028
+ export async function prepareHosts(options) {
1029
+ const subjectSha = String(options.subject_sha ?? "");
1030
+ if (!SHA40.test(subjectSha))
1031
+ throw new Error("subject SHA must be exactly 40 lowercase hex characters");
1032
+ const liveHead = await commandOutput("git", ["rev-parse", "HEAD"], {
1033
+ cwd: ROOT,
1034
+ });
1035
+ if (liveHead !== subjectSha)
1036
+ throw new Error(
1037
+ `checked-out head ${liveHead} differs from subject ${subjectSha}`
1038
+ );
1039
+ const output = path.resolve(String(options.output));
1040
+ if (output === ROOT || output.startsWith(`${ROOT}${path.sep}`))
1041
+ throw new Error("prepared hosts must live outside the repository");
1042
+ rmSync(output, { recursive: true, force: true });
1043
+ mkdirSync(output, { recursive: true });
1044
+
1045
+ const tarball = path.resolve(String(options.tarball));
1046
+ if (!existsSync(tarball) || !lstatSync(tarball).isFile())
1047
+ throw new Error("exact package tarball is missing");
1048
+ const packageSha = sha256File(tarball);
1049
+ const toolRoot = path.join(output, "tool");
1050
+ mkdirSync(toolRoot, { recursive: true });
1051
+ await commandOutput(
1052
+ "npm",
1053
+ [
1054
+ "install",
1055
+ "--ignore-scripts",
1056
+ "--no-audit",
1057
+ "--no-fund",
1058
+ "--prefix",
1059
+ toolRoot,
1060
+ tarball,
1061
+ ],
1062
+ {
1063
+ cwd: output,
1064
+ timeout: 8 * 60 * 1000,
1065
+ }
1066
+ );
1067
+ const installedRoot = path.join(toolRoot, "node_modules/@codyswann/lisa");
1068
+ const entry = path.join(installedRoot, "dist/index.js");
1069
+ const detectionEntry = path.join(installedRoot, "dist/detection/index.js");
1070
+ if (!existsSync(entry) || !existsSync(detectionEntry))
1071
+ throw new Error("packed Lisa entrypoints are missing");
1072
+ const installedManifest = readJson(path.join(installedRoot, PACKAGE_JSON));
1073
+
1074
+ const fakeBin = path.join(output, "fake-bin");
1075
+ mkdirSync(fakeBin, { recursive: true });
1076
+ const marker = path.join(output, "gh-called");
1077
+ const fakeGh = path.join(fakeBin, "gh");
1078
+ writeFileSync(fakeGh, '#!/bin/sh\n: > "$LISA_MEASURE_GH_MARKER"\nexit 99\n', {
1079
+ mode: 0o700,
1080
+ });
1081
+
1082
+ const working = path.join(output, "working");
1083
+ const archives = path.join(output, "hosts");
1084
+ mkdirSync(working, { recursive: true });
1085
+ mkdirSync(archives, { recursive: true });
1086
+ const rows = [];
1087
+ for (const stack of EXPECTED_STACKS) {
1088
+ const host = path.join(working, stack);
1089
+ seedStack(stack, host);
1090
+ await initializeGit(host);
1091
+ const detected = await detectTypes(detectionEntry, host);
1092
+ if (!detected.direct.includes(expectedDirectType(stack))) {
1093
+ throw new Error(
1094
+ `${stack} detector did not fire: ${detected.direct.join(", ")}`
1095
+ );
1096
+ }
1097
+ if (
1098
+ JSON.stringify(detected.expanded) !==
1099
+ JSON.stringify(expectedExpandedTypes(stack))
1100
+ ) {
1101
+ throw new Error(
1102
+ `${stack} expanded detection differs: ${detected.expanded.join(", ")}`
1103
+ );
1104
+ }
1105
+ await applyExactPackage({ entry, host, fakeBin, marker });
1106
+ const first = governedSnapshot(host);
1107
+ await git(host, ["add", "-A"]);
1108
+ await git(host, ["commit", "-m", "fixture: apply exact packed Lisa"]);
1109
+ await applyExactPackage({ entry, host, fakeBin, marker });
1110
+ const second = governedSnapshot(host);
1111
+ const idempotent = JSON.stringify(first) === JSON.stringify(second);
1112
+ if (!idempotent)
1113
+ throw new Error(
1114
+ `${stack} changed governed mutation bytes on second apply`
1115
+ );
1116
+
1117
+ const strykerPresent = existsSync(path.join(host, STRYKER_CONFIG));
1118
+ if (stack === "rails" ? strykerPresent : !strykerPresent)
1119
+ throw new Error(`${stack} received the wrong mutation engine`);
1120
+ let history = null;
1121
+ if (stack === "typescript" || stack === "expo") {
1122
+ history = await prepareMeasuredHistory(host);
1123
+ const archiveStack = stack === "expo" ? "jest" : "vitest";
1124
+ const archive = path.join(archives, `${archiveStack}.tar.gz`);
1125
+ await commandOutput("tar", ["-czf", archive, "-C", working, stack], {
1126
+ cwd: output,
1127
+ });
1128
+ }
1129
+ rows.push({
1130
+ stack,
1131
+ detected_types: detected.expanded,
1132
+ second_apply_idempotent: idempotent,
1133
+ stryker_present: strykerPresent,
1134
+ governed_digest: governedDigest(second),
1135
+ canonical_runner_sha256:
1136
+ second[RUNNER_PATH] ?? second["scripts/lisa-mutation.sh"],
1137
+ host_base_sha: history?.base ?? null,
1138
+ host_head_sha: history?.head ?? null,
1139
+ });
1140
+ }
1141
+ if (existsSync(marker))
1142
+ throw new Error(
1143
+ "packed apply attempted to invoke GitHub repository tooling"
1144
+ );
1145
+ const manifest = {
1146
+ schema_version: "lisa.mutation-prepared-hosts/v1",
1147
+ subject_sha: subjectSha,
1148
+ tree_sha: await commandOutput("git", ["rev-parse", "HEAD^{tree}"], {
1149
+ cwd: ROOT,
1150
+ }),
1151
+ package_version: installedManifest.version,
1152
+ package_sha256: packageSha,
1153
+ fixture_schema: "fixture/v1",
1154
+ fixture_sha256: sha256(
1155
+ JSON.stringify(
1156
+ canonicalJson(
1157
+ rows.map(({ stack, detected_types, governed_digest }) => ({
1158
+ stack,
1159
+ detected_types,
1160
+ governed_digest,
1161
+ }))
1162
+ )
1163
+ )
1164
+ ),
1165
+ stacks: rows,
1166
+ };
1167
+ const verdict = verifyPreparedManifest(manifest);
1168
+ if (!verdict.valid) throw new Error(verdict.reasons.join("; "));
1169
+ atomicJson(path.join(output, HOST_MANIFEST), manifest);
1170
+ rmSync(working, { recursive: true, force: true });
1171
+ rmSync(toolRoot, { recursive: true, force: true });
1172
+ rmSync(fakeBin, { recursive: true, force: true });
1173
+ return manifest;
1174
+ }
1175
+
1176
+ const listSandboxEntries = host => {
1177
+ const root = path.join(host, ".stryker-tmp");
1178
+ return existsSync(root) ? readdirSync(root).length : 0;
1179
+ };
1180
+
1181
+ const elapsed = (later, earlier) =>
1182
+ Math.max(0, Date.parse(later) - Date.parse(earlier));
1183
+
1184
+ const versionOf = (host, packageName) =>
1185
+ readJson(path.join(host, "node_modules", packageName, PACKAGE_JSON)).version;
1186
+
1187
+ const relativeHostPath = (host, file) => {
1188
+ const relative = path.relative(host, file);
1189
+ return relative.startsWith("..")
1190
+ ? String(file)
1191
+ : relative.replaceAll(path.sep, "/");
1192
+ };
1193
+
1194
+ const reportMutants = report =>
1195
+ Object.entries(report.files ?? {}).flatMap(([file, detail]) =>
1196
+ (detail.mutants ?? []).map(mutant => ({ file, mutant }))
1197
+ );
1198
+
1199
+ const statusTally = mutants => {
1200
+ const statuses = Object.fromEntries(
1201
+ FINAL_STATUSES.map(status => [status, 0])
1202
+ );
1203
+ for (const { mutant } of mutants) {
1204
+ if (!Object.hasOwn(statuses, mutant.status))
1205
+ throw new Error(`unknown mutant status: ${mutant.status}`);
1206
+ statuses[mutant.status] += 1;
1207
+ }
1208
+ return statuses;
1209
+ };
1210
+
1211
+ const scoreReportedByStryker = statuses => {
1212
+ const denominator =
1213
+ statuses.Killed +
1214
+ statuses.Timeout +
1215
+ statuses.Survived +
1216
+ statuses.NoCoverage;
1217
+ return denominator === 0
1218
+ ? Number.NaN
1219
+ : ((statuses.Killed + statuses.Timeout) / denominator) * 100;
1220
+ };
1221
+
1222
+ const phaseBreakdown = ({ installMs, observed, events }) => {
1223
+ const runnerStarted = new Date(observed.started_at_ms).toISOString();
1224
+ const runnerFinished = new Date(observed.finished_at_ms).toISOString();
1225
+ const dry = Number(events.dry_run_ms);
1226
+ const runnerSetup = elapsed(events.started_at, runnerStarted);
1227
+ const instrumentation = Math.max(
1228
+ 0,
1229
+ elapsed(events.dry_run_at, events.started_at) - dry
1230
+ );
1231
+ const plan = elapsed(events.plan_ready_at, events.dry_run_at);
1232
+ const mutation = elapsed(events.report_ready_at, events.plan_ready_at);
1233
+ const reporting = elapsed(events.finished_at, events.report_ready_at);
1234
+ const teardown = elapsed(runnerFinished, events.finished_at);
1235
+ const phases = {
1236
+ install: installMs,
1237
+ runner_setup: runnerSetup,
1238
+ instrumentation,
1239
+ dry_run: dry,
1240
+ plan,
1241
+ mutation,
1242
+ reporting,
1243
+ teardown,
1244
+ };
1245
+ return {
1246
+ ...phases,
1247
+ end_to_end: Object.values(phases).reduce(
1248
+ (total, value) => total + value,
1249
+ 0
1250
+ ),
1251
+ clock_tolerance: 0,
1252
+ };
1253
+ };
1254
+
1255
+ const assertArtifactBounds = root => {
1256
+ const limits = {
1257
+ "runner.log": 64 * 1024 * 1024,
1258
+ "stryker-report.json": 64 * 1024 * 1024,
1259
+ "process-samples.jsonl": 64 * 1024 * 1024,
1260
+ };
1261
+ let total = 0;
1262
+ for (const entry of readdirSync(root)) {
1263
+ const file = resolveArtifactPath(root, entry);
1264
+ const info = lstatSync(file);
1265
+ if (!info.isFile())
1266
+ throw new Error(`artifact entry is not a regular file: ${entry}`);
1267
+ total += info.size;
1268
+ if (limits[entry] !== undefined && info.size > limits[entry])
1269
+ throw new Error(`${entry} exceeds its evidence bound`);
1270
+ }
1271
+ if (total > 128 * 1024 * 1024)
1272
+ throw new Error("draw evidence exceeds 128 MiB");
1273
+ };
1274
+
1275
+ const selectionFrom = async ({ host, report, events, base, head }) => {
1276
+ const changed = (
1277
+ await commandOutput("git", ["diff", "--name-only", `${base}..${head}`], {
1278
+ cwd: host,
1279
+ })
1280
+ )
1281
+ .split("\n")
1282
+ .filter(Boolean)
1283
+ .sort();
1284
+ const mutants = reportMutants(report);
1285
+ return {
1286
+ merge_base: base,
1287
+ head,
1288
+ changed_paths: changed,
1289
+ mutate_files: [...new Set(mutants.map(row => row.file))].sort(),
1290
+ tests: (events.tests ?? [])
1291
+ .map(test => ({
1292
+ file: relativeHostPath(host, test.file),
1293
+ name: String(test.name),
1294
+ location: String(test.location),
1295
+ }))
1296
+ .sort((left, right) =>
1297
+ JSON.stringify(left).localeCompare(JSON.stringify(right))
1298
+ ),
1299
+ mutants: mutants
1300
+ .map(({ file, mutant }) => mutantIdentity(mutant, file))
1301
+ .sort((left, right) =>
1302
+ JSON.stringify(left).localeCompare(JSON.stringify(right))
1303
+ ),
1304
+ };
1305
+ };
1306
+
1307
+ const writeDrawEvidence = async ({
1308
+ armOutput,
1309
+ host,
1310
+ stack,
1311
+ draw,
1312
+ role,
1313
+ subjectSha,
1314
+ manifest,
1315
+ stackManifest,
1316
+ rawConfig,
1317
+ effectiveConfig,
1318
+ installMs,
1319
+ journeyStarted,
1320
+ observed,
1321
+ cacheBefore,
1322
+ cacheAfter,
1323
+ coldParent,
1324
+ }) => {
1325
+ const phasePath = path.join(armOutput, PHASE_EVENTS);
1326
+ const reportPath = path.join(armOutput, STRYKER_REPORT);
1327
+ if (!existsSync(phasePath) || !existsSync(reportPath))
1328
+ throw new Error("runner omitted required phase or JSON report evidence");
1329
+ const events = readJson(phasePath);
1330
+ const report = readJson(reportPath);
1331
+ const selection = await selectionFrom({
1332
+ host,
1333
+ report,
1334
+ events,
1335
+ base: stackManifest.host_base_sha,
1336
+ head: stackManifest.host_head_sha,
1337
+ });
1338
+ atomicJson(path.join(armOutput, "selection.json"), selection);
1339
+ atomicJson(path.join(armOutput, EFFECTIVE_CONFIG), effectiveConfig);
1340
+ atomicJson(path.join(armOutput, "raw-stryker.conf.json"), rawConfig);
1341
+ atomicJson(path.join(armOutput, HOST_MANIFEST), manifest);
1342
+
1343
+ const mutants = reportMutants(report);
1344
+ const statuses = statusTally(mutants);
1345
+ const reportedScore = scoreReportedByStryker(statuses);
1346
+ const honestScore = scoreWithoutTimeouts(statuses);
1347
+ if (!Number.isFinite(reportedScore) || !Number.isFinite(honestScore))
1348
+ throw new Error("mutation score is not measurable");
1349
+ const thresholds = report.thresholds ?? effectiveConfig.thresholds;
1350
+ const verdict = honestScore < thresholds.break ? "threshold-red" : "passed";
1351
+ const incrementalConfigured = effectiveConfig.incremental === true;
1352
+ const mode = coldParent ? WARM_MODE : COLD_MODE;
1353
+ const runnerPackage =
1354
+ stack === "vitest"
1355
+ ? "@stryker-mutator/vitest-runner"
1356
+ : "@stryker-mutator/jest-runner";
1357
+ const lockFile = existsSync(path.join(host, "bun.lock"))
1358
+ ? "bun.lock"
1359
+ : "package-lock.json";
1360
+ const rawFiles = [
1361
+ PHASE_EVENTS,
1362
+ "process-samples.jsonl",
1363
+ "selection.json",
1364
+ EFFECTIVE_CONFIG,
1365
+ "raw-stryker.conf.json",
1366
+ STRYKER_REPORT,
1367
+ "runner.log",
1368
+ HOST_MANIFEST,
1369
+ ];
1370
+ const rawDigests = createDigestManifest(armOutput, rawFiles);
1371
+ const summary = {
1372
+ schema_version: MEASUREMENT_SCHEMA_VERSION,
1373
+ conclusion: MEASUREMENT_ONLY,
1374
+ identity: {
1375
+ subject_sha: subjectSha,
1376
+ tree_sha: manifest.tree_sha,
1377
+ package_sha256: manifest.package_sha256,
1378
+ package_version: manifest.package_version,
1379
+ stack,
1380
+ runner_family: stack,
1381
+ fixture_schema: manifest.fixture_schema,
1382
+ fixture_sha256: manifest.fixture_sha256,
1383
+ host_base_sha: stackManifest.host_base_sha,
1384
+ host_head_sha: stackManifest.host_head_sha,
1385
+ run_id: process.env.GITHUB_RUN_ID ?? "0",
1386
+ job_id: process.env.GITHUB_JOB ?? `${stack}-${draw}`,
1387
+ run_attempt: Number(process.env.GITHUB_RUN_ATTEMPT ?? 1),
1388
+ ubuntu_image: process.env.ImageOS ?? process.platform,
1389
+ node_version: process.version.replace(/^v/u, ""),
1390
+ bun_version: await commandOutput("bun", ["--version"]),
1391
+ npm_version: await commandOutput("npm", ["--version"]),
1392
+ stryker_version: versionOf(host, "@stryker-mutator/core"),
1393
+ runner_version: versionOf(host, runnerPackage),
1394
+ lock_sha256: sha256File(path.join(host, lockFile)),
1395
+ package_json_sha256: sha256File(path.join(host, PACKAGE_JSON)),
1396
+ runner_sha256: sha256File(path.join(host, RUNNER_PATH)),
1397
+ raw_config_sha256: sha256(`${JSON.stringify(rawConfig, null, 2)}\n`),
1398
+ effective_config_sha256: sha256File(
1399
+ path.join(armOutput, EFFECTIVE_CONFIG)
1400
+ ),
1401
+ gate_sha256: sha256File(path.join(host, "mutation.gate.json")),
1402
+ workflow_sha256: sha256File(
1403
+ path.join(ROOT, ".github/workflows/mutation-performance-baseline.yml")
1404
+ ),
1405
+ source_sha256: sha256File(path.join(host, SOURCE_PATH)),
1406
+ test_sha256: sha256File(path.join(host, TEST_PATH)),
1407
+ },
1408
+ protocol: {
1409
+ role,
1410
+ draw: Number(draw),
1411
+ mode,
1412
+ started_at: new Date(journeyStarted).toISOString(),
1413
+ finished_at: new Date(observed.finished_at_ms).toISOString(),
1414
+ invalid_reasons: [],
1415
+ cold_parent_summary_sha256: coldParent?.summary_sha256 ?? null,
1416
+ cold_parent_incremental_sha256: coldParent?.incremental_sha256 ?? null,
1417
+ },
1418
+ selection,
1419
+ phases_ms: phaseBreakdown({ installMs, observed, events }),
1420
+ resources: observed.resources,
1421
+ results: {
1422
+ statuses,
1423
+ expected_total: Number(events.plan_count),
1424
+ observed_total: mutants.length,
1425
+ thresholds: {
1426
+ high: Number(thresholds.high),
1427
+ low: Number(thresholds.low),
1428
+ break: Number(thresholds.break),
1429
+ },
1430
+ reported_score: reportedScore,
1431
+ timeout_recomputed_score: honestScore,
1432
+ exit_code: observed.exit_code,
1433
+ signal: observed.signal,
1434
+ verdict,
1435
+ named_survivors: mutants
1436
+ .filter(({ mutant }) => mutant.status === "Survived")
1437
+ .map(({ file, mutant }) => canonicalMutantIdentity(mutant, file)),
1438
+ },
1439
+ cache: {
1440
+ incremental_configured: incrementalConfigured,
1441
+ before_exists: cacheBefore !== null,
1442
+ before_sha256: cacheBefore,
1443
+ after_exists: cacheAfter !== null,
1444
+ after_sha256: cacheAfter,
1445
+ cold_parent_identity: coldParent?.summary_sha256 ?? null,
1446
+ disposition: coldParent ? "diagnostic-local-reuse" : "cold-empty",
1447
+ remote_read: false,
1448
+ remote_write: false,
1449
+ },
1450
+ cleanup: {
1451
+ root_pid: observed.cleanup.root_pid,
1452
+ root_birth_tick: observed.cleanup.root_birth_tick,
1453
+ signal_forwarded: observed.cleanup.signal_forwarded,
1454
+ kill_reason: observed.cleanup.kill_reason,
1455
+ descendant_survivors: observed.cleanup.descendant_survivors,
1456
+ sandbox_entries_before: observed.sandbox_before,
1457
+ sandbox_entries_after: observed.sandbox_after,
1458
+ sampler_healthy: observed.cleanup.sampler_healthy,
1459
+ },
1460
+ digests: {
1461
+ phase_events_sha256: rawDigests[PHASE_EVENTS],
1462
+ process_samples_sha256: rawDigests["process-samples.jsonl"],
1463
+ selection_sha256: rawDigests["selection.json"],
1464
+ stryker_report_sha256: rawDigests[STRYKER_REPORT],
1465
+ runner_log_sha256: rawDigests["runner.log"],
1466
+ host_manifest_sha256: rawDigests[HOST_MANIFEST],
1467
+ },
1468
+ };
1469
+ const validation = validateMeasurement(summary);
1470
+ if (!validation.valid)
1471
+ throw new Error(
1472
+ `invalid ${mode} evidence: ${validation.reasons.join("; ")}`
1473
+ );
1474
+ atomicJson(path.join(armOutput, SUMMARY_JSON), summary);
1475
+ assertArtifactBounds(armOutput);
1476
+ const sums = createDigestManifest(armOutput, [...rawFiles, SUMMARY_JSON]);
1477
+ atomicJson(path.join(armOutput, "sha256sums.json"), sums);
1478
+ return {
1479
+ summary,
1480
+ summary_sha256: sha256File(path.join(armOutput, SUMMARY_JSON)),
1481
+ };
1482
+ };
1483
+
1484
+ const runArm = async ({
1485
+ host,
1486
+ armOutput,
1487
+ stack,
1488
+ draw,
1489
+ role,
1490
+ subjectSha,
1491
+ manifest,
1492
+ stackManifest,
1493
+ rawConfig,
1494
+ installMs,
1495
+ journeyStarted,
1496
+ coldParent,
1497
+ }) => {
1498
+ mkdirSync(armOutput, { recursive: true });
1499
+ const reportPath = path.join(armOutput, STRYKER_REPORT);
1500
+ const reporterCopy = path.join(
1501
+ host,
1502
+ ".lisa-measurement",
1503
+ "mutation-performance-reporter.mjs"
1504
+ );
1505
+ mkdirSync(path.dirname(reporterCopy), { recursive: true });
1506
+ cpSync(
1507
+ path.join(ROOT, "scripts/lib/mutation-performance-reporter.mjs"),
1508
+ reporterCopy
1509
+ );
1510
+ const effectiveConfig = overlayMeasurementReporters(
1511
+ rawConfig,
1512
+ reporterCopy,
1513
+ reportPath
1514
+ );
1515
+ writeJson(path.join(host, STRYKER_CONFIG), effectiveConfig);
1516
+ const incrementalPath = path.join(
1517
+ host,
1518
+ effectiveConfig.incrementalFile ?? INCREMENTAL_DEFAULT
1519
+ );
1520
+ const cacheBefore = existsSync(incrementalPath)
1521
+ ? sha256File(incrementalPath)
1522
+ : null;
1523
+ if (!coldParent && cacheBefore !== null)
1524
+ throw new Error("cold draw starts with incremental residue");
1525
+ const sandboxBefore = listSandboxEntries(host);
1526
+ const observed = await runObservedCommand({
1527
+ cwd: host,
1528
+ args: [path.join(host, RUNNER_PATH)],
1529
+ output: armOutput,
1530
+ env: {
1531
+ CI: "1",
1532
+ MUTATION_SINCE: "HEAD^",
1533
+ LISA_MUTATION_PHASE_EVENTS: path.join(armOutput, PHASE_EVENTS),
1534
+ NODE_OPTIONS: "--max-old-space-size=6144",
1535
+ },
1536
+ });
1537
+ observed.sandbox_before = sandboxBefore;
1538
+ observed.sandbox_after = listSandboxEntries(host);
1539
+ const cacheAfter = existsSync(incrementalPath)
1540
+ ? sha256File(incrementalPath)
1541
+ : null;
1542
+ if (effectiveConfig.incremental === true && cacheAfter === null)
1543
+ throw new Error("incremental run omitted local evidence");
1544
+ return writeDrawEvidence({
1545
+ armOutput,
1546
+ host,
1547
+ stack,
1548
+ draw,
1549
+ role,
1550
+ subjectSha,
1551
+ manifest,
1552
+ stackManifest,
1553
+ rawConfig,
1554
+ effectiveConfig,
1555
+ installMs,
1556
+ journeyStarted,
1557
+ observed,
1558
+ cacheBefore,
1559
+ cacheAfter,
1560
+ coldParent,
1561
+ });
1562
+ };
1563
+
1564
+ const compareColdWarm = (cold, warm) => {
1565
+ const coldSelection = sha256(JSON.stringify(canonicalJson(cold.selection)));
1566
+ const warmSelection = sha256(JSON.stringify(canonicalJson(warm.selection)));
1567
+ if (coldSelection !== warmSelection)
1568
+ throw new Error("warm selection differs from its cold parent");
1569
+ if (
1570
+ JSON.stringify(cold.results.statuses) !==
1571
+ JSON.stringify(warm.results.statuses)
1572
+ )
1573
+ throw new Error("warm outcomes differ from its cold parent");
1574
+ if (cold.results.verdict !== warm.results.verdict)
1575
+ throw new Error("warm threshold verdict differs from its cold parent");
1576
+ if (
1577
+ warm.protocol.cold_parent_summary_sha256 !==
1578
+ sha256(`${JSON.stringify(cold, null, 2)}\n`)
1579
+ ) {
1580
+ throw new Error("warm evidence is not bound to the exact cold summary");
1581
+ }
1582
+ };
1583
+
1584
+ /** Extract a fresh prepared host and collect cold plus local-warm evidence. */
1585
+ export async function runJourney(options) {
1586
+ if (process.platform !== "linux")
1587
+ throw new Error("hosted measurement must run on ubuntu/Linux");
1588
+ const subjectSha = String(options.subject_sha ?? "");
1589
+ const stack = String(options.stack ?? "");
1590
+ const draw = Number(options.draw);
1591
+ const role = String(options.role ?? "baseline");
1592
+ if (
1593
+ !SHA40.test(subjectSha) ||
1594
+ !MEASURED_STACKS.includes(stack) ||
1595
+ !BASELINE_DRAWS.includes(draw)
1596
+ )
1597
+ throw new Error("invalid measurement identity");
1598
+ const prepared = path.resolve(String(options.prepared));
1599
+ const output = path.resolve(String(options.output));
1600
+ if (output === ROOT || output.startsWith(`${ROOT}${path.sep}`))
1601
+ throw new Error("draw evidence must live outside the repository");
1602
+ rmSync(output, { recursive: true, force: true });
1603
+ mkdirSync(output, { recursive: true });
1604
+ const manifestPath = path.join(prepared, HOST_MANIFEST);
1605
+ const manifest = readJson(manifestPath);
1606
+ const prepareVerdict = verifyPreparedManifest(manifest);
1607
+ if (!prepareVerdict.valid || manifest.subject_sha !== subjectSha)
1608
+ throw new Error(
1609
+ `prepared-host identity invalid: ${prepareVerdict.reasons.join("; ")}`
1610
+ );
1611
+ const preparedStack = stack === "vitest" ? "typescript" : "expo";
1612
+ const stackManifest = manifest.stacks.find(
1613
+ row => row.stack === preparedStack
1614
+ );
1615
+ if (!stackManifest?.host_base_sha || !stackManifest?.host_head_sha)
1616
+ throw new Error("measured host history is absent");
1617
+ const journey = mkdtempSync(
1618
+ path.join(tmpdir(), `lisa-mutation-${stack}-${draw}-`)
1619
+ );
1620
+ const archive = path.join(prepared, "hosts", `${stack}.tar.gz`);
1621
+ if (!existsSync(archive))
1622
+ throw new Error(`prepared ${stack} archive is missing`);
1623
+ try {
1624
+ await commandOutput("tar", ["-xzf", archive, "-C", journey]);
1625
+ const host = path.join(journey, preparedStack);
1626
+ if (existsSync(path.join(host, "node_modules")))
1627
+ throw new Error(
1628
+ "prepared host contains node_modules before cold install"
1629
+ );
1630
+ if (existsSync(path.join(host, INCREMENTAL_DEFAULT)))
1631
+ throw new Error("prepared host contains incremental residue");
1632
+ const journeyStarted = Date.now();
1633
+ const installStarted = Date.now();
1634
+ const install = await execFileAsync(
1635
+ "bun",
1636
+ ["install", "--frozen-lockfile"],
1637
+ {
1638
+ cwd: host,
1639
+ env: { ...process.env, CI: "1" },
1640
+ timeout: 15 * 60 * 1000,
1641
+ maxBuffer: 64 * 1024 * 1024,
1642
+ encoding: "utf8",
1643
+ }
1644
+ );
1645
+ const installMs = Date.now() - installStarted;
1646
+ writeFileSync(
1647
+ path.join(output, "install.log"),
1648
+ `${install.stdout}${install.stderr}`,
1649
+ { mode: 0o600 }
1650
+ );
1651
+ const rawConfig = readJson(path.join(host, STRYKER_CONFIG));
1652
+ const coldResult = await runArm({
1653
+ host,
1654
+ armOutput: path.join(output, "cold"),
1655
+ stack,
1656
+ draw,
1657
+ role,
1658
+ subjectSha,
1659
+ manifest,
1660
+ stackManifest,
1661
+ rawConfig,
1662
+ installMs,
1663
+ journeyStarted,
1664
+ coldParent: null,
1665
+ });
1666
+ const incrementalPath = path.join(
1667
+ host,
1668
+ rawConfig.incrementalFile ?? INCREMENTAL_DEFAULT
1669
+ );
1670
+ const warmResult = await runArm({
1671
+ host,
1672
+ armOutput: path.join(output, "warm"),
1673
+ stack,
1674
+ draw,
1675
+ role,
1676
+ subjectSha,
1677
+ manifest,
1678
+ stackManifest,
1679
+ rawConfig,
1680
+ installMs: 0,
1681
+ journeyStarted: Date.now(),
1682
+ coldParent: {
1683
+ summary_sha256: coldResult.summary_sha256,
1684
+ incremental_sha256: sha256File(incrementalPath),
1685
+ },
1686
+ });
1687
+ compareColdWarm(coldResult.summary, warmResult.summary);
1688
+ atomicJson(path.join(output, "comparison.json"), {
1689
+ schema_version: "lisa.mutation-cold-warm-comparison/v1",
1690
+ conclusion: MEASUREMENT_ONLY,
1691
+ stack,
1692
+ draw,
1693
+ subject_sha: subjectSha,
1694
+ selection_sha256: sha256(
1695
+ JSON.stringify(canonicalJson(coldResult.summary.selection))
1696
+ ),
1697
+ outcomes_sha256: sha256(
1698
+ JSON.stringify(coldResult.summary.results.statuses)
1699
+ ),
1700
+ verdict: coldResult.summary.results.verdict,
1701
+ cold_summary_sha256: coldResult.summary_sha256,
1702
+ warm_summary_sha256: warmResult.summary_sha256,
1703
+ });
1704
+ return { cold: coldResult.summary, warm: warmResult.summary };
1705
+ } finally {
1706
+ rmSync(journey, { recursive: true, force: true });
1707
+ }
1708
+ }
1709
+
1710
+ const parseArgs = argv => {
1711
+ const [command, ...rest] = argv;
1712
+ const options = { command };
1713
+ for (let index = 0; index < rest.length; index += 1) {
1714
+ const token = rest[index];
1715
+ if (!token.startsWith("--"))
1716
+ throw new Error(`unexpected argument: ${token}`);
1717
+ const key = token.slice(2).replaceAll("-", "_");
1718
+ const next = rest[index + 1];
1719
+ if (next === undefined || next.startsWith("--")) options[key] = true;
1720
+ else {
1721
+ options[key] = next;
1722
+ index += 1;
1723
+ }
1724
+ }
1725
+ return options;
1726
+ };
1727
+
1728
+ const atomicJson = (file, value) => {
1729
+ mkdirSync(path.dirname(file), { recursive: true });
1730
+ const temporary = `${file}.tmp`;
1731
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
1732
+ flag: "wx",
1733
+ mode: 0o600,
1734
+ });
1735
+ renameSync(temporary, file);
1736
+ };
1737
+
1738
+ const commandOutput = async (command, args, options = {}) => {
1739
+ const result = await execFileAsync(command, args, {
1740
+ maxBuffer: 32 * 1024 * 1024,
1741
+ encoding: "utf8",
1742
+ ...options,
1743
+ });
1744
+ return result.stdout.trim();
1745
+ };
1746
+
1747
+ const main = async () => {
1748
+ const options = parseArgs(process.argv.slice(2));
1749
+ if (options.command === "aggregate") {
1750
+ const root = path.resolve(String(options.input));
1751
+ const rows = [];
1752
+ for (const entry of readdirSync(root, { recursive: true })) {
1753
+ if (
1754
+ String(entry).split(path.sep).at(-2) === "cold" &&
1755
+ String(entry).endsWith(SUMMARY_JSON)
1756
+ ) {
1757
+ rows.push(
1758
+ JSON.parse(readFileSync(path.join(root, String(entry)), "utf8"))
1759
+ );
1760
+ }
1761
+ }
1762
+ const aggregate = aggregateMeasurements(rows);
1763
+ atomicJson(path.resolve(String(options.output)), aggregate);
1764
+ return;
1765
+ }
1766
+ if (options.command === "verify") {
1767
+ const value = JSON.parse(
1768
+ readFileSync(path.resolve(String(options.input)), "utf8")
1769
+ );
1770
+ const verdict = validateMeasurement(value);
1771
+ if (!verdict.valid) throw new Error(verdict.reasons.join("; "));
1772
+ return;
1773
+ }
1774
+ if (options.command === "prepare") {
1775
+ await prepareHosts(options);
1776
+ return;
1777
+ }
1778
+ if (options.command === "run") {
1779
+ await runJourney(options);
1780
+ return;
1781
+ }
1782
+ throw new Error(
1783
+ "usage: mutation-performance-measure.mjs <prepare|run|aggregate|verify>"
1784
+ );
1785
+ };
1786
+
1787
+ if (invokedAsScript(import.meta.url)) {
1788
+ main().catch(error => {
1789
+ process.stderr.write(
1790
+ `${error instanceof Error ? error.stack : String(error)}\n`
1791
+ );
1792
+ process.exitCode = 1;
1793
+ });
1794
+ }