@tachikomagundam/abathur 0.1.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 (119) hide show
  1. package/.github/workflows/ci.yml +29 -0
  2. package/.github/workflows/publish.yml +74 -0
  3. package/LICENSE +21 -0
  4. package/README.md +461 -0
  5. package/config/abathur.jsonc +17 -0
  6. package/config/genomes/historian.example.jsonc +124 -0
  7. package/dist/bench/adapter.js +201 -0
  8. package/dist/bench/fixture-probe.js +92 -0
  9. package/dist/bench/fixture-support.js +173 -0
  10. package/dist/bench/fixture.js +236 -0
  11. package/dist/bench/toy.js +152 -0
  12. package/dist/cli.js +110 -0
  13. package/dist/commands/bundle.js +79 -0
  14. package/dist/commands/genome.js +94 -0
  15. package/dist/commands/graft.js +71 -0
  16. package/dist/commands/kernel.js +47 -0
  17. package/dist/commands/promote.js +25 -0
  18. package/dist/commands/run.js +145 -0
  19. package/dist/commands/self-eval.js +240 -0
  20. package/dist/commands/status.js +186 -0
  21. package/dist/commands/tombstone.js +72 -0
  22. package/dist/config.js +161 -0
  23. package/dist/core/bundle-common.js +119 -0
  24. package/dist/core/bundle-export.js +212 -0
  25. package/dist/core/bundle-inspect.js +143 -0
  26. package/dist/core/bundle-manifest.js +105 -0
  27. package/dist/core/bundle-mask.js +75 -0
  28. package/dist/core/bundle-tar.js +240 -0
  29. package/dist/core/bundle.js +9 -0
  30. package/dist/core/evolve/brief.js +45 -0
  31. package/dist/core/evolve/candidate.js +140 -0
  32. package/dist/core/evolve/child-track.js +197 -0
  33. package/dist/core/evolve/friction.js +150 -0
  34. package/dist/core/evolve/reflect.js +191 -0
  35. package/dist/core/evolve/run-bench.js +170 -0
  36. package/dist/core/evolve/run-friction.js +63 -0
  37. package/dist/core/evolve/run-loop.js +282 -0
  38. package/dist/core/evolve/run-plan.js +39 -0
  39. package/dist/core/evolve/run-rows.js +145 -0
  40. package/dist/core/evolve/self-overlay.js +213 -0
  41. package/dist/core/evolve/self-snapshot.js +170 -0
  42. package/dist/core/evolve/stub-mutators.mjs +105 -0
  43. package/dist/core/evolve/udiff.js +189 -0
  44. package/dist/core/genome-paths.js +76 -0
  45. package/dist/core/genome.js +176 -0
  46. package/dist/core/glob.js +106 -0
  47. package/dist/core/graft-gates.js +184 -0
  48. package/dist/core/graft-rebench.js +187 -0
  49. package/dist/core/graft-support.js +181 -0
  50. package/dist/core/graft.js +218 -0
  51. package/dist/core/ids.js +154 -0
  52. package/dist/core/incumbent.js +46 -0
  53. package/dist/core/kernel.js +112 -0
  54. package/dist/core/ledger.js +198 -0
  55. package/dist/core/locks.js +172 -0
  56. package/dist/core/promote.js +119 -0
  57. package/dist/core/snapshot.js +61 -0
  58. package/dist/core/spec.js +178 -0
  59. package/dist/core/stats-math.js +102 -0
  60. package/dist/core/stats-pareto.js +57 -0
  61. package/dist/core/stats.js +184 -0
  62. package/dist/core/worktree.js +190 -0
  63. package/dist/exit.js +32 -0
  64. package/dist/genomes/toy-smoke/genome.jsonc +30 -0
  65. package/dist/genomes/toy-smoke/grader.mjs +61 -0
  66. package/dist/genomes/toy-smoke/init.mjs +63 -0
  67. package/dist/genomes/toy-smoke/units/add.mjs +17 -0
  68. package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
  69. package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
  70. package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
  71. package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
  72. package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
  73. package/dist/jsonc.js +77 -0
  74. package/dist/out.js +5 -0
  75. package/dist/test/bench-adapter.test.js +33 -0
  76. package/dist/test/bench-fixture.test.js +407 -0
  77. package/dist/test/bench-toy.test.js +251 -0
  78. package/dist/test/bundle.test.js +659 -0
  79. package/dist/test/config.test.js +185 -0
  80. package/dist/test/d7-gate.test.js +56 -0
  81. package/dist/test/fixture-loop.test.js +267 -0
  82. package/dist/test/fixtures/friction-writer.js +16 -0
  83. package/dist/test/fixtures-historian.js +82 -0
  84. package/dist/test/fixtures-self.js +143 -0
  85. package/dist/test/fixtures-wt.js +64 -0
  86. package/dist/test/friction.test.js +398 -0
  87. package/dist/test/genome.test.js +453 -0
  88. package/dist/test/git.test.js +69 -0
  89. package/dist/test/graft.test.js +567 -0
  90. package/dist/test/historian-genome.test.js +134 -0
  91. package/dist/test/historian-grader-io.test.js +148 -0
  92. package/dist/test/historian-grader.test.js +209 -0
  93. package/dist/test/ids.test.js +116 -0
  94. package/dist/test/include-val.test.js +120 -0
  95. package/dist/test/ledger-lock.test.js +99 -0
  96. package/dist/test/ledger.test.js +102 -0
  97. package/dist/test/promote.test.js +394 -0
  98. package/dist/test/reflect.test.js +410 -0
  99. package/dist/test/run-loop.test.js +433 -0
  100. package/dist/test/self-snapshot.test.js +328 -0
  101. package/dist/test/snapshot.test.js +86 -0
  102. package/dist/test/stats.test.js +423 -0
  103. package/dist/test/stub-mutators.test.js +17 -0
  104. package/dist/test/testutil.js +30 -0
  105. package/dist/test/worktree.test.js +198 -0
  106. package/dist/util/freeze.js +30 -0
  107. package/dist/util/git.js +85 -0
  108. package/docs/federation.md +184 -0
  109. package/docs/immutable-kernel.md +87 -0
  110. package/graders/historian/grader-core.d.mts +53 -0
  111. package/graders/historian/grader-core.mjs +276 -0
  112. package/graders/historian/grader-support.d.mts +57 -0
  113. package/graders/historian/grader-support.mjs +137 -0
  114. package/graders/historian/grader.mjs +113 -0
  115. package/graders/historian/mutate.sh +114 -0
  116. package/graders/historian/reset-sandbox.sh +60 -0
  117. package/graders/historian/run-scenario.sh +49 -0
  118. package/graders/historian/seed-wrapped.sh +32 -0
  119. package/package.json +42 -0
