@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,134 @@
|
|
|
1
|
+
// Task-14: the historian genome instance + the additive dry-run requires-probe.
|
|
2
|
+
// Everything here stays offline: fake repos in tmp dirs, `test -f` probes only.
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { loadGenomeSpecFile } from "../core/spec.js";
|
|
11
|
+
import { registerGenome, requireGenomesByLabel } from "../core/genome.js";
|
|
12
|
+
import { runEvolution } from "../core/evolve/run-loop.js";
|
|
13
|
+
import { ExitSignal } from "../exit.js";
|
|
14
|
+
const exec = promisify(execFile);
|
|
15
|
+
const EXAMPLE = path.join(import.meta.dirname, "..", "..", "config", "genomes", "historian.example.jsonc");
|
|
16
|
+
/** Replace every ${NAME} with a deterministic fake value (materialization stand-in). */
|
|
17
|
+
function materialize(text) {
|
|
18
|
+
return text.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_all, name) => {
|
|
19
|
+
if (name.endsWith("WIKI_BASE"))
|
|
20
|
+
return "http://wiki.invalid:3000";
|
|
21
|
+
if (name.endsWith("WIKI_OPS"))
|
|
22
|
+
return "/fake/opt/wiki-ops/wiki-ops.py";
|
|
23
|
+
if (name.includes("REPO"))
|
|
24
|
+
return "/fake/repo";
|
|
25
|
+
return "/fake/" + name.toLowerCase();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function materializeToFile() {
|
|
29
|
+
const out = path.join(mkdtempSync(path.join(tmpdir(), "t14-gen-")), "historian.jsonc");
|
|
30
|
+
writeFileSync(out, materialize(readFileSync(EXAMPLE, "utf8")), "utf8");
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
test("historian.example.jsonc: zero machine literals, placeholders only", () => {
|
|
34
|
+
const text = readFileSync(EXAMPLE, "utf8");
|
|
35
|
+
assert.doesNotMatch(text, /\/home\//, "repo-tracked config must not embed /home/... literals");
|
|
36
|
+
assert.match(text, /\$\{ABATHUR_HISTORIAN_REPO\}/);
|
|
37
|
+
assert.match(text, /\$\{ABATHUR_WIKI_BASE\}/);
|
|
38
|
+
});
|
|
39
|
+
test("historian.example.jsonc materializes into a valid GenomeSpec (plan 181-188 contract)", () => {
|
|
40
|
+
const spec = loadGenomeSpecFile(materializeToFile());
|
|
41
|
+
assert.equal(spec.label, "historian");
|
|
42
|
+
assert.equal(spec.bench.type, "opencode-fixture-scenarios");
|
|
43
|
+
assert.equal(spec.bench.units.length, 9);
|
|
44
|
+
assert.deepEqual(spec.bench.units.map((u) => u.id), [
|
|
45
|
+
"scenario-01", "scenario-02", "scenario-03", "scenario-04", "scenario-05",
|
|
46
|
+
"scenario-06", "scenario-07", "scenario-08", "scenario-09",
|
|
47
|
+
]);
|
|
48
|
+
const train = spec.bench.units.filter((u) => u.split === "train").map((u) => u.id);
|
|
49
|
+
const val = spec.bench.units.filter((u) => u.split === "val").map((u) => u.id);
|
|
50
|
+
assert.deepEqual(train, ["scenario-01", "scenario-02", "scenario-03", "scenario-05", "scenario-06", "scenario-07", "scenario-08"]);
|
|
51
|
+
assert.deepEqual(val, ["scenario-04", "scenario-09"]);
|
|
52
|
+
// unit paths are repo-RELATIVE: bundle self-description digests tree content (bundle-common.ts:97).
|
|
53
|
+
assert.equal(spec.bench.units[0]?.path, "scenarios/01-new-finding.md");
|
|
54
|
+
assert.equal(spec.bench.units[8]?.path, "scenarios/09-timeline-week-groups.md");
|
|
55
|
+
assert.deepEqual(spec.bench.stats, { halfWidth: 0.15, minEffect: 0.1, nReps: { initial: 1, max: 3 } });
|
|
56
|
+
assert.ok(spec.bench.timeoutS >= 600, "real agent runs need a generous per-unit cap");
|
|
57
|
+
assert.ok(spec.bench.agentModel !== undefined && spec.bench.agentModel.includes("/"));
|
|
58
|
+
assert.match(spec.bench.seedCommand ?? "", /seed_sandbox\.sh/);
|
|
59
|
+
assert.match(spec.bench.resetCommand ?? "", /reset-sandbox\.sh/);
|
|
60
|
+
assert.match(spec.bench.runCommand, /run-scenario\.sh/);
|
|
61
|
+
assert.match(spec.bench.graderCommand, /graders\/historian\/grader\.mjs/);
|
|
62
|
+
assert.equal(spec.bench.judgeCommand, undefined); // unwired: judgeModel rule stays vacuous
|
|
63
|
+
assert.ok(spec.requires !== undefined && spec.requires.length === 2);
|
|
64
|
+
assert.ok(spec.kernel.immutableGlobs.includes("scenarios/**"));
|
|
65
|
+
assert.ok(spec.kernel.immutableGlobs.includes("rubric.md"));
|
|
66
|
+
assert.ok(spec.budget.maxCandidates >= 1);
|
|
67
|
+
});
|
|
68
|
+
// ------------------------------------------------- dry-run requires probes
|
|
69
|
+
async function fixtureRepo(files) {
|
|
70
|
+
const dir = mkdtempSync(path.join(tmpdir(), "t14-fr-"));
|
|
71
|
+
for (const [name, text] of Object.entries(files)) {
|
|
72
|
+
mkdirSync(path.join(dir, path.dirname(name)), { recursive: true });
|
|
73
|
+
writeFileSync(path.join(dir, name), text, "utf8");
|
|
74
|
+
}
|
|
75
|
+
await exec("git", ["init", "-b", "main", dir]);
|
|
76
|
+
await exec("git", ["-C", dir, "add", "-A"]);
|
|
77
|
+
await exec("git", [
|
|
78
|
+
"-C", dir,
|
|
79
|
+
"-c", "user.name=t", "-c", "user.email=t@t",
|
|
80
|
+
"commit", "-m", "seed",
|
|
81
|
+
]);
|
|
82
|
+
return dir;
|
|
83
|
+
}
|
|
84
|
+
function fixtureSpec(repo, label, probeFile, opts = {}) {
|
|
85
|
+
const type = opts.type ?? "opencode-fixture-scenarios";
|
|
86
|
+
const doc = {
|
|
87
|
+
label,
|
|
88
|
+
repoPath: repo,
|
|
89
|
+
bench: {
|
|
90
|
+
type,
|
|
91
|
+
units: [
|
|
92
|
+
{ id: "u1", path: "units/u1.md", split: "train" },
|
|
93
|
+
{ id: "u2", path: "units/u2.md", split: "val" },
|
|
94
|
+
],
|
|
95
|
+
runCommand: "true {unit.id}",
|
|
96
|
+
graderCommand: "true {unit.id}",
|
|
97
|
+
...(type === "opencode-fixture-scenarios" ? { agentModel: "family/model" } : {}),
|
|
98
|
+
timeoutS: 600,
|
|
99
|
+
stats: { halfWidth: 0.15, minEffect: 0.1, nReps: { initial: 1, max: 3 } },
|
|
100
|
+
},
|
|
101
|
+
budget: { maxCandidates: 1, maxModelCalls: 4, maxTokens: 1000, maxWallS: 1000 },
|
|
102
|
+
kernel: { immutableGlobs: ["sealed.txt"] },
|
|
103
|
+
};
|
|
104
|
+
if (opts.withRequires ?? true)
|
|
105
|
+
doc["requires"] = [{ cmd: "test", args: ["-f", probeFile], probeExit: 0 }];
|
|
106
|
+
return doc;
|
|
107
|
+
}
|
|
108
|
+
async function dryRun(specDoc) {
|
|
109
|
+
const configDir = mkdtempSync(path.join(tmpdir(), "t14-cfg-"));
|
|
110
|
+
const specFile = path.join(configDir, "spec.jsonc");
|
|
111
|
+
writeFileSync(specFile, JSON.stringify(specDoc, null, 2), "utf8");
|
|
112
|
+
const label = String(specDoc.label);
|
|
113
|
+
registerGenome(configDir, specFile);
|
|
114
|
+
const entry = requireGenomesByLabel(configDir, label).entries[0];
|
|
115
|
+
if (entry === undefined)
|
|
116
|
+
throw new Error(`entry ${label} missing after register`);
|
|
117
|
+
return runEvolution({ entry, configDir, dryRun: true });
|
|
118
|
+
}
|
|
119
|
+
test("dry-run (fixture type): requires probes run WITHOUT spawning opencode and print OK", async () => {
|
|
120
|
+
const repo = await fixtureRepo({ "sealed.txt": "seal\n", "units/u1.md": "a\n", "units/u2.md": "b\n" });
|
|
121
|
+
const out = await dryRun(fixtureSpec(repo, "probe-ok", "sealed.txt"));
|
|
122
|
+
assert.equal(out.exitCode, 0);
|
|
123
|
+
assert.ok(out.lines.some((l) => /requires probes: 1\/1 OK/.test(l)), out.lines.join("\n"));
|
|
124
|
+
});
|
|
125
|
+
test("dry-run (fixture type): failing requires probe exits 2 naming the prerequisite, before any plan", async () => {
|
|
126
|
+
const repo = await fixtureRepo({ "sealed.txt": "seal\n", "units/u1.md": "a\n", "units/u2.md": "b\n" });
|
|
127
|
+
await assert.rejects(() => dryRun(fixtureSpec(repo, "probe-bad", "missing-file.txt")), (e) => e instanceof ExitSignal && e.code === 2 && /missing-file\.txt/.test(e.message));
|
|
128
|
+
});
|
|
129
|
+
test("dry-run (toy type, no requires): no probe line", async () => {
|
|
130
|
+
const repo = await fixtureRepo({ "sealed.txt": "seal\n", "units/u1.md": "a\n", "units/u2.md": "b\n" });
|
|
131
|
+
const out = await dryRun(fixtureSpec(repo, "toy-nodefault", "sealed.txt", { type: "toy", withRequires: false }));
|
|
132
|
+
assert.equal(out.exitCode, 0);
|
|
133
|
+
assert.ok(!out.lines.some((l) => /requires probes/.test(l)), out.lines.join("\n"));
|
|
134
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Task-14 IO tests: transcript parsing (recorded opencode --format json events),
|
|
2
|
+
// wiki pre/post diffing, and the grader CLI contract — all OFFLINE
|
|
3
|
+
// (ABATHUR_GRADER_STATE replaces every wiki/URL call; no live network).
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { diffWiki, parseTranscript, scenarioNoFromUnit } from "../../graders/historian/grader-support.mjs";
|
|
12
|
+
import { FIXTURE5_PATHS, GOOD_FINAL, GOOD_PAGE } from "./fixtures-historian.js";
|
|
13
|
+
const GRADER = fileURLToPath(new URL("../../graders/historian/grader.mjs", import.meta.url));
|
|
14
|
+
// Recorded from a real `opencode run --format json "say exactly OK"` smoke run (2026-09-10).
|
|
15
|
+
const RECORDED_TRANSCRIPT = [
|
|
16
|
+
'{"type":"step_start","part":{"type":"step-start"}}',
|
|
17
|
+
'{"type":"text","part":{"type":"text","text":"OK"}}',
|
|
18
|
+
'{"type":"step_finish","part":{"type":"step-finish","reason":"stop","tokens":{"total":34849,"input":6,"output":69,"reasoning":0,"cache":{"write":34774,"read":0}}}}',
|
|
19
|
+
].join("\n");
|
|
20
|
+
function transcript(text, total = 1234) {
|
|
21
|
+
return [
|
|
22
|
+
'{"type":"step_start","part":{"type":"step-start"}}',
|
|
23
|
+
JSON.stringify({ type: "text", part: { type: "text", text } }),
|
|
24
|
+
`{"type":"step_finish","part":{"type":"step-finish","reason":"stop","tokens":{"total":${total},"input":1,"output":2}}}`,
|
|
25
|
+
].join("\n");
|
|
26
|
+
}
|
|
27
|
+
test("parseTranscript: recorded smoke events ⇒ finalMessage/tokensEst/turns", () => {
|
|
28
|
+
const m = parseTranscript(RECORDED_TRANSCRIPT);
|
|
29
|
+
assert.equal(m.finalMessage, "OK");
|
|
30
|
+
assert.equal(m.tokensEst, 34849);
|
|
31
|
+
assert.equal(m.turns, 1);
|
|
32
|
+
});
|
|
33
|
+
test("parseTranscript: multi-turn sums step_finish totals; final message is the LAST text", () => {
|
|
34
|
+
const multi = [
|
|
35
|
+
'{"type":"text","part":{"type":"text","text":"working..."}}',
|
|
36
|
+
'{"type":"step_finish","part":{"tokens":{"total":1000}}}',
|
|
37
|
+
'{"type":"text","part":{"type":"text","text":"done now"}}',
|
|
38
|
+
'{"type":"step_finish","part":{"tokens":{"total":500}}}',
|
|
39
|
+
].join("\n");
|
|
40
|
+
const m = parseTranscript(multi);
|
|
41
|
+
assert.equal(m.finalMessage, "done now");
|
|
42
|
+
assert.equal(m.tokensEst, 1500);
|
|
43
|
+
assert.equal(m.turns, 2);
|
|
44
|
+
});
|
|
45
|
+
test("parseTranscript: garbage / empty / never-finished ⇒ throws (CLI exits nonzero ⇒ adapter inconclusive)", () => {
|
|
46
|
+
assert.throws(() => parseTranscript("not json\n{broken"));
|
|
47
|
+
assert.throws(() => parseTranscript(""));
|
|
48
|
+
assert.throws(() => parseTranscript('{"type":"step_start","part":{}}')); // no step_finish ⇒ run never completed
|
|
49
|
+
});
|
|
50
|
+
test("scenarioNoFromUnit: scenario-07 ⇒ 7; bare 07 ⇒ 7", () => {
|
|
51
|
+
assert.equal(scenarioNoFromUnit("scenario-07"), 7);
|
|
52
|
+
assert.equal(scenarioNoFromUnit("07"), 7);
|
|
53
|
+
});
|
|
54
|
+
function row(id, p, updatedAt, locale = "en") {
|
|
55
|
+
return { id, path: p, locale, updatedAt };
|
|
56
|
+
}
|
|
57
|
+
const PRE = [
|
|
58
|
+
...FIXTURE5_PATHS.map((p, i) => row(400 + i, p, "2026-09-10T00:00:00Z")),
|
|
59
|
+
row(4, "infra/network", "2026-09-01T00:00:00Z"),
|
|
60
|
+
];
|
|
61
|
+
test("diffWiki: new sandbox page ⇒ created; index updatedAt bump ⇒ indexUpdated; untouched fixture ⇒ nothing", () => {
|
|
62
|
+
const post = [
|
|
63
|
+
...PRE.filter((r) => r.path !== "_sandbox/index"),
|
|
64
|
+
row(400, "_sandbox/index", "2026-09-10T02:00:00Z"),
|
|
65
|
+
row(600, "_sandbox/llm-inference/qwen27b-threading", "2026-09-10T02:05:00Z"),
|
|
66
|
+
];
|
|
67
|
+
const d = diffWiki({ pre: [...PRE], post, content: { 400: "idx text", 600: GOOD_PAGE }, scenarioNo: 1 });
|
|
68
|
+
assert.equal(d.created.length, 1);
|
|
69
|
+
assert.equal(d.created[0]?.path, "_sandbox/llm-inference/qwen27b-threading");
|
|
70
|
+
assert.equal(d.indexUpdated, true);
|
|
71
|
+
assert.equal(d.indexContent, "idx text");
|
|
72
|
+
assert.deepEqual(d.outside, { created: [], updated: [], deleted: [] });
|
|
73
|
+
assert.deepEqual(d.deletedFixturePaths, []);
|
|
74
|
+
});
|
|
75
|
+
test("diffWiki: move (same id, new path) is moved-not-created-not-deleted", () => {
|
|
76
|
+
const post = PRE.map((r) => (r.path === "_sandbox/mess/gpu-notes" ? { ...r, path: "_sandbox/runbooks/gpu-fan-curve" } : r));
|
|
77
|
+
const d = diffWiki({ pre: [...PRE], post, content: {}, scenarioNo: 4 });
|
|
78
|
+
assert.deepEqual(d.moved, [{ from: "_sandbox/mess/gpu-notes", to: "_sandbox/runbooks/gpu-fan-curve" }]);
|
|
79
|
+
assert.equal(d.created.length, 0);
|
|
80
|
+
assert.deepEqual(d.deletedFixturePaths, []);
|
|
81
|
+
});
|
|
82
|
+
test("diffWiki: hard delete ⇒ deletedFixturePaths; non-sandbox row gone ⇒ outside.deleted; edited non-sandbox ⇒ outside.updated", () => {
|
|
83
|
+
const post = PRE.filter((r) => r.path !== "_sandbox/mess/untitled").map((r) => r.path === "infra/network" ? { ...r, updatedAt: "2026-09-10T03:00:00Z" } : r);
|
|
84
|
+
const d = diffWiki({ pre: [...PRE], post, content: {}, scenarioNo: 1 });
|
|
85
|
+
assert.deepEqual(d.deletedFixturePaths, ["_sandbox/mess/untitled"]);
|
|
86
|
+
assert.deepEqual(d.outside, { created: [], updated: ["infra/network"], deleted: [] });
|
|
87
|
+
});
|
|
88
|
+
test("diffWiki: scenario 09 whitelists _meta/page-map refresh as the only outside update", () => {
|
|
89
|
+
const pre = [...PRE, row(700, "_meta/page-map", "2026-09-01T00:00:00Z")];
|
|
90
|
+
const post = [...PRE, row(700, "_meta/page-map", "2026-09-10T04:00:00Z")];
|
|
91
|
+
const d = diffWiki({ pre, post, content: {}, scenarioNo: 9 });
|
|
92
|
+
assert.deepEqual(d.outside.updated, []);
|
|
93
|
+
});
|
|
94
|
+
// ------------------------------------------------------------------ CLI
|
|
95
|
+
function sandboxWith(transcriptText, pre) {
|
|
96
|
+
const dir = mkdtempSync(path.join(tmpdir(), "t14-gr-"));
|
|
97
|
+
mkdirSync(path.join(dir, ".bench", "transcripts"), { recursive: true });
|
|
98
|
+
writeFileSync(path.join(dir, ".bench", "transcripts", "scenario-01.jsonl"), transcriptText);
|
|
99
|
+
writeFileSync(path.join(dir, ".bench", "wiki-pre.json"), JSON.stringify(pre));
|
|
100
|
+
return dir;
|
|
101
|
+
}
|
|
102
|
+
function runGrader(cwd, argv, env) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
execFile(process.execPath, [GRADER, ...argv], { cwd, env: { ...process.env, ...env } }, (err, stdout, stderr) => resolve({ code: err === null ? 0 : typeof err.code === "number" ? err.code : 1, stdout, stderr }));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const POST_OK = [
|
|
108
|
+
...PRE.filter((r) => r.path !== "_sandbox/index"),
|
|
109
|
+
row(400, "_sandbox/index", "2026-09-10T02:00:00Z"),
|
|
110
|
+
{ id: 600, path: "_sandbox/llm-inference/qwen27b-threading", locale: "en", updatedAt: "2026-09-10T02:05:00Z", title: "Qwen 27B Threading Findings" },
|
|
111
|
+
];
|
|
112
|
+
test("grader CLI (offline, passing scenario-01): emits contract JSON line score=1 pass=true", async () => {
|
|
113
|
+
const dir = sandboxWith(transcript(GOOD_FINAL, 5678), PRE);
|
|
114
|
+
const stateFile = path.join(dir, "state.json");
|
|
115
|
+
writeFileSync(stateFile, JSON.stringify({ post: POST_OK, content: { 400: "[qwen27b](/_sandbox/llm-inference/qwen27b-threading)", 600: GOOD_PAGE }, urlStatus: {} }));
|
|
116
|
+
const r = await runGrader(dir, ["scenario-01", "scenarios/01-new-finding.md", "http://localhost:3000"], {
|
|
117
|
+
ABATHUR_GRADER_STATE: stateFile,
|
|
118
|
+
});
|
|
119
|
+
assert.equal(r.code, 0, r.stderr);
|
|
120
|
+
const line = r.stdout.trim().split("\n").at(-1) ?? "";
|
|
121
|
+
const parsed = JSON.parse(line);
|
|
122
|
+
assert.equal(parsed.unit, "scenario-01");
|
|
123
|
+
assert.equal(parsed.score, 1);
|
|
124
|
+
assert.equal(parsed.pass, true);
|
|
125
|
+
assert.equal(parsed.metrics.tokensEst, 5678);
|
|
126
|
+
assert.equal(parsed.metrics.turns, 1);
|
|
127
|
+
});
|
|
128
|
+
test("grader CLI (offline, G-gated): outside-sandbox edit ⇒ pass=false despite Σ=11, score=11/12", async () => {
|
|
129
|
+
const dir = sandboxWith(transcript(GOOD_FINAL), PRE);
|
|
130
|
+
const post = POST_OK.map((r) => (r.path === "infra/network" ? { ...r, updatedAt: "2026-09-10T09:00:00Z" } : r));
|
|
131
|
+
const stateFile = path.join(dir, "state.json");
|
|
132
|
+
writeFileSync(stateFile, JSON.stringify({ post, content: { 400: "[qwen27b](/_sandbox/llm-inference/qwen27b-threading)", 600: GOOD_PAGE }, urlStatus: {} }));
|
|
133
|
+
const r = await runGrader(dir, ["scenario-01", "scenarios/01-new-finding.md", "http://localhost:3000"], {
|
|
134
|
+
ABATHUR_GRADER_STATE: stateFile,
|
|
135
|
+
});
|
|
136
|
+
assert.equal(r.code, 0, r.stderr);
|
|
137
|
+
const parsed = JSON.parse(r.stdout.trim().split("\n").at(-1) ?? "{}");
|
|
138
|
+
assert.equal(parsed.pass, false);
|
|
139
|
+
assert.equal(parsed.score, 11 / 12);
|
|
140
|
+
});
|
|
141
|
+
test("grader CLI: garbage transcript ⇒ nonzero exit, no score line (adapter ⇒ inconclusive, never 0-score)", async () => {
|
|
142
|
+
const dir = sandboxWith("not json at all\n{broken", PRE);
|
|
143
|
+
const r = await runGrader(dir, ["scenario-01", "scenarios/01-new-finding.md", "http://localhost:3000"], {
|
|
144
|
+
ABATHUR_GRADER_STATE: undefined,
|
|
145
|
+
});
|
|
146
|
+
assert.notEqual(r.code, 0);
|
|
147
|
+
assert.equal(r.stdout.trim(), "");
|
|
148
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Task-14 RED/GREEN: pure scoring math of the historian grader (plan 185).
|
|
2
|
+
// Arithmetic shown inline: weights A2 B2 C2 D2 E1 F1 G1 H1 = 12 total.
|
|
3
|
+
// Full scenario: score = Σ(w×d)/12, pass = Σ ≥ 10 ∧ G=1 (G=0 ⇒ hard fail).
|
|
4
|
+
// Scenario 05: dims A-F N/A; score = (G+H+judgment)/3, pass = G ∧ H ∧ judgment.
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { computeDims, judgment, scoreFromDims, scoreUnit, WEIGHTS, } from "../../graders/historian/grader-core.mjs";
|
|
8
|
+
import { GOOD_FINAL, GOOD_INCIDENT_PAGE, GOOD_PAGE, FIXTURE5_PATHS, goodCreated, obs } from "./fixtures-historian.js";
|
|
9
|
+
function full(all) {
|
|
10
|
+
return { A: all, B: all, C: all, D: all, E: all, F: all, G: all, H: all };
|
|
11
|
+
}
|
|
12
|
+
test("weights table sums to exactly 12 (A2+B2+C2+D2=8, E1+F1+G1+H1=4)", () => {
|
|
13
|
+
const sum = Object.values(WEIGHTS).reduce((a, b) => a + b, 0);
|
|
14
|
+
assert.equal(sum, 12);
|
|
15
|
+
assert.deepEqual({ ...WEIGHTS }, { A: 2, B: 2, C: 2, D: 2, E: 1, F: 1, G: 1, H: 1 });
|
|
16
|
+
});
|
|
17
|
+
test("scoreFromDims full scenario all-1: Σ=12, score=12/12=1, pass", () => {
|
|
18
|
+
const r = scoreFromDims(1, full(1));
|
|
19
|
+
assert.equal(r.total, 12); // 2+2+2+2+1+1+1+1
|
|
20
|
+
assert.equal(r.applicableWeight, 12);
|
|
21
|
+
assert.equal(r.score, 1); // 12/12
|
|
22
|
+
assert.equal(r.pass, true);
|
|
23
|
+
});
|
|
24
|
+
test("scoreFromDims G hard gate: Σ=11 ≥ 10 but G=0 ⇒ pass=false, score=11/12", () => {
|
|
25
|
+
// 12 total − G(1×0) = 11; 11/12 exactly, no rounding contract.
|
|
26
|
+
const r = scoreFromDims(1, { ...full(1), G: 0 });
|
|
27
|
+
assert.equal(r.total, 11);
|
|
28
|
+
assert.equal(r.score, 11 / 12);
|
|
29
|
+
assert.equal(r.pass, false);
|
|
30
|
+
});
|
|
31
|
+
test("scoreFromDims below floor: C=0,D=0 ⇒ Σ=12−2−2=8 < 10 ⇒ fail, score=8/12", () => {
|
|
32
|
+
const r = scoreFromDims(2, { ...full(1), C: 0, D: 0 });
|
|
33
|
+
assert.equal(r.total, 8);
|
|
34
|
+
assert.equal(r.score, 8 / 12);
|
|
35
|
+
assert.equal(r.pass, false);
|
|
36
|
+
});
|
|
37
|
+
test("scoreFromDims scenario 05: (G,H,J) triples score over applicable weight 3", () => {
|
|
38
|
+
const pass = scoreFromDims(5, { G: 1, H: 1, J: 1 });
|
|
39
|
+
assert.equal(pass.score, 1); // (1+1+1)/3
|
|
40
|
+
assert.equal(pass.applicableWeight, 3);
|
|
41
|
+
assert.equal(pass.pass, true);
|
|
42
|
+
const noJudge = scoreFromDims(5, { G: 1, H: 1, J: 0 });
|
|
43
|
+
assert.equal(noJudge.score, 2 / 3); // (1+1+0)/3
|
|
44
|
+
assert.equal(noJudge.pass, false);
|
|
45
|
+
const noG = scoreFromDims(5, { G: 0, H: 1, J: 1 });
|
|
46
|
+
assert.equal(noG.score, 2 / 3); // (0+1+1)/3
|
|
47
|
+
assert.equal(noG.pass, false); // G=0 hard gate
|
|
48
|
+
});
|
|
49
|
+
test("computeDims: clean scenario-01 observation ⇒ all dims 1 ⇒ scoreUnit passes", () => {
|
|
50
|
+
const dims = computeDims(obs());
|
|
51
|
+
assert.deepEqual(dims, full(1));
|
|
52
|
+
const r = scoreUnit(obs());
|
|
53
|
+
assert.equal(r.score, 1);
|
|
54
|
+
assert.equal(r.pass, true);
|
|
55
|
+
});
|
|
56
|
+
test("computeDims D: raw float 47.3829104823 in created page ⇒ D=0", () => {
|
|
57
|
+
const dirty = GOOD_PAGE.replace("47.4", "47.3829104823");
|
|
58
|
+
const dims = computeDims(obs({ created: [goodCreated({ content: dirty })] }));
|
|
59
|
+
assert.equal(dims.D, 0);
|
|
60
|
+
});
|
|
61
|
+
test("computeDims A: root-level page (_sandbox/depth-1) fails non-08 scenarios", () => {
|
|
62
|
+
const dims = computeDims(obs({ created: [goodCreated({ path: "_sandbox/tuning-notes" })] }));
|
|
63
|
+
assert.equal(dims.A, 0);
|
|
64
|
+
});
|
|
65
|
+
test("computeDims A: incident date slug allowed for scenario 02/06, rejected for 01", () => {
|
|
66
|
+
const ok = computeDims(obs({
|
|
67
|
+
scenarioNo: 2,
|
|
68
|
+
created: [
|
|
69
|
+
goodCreated({
|
|
70
|
+
path: "_sandbox/troubleshooting/outage-2026-08-28",
|
|
71
|
+
title: "Wiki ES OOM 2026-08-28",
|
|
72
|
+
content: GOOD_INCIDENT_PAGE,
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
75
|
+
livePaths: [...FIXTURE5_PATHS, "_sandbox/troubleshooting/outage-2026-08-28"],
|
|
76
|
+
allPaths: [...FIXTURE5_PATHS, "_sandbox/troubleshooting/outage-2026-08-28"],
|
|
77
|
+
indexContent: "[outage](/_sandbox/troubleshooting/outage-2026-08-28) (Active)",
|
|
78
|
+
}));
|
|
79
|
+
assert.equal(ok.A, 1);
|
|
80
|
+
const bad = computeDims(obs({ created: [goodCreated({ path: "_sandbox/llm-inference/run-2026-08-28" })] }));
|
|
81
|
+
assert.equal(bad.A, 0);
|
|
82
|
+
});
|
|
83
|
+
test("computeDims B: scenario 03 integrates ⇒ B=1 only when rocm-tuning was updated; new rocm twin ⇒ B=0", () => {
|
|
84
|
+
const integrated = computeDims(obs({
|
|
85
|
+
scenarioNo: 3,
|
|
86
|
+
created: [],
|
|
87
|
+
updated: [{ path: "_sandbox/llm-inference/rocm-tuning", locale: "en", title: "ROCm Tuning", content: GOOD_PAGE }],
|
|
88
|
+
}));
|
|
89
|
+
assert.equal(integrated.B, 1);
|
|
90
|
+
const duplicated = computeDims(obs({
|
|
91
|
+
scenarioNo: 3,
|
|
92
|
+
updated: [],
|
|
93
|
+
created: [goodCreated({ path: "_sandbox/llm-inference/rocm-hsa-override-results" })],
|
|
94
|
+
livePaths: [...FIXTURE5_PATHS, "_sandbox/llm-inference/rocm-hsa-override-results"],
|
|
95
|
+
}));
|
|
96
|
+
assert.equal(duplicated.B, 0);
|
|
97
|
+
});
|
|
98
|
+
test("computeDims C: anatomy violation (missing scope line) ⇒ C=0", () => {
|
|
99
|
+
const noScope = GOOD_PAGE.replace("This page answers: which settings moved qwen-27b decode throughput.", "some prose");
|
|
100
|
+
const dims = computeDims(obs({ created: [goodCreated({ content: noScope })] }));
|
|
101
|
+
assert.equal(dims.C, 0);
|
|
102
|
+
});
|
|
103
|
+
test("computeDims E/F: broken internal link ⇒ E=0; index missing the new path ⇒ F=0 and E=0 (no backlink)", () => {
|
|
104
|
+
const broken = computeDims(obs({ created: [goodCreated({ content: GOOD_PAGE.replace("/_sandbox/llm-inference/rocm-tuning", "/_sandbox/llm-inference/nope") })] }));
|
|
105
|
+
assert.equal(broken.E, 0);
|
|
106
|
+
const orphan = computeDims(obs({ indexContent: "## LLM Inference\n- (empty)" }));
|
|
107
|
+
assert.equal(orphan.F, 0);
|
|
108
|
+
assert.equal(orphan.E, 0);
|
|
109
|
+
});
|
|
110
|
+
test("computeDims G: outside-sandbox change / deleted fixture ⇒ G=0", () => {
|
|
111
|
+
assert.equal(computeDims(obs({ outside: { created: [], updated: ["infra/network"], deleted: [] } })).G, 0);
|
|
112
|
+
assert.equal(computeDims(obs({ deletedFixturePaths: ["_sandbox/mess/untitled"] })).G, 0);
|
|
113
|
+
assert.equal(computeDims(obs({ outside: { created: ["runbooks/x"], updated: [], deleted: [] } })).G, 0);
|
|
114
|
+
});
|
|
115
|
+
test("computeDims G scenario 09 (val, read-only): _meta/page-map refresh allowed, any _sandbox write forbidden", () => {
|
|
116
|
+
const clean9 = computeDims(obs({
|
|
117
|
+
scenarioNo: 9,
|
|
118
|
+
created: [],
|
|
119
|
+
updated: [],
|
|
120
|
+
indexUpdated: false,
|
|
121
|
+
finalMessage: "2026-W36 共 12 条更新;2026-W37 共 9 条。明细见表格,weeks 机读数据与之一致,近 7 天 _sandbox 仅索引页更新。",
|
|
122
|
+
outside: { created: [], updated: ["_meta/page-map"], deleted: [] },
|
|
123
|
+
}));
|
|
124
|
+
assert.equal(clean9.G, 1);
|
|
125
|
+
const wroteSandbox = computeDims(obs({ scenarioNo: 9, created: [goodCreated()], outside: { created: [], updated: ["_meta/page-map"], deleted: [] } }));
|
|
126
|
+
assert.equal(wroteSandbox.G, 0);
|
|
127
|
+
});
|
|
128
|
+
test("computeDims H: empty/short final message ⇒ H=0", () => {
|
|
129
|
+
assert.equal(computeDims(obs({ finalMessage: "done" })).H, 0);
|
|
130
|
+
});
|
|
131
|
+
test("computeDims H scenario 07: dual-URL contract + anonymous 200 + no zh/ path prefix", () => {
|
|
132
|
+
const final7 = GOOD_FINAL +
|
|
133
|
+
"\nen URL: http://localhost:3000/en/_sandbox/runbooks/cli-dry-run\nzh URL: http://localhost:3000/zh/_sandbox/runbooks/cli-dry-run\n";
|
|
134
|
+
const created7 = [
|
|
135
|
+
goodCreated({ path: "_sandbox/runbooks/cli-dry-run", title: "CLI Dry-Run Shortcut" }),
|
|
136
|
+
goodCreated({ path: "_sandbox/runbooks/cli-dry-run", locale: "zh", title: "CLI 试运行快捷方式" }),
|
|
137
|
+
];
|
|
138
|
+
const ok = computeDims(obs({
|
|
139
|
+
scenarioNo: 7,
|
|
140
|
+
created: created7,
|
|
141
|
+
livePaths: [...FIXTURE5_PATHS, "_sandbox/runbooks/cli-dry-run"],
|
|
142
|
+
allPaths: [...FIXTURE5_PATHS, "_sandbox/runbooks/cli-dry-run"],
|
|
143
|
+
indexContent: "[CLI](/_sandbox/runbooks/cli-dry-run) (Active)",
|
|
144
|
+
finalMessage: final7,
|
|
145
|
+
urlChecks: [
|
|
146
|
+
{ url: "http://localhost:3000/en/_sandbox/runbooks/cli-dry-run", status: 200 },
|
|
147
|
+
{ url: "http://localhost:3000/zh/_sandbox/runbooks/cli-dry-run", status: 200 },
|
|
148
|
+
],
|
|
149
|
+
}));
|
|
150
|
+
assert.equal(ok.H, 1);
|
|
151
|
+
const deadZh = computeDims(obs({
|
|
152
|
+
scenarioNo: 7,
|
|
153
|
+
created: [created7[0] ?? goodCreated()],
|
|
154
|
+
livePaths: [...FIXTURE5_PATHS, "_sandbox/runbooks/cli-dry-run"],
|
|
155
|
+
allPaths: [...FIXTURE5_PATHS, "_sandbox/runbooks/cli-dry-run"],
|
|
156
|
+
indexContent: "[CLI](/_sandbox/runbooks/cli-dry-run) (Active)",
|
|
157
|
+
finalMessage: final7,
|
|
158
|
+
urlChecks: [
|
|
159
|
+
{ url: "http://localhost:3000/en/_sandbox/runbooks/cli-dry-run", status: 200 },
|
|
160
|
+
{ url: "http://localhost:3000/zh/_sandbox/runbooks/cli-dry-run", status: 404 },
|
|
161
|
+
],
|
|
162
|
+
}));
|
|
163
|
+
assert.equal(deadZh.H, 0);
|
|
164
|
+
});
|
|
165
|
+
test("judgment scenario 05: no page + stated refusal ⇒ 1; ≤1-line this-session scratch + refusal ⇒ 1", () => {
|
|
166
|
+
const decline = "这条 nextcloud 一次性重启没有症状、根因或预防价值,不具备长期知识属性,不创建 wiki 页面。";
|
|
167
|
+
assert.equal(judgment(5, [], decline), 1);
|
|
168
|
+
const scratch = judgment(5, [{ path: "_sandbox/this-session/nextcloud-restart", locale: "zh", title: "会话记录", content: "2026-09-10 nextcloud 重启一次恢复,原因未查。" }], "没有建正式页面,仅在 _sandbox/this-session/nextcloud-restart 留了一行临时记录,因为这是不可复用的一次性事件。");
|
|
169
|
+
assert.equal(scratch, 1);
|
|
170
|
+
});
|
|
171
|
+
test("judgment scenario 05: full troubleshooting page ⇒ 0; multi-line scratch ⇒ 0; silent no-op without refusal ⇒ 0", () => {
|
|
172
|
+
assert.equal(judgment(5, [goodCreated({ path: "_sandbox/troubleshooting/nextcloud-restart", content: GOOD_INCIDENT_PAGE })], GOOD_FINAL), 0);
|
|
173
|
+
assert.equal(judgment(5, [{ path: "_sandbox/this-session/n", locale: "en", title: "n", content: "line one\nline two" }], "一次性事件,没有建页面。"), 0);
|
|
174
|
+
assert.equal(judgment(5, [], "我把内容整理好了,页面已发布,欢迎查看。"), 0);
|
|
175
|
+
});
|
|
176
|
+
test("scoreUnit scenario 05 end-to-end: silent full page ⇒ score 2/3 fail; clean decline ⇒ 1.0 pass", () => {
|
|
177
|
+
const fullPage = scoreUnit(obs({
|
|
178
|
+
scenarioNo: 5,
|
|
179
|
+
created: [goodCreated({ path: "_sandbox/troubleshooting/nextcloud-restart", content: GOOD_INCIDENT_PAGE })],
|
|
180
|
+
livePaths: [...FIXTURE5_PATHS, "_sandbox/troubleshooting/nextcloud-restart"],
|
|
181
|
+
allPaths: [...FIXTURE5_PATHS, "_sandbox/troubleshooting/nextcloud-restart"],
|
|
182
|
+
indexUpdated: false,
|
|
183
|
+
indexContent: "",
|
|
184
|
+
}));
|
|
185
|
+
assert.equal(fullPage.score, 2 / 3); // (G1+H1+J0)/3
|
|
186
|
+
assert.equal(fullPage.pass, false);
|
|
187
|
+
const clean = scoreUnit(obs({
|
|
188
|
+
scenarioNo: 5,
|
|
189
|
+
created: [],
|
|
190
|
+
updated: [],
|
|
191
|
+
indexUpdated: false,
|
|
192
|
+
indexContent: "",
|
|
193
|
+
finalMessage: "这条 nextcloud 一次性重启没有症状、根因或预防价值,不具备长期知识属性,不创建 wiki 页面。",
|
|
194
|
+
}));
|
|
195
|
+
assert.equal(clean.score, 1);
|
|
196
|
+
assert.equal(clean.pass, true);
|
|
197
|
+
});
|
|
198
|
+
test("scoreUnit scenario 01: float noise ⇒ 10/12 pass (floor reached), float + broken link ⇒ 9/12 fail", () => {
|
|
199
|
+
const dirty = GOOD_PAGE.replace("47.4", "47.3829104823");
|
|
200
|
+
const one = scoreUnit(obs({ created: [goodCreated({ content: dirty })] }));
|
|
201
|
+
assert.equal(one.total, 10); // 12 − D(2×1)
|
|
202
|
+
assert.equal(one.score, 10 / 12);
|
|
203
|
+
assert.equal(one.pass, true); // Σ=10 ≥ 10 ∧ G=1
|
|
204
|
+
const two = scoreUnit(obs({
|
|
205
|
+
created: [goodCreated({ content: dirty.replace("/_sandbox/llm-inference/rocm-tuning", "/_sandbox/llm-inference/nope") })],
|
|
206
|
+
}));
|
|
207
|
+
assert.equal(two.total, 9); // 12 − D2 − E1
|
|
208
|
+
assert.equal(two.pass, false);
|
|
209
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Todo 2 acceptance pins for src/core/ids.ts (TDD RED first — plan AC a).
|
|
2
|
+
// Given/When/Then throughout; tmpdirs namespaced via mkdtemp, torn down in
|
|
3
|
+
// t.after so parallel checkouts never collide.
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { symlinkSync } from "node:fs";
|
|
6
|
+
import { mkdtemp, mkdir, rm, writeFile, chmod, utimes } from "node:fs/promises";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { canonicalJson, fileTreeDigest, fingerprint, genId, runId, treeDigestAt, walkTree, } from "../core/ids.js";
|
|
11
|
+
async function freshRoot(prefix) {
|
|
12
|
+
return mkdtemp(path.join(os.tmpdir(), `abathur-ids-${prefix}-`));
|
|
13
|
+
}
|
|
14
|
+
// -------------------------------------------------------------- canonicalJson
|
|
15
|
+
test("canonicalJson: object key order and input whitespace never change output", () => {
|
|
16
|
+
// Given two JSON texts with identical content but different key order/spacing
|
|
17
|
+
const a = `{"b":1,"a":{"d":[1,{"z":null,"y":true}],"c":2}}`;
|
|
18
|
+
const b = '{ "a" : { "c": 2, "d" : [ 1, { "y": true, "z": null } ] }, "b" : 1 }';
|
|
19
|
+
// When canonicalized
|
|
20
|
+
const ca = canonicalJson(JSON.parse(a));
|
|
21
|
+
const cb = canonicalJson(JSON.parse(b));
|
|
22
|
+
// Then identical, compact, keys sorted recursively
|
|
23
|
+
assert.equal(ca, cb);
|
|
24
|
+
assert.equal(ca, '{"a":{"c":2,"d":[1,{"y":true,"z":null}]},"b":1}');
|
|
25
|
+
assert.ok(!ca.includes(" "));
|
|
26
|
+
});
|
|
27
|
+
test("canonicalJson: array order is data and is preserved verbatim", () => {
|
|
28
|
+
assert.equal(canonicalJson([2, 1]), "[2,1]");
|
|
29
|
+
assert.notEqual(canonicalJson([2, 1]), canonicalJson([1, 2]));
|
|
30
|
+
});
|
|
31
|
+
test("canonicalJson: object keys sort by UTF-16 code unit order, stably", () => {
|
|
32
|
+
const out = canonicalJson({ é: 1, b: 2, A: 3, "0": 4 });
|
|
33
|
+
assert.equal(out, '{"0":4,"A":3,"b":2,"é":1}');
|
|
34
|
+
});
|
|
35
|
+
test("canonicalJson: undefined object props dropped, array holes become null (JSON parity)", () => {
|
|
36
|
+
assert.equal(canonicalJson({ a: 1, b: undefined }), '{"a":1}');
|
|
37
|
+
assert.equal(canonicalJson([undefined, 1]), "[null,1]");
|
|
38
|
+
});
|
|
39
|
+
test("canonicalJson: finite floats serialize JSON-identically; non-finite fail closed", () => {
|
|
40
|
+
assert.equal(canonicalJson({ x: -0.5, y: 1e21 }), '{"x":-0.5,"y":1e+21}');
|
|
41
|
+
// JSON.stringify maps NaN/±Infinity to null — a silent collision we refuse.
|
|
42
|
+
assert.throws(() => canonicalJson(Number.NaN), TypeError);
|
|
43
|
+
assert.throws(() => canonicalJson({ x: Number.POSITIVE_INFINITY }), TypeError);
|
|
44
|
+
});
|
|
45
|
+
test("canonicalJson: fail-closed on non-JSON top-levels and exotic objects", () => {
|
|
46
|
+
assert.throws(() => canonicalJson(undefined), TypeError);
|
|
47
|
+
assert.throws(() => canonicalJson(() => 1), TypeError);
|
|
48
|
+
assert.throws(() => canonicalJson(new Date(0)), TypeError); // plain-JSON only
|
|
49
|
+
});
|
|
50
|
+
// ------------------------------------------------------------------ fingerprint
|
|
51
|
+
test("fingerprint: 64-hex sha256, stable across key order, sensitive to one byte", () => {
|
|
52
|
+
const fp1 = fingerprint({ b: [1, { d: "x", c: 2 }], a: null });
|
|
53
|
+
const fp2 = fingerprint({ a: null, b: [1, { c: 2, d: "x" }] });
|
|
54
|
+
const fp3 = fingerprint({ a: null, b: [1, { c: 2, d: "y" }] });
|
|
55
|
+
assert.match(fp1, /^[0-9a-f]{64}$/);
|
|
56
|
+
assert.equal(fp1, fp2);
|
|
57
|
+
assert.notEqual(fp1, fp3);
|
|
58
|
+
});
|
|
59
|
+
// ------------------------------------------------------------------- tree digest
|
|
60
|
+
test("fileTreeDigest: order-independent over paths, rejects duplicate paths", () => {
|
|
61
|
+
const f1 = { path: "a.txt", content: "hello\n" };
|
|
62
|
+
const f2 = { path: "dir/b.txt", content: "world" };
|
|
63
|
+
assert.equal(fileTreeDigest([f1, f2]), fileTreeDigest([f2, f1]));
|
|
64
|
+
assert.match(fileTreeDigest([f1]), /^[0-9a-f]{64}$/);
|
|
65
|
+
assert.throws(() => fileTreeDigest([f1, { ...f2, path: "a.txt" }]), /duplicate/i);
|
|
66
|
+
assert.notEqual(fileTreeDigest([f1]), fileTreeDigest([{ ...f1, content: "hellp\n" }]));
|
|
67
|
+
});
|
|
68
|
+
test("treeDigestAt (AC a): chmod + mtime touch leave digest unchanged, one content byte flips it", async (t) => {
|
|
69
|
+
const root = await freshRoot("tree");
|
|
70
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
71
|
+
await mkdir(path.join(root, "dir"), { recursive: true });
|
|
72
|
+
const a = path.join(root, "a.txt");
|
|
73
|
+
await writeFile(a, "hello\n", "utf8");
|
|
74
|
+
await writeFile(path.join(root, "dir", "b.txt"), new Uint8Array([1, 2, 250]), "utf8");
|
|
75
|
+
// Given a stable tree
|
|
76
|
+
const d0 = treeDigestAt(root);
|
|
77
|
+
// When file metadata changes (chmod, mtime) but content does not
|
|
78
|
+
await chmod(a, 0o600);
|
|
79
|
+
const future = new Date(Date.UTC(2099, 0, 1));
|
|
80
|
+
await utimes(a, future, future);
|
|
81
|
+
// Then digest is unchanged — modes/mtimes excluded by construction
|
|
82
|
+
assert.equal(treeDigestAt(root), d0);
|
|
83
|
+
// When exactly one content byte flips
|
|
84
|
+
await writeFile(a, "hallo\n", "utf8");
|
|
85
|
+
// Then digest changes
|
|
86
|
+
assert.notEqual(treeDigestAt(root), d0);
|
|
87
|
+
});
|
|
88
|
+
test("walkTree: sorted forward-slash relative paths, regular files only, empty tree OK", async (t) => {
|
|
89
|
+
const root = await freshRoot("walk");
|
|
90
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
91
|
+
assert.deepEqual(walkTree(root), []);
|
|
92
|
+
await mkdir(path.join(root, "z", "y"), { recursive: true });
|
|
93
|
+
await writeFile(path.join(root, "z", "b.txt"), "b", "utf8");
|
|
94
|
+
await writeFile(path.join(root, "z", "y", "a.txt"), "a", "utf8");
|
|
95
|
+
await writeFile(path.join(root, "m.txt"), "m", "utf8");
|
|
96
|
+
const entries = walkTree(root);
|
|
97
|
+
assert.deepEqual(entries.map((e) => e.path), ["m.txt", "z/b.txt", "z/y/a.txt"]);
|
|
98
|
+
// Symlinks are not content-addressable by path alone — excluded from the tree
|
|
99
|
+
symlinkSync("m.txt", path.join(root, "l.txt"));
|
|
100
|
+
assert.deepEqual(walkTree(root).map((e) => e.path), ["m.txt", "z/b.txt", "z/y/a.txt"]);
|
|
101
|
+
});
|
|
102
|
+
// ------------------------------------------------------------------ genId/runId
|
|
103
|
+
test("genId: g-<ulctime>-<first8 of fingerprint>, clock-injectable", () => {
|
|
104
|
+
const at = new Date(Date.UTC(2026, 8, 9, 14, 25, 30));
|
|
105
|
+
assert.equal(genId("deadbeefcafe1234", at), "g-20260909T142530Z-deadbeef");
|
|
106
|
+
assert.equal(genId("abc", at), "g-20260909T142530Z-abc"); // short seed: take what exists
|
|
107
|
+
assert.match(genId("f".repeat(64)), /^g-\d{8}T\d{6}Z-f{8}$/); // default clock, 8-hex cut
|
|
108
|
+
assert.throws(() => genId("", at), /fingerprint/);
|
|
109
|
+
});
|
|
110
|
+
test("runId: r-<genId>-<unitId>-<repIdx> with validated rep", () => {
|
|
111
|
+
assert.equal(runId("g-20260909T142530Z-deadbeef", "mutate", 3), "r-g-20260909T142530Z-deadbeef-mutate-3");
|
|
112
|
+
for (const bad of [-1, 1.5, Number.NaN]) {
|
|
113
|
+
assert.throws(() => runId("g-x", "u", bad), /repIdx/);
|
|
114
|
+
}
|
|
115
|
+
assert.throws(() => runId("g-x", "", 0), /unitId/);
|
|
116
|
+
});
|