@tachikomagundam/abathur 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.github/workflows/ci.yml +29 -0
  2. package/.github/workflows/publish.yml +74 -0
  3. package/LICENSE +21 -0
  4. package/README.md +461 -0
  5. package/config/abathur.jsonc +17 -0
  6. package/config/genomes/historian.example.jsonc +124 -0
  7. package/dist/bench/adapter.js +201 -0
  8. package/dist/bench/fixture-probe.js +92 -0
  9. package/dist/bench/fixture-support.js +173 -0
  10. package/dist/bench/fixture.js +236 -0
  11. package/dist/bench/toy.js +152 -0
  12. package/dist/cli.js +110 -0
  13. package/dist/commands/bundle.js +79 -0
  14. package/dist/commands/genome.js +94 -0
  15. package/dist/commands/graft.js +71 -0
  16. package/dist/commands/kernel.js +47 -0
  17. package/dist/commands/promote.js +25 -0
  18. package/dist/commands/run.js +145 -0
  19. package/dist/commands/self-eval.js +240 -0
  20. package/dist/commands/status.js +186 -0
  21. package/dist/commands/tombstone.js +72 -0
  22. package/dist/config.js +161 -0
  23. package/dist/core/bundle-common.js +119 -0
  24. package/dist/core/bundle-export.js +212 -0
  25. package/dist/core/bundle-inspect.js +143 -0
  26. package/dist/core/bundle-manifest.js +105 -0
  27. package/dist/core/bundle-mask.js +75 -0
  28. package/dist/core/bundle-tar.js +240 -0
  29. package/dist/core/bundle.js +9 -0
  30. package/dist/core/evolve/brief.js +45 -0
  31. package/dist/core/evolve/candidate.js +140 -0
  32. package/dist/core/evolve/child-track.js +197 -0
  33. package/dist/core/evolve/friction.js +150 -0
  34. package/dist/core/evolve/reflect.js +191 -0
  35. package/dist/core/evolve/run-bench.js +170 -0
  36. package/dist/core/evolve/run-friction.js +63 -0
  37. package/dist/core/evolve/run-loop.js +282 -0
  38. package/dist/core/evolve/run-plan.js +39 -0
  39. package/dist/core/evolve/run-rows.js +145 -0
  40. package/dist/core/evolve/self-overlay.js +213 -0
  41. package/dist/core/evolve/self-snapshot.js +170 -0
  42. package/dist/core/evolve/stub-mutators.mjs +105 -0
  43. package/dist/core/evolve/udiff.js +189 -0
  44. package/dist/core/genome-paths.js +76 -0
  45. package/dist/core/genome.js +176 -0
  46. package/dist/core/glob.js +106 -0
  47. package/dist/core/graft-gates.js +184 -0
  48. package/dist/core/graft-rebench.js +187 -0
  49. package/dist/core/graft-support.js +181 -0
  50. package/dist/core/graft.js +218 -0
  51. package/dist/core/ids.js +154 -0
  52. package/dist/core/incumbent.js +46 -0
  53. package/dist/core/kernel.js +112 -0
  54. package/dist/core/ledger.js +198 -0
  55. package/dist/core/locks.js +172 -0
  56. package/dist/core/promote.js +119 -0
  57. package/dist/core/snapshot.js +61 -0
  58. package/dist/core/spec.js +178 -0
  59. package/dist/core/stats-math.js +102 -0
  60. package/dist/core/stats-pareto.js +57 -0
  61. package/dist/core/stats.js +184 -0
  62. package/dist/core/worktree.js +190 -0
  63. package/dist/exit.js +32 -0
  64. package/dist/genomes/toy-smoke/genome.jsonc +30 -0
  65. package/dist/genomes/toy-smoke/grader.mjs +61 -0
  66. package/dist/genomes/toy-smoke/init.mjs +63 -0
  67. package/dist/genomes/toy-smoke/units/add.mjs +17 -0
  68. package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
  69. package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
  70. package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
  71. package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
  72. package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
  73. package/dist/jsonc.js +77 -0
  74. package/dist/out.js +5 -0
  75. package/dist/test/bench-adapter.test.js +33 -0
  76. package/dist/test/bench-fixture.test.js +407 -0
  77. package/dist/test/bench-toy.test.js +251 -0
  78. package/dist/test/bundle.test.js +659 -0
  79. package/dist/test/config.test.js +185 -0
  80. package/dist/test/d7-gate.test.js +56 -0
  81. package/dist/test/fixture-loop.test.js +267 -0
  82. package/dist/test/fixtures/friction-writer.js +16 -0
  83. package/dist/test/fixtures-historian.js +82 -0
  84. package/dist/test/fixtures-self.js +143 -0
  85. package/dist/test/fixtures-wt.js +64 -0
  86. package/dist/test/friction.test.js +398 -0
  87. package/dist/test/genome.test.js +453 -0
  88. package/dist/test/git.test.js +69 -0
  89. package/dist/test/graft.test.js +567 -0
  90. package/dist/test/historian-genome.test.js +134 -0
  91. package/dist/test/historian-grader-io.test.js +148 -0
  92. package/dist/test/historian-grader.test.js +209 -0
  93. package/dist/test/ids.test.js +116 -0
  94. package/dist/test/include-val.test.js +120 -0
  95. package/dist/test/ledger-lock.test.js +99 -0
  96. package/dist/test/ledger.test.js +102 -0
  97. package/dist/test/promote.test.js +394 -0
  98. package/dist/test/reflect.test.js +410 -0
  99. package/dist/test/run-loop.test.js +433 -0
  100. package/dist/test/self-snapshot.test.js +328 -0
  101. package/dist/test/snapshot.test.js +86 -0
  102. package/dist/test/stats.test.js +423 -0
  103. package/dist/test/stub-mutators.test.js +17 -0
  104. package/dist/test/testutil.js +30 -0
  105. package/dist/test/worktree.test.js +198 -0
  106. package/dist/util/freeze.js +30 -0
  107. package/dist/util/git.js +85 -0
  108. package/docs/federation.md +184 -0
  109. package/docs/immutable-kernel.md +87 -0
  110. package/graders/historian/grader-core.d.mts +53 -0
  111. package/graders/historian/grader-core.mjs +276 -0
  112. package/graders/historian/grader-support.d.mts +57 -0
  113. package/graders/historian/grader-support.mjs +137 -0
  114. package/graders/historian/grader.mjs +113 -0
  115. package/graders/historian/mutate.sh +114 -0
  116. package/graders/historian/reset-sandbox.sh +60 -0
  117. package/graders/historian/run-scenario.sh +49 -0
  118. package/graders/historian/seed-wrapped.sh +32 -0
  119. package/package.json +42 -0
