@tachikomagundam/abathur 0.2.5 → 0.2.7
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/README.md +3 -3
- package/dist/cli.js +4 -0
- package/dist/commands/genome.js +2 -2
- package/dist/commands/graft.js +1 -1
- package/dist/commands/readjudicate.js +28 -0
- package/dist/commands/retract.js +53 -0
- package/dist/commands/run.js +1 -1
- package/dist/commands/self-eval.js +1 -1
- package/dist/commands/tombstone.js +30 -16
- package/dist/core/evolve/run-loop.js +14 -0
- package/dist/core/evolve/run-rows.js +14 -0
- package/dist/core/evolve/score-bank.js +214 -0
- package/dist/core/graft-rebench.js +3 -0
- package/dist/core/ledger.js +8 -1
- package/dist/core/promote.js +63 -47
- package/dist/core/readjudicate.js +247 -0
- package/dist/core/retract.js +166 -0
- package/dist/core/stats-math.js +39 -0
- package/dist/core/stats.js +93 -19
- package/dist/core/worktree.js +1 -1
- package/dist/genomes/toy-smoke/init.mjs +1 -1
- package/dist/test/bundle.test.js +1 -1
- package/dist/test/fixture-loop.test.js +1 -1
- package/dist/test/fixtures-self.js +1 -1
- package/dist/test/fixtures-wt.js +1 -1
- package/dist/test/friction.test.js +3 -3
- package/dist/test/graft.test.js +1 -1
- package/dist/test/historian-grader-integrity.test.js +509 -2
- package/dist/test/promote.test.js +222 -5
- package/dist/test/readjudicate.test.js +212 -0
- package/dist/test/reflect.test.js +1 -1
- package/dist/test/repopath-seams.test.js +55 -0
- package/dist/test/score-bank.test.js +235 -0
- package/dist/test/seam-gates.test.js +84 -0
- package/dist/test/self-snapshot.test.js +2 -2
- package/dist/test/stats-acceptance.test.js +329 -0
- package/graders/historian/grader-core.d.mts +27 -0
- package/graders/historian/grader-core.mjs +311 -19
- package/graders/historian/grader.mjs +5 -5
- package/graders/historian/judge-poststage-19.mjs +212 -0
- package/graders/historian/run-scenario-19.sh +43 -0
- package/graders/historian/run-scenario.sh +16 -0
- package/package.json +1 -1
- package/plugin/abathur-command.md +3 -3
- package/plugin/abathur.ts +6 -6
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
// real seals via todo-3 worktree APIs, commands driven through the real CLI.
|
|
12
12
|
import assert from "node:assert/strict";
|
|
13
13
|
import { spawnSync } from "node:child_process";
|
|
14
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
|
15
15
|
import { mkdtemp } from "node:fs/promises";
|
|
16
16
|
import * as os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import test, {} from "node:test";
|
|
20
|
-
import { Ledger } from "../core/ledger.js";
|
|
20
|
+
import { acquireGenomeLock, acquireLock, Ledger } from "../core/ledger.js";
|
|
21
21
|
import { fingerprint, genId } from "../core/ids.js";
|
|
22
22
|
import { newGeneration, openGenome, sealGeneration } from "../core/worktree.js";
|
|
23
23
|
import { manifestPathFor, readManifestFile } from "../core/kernel.js";
|
|
@@ -315,6 +315,196 @@ test("tombstone fail-closed + prompt_injection hygiene", async (t) => {
|
|
|
315
315
|
const tombPromoted = cli(f, "tombstone", "toy-smoke", promoted.genId, "--reason", "too late");
|
|
316
316
|
assert.equal(tombPromoted.status, 1, "promoted gens cannot be tombstoned");
|
|
317
317
|
});
|
|
318
|
+
// -------------------------------------------------------------------- retract
|
|
319
|
+
test("AC(r1): retract undoes a fresh promote — ref deleted, manifest cleared, ledger append-only", async (t) => {
|
|
320
|
+
const f = await gateFixture(t);
|
|
321
|
+
const gen = await handSeal(f, "retract-happy", CLEAN_MUTATION, "nominated");
|
|
322
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
323
|
+
assert.equal(incumbentSha(f), gen.commitSha);
|
|
324
|
+
const manifestFile = manifestPathFor(f.configDir, f.entry.fingerprint);
|
|
325
|
+
assert.ok(existsSync(manifestFile), "manifest exists after promote");
|
|
326
|
+
const before = readFileSync(path.join(f.repo, ".state", "abathur", "ledger.jsonl"), "utf8");
|
|
327
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "zero-gain change, human-ordered rollback");
|
|
328
|
+
assert.equal(run.status, 0, `retract: ${run.stderr}`);
|
|
329
|
+
assert.equal(incumbentSha(f), null, "incumbent ref deleted");
|
|
330
|
+
assert.ok(!existsSync(manifestFile), "kernel manifest cleared");
|
|
331
|
+
const retractRows = rowsOfKind(f, "retract");
|
|
332
|
+
assert.equal(retractRows.length, 1);
|
|
333
|
+
const data = retractRows[0]?.data;
|
|
334
|
+
assert.equal(data.genId, gen.genId);
|
|
335
|
+
assert.equal(data.retractedTo, gen.commitSha);
|
|
336
|
+
assert.equal(data.reason, "zero-gain change, human-ordered rollback");
|
|
337
|
+
assert.equal(rowsOfKind(f, "promote").length, 1, "promote row remains (history never rewritten)");
|
|
338
|
+
const after = readFileSync(path.join(f.repo, ".state", "abathur", "ledger.jsonl"), "utf8");
|
|
339
|
+
assert.ok(after.startsWith(before), "ledger stays append-only");
|
|
340
|
+
const cat = spawnSync("git", ["-C", f.repo, "cat-file", "-e", `${gen.commitSha}^{commit}`], { encoding: "utf8" });
|
|
341
|
+
assert.equal(cat.status, 0, "candidate commit preserved (retract ≠ delete)");
|
|
342
|
+
const st = cli(f, "status", "toy-smoke");
|
|
343
|
+
assert.equal(st.status, 0);
|
|
344
|
+
assert.match(st.stdout, /incumbent: none/);
|
|
345
|
+
assert.match(st.stdout, /ref is missing/, "status honestly warns about the retracted target");
|
|
346
|
+
});
|
|
347
|
+
test("retract fail-closed: missing reason, unknown gen, never-promoted, double retract", async (t) => {
|
|
348
|
+
const f = await gateFixture(t);
|
|
349
|
+
const gen = await handSeal(f, "retract-guards", CLEAN_MUTATION, "nominated");
|
|
350
|
+
assert.equal(cli(f, "retract", "toy-smoke", gen.genId).status, 2, "--reason is required");
|
|
351
|
+
assert.equal(cli(f, "retract", "toy-smoke", "g-nope", "--reason", "x").status, 1);
|
|
352
|
+
const neverPromoted = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "not promoted yet");
|
|
353
|
+
assert.equal(neverPromoted.status, 1);
|
|
354
|
+
assert.match(neverPromoted.stderr + neverPromoted.stdout, /no promote ledger row/);
|
|
355
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
356
|
+
assert.equal(cli(f, "retract", "toy-smoke", gen.genId, "--reason", "undo").status, 0);
|
|
357
|
+
const twice = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "again");
|
|
358
|
+
assert.equal(twice.status, 1);
|
|
359
|
+
assert.match(twice.stderr + twice.stdout, /already retracted/);
|
|
360
|
+
});
|
|
361
|
+
test("retract LIFO + descendant guard: live descendant blocks, released descendant frees", async (t) => {
|
|
362
|
+
const f = await gateFixture(t);
|
|
363
|
+
const genA = await handSeal(f, "retract-A", { file: "units/mul.mjs", append: "\n// A\n" }, "nominated");
|
|
364
|
+
assert.equal(cli(f, "promote", "toy-smoke", genA.genId).status, 0);
|
|
365
|
+
// handSeal forks from the repo HEAD, so fast-forward main onto the new
|
|
366
|
+
// incumbent first — the child must be a descendant of A or its own promote
|
|
367
|
+
// would hit the not-an-ancestor stale_state guard.
|
|
368
|
+
git(f, "merge", "--ff-only", "-q", genA.commitSha);
|
|
369
|
+
const genChild = await handSeal(f, "retract-child", { file: "units/add.mjs", append: "\n// child\n" }, "nominated");
|
|
370
|
+
const blockedByChild = cli(f, "retract", "toy-smoke", genA.genId, "--reason", "too late");
|
|
371
|
+
assert.equal(blockedByChild.status, 1);
|
|
372
|
+
assert.match(blockedByChild.stderr + blockedByChild.stdout, /orphan/);
|
|
373
|
+
assert.equal(incumbentSha(f), genA.commitSha, "refusal leaves the ref where it was");
|
|
374
|
+
assert.equal(cli(f, "promote", "toy-smoke", genChild.genId).status, 0);
|
|
375
|
+
const lifo = cli(f, "retract", "toy-smoke", genA.genId, "--reason", "out of order");
|
|
376
|
+
assert.equal(lifo.status, 1);
|
|
377
|
+
assert.match(lifo.stderr + lifo.stdout, /LIFO/);
|
|
378
|
+
assert.equal(cli(f, "retract", "toy-smoke", genChild.genId, "--reason", "child first").status, 0);
|
|
379
|
+
assert.equal(incumbentSha(f), null);
|
|
380
|
+
// child retracted ⇒ its lineage released ⇒ A retracts without a tombstone
|
|
381
|
+
assert.equal(cli(f, "retract", "toy-smoke", genA.genId, "--reason", "now clear").status, 0);
|
|
382
|
+
assert.equal(incumbentSha(f), null);
|
|
383
|
+
});
|
|
384
|
+
test("retract ref-absent: sole live promote records refState=absent; two live promotes refuse", async (t) => {
|
|
385
|
+
const f = await gateFixture(t);
|
|
386
|
+
const genA = await handSeal(f, "absent-A", CLEAN_MUTATION, "nominated");
|
|
387
|
+
assert.equal(cli(f, "promote", "toy-smoke", genA.genId).status, 0);
|
|
388
|
+
// documented manual-rollback shape: ref deleted out-of-band, sole live promote
|
|
389
|
+
git(f, "update-ref", "-d", `refs/heads/${INCUMBENT_BRANCH}`);
|
|
390
|
+
const ok = cli(f, "retract", "toy-smoke", genA.genId, "--reason", "manual rollback already done");
|
|
391
|
+
assert.equal(ok.status, 0, `retract: ${ok.stderr}`);
|
|
392
|
+
const row = rowsOfKind(f, "retract")[0]?.data;
|
|
393
|
+
assert.equal(row.refState, "absent", "row honestly marks the ref was already gone");
|
|
394
|
+
});
|
|
395
|
+
test("retract ref-absent with two live promotes refuses (no laundering of divergence)", async (t) => {
|
|
396
|
+
const f = await gateFixture(t);
|
|
397
|
+
const genA = await handSeal(f, "absent2-A", { file: "units/mul.mjs", append: "\n// A\n" }, "nominated");
|
|
398
|
+
assert.equal(cli(f, "promote", "toy-smoke", genA.genId).status, 0);
|
|
399
|
+
git(f, "merge", "--ff-only", "-q", genA.commitSha);
|
|
400
|
+
const genB = await handSeal(f, "absent2-B", { file: "units/add.mjs", append: "\n// B\n" }, "nominated");
|
|
401
|
+
assert.equal(cli(f, "promote", "toy-smoke", genB.genId).status, 0);
|
|
402
|
+
git(f, "update-ref", "-d", `refs/heads/${INCUMBENT_BRANCH}`); // out-of-band divergence
|
|
403
|
+
const run = cli(f, "retract", "toy-smoke", genB.genId, "--reason", "try to launder");
|
|
404
|
+
assert.equal(run.status, 1);
|
|
405
|
+
assert.match(run.stderr + run.stdout, /cannot verify what the retract undoes/);
|
|
406
|
+
assert.equal(rowsOfKind(f, "retract").length, 0, "no row written on refusal");
|
|
407
|
+
});
|
|
408
|
+
test("retract refuses while a LINKED worktree has the incumbent branch checked out", async (t) => {
|
|
409
|
+
const f = await gateFixture(t);
|
|
410
|
+
const gen = await handSeal(f, "retract-wt", CLEAN_MUTATION, "nominated");
|
|
411
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
412
|
+
const wt2 = path.join(f.root, "wt2");
|
|
413
|
+
git(f, "worktree", "add", "-q", wt2, INCUMBENT_BRANCH);
|
|
414
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "x");
|
|
415
|
+
assert.equal(run.status, 1);
|
|
416
|
+
assert.match(run.stderr + run.stdout, /checked out/);
|
|
417
|
+
assert.equal(incumbentSha(f), gen.commitSha, "ref untouched while a linked worktree holds the branch");
|
|
418
|
+
});
|
|
419
|
+
test("retract echo hygiene: control bytes never reach stdout or the ledger file", async (t) => {
|
|
420
|
+
const f = await gateFixture(t);
|
|
421
|
+
const gen = await handSeal(f, "retract-evil", CLEAN_MUTATION, "nominated");
|
|
422
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
423
|
+
const evil = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "evil\n\u001b[31mred\u001b[0m reason");
|
|
424
|
+
assert.equal(evil.status, 0, `evil reason: ${evil.stderr}`);
|
|
425
|
+
assert.ok(!evil.stdout.includes("\u001b"), "raw ANSI never reaches stdout");
|
|
426
|
+
const raw = readFileSync(path.join(f.repo, ".state", "abathur", "ledger.jsonl"), "utf8");
|
|
427
|
+
assert.ok(!raw.includes("\u001b"), "no raw ESC byte in ledger text");
|
|
428
|
+
const stored = rowsOfKind(f, "retract")[0]?.data;
|
|
429
|
+
assert.equal(stored.reason, "evil\n\u001b[31mred\u001b[0m reason", "ledger stores verbatim (JSON-escaped on disk)");
|
|
430
|
+
});
|
|
431
|
+
test("gate locks: a live genome-lock holder blocks promote with a named pid (exit 2)", async (t) => {
|
|
432
|
+
const f = await gateFixture(t);
|
|
433
|
+
const gen = await handSeal(f, "lock-holder", CLEAN_MUTATION, "nominated");
|
|
434
|
+
const lease = acquireGenomeLock({ ledger: Ledger.open(f.repo), configDir: f.configDir, genomeFp: f.entry.fingerprint });
|
|
435
|
+
t.after(() => lease.release());
|
|
436
|
+
const run = cli(f, "promote", "toy-smoke", gen.genId);
|
|
437
|
+
assert.equal(run.status, 2, `promote while locked: ${run.stderr}`);
|
|
438
|
+
assert.match(run.stderr + run.stdout, /held by live pid/);
|
|
439
|
+
assert.equal(incumbentSha(f), null, "no ref movement while locked out");
|
|
440
|
+
});
|
|
441
|
+
test("gate locks: the repo-scoped gate lock contends across config homes", async (t) => {
|
|
442
|
+
const f = await gateFixture(t);
|
|
443
|
+
const gen = await handSeal(f, "repo-lock", CLEAN_MUTATION, "nominated");
|
|
444
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
445
|
+
// a second config home registering the SAME repo (different genome lock, same gate lock)
|
|
446
|
+
const configDir2 = path.join(f.root, "config2");
|
|
447
|
+
mkdirSync(configDir2, { recursive: true });
|
|
448
|
+
writeFileSync(path.join(configDir2, "config.jsonc"), '{ "opencodeBin": null }\n', "utf8");
|
|
449
|
+
const doc = JSON.parse(readFileSync(path.join(f.repo, "genome.jsonc"), "utf8"));
|
|
450
|
+
doc["label"] = "toy-smoke";
|
|
451
|
+
const spec2 = path.join(f.root, "private2.jsonc");
|
|
452
|
+
writeFileSync(spec2, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
453
|
+
registerGenome(configDir2, spec2);
|
|
454
|
+
const repoLease = acquireLock({ configDir: path.join(f.repo, ".state", "abathur"), key: "gate", label: "gate lock" });
|
|
455
|
+
t.after(() => repoLease.release());
|
|
456
|
+
const run = spawnSync(process.execPath, [CLI, "retract", "toy-smoke", gen.genId, "--reason", "x"], {
|
|
457
|
+
env: { ...process.env, ABATHUR_CONFIG: path.join(configDir2, "config.jsonc"), HOME: path.join(f.root, "home") },
|
|
458
|
+
encoding: "utf8",
|
|
459
|
+
cwd: f.root,
|
|
460
|
+
});
|
|
461
|
+
assert.equal(run.status, 2, `retract via second config while gate-locked: ${run.stderr}`);
|
|
462
|
+
assert.match(run.stderr + run.stdout, /gate lock 'gate' is held by live pid/);
|
|
463
|
+
assert.equal(incumbentSha(f), gen.commitSha, "ref untouched while gate-locked");
|
|
464
|
+
assert.equal(rowsOfKind(f, "retract").length, 0, "no row appended by the locked-out process");
|
|
465
|
+
});
|
|
466
|
+
test("retract refuses when the incumbent ref moved out-of-band after the promote", async (t) => {
|
|
467
|
+
const f = await gateFixture(t);
|
|
468
|
+
const gen = await handSeal(f, "diverge", CLEAN_MUTATION, "nominated");
|
|
469
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
470
|
+
git(f, "update-ref", `refs/heads/${INCUMBENT_BRANCH}`, gen.parent); // rewind out-of-band
|
|
471
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "x");
|
|
472
|
+
assert.equal(run.status, 1);
|
|
473
|
+
assert.match(run.stderr + run.stdout, /state diverged/);
|
|
474
|
+
assert.equal(incumbentSha(f), gen.parent, "the moved ref is NOT deleted");
|
|
475
|
+
});
|
|
476
|
+
test("promote after retract refuses — a retracted gen stays dead (no re-promote lockup)", async (t) => {
|
|
477
|
+
const f = await gateFixture(t);
|
|
478
|
+
const gen = await handSeal(f, "re-promote", CLEAN_MUTATION, "nominated");
|
|
479
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
480
|
+
assert.equal(cli(f, "retract", "toy-smoke", gen.genId, "--reason", "undo").status, 0);
|
|
481
|
+
const again = cli(f, "promote", "toy-smoke", gen.genId);
|
|
482
|
+
assert.equal(again.status, 1);
|
|
483
|
+
assert.match(again.stderr + again.stdout, /already promoted/);
|
|
484
|
+
});
|
|
485
|
+
test("retract: a PRUNABLE worktree (deleted directory) does not block the rollback valve", async (t) => {
|
|
486
|
+
const f = await gateFixture(t);
|
|
487
|
+
const gen = await handSeal(f, "retract-prunable", CLEAN_MUTATION, "nominated");
|
|
488
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
489
|
+
const wt2 = path.join(f.root, "wt-stale");
|
|
490
|
+
git(f, "worktree", "add", "-q", wt2, INCUMBENT_BRANCH);
|
|
491
|
+
rmSync(wt2, { recursive: true, force: true }); // directory gone, metadata stale ⇒ porcelain marks it prunable
|
|
492
|
+
const list = spawnSync("git", ["-C", f.repo, "worktree", "list", "--porcelain"], { encoding: "utf8" });
|
|
493
|
+
assert.match(list.stdout, /prunable/, "fixture sanity: git itself marks the stale worktree prunable");
|
|
494
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "stale worktree must not block");
|
|
495
|
+
assert.equal(run.status, 0, `retract: ${run.stderr}`);
|
|
496
|
+
assert.equal(incumbentSha(f), null);
|
|
497
|
+
});
|
|
498
|
+
test("retract refuses while the incumbent branch is checked out", async (t) => {
|
|
499
|
+
const f = await gateFixture(t);
|
|
500
|
+
const gen = await handSeal(f, "retract-checkout", CLEAN_MUTATION, "nominated");
|
|
501
|
+
assert.equal(cli(f, "promote", "toy-smoke", gen.genId).status, 0);
|
|
502
|
+
git(f, "checkout", "-q", INCUMBENT_BRANCH);
|
|
503
|
+
const run = cli(f, "retract", "toy-smoke", gen.genId, "--reason", "x");
|
|
504
|
+
assert.equal(run.status, 1);
|
|
505
|
+
assert.match(run.stderr + run.stdout, /checked out/i);
|
|
506
|
+
assert.equal(incumbentSha(f), gen.commitSha, "ref untouched while checked out");
|
|
507
|
+
});
|
|
318
508
|
// -------------------------------------------------------------------- status
|
|
319
509
|
test("AC(d): status renders incumbent, generations, quarantine depth, budget; never fabricates", async (t) => {
|
|
320
510
|
const f = await gateFixture(t);
|
|
@@ -368,7 +558,7 @@ test("genome rm: ledgerless genomes unregister (manifest stays), ledger history
|
|
|
368
558
|
assert.equal(empty.status, 0, "no ledger rows ⇒ still removable");
|
|
369
559
|
});
|
|
370
560
|
// -------------------------------------------------------------- structural
|
|
371
|
-
test("structural: promote
|
|
561
|
+
test("structural: promote/retract cores are CLI-only; no auto-gate import path; no --force anywhere", () => {
|
|
372
562
|
const importers = [];
|
|
373
563
|
const scan = (dir) => {
|
|
374
564
|
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -386,9 +576,36 @@ test("structural: promote core is CLI-only; no auto-promote import path; no --fo
|
|
|
386
576
|
scan(path.join(REPO_ROOT, "src"));
|
|
387
577
|
const relative = importers.map((p) => path.relative(REPO_ROOT, p).split(path.sep).join("/")).sort();
|
|
388
578
|
assert.deepEqual(relative, ["src/commands/promote.ts"], "core/promote imported ONLY by commands/promote");
|
|
579
|
+
const retractImporters = [];
|
|
580
|
+
const scanRetract = (dir) => {
|
|
581
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
582
|
+
const p = path.join(dir, ent.name);
|
|
583
|
+
if (ent.isDirectory()) {
|
|
584
|
+
if (ent.name !== "node_modules")
|
|
585
|
+
scanRetract(p);
|
|
586
|
+
}
|
|
587
|
+
else if (ent.name.endsWith(".ts")) {
|
|
588
|
+
const src = readFileSync(p, "utf8");
|
|
589
|
+
if (/(?:from\s+|import\(\s*)["'](?:[^"']*\/)?core\/retract\.js["']|from\s+["']\.\/retract\.js["']/.test(src))
|
|
590
|
+
retractImporters.push(p);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
scanRetract(path.join(REPO_ROOT, "src"));
|
|
595
|
+
const relRetract = retractImporters.map((p) => path.relative(REPO_ROOT, p).split(path.sep).join("/")).sort();
|
|
596
|
+
assert.deepEqual(relRetract, ["src/commands/retract.ts"], "core/retract imported ONLY by commands/retract");
|
|
389
597
|
for (const rel of ["src/core/evolve/run-loop.ts", "src/core/evolve/reflect.ts", "src/core/evolve/candidate.ts"]) {
|
|
390
598
|
assert.doesNotMatch(readFileSync(path.join(REPO_ROOT, rel), "utf8"), /from\s+"[^"]*promote\.js"/, `${rel} must not import promote`);
|
|
599
|
+
assert.doesNotMatch(readFileSync(path.join(REPO_ROOT, rel), "utf8"), /from\s+"[^"]*retract\.js"/, `${rel} must not import retract`);
|
|
600
|
+
}
|
|
601
|
+
for (const gateFile of ["src/core/promote.ts", "src/core/retract.ts"]) {
|
|
602
|
+
assert.doesNotMatch(readFileSync(path.join(REPO_ROOT, gateFile), "utf8"), /--force/, `no force flag in ${gateFile}`);
|
|
603
|
+
}
|
|
604
|
+
// the plugin tool allowlist must never grow a human gate (terminal-only class)
|
|
605
|
+
const pluginSrc = readFileSync(path.join(REPO_ROOT, "plugin", "abathur.ts"), "utf8");
|
|
606
|
+
const allowBlock = pluginSrc.match(/const ALLOWED_COMMANDS[\s\S]*?;/)?.[0] ?? "";
|
|
607
|
+
assert.ok(allowBlock.length > 0, "ALLOWED_COMMANDS block must be found — a vacuous match tests nothing");
|
|
608
|
+
for (const gate of ['"promote"', '"tombstone"', '"retract"']) {
|
|
609
|
+
assert.ok(!allowBlock.includes(gate), `${gate} must NOT be plugin-reachable`);
|
|
391
610
|
}
|
|
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
611
|
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// re-adjudicate (core) tests. The mechanism under test: replay the CURRENT gate over
|
|
2
|
+
// archived generation rows and append a corrected verdict with provenance, files immutable.
|
|
3
|
+
//
|
|
4
|
+
// Coverage: verdict flip culled->nominated (synthetic), honest no-flip (culled stays
|
|
5
|
+
// culled), idempotence after append, promote-row refusal, missing-incumbent refusal,
|
|
6
|
+
// corrupt-archive notice (never a crash), and the historical anchor: a sandboxed COPY of
|
|
7
|
+
// the real historian archives must reproduce the c17 re-adjudication (NOMINATED,
|
|
8
|
+
// gain 0.2917) that the driver computed offline on 2026-09-26 — the engine's own replay
|
|
9
|
+
// of the incident that born this command.
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, copyFileSync } from "node:fs";
|
|
13
|
+
import { mkdtempSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { readjudicateGeneration } from "../core/readjudicate.js";
|
|
17
|
+
import { ledgerRecordSchema, LEDGER_KIND_GENERATION_COMPLETE, LEDGER_KIND_PROMOTE } from "../core/ledger.js";
|
|
18
|
+
import { decodeGenerationRecord } from "../core/evolve/run-rows.js";
|
|
19
|
+
const STATS = { halfWidth: 0.15, minEffect: 0.1, nReps: { initial: 2, max: 3 } };
|
|
20
|
+
const BUDGET = { maxCandidates: 1, maxModelCalls: 96, maxTokens: 25_000_000, maxWallS: 26_000 };
|
|
21
|
+
function entryFor(repo) {
|
|
22
|
+
return {
|
|
23
|
+
fingerprint: "f".repeat(16),
|
|
24
|
+
label: "test-genome",
|
|
25
|
+
registryFile: "/dev/null",
|
|
26
|
+
storedText: "",
|
|
27
|
+
spec: {
|
|
28
|
+
label: "test-genome",
|
|
29
|
+
repoPath: repo,
|
|
30
|
+
bench: {
|
|
31
|
+
type: "opencode-fixture-scenarios",
|
|
32
|
+
units: [
|
|
33
|
+
{ id: "scenario-19", split: "train", path: "scenarios/19.md" },
|
|
34
|
+
{ id: "scenario-13", split: "val", path: "scenarios/13.md" },
|
|
35
|
+
],
|
|
36
|
+
runCommand: "true",
|
|
37
|
+
graderCommand: "true",
|
|
38
|
+
timeoutS: 1800,
|
|
39
|
+
stats: STATS,
|
|
40
|
+
},
|
|
41
|
+
budget: BUDGET,
|
|
42
|
+
kernel: { immutableGlobs: ["scenarios/**"] },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function row(o) {
|
|
47
|
+
const data = {
|
|
48
|
+
source: o.source,
|
|
49
|
+
headCommit: "1".repeat(40),
|
|
50
|
+
complete: true,
|
|
51
|
+
reps: o.units[0]?.scores.length ?? 0,
|
|
52
|
+
units: o.units.map((u) => ({ unitId: u.unitId, split: u.split, scores: u.scores, runIds: u.scores.map((_, i) => `r-${u.unitId}-${String(i)}`), failures: u.scores.filter((s) => s < 1).map((s) => `${u.unitId}: scored ${String(s)}`) })),
|
|
53
|
+
counters: { candidates: o.source === "candidate" ? 1 : 0, modelCalls: 10, tokens: 1000, wallS: 100 },
|
|
54
|
+
manifest: [],
|
|
55
|
+
benchProvenance: { benchType: "opencode-fixture-scenarios", versions: [{ bin: "opencode", version: "test" }] },
|
|
56
|
+
};
|
|
57
|
+
if (o.source === "candidate") {
|
|
58
|
+
data.candidateId = "mutate-live";
|
|
59
|
+
data.commitSha = "2".repeat(40);
|
|
60
|
+
data.treeSha = o.treeSha ?? "3".repeat(64);
|
|
61
|
+
}
|
|
62
|
+
if (o.verdict !== undefined)
|
|
63
|
+
data.verdict = o.verdict;
|
|
64
|
+
if (o.gain !== undefined)
|
|
65
|
+
data.gain = o.gain;
|
|
66
|
+
return `${JSON.stringify({ v: 1, ts: o.ts, kind: LEDGER_KIND_GENERATION_COMPLETE, genId: o.genId, runId: o.runId, data })}\n`;
|
|
67
|
+
}
|
|
68
|
+
function sandbox(t) {
|
|
69
|
+
const dir = mkdtempSync(path.join(tmpdir(), "readjud-"));
|
|
70
|
+
const state = path.join(dir, ".state", "abathur");
|
|
71
|
+
mkdirSync(state, { recursive: true });
|
|
72
|
+
writeFileSync(path.join(state, "ledger.jsonl"), "");
|
|
73
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
74
|
+
return { dir, state };
|
|
75
|
+
}
|
|
76
|
+
function cfg(t) {
|
|
77
|
+
const d = mkdtempSync(path.join(tmpdir(), "readjud-cfg-"));
|
|
78
|
+
t.after(() => rmSync(d, { recursive: true, force: true }));
|
|
79
|
+
return d;
|
|
80
|
+
}
|
|
81
|
+
function lastRows(stateDir) {
|
|
82
|
+
return readFileSync(path.join(stateDir, "ledger.jsonl"), "utf8")
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter((l) => l.trim().length > 0)
|
|
85
|
+
.map((l) => {
|
|
86
|
+
const parsed = ledgerRecordSchema.parse(JSON.parse(l));
|
|
87
|
+
return parsed;
|
|
88
|
+
})
|
|
89
|
+
.filter((r) => r.kind === LEDGER_KIND_GENERATION_COMPLETE)
|
|
90
|
+
.map((r) => {
|
|
91
|
+
try {
|
|
92
|
+
return decodeGenerationRecord(r);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
.filter((d) => d !== null);
|
|
99
|
+
}
|
|
100
|
+
test("flip: culled row re-judged nominated by the current gate; corrected row appended with provenance", (t) => {
|
|
101
|
+
const { dir, state } = sandbox(t);
|
|
102
|
+
// one rotated archive with the whole epoch (activation law: bank needs an archive)
|
|
103
|
+
const lines = [
|
|
104
|
+
// historical incumbent groups that price a small-ish sigma on both units
|
|
105
|
+
row({ genId: "g-old-a", runId: "r-old", ts: "2026-09-20T00:00:00.000Z", source: "incumbent", units: [{ unitId: "scenario-19", split: "train", scores: [0.875, 1, 0.875, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1, 1, 1] }] }),
|
|
106
|
+
// the epoch under re-adjudication
|
|
107
|
+
row({ genId: "g-epoch", runId: "run-1", ts: "2026-09-21T00:00:00.000Z", source: "incumbent", units: [{ unitId: "scenario-19", split: "train", scores: [0.75, 0.875] }, { unitId: "scenario-13", split: "val", scores: [1, 1] }], verdict: undefined }),
|
|
108
|
+
row({ genId: "g-epoch-cand", runId: "run-1", ts: "2026-09-21T01:00:00.000Z", source: "candidate", units: [{ unitId: "scenario-19", split: "train", scores: [1, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1] }], verdict: "culled", gain: 0.1 }),
|
|
109
|
+
];
|
|
110
|
+
writeFileSync(path.join(state, "ledger.campaign1-2026-09-21.jsonl"), lines.join(""));
|
|
111
|
+
const out = readjudicateGeneration({ entry: entryFor(dir), configDir: cfg(t), genId: "g-epoch-cand", now: () => new Date("2026-09-22T00:00:00Z") });
|
|
112
|
+
assert.equal(out.appended, true, out.lines.join("\n"));
|
|
113
|
+
assert.equal(out.verdict, "nominated", out.lines.join("\n"));
|
|
114
|
+
const appended = lastRows(state).at(-1);
|
|
115
|
+
assert.ok(appended !== undefined);
|
|
116
|
+
assert.equal(appended.verdict, "nominated");
|
|
117
|
+
assert.ok(appended.readjudication !== undefined, "provenance must be present");
|
|
118
|
+
assert.equal(appended.readjudication.replacedVerdict, "culled");
|
|
119
|
+
assert.equal(appended.readjudication.gate, "acceptance");
|
|
120
|
+
assert.equal(appended.readjudication.sourceRows.candidate.file, "ledger.campaign1-2026-09-21.jsonl");
|
|
121
|
+
// archive untouched: still exactly 3 rows, original culled row intact
|
|
122
|
+
assert.equal(readFileSync(path.join(state, "ledger.campaign1-2026-09-21.jsonl"), "utf8").trim().split("\n").length, 3);
|
|
123
|
+
// idempotence: second pass sees the corrected last row -> no-op
|
|
124
|
+
const again = readjudicateGeneration({ entry: entryFor(dir), configDir: cfg(t), genId: "g-epoch-cand", now: () => new Date("2026-09-22T00:00:00Z") });
|
|
125
|
+
assert.equal(again.appended, false, again.lines.join("\n"));
|
|
126
|
+
assert.match(again.lines.join("\n"), /no-op/);
|
|
127
|
+
});
|
|
128
|
+
test("honest replay: a weak candidate stays culled — the tool re-judges, it never flips", (t) => {
|
|
129
|
+
const { dir, state } = sandbox(t);
|
|
130
|
+
const lines = [
|
|
131
|
+
row({ genId: "g-old-a", runId: "r-old", ts: "2026-09-20T00:00:00.000Z", source: "incumbent", units: [{ unitId: "scenario-19", split: "train", scores: [1, 1, 1, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1, 1, 1] }] }),
|
|
132
|
+
row({ genId: "g-epoch", runId: "run-1", ts: "2026-09-21T00:00:00.000Z", source: "incumbent", units: [{ unitId: "scenario-19", split: "train", scores: [1, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1] }] }),
|
|
133
|
+
row({ genId: "g-epoch-cand", runId: "run-1", ts: "2026-09-21T01:00:00.000Z", source: "candidate", units: [{ unitId: "scenario-19", split: "train", scores: [1, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1] }], verdict: "culled", gain: 0 }),
|
|
134
|
+
];
|
|
135
|
+
writeFileSync(path.join(state, "ledger.campaign1-2026-09-21.jsonl"), lines.join(""));
|
|
136
|
+
const out = readjudicateGeneration({ entry: entryFor(dir), configDir: cfg(t), genId: "g-epoch-cand", now: () => new Date() });
|
|
137
|
+
assert.equal(out.verdict, "culled", out.lines.join("\n"));
|
|
138
|
+
assert.equal(out.appended, false, "already-agreeing row must not append");
|
|
139
|
+
});
|
|
140
|
+
test("refusals: promoted gen, missing incumbent, unknown gen — all block before any append", (t) => {
|
|
141
|
+
const { dir, state } = sandbox(t);
|
|
142
|
+
const epoch = [
|
|
143
|
+
row({ genId: "g-epoch", runId: "run-9", ts: "2026-09-21T00:00:00.000Z", source: "incumbent", units: [{ unitId: "scenario-19", split: "train", scores: [0.75, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1] }] }),
|
|
144
|
+
row({ genId: "g-epoch-cand", runId: "run-9", ts: "2026-09-21T01:00:00.000Z", source: "candidate", units: [{ unitId: "scenario-19", split: "train", scores: [1, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1] }], verdict: "culled", gain: 0.1 }),
|
|
145
|
+
];
|
|
146
|
+
writeFileSync(path.join(state, "ledger.campaign1-2026-09-21.jsonl"), epoch.join(""));
|
|
147
|
+
// promoted epoch is untouchable
|
|
148
|
+
const promotedRun = "run-p";
|
|
149
|
+
writeFileSync(path.join(state, "ledger.campaign2-2026-09-22.jsonl"), epoch.join("") + `${JSON.stringify({ v: 1, ts: "2026-09-22T00:00:00.000Z", kind: LEDGER_KIND_PROMOTE, genId: "g-epoch-cand", runId: promotedRun, data: { actor: "cli", genId: "g-epoch-cand", from: null, to: "2".repeat(40) } })}\n`);
|
|
150
|
+
assert.throws(() => readjudicateGeneration({ entry: entryFor(dir), configDir: cfg(t), genId: "g-epoch-cand", now: () => new Date() }), (e) => /already promoted/.test(String(e.message ?? e)));
|
|
151
|
+
// unknown gen
|
|
152
|
+
assert.throws(() => readjudicateGeneration({ entry: entryFor(dir), configDir: cfg(t), genId: "g-nope", now: () => new Date() }), (e) => /no candidate generation_complete row/.test(String(e.message ?? e)));
|
|
153
|
+
// candidate without its incumbent: run-1 incumbent exists here, so cut a state with candidate only
|
|
154
|
+
const { dir: d2, state: s2 } = sandbox(t);
|
|
155
|
+
writeFileSync(path.join(s2, "ledger.campaign1-2026-09-21.jsonl"), row({ genId: "g-solo", runId: "run-s", ts: "2026-09-21T01:00:00.000Z", source: "candidate", units: [{ unitId: "scenario-19", split: "train", scores: [1, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1] }], verdict: "culled", gain: 0.1 }));
|
|
156
|
+
assert.throws(() => readjudicateGeneration({ entry: entryFor(d2), configDir: cfg(t), genId: "g-solo", now: () => new Date() }), (e) => /no incumbent baseline row/.test(String(e.message ?? e)));
|
|
157
|
+
});
|
|
158
|
+
test("corrupt archive lines are notices, never a crash, and never poison the replay", (t) => {
|
|
159
|
+
const { dir, state } = sandbox(t);
|
|
160
|
+
const good = [
|
|
161
|
+
row({ genId: "g-old-a", runId: "r-old", ts: "2026-09-20T00:00:00.000Z", source: "incumbent", units: [{ unitId: "scenario-19", split: "train", scores: [0.875, 1, 0.875, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1, 1, 1] }] }),
|
|
162
|
+
row({ genId: "g-epoch", runId: "run-1", ts: "2026-09-21T00:00:00.000Z", source: "incumbent", units: [{ unitId: "scenario-19", split: "train", scores: [0.75, 0.875] }, { unitId: "scenario-13", split: "val", scores: [1, 1] }] }),
|
|
163
|
+
row({ genId: "g-epoch-cand", runId: "run-1", ts: "2026-09-21T01:00:00.000Z", source: "candidate", units: [{ unitId: "scenario-19", split: "train", scores: [1, 1] }, { unitId: "scenario-13", split: "val", scores: [1, 1] }], verdict: "culled", gain: 0.1 }),
|
|
164
|
+
];
|
|
165
|
+
writeFileSync(path.join(state, "ledger.campaign1-2026-09-21.jsonl"), `not json at all\n${good.join("")}{"v":9,"kind":"broken-schema"}\n`);
|
|
166
|
+
const out = readjudicateGeneration({ entry: entryFor(dir), configDir: cfg(t), genId: "g-epoch-cand", now: () => new Date("2026-09-22T00:00:00Z") });
|
|
167
|
+
assert.equal(out.verdict, "nominated", out.lines.join("\n"));
|
|
168
|
+
assert.ok(out.lines.some((l) => l.includes("not JSON")), "skip notice must surface");
|
|
169
|
+
});
|
|
170
|
+
// ------------------------------------------------------------------ historical anchor
|
|
171
|
+
// The incident replay: c17's real rows, judged first by the silently-downgraded engine
|
|
172
|
+
// (culled), then re-adjudicated offline to NOMINATED gain 0.2917 (start-work L141).
|
|
173
|
+
// A sandboxed copy of the real archives run through this command must reproduce that
|
|
174
|
+
// verdict with the same numbers — the engine re-judging its own history correctly.
|
|
175
|
+
// Skips (never fails) when the historian state files are absent (CI elsewhere).
|
|
176
|
+
test("anchor: real c17 archives replay to NOMINATED 0.2917 (copy, no mutation)", (t) => {
|
|
177
|
+
// located via env, never a hardcoded homedir (release hygiene); skipped wherever the
|
|
178
|
+
// historian repo is not wired — this anchor guards the OPERATOR's machine, CI has no history.
|
|
179
|
+
const hist = process.env["ABATHUR_HISTORIAN_REPO"];
|
|
180
|
+
if (hist === undefined || hist.length === 0) {
|
|
181
|
+
t.skip("ABATHUR_HISTORIAN_REPO unset — anchor needs the real archives");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const real = path.join(hist, ".state", "abathur");
|
|
185
|
+
let names;
|
|
186
|
+
try {
|
|
187
|
+
names = readdirSync(real);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
t.skip("historian .state/abathur not present on this machine");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const archives = names.filter((n) => n.startsWith("ledger.") && n.endsWith(".jsonl"));
|
|
194
|
+
if (!archives.some((n) => n.includes("campaign17"))) {
|
|
195
|
+
t.skip("campaign17 archive not present");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const { dir, state } = sandbox(t);
|
|
199
|
+
for (const n of archives)
|
|
200
|
+
copyFileSync(path.join(real, n), path.join(state, n));
|
|
201
|
+
writeFileSync(path.join(state, "ledger.jsonl"), ""); // copy of live would carry future rows — start empty
|
|
202
|
+
// spec: mirror the historian constitution's anchors exactly (stats/budget as registered)
|
|
203
|
+
const spec = entryFor(dir);
|
|
204
|
+
spec.spec.bench.stats = STATS;
|
|
205
|
+
const out = readjudicateGeneration({ entry: spec, configDir: cfg(t), genId: "g-20260925T150918Z-9969e4b6", now: () => new Date("2026-09-28T00:00:00Z") });
|
|
206
|
+
assert.equal(out.verdict, "nominated", out.lines.join("\n"));
|
|
207
|
+
const appended = lastRows(state).at(-1);
|
|
208
|
+
assert.ok(appended?.readjudication !== undefined);
|
|
209
|
+
assert.ok(Math.abs((appended.gain ?? 0) - 0.2916666666666667) < 1e-9, `gain ${String(appended.gain)} must match the offline replay 0.2917`);
|
|
210
|
+
assert.equal(appended.readjudication.replacedVerdict, "culled");
|
|
211
|
+
assert.equal(appended.readjudication.sourceRows.candidate.file, "ledger.campaign17-2026-09-25.jsonl");
|
|
212
|
+
});
|
|
@@ -130,7 +130,7 @@ async function sessionFixture(t) {
|
|
|
130
130
|
// plant the canary in the VAL unit and commit it, so it rides into worktrees
|
|
131
131
|
const sub = path.join(repo, "units", "sub.mjs");
|
|
132
132
|
writeFileSync(sub, `${readFileSync(sub, "utf8")}\n// ${CANARY}\n`, "utf8");
|
|
133
|
-
await gitIn(repo, "-c", "user.name=abathur", "-c", "user.email=
|
|
133
|
+
await gitIn(repo, "-c", "user.name=abathur", "-c", "user.email=agent@host.example", "commit", "-aqm", "plant val canary");
|
|
134
134
|
const spec = loadGenomeSpecFile(path.join(repo, "genome.jsonc"));
|
|
135
135
|
const env = {
|
|
136
136
|
XDG_CACHE_HOME: path.join(root, "xdg-cache"),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Tripwire (postmortem 863e6c2): the env-literal repoPath defect class was
|
|
2
|
+
// caused by per-call-site resolution being a CONVENTION — every new FS
|
|
3
|
+
// consumer had to remember to call effectiveRepoPath, and promote/tombstone
|
|
4
|
+
// forgot for 18 days. This test makes the convention mechanical: any line
|
|
5
|
+
// on the command/human-gate surface that touches `spec.repoPath` must either
|
|
6
|
+
// resolve it through effectiveRepoPath(...) on the same line, or carry an
|
|
7
|
+
// inline waiver comment `raw-repoPath: <reason>` documenting WHY the raw
|
|
8
|
+
// stored form is correct there (display strings, self-mode literal
|
|
9
|
+
// detection, post-swap pipeline paths). A new command that forgets now
|
|
10
|
+
// fails CI instead of failing a human gate 18 days later.
|
|
11
|
+
//
|
|
12
|
+
// Scope note: engine-internal pipeline modules (src/core/evolve/**,
|
|
13
|
+
// src/core/graft*.ts, src/core/bundle-*.ts, src/bench/**) operate on
|
|
14
|
+
// candidate-worktree-swapped absolute paths by design (run-loop.ts swaps
|
|
15
|
+
// spec.repoPath before invoking them) and are out of scope here; the ledger
|
|
16
|
+
// FS seam additionally resolves unconditionally in ledgerPath().
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { test } from "node:test";
|
|
22
|
+
// dist/test/*.js -> repo src/ (tests run from the built tree but guard SOURCE).
|
|
23
|
+
const SRC = fileURLToPath(new URL("../../src/", import.meta.url));
|
|
24
|
+
const GUARDED_FILES = [
|
|
25
|
+
...readdirSync(path.join(SRC, "commands"))
|
|
26
|
+
.filter((f) => f.endsWith(".ts"))
|
|
27
|
+
.map((f) => path.join(SRC, "commands", f)),
|
|
28
|
+
path.join(SRC, "core", "promote.ts"),
|
|
29
|
+
path.join(SRC, "core", "tombstone.ts"),
|
|
30
|
+
path.join(SRC, "core", "retract.ts"),
|
|
31
|
+
];
|
|
32
|
+
function collectHits(file) {
|
|
33
|
+
const hits = [];
|
|
34
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
35
|
+
lines.forEach((line, i) => {
|
|
36
|
+
if (!line.includes("spec.repoPath"))
|
|
37
|
+
return;
|
|
38
|
+
if (line.includes("effectiveRepoPath("))
|
|
39
|
+
return;
|
|
40
|
+
if (line.includes("raw-repoPath:"))
|
|
41
|
+
return;
|
|
42
|
+
hits.push(`${path.relative(SRC, file)}:${String(i + 1)}: ${line.trim()}`);
|
|
43
|
+
});
|
|
44
|
+
return hits;
|
|
45
|
+
}
|
|
46
|
+
test("command/gate surface: every spec.repoPath use resolves via effectiveRepoPath or waives in writing", () => {
|
|
47
|
+
const violations = [];
|
|
48
|
+
for (const file of GUARDED_FILES) {
|
|
49
|
+
if (!statSync(file, { throwIfNoEntry: false }))
|
|
50
|
+
continue;
|
|
51
|
+
violations.push(...collectHits(file));
|
|
52
|
+
}
|
|
53
|
+
assert.deepEqual(violations, [], "raw spec.repoPath uses without effectiveRepoPath() or a `raw-repoPath:` waiver:\n" +
|
|
54
|
+
violations.join("\n"));
|
|
55
|
+
});
|