@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,394 @@
|
|
|
1
|
+
// Todo 10 AC pins (plan lines 149-156): human-gate promote / tombstone / status + genome rm.
|
|
2
|
+
// AC(a) promote of a nominated clean gen fast-forwards abathur/incumbent, refreshes the
|
|
3
|
+
// config-home kernel manifest, `kernel audit` exits 0 after, promote ledger row appended;
|
|
4
|
+
// AC(b) promote whose diff touches a sealed path is blocked exit 1 EVEN with a forged
|
|
5
|
+
// nominated generation_complete row (tamper fixture), with ref + manifest untouched;
|
|
6
|
+
// AC(c) tombstone records culled lineage and deletes nothing (gen commit stays reachable);
|
|
7
|
+
// AC(d) status renders incumbent + quarantine depth, never fabricates an incumbent;
|
|
8
|
+
// plus: stale promote refused (not an ancestor), checked-out incumbent refused,
|
|
9
|
+
// row-without-verdict refused, double promote refused, genome rm refuses ledger history.
|
|
10
|
+
// Promotion state is derived ONLY from the ledger; no mocks of git — real toy genome,
|
|
11
|
+
// real seals via todo-3 worktree APIs, commands driven through the real CLI.
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
15
|
+
import { mkdtemp } from "node:fs/promises";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import test, {} from "node:test";
|
|
20
|
+
import { Ledger } from "../core/ledger.js";
|
|
21
|
+
import { fingerprint, genId } from "../core/ids.js";
|
|
22
|
+
import { newGeneration, openGenome, sealGeneration } from "../core/worktree.js";
|
|
23
|
+
import { manifestPathFor, readManifestFile } from "../core/kernel.js";
|
|
24
|
+
import { registerGenome, requireGenomesByLabel, readRegistry } from "../core/genome.js";
|
|
25
|
+
import { INCUMBENT_BRANCH } from "../core/incumbent.js";
|
|
26
|
+
import { decodeGenerationRecord } from "../core/evolve/run-bench.js";
|
|
27
|
+
import { runEvolution } from "../core/evolve/run-loop.js";
|
|
28
|
+
import { prepareToyGenome } from "../bench/toy.js";
|
|
29
|
+
const CLI = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
|
|
30
|
+
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
|
31
|
+
// ------------------------------------------------------------------ fixture
|
|
32
|
+
const ADD_ANCHOR = "return a - b; // seeded bug: must be `return a + b;`";
|
|
33
|
+
const STUB_SOURCE = `#!/usr/bin/env node
|
|
34
|
+
import { readFileSync } from "node:fs";
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
37
|
+
const mode = opt("--mode");
|
|
38
|
+
const dir = opt("--dir");
|
|
39
|
+
const read = (f) => readFileSync(dir + "/" + f, "utf8");
|
|
40
|
+
function lineDiff(file, anchor, replacement) {
|
|
41
|
+
const lines = read(file).split("\\n");
|
|
42
|
+
const i = lines.findIndex((l) => l.includes(anchor));
|
|
43
|
+
if (i < 0) { process.stderr.write("stub: no anchor in " + file + "\\n"); process.exit(1); }
|
|
44
|
+
const changed = lines[i].replace(anchor, replacement);
|
|
45
|
+
return "--- a/" + file + "\\n+++ b/" + file + "\\n@@ -" + String(i + 1) + ",1 +" + String(i + 1) + ",1 @@\\n-" + lines[i] + "\\n+" + changed + "\\n";
|
|
46
|
+
}
|
|
47
|
+
const FIX = ${JSON.stringify(ADD_ANCHOR)};
|
|
48
|
+
const out = (candidates) => process.stdout.write(JSON.stringify({ candidates }) + "\\n");
|
|
49
|
+
if (mode === "three") {
|
|
50
|
+
out([
|
|
51
|
+
{ id: "annotate-add", rationale: "annotate add()", diffs: [lineDiff("units/add.mjs", "export function add(", "export function add /* patched */(")] },
|
|
52
|
+
{ id: "touch-kernel", rationale: "subvert grading", diffs: [lineDiff("grader.mjs", "model-free", "model-free-ish")] },
|
|
53
|
+
{ id: "fix-add", rationale: "fix add(): subtraction to addition", diffs: [lineDiff("units/add.mjs", FIX, "return a + b;")] },
|
|
54
|
+
]);
|
|
55
|
+
} else {
|
|
56
|
+
process.stderr.write("stub: unknown mode\\n");
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
/** Isolated tmp home: registered toy genome + stub mutator; NO evolution run yet. */
|
|
61
|
+
async function gateFixture(t) {
|
|
62
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "abathur-promote-"));
|
|
63
|
+
t.after(() => {
|
|
64
|
+
spawnSync("rm", ["-rf", root], { encoding: "utf8" });
|
|
65
|
+
});
|
|
66
|
+
const configDir = path.join(root, "config");
|
|
67
|
+
mkdirSync(configDir, { recursive: true });
|
|
68
|
+
writeFileSync(path.join(configDir, "config.jsonc"), '{ "opencodeBin": null }\n', "utf8");
|
|
69
|
+
const repo = await prepareToyGenome(path.join(root, "genome"));
|
|
70
|
+
registerGenome(configDir, path.join(repo, "genome.jsonc"));
|
|
71
|
+
const entry = requireGenomesByLabel(configDir, "toy-smoke").entries[0];
|
|
72
|
+
if (entry === undefined)
|
|
73
|
+
throw new Error("fixture: toy-smoke registration vanished");
|
|
74
|
+
const stub = path.join(root, "stub.mjs");
|
|
75
|
+
writeFileSync(stub, STUB_SOURCE, "utf8");
|
|
76
|
+
chmodSync(stub, 0o755);
|
|
77
|
+
const env = { XDG_CACHE_HOME: path.join(root, "xdg-cache"), HOME: path.join(root, "home") };
|
|
78
|
+
return { root, configDir, repo, entry, env, wopts: { env } };
|
|
79
|
+
}
|
|
80
|
+
/** CLI env for spawned runs: config + HOME + cache all pinned into the tmp root. */
|
|
81
|
+
function cliEnv(f) {
|
|
82
|
+
return {
|
|
83
|
+
...process.env,
|
|
84
|
+
ABATHUR_CONFIG: path.join(f.configDir, "config.jsonc"),
|
|
85
|
+
HOME: path.join(f.root, "home"),
|
|
86
|
+
XDG_CACHE_HOME: path.join(f.root, "xdg-cache"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function cli(f, ...args) {
|
|
90
|
+
return spawnSync(process.execPath, [CLI, ...args], { env: cliEnv(f), encoding: "utf8", cwd: f.root });
|
|
91
|
+
}
|
|
92
|
+
function git(f, ...args) {
|
|
93
|
+
const run = spawnSync("git", ["-C", f.repo, ...args], { encoding: "utf8" });
|
|
94
|
+
if (run.status !== 0)
|
|
95
|
+
throw new Error(`git ${args.join(" ")}: ${run.stderr}`);
|
|
96
|
+
return run.stdout.trim();
|
|
97
|
+
}
|
|
98
|
+
function allRows(f) {
|
|
99
|
+
return Ledger.open(f.repo).readAll();
|
|
100
|
+
}
|
|
101
|
+
function genRows(f) {
|
|
102
|
+
return allRows(f)
|
|
103
|
+
.filter((r) => r.kind === "generation_complete" && r.genId !== undefined)
|
|
104
|
+
.map((r) => ({ genId: r.genId, data: decodeGenerationRecord(r) }));
|
|
105
|
+
}
|
|
106
|
+
function rowsOfKind(f, kind) {
|
|
107
|
+
return allRows(f).filter((r) => r.kind === kind);
|
|
108
|
+
}
|
|
109
|
+
/** Real 2-candidate evolution (run-loop, no mocks): fix-add nominated, annotate-add culled. */
|
|
110
|
+
async function evolve(f) {
|
|
111
|
+
const outcome = await runEvolution({
|
|
112
|
+
entry: f.entry,
|
|
113
|
+
configDir: f.configDir,
|
|
114
|
+
mutatorCommand: `node ${path.join(f.root, "stub.mjs")} --mode three --dir {worktree} --brief {brief}`,
|
|
115
|
+
env: f.env,
|
|
116
|
+
sandboxRoot: path.join(f.root, "sandboxes"),
|
|
117
|
+
});
|
|
118
|
+
assert.equal(outcome.exitCode, 0, "fixture evolution must succeed");
|
|
119
|
+
return genRows(f);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Seal a generation BY HAND (todo-3 APIs) and forge its generation_complete row.
|
|
123
|
+
* mutation.file may be a sealed path (grader.mjs) — the forged verdict is whatever
|
|
124
|
+
* the caller says, which is exactly the tampered-ledger scenario promote must refuse.
|
|
125
|
+
*/
|
|
126
|
+
async function handSeal(f, tag, mutation, verdict, opts = {}) {
|
|
127
|
+
const opened = await openGenome(f.repo, [], f.wopts);
|
|
128
|
+
const id = genId(fingerprint({ handseal: tag, root: f.root }));
|
|
129
|
+
const genome = { repoPath: f.repo, genomeFp: f.entry.fingerprint };
|
|
130
|
+
const gen = await newGeneration(genome, opened.headCommit, id, f.wopts);
|
|
131
|
+
const target = path.join(gen.worktreePath, mutation.file);
|
|
132
|
+
writeFileSync(target, `${readFileSync(target, "utf8")}${mutation.append}`, "utf8");
|
|
133
|
+
const sealed = await sealGeneration(genome, id, `hand-seal ${id} (${tag})`, f.wopts);
|
|
134
|
+
const data = {
|
|
135
|
+
source: "candidate",
|
|
136
|
+
candidateId: `hand-${tag}`,
|
|
137
|
+
rationale: `hand-sealed ${tag}`,
|
|
138
|
+
headCommit: opened.headCommit,
|
|
139
|
+
commitSha: sealed.commitSha,
|
|
140
|
+
treeSha: sealed.treeSha,
|
|
141
|
+
complete: true,
|
|
142
|
+
reps: 2,
|
|
143
|
+
units: [],
|
|
144
|
+
counters: { candidates: 1, modelCalls: 0, tokens: 0, wallS: 0 },
|
|
145
|
+
manifest: [],
|
|
146
|
+
...(verdict === undefined ? {} : { verdict }),
|
|
147
|
+
...(opts.gain === undefined ? {} : { gain: opts.gain }),
|
|
148
|
+
benchProvenance: { benchType: "toy", versions: [{ bin: "node", version: process.version }] },
|
|
149
|
+
};
|
|
150
|
+
Ledger.open(f.repo).append({ kind: "generation_complete", genId: id, data });
|
|
151
|
+
return { genId: id, commitSha: sealed.commitSha, treeSha: sealed.treeSha, parent: opened.headCommit };
|
|
152
|
+
}
|
|
153
|
+
const CLEAN_MUTATION = { file: "units/mul.mjs", append: "\n// hand-sealed clean mutation\n" };
|
|
154
|
+
const SEALED_MUTATION = { file: "grader.mjs", append: "\n// tampered grader\n" };
|
|
155
|
+
function incumbentSha(f) {
|
|
156
|
+
const run = spawnSync("git", ["-C", f.repo, "rev-parse", "--verify", "-q", `refs/heads/${INCUMBENT_BRANCH}`], { encoding: "utf8" });
|
|
157
|
+
return run.status === 0 ? run.stdout.trim() : null;
|
|
158
|
+
}
|
|
159
|
+
function manifestBytes(f) {
|
|
160
|
+
return readFileSync(manifestPathFor(f.configDir, f.entry.fingerprint), "utf8");
|
|
161
|
+
}
|
|
162
|
+
// -------------------------------------------------------------- AC (a) happy
|
|
163
|
+
test("AC(a): promote of nominated clean gen fast-forwards incumbent, refreshes manifest, kernel audit exits 0", async (t) => {
|
|
164
|
+
const f = await gateFixture(t);
|
|
165
|
+
const rows = await evolve(f);
|
|
166
|
+
const nominated = rows.find((r) => r.data.verdict === "nominated");
|
|
167
|
+
assert.ok(nominated !== undefined && nominated.data.commitSha !== undefined, "fixture produced a nominated row");
|
|
168
|
+
const genSha = nominated.data.commitSha;
|
|
169
|
+
assert.equal(incumbentSha(f), null, "no incumbent branch before first promote");
|
|
170
|
+
const before = manifestBytes(f);
|
|
171
|
+
const run = cli(f, "promote", "toy-smoke", nominated.genId);
|
|
172
|
+
assert.equal(run.status, 0, `promote exit 0: ${run.stderr}`);
|
|
173
|
+
assert.match(run.stdout, new RegExp(nominated.genId));
|
|
174
|
+
assert.match(run.stdout, /manifest/i);
|
|
175
|
+
assert.equal(incumbentSha(f), genSha, "incumbent branch fast-forwarded to the gen commit");
|
|
176
|
+
// sealed files were untouched by this gen ⇒ same entries, byte-identical manifest
|
|
177
|
+
assert.equal(manifestBytes(f), before, "clean promote leaves the manifest bytes unchanged");
|
|
178
|
+
const audit = cli(f, "kernel", "audit", "toy-smoke");
|
|
179
|
+
assert.equal(audit.status, 0, `kernel audit after promote: ${audit.stdout}${audit.stderr}`);
|
|
180
|
+
const promoteRows = rowsOfKind(f, "promote");
|
|
181
|
+
assert.equal(promoteRows.length, 1);
|
|
182
|
+
const data = promoteRows[0]?.data;
|
|
183
|
+
assert.equal(data.actor, "cli");
|
|
184
|
+
assert.equal(data.genId, nominated.genId);
|
|
185
|
+
// first promote creates the ref: there was no prior incumbent, so `from` is null
|
|
186
|
+
assert.equal(data.from, null);
|
|
187
|
+
assert.equal(data.to, genSha);
|
|
188
|
+
// a second promote of the same genId is refused (idempotence is not a feature here)
|
|
189
|
+
const again = cli(f, "promote", "toy-smoke", nominated.genId);
|
|
190
|
+
assert.equal(again.status, 1);
|
|
191
|
+
assert.match(again.stderr + again.stdout, /already promoted/i);
|
|
192
|
+
});
|
|
193
|
+
test("AC(a2): hand-sealed clean nominated gen promotes and reseals manifest from the new tree", async (t) => {
|
|
194
|
+
const f = await gateFixture(t);
|
|
195
|
+
const gen = await handSeal(f, "clean-1", CLEAN_MUTATION, "nominated");
|
|
196
|
+
const run = cli(f, "promote", "toy-smoke", gen.genId);
|
|
197
|
+
assert.equal(run.status, 0, `promote clean hand-seal: ${run.stderr}${run.stdout}`);
|
|
198
|
+
assert.equal(incumbentSha(f), gen.commitSha);
|
|
199
|
+
const entries = readManifestFile(manifestPathFor(f.configDir, f.entry.fingerprint));
|
|
200
|
+
assert.deepEqual([...new Set(entries.map((e) => e.path))].sort(), ["grader.mjs"]);
|
|
201
|
+
const audit = cli(f, "kernel", "audit", "toy-smoke");
|
|
202
|
+
assert.equal(audit.status, 0);
|
|
203
|
+
});
|
|
204
|
+
// ------------------------------------------------- AC (b) second enforcement
|
|
205
|
+
test("AC(b): tampered gen touching grader.mjs blocked exit 1 despite forged nominated row", async (t) => {
|
|
206
|
+
const f = await gateFixture(t);
|
|
207
|
+
const tampered = await handSeal(f, "sealed-touch", SEALED_MUTATION, "nominated");
|
|
208
|
+
const before = manifestBytes(f);
|
|
209
|
+
const run = cli(f, "promote", "toy-smoke", tampered.genId);
|
|
210
|
+
assert.equal(run.status, 1, `sealed-path promote must block: ${run.stdout}`);
|
|
211
|
+
assert.match(run.stderr + run.stdout, /grader\.mjs/, "blocks by naming the sealed path");
|
|
212
|
+
assert.equal(incumbentSha(f), null, "nothing mutated: no incumbent ref");
|
|
213
|
+
assert.equal(manifestBytes(f), before, "manifest untouched by a blocked promote");
|
|
214
|
+
assert.equal(rowsOfKind(f, "promote").length, 0);
|
|
215
|
+
// even after a legitimate promote exists, the tampered gen stays blocked
|
|
216
|
+
const clean = await handSeal(f, "clean-first", CLEAN_MUTATION, "nominated");
|
|
217
|
+
assert.equal(cli(f, "promote", "toy-smoke", clean.genId).status, 0);
|
|
218
|
+
const run2 = cli(f, "promote", "toy-smoke", tampered.genId);
|
|
219
|
+
assert.equal(run2.status, 1);
|
|
220
|
+
assert.match(run2.stderr + run2.stdout, /grader\.mjs/);
|
|
221
|
+
});
|
|
222
|
+
test("fail-closed: non-nominated verdict, missing row, missing genId, unknown label", async (t) => {
|
|
223
|
+
const f = await gateFixture(t);
|
|
224
|
+
const rows = await evolve(f);
|
|
225
|
+
const culled = rows.find((r) => r.data.verdict === "culled");
|
|
226
|
+
assert.ok(culled !== undefined, "fixture produced a culled row");
|
|
227
|
+
const c1 = cli(f, "promote", "toy-smoke", culled.genId);
|
|
228
|
+
assert.equal(c1.status, 1);
|
|
229
|
+
assert.match(c1.stderr + c1.stdout, /culled/, "names the actual verdict");
|
|
230
|
+
const c2 = cli(f, "promote", "toy-smoke", "g-20200101T000000Z-deadbeef");
|
|
231
|
+
assert.equal(c2.status, 1);
|
|
232
|
+
assert.match(c2.stderr + c2.stdout, /g-20200101T000000Z-deadbeef/, "exit 1 NAMES the genId");
|
|
233
|
+
const noVerdict = await handSeal(f, "no-verdict", CLEAN_MUTATION, undefined);
|
|
234
|
+
const c3 = cli(f, "promote", "toy-smoke", noVerdict.genId);
|
|
235
|
+
assert.equal(c3.status, 1, "row without verdict cannot be promoted");
|
|
236
|
+
const c4 = cli(f, "promote", "nope-not-registered", "g-whatever");
|
|
237
|
+
assert.equal(c4.status, 2, "unknown label is cannot-answer");
|
|
238
|
+
const c5 = cli(f, "promote", "toy-smoke");
|
|
239
|
+
assert.equal(c5.status, 2, "missing genId is cannot-answer");
|
|
240
|
+
});
|
|
241
|
+
// ------------------------------------------------------- stale / checked-out
|
|
242
|
+
test("stale_state: promoting an older nominated gen after a newer one is refused (not an ancestor)", async (t) => {
|
|
243
|
+
const f = await gateFixture(t);
|
|
244
|
+
const first = await handSeal(f, "gen-A", { file: "units/mul.mjs", append: "\n// A\n" }, "nominated");
|
|
245
|
+
const second = await handSeal(f, "gen-B", { file: "units/add.mjs", append: "\n// B\n" }, "nominated");
|
|
246
|
+
// both fork from the same base ⇒ after A is incumbent, B is not a descendant
|
|
247
|
+
assert.equal(cli(f, "promote", "toy-smoke", first.genId).status, 0);
|
|
248
|
+
const stale = cli(f, "promote", "toy-smoke", second.genId);
|
|
249
|
+
assert.equal(stale.status, 1);
|
|
250
|
+
assert.match(stale.stderr + stale.stdout, /ancestor/i);
|
|
251
|
+
assert.equal(incumbentSha(f), first.commitSha, "refusal leaves the ref where it was");
|
|
252
|
+
});
|
|
253
|
+
test("dirty_worktree: promote refuses while the incumbent branch is checked out", async (t) => {
|
|
254
|
+
const f = await gateFixture(t);
|
|
255
|
+
const gen = await handSeal(f, "checkout-block", CLEAN_MUTATION, "nominated");
|
|
256
|
+
git(f, "branch", INCUMBENT_BRANCH, gen.parent);
|
|
257
|
+
git(f, "checkout", "-q", INCUMBENT_BRANCH);
|
|
258
|
+
const run = cli(f, "promote", "toy-smoke", gen.genId);
|
|
259
|
+
assert.equal(run.status, 1);
|
|
260
|
+
assert.match(run.stderr + run.stdout, /checked out/i);
|
|
261
|
+
assert.equal(incumbentSha(f), gen.parent, "ref branch untouched while checked out");
|
|
262
|
+
});
|
|
263
|
+
// ------------------------------------------------------------------ tombstone
|
|
264
|
+
test("AC(c): tombstone records culled lineage, keeps commits and every byte of state", async (t) => {
|
|
265
|
+
const f = await gateFixture(t);
|
|
266
|
+
const rows = await evolve(f);
|
|
267
|
+
const culled = rows.find((r) => r.data.verdict === "culled");
|
|
268
|
+
const nominated = rows.find((r) => r.data.verdict === "nominated");
|
|
269
|
+
assert.ok(culled !== undefined && nominated !== undefined && culled.data.commitSha !== undefined);
|
|
270
|
+
const before = readFileSync(path.join(f.repo, ".state", "abathur", "ledger.jsonl"), "utf8");
|
|
271
|
+
const run = cli(f, "tombstone", "toy-smoke", culled.genId, "--reason", "sibling of fix-add, no val gain");
|
|
272
|
+
assert.equal(run.status, 0, `tombstone: ${run.stderr}`);
|
|
273
|
+
const tomb = rowsOfKind(f, "tombstone");
|
|
274
|
+
assert.equal(tomb.length, 1);
|
|
275
|
+
const data = tomb[0]?.data;
|
|
276
|
+
assert.deepEqual(data, { genId: culled.genId, reason: "sibling of fix-add, no val gain", actor: "cli" });
|
|
277
|
+
// cull ≠ delete: the sealed commit stays reachable-by-object and history files only grow
|
|
278
|
+
const cat = spawnSync("git", ["-C", f.repo, "cat-file", "-e", `${culled.data.commitSha}^{commit}`], { encoding: "utf8" });
|
|
279
|
+
assert.equal(cat.status, 0, "gen commit object preserved after tombstone");
|
|
280
|
+
const after = readFileSync(path.join(f.repo, ".state", "abathur", "ledger.jsonl"), "utf8");
|
|
281
|
+
assert.ok(after.startsWith(before), "ledger stays append-only");
|
|
282
|
+
// resolve the remaining nominated gen too (promote one, tombstone the other) ⇒
|
|
283
|
+
// status shows zero pending decisions: both quarantine exits exist
|
|
284
|
+
const promoted = await handSeal(f, "promote-then-status", { file: "units/mul.mjs", append: "\n// C\n" }, "nominated");
|
|
285
|
+
assert.equal(cli(f, "promote", "toy-smoke", promoted.genId).status, 0);
|
|
286
|
+
assert.equal(cli(f, "tombstone", "toy-smoke", nominated.genId, "--reason", "superseded by hand-seal C").status, 0);
|
|
287
|
+
const st = cli(f, "status", "toy-smoke");
|
|
288
|
+
assert.equal(st.status, 0);
|
|
289
|
+
assert.match(st.stdout, new RegExp(`# ${promoted.genId}`), "promoted gen visible in generations table");
|
|
290
|
+
assert.match(st.stdout, /quarantine depth: 0/, "promoted + tombstoned ⇒ nothing pending");
|
|
291
|
+
assert.match(st.stdout, /incumbent: [0-9a-f]{7,}/);
|
|
292
|
+
});
|
|
293
|
+
test("tombstone fail-closed + prompt_injection hygiene", async (t) => {
|
|
294
|
+
const f = await gateFixture(t);
|
|
295
|
+
const gen = await handSeal(f, "evil-reason", CLEAN_MUTATION, "culled");
|
|
296
|
+
const missing = cli(f, "tombstone", "toy-smoke", gen.genId);
|
|
297
|
+
assert.equal(missing.status, 2, "--reason is required");
|
|
298
|
+
const unknown = cli(f, "tombstone", "toy-smoke", "g-nope-not-here", "--reason", "x");
|
|
299
|
+
assert.equal(unknown.status, 1);
|
|
300
|
+
const evil = cli(f, "tombstone", "toy-smoke", gen.genId, "--reason", "evil\n\u001b[31mred\u001b[0m reason");
|
|
301
|
+
assert.equal(evil.status, 0, `evil reason: ${evil.stderr}`);
|
|
302
|
+
assert.ok(!evil.stdout.includes("\u001b"), "raw ANSI never reaches stdout");
|
|
303
|
+
assert.ok(!evil.stdout.includes("\n\u001b"), "no injected escape line");
|
|
304
|
+
const echoLine = evil.stdout.split("\n").filter((l) => l.length > 0);
|
|
305
|
+
assert.equal(echoLine.length, 1, "echo is exactly one line");
|
|
306
|
+
// the stored row is JSON-escaped on disk (canonicalJson), never a raw control byte line
|
|
307
|
+
const raw = readFileSync(path.join(f.repo, ".state", "abathur", "ledger.jsonl"), "utf8");
|
|
308
|
+
assert.ok(!raw.includes("\u001b"), "no raw ESC byte in ledger text");
|
|
309
|
+
const stored = rowsOfKind(f, "tombstone")[0]?.data;
|
|
310
|
+
assert.equal(stored.reason, "evil\n\u001b[31mred\u001b[0m reason", "ledger stores verbatim (escaped by JSON)");
|
|
311
|
+
const twice = cli(f, "tombstone", "toy-smoke", gen.genId, "--reason", "again");
|
|
312
|
+
assert.equal(twice.status, 1, "already tombstoned ⇒ refused");
|
|
313
|
+
const promoted = await handSeal(f, "promoted-no-tomb", CLEAN_MUTATION, "nominated");
|
|
314
|
+
assert.equal(cli(f, "promote", "toy-smoke", promoted.genId).status, 0);
|
|
315
|
+
const tombPromoted = cli(f, "tombstone", "toy-smoke", promoted.genId, "--reason", "too late");
|
|
316
|
+
assert.equal(tombPromoted.status, 1, "promoted gens cannot be tombstoned");
|
|
317
|
+
});
|
|
318
|
+
// -------------------------------------------------------------------- status
|
|
319
|
+
test("AC(d): status renders incumbent, generations, quarantine depth, budget; never fabricates", async (t) => {
|
|
320
|
+
const f = await gateFixture(t);
|
|
321
|
+
const virgin = cli(f, "status", "toy-smoke");
|
|
322
|
+
assert.equal(virgin.status, 0);
|
|
323
|
+
assert.match(virgin.stdout, /incumbent: none/, "no incumbent ⇒ literal 'none'");
|
|
324
|
+
assert.match(virgin.stdout, /quarantine depth: 0/);
|
|
325
|
+
assert.match(virgin.stdout, /pending-bench graft queue: 0/);
|
|
326
|
+
const rows = await evolve(f);
|
|
327
|
+
const mid = cli(f, "status", "toy-smoke");
|
|
328
|
+
assert.equal(mid.status, 0);
|
|
329
|
+
assert.match(mid.stdout, /incumbent: none/);
|
|
330
|
+
assert.match(mid.stdout, /quarantine depth: 1/, "nominated-but-unpromoted fix-add is pending");
|
|
331
|
+
for (const r of rows)
|
|
332
|
+
assert.match(mid.stdout, new RegExp(`# ${r.genId}`), "every generation listed");
|
|
333
|
+
assert.match(mid.stdout, /budget: candidates \d+\/\d+/);
|
|
334
|
+
const nominated = rows.find((r) => r.data.verdict === "nominated");
|
|
335
|
+
assert.ok(nominated !== undefined);
|
|
336
|
+
assert.equal(cli(f, "promote", "toy-smoke", nominated.genId).status, 0);
|
|
337
|
+
const after = cli(f, "status", "toy-smoke");
|
|
338
|
+
assert.match(after.stdout, new RegExp(`incumbent: ${nominated.data.commitSha?.slice(0, 8)}`));
|
|
339
|
+
assert.match(after.stdout, /last promote:/);
|
|
340
|
+
assert.match(after.stdout, /quarantine depth: 0/);
|
|
341
|
+
assert.ok(!/incumbent: none/.test(after.stdout), "never both none and a ref");
|
|
342
|
+
});
|
|
343
|
+
// ----------------------------------------------------------------- genome rm
|
|
344
|
+
test("genome rm: ledgerless genomes unregister (manifest stays), ledger history refuses", async (t) => {
|
|
345
|
+
const f = await gateFixture(t);
|
|
346
|
+
const unknown = cli(f, "genome", "rm", "ghost");
|
|
347
|
+
assert.equal(unknown.status, 2);
|
|
348
|
+
const noArgs = cli(f, "genome", "rm");
|
|
349
|
+
assert.equal(noArgs.status, 2);
|
|
350
|
+
const run = cli(f, "genome", "rm", "toy-smoke");
|
|
351
|
+
assert.equal(run.status, 0, `ledgerless rm: ${run.stderr}${run.stdout}`);
|
|
352
|
+
assert.ok(!existsSync(f.entry.registryFile), "registry entry removed");
|
|
353
|
+
assert.ok(existsSync(manifestPathFor(f.configDir, f.entry.fingerprint)), "kernel manifest kept (archive-not-delete)");
|
|
354
|
+
assert.ok(existsSync(path.join(f.repo, "genome.jsonc")), "repo untouched");
|
|
355
|
+
assert.equal(readRegistry(f.configDir).entries.length, 0);
|
|
356
|
+
// history appears once the genome has been run ⇒ rm must refuse
|
|
357
|
+
const g = await gateFixture(t);
|
|
358
|
+
await evolve(g);
|
|
359
|
+
const refused = cli(g, "genome", "rm", "toy-smoke");
|
|
360
|
+
assert.equal(refused.status, 1);
|
|
361
|
+
assert.match(refused.stderr + refused.stdout, /has ledger history — archive instead/);
|
|
362
|
+
assert.ok(existsSync(g.entry.registryFile), "refusal removes nothing");
|
|
363
|
+
// even an empty-but-existing ledger dir counts as history once rows exist;
|
|
364
|
+
// a bare .state dir without the file is still ledgerless
|
|
365
|
+
const h = await gateFixture(t);
|
|
366
|
+
mkdirSync(path.join(h.repo, ".state", "abathur"), { recursive: true });
|
|
367
|
+
const empty = cli(h, "genome", "rm", "toy-smoke");
|
|
368
|
+
assert.equal(empty.status, 0, "no ledger rows ⇒ still removable");
|
|
369
|
+
});
|
|
370
|
+
// -------------------------------------------------------------- structural
|
|
371
|
+
test("structural: promote core is CLI-only; no auto-promote import path; no --force anywhere", () => {
|
|
372
|
+
const importers = [];
|
|
373
|
+
const scan = (dir) => {
|
|
374
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
375
|
+
const p = path.join(dir, ent.name);
|
|
376
|
+
if (ent.isDirectory()) {
|
|
377
|
+
if (ent.name !== "node_modules")
|
|
378
|
+
scan(p);
|
|
379
|
+
}
|
|
380
|
+
else if (ent.name.endsWith(".ts")) {
|
|
381
|
+
if (/from\s+"(?:\.\.\/)*core\/promote\.js"/.test(readFileSync(p, "utf8")))
|
|
382
|
+
importers.push(p);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
scan(path.join(REPO_ROOT, "src"));
|
|
387
|
+
const relative = importers.map((p) => path.relative(REPO_ROOT, p).split(path.sep).join("/")).sort();
|
|
388
|
+
assert.deepEqual(relative, ["src/commands/promote.ts"], "core/promote imported ONLY by commands/promote");
|
|
389
|
+
for (const rel of ["src/core/evolve/run-loop.ts", "src/core/evolve/reflect.ts", "src/core/evolve/candidate.ts"]) {
|
|
390
|
+
assert.doesNotMatch(readFileSync(path.join(REPO_ROOT, rel), "utf8"), /from\s+"[^"]*promote\.js"/, `${rel} must not import promote`);
|
|
391
|
+
}
|
|
392
|
+
const gate = readFileSync(path.join(REPO_ROOT, "src/core/promote.ts"), "utf8");
|
|
393
|
+
assert.doesNotMatch(gate, /--force/, "no force flag in the gate");
|
|
394
|
+
});
|