@@ -0,0 +1,410 @@
1
+ // Todo 8 AC pins (plan lines 133-140): reflection brief builder + constrained-diff
2
+ // mutator session driver. Three layers:
3
+ // 1. buildBrief — pure text policy: train failures in, val aliases only, canary-out.
4
+ // 2. udiff + validateCandidate — pure parse/path policy: whole-candidate rejection,
5
+ // traversal/artifact/immutable/syntax refusals, atomic in-memory apply.
6
+ // 3. runMutatorSession — integration over the toy genome in throwaway worktrees:
7
+ // stub child emits 3 candidates (clean seal / kernel touch / fix-add), fitness
8
+ // moves 0→1, rejections land in the ledger, malformed stdout is a clean error.
9
+ import assert from "node:assert/strict";
10
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { mkdtemp } from "node:fs/promises";
12
+ import * as os from "node:os";
13
+ import path from "node:path";
14
+ import test, {} from "node:test";
15
+ import { ExitSignal } from "../exit.js";
16
+ import { ARTIFACT_GLOBS, artifactGlobsFromGitignore, buildBrief, runMutatorSession, validateCandidate, } from "../core/evolve/reflect.js";
17
+ import { applyChanges, parseUnifiedDiff } from "../core/evolve/udiff.js";
18
+ import { scriptedPatches } from "../core/evolve/stub-mutators.mjs";
19
+ import { Ledger, ledgerPath } from "../core/ledger.js";
20
+ import { fingerprint16 } from "../core/genome.js";
21
+ import { loadGenomeSpecFile } from "../core/spec.js";
22
+ import { sealGeneration } from "../core/worktree.js";
23
+ import { aggregateScore } from "../core/stats.js";
24
+ import { prepareToyGenome, ToyBenchAdapter } from "../bench/toy.js";
25
+ import { gitIn } from "./fixtures-wt.js";
26
+ // ------------------------------------------------------------------ fixtures
27
+ const COUNTERS = { candidates: 3, modelCalls: 7, tokens: 4242, wallS: 61.5 };
28
+ const CANARY = "VAL-TOPIC-CANARY-3f9c1a7e-organize-mess";
29
+ // byte-identical to genomes/toy-smoke/units/add.mjs (seeded bug on line 10)
30
+ const ADD_MJS = `// Toy unit: add — shipped WITH A SEEDED BUG (subtraction). The stub-mutators
31
+ // 'fix-add' scripted patch flips it to the correct sum; grader checks() grade it.
32
+ import path from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ const isMain =
36
+ process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
37
+
38
+ export function add(a, b) {
39
+ return a - b; // seeded bug: must be \`return a + b;\`
40
+ }
41
+
42
+ export function checks() {
43
+ return [add(2, 3) === 5, add(0, 7) === 7, add(-1, 1) === 0];
44
+ }
45
+
46
+ if (isMain) console.log(\`add(2,3)=\${add(2, 3)}\`);
47
+ `;
48
+ const FIX_ANCHOR = "return a - b; // seeded bug: must be `return a + b;`";
49
+ // The mutator child stub — mimics an opencode-or-script candidate generator: prints
50
+ // candidate JSON on stdout per --mode and copies every artifact it was GIVEN (argv,
51
+ // brief) and every byte it EMITS (candidates) into --capture, so the canary test can
52
+ // grep the entire mutator-facing surface. Written to a per-run tmp dir at test time.
53
+ const STUB_SOURCE = `#!/usr/bin/env node
54
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
55
+ const args = process.argv.slice(2);
56
+ const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
57
+ const mode = opt("--mode");
58
+ const dir = opt("--dir");
59
+ const brief = opt("--brief");
60
+ const capture = opt("--capture");
61
+ if (capture !== undefined) {
62
+ mkdirSync(capture, { recursive: true });
63
+ writeFileSync(capture + "/argv.json", JSON.stringify(process.argv) + "\\n");
64
+ if (brief !== undefined) writeFileSync(capture + "/brief.md", readFileSync(brief, "utf8"));
65
+ }
66
+ const read = (f) => readFileSync(dir + "/" + f, "utf8");
67
+ function lineDiff(file, anchor, replacement) {
68
+ const lines = read(file).split("\\n");
69
+ const i = lines.findIndex((l) => l.includes(anchor));
70
+ if (i < 0) { process.stderr.write("stub: no anchor in " + file + "\\n"); process.exit(1); }
71
+ const changed = lines[i].replace(anchor, replacement);
72
+ return "--- a/" + file + "\\n+++ b/" + file + "\\n@@ -" + String(i + 1) + ",1 +" + String(i + 1) + ",1 @@\\n-" + lines[i] + "\\n+" + changed + "\\n";
73
+ }
74
+ function createDiff(file, body) {
75
+ const adds = body.split("\\n").map((l) => "+" + l).join("\\n");
76
+ return "--- /dev/null\\n+++ b/" + file + "\\n@@ -0,0 +1," + String(body.split("\\n").length) + " @@\\n" + adds + "\\n";
77
+ }
78
+ const FIX = ${JSON.stringify(FIX_ANCHOR)};
79
+ const out = (candidates) => {
80
+ const doc = JSON.stringify({ candidates });
81
+ if (capture !== undefined) writeFileSync(capture + "/candidates.json", doc + "\\n");
82
+ process.stdout.write(doc + "\\n");
83
+ };
84
+ if (mode === "three") {
85
+ out([
86
+ { id: "annotate-add", rationale: "annotate add()", diffs: [lineDiff("units/add.mjs", "export function add(", "export function add /* patched */(")] },
87
+ { id: "touch-kernel", rationale: "subvert grading", diffs: [lineDiff("grader.mjs", "model-free", "model-free-ish")] },
88
+ { id: "fix-add", rationale: "fix add(): subtraction to addition", diffs: [lineDiff("units/add.mjs", FIX, "return a + b;")] },
89
+ ]);
90
+ } else if (mode === "artifact") {
91
+ out([{ id: "artifact-evil", rationale: "plant build output", diffs: [createDiff("dist/evil.js", "console.log('pwned');")] }]);
92
+ } else if (mode === "garbage") {
93
+ process.stdout.write("this is not json {{{\\n");
94
+ } else if (mode === "empty") {
95
+ // deliberate: no output at all
96
+ } else if (mode === "array") {
97
+ process.stdout.write('["diff", "strings"]\\n');
98
+ } else if (mode === "no-rationale") {
99
+ out([{ id: "nameless", diffs: [lineDiff("units/add.mjs", FIX, "return a + b;")] }]);
100
+ } else {
101
+ process.stderr.write("stub: unknown mode\\n");
102
+ process.exit(2);
103
+ }
104
+ `;
105
+ function trainFail() {
106
+ return {
107
+ unit: { id: "add", path: "units/add.mjs", split: "train" },
108
+ scores: [0, 0],
109
+ failures: ["assertion diff: expected add(2,3)===5, got -1"],
110
+ };
111
+ }
112
+ function valEvidence() {
113
+ return {
114
+ unit: { id: "sub", path: "units/sub.mjs", split: "val" },
115
+ scores: [1, 1],
116
+ failures: [CANARY], // even a failure note on val must never reach the brief
117
+ };
118
+ }
119
+ // Only .label + .budget are read by buildBrief; full specs ride through unchanged.
120
+ const MIN_SPEC = {
121
+ label: "budget-only",
122
+ budget: { maxCandidates: 4, maxModelCalls: 16, maxTokens: 100000, maxWallS: 300 },
123
+ };
124
+ async function sessionFixture(t) {
125
+ const root = await mkdtemp(path.join(os.tmpdir(), "abathur-reflect-"));
126
+ t.after(() => rmSync(root, { recursive: true, force: true }));
127
+ const stub = path.join(root, "mutator-stub.mjs");
128
+ writeFileSync(stub, STUB_SOURCE, "utf8");
129
+ const repo = await prepareToyGenome(path.join(root, "genome"));
130
+ // plant the canary in the VAL unit and commit it, so it rides into worktrees
131
+ const sub = path.join(repo, "units", "sub.mjs");
132
+ writeFileSync(sub, `${readFileSync(sub, "utf8")}\n// ${CANARY}\n`, "utf8");
133
+ await gitIn(repo, "-c", "user.name=abathur", "-c", "user.email=abathur@harness.local", "commit", "-aqm", "plant val canary");
134
+ const spec = loadGenomeSpecFile(path.join(repo, "genome.jsonc"));
135
+ const env = {
136
+ XDG_CACHE_HOME: path.join(root, "xdg-cache"),
137
+ HOME: path.join(root, "fake-home"),
138
+ };
139
+ const capture = path.join(root, "capture");
140
+ const genome = { repoPath: repo, genomeFp: fingerprint16(spec) };
141
+ return {
142
+ spec,
143
+ env,
144
+ root,
145
+ capture,
146
+ genome,
147
+ run(mutatorMode, brief = "(brief withheld in this session)") {
148
+ const command = `node ${stub} --mode ${mutatorMode} --dir {worktree} --brief {brief} --capture ${capture}`;
149
+ return runMutatorSession({ spec, brief, mutatorCommand: command, env });
150
+ },
151
+ };
152
+ }
153
+ function rejectedId(result, id) {
154
+ const hits = result.rejected.filter((r) => r.candidateId === id);
155
+ assert.equal(hits.length, 1, `expected exactly one rejection for ${id}`);
156
+ const hit = hits[0];
157
+ if (hit === undefined)
158
+ assert.fail("unreachable");
159
+ return hit;
160
+ }
161
+ function parseErrors(text) {
162
+ const r = parseUnifiedDiff(text);
163
+ if (r.ok)
164
+ assert.fail("expected parse rejection");
165
+ return r.error;
166
+ }
167
+ function rejectionRecords(repo) {
168
+ const lines = readFileSync(ledgerPath(repo), "utf8")
169
+ .split("\n")
170
+ .filter((l) => l.length > 0)
171
+ .map((l) => JSON.parse(l));
172
+ return lines
173
+ .filter((l) => l.kind === "candidate_rejected")
174
+ .map((l) => ({ candidateId: String(l.data.candidateId), stage: String(l.data.stage) }));
175
+ }
176
+ async function expectExit(code, pattern, action) {
177
+ try {
178
+ await action;
179
+ }
180
+ catch (e) {
181
+ assert.ok(e instanceof ExitSignal, `expected ExitSignal, got ${String(e)}`);
182
+ assert.equal(e.code, code);
183
+ assert.match(e.message, pattern);
184
+ return;
185
+ }
186
+ assert.fail(`expected rejection with exit ${code}`);
187
+ }
188
+ /** Bench every train unit of `spec` over `reps` reps → aggregate (inconclusive excluded). */
189
+ async function trainAggregate(spec, sandboxRoot, reps) {
190
+ const adapter = new ToyBenchAdapter(spec);
191
+ const units = [];
192
+ for (const unit of spec.bench.units.filter((u) => u.split === "train")) {
193
+ const scores = [];
194
+ for (let rep = 0; rep < reps; rep += 1) {
195
+ const sandbox = path.join(sandboxRoot, `${unit.id}-${String(rep)}`);
196
+ await adapter.reset(sandbox);
197
+ await adapter.seed(sandbox);
198
+ await adapter.run(unit, sandbox, spec.bench.timeoutS);
199
+ const graded = await adapter.score(unit);
200
+ if (graded.kind === "scored")
201
+ scores.push(graded.result.score);
202
+ }
203
+ if (scores.length > 0)
204
+ units.push({ unitId: unit.id, split: "train", scores });
205
+ }
206
+ return aggregateScore(units);
207
+ }
208
+ // ------------------------------------------------------------------ buildBrief
209
+ test("buildBrief: failed train units in, with assertion diffs + budget counters", () => {
210
+ const passing = { unit: { id: "mul", path: "units/mul.mjs", split: "train" }, scores: [1, 1], failures: [] };
211
+ const brief = buildBrief(MIN_SPEC, [trainFail(), passing, valEvidence()], COUNTERS);
212
+ assert.match(brief, /units\/add\.mjs/);
213
+ assert.match(brief, /expected add\(2,3\)===5, got -1/);
214
+ assert.match(brief, /candidates=3/);
215
+ assert.match(brief, /tokens=4242/);
216
+ assert.match(brief, /maxTokens=100000/);
217
+ // passing train units are not failure material
218
+ assert.doesNotMatch(brief, /units\/mul\.mjs/);
219
+ });
220
+ test("buildBrief: val units are OPAQUE aliases only — no id, path, score or content", () => {
221
+ const val2 = { unit: { id: "organize-mess", path: "scenarios/04-organize-mess.md", split: "val" }, scores: [0], failures: [] };
222
+ const brief = buildBrief(MIN_SPEC, [trainFail(), valEvidence(), val2], COUNTERS);
223
+ assert.match(brief, /val-1/);
224
+ assert.match(brief, /val-2/);
225
+ assert.doesNotMatch(brief, /VAL-TOPIC-CANARY/);
226
+ assert.doesNotMatch(brief, /units\/sub\.mjs/);
227
+ assert.doesNotMatch(brief, /organize-mess/);
228
+ assert.doesNotMatch(brief, /04-organize/);
229
+ });
230
+ test("buildBrief: pure + deterministic — same inputs byte-identical brief", () => {
231
+ assert.equal(buildBrief(MIN_SPEC, [trainFail(), valEvidence()], COUNTERS), buildBrief(MIN_SPEC, [trainFail(), valEvidence()], COUNTERS));
232
+ });
233
+ test("stub anchors stay byte-identical to the todo-5 scriptedPatches table", () => {
234
+ const byId = new Map(scriptedPatches().map((p) => [p.id, p]));
235
+ const fix = byId.get("fix-add");
236
+ const anno = byId.get("annotate-add");
237
+ if (fix === undefined || anno === undefined)
238
+ assert.fail("table drifted");
239
+ assert.ok(STUB_SOURCE.includes(JSON.stringify(fix.from)));
240
+ assert.ok(STUB_SOURCE.includes(fix.to));
241
+ assert.ok(STUB_SOURCE.includes(anno.from));
242
+ assert.ok(STUB_SOURCE.includes(anno.to));
243
+ });
244
+ // ------------------------------------------------------------- udiff parse/apply
245
+ const FIX_ADD_DIFF = "--- a/units/add.mjs\n+++ b/units/add.mjs\n@@ -10,1 +10,1 @@\n- return a - b; // seeded bug: must be `return a + b;`\n+ return a + b;\n";
246
+ test("udiff: parses modify hunk and applies it atomically in memory", () => {
247
+ const parsed = parseUnifiedDiff(FIX_ADD_DIFF);
248
+ if (!parsed.ok)
249
+ assert.fail(parsed.error);
250
+ const applied = applyChanges(parsed.changes, (rel) => (rel === "units/add.mjs" ? ADD_MJS : null));
251
+ if (!applied.ok)
252
+ assert.fail(applied.reason);
253
+ assert.match(applied.files.get("units/add.mjs") ?? "", /return a \+ b;/);
254
+ assert.deepEqual(applied.touched, ["units/add.mjs"]);
255
+ assert.equal(/^[ ]{2}return a \+ b;$/m.test(ADD_MJS), false, "source sample must still carry the bug");
256
+ });
257
+ test("udiff: create-from-/dev/null allowed; delete, rename, binary refused as candidate ops", () => {
258
+ assert.ok(parseUnifiedDiff("--- /dev/null\n+++ b/n/new.mjs\n@@ -0,0 +1,1 @@\n+hi\n").ok);
259
+ assert.match(parseErrors("--- a/x.mjs\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-hi\n"), /delete/i);
260
+ assert.match(parseErrors("diff --git a/x.mjs b/y.mjs\nsimilarity index 90%\nrename from x.mjs\nrename to y.mjs\n"), /rename/i);
261
+ assert.match(parseErrors("--- a/x.bin\n+++ b/x.bin\nBinary files a/x.bin and b/x.bin differ\n"), /binary/i);
262
+ assert.match(parseErrors("--- a/x.mjs\n+++ b/x.mjs\nGIT binary patch\nliteral 42\n"), /binary/i);
263
+ assert.match(parseErrors("--- a/x.mjs\n+++ b/x.mjs\n@@ -1,1 +1,1 @@\n-\x00\x01\x7fELFgarbage\n+x\n"), /control characters/i);
264
+ });
265
+ test("udiff: hunk position or context mismatch (drifted worktree) → apply refuses, writes nothing", () => {
266
+ const parsed = parseUnifiedDiff(FIX_ADD_DIFF);
267
+ if (!parsed.ok)
268
+ assert.fail(parsed.error);
269
+ // one inserted line shifts the target from line 10 to line 11: positional apply refuses
270
+ const applied = applyChanges(parsed.changes, (rel) => (rel === "units/add.mjs" ? `// drift notice\n${ADD_MJS}` : null));
271
+ assert.equal(applied.ok, false);
272
+ assert.match(applied.ok ? "" : applied.reason, /mismatch/i);
273
+ });
274
+ // ----------------------------------------------------------- validateCandidate
275
+ const POLICY = { immutableGlobs: ["grader.mjs"], artifactGlobs: ARTIFACT_GLOBS };
276
+ test("validateCandidate: clean candidate passes with parsed changes", () => {
277
+ const v = validateCandidate({ id: "fix", rationale: "fix add()", diffs: [FIX_ADD_DIFF] }, POLICY);
278
+ assert.ok(v.ok, v.ok ? "" : `${v.stage}: ${v.reason}`);
279
+ assert.deepEqual(v.changes.map((c) => c.path), ["units/add.mjs"]);
280
+ });
281
+ test("validateCandidate: kernel-immutable AND artifact paths reject the WHOLE candidate", () => {
282
+ const sealed = validateCandidate({ rationale: "sneak", diffs: ["--- a/grader.mjs\n+++ b/grader.mjs\n@@ -1,1 +1,1 @@\n-x\n+y\n", FIX_ADD_DIFF] }, POLICY);
283
+ assert.equal(sealed.ok, false);
284
+ assert.equal(sealed.ok ? "" : sealed.stage, "path");
285
+ // mixed candidate: one artifact path poisons an otherwise-valid fix in the same candidate
286
+ const mixed = validateCandidate({ rationale: "evil+good", diffs: ["--- /dev/null\n+++ b/dist/evil.js\n@@ -0,0 +1,1 @@\n+pwn\n", FIX_ADD_DIFF] }, POLICY);
287
+ assert.equal(mixed.ok, false);
288
+ assert.equal(mixed.ok ? "" : mixed.stage, "path");
289
+ assert.match(mixed.ok ? "" : mixed.reason, /dist\/evil\.js/);
290
+ });
291
+ test("validateCandidate: traversal, .git, and hidden-state paths are refused by the path policy", () => {
292
+ for (const bad of ["../../etc/passwd", "/etc/passwd", ".git/config", ".state/x.jsonc", "config.local.jsonc", "a/../../b/x.mjs"]) {
293
+ const v = validateCandidate({ rationale: "r", diffs: [`--- /dev/null\n+++ b/${bad}\n@@ -0,0 +1,1 @@\n+x\n`] }, POLICY);
294
+ assert.equal(v.ok, false, `must refuse ${bad}`);
295
+ }
296
+ });
297
+ test("validateCandidate: schema stage — missing rationale, unknown field, empty diffs, non-object", () => {
298
+ const missing = validateCandidate({ id: "x", diffs: [FIX_ADD_DIFF] }, POLICY);
299
+ assert.equal(missing.ok, false);
300
+ assert.equal(missing.ok ? "" : missing.stage, "schema");
301
+ assert.equal(validateCandidate({ rationale: "r", diffs: [FIX_ADD_DIFF], hacker: true }, POLICY).ok, false);
302
+ assert.equal(validateCandidate({ rationale: "r", diffs: [] }, POLICY).ok, false);
303
+ assert.equal(validateCandidate("just a string", POLICY).ok, false);
304
+ assert.equal(validateCandidate({ rationale: "", diffs: [FIX_ADD_DIFF] }, POLICY).ok, false);
305
+ });
306
+ test("validateCandidate: syntax stage — malformed diff body inside an otherwise fine candidate", () => {
307
+ const v = validateCandidate({ rationale: "r", diffs: ["--- a/x\n+++ b/x\nnot a hunk at all\n"] }, POLICY);
308
+ assert.equal(v.ok, false);
309
+ assert.equal(v.ok ? "" : v.stage, "syntax");
310
+ });
311
+ test("artifactGlobsFromGitignore: directories, root-anchored, and negation skipping", () => {
312
+ const globs = artifactGlobsFromGitignore("# c\n\nbuild/\n/only-root.txt\n*.tmp\n!keep.tmp\n");
313
+ assert.ok(globs.includes("build/**"));
314
+ assert.ok(globs.includes("**/build/**"));
315
+ assert.ok(globs.includes("only-root.txt"));
316
+ assert.equal(globs.some((g) => g.includes("keep")), false, "negations are skipped (fail-closed broadening)");
317
+ });
318
+ // --------------------------------------------------------- runMutatorSession AC
319
+ test("session AC: stub emits 3 candidates — clean seal, kernel-touch rejected+logged, fix-add applied", async (t) => {
320
+ const fx = await sessionFixture(t);
321
+ const brief = buildBrief(fx.spec, [trainFail(), valEvidence()], COUNTERS);
322
+ const result = await fx.run("three", brief);
323
+ assert.deepEqual(result.applied.map((a) => a.candidateId), ["annotate-add", "fix-add"]);
324
+ const touch = rejectedId(result, "touch-kernel");
325
+ assert.equal(touch.stage, "path");
326
+ assert.match(touch.reason, /immutable/i);
327
+ // rejections are booked in the genome ledger
328
+ assert.deepEqual(rejectionRecords(fx.spec.repoPath), [{ candidateId: "touch-kernel", stage: "path" }]);
329
+ assert.ok(Ledger.open(fx.spec.repoPath).readAll().some((r) => r.kind === "candidate_rejected"));
330
+ // sealed generations are real worktrees with real commits
331
+ for (const applied of result.applied) {
332
+ assert.ok(applied.commitSha.length >= 7);
333
+ assert.ok(existsSync(applied.worktreePath));
334
+ }
335
+ // fitness move: fix-add's sealed tree flips the train aggregate 0 → 1 on `add`
336
+ const before = await trainAggregate(fx.spec, path.join(fx.root, "bench-before"), 2);
337
+ const fix = result.applied.find((a) => a.candidateId === "fix-add");
338
+ if (fix === undefined)
339
+ assert.fail("fix-add must apply");
340
+ const after = await trainAggregate({ ...fx.spec, repoPath: fix.worktreePath }, path.join(fx.root, "bench-after"), 2);
341
+ console.log(`FITNESS MOVE train aggregate: ${String(before)} -> ${String(after)}`);
342
+ assert.equal(before, 0.5); // add=0, mul=1 (explode inconclusive → excluded)
343
+ assert.equal(after, 1); // add fixed to 1 by the sealed candidate
344
+ });
345
+ test("session AC: adversarial dist/evil.js candidate rejected at validation, logged, nothing applied", async (t) => {
346
+ const fx = await sessionFixture(t);
347
+ const result = await fx.run("artifact");
348
+ assert.equal(result.applied.length, 0);
349
+ const evil = rejectedId(result, "artifact-evil");
350
+ assert.equal(evil.stage, "path");
351
+ assert.match(evil.reason, /artifact/i);
352
+ assert.deepEqual(rejectionRecords(fx.spec.repoPath), [{ candidateId: "artifact-evil", stage: "path" }]);
353
+ });
354
+ test("session canary: val scenario string absent from every mutator-facing artifact", async (t) => {
355
+ const fx = await sessionFixture(t);
356
+ const brief = buildBrief(fx.spec, [trainFail(), valEvidence()], COUNTERS);
357
+ await fx.run("three", brief);
358
+ // sanity: the canary IS in the genome (a real leak surface exists)
359
+ assert.ok(readFileSync(path.join(fx.spec.repoPath, "units", "sub.mjs"), "utf8").includes(CANARY));
360
+ // every file the capture dir holds = everything the child was given / emitted
361
+ const files = readdirSync(fx.capture);
362
+ assert.ok(files.includes("argv.json") && files.includes("brief.md"));
363
+ for (const f of files) {
364
+ const text = readFileSync(path.join(fx.capture, f), "utf8");
365
+ assert.equal(text.includes(CANARY), false, `canary leaked into ${f}`);
366
+ assert.equal(text.includes("units/sub.mjs"), false, `val path leaked into ${f}`);
367
+ }
368
+ assert.equal(brief.includes(CANARY), false);
369
+ });
370
+ test("session: missing mutator binary → clean exit 2 BEFORE spawn, nothing materialized", async (t) => {
371
+ const fx = await sessionFixture(t);
372
+ const command = `${path.join(fx.root, "no-such-mutator")} --dir {worktree} --brief {brief}`;
373
+ await expectExit(2, /not found|not executable/i, runMutatorSession({ spec: fx.spec, brief: "b", mutatorCommand: command, env: fx.env }));
374
+ // opencodeBin set to a dead path → same clean exit 2 for an 'opencode run …' template
375
+ await expectExit(2, /opencode/i, runMutatorSession({
376
+ spec: fx.spec,
377
+ brief: "b",
378
+ mutatorCommand: "opencode run --dir {worktree}",
379
+ opencodeBin: path.join(fx.root, "definitely-missing-opencode"),
380
+ env: fx.env,
381
+ }));
382
+ assert.equal(existsSync(path.join(fx.root, "xdg-cache")), false, "nothing was materialized pre-spawn");
383
+ });
384
+ test("session: malformed stdout — non-JSON is a fatal clean error + ledger parse entry", async (t) => {
385
+ const fx = await sessionFixture(t);
386
+ await expectExit(1, /not JSON/i, fx.run("garbage"));
387
+ assert.deepEqual(rejectionRecords(fx.spec.repoPath), [{ candidateId: "mutator-stdout", stage: "parse" }]);
388
+ });
389
+ test("session: malformed stdout — empty output and JSON array of strings are clean errors", async (t) => {
390
+ const fx = await sessionFixture(t);
391
+ await expectExit(1, /not JSON|no output/i, fx.run("empty"));
392
+ await expectExit(1, /candidates/i, fx.run("array"));
393
+ const stages = rejectionRecords(fx.spec.repoPath).map((r) => r.stage);
394
+ assert.deepEqual(stages, ["parse", "schema"]);
395
+ });
396
+ test("session: per-candidate schema failure (missing rationale) rejects that candidate, session survives", async (t) => {
397
+ const fx = await sessionFixture(t);
398
+ const result = await fx.run("no-rationale");
399
+ assert.equal(result.applied.length, 0);
400
+ assert.equal(rejectedId(result, "nameless").stage, "schema");
401
+ assert.deepEqual(rejectionRecords(fx.spec.repoPath), [{ candidateId: "nameless", stage: "schema" }]);
402
+ });
403
+ test("session stale_state: discarded worktrees are never sealable; same seed → same candidates", async (t) => {
404
+ const fx = await sessionFixture(t);
405
+ const first = await fx.run("three");
406
+ await expectExit(1, /not found/i, sealGeneration(fx.genome, first.launchGenId, "abathur: stale seal probe", { env: fx.env }));
407
+ const second = await fx.run("three");
408
+ assert.deepEqual(second.applied.map((a) => a.candidateId), first.applied.map((a) => a.candidateId));
409
+ assert.deepEqual(second.applied.map((a) => a.treeSha), first.applied.map((a) => a.treeSha));
410
+ });