@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.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +74 -0
- package/LICENSE +21 -0
- package/README.md +461 -0
- package/config/abathur.jsonc +17 -0
- package/config/genomes/historian.example.jsonc +124 -0
- package/dist/bench/adapter.js +201 -0
- package/dist/bench/fixture-probe.js +92 -0
- package/dist/bench/fixture-support.js +173 -0
- package/dist/bench/fixture.js +236 -0
- package/dist/bench/toy.js +152 -0
- package/dist/cli.js +110 -0
- package/dist/commands/bundle.js +79 -0
- package/dist/commands/genome.js +94 -0
- package/dist/commands/graft.js +71 -0
- package/dist/commands/kernel.js +47 -0
- package/dist/commands/promote.js +25 -0
- package/dist/commands/run.js +145 -0
- package/dist/commands/self-eval.js +240 -0
- package/dist/commands/status.js +186 -0
- package/dist/commands/tombstone.js +72 -0
- package/dist/config.js +161 -0
- package/dist/core/bundle-common.js +119 -0
- package/dist/core/bundle-export.js +212 -0
- package/dist/core/bundle-inspect.js +143 -0
- package/dist/core/bundle-manifest.js +105 -0
- package/dist/core/bundle-mask.js +75 -0
- package/dist/core/bundle-tar.js +240 -0
- package/dist/core/bundle.js +9 -0
- package/dist/core/evolve/brief.js +45 -0
- package/dist/core/evolve/candidate.js +140 -0
- package/dist/core/evolve/child-track.js +197 -0
- package/dist/core/evolve/friction.js +150 -0
- package/dist/core/evolve/reflect.js +191 -0
- package/dist/core/evolve/run-bench.js +170 -0
- package/dist/core/evolve/run-friction.js +63 -0
- package/dist/core/evolve/run-loop.js +282 -0
- package/dist/core/evolve/run-plan.js +39 -0
- package/dist/core/evolve/run-rows.js +145 -0
- package/dist/core/evolve/self-overlay.js +213 -0
- package/dist/core/evolve/self-snapshot.js +170 -0
- package/dist/core/evolve/stub-mutators.mjs +105 -0
- package/dist/core/evolve/udiff.js +189 -0
- package/dist/core/genome-paths.js +76 -0
- package/dist/core/genome.js +176 -0
- package/dist/core/glob.js +106 -0
- package/dist/core/graft-gates.js +184 -0
- package/dist/core/graft-rebench.js +187 -0
- package/dist/core/graft-support.js +181 -0
- package/dist/core/graft.js +218 -0
- package/dist/core/ids.js +154 -0
- package/dist/core/incumbent.js +46 -0
- package/dist/core/kernel.js +112 -0
- package/dist/core/ledger.js +198 -0
- package/dist/core/locks.js +172 -0
- package/dist/core/promote.js +119 -0
- package/dist/core/snapshot.js +61 -0
- package/dist/core/spec.js +178 -0
- package/dist/core/stats-math.js +102 -0
- package/dist/core/stats-pareto.js +57 -0
- package/dist/core/stats.js +184 -0
- package/dist/core/worktree.js +190 -0
- package/dist/exit.js +32 -0
- package/dist/genomes/toy-smoke/genome.jsonc +30 -0
- package/dist/genomes/toy-smoke/grader.mjs +61 -0
- package/dist/genomes/toy-smoke/init.mjs +63 -0
- package/dist/genomes/toy-smoke/units/add.mjs +17 -0
- package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
- package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
- package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
- package/dist/jsonc.js +77 -0
- package/dist/out.js +5 -0
- package/dist/test/bench-adapter.test.js +33 -0
- package/dist/test/bench-fixture.test.js +407 -0
- package/dist/test/bench-toy.test.js +251 -0
- package/dist/test/bundle.test.js +659 -0
- package/dist/test/config.test.js +185 -0
- package/dist/test/d7-gate.test.js +56 -0
- package/dist/test/fixture-loop.test.js +267 -0
- package/dist/test/fixtures/friction-writer.js +16 -0
- package/dist/test/fixtures-historian.js +82 -0
- package/dist/test/fixtures-self.js +143 -0
- package/dist/test/fixtures-wt.js +64 -0
- package/dist/test/friction.test.js +398 -0
- package/dist/test/genome.test.js +453 -0
- package/dist/test/git.test.js +69 -0
- package/dist/test/graft.test.js +567 -0
- package/dist/test/historian-genome.test.js +134 -0
- package/dist/test/historian-grader-io.test.js +148 -0
- package/dist/test/historian-grader.test.js +209 -0
- package/dist/test/ids.test.js +116 -0
- package/dist/test/include-val.test.js +120 -0
- package/dist/test/ledger-lock.test.js +99 -0
- package/dist/test/ledger.test.js +102 -0
- package/dist/test/promote.test.js +394 -0
- package/dist/test/reflect.test.js +410 -0
- package/dist/test/run-loop.test.js +433 -0
- package/dist/test/self-snapshot.test.js +328 -0
- package/dist/test/snapshot.test.js +86 -0
- package/dist/test/stats.test.js +423 -0
- package/dist/test/stub-mutators.test.js +17 -0
- package/dist/test/testutil.js +30 -0
- package/dist/test/worktree.test.js +198 -0
- package/dist/util/freeze.js +30 -0
- package/dist/util/git.js +85 -0
- package/docs/federation.md +184 -0
- package/docs/immutable-kernel.md +87 -0
- package/graders/historian/grader-core.d.mts +53 -0
- package/graders/historian/grader-core.mjs +276 -0
- package/graders/historian/grader-support.d.mts +57 -0
- package/graders/historian/grader-support.mjs +137 -0
- package/graders/historian/grader.mjs +113 -0
- package/graders/historian/mutate.sh +114 -0
- package/graders/historian/reset-sandbox.sh +60 -0
- package/graders/historian/run-scenario.sh +49 -0
- package/graders/historian/seed-wrapped.sh +32 -0
- 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
|
+
});
|