@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,213 @@
|
|
|
1
|
+
// Low-level pieces of the snapshot-overlay self-bench (plan 157-164): clone a
|
|
2
|
+
// frozen incumbent snapshot into a mutable build dir, decide + write the
|
|
3
|
+
// candidate overlay, and run the three pinned-toolchain steps (build / suite /
|
|
4
|
+
// replay) as argv children via runChild. NO promote authority, NO dynamic
|
|
5
|
+
// imports — the candidate's code is only ever executed by CHILD processes inside
|
|
6
|
+
// the snapshot; trusted files (sealed globs, src/test/**, selfbench/**) always
|
|
7
|
+
// come from the incumbent snapshot, never from the candidate tree.
|
|
8
|
+
import { cpSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { runChild } from "../../bench/adapter.js";
|
|
11
|
+
import { cannotAnswer } from "../../exit.js";
|
|
12
|
+
import { thawTree } from "../../util/freeze.js";
|
|
13
|
+
import { snapshotCommit } from "../worktree.js";
|
|
14
|
+
import { compileGlob } from "../glob.js";
|
|
15
|
+
const OVERLAY_PREFIX = "src/";
|
|
16
|
+
const TRUSTED_TEST_PREFIX = "src/test/";
|
|
17
|
+
/** Relative posix paths of a tree (regular files only, symlinks skipped), sorted. */
|
|
18
|
+
export function treePaths(root) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const walk = (dir, rel) => {
|
|
21
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
22
|
+
const abs = path.join(dir, entry);
|
|
23
|
+
const relPath = rel === "" ? entry : `${rel}/${entry}`;
|
|
24
|
+
const stat = lstatSync(abs);
|
|
25
|
+
if (stat.isDirectory())
|
|
26
|
+
walk(abs, relPath);
|
|
27
|
+
else if (stat.isFile())
|
|
28
|
+
out.push(relPath);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
walk(root, "");
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Overlay law (plan 11c): ONLY candidate src/** files matching no immutableGlob
|
|
36
|
+
* and not under src/test/** are copied over the snapshot. Candidate edits to
|
|
37
|
+
* src/test/** are DROPPED (the trusted suite scores every candidate); deletions
|
|
38
|
+
* are unrepresentable in a copy-only overlay (matching udiff v1's
|
|
39
|
+
* modify/create-only contract). Everything outside src/** is ignored.
|
|
40
|
+
*/
|
|
41
|
+
export function planOverlay(baseFiles, candidateFiles, spec) {
|
|
42
|
+
const globs = spec.kernel.immutableGlobs.map((glob) => compileGlob(glob));
|
|
43
|
+
const overlay = [];
|
|
44
|
+
const dropped = [];
|
|
45
|
+
for (const [relPath, content] of candidateFiles) {
|
|
46
|
+
if (!relPath.startsWith(OVERLAY_PREFIX))
|
|
47
|
+
continue;
|
|
48
|
+
if (baseFiles.get(relPath) === content)
|
|
49
|
+
continue; // unchanged bytes ride in the base clone
|
|
50
|
+
if (relPath.startsWith(TRUSTED_TEST_PREFIX)) {
|
|
51
|
+
dropped.push({ path: relPath, reason: "trusted-tests" });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (globs.some((re) => re.test(relPath))) {
|
|
55
|
+
dropped.push({ path: relPath, reason: "sealed" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
overlay.push({ path: relPath, content });
|
|
59
|
+
}
|
|
60
|
+
for (const relPath of baseFiles.keys()) {
|
|
61
|
+
if (!relPath.startsWith(OVERLAY_PREFIX) || candidateFiles.has(relPath))
|
|
62
|
+
continue;
|
|
63
|
+
// The candidate DELETED a base file: trusted paths must survive (report the
|
|
64
|
+
// drop); unsealed deletions are unrepresentable in a copy-only v1 overlay.
|
|
65
|
+
if (relPath.startsWith(TRUSTED_TEST_PREFIX))
|
|
66
|
+
dropped.push({ path: relPath, reason: "trusted-tests" });
|
|
67
|
+
else if (globs.some((re) => re.test(relPath)))
|
|
68
|
+
dropped.push({ path: relPath, reason: "sealed" });
|
|
69
|
+
}
|
|
70
|
+
return { overlay, dropped };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Mutable clone of a frozen snapshot (minus .git): cpSync preserves the 0444/
|
|
74
|
+
* 0555 freeze modes, so the clone is thawed before anything overlays onto it;
|
|
75
|
+
* the harness's own node_modules is SYMLINKED (pinned toolchain — never copied,
|
|
76
|
+
* never npm ci, never candidate-supplied build scripts).
|
|
77
|
+
*/
|
|
78
|
+
export async function cloneSnapshot(snapshotPath, buildDir, nodeModules) {
|
|
79
|
+
mkdirSync(path.dirname(buildDir), { recursive: true });
|
|
80
|
+
cpSync(snapshotPath, buildDir, {
|
|
81
|
+
recursive: true,
|
|
82
|
+
filter: (src) => {
|
|
83
|
+
const rel = path.relative(snapshotPath, src);
|
|
84
|
+
return rel !== ".git" && !rel.startsWith(`.git${path.sep}`);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
await thawTree(buildDir);
|
|
88
|
+
symlinkSync(nodeModules, path.join(buildDir, "node_modules"), "dir");
|
|
89
|
+
}
|
|
90
|
+
export function writeOverlay(buildDir, decision) {
|
|
91
|
+
for (const file of decision.overlay) {
|
|
92
|
+
const abs = path.join(buildDir, file.path);
|
|
93
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
94
|
+
writeFileSync(abs, file.content, "utf8");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The repo's OWN build recipe under the HARNESS-PINNED toolchain reached
|
|
99
|
+
* through the node_modules symlink: `tsc -p tsconfig.json` then
|
|
100
|
+
* `node scripts/copy-assets.mjs` (the two steps npm run build runs — never
|
|
101
|
+
* npm, never candidate-supplied scripts; copy-assets is itself sealed).
|
|
102
|
+
* timeoutS bounds BOTH steps together.
|
|
103
|
+
*/
|
|
104
|
+
export async function runBuild(buildDir, timeoutS, onChild) {
|
|
105
|
+
const started = Date.now();
|
|
106
|
+
const remaining = () => Math.max(5, timeoutS - Math.round((Date.now() - started) / 1000));
|
|
107
|
+
const steps = [
|
|
108
|
+
{ label: "tsc", argv: [process.execPath, path.join(buildDir, "node_modules", "typescript", "bin", "tsc"), "-p", "tsconfig.json"] },
|
|
109
|
+
{ label: "copy-assets", argv: [process.execPath, "scripts/copy-assets.mjs"] },
|
|
110
|
+
];
|
|
111
|
+
for (const stepDef of steps) {
|
|
112
|
+
const outcome = await step({ argv: stepDef.argv, cwd: buildDir, timeoutS: remaining() }, onChild);
|
|
113
|
+
if (outcome.kind === "timeout")
|
|
114
|
+
return { status: "timeout", exit: null, note: `${stepDef.label}: ${outcome.reason}` };
|
|
115
|
+
if (outcome.kind === "spawn_failed")
|
|
116
|
+
return { status: "failed", exit: null, note: `${stepDef.label}: ${outcome.reason}` };
|
|
117
|
+
if (outcome.exitCode !== 0) {
|
|
118
|
+
const first = outcome.stdout.trim().split("\n").find((l) => l.length > 0) ?? outcome.stderr.trim().split("\n")[0] ?? "no output";
|
|
119
|
+
return { status: "failed", exit: outcome.exitCode, note: `${stepDef.label}: ${first}` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { status: "ok", exit: 0, note: "" };
|
|
123
|
+
}
|
|
124
|
+
/** node --test over the compiled suite (the plan's scoring surface). */
|
|
125
|
+
export async function runSuite(buildDir, timeoutS, onChild) {
|
|
126
|
+
return step({ argv: [process.execPath, "--test", "dist/test/**/*.test.js"], cwd: buildDir, timeoutS, env: { NODE_TEST_CONTEXT: undefined } }, onChild);
|
|
127
|
+
}
|
|
128
|
+
export async function runReplay(buildDir, timeoutS, onChild) {
|
|
129
|
+
return step({ argv: [process.execPath, "selfbench/replay.mjs"], cwd: buildDir, timeoutS }, onChild);
|
|
130
|
+
}
|
|
131
|
+
function step(opts, onChild) {
|
|
132
|
+
return runChild(onChild === undefined ? opts : { ...opts, onChild });
|
|
133
|
+
}
|
|
134
|
+
/** Parse the node:test TAP summary lines ('# tests N' / '# pass N' / '# fail N'). */
|
|
135
|
+
export function parseTapSummary(stdout) {
|
|
136
|
+
let tests = null;
|
|
137
|
+
let pass = null;
|
|
138
|
+
let fail = null;
|
|
139
|
+
for (const line of stdout.split("\n")) {
|
|
140
|
+
const m = /^# (tests|pass|fail) (\d+)$/.exec(line);
|
|
141
|
+
if (m === null)
|
|
142
|
+
continue;
|
|
143
|
+
const value = Number(m[2]);
|
|
144
|
+
if (m[1] === "tests")
|
|
145
|
+
tests = value;
|
|
146
|
+
else if (m[1] === "pass")
|
|
147
|
+
pass = value;
|
|
148
|
+
else
|
|
149
|
+
fail = value;
|
|
150
|
+
}
|
|
151
|
+
if (tests === null || pass === null || fail === null)
|
|
152
|
+
return null;
|
|
153
|
+
return { tests, pass, fail };
|
|
154
|
+
}
|
|
155
|
+
export function parseReplayDigest(stdout) {
|
|
156
|
+
const lines = stdout.trim().split("\n");
|
|
157
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
158
|
+
const line = lines[i] ?? "";
|
|
159
|
+
if (!line.startsWith("{"))
|
|
160
|
+
continue;
|
|
161
|
+
try {
|
|
162
|
+
const value = JSON.parse(line);
|
|
163
|
+
if (typeof value === "object" && value !== null && "digest" in value) {
|
|
164
|
+
const d = value.digest;
|
|
165
|
+
if (typeof d === "string" && /^[0-9a-f]{64}$/.test(d))
|
|
166
|
+
return d;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// not the digest line — keep scanning upward.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
export async function incumbentSnapshot(genomeRepo, genomeFp, commitSha, env) {
|
|
176
|
+
const handle = await snapshotCommit({ repoPath: genomeRepo, genomeFp }, commitSha, { env });
|
|
177
|
+
return handle.snapshotPath;
|
|
178
|
+
}
|
|
179
|
+
/** src/** candidate files (rel path -> text) from a candidate snapshot. */
|
|
180
|
+
export function candidateSrcFiles(snapshotPath) {
|
|
181
|
+
const out = new Map();
|
|
182
|
+
for (const rel of treePaths(snapshotPath)) {
|
|
183
|
+
if (rel.startsWith(OVERLAY_PREFIX))
|
|
184
|
+
out.set(rel, readFileSync(path.join(snapshotPath, rel), "utf8"));
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
export function removeBuildDir(buildDir) {
|
|
189
|
+
rmSync(buildDir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
/** The TRUSTED replay expectation — a corrupt goalpost file is cannot-answer, never a pass. */
|
|
192
|
+
export function readExpectedDigest(snapshotPath) {
|
|
193
|
+
let raw;
|
|
194
|
+
try {
|
|
195
|
+
raw = readFileSync(path.join(snapshotPath, "selfbench", "expected.json"), "utf8");
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return cannotAnswer(`self-snapshot: trusted selfbench/expected.json missing from snapshot ${snapshotPath}`);
|
|
199
|
+
}
|
|
200
|
+
let digest;
|
|
201
|
+
try {
|
|
202
|
+
const value = JSON.parse(raw);
|
|
203
|
+
if (typeof value === "object" && value !== null && "digest" in value)
|
|
204
|
+
digest = value.digest;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return cannotAnswer(`self-snapshot: selfbench/expected.json in ${snapshotPath} is not valid JSON`);
|
|
208
|
+
}
|
|
209
|
+
if (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
210
|
+
return cannotAnswer(`self-snapshot: selfbench/expected.json in ${snapshotPath} carries no 64-hex digest`);
|
|
211
|
+
}
|
|
212
|
+
return digest;
|
|
213
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Snapshot-overlay self-bench (plan todo 11c): score = candidate src UNDER the
|
|
2
|
+
// incumbent-pinned test suite + golden toy-replay, both executed inside a
|
|
3
|
+
// private clone of the frozen incumbent snapshot. Pure incumbent-vs-incumbent
|
|
4
|
+
// is the forbidden degenerate case, so the incumbent bench doubles as the
|
|
5
|
+
// baseline for exactly that suite; every candidate bench re-runs it against the
|
|
6
|
+
// overlaid build. Nothing here loads candidate code into this process, touches
|
|
7
|
+
// the operator's real repo worktree (snapshots come from COMMITS only), or
|
|
8
|
+
// references promote — `self-eval` reports fitness; humans merge.
|
|
9
|
+
//
|
|
10
|
+
// Timeout caps (documented): build 180s (a candidate that makes tsc hang is
|
|
11
|
+
// killed => inconclusive), suite 900s (spec.bench.timeoutS semantics), replay
|
|
12
|
+
// 120s. All steps are argv runChild children tracked by the caller's
|
|
13
|
+
// ChildTracker, so a killed run leaves no orphans.
|
|
14
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { cannotAnswer } from "../../exit.js";
|
|
18
|
+
import { runId } from "../ids.js";
|
|
19
|
+
import { genomeDir } from "../genome-paths.js";
|
|
20
|
+
import { round3, unitMatrixRowSchema } from "./run-rows.js";
|
|
21
|
+
import { candidateSrcFiles, cloneSnapshot, incumbentSnapshot, parseReplayDigest, parseTapSummary, planOverlay, readExpectedDigest, removeBuildDir, runBuild, runReplay, runSuite, writeOverlay, } from "./self-overlay.js";
|
|
22
|
+
export const DEFAULT_BUILD_TIMEOUT_S = 180;
|
|
23
|
+
export const DEFAULT_TEST_TIMEOUT_S = 900;
|
|
24
|
+
export const DEFAULT_REPLAY_TIMEOUT_S = 120;
|
|
25
|
+
export const SELF_OVERLAY_EMPTY = "self-bench: overlay empty";
|
|
26
|
+
export const SELF_BUILD_TIMEOUT = "self-bench: build timeout";
|
|
27
|
+
const HARNESS_ROOT = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
|
28
|
+
function specUnits(spec) {
|
|
29
|
+
const train = spec.bench.units.find((u) => u.split === "train");
|
|
30
|
+
const val = spec.bench.units.find((u) => u.split === "val");
|
|
31
|
+
if (train === undefined || val === undefined) {
|
|
32
|
+
cannotAnswer("self-snapshot: the self genome spec needs one train (suite) and one val (replay) unit");
|
|
33
|
+
}
|
|
34
|
+
return { trainId: train.id, valId: val.id };
|
|
35
|
+
}
|
|
36
|
+
function tscVersion(nodeModules) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(readFileSync(path.join(nodeModules, "typescript", "package.json"), "utf8"));
|
|
39
|
+
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "unknown";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function selfBench(req) {
|
|
46
|
+
const started = Date.now();
|
|
47
|
+
const { trainId, valId } = specUnits(req.spec);
|
|
48
|
+
const baseSnapshot = await incumbentSnapshot(req.genomeRepo, req.genomeFp, req.incumbentCommit, req.env);
|
|
49
|
+
const replayExpected = readExpectedDigest(baseSnapshot);
|
|
50
|
+
const decision = req.candidateCommit === null
|
|
51
|
+
? { overlay: [], dropped: [] }
|
|
52
|
+
: planOverlay(candidateSrcFiles(baseSnapshot), candidateSrcFiles(await incumbentSnapshot(req.genomeRepo, req.genomeFp, req.candidateCommit, req.env)), req.spec);
|
|
53
|
+
const buildDir = mkdtempSync(path.join(genomeDir(req.env, { repoPath: req.genomeRepo, genomeFp: req.genomeFp }), `selfbench-${req.genId}-`));
|
|
54
|
+
const suiteRow = { unitId: trainId, split: "train", scores: [], runIds: [], failures: [] };
|
|
55
|
+
const replayRow = { unitId: valId, split: "val", scores: [], runIds: [], failures: [] };
|
|
56
|
+
let buildStatus = "ok";
|
|
57
|
+
let buildNote = "";
|
|
58
|
+
let complete = true;
|
|
59
|
+
const suiteRuns = [];
|
|
60
|
+
let replayDigest = null;
|
|
61
|
+
const nodeModules = path.join(HARNESS_ROOT, "node_modules");
|
|
62
|
+
try {
|
|
63
|
+
await cloneSnapshot(baseSnapshot, buildDir, nodeModules);
|
|
64
|
+
writeOverlay(buildDir, decision);
|
|
65
|
+
const buildTimeoutS = req.buildTimeoutS ?? DEFAULT_BUILD_TIMEOUT_S;
|
|
66
|
+
const build = await runBuild(buildDir, buildTimeoutS, req.onChild);
|
|
67
|
+
buildStatus = build.status;
|
|
68
|
+
buildNote = build.note;
|
|
69
|
+
if (build.status === "timeout") {
|
|
70
|
+
complete = false;
|
|
71
|
+
suiteRow.failures.push(`${SELF_BUILD_TIMEOUT}: tsc killed after ${String(buildTimeoutS)}s (hung build is cannot-answer, never a zero)`);
|
|
72
|
+
}
|
|
73
|
+
else if (build.status === "failed") {
|
|
74
|
+
for (let rep = 0; rep < req.reps; rep += 1) {
|
|
75
|
+
suiteRow.scores.push(0);
|
|
76
|
+
suiteRow.runIds.push(runId(req.genId, trainId, rep));
|
|
77
|
+
suiteRow.failures.push(`${trainId} rep ${String(rep)}: run build_failed: tsc exit ${String(build.exit ?? "spawn")}`);
|
|
78
|
+
replayRow.scores.push(0);
|
|
79
|
+
replayRow.runIds.push(runId(req.genId, valId, rep));
|
|
80
|
+
replayRow.failures.push(`${valId} rep ${String(rep)}: run build_failed: replay skipped (build broken)`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const testTimeoutS = req.testTimeoutS ?? DEFAULT_TEST_TIMEOUT_S;
|
|
85
|
+
const replayTimeoutS = req.replayTimeoutS ?? DEFAULT_REPLAY_TIMEOUT_S;
|
|
86
|
+
for (let rep = 0; rep < req.reps; rep += 1) {
|
|
87
|
+
const suite = await runSuite(buildDir, testTimeoutS, req.onChild);
|
|
88
|
+
if (suite.kind !== "exited") {
|
|
89
|
+
complete = false;
|
|
90
|
+
suiteRow.failures.push(`${trainId} rep ${String(rep)}: run ${suite.kind === "timeout" ? "timeout" : "infra_failed"}: ${suite.reason}`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const summary = parseTapSummary(suite.stdout);
|
|
94
|
+
if (summary === null || summary.tests === 0) {
|
|
95
|
+
suiteRow.failures.push(`${trainId} rep ${String(rep)}: grader inconclusive: no usable TAP summary from node --test`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
suiteRuns.push(summary);
|
|
99
|
+
suiteRow.scores.push(summary.fail === 0 ? 1 : summary.pass / summary.tests);
|
|
100
|
+
suiteRow.runIds.push(runId(req.genId, trainId, rep));
|
|
101
|
+
if (summary.fail > 0)
|
|
102
|
+
suiteRow.failures.push(`${trainId} rep ${String(rep)}: scored ${(summary.pass / summary.tests).toFixed(4)} (${String(summary.fail)} failing trusted tests, not passing)`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const replay = await runReplay(buildDir, replayTimeoutS, req.onChild);
|
|
106
|
+
if (replay.kind !== "exited") {
|
|
107
|
+
complete = false;
|
|
108
|
+
replayRow.failures.push(`${valId} rep ${String(rep)}: run ${replay.kind === "timeout" ? "timeout" : "infra_failed"}: ${replay.reason}`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const digest = parseReplayDigest(replay.stdout);
|
|
112
|
+
if (digest === null) {
|
|
113
|
+
replayRow.failures.push(`${valId} rep ${String(rep)}: grader inconclusive: replay printed no 64-hex digest (exit ${String(replay.exitCode)})`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
replayDigest ??= digest;
|
|
117
|
+
replayRow.scores.push(digest === replayExpected ? 1 : 0);
|
|
118
|
+
replayRow.runIds.push(runId(req.genId, valId, rep));
|
|
119
|
+
if (digest !== replayExpected)
|
|
120
|
+
replayRow.failures.push(`${valId} rep ${String(rep)}: scored 0 (golden-replay digest mismatch — behavior moved)`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (req.candidateCommit !== null && decision.overlay.length === 0 && decision.dropped.length > 0) {
|
|
125
|
+
suiteRow.failures.push(`${SELF_OVERLAY_EMPTY}: every candidate src change matched a seal or the trusted-test rule — score equals the incumbent by construction, no nomination without scored change`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
removeBuildDir(buildDir);
|
|
131
|
+
}
|
|
132
|
+
const failures = [...suiteRow.failures, ...replayRow.failures];
|
|
133
|
+
return {
|
|
134
|
+
units: [unitMatrixRowSchema.parse(suiteRow), unitMatrixRowSchema.parse(replayRow)],
|
|
135
|
+
spent: { candidates: req.candidateCommit === null ? 0 : 1, modelCalls: 0, tokens: 0, wallS: round3((Date.now() - started) / 1000) },
|
|
136
|
+
provenance: {
|
|
137
|
+
benchType: req.spec.bench.type,
|
|
138
|
+
versions: [
|
|
139
|
+
{ bin: "node", version: process.version },
|
|
140
|
+
{ bin: "tsc", version: tscVersion(nodeModules) },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
complete,
|
|
144
|
+
failures,
|
|
145
|
+
overlaid: decision.overlay.map((f) => f.path),
|
|
146
|
+
dropped: decision.dropped,
|
|
147
|
+
buildStatus,
|
|
148
|
+
buildNote,
|
|
149
|
+
suiteRuns,
|
|
150
|
+
replayDigest,
|
|
151
|
+
replayExpected,
|
|
152
|
+
snapshotPath: baseSnapshot,
|
|
153
|
+
buildDir,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Self-bench verdict guards (plan AC-4 / hung_commands): a killed step (build
|
|
158
|
+
* timeout, hung/crashed suite or replay child) can never answer — inconclusive;
|
|
159
|
+
* a candidate whose entire overlay was dropped never changed scored bytes, so a
|
|
160
|
+
* (degenerate) nomination downgrades to indeterminate. Everything else passes
|
|
161
|
+
* through to evaluate's verdict.
|
|
162
|
+
*/
|
|
163
|
+
export function selfGuardVerdict(failures, base) {
|
|
164
|
+
if (failures.some((f) => f.startsWith(SELF_BUILD_TIMEOUT) || f.includes(": run timeout") || f.includes(": run infra_failed"))) {
|
|
165
|
+
return "inconclusive";
|
|
166
|
+
}
|
|
167
|
+
if (failures.some((f) => f.startsWith(SELF_OVERLAY_EMPTY)) && base === "nominated")
|
|
168
|
+
return "indeterminate";
|
|
169
|
+
return base;
|
|
170
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Deterministic scripted-patch mutator STUB (plan todo 5). Model-free candidate
|
|
2
|
+
// generator for the toy substrate: a fixed table of textual patches, seedable
|
|
3
|
+
// selection (mulberry32 + Fisher-Yates — same seed, same order, always), and an
|
|
4
|
+
// all-or-nothing single-occurrence apply. todo 8/9 consume this via the mutator
|
|
5
|
+
// interface; real LLM-driven mutators replace the table later, the selection
|
|
6
|
+
// contract stays.
|
|
7
|
+
//
|
|
8
|
+
// Patch semantics: `from` must occur EXACTLY ONCE in <repoDir>/<file> or applyPatch
|
|
9
|
+
// refuses (returns false, writes nothing) — ambiguous rewrites are how silent
|
|
10
|
+
// corruption starts.
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
/** @type {ReadonlyArray<{id: string, description: string, file: string, from: string, to: string}>} */
|
|
16
|
+
const SCRIPTED_PATCHES = [
|
|
17
|
+
{
|
|
18
|
+
id: "fix-add",
|
|
19
|
+
description: "seeded-bug fix: add() must sum, not subtract",
|
|
20
|
+
file: "units/add.mjs",
|
|
21
|
+
from: "return a - b; // seeded bug: must be `return a + b;`",
|
|
22
|
+
to: "return a + b;",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "break-mul",
|
|
26
|
+
description: "harmful mutation: mul gains a spurious +a term",
|
|
27
|
+
file: "units/mul.mjs",
|
|
28
|
+
from: "return a * b;",
|
|
29
|
+
to: "return a * b + a;",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "break-sub",
|
|
33
|
+
description: "harmful mutation: sub() flips sign",
|
|
34
|
+
file: "units/sub.mjs",
|
|
35
|
+
from: "return a - b;",
|
|
36
|
+
to: "return a + b;",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "annotate-add",
|
|
40
|
+
description: "benign mutation: cosmetic marker inside add()'s signature",
|
|
41
|
+
file: "units/add.mjs",
|
|
42
|
+
from: "export function add(",
|
|
43
|
+
to: "export function add /* patched */(",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** Every scripted patch, in stable declaration order (a copy — callers may sort). */
|
|
48
|
+
export function scriptedPatches() {
|
|
49
|
+
return SCRIPTED_PATCHES.slice();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Deterministically select up to `count` patches for `seed`: seeded shuffle of the
|
|
54
|
+
* table, then slice. Same (seed, count) => element-wise identical arrays.
|
|
55
|
+
* @param {number} seed non-negative integer
|
|
56
|
+
* @param {number} count desired patch count, clamped into [0, table length]
|
|
57
|
+
*/
|
|
58
|
+
export function selectPatches(seed, count) {
|
|
59
|
+
if (!Number.isInteger(seed) || seed < 0) {
|
|
60
|
+
throw new TypeError(`selectPatches: seed must be an integer >= 0, got ${String(seed)}`);
|
|
61
|
+
}
|
|
62
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
63
|
+
throw new TypeError(`selectPatches: count must be an integer >= 0, got ${String(count)}`);
|
|
64
|
+
}
|
|
65
|
+
const items = SCRIPTED_PATCHES.slice();
|
|
66
|
+
const rand = mulberry32(seed >>> 0);
|
|
67
|
+
for (let i = items.length - 1; i > 0; i -= 1) {
|
|
68
|
+
const j = Math.floor(rand() * (i + 1));
|
|
69
|
+
const swap = /** @type {const} */ ([items[i], items[j]]);
|
|
70
|
+
items[j] = swap[0];
|
|
71
|
+
items[i] = swap[1];
|
|
72
|
+
}
|
|
73
|
+
return items.slice(0, Math.min(count, items.length));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Apply one patch under repoDir (accepts a patch from scriptedPatches/selectPatches
|
|
78
|
+
* or any same-shaped object). Returns true on success; false — with NOTHING written —
|
|
79
|
+
* when the file is missing, `from` is absent, or it occurs more than once.
|
|
80
|
+
* @param {string} repoDir
|
|
81
|
+
* @param {{file: string, from: string, to: string}} patch
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
export function applyPatch(repoDir, patch) {
|
|
85
|
+
if (typeof patch.from !== "string" || patch.from.length === 0) return false;
|
|
86
|
+
const file = path.join(repoDir, patch.file);
|
|
87
|
+
if (!existsSync(file)) return false;
|
|
88
|
+
const text = readFileSync(file, "utf8");
|
|
89
|
+
const first = text.indexOf(patch.from);
|
|
90
|
+
if (first === -1) return false;
|
|
91
|
+
if (text.indexOf(patch.from, first + 1) !== -1) return false; // ambiguous
|
|
92
|
+
writeFileSync(file, `${text.slice(0, first)}${patch.to}${text.slice(first + patch.from.length)}`, "utf8");
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Classic mulberry32 PRNG — 32-bit state, tiny, stable across engines. */
|
|
97
|
+
function mulberry32(a) {
|
|
98
|
+
return function next() {
|
|
99
|
+
a |= 0;
|
|
100
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
101
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
102
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
103
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Minimal unified-diff parser + atomic in-memory applier for mutator candidates
|
|
2
|
+
// (todo 8, plan lines 133-140). Git style sections: `--- a/f`, `+++ b/f`,
|
|
3
|
+
// `@@ -oldStart[,oldCount] +newStart[,newCount] @@` with ' '/'-'/'+' body lines.
|
|
4
|
+
//
|
|
5
|
+
// v1 candidate ops, DOCUMENTED SCOPE:
|
|
6
|
+
// allowed: modify existing text file, create new text file (`--- /dev/null`).
|
|
7
|
+
// refused: delete (`+++ /dev/null`), rename/copy (a/ != b/ or rename lines),
|
|
8
|
+
// binary (`Binary files … differ`, `GIT binary patch`), and
|
|
9
|
+
// `` markers.
|
|
10
|
+
// Nothing touches disk here: parseUnifiedDiff + applyChanges are pure, so a
|
|
11
|
+
// candidate with ANY violating file is rejected before ANY file is mutated
|
|
12
|
+
// (whole-candidate rejection is enforceable, not best-effort).
|
|
13
|
+
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
14
|
+
const DIFF_GIT_RE = /^diff --git a\/(\S+) b\/(\S+)$/;
|
|
15
|
+
const fail = (error) => ({ ok: false, error });
|
|
16
|
+
function isBinaryMarker(line) {
|
|
17
|
+
return line.startsWith("Binary files") || line.startsWith("GIT binary patch") || line.startsWith("literal ") || line.startsWith("delta ");
|
|
18
|
+
}
|
|
19
|
+
/** Skip metadata lines between a `diff --git` header and its `---` line. */
|
|
20
|
+
function metadata(line) {
|
|
21
|
+
return (line.startsWith("index ") ||
|
|
22
|
+
line.startsWith("new file mode") ||
|
|
23
|
+
line.startsWith("old mode") ||
|
|
24
|
+
line.startsWith("new mode") ||
|
|
25
|
+
line.startsWith("similarity index"));
|
|
26
|
+
}
|
|
27
|
+
export function parseUnifiedDiff(text) {
|
|
28
|
+
if (text.includes("\r"))
|
|
29
|
+
return fail("CRLF line endings are not supported in v1");
|
|
30
|
+
// control bytes outside tab/LF = binary payload smuggled into a text hunk
|
|
31
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text))
|
|
32
|
+
return fail("control characters (binary content) are not supported as candidate ops");
|
|
33
|
+
const lines = text.split("\n");
|
|
34
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
35
|
+
lines.pop();
|
|
36
|
+
const changes = [];
|
|
37
|
+
let pendingGit = null;
|
|
38
|
+
let i = 0;
|
|
39
|
+
while (i < lines.length) {
|
|
40
|
+
const line = lines[i];
|
|
41
|
+
if (line.length === 0 || metadata(line)) {
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (line.startsWith("diff --git ")) {
|
|
46
|
+
const m = DIFF_GIT_RE.exec(line);
|
|
47
|
+
if (m === null)
|
|
48
|
+
return fail(`diff --git header not parsable (spaces in paths unsupported in v1): ${line}`);
|
|
49
|
+
if (m[1] !== m[2])
|
|
50
|
+
return fail(`renames are not supported as candidate ops: ${String(m[1])} -> ${String(m[2])}`);
|
|
51
|
+
pendingGit = String(m[1]);
|
|
52
|
+
i += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (line.startsWith("rename ") || line.startsWith("copy "))
|
|
56
|
+
return fail("renames/copies are not supported as candidate ops");
|
|
57
|
+
if (line.startsWith("deleted file mode"))
|
|
58
|
+
return fail("deletes are not supported as candidate ops");
|
|
59
|
+
if (isBinaryMarker(line))
|
|
60
|
+
return fail("binary patches are not supported as candidate ops");
|
|
61
|
+
if (!line.startsWith("--- "))
|
|
62
|
+
return fail(`unrecognized diff line: ${line}`);
|
|
63
|
+
const oldRaw = line.slice(4).split("\t")[0].trim();
|
|
64
|
+
const next = lines[i + 1];
|
|
65
|
+
if (next === undefined || !next.startsWith("+++ "))
|
|
66
|
+
return fail(`expected '+++ b/<path>' after '---' at line ${String(i + 1)}`);
|
|
67
|
+
const newRaw = next.slice(4).split("\t")[0].trim();
|
|
68
|
+
if (newRaw === "/dev/null")
|
|
69
|
+
return fail("deletes (+++ /dev/null) are not supported as candidate ops");
|
|
70
|
+
if (!newRaw.startsWith("b/"))
|
|
71
|
+
return fail(`+++ header must name a b/<path>: ${newRaw}`);
|
|
72
|
+
const newPath = newRaw.slice(2);
|
|
73
|
+
let kind;
|
|
74
|
+
if (oldRaw === "/dev/null") {
|
|
75
|
+
kind = "create";
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
if (!oldRaw.startsWith("a/"))
|
|
79
|
+
return fail(`--- header must name an a/<path> or /dev/null: ${oldRaw}`);
|
|
80
|
+
if (oldRaw.slice(2) !== newPath)
|
|
81
|
+
return fail(`renames are not supported as candidate ops: ${oldRaw.slice(2)} -> ${newPath}`);
|
|
82
|
+
kind = "modify";
|
|
83
|
+
}
|
|
84
|
+
if (pendingGit !== null && pendingGit !== newPath) {
|
|
85
|
+
return fail(`diff --git header path '${pendingGit}' disagrees with section path '${newPath}'`);
|
|
86
|
+
}
|
|
87
|
+
i += 2;
|
|
88
|
+
const hunks = [];
|
|
89
|
+
while (i < lines.length && !isBinaryMarker(lines[i]) && !metadata(lines[i])) {
|
|
90
|
+
const hm = HUNK_RE.exec(lines[i]);
|
|
91
|
+
if (hm === null)
|
|
92
|
+
break; // section ends where a new one begins
|
|
93
|
+
const oldStart = Number(hm[1]);
|
|
94
|
+
const oldCount = hm[2] === undefined ? 1 : Number(hm[2]);
|
|
95
|
+
const newStart = Number(hm[3]);
|
|
96
|
+
const newCount = hm[4] === undefined ? 1 : Number(hm[4]);
|
|
97
|
+
i += 1;
|
|
98
|
+
const body = [];
|
|
99
|
+
let oldSeen = 0;
|
|
100
|
+
let newSeen = 0;
|
|
101
|
+
while (i < lines.length) {
|
|
102
|
+
const bl = lines[i];
|
|
103
|
+
if (bl.startsWith("@@") || bl.startsWith("--- ") || bl.startsWith("diff --git "))
|
|
104
|
+
break;
|
|
105
|
+
if (isBinaryMarker(bl))
|
|
106
|
+
break;
|
|
107
|
+
if (bl.startsWith("\\"))
|
|
108
|
+
return fail('"No newline at end of file" markers are not supported in v1');
|
|
109
|
+
const op = bl.length === 0 ? " " : bl[0] === " " || bl[0] === "-" || bl[0] === "+" ? bl[0] : null;
|
|
110
|
+
if (op === null)
|
|
111
|
+
return fail(`unrecognized hunk line: ${bl}`);
|
|
112
|
+
if (op !== "+")
|
|
113
|
+
oldSeen += 1;
|
|
114
|
+
if (op !== "-")
|
|
115
|
+
newSeen += 1;
|
|
116
|
+
body.push({ op, text: bl.slice(1) });
|
|
117
|
+
i += 1;
|
|
118
|
+
}
|
|
119
|
+
if (oldSeen !== oldCount || newSeen !== newCount) {
|
|
120
|
+
return fail(`hunk @@ -${oldStart},${oldCount} +${newStart},${newCount} @@ declares ${oldCount}/${newCount} lines, body has ${oldSeen}/${newSeen}`);
|
|
121
|
+
}
|
|
122
|
+
if (oldCount + newCount === 0)
|
|
123
|
+
return fail(`empty hunk at ${newPath}:${String(oldStart)}`);
|
|
124
|
+
hunks.push({ oldStart, oldCount, newStart, newCount, lines: body });
|
|
125
|
+
}
|
|
126
|
+
if (i < lines.length && isBinaryMarker(lines[i]))
|
|
127
|
+
return fail("binary patches are not supported as candidate ops");
|
|
128
|
+
if (hunks.length === 0)
|
|
129
|
+
return fail(`no valid @@ hunk for file section ${newPath}`);
|
|
130
|
+
changes.push({ path: newPath, kind, hunks });
|
|
131
|
+
pendingGit = null;
|
|
132
|
+
}
|
|
133
|
+
if (changes.length === 0)
|
|
134
|
+
return fail("no file sections found in diff");
|
|
135
|
+
return { ok: true, changes };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Apply parsed changes fully in memory. `readBase(relPath)` returns the current
|
|
139
|
+
* file text or null when absent. Hunks are POSITIONAL: the drifted worktree case
|
|
140
|
+
* (file shifted or edited around the anchor) refuses with a mismatch reason and
|
|
141
|
+
* the caller — having written nothing yet — discards the whole candidate.
|
|
142
|
+
* Precondition enforced here: paths unique across `changes`.
|
|
143
|
+
*/
|
|
144
|
+
export function applyChanges(changes, readBase) {
|
|
145
|
+
const files = new Map();
|
|
146
|
+
const touched = [];
|
|
147
|
+
for (const change of changes) {
|
|
148
|
+
if (files.has(change.path))
|
|
149
|
+
return failApply(`duplicate file section for ${change.path}`);
|
|
150
|
+
const base = readBase(change.path);
|
|
151
|
+
if (change.kind === "create") {
|
|
152
|
+
if (base !== null)
|
|
153
|
+
return failApply(`create refused: ${change.path} already exists`);
|
|
154
|
+
const added = change.hunks.flatMap((h) => h.lines.filter((l) => l.op !== "-").map((l) => l.text));
|
|
155
|
+
files.set(change.path, `${added.join("\n")}\n`);
|
|
156
|
+
touched.push(change.path);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (base === null)
|
|
160
|
+
return failApply(`target file missing in worktree: ${change.path}`);
|
|
161
|
+
const hadFinalNewline = base.endsWith("\n");
|
|
162
|
+
const src = base.split("\n");
|
|
163
|
+
if (hadFinalNewline)
|
|
164
|
+
src.pop();
|
|
165
|
+
const out = [...src];
|
|
166
|
+
let offset = 0;
|
|
167
|
+
for (const h of change.hunks) {
|
|
168
|
+
const olds = h.lines.filter((l) => l.op !== "+").map((l) => l.text);
|
|
169
|
+
const news = h.lines.filter((l) => l.op !== "-").map((l) => l.text);
|
|
170
|
+
const start = h.oldStart - 1 + offset;
|
|
171
|
+
if (start < 0 || start + olds.length > src.length) {
|
|
172
|
+
return failApply(`hunk at ${change.path}:${String(h.oldStart)} out of range — worktree drifted`);
|
|
173
|
+
}
|
|
174
|
+
for (let j = 0; j < olds.length; j += 1) {
|
|
175
|
+
if (out[start + j] !== olds[j]) {
|
|
176
|
+
return failApply(`hunk context mismatch at ${change.path}:${String(h.oldStart + j)} — worktree drifted, refusing without force`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
out.splice(start, olds.length, ...news);
|
|
180
|
+
offset += news.length - olds.length;
|
|
181
|
+
}
|
|
182
|
+
files.set(change.path, out.join("\n") + (hadFinalNewline ? "\n" : ""));
|
|
183
|
+
touched.push(change.path);
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, files, touched };
|
|
186
|
+
}
|
|
187
|
+
function failApply(reason) {
|
|
188
|
+
return { ok: false, reason };
|
|
189
|
+
}
|