@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,453 @@
1
+ // Todo 4 acceptance pins: GenomeSpec schema, out-of-tree genome registry, and the
2
+ // sealed-path kernel manifest (TDD RED first — plan protocol). Given/When/Then
3
+ // throughout; every fixture lives in a per-run mkdtemp tmpdir removed via t.after.
4
+ // Registry/manifest paths stay under <configDir> (ABATHUR_CONFIG parent), never
5
+ // inside a genome repo tree — that isolation is itself pinned here.
6
+ import assert from "node:assert/strict";
7
+ import { spawnSync } from "node:child_process";
8
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
9
+ import * as os from "node:os";
10
+ import path from "node:path";
11
+ import { test } from "node:test";
12
+ import { fileURLToPath } from "node:url";
13
+ import { ExitSignal } from "../exit.js";
14
+ import { canonicalJson, fingerprint } from "../core/ids.js"; // todo 2 owns canonical JSON
15
+ import { fingerprint16, readRegistry, registerGenome, requireGenomesByLabel, } from "../core/genome.js";
16
+ import { auditKernel, buildManifest, compareManifest, serializeManifest, } from "../core/kernel.js";
17
+ import { loadGenomeSpecFile } from "../core/spec.js";
18
+ import { compileGlob, filesMatching, listFiles } from "../core/glob.js";
19
+ const cliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
20
+ // ------------------------------------------------------------------- fixtures
21
+ async function freshDir(prefix) {
22
+ return mkdtemp(path.join(os.tmpdir(), `abathur-${prefix}-`));
23
+ }
24
+ function keep(t, dir) {
25
+ t.after(() => rm(dir, { recursive: true, force: true }));
26
+ return dir;
27
+ }
28
+ /** Plain working-tree fixture repo (kernel seals match WORKING-TREE files). */
29
+ async function makeRepo(t) {
30
+ const root = keep(t, await freshDir("repo"));
31
+ await writeFile(path.join(root, "kernel.mjs"), "export const frozen = 1;\n", "utf8");
32
+ await writeFile(path.join(root, "README.md"), "# fixture genome\n", "utf8");
33
+ await mkdir(path.join(root, "secrets"), { recursive: true });
34
+ await writeFile(path.join(root, "secrets", "key.pem"), "PRIVATE\n", "utf8");
35
+ await mkdir(path.join(root, "evolve"), { recursive: true });
36
+ await writeFile(path.join(root, "evolve", "hot.ts"), "export const hot = 2;\n", "utf8");
37
+ return root;
38
+ }
39
+ /** Writable config home: ABATHUR_CONFIG inside, so resolveConfigDir() is isolated. */
40
+ async function makeEnv(t) {
41
+ const home = keep(t, await freshDir("home"));
42
+ const configDir = path.join(home, ".config", "abathur");
43
+ await mkdir(configDir, { recursive: true });
44
+ const configPath = path.join(configDir, "config.jsonc");
45
+ await writeFile(configPath, "// isolated test config\n{}\n", "utf8");
46
+ return { configDir, env: { ...process.env, ABATHUR_CONFIG: configPath, HOME: home } };
47
+ }
48
+ function baseSpec(repoRoot) {
49
+ return {
50
+ label: "fixture-genome",
51
+ repoPath: repoRoot,
52
+ bench: {
53
+ type: "toy",
54
+ units: [
55
+ { id: "t1", path: "bench/t1.json", split: "train" },
56
+ { id: "v1", path: "bench/v1.json", split: "val" },
57
+ ],
58
+ seedCommand: "echo seed",
59
+ runCommand: "abathur-bench run {unit.path}",
60
+ graderCommand: "abathur-bench grade {unit.path}",
61
+ timeoutS: 120,
62
+ stats: { halfWidth: 0.05, minEffect: 0.1, nReps: { initial: 3, max: 9 } },
63
+ },
64
+ budget: { maxCandidates: 8, maxModelCalls: 64, maxTokens: 2000000, maxWallS: 3600 },
65
+ kernel: { immutableGlobs: ["kernel.mjs", "secrets/**"] },
66
+ };
67
+ }
68
+ async function writeSpec(t, name, spec) {
69
+ const dir = keep(t, await freshDir("specs"));
70
+ const file = path.join(dir, name);
71
+ await writeFile(file, JSON.stringify(spec, null, 2), "utf8");
72
+ return file;
73
+ }
74
+ async function writeSpecText(t, name, text) {
75
+ const dir = keep(t, await freshDir("specs"));
76
+ const file = path.join(dir, name);
77
+ await writeFile(file, text, "utf8");
78
+ return file;
79
+ }
80
+ function abathur(env, ...args) {
81
+ const run = spawnSync(process.execPath, [cliPath, ...args], { env, encoding: "utf8" });
82
+ return { status: run.status ?? -1, stdout: run.stdout, stderr: run.stderr };
83
+ }
84
+ async function expectExit(action, code, ...needles) {
85
+ let signal;
86
+ try {
87
+ await action();
88
+ }
89
+ catch (error) {
90
+ if (error instanceof ExitSignal)
91
+ signal = error;
92
+ else
93
+ throw error;
94
+ }
95
+ assert.ok(signal instanceof ExitSignal, `expected ExitSignal(code ${code}), got none`);
96
+ assert.equal(signal.code, code, `wrong exit code — message: ${signal.message}`);
97
+ for (const needle of needles) {
98
+ assert.ok(signal.message.includes(needle), `message must contain '${needle}' — got: ${signal.message}`);
99
+ }
100
+ return signal;
101
+ }
102
+ function entryByFingerprint(entries, fp) {
103
+ const found = entries.find((e) => e.fingerprint === fp);
104
+ assert.ok(found !== undefined, `registry has no entry ${fp}`);
105
+ return found;
106
+ }
107
+ // ------------------------------------------------------------- canonical json
108
+ test("canonicalJson: recursively sorts keys, compact separators", () => {
109
+ assert.equal(canonicalJson({ b: 1, a: [{ d: 2, c: 3 }] }), '{"a":[{"c":3,"d":2}],"b":1}');
110
+ assert.equal(canonicalJson([2, [3, { z: 1, a: 2 }]]), "[2,[3,{\"a\":2,\"z\":1}]]");
111
+ });
112
+ test("fingerprint: invariant to key insertion order", () => {
113
+ const one = JSON.parse('{"label":"x","bench":{"type":"toy","timeoutS":1}}');
114
+ const two = JSON.parse('{"bench":{"timeoutS":1,"type":"toy"},"label":"x"}');
115
+ assert.equal(fingerprint(one), fingerprint(two));
116
+ });
117
+ test("fingerprint: equals sha256 hex of canonicalJson", async () => {
118
+ const { createHash } = await import("node:crypto");
119
+ const value = { b: 1, a: 2 };
120
+ const expected = createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
121
+ assert.equal(fingerprint(value), expected);
122
+ });
123
+ // -------------------------------------------------------------- glob compiler
124
+ test("compileGlob: star stays inside one segment", () => {
125
+ assert.ok(compileGlob("*.ts").test("a.ts"));
126
+ assert.ok(!compileGlob("*.ts").test("dir/a.ts"));
127
+ assert.ok(compileGlob("src/*.ts").test("src/a.ts"));
128
+ });
129
+ test("compileGlob: ** spans zero or more segments", () => {
130
+ assert.ok(compileGlob("secrets/**").test("secrets/a.pem"));
131
+ assert.ok(compileGlob("secrets/**").test("secrets/sub/a.pem"));
132
+ assert.ok(!compileGlob("secrets/**").test("other/a.pem"));
133
+ assert.ok(compileGlob("**/*.ts").test("a.ts"));
134
+ assert.ok(compileGlob("**/*.ts").test("x/y/a.ts"));
135
+ assert.ok(compileGlob("src/**/index.ts").test("src/index.ts"));
136
+ assert.ok(compileGlob("src/**/index.ts").test("src/a/b/index.ts"));
137
+ });
138
+ test("compileGlob: ?, classes, negated classes, literal dots", () => {
139
+ assert.ok(compileGlob("a?.ts").test("ab.ts"));
140
+ assert.ok(!compileGlob("a?.ts").test("abc.ts"));
141
+ assert.ok(compileGlob("x[ab]y").test("xay"));
142
+ assert.ok(!compileGlob("x[ab]y").test("xcy"));
143
+ assert.ok(compileGlob("x[!ab]y").test("xcy"));
144
+ assert.ok(!compileGlob("a.ts").test("axts")); // '.' is literal, not regex any-char
145
+ });
146
+ test("listFiles/filesMatching: sorted working-tree paths, .git skipped", async (t) => {
147
+ const repo = await makeRepo(t);
148
+ const files = listFiles(repo);
149
+ assert.deepEqual(files, ["README.md", "evolve/hot.ts", "kernel.mjs", "secrets/key.pem"]);
150
+ assert.deepEqual(filesMatching(repo, ["secrets/**", "kernel.mjs"]), [
151
+ "kernel.mjs",
152
+ "secrets/key.pem",
153
+ ]);
154
+ });
155
+ // ----------------------------------------------------------------- validation
156
+ test("loadGenomeSpecFile: accepts a fully valid toy spec", async (t) => {
157
+ const repo = await makeRepo(t);
158
+ const file = await writeSpec(t, "ok.jsonc", baseSpec(repo));
159
+ const spec = loadGenomeSpecFile(file);
160
+ assert.equal(spec.label, "fixture-genome");
161
+ assert.equal(spec.bench.stats.halfWidth, 0.05);
162
+ });
163
+ test("validation: unknown bench.type fails exit 2 naming bench.type", async (t) => {
164
+ const repo = await makeRepo(t);
165
+ const spec = baseSpec(repo);
166
+ spec.bench.type = "not-a-bench";
167
+ const file = await writeSpec(t, "badtype.jsonc", spec);
168
+ await expectExit(() => loadGenomeSpecFile(file), 2, "bench.type");
169
+ });
170
+ test("validation: zero val units rejected exit 2", async (t) => {
171
+ const repo = await makeRepo(t);
172
+ const spec = baseSpec(repo);
173
+ spec.bench.units = [
174
+ { id: "t1", path: "bench/t1.json", split: "train" },
175
+ ];
176
+ const file = await writeSpec(t, "noval.jsonc", spec);
177
+ await expectExit(() => loadGenomeSpecFile(file), 2, "val");
178
+ });
179
+ test("validation: missing stats.halfWidth fails exit 2 NAMING THE KEY (hr required-entry discipline)", async (t) => {
180
+ const repo = await makeRepo(t);
181
+ const spec = baseSpec(repo);
182
+ delete spec.bench.stats.halfWidth;
183
+ const file = await writeSpec(t, "nohalf.jsonc", spec);
184
+ await expectExit(() => loadGenomeSpecFile(file), 2, "bench.stats.halfWidth");
185
+ });
186
+ test("validation: missing stats (whole object) fails exit 2 naming bench.stats", async (t) => {
187
+ const repo = await makeRepo(t);
188
+ const spec = baseSpec(repo);
189
+ delete spec.bench.stats;
190
+ const file = await writeSpec(t, "nostats.jsonc", spec);
191
+ await expectExit(() => loadGenomeSpecFile(file), 2, "bench.stats");
192
+ });
193
+ test("validation: opencode-fixture-scenarios without agentModel fails exit 2 naming bench.agentModel", async (t) => {
194
+ const repo = await makeRepo(t);
195
+ const spec = baseSpec(repo);
196
+ spec.bench.type = "opencode-fixture-scenarios";
197
+ const file = await writeSpec(t, "noagent.jsonc", spec);
198
+ await expectExit(() => loadGenomeSpecFile(file), 2, "bench.agentModel");
199
+ });
200
+ test("validation: judgeCommand without judgeModel fails exit 2 naming bench.judgeModel", async (t) => {
201
+ const repo = await makeRepo(t);
202
+ const spec = baseSpec(repo);
203
+ spec.bench.judgeCommand = "judge {unit.path}";
204
+ const file = await writeSpec(t, "nojudge.jsonc", spec);
205
+ await expectExit(() => loadGenomeSpecFile(file), 2, "bench.judgeModel");
206
+ });
207
+ test("validation: unknown spec key fails exit 2 (strict schema, hr required-entry style)", async (t) => {
208
+ const repo = await makeRepo(t);
209
+ const spec = baseSpec(repo);
210
+ spec.mystery = true;
211
+ const file = await writeSpec(t, "extrakey.jsonc", spec);
212
+ await expectExit(() => loadGenomeSpecFile(file), 2, '"mystery"');
213
+ });
214
+ test("validation: wrong type for bench.timeoutS fails exit 2 naming the key", async (t) => {
215
+ const repo = await makeRepo(t);
216
+ const spec = baseSpec(repo);
217
+ spec.bench.timeoutS = "soon";
218
+ const file = await writeSpec(t, "badtimeout.jsonc", spec);
219
+ await expectExit(() => loadGenomeSpecFile(file), 2, "bench.timeoutS");
220
+ });
221
+ test("validation: garbage JSONC fails exit 2 as malformed, never a stack", async (t) => {
222
+ const file = await writeSpecText(t, "garbage.jsonc", "{ this is not json // ??? ");
223
+ await expectExit(() => loadGenomeSpecFile(file), 2, "malformed");
224
+ });
225
+ // ------------------------------------------------------------ register: happy
226
+ test("registerGenome: writes out-of-tree registry + kernel manifest", async (t) => {
227
+ const repo = await makeRepo(t);
228
+ const { configDir } = await makeEnv(t);
229
+ const file = await writeSpec(t, "ok.jsonc", baseSpec(repo));
230
+ const result = registerGenome(configDir, file);
231
+ assert.equal(result.kind, "registered");
232
+ assert.equal(result.label, "fixture-genome");
233
+ const registryFile = path.join(configDir, "genomes", `${result.fingerprint}.jsonc`);
234
+ const manifestFile = path.join(configDir, "kernels", `${result.fingerprint}.json`);
235
+ const stored = JSON.parse(await readFile(registryFile, "utf8"));
236
+ assert.equal(stored.label, "fixture-genome");
237
+ assert.equal(result.fingerprint, fingerprint16(stored));
238
+ const manifest = JSON.parse(await readFile(manifestFile, "utf8"));
239
+ const sealed = manifest.entries.map((e) => e.path);
240
+ assert.deepEqual(sealed, ["kernel.mjs", "secrets/key.pem"]); // evolve/hot.ts NOT sealed
241
+ for (const entry of manifest.entries)
242
+ assert.match(entry.sha256, /^[0-9a-f]{64}$/);
243
+ // Registry lives under configDir — structurally outside every genome repo tree.
244
+ assert.ok(!path.resolve(registryFile).startsWith(path.resolve(repo)));
245
+ });
246
+ test("registerGenome: idempotent re-add is a clean no-op", async (t) => {
247
+ const repo = await makeRepo(t);
248
+ const { configDir } = await makeEnv(t);
249
+ const file = await writeSpec(t, "ok.jsonc", baseSpec(repo));
250
+ registerGenome(configDir, file);
251
+ const again = registerGenome(configDir, file);
252
+ assert.equal(again.kind, "already-sealed");
253
+ const entries = await readRegistry(configDir);
254
+ assert.equal(entries.entries.length, 1);
255
+ });
256
+ test("registerGenome: same label, different fingerprint ⇒ distinct genomes", async (t) => {
257
+ const repo = await makeRepo(t);
258
+ const { configDir } = await makeEnv(t);
259
+ registerGenome(configDir, await writeSpec(t, "a.jsonc", baseSpec(repo)));
260
+ const variant = baseSpec(repo);
261
+ variant.bench.timeoutS = 999;
262
+ const second = registerGenome(configDir, await writeSpec(t, "b.jsonc", variant));
263
+ assert.equal(second.kind, "registered");
264
+ const entries = await readRegistry(configDir);
265
+ assert.equal(entries.entries.length, 2);
266
+ assert.notEqual(entries.entries[0]?.fingerprint, entries.entries[1]?.fingerprint);
267
+ for (const e of entries.entries)
268
+ assert.equal(e.label, "fixture-genome");
269
+ });
270
+ test("registerGenome: glob matching zero existing files fails exit 2 fail-closed", async (t) => {
271
+ const repo = await makeRepo(t);
272
+ const { configDir } = await makeEnv(t);
273
+ const spec = baseSpec(repo);
274
+ spec.kernel.immutableGlobs = ["kernel.mjs", "ghosts/**"];
275
+ const file = await writeSpec(t, "ghost.jsonc", spec);
276
+ await expectExit(() => registerGenome(configDir, file), 2, "ghosts/**");
277
+ const entries = await readRegistry(configDir);
278
+ assert.equal(entries.entries.length, 0); // nothing written
279
+ });
280
+ // ------------------------------------------------------------- kernel: audit
281
+ test("auditKernel: clean genome is ok; tampered file is named as modified", async (t) => {
282
+ const repo = await makeRepo(t);
283
+ const { configDir } = await makeEnv(t);
284
+ const result = registerGenome(configDir, await writeSpec(t, "ok.jsonc", baseSpec(repo)));
285
+ const scan = await readRegistry(configDir);
286
+ const entry = entryByFingerprint(scan.entries, result.fingerprint);
287
+ assert.deepEqual(await auditKernel(entry, configDir), { ok: true, drifted: [] });
288
+ await writeFile(path.join(repo, "kernel.mjs"), "export const frozen = 2;\n", "utf8");
289
+ const audit = await auditKernel(entry, configDir);
290
+ assert.equal(audit.ok, false);
291
+ assert.deepEqual(audit.drifted, [{ path: "kernel.mjs", kind: "modified", glob: "kernel.mjs" }]);
292
+ });
293
+ test("auditKernel: deleted sealed file ⇒ missing; new glob-matched file ⇒ added", async (t) => {
294
+ const repo = await makeRepo(t);
295
+ const { configDir } = await makeEnv(t);
296
+ const result = registerGenome(configDir, await writeSpec(t, "ok.jsonc", baseSpec(repo)));
297
+ const entry = entryByFingerprint((await readRegistry(configDir)).entries, result.fingerprint);
298
+ await rm(path.join(repo, "secrets", "key.pem"));
299
+ await writeFile(path.join(repo, "secrets", "extra.pem"), "NEW\n", "utf8");
300
+ const audit = await auditKernel(entry, configDir);
301
+ assert.equal(audit.ok, false);
302
+ assert.deepEqual(audit.drifted, [
303
+ { path: "secrets/extra.pem", kind: "added", glob: "secrets/**" },
304
+ { path: "secrets/key.pem", kind: "missing", glob: "secrets/**" },
305
+ ]);
306
+ });
307
+ test("re-add after tamper with SAME fingerprint ⇒ exit 1 naming drifted files, no reseal", async (t) => {
308
+ const repo = await makeRepo(t);
309
+ const { configDir } = await makeEnv(t);
310
+ const file = await writeSpec(t, "ok.jsonc", baseSpec(repo));
311
+ registerGenome(configDir, file);
312
+ const manifestFile = path.join(configDir, "kernels", `${fingerprint16(loadGenomeSpecFile(file))}.json`);
313
+ const beforeManifest = await readFile(manifestFile, "utf8");
314
+ await writeFile(path.join(repo, "kernel.mjs"), "SABOTAGE\n", "utf8");
315
+ await expectExit(() => registerGenome(configDir, file), 1, "kernel.mjs");
316
+ // The manifest must be untouched — re-add never reseals.
317
+ assert.equal(await readFile(manifestFile, "utf8"), beforeManifest);
318
+ });
319
+ test("re-add with missing manifest (deleted behind our back) ⇒ refused, NEVER resealed", async (t) => {
320
+ const repo = await makeRepo(t);
321
+ const { configDir } = await makeEnv(t);
322
+ const file = await writeSpec(t, "ok.jsonc", baseSpec(repo));
323
+ const result = registerGenome(configDir, file);
324
+ await rm(path.join(configDir, "kernels", `${result.fingerprint}.json`));
325
+ await expectExit(() => registerGenome(configDir, file), 1, "refus");
326
+ assert.deepEqual(await readdir(path.join(configDir, "kernels")), []); // nothing resealed
327
+ });
328
+ // ------------------------------------------------- kernel: glob superset rule
329
+ test("new fingerprint, same repoPath, weakened globs ⇒ exit 1 naming the weakened glob", async (t) => {
330
+ const repo = await makeRepo(t);
331
+ const { configDir } = await makeEnv(t);
332
+ registerGenome(configDir, await writeSpec(t, "a.jsonc", baseSpec(repo)));
333
+ const weaker = baseSpec(repo);
334
+ weaker.kernel.immutableGlobs = ["kernel.mjs"];
335
+ const weakerFile = await writeSpec(t, "weaker.jsonc", weaker);
336
+ await expectExit(() => registerGenome(configDir, weakerFile), 1, "secrets/**");
337
+ const entries = await readRegistry(configDir);
338
+ assert.equal(entries.entries.length, 1); // weakened spec not written
339
+ });
340
+ test("new fingerprint, same repoPath, superset globs ⇒ registered", async (t) => {
341
+ const repo = await makeRepo(t);
342
+ const { configDir } = await makeEnv(t);
343
+ registerGenome(configDir, await writeSpec(t, "a.jsonc", baseSpec(repo)));
344
+ const wider = baseSpec(repo);
345
+ wider.label = "fixture-genome-wide";
346
+ wider.kernel.immutableGlobs = [
347
+ "kernel.mjs",
348
+ "secrets/**",
349
+ "README.md",
350
+ ];
351
+ const result = registerGenome(configDir, await writeSpec(t, "wider.jsonc", wider));
352
+ assert.equal(result.kind, "registered");
353
+ });
354
+ test("new fingerprint, different repoPath ⇒ superset rule does not apply", async (t) => {
355
+ const repoA = await makeRepo(t);
356
+ const repoB = await makeRepo(t);
357
+ const { configDir } = await makeEnv(t);
358
+ registerGenome(configDir, await writeSpec(t, "a.jsonc", baseSpec(repoA)));
359
+ const narrow = baseSpec(repoB);
360
+ narrow.label = "other-genome";
361
+ narrow.kernel.immutableGlobs = ["kernel.mjs"];
362
+ const result = registerGenome(configDir, await writeSpec(t, "narrow.jsonc", narrow));
363
+ assert.equal(result.kind, "registered");
364
+ });
365
+ // --------------------------------------------------------------- registry I/O
366
+ test("buildManifest/compareManifest/serializeManifest are deterministic", async (t) => {
367
+ const repo = await makeRepo(t);
368
+ const first = buildManifest(repo, ["kernel.mjs", "secrets/**"]);
369
+ const second = buildManifest(repo, ["secrets/**", "kernel.mjs"]); // glob order ≠ entry order
370
+ assert.deepEqual(serializeManifest(first), serializeManifest(second));
371
+ assert.deepEqual(compareManifest(first, second), []);
372
+ });
373
+ test("readRegistry: corrupt registry file degrades to a warning, never a crash", async (t) => {
374
+ const repo = await makeRepo(t);
375
+ const { configDir } = await makeEnv(t);
376
+ registerGenome(configDir, await writeSpec(t, "ok.jsonc", baseSpec(repo)));
377
+ const genomeFiles = (await readdir(path.join(configDir, "genomes"))).filter((f) => f.endsWith(".jsonc"));
378
+ assert.equal(genomeFiles.length, 1);
379
+ await writeFile(path.join(configDir, "genomes", "deadbeefdeadbeef.jsonc"), "{{{ nope", "utf8");
380
+ const scan = await readRegistry(configDir);
381
+ assert.equal(scan.entries.length, 1);
382
+ assert.equal(scan.warnings.length, 1);
383
+ assert.ok(scan.warnings[0]?.includes("deadbeefdeadbeef"));
384
+ });
385
+ test("requireGenomesByLabel: unknown label ⇒ exit 2 naming the label", async (t) => {
386
+ const { configDir } = await makeEnv(t);
387
+ await expectExit(() => requireGenomesByLabel(configDir, "ghost"), 2, "ghost");
388
+ });
389
+ // ------------------------------------------------------------------- CLI wiring
390
+ test("CLI: add → list → show → seals round-trip; kernel audit passes then blocks", async (t) => {
391
+ const repo = await makeRepo(t);
392
+ const { env } = await makeEnv(t);
393
+ const file = await writeSpec(t, "ok.jsonc", baseSpec(repo));
394
+ const add = abathur(env, "genome", "add", file);
395
+ assert.equal(add.status, 0, add.stderr);
396
+ assert.match(add.stdout, /fixture-genome/);
397
+ const fp = /\(([0-9a-f]{16})\)/.exec(add.stdout)?.[1];
398
+ assert.ok(fp !== undefined, `add output must print the fingerprint: ${add.stdout}`);
399
+ const list = abathur(env, "genome", "list");
400
+ assert.equal(list.status, 0, list.stderr);
401
+ assert.ok(list.stdout.includes("fixture-genome"));
402
+ assert.ok(list.stdout.includes(fp));
403
+ const show = abathur(env, "genome", "show", "fixture-genome");
404
+ assert.equal(show.status, 0, show.stderr);
405
+ const shown = JSON.parse(show.stdout.split("\n").filter((l) => !l.startsWith("#")).join("\n"));
406
+ assert.equal(shown.bench.stats.halfWidth, 0.05);
407
+ const seals = abathur(env, "genome", "seals", "fixture-genome");
408
+ assert.equal(seals.status, 0, seals.stderr);
409
+ assert.ok(seals.stdout.includes("kernel.mjs"));
410
+ assert.ok(seals.stdout.includes("secrets/**"));
411
+ const auditOk = abathur(env, "kernel", "audit", "fixture-genome");
412
+ assert.equal(auditOk.status, 0, auditOk.stderr);
413
+ await writeFile(path.join(repo, "kernel.mjs"), "TAMPERED\n", "utf8");
414
+ const auditBad = abathur(env, "kernel", "audit", "fixture-genome");
415
+ assert.equal(auditBad.status, 1);
416
+ assert.ok(`${auditBad.stdout}${auditBad.stderr}`.includes("kernel.mjs"));
417
+ const readd = abathur(env, "genome", "add", file);
418
+ assert.equal(readd.status, 1);
419
+ assert.ok(`${readd.stdout}${readd.stderr}`.includes("kernel.mjs"));
420
+ const unknown = abathur(env, "kernel", "audit", "never-registered");
421
+ assert.equal(unknown.status, 2);
422
+ });
423
+ test("CLI: absent glob / zero val / missing threshold / garbage spec all exit 2 without stacks", async (t) => {
424
+ const repo = await makeRepo(t);
425
+ const { env, configDir } = await makeEnv(t);
426
+ const ghost = baseSpec(repo);
427
+ ghost.kernel.immutableGlobs = ["ghosts/**"];
428
+ const badGlob = abathur(env, "genome", "add", await writeSpec(t, "g.jsonc", ghost));
429
+ assert.equal(badGlob.status, 2, badGlob.stdout + badGlob.stderr);
430
+ const noVal = baseSpec(repo);
431
+ noVal.bench.units = [
432
+ { id: "t1", path: "b/t1.json", split: "train" },
433
+ ];
434
+ const badVal = abathur(env, "genome", "add", await writeSpec(t, "v.jsonc", noVal));
435
+ assert.equal(badVal.status, 2, badVal.stdout + badVal.stderr);
436
+ const noThresh = baseSpec(repo);
437
+ delete noThresh.bench.stats
438
+ .halfWidth;
439
+ const badThresh = abathur(env, "genome", "add", await writeSpec(t, "h.jsonc", noThresh));
440
+ assert.equal(badThresh.status, 2, badThresh.stdout + badThresh.stderr);
441
+ assert.ok(`${badThresh.stdout}${badThresh.stderr}`.includes("bench.stats.halfWidth"));
442
+ const garbage = abathur(env, "genome", "add", await writeSpecText(t, "garbage.jsonc", "NOT JSON AT ALL {{{"));
443
+ assert.equal(garbage.status, 2);
444
+ const combined = `${garbage.stdout}${garbage.stderr}`;
445
+ assert.ok(!combined.includes("\n at "), `stack leaked into output: ${combined}`);
446
+ const entries = await readRegistry(configDir);
447
+ assert.equal(entries.entries.length, 0); // every rejection wrote nothing
448
+ });
449
+ test("CLI: genome rm stays pending until todo 10", async (t) => {
450
+ const { env } = await makeEnv(t);
451
+ const rm = abathur(env, "genome", "rm", "whatever");
452
+ assert.equal(rm.status, 2);
453
+ });
@@ -0,0 +1,69 @@
1
+ // src/util/git.ts acceptance pins (todo 3): array-argv execFile wrapper —
2
+ // timeout kill (fake binary proves the plumbing), error classification, tryGit.
3
+ import assert from "node:assert/strict";
4
+ import { mkdtemp, writeFile, chmod, rm } from "node:fs/promises";
5
+ import * as os from "node:os";
6
+ import path from "node:path";
7
+ import test from "node:test";
8
+ import { GitError, git, tryGit } from "../util/git.js";
9
+ async function tmp() {
10
+ return mkdtemp(path.join(os.tmpdir(), "abathur-git-test-"));
11
+ }
12
+ test("git(): returns stdout/stderr for a successful run", async (t) => {
13
+ const dir = await tmp();
14
+ t.after(() => rm(dir, { recursive: true, force: true }));
15
+ const r = await git(["init", "-q", "-b", "main", "."], { cwd: dir });
16
+ assert.equal(r.stdout, "");
17
+ const v = await git(["--version"], { cwd: dir });
18
+ assert.match(v.stdout, /^git version \d+\.\d+/);
19
+ });
20
+ test("git(): classifies a non-zero exit as kind=failed with code and stderr", async (t) => {
21
+ const dir = await tmp();
22
+ t.after(() => rm(dir, { recursive: true, force: true }));
23
+ await assert.rejects(git(["rev-parse", "--verify", "refs/heads/nope"], { cwd: dir }), (e) => {
24
+ assert.ok(e instanceof GitError);
25
+ assert.equal(e.kind, "failed");
26
+ assert.equal(typeof e.exitCode, "number");
27
+ assert.notEqual(e.exitCode, 0);
28
+ assert.ok(Array.isArray(e.args));
29
+ return true;
30
+ });
31
+ });
32
+ test("tryGit(): plain non-zero exit becomes ok:false, not a rejection", async (t) => {
33
+ const dir = await tmp();
34
+ t.after(() => rm(dir, { recursive: true, force: true }));
35
+ const r = await tryGit(["rev-parse", "--verify", "refs/heads/nope"], { cwd: dir });
36
+ assert.equal(r.ok, false);
37
+ if (!r.ok)
38
+ assert.equal(r.error.kind, "failed");
39
+ const ok = await tryGit(["--version"], { cwd: dir });
40
+ assert.equal(ok.ok, true);
41
+ if (ok.ok)
42
+ assert.match(ok.stdout, /^git version/);
43
+ });
44
+ test("git(): spawn failure is kind=spawn", async (t) => {
45
+ const dir = await tmp();
46
+ t.after(() => rm(dir, { recursive: true, force: true }));
47
+ await assert.rejects(git(["status"], { bin: path.join(dir, "no-such-git") }), (e) => {
48
+ assert.ok(e instanceof GitError);
49
+ assert.equal(e.kind, "spawn");
50
+ return true;
51
+ });
52
+ });
53
+ // Adversarial class hung/stuck-git: a fake binary that never exits must be
54
+ // killed at timeoutMs (execFile plumbing proof — no real network hang needed).
55
+ test("git(): kills a hung invocation at timeoutMs instead of blocking forever", async (t) => {
56
+ const dir = await tmp();
57
+ t.after(() => rm(dir, { recursive: true, force: true }));
58
+ const fake = path.join(dir, "hang-git");
59
+ await writeFile(fake, "#!/bin/sh\nexec sleep 30\n", { mode: 0o755 });
60
+ await chmod(fake, 0o755);
61
+ const started = Date.now();
62
+ await assert.rejects(git(["whatever"], { bin: fake, timeoutMs: 300 }), (e) => {
63
+ assert.ok(e instanceof GitError);
64
+ assert.equal(e.kind, "timeout");
65
+ assert.match(e.message, /timed out/);
66
+ return true;
67
+ });
68
+ assert.ok(Date.now() - started < 5000, "hung git must be reaped quickly");
69
+ });