@@ -0,0 +1,76 @@
1
+ // Shared path scheme + input discipline for the generation store (todo 3).
2
+ //
3
+ // $XDG_CACHE_HOME/abathur/worktrees/<genomeFp>/<genId> generation worktrees
4
+ // $XDG_CACHE_HOME/abathur/worktrees/<genomeFp>/snapshots/<sha> read-only commit copies
5
+ // fallback root when XDG_CACHE_HOME is unset/empty/relative: ~/.cache/abathur/worktrees
6
+ //
7
+ // All ids arrive as plain strings (todo 2's ids.ts owns their generation — no
8
+ // import here). Segments are validated to a conservative charset so a hostile
9
+ // or buggy id can never escape the cache dir ("../"), start with "-" (argv
10
+ // option injection), or contain shell metacharacters (irrelevant to execFile
11
+ // argv, but such paths break every downstream tool that does use a shell).
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import { cannotAnswer } from "../exit.js";
15
+ /** Reserved child name inside <genomeFp>/ — generation ids may not use it. */
16
+ export const SNAPSHOTS_DIR = "snapshots";
17
+ /** One safe path segment: alnum start, then [A-Za-z0-9._-] — no "/", no "..", no leading "-". */
18
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
19
+ /** sha/ref spelling a caller may hand to rev-parse/worktree add: alnum start, no leading "-". */
20
+ const REV = /^[A-Za-z0-9][A-Za-z0-9._/@{}^~-]*$/;
21
+ const HEX_SHA = /^[0-9a-f]{7,64}$/i;
22
+ function requireSegment(value, what) {
23
+ if (!SAFE_SEGMENT.test(value)) {
24
+ cannotAnswer(`worktree: unsafe ${what} "${value}" — expected a single safe path segment`, "fingerprints and generation ids come from ids.ts / CLI args; refuse traversal or option-like values");
25
+ }
26
+ return value;
27
+ }
28
+ /** Validate a commit sha (hex, 7..64) and normalize to lowercase (dir names are content-addressed). */
29
+ export function requireSha(value, what = "commit sha") {
30
+ if (!HEX_SHA.test(value)) {
31
+ cannotAnswer(`worktree: unsafe ${what} "${value}" — expected a hex object id`);
32
+ }
33
+ return value.toLowerCase();
34
+ }
35
+ /** Validate a revision spelling (sha or simple ref name) safe for argv. */
36
+ export function requireRev(value) {
37
+ if (!REV.test(value)) {
38
+ cannotAnswer(`worktree: unsafe parent revision "${value}" — expected a sha or plain ref name`);
39
+ }
40
+ return value;
41
+ }
42
+ function resolveHome(env) {
43
+ return env.HOME === undefined || env.HOME.length === 0 ? os.homedir() : env.HOME;
44
+ }
45
+ /** Root for all generation worktrees/snapshots: $XDG_CACHE_HOME/abathur/worktrees else ~/.cache/... */
46
+ export function cacheRoot(env = process.env) {
47
+ const xdg = env.XDG_CACHE_HOME;
48
+ const base = xdg !== undefined && xdg.length > 0 && path.isAbsolute(xdg)
49
+ ? xdg
50
+ : path.join(resolveHome(env), ".cache");
51
+ return path.join(base, "abathur", "worktrees");
52
+ }
53
+ /** <cacheRoot>/<genomeFp> (fingerprint validated). */
54
+ export function genomeDir(env, genome) {
55
+ return path.join(cacheRoot(env), requireSegment(genome.genomeFp, "genome fingerprint"));
56
+ }
57
+ /** <cacheRoot>/<genomeFp>/<genId> (both validated; reserved snapshot dir name refused). */
58
+ export function generationPath(env, genome, genId) {
59
+ const id = requireSegment(genId, "generation id");
60
+ if (id === SNAPSHOTS_DIR) {
61
+ cannotAnswer(`worktree: "${SNAPSHOTS_DIR}" is a reserved generation id`);
62
+ }
63
+ return path.join(genomeDir(env, genome), id);
64
+ }
65
+ /** <cacheRoot>/<genomeFp>/snapshots (fingerprint validated). */
66
+ export function snapshotsDir(env, genome) {
67
+ return path.join(genomeDir(env, genome), SNAPSHOTS_DIR);
68
+ }
69
+ /** Narrow WorktreeOptions to GitRunOptions for one cwd (exactOptionalPropertyTypes-friendly). */
70
+ export function gitOpts(o, cwd) {
71
+ return {
72
+ cwd,
73
+ ...(o.timeoutMs === undefined ? {} : { timeoutMs: o.timeoutMs }),
74
+ ...(o.bin === undefined ? {} : { bin: o.bin }),
75
+ };
76
+ }
@@ -0,0 +1,176 @@
1
+ // Genome registry (todo 4): <configDir>/genomes/<fingerprint16>.jsonc + kernel
2
+ // manifests under <configDir>/kernels/<fingerprint16>.json — structurally outside
3
+ // every genome repo tree, so evolution can never mutate its own identity store.
4
+ // Identity is ONLY the content fingerprint (sha256 of canonical spec JSON via
5
+ // todo 2's ids.ts); labels are free and may repeat across distinct genomes.
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { blocked, cannotAnswer } from "../exit.js";
9
+ import { parseJsonc } from "../jsonc.js";
10
+ import { canonicalJson, fingerprint } from "./ids.js";
11
+ import { filesMatching } from "./glob.js";
12
+ import { buildManifest, compareManifest, manifestPathFor, parseManifestText, readManifestFile, serializeManifest, } from "./kernel.js";
13
+ import { effectiveRepoPath, errorText, formatZodIssues, genomeSpecSchema, loadGenomeSpecFile, } from "./spec.js";
14
+ export function registryDirOf(configDir) {
15
+ return path.join(configDir, "genomes");
16
+ }
17
+ /** Stable 16-char content fingerprint of a parsed spec (ids.ts sha256, truncated). */
18
+ export function fingerprint16(spec) {
19
+ return fingerprint(spec).slice(0, 16);
20
+ }
21
+ function requireRepoPath(spec) {
22
+ const root = effectiveRepoPath(spec.repoPath);
23
+ let isDirectory = false;
24
+ try {
25
+ isDirectory = statSync(root).isDirectory();
26
+ }
27
+ catch {
28
+ isDirectory = false;
29
+ }
30
+ if (!isDirectory) {
31
+ cannotAnswer(`genome: repoPath '${spec.repoPath}' is not a readable directory`);
32
+ }
33
+ return root;
34
+ }
35
+ /** Fail-closed: a seal pattern covering zero existing files is a spec error (exit 2). */
36
+ function requireSealedCoverage(repoRoot, spec) {
37
+ for (const glob of spec.kernel.immutableGlobs) {
38
+ if (filesMatching(repoRoot, [glob]).length === 0) {
39
+ cannotAnswer(`genome: kernel.immutableGlobs pattern '${glob}' matches no existing file under ${repoRoot} — ` +
40
+ "fail-closed: every seal pattern must cover at least one file");
41
+ }
42
+ }
43
+ }
44
+ /**
45
+ * Superset rule (plan round-2 #2). EXACT semantics: for every registered genome whose
46
+ * repoPath resolves to the same directory (any label, any fingerprint except this
47
+ * one), every file that each of its existing globs matches IN THE CURRENT WORKING
48
+ * TREE must be matched by at least one glob of the incoming spec. Resolved-pattern
49
+ * comparison means vacuous/stale patterns (matching nothing now) pass trivially, and
50
+ * equivalence is judged by file coverage, not string equality.
51
+ */
52
+ function enforceGlobSuperset(configDir, spec, repoRoot) {
53
+ const incoming = new Set(filesMatching(repoRoot, spec.kernel.immutableGlobs));
54
+ const self = fingerprint16(spec);
55
+ const weakened = [];
56
+ for (const entry of readRegistry(configDir).entries) {
57
+ if (entry.fingerprint === self)
58
+ continue; // same-fp re-add ⇒ manifest byte-compare
59
+ if (effectiveRepoPath(entry.spec.repoPath) !== repoRoot)
60
+ continue;
61
+ for (const existing of entry.spec.kernel.immutableGlobs) {
62
+ const uncovered = filesMatching(repoRoot, [existing]).filter((f) => !incoming.has(f));
63
+ if (uncovered.length > 0 && !weakened.some((w) => w.startsWith(`'${existing}'`))) {
64
+ weakened.push(`'${existing}' (no longer seals e.g. ${uncovered.slice(0, 3).join(", ")})`);
65
+ }
66
+ }
67
+ }
68
+ if (weakened.length > 0) {
69
+ blocked(`genome add refused: incoming kernel.immutableGlobs weaken existing seals for ${repoRoot}: ` +
70
+ `${weakened.join("; ")} — sealed globs may only grow until a promote reseals`);
71
+ }
72
+ }
73
+ /**
74
+ * Re-add of an EXISTING fingerprint: byte-compare the recomputed kernel manifest
75
+ * against the stored one. Equal ⇒ clean no-op; anything else ⇒ exit 1. This path
76
+ * NEVER writes — tampering is resolved only at promote (todo 10), never by
77
+ * resealing through `genome add`.
78
+ */
79
+ function refuseReseal(configDir, fp, spec, freshManifestText) {
80
+ const filePath = manifestPathFor(configDir, fp);
81
+ let saved;
82
+ try {
83
+ saved = readManifestFile(filePath);
84
+ }
85
+ catch (cause) {
86
+ blocked(`genome add refused: ${errorText(cause)} — refusing to reseal '${spec.label}' (${fp}); ` +
87
+ "kernel resealing happens only at promote");
88
+ }
89
+ const drifted = compareManifest(saved, parseManifestText(freshManifestText, filePath));
90
+ if (drifted.length > 0) {
91
+ const listed = drifted.map((d) => `${d.path} (${d.kind})`);
92
+ const more = listed.length > 10 ? ` (+${String(listed.length - 10)} more)` : "";
93
+ blocked(`genome add refused: kernel drifted for '${spec.label}' (${fp}): ` +
94
+ `${listed.slice(0, 10).join(", ")}${more} — re-add never reseals; ` +
95
+ "restore the tampered files or resolve drift at promote");
96
+ }
97
+ }
98
+ export function registerGenome(configDir, specFilePath) {
99
+ const spec = loadGenomeSpecFile(specFilePath);
100
+ const repoRoot = requireRepoPath(spec);
101
+ requireSealedCoverage(repoRoot, spec);
102
+ enforceGlobSuperset(configDir, spec, repoRoot);
103
+ const fp = fingerprint16(spec);
104
+ const registryFile = path.join(registryDirOf(configDir), `${fp}.jsonc`);
105
+ const manifestText = serializeManifest(buildManifest(repoRoot, spec.kernel.immutableGlobs));
106
+ if (existsSync(registryFile)) {
107
+ refuseReseal(configDir, fp, spec, manifestText);
108
+ return { kind: "already-sealed", fingerprint: fp, label: spec.label };
109
+ }
110
+ mkdirSync(registryDirOf(configDir), { recursive: true });
111
+ mkdirSync(path.dirname(manifestPathFor(configDir, fp)), { recursive: true });
112
+ writeFileSync(registryFile, `${canonicalJson(spec)}\n`, "utf8");
113
+ writeFileSync(manifestPathFor(configDir, fp), manifestText, "utf8");
114
+ return { kind: "registered", fingerprint: fp, label: spec.label };
115
+ }
116
+ /** Parse one registry file into an entry, or return the warning describing why not. */
117
+ function scanRegistryFile(filePath, fp) {
118
+ let text;
119
+ let document;
120
+ try {
121
+ text = readFileSync(filePath, "utf8");
122
+ document = parseJsonc(text);
123
+ }
124
+ catch (cause) {
125
+ return `malformed JSONC (${errorText(cause)})`;
126
+ }
127
+ const result = genomeSpecSchema.safeParse(document);
128
+ if (!result.success) {
129
+ return `does not validate as GenomeSpec: ${formatZodIssues(result.error.issues).join("; ")}`;
130
+ }
131
+ if (fingerprint16(result.data) !== fp) {
132
+ return "content does not match its filename fingerprint (registry edited out-of-band)";
133
+ }
134
+ return {
135
+ fingerprint: fp,
136
+ label: result.data.label,
137
+ spec: result.data,
138
+ registryFile: filePath,
139
+ storedText: text,
140
+ };
141
+ }
142
+ /**
143
+ * Read every registry entry. Corrupt / foreign entries degrade to warnings — stale
144
+ * or meddled state must never crash `list`/`show`/`audit`.
145
+ */
146
+ export function readRegistry(configDir) {
147
+ const dir = registryDirOf(configDir);
148
+ let names;
149
+ try {
150
+ names = readdirSync(dir)
151
+ .filter((name) => name.endsWith(".jsonc"))
152
+ .sort();
153
+ }
154
+ catch {
155
+ return { entries: [], warnings: [] }; // no registry yet = empty, not an error
156
+ }
157
+ const entries = [];
158
+ const warnings = [];
159
+ for (const name of names) {
160
+ const scanned = scanRegistryFile(path.join(dir, name), name.slice(0, -".jsonc".length));
161
+ if (typeof scanned === "string")
162
+ warnings.push(`${name}: ${scanned}`);
163
+ else
164
+ entries.push(scanned);
165
+ }
166
+ return { entries, warnings };
167
+ }
168
+ /** Labels are non-unique: returns EVERY genome registered under `label`, exit 2 if none. */
169
+ export function requireGenomesByLabel(configDir, label) {
170
+ const scan = readRegistry(configDir);
171
+ const matches = scan.entries.filter((entry) => entry.label === label);
172
+ if (matches.length === 0) {
173
+ cannotAnswer(`genome: no registered genome with label '${label}'`, "check 'abathur genome list'; if the registry was deleted out-of-band, re-add the spec");
174
+ }
175
+ return { entries: matches, warnings: scan.warnings };
176
+ }
@@ -0,0 +1,106 @@
1
+ // Dependency-free glob matcher + working-tree walker for the kernel seal (todo 4).
2
+ // minimatch would be a new runtime dep, which the plan forbids; this covers the
3
+ // documented subset the registry needs:
4
+ // ** zero or more whole path segments (must occupy a full segment)
5
+ // * zero or more chars WITHIN one segment (never '/')
6
+ // ? exactly one char within one segment
7
+ // [..] char class; leading '!' or '^' negates; a-z ranges supported
8
+ // Anything else is literal (regex metachars escaped, '.' included). Patterns match
9
+ // repo-root-relative POSIX-style paths, anchored as a full match. The `.git`
10
+ // directory and symlinks are never walked: seals cover working-tree FILE content.
11
+ import { lstatSync, readdirSync } from "node:fs";
12
+ import path from "node:path";
13
+ function escapeLiteral(char) {
14
+ return char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
+ }
16
+ function segmentToRegExpSource(segment) {
17
+ let out = "";
18
+ let i = 0;
19
+ while (i < segment.length) {
20
+ const char = segment[i];
21
+ if (char === "*") {
22
+ out += "[^/]*";
23
+ i += 1;
24
+ }
25
+ else if (char === "?") {
26
+ out += "[^/]";
27
+ i += 1;
28
+ }
29
+ else if (char === "[") {
30
+ const close = segment.indexOf("]", i + 1);
31
+ if (close === -1) {
32
+ out += "\\["; // unterminated class: literal bracket
33
+ i += 1;
34
+ continue;
35
+ }
36
+ let body = segment.slice(i + 1, close);
37
+ const negated = body.startsWith("!") || body.startsWith("^");
38
+ if (negated)
39
+ body = body.slice(1);
40
+ const safe = body.replace(/[\\\]^]/g, (m) => `\\${m}`);
41
+ out += negated ? `[^/${safe}]` : `[${safe}]`;
42
+ i = close + 1;
43
+ }
44
+ else {
45
+ out += escapeLiteral(char);
46
+ i += 1;
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ export function compileGlob(glob) {
52
+ const segments = glob.split("/");
53
+ let source = "^";
54
+ for (let i = 0; i < segments.length; i += 1) {
55
+ const segment = segments[i];
56
+ const isLast = i === segments.length - 1;
57
+ if (segment === "**") {
58
+ if (segments.length === 1)
59
+ source += ".*"; // bare ** matches every path
60
+ else if (i === 0)
61
+ source += "(?:[^/]+/)*"; // **/x ⇒ x, a/x, a/b/x
62
+ else if (isLast)
63
+ source += "/.*"; // x/** ⇒ everything strictly under x/
64
+ else
65
+ source += "/(?:[^/]+/)*"; // x/**/y ⇒ x/y, x/a/y, x/a/b/y
66
+ continue; // each ** branch already consumed its leading separator
67
+ }
68
+ if (i > 0 && segments[i - 1] !== "**")
69
+ source += "/";
70
+ source += segmentToRegExpSource(segment);
71
+ }
72
+ return new RegExp(`${source}$`);
73
+ }
74
+ function walk(absDir, rel, out) {
75
+ let names;
76
+ try {
77
+ names = readdirSync(absDir).sort();
78
+ }
79
+ catch {
80
+ return; // unreadable subdirectory: nothing sealable there
81
+ }
82
+ for (const name of names) {
83
+ if (name === ".git")
84
+ continue; // VCS metadata is never genome content
85
+ const abs = path.join(absDir, name);
86
+ const relPath = rel.length === 0 ? name : `${rel}/${name}`;
87
+ const stat = lstatSync(abs);
88
+ if (stat.isSymbolicLink())
89
+ continue; // never follow links out of the tree
90
+ if (stat.isDirectory())
91
+ walk(abs, relPath, out);
92
+ else if (stat.isFile())
93
+ out.push(relPath);
94
+ }
95
+ }
96
+ /** Every regular file under `root` (excluding .git and symlinks), sorted POSIX paths. */
97
+ export function listFiles(root) {
98
+ const out = [];
99
+ walk(root, "", out);
100
+ return out;
101
+ }
102
+ /** Files matching at least one glob, in listFiles() (sorted) order. */
103
+ export function filesMatching(root, globs) {
104
+ const matchers = globs.map(compileGlob);
105
+ return listFiles(root).filter((file) => matchers.some((re) => re.test(file)));
106
+ }
@@ -0,0 +1,184 @@
1
+ // Graft gates (todo 13): bundle IO + the four byte-exact pre-conditions —
2
+ // genome fingerprint, benchDigest, scoring provenance, requires[] probes —
3
+ // plus the duplicate-graft refusal. All mismatch messages carry the FULL
4
+ // expected vs actual digests (misleading_success_output guard: quarantine must
5
+ // always state WHICH gate failed). The trust narrative lives in
6
+ // docs/federation.md (todo 15); v1 has no noise-tolerance band.
7
+ import { readFileSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { gunzipSync } from "node:zlib";
10
+ import { EXIT_BLOCKED, ExitSignal, cannotAnswer } from "../exit.js";
11
+ import { runChild } from "../bench/adapter.js";
12
+ import { probeEngines, resolveOpencodeBin } from "../bench/fixture-probe.js";
13
+ import { compareSemver, parseSemver } from "../bench/fixture-support.js";
14
+ import { benchDigestFor, containedSpec } from "./bundle-common.js";
15
+ import { bundleManifestSchema, deriveBenchProvenance } from "./bundle-manifest.js";
16
+ import { readTar } from "./bundle-tar.js";
17
+ import { clampReps } from "./stats.js";
18
+ import { emptyCounters, readResume } from "./evolve/run-rows.js";
19
+ import { Ledger } from "./ledger.js";
20
+ import { decodeGraftImport, isTerminalDecision, sha12, LEDGER_KIND_GRAFT_IMPORT } from "./graft-support.js";
21
+ const BUNDLE_MAX_BYTES = 256 * 1024 * 1024;
22
+ const PROBE_TIMEOUT_S = 30;
23
+ export function readBundleBytes(bundlePath) {
24
+ let bytes;
25
+ try {
26
+ bytes = readFileSync(bundlePath);
27
+ }
28
+ catch {
29
+ return cannotAnswer(`graft: cannot read bundle ${bundlePath}`);
30
+ }
31
+ if (bytes.byteLength > BUNDLE_MAX_BYTES) {
32
+ cannotAnswer(`graft: bundle ${bundlePath} is ${String(bytes.byteLength)} bytes — exceeds the ${String(BUNDLE_MAX_BYTES)} byte ceiling`);
33
+ }
34
+ return bytes;
35
+ }
36
+ /** Manifest + members under the EXACT todo-12 schema — export/inspect/graft share
37
+ * one parser, so the verification can never drift from what `inspect` proved. */
38
+ export function parseBundleBytes(bytes) {
39
+ let members;
40
+ try {
41
+ members = readTar(new Uint8Array(gunzipSync(bytes)));
42
+ }
43
+ catch (cause) {
44
+ return cannotAnswer(`graft: bundle bytes unreadable after inspect passed: ${cause instanceof Error ? cause.message : String(cause)}`);
45
+ }
46
+ const raw = members.find((m) => m.path === "manifest.json");
47
+ if (raw === undefined)
48
+ return cannotAnswer("graft: manifest.json vanished from the bundle");
49
+ let document;
50
+ try {
51
+ document = JSON.parse(new TextDecoder().decode(raw.content));
52
+ }
53
+ catch (cause) {
54
+ return cannotAnswer(`graft: manifest.json is not valid JSON: ${cause instanceof Error ? cause.message.split("\n")[0] : String(cause)}`);
55
+ }
56
+ const parsed = bundleManifestSchema.safeParse(document);
57
+ if (!parsed.success) {
58
+ return cannotAnswer(`graft: manifest.json failed schema v1: ${String(parsed.error.issues[0]?.message ?? "schema")}`);
59
+ }
60
+ return { manifest: parsed.data, members };
61
+ }
62
+ /** Best-effort manifest claim extraction when inspect itself failed on a
63
+ * pinned-member integrity problem — the ledger row should still name the source
64
+ * genome if the manifest parses; unparseable garbage yields nulls. */
65
+ export function bestEffortClaim(bytes) {
66
+ try {
67
+ const { manifest } = parseBundleBytes(bytes);
68
+ return { fingerprint: manifest.genome.fingerprint, benchDigest: manifest.benchDigest };
69
+ }
70
+ catch {
71
+ return { fingerprint: null, benchDigest: null };
72
+ }
73
+ }
74
+ /** Gate 2 anchor: the digest recomputed from the bundle's contained primary tree
75
+ * against the LOCAL registered spec (identities matching is gate 1's job; this
76
+ * gate pins the bench SURFACE the peer actually measured). */
77
+ export function localBenchDigest(spec, tree, primary) {
78
+ return benchDigestFor(spec, tree, primary);
79
+ }
80
+ /**
81
+ * Gate 3: scoring-provenance subset {agentModel, judgeModel, statsConfigDigest,
82
+ * opencodeVersion-compatible} — byte-equal on the spec-recomputable fields,
83
+ * semver-normalized on opencodeVersion (toy: both-null is the honest equality).
84
+ * The peer side is re-derived from the bundle's OWN contained spec where
85
+ * recomputable: manifest claims alone are never the word (adapterConfigDigest
86
+ * and fixtureSeedId ride along as bonus recomputables).
87
+ */
88
+ export function provenanceGateFailures(manifest, spec, tree, primary, ledger) {
89
+ const claim = manifest.benchProvenance;
90
+ const peerSpec = containedSpec(tree, primary);
91
+ const localVersions = latestIncumbentVersions(ledger);
92
+ const local = deriveBenchProvenance(provenanceRow(localVersions, clampReps(undefined, spec.bench.stats.nReps), spec), spec);
93
+ const peer = deriveBenchProvenance(provenanceRow(claim.opencodeVersion === null ? [] : [{ bin: "opencode", version: claim.opencodeVersion }], claim.nRepeats ?? 1, peerSpec), peerSpec);
94
+ const failures = [];
95
+ const keys = ["agentModel", "judgeModel", "statsConfigDigest", "adapterConfigDigest", "fixtureSeedId"];
96
+ for (const key of keys) {
97
+ if (peer[key] !== local[key]) {
98
+ failures.push(`${key}: local '${String(local[key] ?? "null")}' vs peer-derived '${String(peer[key] ?? "null")}'`);
99
+ }
100
+ if (claim[key] !== peer[key]) {
101
+ failures.push(`${key}: manifest claims '${String(claim[key] ?? "null")}' but the contained spec derives '${String(peer[key] ?? "null")}'`);
102
+ }
103
+ }
104
+ if (!opencodeVersionCompatible(local.opencodeVersion, claim.opencodeVersion)) {
105
+ failures.push(`opencodeVersion: local '${local.opencodeVersion ?? "null"}' not semver-equal to peer '${claim.opencodeVersion ?? "null"}'`);
106
+ }
107
+ return failures;
108
+ }
109
+ function opencodeVersionCompatible(local, peer) {
110
+ if (local === null && peer === null)
111
+ return true;
112
+ if (local === null || peer === null)
113
+ return false;
114
+ const a = parseSemver(local);
115
+ const b = parseSemver(peer);
116
+ if (a === null || b === null)
117
+ return false; // unparsable provenance is not comparable evidence
118
+ return compareSemver(a, b) === 0;
119
+ }
120
+ /** Schema-valid row so deriveBenchProvenance (which reads .reps + .benchProvenance.versions)
121
+ * gets a real GenerationRowData, never a cast. */
122
+ function provenanceRow(versions, reps, spec) {
123
+ return {
124
+ source: "incumbent",
125
+ headCommit: "0".repeat(40),
126
+ complete: true,
127
+ reps,
128
+ units: [],
129
+ counters: emptyCounters(),
130
+ manifest: [],
131
+ benchProvenance: { benchType: spec.bench.type, versions: versions.map((v) => ({ bin: v.bin, version: v.version })) },
132
+ };
133
+ }
134
+ function latestIncumbentVersions(ledger) {
135
+ let versions = [];
136
+ for (const row of readResume(ledger).incumbentByHead.values())
137
+ versions = row.benchProvenance.versions;
138
+ return versions;
139
+ }
140
+ /**
141
+ * Gate 4 — requires[] probes via the todo-6 machinery BEFORE any worktree or
142
+ * bench. Fixture genomes go through probeEngines (opencode --version +
143
+ * minVersion + requires[]). Toy benches never spawn opencode, so probeEngines'
144
+ * mandatory bin probe would be wrong there; the requires[] loop below mirrors
145
+ * fixture-probe's runChild discipline exactly (argv spawn, 30s, exit-2-before-
146
+ * any-unit on spawn_failed / probeExit mismatch).
147
+ */
148
+ export async function probeGraftPrerequisites(spec, opencodeBin) {
149
+ const repoRoot = path.resolve(spec.repoPath);
150
+ if (spec.bench.type === "opencode-fixture-scenarios") {
151
+ const env = process.env;
152
+ const bin = resolveOpencodeBin(opencodeBin === null || opencodeBin === undefined ? { env } : { opencodeBin, env });
153
+ await probeEngines(spec, bin, repoRoot);
154
+ return;
155
+ }
156
+ for (const prereq of spec.requires ?? []) {
157
+ const outcome = await runChild({
158
+ argv: [prereq.cmd, ...(prereq.args ?? ["--version"])],
159
+ cwd: repoRoot,
160
+ timeoutS: PROBE_TIMEOUT_S,
161
+ });
162
+ if (outcome.kind === "spawn_failed") {
163
+ cannotAnswer(`graft: required prerequisite '${prereq.cmd}' is missing: ${outcome.reason}`, "install it or fix spec.requires before grafting");
164
+ }
165
+ if (outcome.kind !== "exited" || outcome.exitCode !== prereq.probeExit) {
166
+ cannotAnswer(`graft: prerequisite '${prereq.cmd}' probe expected exit ${String(prereq.probeExit)}, got ${outcome.reason}`);
167
+ }
168
+ }
169
+ }
170
+ /** stale_state pin: the same bundle + genome never grafts twice — a terminal
171
+ * decision row (quarantined/nominated/culled/…) refuses re-entry. Pending
172
+ * rows do NOT: the plan's eligible path is "register genome then re-run". */
173
+ export function assertNotGrafted(ledger, bundleSha256, genomeLabel) {
174
+ for (const record of ledger.readAll()) {
175
+ if (record.kind !== LEDGER_KIND_GRAFT_IMPORT)
176
+ continue;
177
+ const row = decodeGraftImport(record.data);
178
+ if (row === null || row.bundleSha256 !== bundleSha256 || row.genomeLabel !== genomeLabel)
179
+ continue;
180
+ if (!isTerminalDecision(row.decision))
181
+ continue;
182
+ throw new ExitSignal(EXIT_BLOCKED, `graft: bundle ${sha12(bundleSha256)} already grafted under genome '${genomeLabel}' (decision '${row.decision}') — the ledger is append-only`, "export a newer bundle for new work; re-running the same bundle never re-benches");
183
+ }
184
+ }