faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Spending one trivial token on each routed runtime before spending the run's.
3
+ *
4
+ * A static probe proves a binary exists and reports a version. It does not
5
+ * prove the credential works, the quota is not spent, or the model answers --
6
+ * and each of those fails a run several minutes in, after a worktree and a
7
+ * campaign event already exist. So this sends a real prompt, in a throwaway git
8
+ * repository, with every runtime clamped to its read-only mode by
9
+ * `safeLiveRuntime`.
10
+ *
11
+ * Every provider string that comes back is redacted against the environment
12
+ * before it reaches a log: a preflight failure is exactly where a token tends
13
+ * to appear in an error message.
14
+ */
15
+ import { emptyUsage } from "../run/usage.mjs";
16
+ import { errorMessage } from "../util.mjs";
17
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+ import { normalizeProviderResult, probeRuntime, providerCommand } from "../harnesses/index.mjs";
20
+ import { reachableRuntimes } from "../host/preflight.mjs";
21
+ import { spawn } from "node:child_process";
22
+ import { boundedGitSync } from "../repo/worktree.mjs";
23
+ import { tmpdir } from "node:os";
24
+ import { validateContract } from "../contract/index.mjs";
25
+ import { validateNodeSnapshot } from "../contract/snapshot.mjs";
26
+ import { compactCost as formatCost } from "../util.mjs";
27
+
28
+ /** @typedef {import("../harnesses/index.mjs").ProbeResult} ProbeResult */
29
+ /** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
30
+ /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
31
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
32
+
33
+ const LIVE_PREFLIGHT_PROMPT = "Respond with exactly FABERUN_PREFLIGHT_OK and do not use tools.";
34
+ const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
35
+ /**
36
+ * @param {string} contractPath
37
+ * @param {{static?: boolean, liveTimeoutSec?: number}} [options]
38
+ * @returns {Promise<ProbeResult[]>}
39
+ */
40
+ export async function preflightContract(contractPath, options = {}) {
41
+ const absoluteContractPath = resolve(contractPath);
42
+ const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath);
43
+ const runtimes = reachableRuntimes(contract);
44
+ const staticChecks = await Promise.all([...runtimes.values()].map(({ runtime, requiredCapabilitySets }) =>
45
+ probeRuntime(runtime, { cwd: contract.cwd, requiredCapabilitySets }),
46
+ ));
47
+ if (options.static === true) return staticChecks;
48
+
49
+ const timeoutSec = livePreflightTimeout(options.liveTimeoutSec);
50
+ let liveRepo;
51
+ try {
52
+ liveRepo = createLivePreflightRepo();
53
+ } catch (error) {
54
+ return staticChecks.map((check) => ({
55
+ ...check,
56
+ ok: false,
57
+ live: true,
58
+ liveStatus: "failed",
59
+ detail: `${check.detail ?? "static probe failed"} · live preflight repository failed: ${redactProviderText(errorMessage(error))}`,
60
+ }));
61
+ }
62
+ try {
63
+ return await Promise.all(staticChecks.map(async (check, index) => {
64
+ const runtime = [...runtimes.values()][index].runtime;
65
+ const live = await livePreflight(runtime, liveRepo, timeoutSec);
66
+ const liveDetail = live.status === "done"
67
+ ? `live done · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`
68
+ : `live ${live.status} · ${live.error?.code ?? "provider_error"}: ${redactProviderText(live.error?.message ?? "generation failed")} · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`;
69
+ return {
70
+ ...check,
71
+ ok: check.ok && live.status === "done",
72
+ live: true,
73
+ liveStatus: live.status,
74
+ usage: live.usage,
75
+ costUsd: live.costUsd,
76
+ detail: `${check.detail ?? "static probe failed"} · ${liveDetail}`,
77
+ };
78
+ }));
79
+ } finally {
80
+ rmSync(liveRepo, { recursive: true, force: true });
81
+ }
82
+ }
83
+ /** @param {number|undefined} configured */
84
+ function livePreflightTimeout(configured) {
85
+ const raw = configured ?? (process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC === undefined
86
+ ? 15
87
+ : Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC));
88
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
89
+ throw new TypeError("preflight live timeout must be a positive number of seconds");
90
+ }
91
+ return raw;
92
+ }
93
+ /** @returns {string} */
94
+ function createLivePreflightRepo() {
95
+ const directory = mkdtempSync(join(tmpdir(), "faberun-preflight-"));
96
+ const result = boundedGitSync(["init", "-q", directory], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
97
+ if (result.status !== 0 || result.error) {
98
+ rmSync(directory, { recursive: true, force: true });
99
+ const reason = result.stderr ? redactProviderText(String(result.stderr)) : result.error?.message;
100
+ throw new Error(`git init failed${reason ? `: ${reason}` : ""}`);
101
+ }
102
+ return directory;
103
+ }
104
+ /**
105
+ * @param {RuntimeSnapshot} runtime
106
+ * @returns {RuntimeSnapshot}
107
+ */
108
+ function safeLiveRuntime(runtime) {
109
+ if (runtime.harness === "codex") return { ...runtime, sandbox: "read-only" };
110
+ if (runtime.harness === "dsh") return { ...runtime, sandbox: "read-only" };
111
+ if (runtime.harness === "claude") return { ...runtime, permissionMode: "plan" };
112
+ // `plan` is the ZCode mode that reads without writing; the adapter's own
113
+ // default is `yolo`, which a preflight prompt must never reach.
114
+ if (runtime.harness === "zcode") return { ...runtime, permissionMode: "plan" };
115
+ return { ...runtime };
116
+ }
117
+ /**
118
+ * @param {RuntimeSnapshot} runtime
119
+ * @param {string} cwd
120
+ * @param {number} timeoutSec
121
+ * @returns {Promise<ProviderEnvelope>}
122
+ */
123
+ function livePreflight(runtime, cwd, timeoutSec) {
124
+ const safeRuntime = safeLiveRuntime(runtime);
125
+ let command;
126
+ try {
127
+ command = providerCommand(safeRuntime, LIVE_PREFLIGHT_PROMPT);
128
+ } catch (error) {
129
+ return Promise.resolve({
130
+ status: "failed",
131
+ result: null,
132
+ continuationId: null,
133
+ usage: emptyUsage(),
134
+ costUsd: null,
135
+ error: { code: "command_invalid", message: errorMessage(error) },
136
+ });
137
+ }
138
+ return new Promise((settle) => {
139
+ /** @type {import("node:child_process").ChildProcessWithoutNullStreams} */
140
+ let child;
141
+ try {
142
+ const env = { ...process.env };
143
+ for (const [key, value] of Object.entries(command.env ?? {})) {
144
+ if (value === null) delete env[key];
145
+ else env[key] = value;
146
+ }
147
+ delete env.FABERUN_NOTIFY_BIN;
148
+ child = /** @type {import("node:child_process").ChildProcessWithoutNullStreams} */ (spawn(command.executable, command.args, {
149
+ cwd,
150
+ env,
151
+ detached: process.platform !== "win32",
152
+ stdio: [command.promptTransport === "stdin" ? "pipe" : "ignore", "pipe", "pipe"],
153
+ }));
154
+ } catch (error) {
155
+ settle({
156
+ status: "failed",
157
+ result: null,
158
+ continuationId: null,
159
+ usage: emptyUsage(),
160
+ costUsd: null,
161
+ error: { code: "spawn_error", message: errorMessage(error) },
162
+ });
163
+ return;
164
+ }
165
+ let stdout = "";
166
+ let stderr = "";
167
+ let settled = false;
168
+ let timedOut = false;
169
+ /** @type {ReturnType<typeof setTimeout>|null} */
170
+ let timer = null;
171
+ /** @type {ReturnType<typeof setTimeout>|null} */
172
+ let killTimer = null;
173
+ /** @param {ProviderEnvelope} envelope */
174
+ const finish = (envelope) => {
175
+ if (settled) return;
176
+ settled = true;
177
+ if (timer) clearTimeout(timer);
178
+ if (killTimer) clearTimeout(killTimer);
179
+ settle(envelope);
180
+ };
181
+ /** @param {NodeJS.Signals} name */
182
+ const signal = (name) => {
183
+ try {
184
+ if (process.platform === "win32") child.kill(name);
185
+ else process.kill(-/** @type {number} */ (child.pid), name);
186
+ } catch {
187
+ // ESRCH: the child is already gone, so there is no process to signal.
188
+ }
189
+ };
190
+ child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk, LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES); });
191
+ child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk, LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES); });
192
+ child.once("error", (error) => finish({
193
+ status: "failed",
194
+ result: null,
195
+ continuationId: null,
196
+ usage: emptyUsage(),
197
+ costUsd: null,
198
+ error: { code: "spawn_error", message: redactProviderText(errorMessage(error)) },
199
+ }));
200
+ child.once("close", (exitCode, signalName) => {
201
+ if (timedOut) {
202
+ finish({
203
+ status: "failed",
204
+ result: null,
205
+ continuationId: null,
206
+ usage: emptyUsage(),
207
+ costUsd: null,
208
+ error: { code: "preflight_timeout", message: `live generation timed out after ${timeoutSec}s` },
209
+ });
210
+ return;
211
+ }
212
+ /** @type {ProviderEnvelope} */
213
+ let envelope;
214
+ try {
215
+ envelope = normalizeProviderResult(safeRuntime, stdout, exitCode, signalName, { stderr });
216
+ } catch (error) {
217
+ envelope = {
218
+ status: "failed",
219
+ result: null,
220
+ continuationId: null,
221
+ usage: emptyUsage(),
222
+ costUsd: null,
223
+ error: { code: "invalid_output", message: redactProviderText(errorMessage(error)) },
224
+ };
225
+ }
226
+ if (envelope.error) envelope.error = { ...envelope.error, message: redactProviderText(envelope.error.message) };
227
+ finish(envelope);
228
+ });
229
+ timer = setTimeout(() => {
230
+ timedOut = true;
231
+ signal("SIGTERM");
232
+ killTimer = setTimeout(() => signal("SIGKILL"), 100);
233
+ }, timeoutSec * 1_000);
234
+ if (command.promptTransport === "stdin") child.stdin.end(command.input);
235
+ });
236
+ }
237
+ /** @param {string} current @param {Uint8Array|string} chunk @param {number} limit */
238
+ function appendBounded(current, chunk, limit) {
239
+ const combined = Buffer.concat([Buffer.from(current), Buffer.from(chunk)]);
240
+ return (combined.length > limit ? combined.subarray(combined.length - limit) : combined).toString("utf8");
241
+ }
242
+ /** @param {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}|undefined} usage */
243
+ function formatUsage(usage) {
244
+ if (!usage) return "in - out - cache -";
245
+ return `in ${compactMetric(usage.inputTokens)} out ${compactMetric(usage.outputTokens)} cache ${compactMetric(usage.cacheReadInputTokens)}`;
246
+ }
247
+ /** @param {number|null|undefined} value */
248
+ function compactMetric(value) {
249
+ return typeof value === "number" && Number.isFinite(value) ? String(value) : "-";
250
+ }
251
+ /** @param {string} value */
252
+ function redactProviderText(value) {
253
+ let result = String(value);
254
+ for (const secret of Object.values(process.env)) {
255
+ if (typeof secret === "string" && secret.length >= 4) result = result.split(secret).join("[REDACTED]");
256
+ }
257
+ return result;
258
+ }
259
+ /**
260
+ * @param {ValidatedContract} contract
261
+ * @returns {string[]}
262
+ */
263
+ export function reusedDoneWarnings(contract) {
264
+ /** @type {string[]} */
265
+ const warnings = [];
266
+ const runsDir = join(contract.cwd, ".runs");
267
+ if (!existsSync(runsDir)) return warnings;
268
+ const ownRunDir = join(runsDir, contract.id);
269
+ for (const name of readdirSync(runsDir)) {
270
+ const otherRunDir = join(runsDir, name);
271
+ const nodeDir = join(otherRunDir, "nodes");
272
+ if (otherRunDir === ownRunDir || !existsSync(nodeDir)) continue;
273
+ const relevantNodes = contract.nodes.filter((node) => existsSync(join(nodeDir, `${node.id}.json`)));
274
+ if (!relevantNodes.length) continue;
275
+ const otherContractPath = join(otherRunDir, "contract.json");
276
+ if (!existsSync(otherContractPath)) throw new TypeError(`missing persisted contract ${otherContractPath}`);
277
+ // A historical contract may reference paths that no longer exist (e.g. a
278
+ // rename); reusing its done nodes is best-effort and must not block a new run.
279
+ let otherContract;
280
+ try {
281
+ otherContract = validateContract(JSON.parse(readFileSync(otherContractPath, "utf8")), otherContractPath, { persisted: true });
282
+ } catch {
283
+ continue;
284
+ }
285
+ for (const node of relevantNodes) {
286
+ const statePath = join(nodeDir, `${node.id}.json`);
287
+ const otherNode = otherContract.nodes.find((candidate) => candidate.id === node.id);
288
+ if (!otherNode) continue;
289
+ try {
290
+ if (validateNodeSnapshot(JSON.parse(readFileSync(statePath, "utf8")), otherNode).status === "done") warnings.push(`node ${node.id} is already done in run ${name}`);
291
+ } catch {
292
+ // Historical snapshots are advisory only. A snapshot written by an
293
+ // older protocol revision must not prevent a new run from starting;
294
+ // the new run still validates its own contract and snapshots strictly.
295
+ }
296
+ }
297
+ }
298
+ return warnings;
299
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Mutation verification: prove a suite asserts by breaking the code on purpose.
3
+ *
4
+ * A test that asserts nothing still exits zero, so re-running a command cannot
5
+ * tell it apart from a real one. This runner rewrites one comparison or logical
6
+ * operator in the files the node declared, re-runs the same argv, and restores
7
+ * the file. A mutant the suite fails on is killed; one it survives is a test
8
+ * that did not assert. The operator set is deliberately six swaps that rarely
9
+ * break syntax and the sample is a deterministic eight, because a gate that
10
+ * fails at random, or on a mutant that does not compile, is worse than no gate.
11
+ *
12
+ * It is a separate module from `run-command.mjs` so that the "doing" of a
13
+ * verification run and the "which file to break" policy do not grow together;
14
+ * the runner receives the argv executor as a callback rather than importing it,
15
+ * which is also what keeps the import graph acyclic.
16
+ */
17
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
18
+ import { resolve } from "node:path";
19
+
20
+ /** At most this many mutants run for one verification entry. */
21
+ export const MUTATION_BUDGET = 8;
22
+
23
+ /**
24
+ * The six operators, and only these: comparison and logical swaps. Each swap
25
+ * keeps the expression well formed, so a surviving mutant is signal about the
26
+ * suite rather than about the parser.
27
+ *
28
+ * @type {Readonly<Record<string, string>>}
29
+ */
30
+ const MUTATION_SUBSTITUTIONS = Object.freeze({
31
+ "===": "!==",
32
+ "!==": "===",
33
+ "<=": "<",
34
+ ">=": ">",
35
+ "&&": "||",
36
+ "||": "&&",
37
+ });
38
+
39
+ /** `!==` first so the `===`/`!==` alternatives cannot shadow each other. */
40
+ const MUTATION_PATTERN = /!==|===|<=|>=|&&|\|\|/gu;
41
+
42
+ /** @typedef {import("../contract/verification.mjs").VerificationAttemptResult} VerificationAttemptResult */
43
+ /** @typedef {import("../contract/verification.mjs").VerificationCommand} VerificationCommand */
44
+
45
+ /**
46
+ * One operator occurrence that can be swapped.
47
+ *
48
+ * @typedef {{path: string, absolute: string, offset: number, from: string, to: string}} MutationCandidate
49
+ */
50
+
51
+ /**
52
+ * @typedef {{writeFiles?: string[], run: (attempt: number) => Promise<VerificationAttemptResult>}} MutationRunOptions
53
+ */
54
+
55
+ /**
56
+ * @typedef {{passed: boolean, killed: number, total: number, threshold: number, attempts: VerificationAttemptResult[]}} MutationResult
57
+ */
58
+
59
+ /**
60
+ * Every swap in the declared files, ordered by (path, offset) so the sample is
61
+ * a function of the tree and not of the filesystem or the clock.
62
+ *
63
+ * @param {string} baseCwd
64
+ * @param {string[]} writeFiles
65
+ * @returns {MutationCandidate[]}
66
+ */
67
+ function mutationCandidates(baseCwd, writeFiles) {
68
+ /** @type {MutationCandidate[]} */
69
+ const candidates = [];
70
+ for (const path of [...new Set(writeFiles)].sort()) {
71
+ const absolute = resolve(baseCwd, path);
72
+ if (!existsSync(absolute) || !statSync(absolute).isFile()) continue;
73
+ const text = readFileSync(absolute, "utf8");
74
+ for (const match of text.matchAll(MUTATION_PATTERN)) {
75
+ candidates.push({ path, absolute, offset: match.index, from: match[0], to: MUTATION_SUBSTITUTIONS[match[0]] });
76
+ }
77
+ }
78
+ candidates.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : left.offset - right.offset));
79
+ return candidates;
80
+ }
81
+
82
+ /**
83
+ * Pick at most `budget` candidates spread evenly across the ordered list. The
84
+ * first and last are always kept, so a large file cannot lose a whole region
85
+ * from the sample silently.
86
+ *
87
+ * @param {MutationCandidate[]} candidates
88
+ * @param {number} budget
89
+ * @returns {MutationCandidate[]}
90
+ */
91
+ function evenlySpaced(candidates, budget) {
92
+ if (candidates.length <= budget) return [...candidates];
93
+ const last = candidates.length - 1;
94
+ /** @type {MutationCandidate[]} */
95
+ const selected = [];
96
+ for (let index = 0; index < budget; index += 1) selected.push(candidates[Math.round((index * last) / (budget - 1))]);
97
+ return selected;
98
+ }
99
+
100
+ /**
101
+ * Run the entry once untouched and then once per sampled mutant. The original
102
+ * content is held in memory and written back in `finally`, so a mutant that
103
+ * times out, throws or kills the process still leaves the tree as it was.
104
+ *
105
+ * @param {VerificationCommand} command
106
+ * @param {string} baseCwd
107
+ * @param {MutationRunOptions} options
108
+ * @returns {Promise<MutationResult>}
109
+ */
110
+ export async function runMutation(command, baseCwd, options) {
111
+ const mutation = command.mutation;
112
+ if (!mutation) throw new TypeError("mutation runner requires a command.mutation threshold");
113
+ const threshold = mutation.threshold;
114
+ const selected = evenlySpaced(mutationCandidates(baseCwd, options.writeFiles ?? []), MUTATION_BUDGET);
115
+ /** @type {VerificationAttemptResult[]} */
116
+ const attempts = [];
117
+ const baseline = await options.run(1);
118
+ attempts.push(baseline);
119
+ if (!baseline.passed) return { passed: false, killed: 0, total: selected.length, threshold, attempts };
120
+ /** @type {Map<string, string>} */
121
+ const originals = new Map();
122
+ let killed = 0;
123
+ for (const [index, candidate] of selected.entries()) {
124
+ let original = originals.get(candidate.absolute);
125
+ if (original === undefined) {
126
+ original = readFileSync(candidate.absolute, "utf8");
127
+ originals.set(candidate.absolute, original);
128
+ }
129
+ const mutated = `${original.slice(0, candidate.offset)}${candidate.to}${original.slice(candidate.offset + candidate.from.length)}`;
130
+ /** @type {VerificationAttemptResult} */
131
+ let result;
132
+ try {
133
+ writeFileSync(candidate.absolute, mutated);
134
+ result = await options.run(index + 2);
135
+ } finally {
136
+ writeFileSync(candidate.absolute, original);
137
+ }
138
+ attempts.push(result);
139
+ if (!result.passed) killed += 1;
140
+ }
141
+ const total = selected.length;
142
+ // No operators to break is a vacuous pass: there is no mutant the suite could
143
+ // have failed to kill. The caller declared the entry, so an empty target is a
144
+ // measurement of nothing, not a suite that proved nothing.
145
+ return { passed: total === 0 || killed / total >= threshold, killed, total, threshold, attempts };
146
+ }