feature-factory 0.7.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.
@@ -0,0 +1,366 @@
1
+ // Observe, don't trust. A subagent's report is a claim, so independently re-derive
2
+ // the diff and re-run its named tests before work-reviewer judges observed evidence.
3
+ // `factory observe` mechanizes that rule because an autonomous run has no human to
4
+ // check whether the orchestrator actually observed anything.
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
7
+ import { dirname, join, relative, resolve, sep } from "node:path";
8
+ import { CONTROL_PLANE } from "../state/schema.js";
9
+
10
+ export const DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS = 900000;
11
+ export const DEFAULT_BOOTSTRAP_TIMEOUT_MS = 900000;
12
+
13
+ export const EVIDENCE_KEYS = Object.freeze([
14
+ "subject", "run_id", "attempt", "branch", "base_ref", "worktree", "status", "blocked_reason",
15
+ "worktree_clean",
16
+ "files_changed", "diff_stat", "diff_observed", "commands", "tests", "commit",
17
+ "observed_by", "review_ready", "claim_reconciliation",
18
+ ]);
19
+
20
+ export function git(cwd, args, { runner = spawnSync } = {}) {
21
+ const result = runner("git", args, { cwd, encoding: "utf8", shell: false, env: { ...process.env, LC_ALL: "C" } });
22
+ const status = Number.isInteger(result?.status) ? result.status : null;
23
+ return {
24
+ ok: status === 0,
25
+ status,
26
+ stdout: String(result?.stdout ?? ""),
27
+ stderr: String(result?.stderr ?? ""),
28
+ argv: ["git", ...args],
29
+ };
30
+ }
31
+
32
+ // Every fact below is re-derived from the repository. A caller-supplied value is
33
+ // never substituted for an observation; if git cannot answer, the field is null
34
+ // and `diff_observed` is false, which blocks review_ready.
35
+ // `options.ref` names what to observe, defaulting to HEAD. A caller that knows the
36
+ // subject's branch should pass it: binding to whatever happens to be checked out
37
+ // makes the observation depend on the orchestrator's current directory state, and a
38
+ // merge legitimately checks out the integration branch in the same worktree.
39
+ export function observeWorktree(worktree, baseRef, options = {}) {
40
+ const ref = options.ref ?? "HEAD";
41
+ const commands = [];
42
+ const record = (result) => {
43
+ commands.push({ cmd: result.argv.join(" "), exit: result.status, summary: summarize(result) });
44
+ return result;
45
+ };
46
+
47
+ const head = record(git(worktree, ["rev-parse", ref], options));
48
+ const names = record(git(worktree, ["--literal-pathspecs", "diff", "--name-only", "-z", `${baseRef}...${ref}`], options));
49
+ const stat = record(git(worktree, ["diff", "--stat", `${baseRef}...${ref}`], options));
50
+
51
+ const observed = head.ok && names.ok && stat.ok;
52
+ return {
53
+ commit: head.ok ? head.stdout.trim() : null,
54
+ // NUL-separated and untrimmed: ownership is decided on these paths, so a name with a
55
+ // trailing space must stay a distinct path rather than collapsing onto another.
56
+ files_changed: names.ok ? names.stdout.split("\0").filter((path) => path !== "") : [],
57
+ diff_stat: stat.ok ? stat.stdout.trim() : null,
58
+ diff_observed: observed,
59
+ commands,
60
+ };
61
+ }
62
+
63
+ // Attack 7: a caller may present any head it likes. Ancestry is asked of git, and
64
+ // git's exit codes are read precisely: 0 is "is an ancestor", 1 is "proven not",
65
+ // anything else is a failed probe that must not be read as either.
66
+ // Finding 3: observeWorktree derives the commit and diff from a git ref, but runTests
67
+ // executes in the mutable working directory. So tests could pass on uncommitted bytes
68
+ // while the evidence claimed the HEAD commit - and the same tests then failed on the
69
+ // clean merged tree. This is not an adversarial case: a builder leaving uncommitted
70
+ // changes is the ordinary state of an agent-driven worktree.
71
+ //
72
+ // Observation of a dirty tree is meaningless, so it is refused rather than recorded.
73
+ export function observeCleanliness(worktree, options = {}) {
74
+ const probe = git(worktree, ["status", "--porcelain", "--untracked-files=normal"], options);
75
+ if (!probe.ok) return { clean: false, reason: "worktree state could not be observed", entries: [] };
76
+ const entries = probe.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
77
+ return { clean: entries.length === 0, reason: entries.length === 0 ? null : "worktree has uncommitted changes", entries };
78
+ }
79
+
80
+ export function runBootstrap(worktree, command, timeoutMs = DEFAULT_BOOTSTRAP_TIMEOUT_MS, { runner = spawnSync } = {}) {
81
+ try {
82
+ const result = runner(command, [], {
83
+ cwd: worktree, shell: true, env: process.env, timeout: timeoutMs, stdio: ["inherit", process.stderr, process.stderr],
84
+ });
85
+ return Number.isSafeInteger(result?.status) && result.status >= 0 ? result.status : null;
86
+ } catch { return null; }
87
+ }
88
+
89
+ export function observeTrackedCleanliness(worktree, options = {}) {
90
+ try {
91
+ const top = git(worktree, ["rev-parse", "--show-toplevel"], options);
92
+ const probes = [
93
+ git(worktree, ["--literal-pathspecs", "diff", "--name-only", "-z"], options),
94
+ git(worktree, ["--literal-pathspecs", "diff", "--cached", "--name-only", "-z"], options),
95
+ ];
96
+ if (!top.ok || realpathSync(resolve(worktree, top.stdout.trim())) !== realpathSync(worktree)
97
+ || probes.some((probe) => !probe.ok)) return { observed: false, entries: [] };
98
+ return { observed: true, entries: [...new Set(probes.flatMap((probe) => probe.stdout.split("\0").filter(Boolean)))].sort() };
99
+ } catch { return { observed: false, entries: [] }; }
100
+ }
101
+
102
+ export function observeAncestry(worktree, ancestor, descendant, options = {}) {
103
+ const probe = git(worktree, ["merge-base", "--is-ancestor", ancestor, descendant], options);
104
+ if (probe.ok) return "ancestor";
105
+ if (probe.status === 1) return "not-ancestor";
106
+ return "indeterminate";
107
+ }
108
+
109
+ // Attack 1: the test command is run here, by us, and its exit code is recorded
110
+ // from the process rather than from anybody's report. `observed: false` means we
111
+ // could not run it, which is not the same as a pass.
112
+ export function runTests(worktree, command, { runner = spawnSync, skipReason = null, shellCommand = false, timeoutMs = DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS } = {}) {
113
+ if (!command) {
114
+ // Finding 1: defaulting a skip reason let omission manufacture review readiness.
115
+ // Tests must be observed green or explicitly skipped with a caller-declared reason;
116
+ // omission alone is neither.
117
+ return { cmd: null, exit: null, observed: false, skipped_reason: skipReason };
118
+ }
119
+ const result = shellCommand
120
+ ? runner(command, [], { cwd: worktree, shell: true, stdio: "inherit", env: process.env, timeout: timeoutMs })
121
+ : runner(command[0], command.slice(1), { cwd: worktree, encoding: "utf8", shell: false });
122
+ const exit = Number.isInteger(result?.status) ? result.status : null;
123
+ return { cmd: shellCommand ? command : command.join(" "), exit, observed: exit !== null, skipped_reason: null };
124
+ }
125
+
126
+ // Readiness requires completed, clean, changed, observed-diff evidence and tests
127
+ // observed passing or ratified as explicitly skipped with a reason.
128
+ export function deriveReviewReady(evidence) {
129
+ if (evidence.status !== "completed") return false;
130
+ // A tree with uncommitted changes cannot produce evidence about the commit it
131
+ // claims, whatever the tests said.
132
+ if (evidence.worktree_clean !== true) return false;
133
+ if (!Array.isArray(evidence.files_changed) || evidence.files_changed.length === 0) return false;
134
+ if (evidence.diff_observed !== true) return false;
135
+ const tests = evidence.tests ?? {};
136
+ if (tests.observed === true) return tests.exit === 0;
137
+ // A skip is only acceptable when a reason was recorded. Absent tests with no
138
+ // reason is the shape a fabricated pass would take.
139
+ return typeof tests.skipped_reason === "string" && tests.skipped_reason.trim().length > 0;
140
+ }
141
+
142
+ // A claim that disagrees with observation is a finding, not the truth. The
143
+ // disagreement is recorded rather than resolved: the reviewer judges it.
144
+ export function reconcileClaim(claim, observation) {
145
+ if (claim === null || claim === undefined) return { claimed: false, mismatches: [] };
146
+ const mismatches = [];
147
+ const compare = (field, claimed, observed) => {
148
+ if (claimed === undefined || claimed === null) return;
149
+ if (Array.isArray(claimed) && Array.isArray(observed)) {
150
+ const claimedSet = [...claimed].sort().join("\n");
151
+ const observedSet = [...observed].sort().join("\n");
152
+ if (claimedSet !== observedSet) mismatches.push({ field, claimed, observed });
153
+ return;
154
+ }
155
+ if (claimed !== observed) mismatches.push({ field, claimed, observed });
156
+ };
157
+ compare("commit", claim.commit, observation.commit);
158
+ compare("files_changed", claim.files_changed, observation.files_changed);
159
+ compare("status", claim.status, observation.status);
160
+ // The important one: a claimed passing test against an observed failure or an
161
+ // unobserved run.
162
+ if (claim.tests?.exit !== undefined && claim.tests.exit !== null) {
163
+ compare("tests.exit", claim.tests.exit, observation.tests?.exit ?? null);
164
+ }
165
+ return { claimed: true, mismatches };
166
+ }
167
+
168
+ // Attack 5: a slice may only change paths it declared. Ownership is decided on the
169
+ // observed file list, never on the builder's report of what it touched.
170
+ export function unownedPaths(filesChanged, declaredPaths) {
171
+ if (!Array.isArray(declaredPaths) || declaredPaths.length === 0) return [...filesChanged];
172
+ return filesChanged.filter((file) => !declaredPaths.some((declared) => coversPath(declared, file)));
173
+ }
174
+
175
+ function coversPath(declared, file) {
176
+ const normalizedDeclared = declared.replace(/\/+$/u, "");
177
+ if (file === normalizedDeclared) return true;
178
+ // A directory declaration covers its subtree; a prefix that is not a path
179
+ // boundary does not, so "src/app" must not cover "src/application/x".
180
+ return file.startsWith(`${normalizedDeclared}/`);
181
+ }
182
+
183
+ // .gitignore can conceal files from cleanliness and observed-diff checks, so it is
184
+ // refused with control-plane prefixes regardless of ownership. Ecosystem manifests
185
+ // are authorized through ratified seeded ownership instead.
186
+ const PRIVILEGED_PREFIXES = Object.freeze([CONTROL_PLANE, ".git"]);
187
+ // `.factory.json` is the repository's declaration of how to resolve, verify, publish and publish-as.
188
+ // It is committed rather than ignored, so ownership cannot come from gitignore the way the run
189
+ // directory's does; it comes from here. A run that could edit it could redefine what it is allowed to
190
+ // run, which is the one thing configuration must not be able to do.
191
+ const PRIVILEGED_EXACT = Object.freeze([".gitignore", ".factory.json"]);
192
+
193
+ export function privilegedPaths(filesChanged) {
194
+ return filesChanged.filter((file) => PRIVILEGED_PREFIXES.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))
195
+ || PRIVILEGED_EXACT.includes(file));
196
+ }
197
+
198
+ export function buildEvidence({ subject, runId, attempt, branch, baseRef, worktree, status, blockedReason = null, claim = null, testCommand = null, skipReason = null, shellCommand = false, testTimeoutMs = DEFAULT_REPOSITORY_VERIFY_TIMEOUT_MS, options = {} }) {
199
+ // Cleanliness is established before anything else is observed, because every later
200
+ // fact - the diff, the commit, and above all the test result - is only about the
201
+ // recorded commit if the tree has nothing uncommitted in it.
202
+ const cleanliness = observeCleanliness(worktree, options);
203
+ const observation = observeWorktree(worktree, baseRef, options);
204
+ // Tests are not run at all against a dirty tree: running them would produce a
205
+ // result about bytes that are not going to merge.
206
+ const tests = cleanliness.clean
207
+ ? runTests(worktree, testCommand, { ...options, skipReason, shellCommand, timeoutMs: testTimeoutMs })
208
+ : { cmd: testCommand ? (shellCommand ? testCommand : testCommand.join(" ")) : null, exit: null, observed: false, skipped_reason: null };
209
+
210
+ // Third round, finding 1: cleanliness was a pre-test snapshot, so a test that wrote
211
+ // tracked files left the tree dirty while the evidence still claimed a clean HEAD -
212
+ // and the same test then failed on the merged tree. The tree and the commit are
213
+ // re-observed after the run, and both must be unchanged for the result to describe
214
+ // the recorded commit.
215
+ //
216
+ // This does NOT close the case where another process mutates the worktree during the
217
+ // run and restores it before the end: both observations are clean and the commit
218
+ // matches, so no before/after comparison can see it. Closing that needs tests run from
219
+ // an isolated checkout. Left open deliberately - its precondition is a second writer
220
+ // in this slice's worktree, meaning a builder that has not actually finished or two
221
+ // slices sharing a worktree, which is an orchestration error rather than one of the
222
+ // twelve attacks.
223
+ const afterTests = observeCleanliness(worktree, options);
224
+ const headAfter = git(worktree, ["rev-parse", options.ref ?? "HEAD"], options);
225
+ const stableUnderTest = cleanliness.clean
226
+ && afterTests.clean
227
+ && headAfter.ok
228
+ && headAfter.stdout.trim() === observation.commit;
229
+ const evidence = {
230
+ subject,
231
+ // Finding 2: evidence carried no run identity, so a record from another run with a
232
+ // matching subject was accepted and merged.
233
+ run_id: runId ?? null,
234
+ attempt,
235
+ branch,
236
+ base_ref: baseRef,
237
+ worktree,
238
+ status,
239
+ blocked_reason: blockedReason ?? cleanliness.reason ?? (stableUnderTest ? null : "worktree changed while the tests ran"),
240
+ // Named for what it asserts: clean before the run, still clean after, and HEAD did
241
+ // not move. A pre-test snapshot alone was not enough.
242
+ worktree_clean: stableUnderTest,
243
+ files_changed: observation.files_changed,
244
+ diff_stat: observation.diff_stat,
245
+ diff_observed: observation.diff_observed,
246
+ commands: observation.commands,
247
+ tests,
248
+ commit: observation.commit,
249
+ observed_by: "orchestrator",
250
+ review_ready: false,
251
+ claim_reconciliation: { claimed: false, mismatches: [] },
252
+ };
253
+ evidence.review_ready = deriveReviewReady(evidence);
254
+ evidence.claim_reconciliation = reconcileClaim(claim, evidence);
255
+ // A claim that disagrees with what we observed cannot be review-ready: the
256
+ // disagreement is itself the finding.
257
+ if (evidence.claim_reconciliation.mismatches.length > 0) evidence.review_ready = false;
258
+ return evidence;
259
+ }
260
+
261
+ export function resolveWorktree(repo, worktree) {
262
+ const absolute = resolve(repo, worktree);
263
+ const rel = relative(resolve(repo), absolute);
264
+ if (rel.startsWith("..") || rel.startsWith(sep) || !existsSync(absolute)) return null;
265
+ return absolute;
266
+ }
267
+
268
+ export function proveInitContainment({ operatorRoot, sandboxPath, runId, worktree }) {
269
+ const container = join(operatorRoot, ".factory-sandboxes");
270
+ if (sandboxPath !== join(container, runId)) throw new Error("sandbox is not the exact derived run path");
271
+ exactDirectory(operatorRoot);
272
+ exactDirectory(container);
273
+ exactDirectory(sandboxPath);
274
+ if (dirname(sandboxPath) !== container || realpathSync(dirname(sandboxPath)) !== container) throw new Error("sandbox parent is not the canonical container");
275
+
276
+ const gitDirectory = join(sandboxPath, ".git");
277
+ if (gitPath(sandboxPath, ["rev-parse", "--show-toplevel"]) !== sandboxPath) throw new Error("sandbox Git top level escapes the sandbox");
278
+ exactDirectory(gitDirectory);
279
+ if (gitPath(sandboxPath, ["rev-parse", "--absolute-git-dir"]) !== gitDirectory) throw new Error("sandbox Git directory is not S/.git");
280
+ if (gitPath(sandboxPath, ["rev-parse", "--git-common-dir"]) !== gitDirectory) throw new Error("sandbox Git common directory is not S/.git");
281
+
282
+ const configuredWorktree = resolve(sandboxPath, worktree);
283
+ if (!within(configuredWorktree, sandboxPath)) throw new Error("configured worktree escapes the sandbox");
284
+ inspectComponents(sandboxPath, configuredWorktree);
285
+ exactDirectory(configuredWorktree);
286
+ const worktreeTop = gitPath(configuredWorktree, ["rev-parse", "--show-toplevel"]);
287
+ if (worktreeTop !== sandboxPath && worktreeTop !== configuredWorktree) throw new Error("configured worktree Git top level escapes the sandbox");
288
+ const worktreeGitDirectory = gitPath(configuredWorktree, ["rev-parse", "--absolute-git-dir"]);
289
+ if (!within(worktreeGitDirectory, gitDirectory)) throw new Error("configured worktree Git directory escapes S/.git");
290
+ assertWorktreeRelationship(sandboxPath, configuredWorktree, gitDirectory, worktreeTop, worktreeGitDirectory);
291
+ if (gitPath(configuredWorktree, ["rev-parse", "--git-common-dir"]) !== gitDirectory) throw new Error("configured worktree Git common directory is not S/.git");
292
+
293
+ const factory = ensureDirectory(join(sandboxPath, CONTROL_PLANE), sandboxPath);
294
+ const runDir = ensureDirectory(join(factory, runId), factory);
295
+ const directories = [
296
+ ...["plan", "artifacts", "evidence", "reviews"].map((name) => ensureDirectory(join(runDir, name), runDir)),
297
+ ensureDirectory(join(factory, "worktrees"), factory),
298
+ ];
299
+ directories.push(ensureDirectory(join(directories.at(-1), runId), directories.at(-1)));
300
+
301
+ for (const path of [operatorRoot, container, sandboxPath, gitDirectory, configuredWorktree, factory, runDir, ...directories]) exactDirectory(path);
302
+ const finalTop = gitPath(configuredWorktree, ["rev-parse", "--show-toplevel"]);
303
+ const finalGitDirectory = gitPath(configuredWorktree, ["rev-parse", "--absolute-git-dir"]);
304
+ if (gitPath(sandboxPath, ["rev-parse", "--show-toplevel"]) !== sandboxPath
305
+ || gitPath(sandboxPath, ["rev-parse", "--absolute-git-dir"]) !== gitDirectory
306
+ || gitPath(sandboxPath, ["rev-parse", "--git-common-dir"]) !== gitDirectory
307
+ || gitPath(configuredWorktree, ["rev-parse", "--git-common-dir"]) !== gitDirectory) {
308
+ throw new Error("final Git containment proof failed");
309
+ }
310
+ assertWorktreeRelationship(sandboxPath, configuredWorktree, gitDirectory, finalTop, finalGitDirectory);
311
+ return { sandboxPath, configuredWorktree };
312
+ }
313
+
314
+ function exactDirectory(path) {
315
+ const stats = lstatSync(path);
316
+ if (stats.isSymbolicLink() || !stats.isDirectory() || realpathSync(path) !== path) throw new Error(`unsafe directory '${path}'`);
317
+ return path;
318
+ }
319
+
320
+ function ensureDirectory(path, parent) {
321
+ exactDirectory(parent);
322
+ try {
323
+ exactDirectory(path);
324
+ } catch (error) {
325
+ if (error?.code !== "ENOENT") throw error;
326
+ mkdirSync(path);
327
+ exactDirectory(path);
328
+ }
329
+ if (dirname(path) !== parent || realpathSync(dirname(path)) !== parent || !within(realpathSync(path), parent, true)) throw new Error(`directory escapes its parent '${path}'`);
330
+ return path;
331
+ }
332
+
333
+ function inspectComponents(root, target) {
334
+ const path = relative(root, target);
335
+ let cursor = root;
336
+ for (const part of path.split(sep).filter(Boolean)) {
337
+ cursor = join(cursor, part);
338
+ const stats = lstatSync(cursor);
339
+ if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error(`unsafe configured worktree component '${cursor}'`);
340
+ }
341
+ }
342
+
343
+ function gitPath(cwd, args) {
344
+ const result = git(cwd, args);
345
+ if (!result.ok || !result.stdout.trim()) throw new Error(`Git observation failed: git ${args.join(" ")}`);
346
+ return realpathSync(resolve(cwd, result.stdout.trim()));
347
+ }
348
+
349
+ function assertWorktreeRelationship(sandbox, worktree, gitDirectory, top, observedGitDirectory) {
350
+ if (top === sandbox && observedGitDirectory === gitDirectory) return;
351
+ if (worktree !== sandbox && top === worktree && within(observedGitDirectory, gitDirectory, true)) return;
352
+ throw new Error("configured worktree Git relationship is not contained");
353
+ }
354
+
355
+ function within(child, parent, strict = false) {
356
+ const path = relative(parent, child);
357
+ return (!strict && path === "") || (path !== "" && path !== ".." && !path.startsWith(`..${sep}`) && !path.startsWith(sep));
358
+ }
359
+
360
+ function summarize(result) {
361
+ const text = (result.ok ? result.stdout : result.stderr).trim().split("\n")[0] ?? "";
362
+ return text.length > 200 ? `${text.slice(0, 197)}...` : text;
363
+ }
364
+
365
+ export const EVIDENCE_DIR = "evidence";
366
+ export const evidenceRef = (subject) => join(EVIDENCE_DIR, `${subject}.json`);
@@ -0,0 +1,300 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, readFileSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { git, privilegedPaths } from "./index.js";
5
+ import { parseRepositoryConfig } from "./repository-config.js";
6
+ import { repositoryRelativePath, validateRun } from "../state/schema.js";
7
+
8
+ export const REPAIR_JOURNAL_REF = "artifacts/post-merge-repairs.md";
9
+ export const REPAIR_EVIDENCE_PREFIX = "repair-reverification.";
10
+
11
+ const SHA = /^[0-9a-f]{40}$/u;
12
+ const DIGEST = /^sha256:[0-9a-f]{64}$/u;
13
+ const RECORD_ID = /^repair-([0-9a-f]{40})-([1-9][0-9]*)$/u;
14
+ const JOURNAL_KEYS = ["version", "records"];
15
+ const RECORD_KEYS = ["record_id", "introducing_merge", "attempt", "starting_head", "trigger", "trigger_result", "test_paths", "cause", "property_outcome", "repair_commit", "post_repair_result", "status"];
16
+ const MARKER_KEYS = ["version", "run_id", "record_id", "attempt", "run_sha256", "journal_sha256", "record_sha256", "introducing_merge", "repair_commit", "trigger", "started_at"];
17
+ const RESULT_KEYS = ["version", "run_id", "record_id", "attempt", "marker_sha256", "run_sha256", "journal_sha256", "record_sha256", "introducing_merge", "repair_commit", "trigger", "result", "observed_at", "observed_by"];
18
+ const STATUSES = ["planned", "committed", "verified", "failed", "exhausted", "needs-human"];
19
+
20
+ const fail = (message) => { throw new Error(`repair state is invalid: ${message}`); };
21
+ const sameKeys = (value, keys) => value && typeof value === "object" && !Array.isArray(value)
22
+ && JSON.stringify(Object.keys(value)) === JSON.stringify(keys);
23
+ const nonblank = (value) => typeof value === "string" && Boolean(value.trim());
24
+ const positive = (value) => Number.isSafeInteger(value) && value > 0;
25
+ const digest = (value) => `sha256:${createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex")}`;
26
+ const equal = (left, right) => JSON.stringify(left) === JSON.stringify(right);
27
+
28
+ function canonicalFile(path, label, { optional = false } = {}) {
29
+ let stats;
30
+ let bytes;
31
+ try {
32
+ stats = lstatSync(path);
33
+ if (!stats.isFile() || stats.isSymbolicLink()) fail(`${label} is not a regular non-symlink file`);
34
+ bytes = readFileSync(path);
35
+ } catch (error) {
36
+ if (optional && error?.code === "ENOENT") return null;
37
+ if (String(error.message).startsWith("repair state is invalid:")) throw error;
38
+ fail(`${label} could not be read: ${error.message}`);
39
+ }
40
+ let text;
41
+ let value;
42
+ try {
43
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
44
+ value = JSON.parse(text);
45
+ } catch {
46
+ fail(`${label} is not canonical UTF-8 JSON`);
47
+ }
48
+ if (!bytes.equals(Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"))) fail(`${label} bytes are not canonical`);
49
+ return { bytes, value };
50
+ }
51
+
52
+ function validResult(value) {
53
+ return sameKeys(value, ["observed", "exit"])
54
+ && typeof value.observed === "boolean"
55
+ && ((value.observed && Number.isSafeInteger(value.exit) && value.exit >= 0)
56
+ || (!value.observed && value.exit === null));
57
+ }
58
+
59
+ function validateRecord(row, index, run) {
60
+ const label = `journal record ${index + 1}`;
61
+ if (!sameKeys(row, RECORD_KEYS)) fail(`${label} has the wrong key order or shape`);
62
+ const match = RECORD_ID.exec(row.record_id);
63
+ if (!match || !positive(row.attempt) || Number(match[2]) !== row.attempt || !Number.isSafeInteger(Number(match[2]))
64
+ || match[1] !== row.introducing_merge) fail(`${label} has an invalid record binding`);
65
+ if (!SHA.test(row.starting_head) || !STATUSES.includes(row.status)) fail(`${label} has an invalid head or status`);
66
+ if (!sameKeys(row.trigger, ["command", "timeout_ms"]) || !nonblank(row.trigger.command) || !positive(row.trigger.timeout_ms)) {
67
+ fail(`${label} has an invalid trigger`);
68
+ }
69
+ if (!validResult(row.trigger_result) || !row.trigger_result.observed || row.trigger_result.exit === 0) {
70
+ fail(`${label} does not record an observed failing trigger`);
71
+ }
72
+ if (!Array.isArray(row.test_paths) || row.test_paths.length === 0
73
+ || row.test_paths.some((path) => !repositoryRelativePath(path))
74
+ || new Set(row.test_paths).size !== row.test_paths.length
75
+ || !equal(row.test_paths, [...row.test_paths].sort()) || privilegedPaths(row.test_paths).length > 0) {
76
+ fail(`${label} has invalid canonical test paths`);
77
+ }
78
+ if (!nonblank(row.cause) || row.attempt > run.max_retries) fail(`${label} has an invalid cause or exceeds max_retries`);
79
+ const property = nonblank(row.property_outcome);
80
+ const commit = SHA.test(String(row.repair_commit));
81
+ const result = validResult(row.post_repair_result);
82
+ const pass = result && row.post_repair_result.observed && row.post_repair_result.exit === 0;
83
+ const observedFailure = result && row.post_repair_result.observed && row.post_repair_result.exit > 0;
84
+ const unobserved = result && !row.post_repair_result.observed;
85
+ const validShape = (row.status === "planned" && row.property_outcome === null && row.repair_commit === null && row.post_repair_result === null)
86
+ || (row.status === "committed" && property && commit && row.post_repair_result === null)
87
+ || (row.status === "verified" && property && commit && pass)
88
+ || (row.status === "failed" && property && commit && observedFailure)
89
+ || (row.status === "exhausted" && property && commit && observedFailure && row.attempt === run.max_retries)
90
+ || (row.status === "needs-human" && ((row.property_outcome === null && row.repair_commit === null && row.post_repair_result === null)
91
+ || (property && commit && (observedFailure || unobserved))));
92
+ if (!validShape) fail(`${label} has a status-conditioned shape mismatch`);
93
+ return row;
94
+ }
95
+
96
+ function gitValue(repo, args, label) {
97
+ const observed = git(repo, args);
98
+ if (!observed.ok) fail(`${label} could not be observed`);
99
+ return observed.stdout;
100
+ }
101
+
102
+ function validateBindings(repo, row, run) {
103
+ const slices = run.slices.filter((slice) => slice.status === "merged" && slice.merge_commit === row.introducing_merge);
104
+ if (slices.length !== 1) fail(`record '${row.record_id}' introducing merge does not identify exactly one merged slice`);
105
+ if (!git(repo, ["merge-base", "--is-ancestor", row.introducing_merge, row.starting_head]).ok) {
106
+ fail(`record '${row.record_id}' starting head does not descend from its introducing merge`);
107
+ }
108
+ if (row.repair_commit === null) return;
109
+ if (row.repair_commit === row.introducing_merge) fail(`record '${row.record_id}' repair commit equals its introducing merge`);
110
+ const parents = gitValue(repo, ["rev-list", "--parents", "-n", "1", row.repair_commit], `record '${row.record_id}' repair commit`)
111
+ .trim().split(/\s+/u).slice(1);
112
+ if (parents.length !== 1 || parents[0] !== row.starting_head) fail(`record '${row.record_id}' repair commit has the wrong parent`);
113
+ const paths = gitValue(repo, ["--literal-pathspecs", "diff", "--name-only", "--no-renames", "-z", row.starting_head, row.repair_commit], `record '${row.record_id}' repair diff`)
114
+ .split("\0").filter(Boolean).sort();
115
+ if (paths.length === 0 || !equal(paths, row.test_paths)) fail(`record '${row.record_id}' repair diff does not equal test_paths`);
116
+ let config;
117
+ try {
118
+ config = parseRepositoryConfig(gitValue(repo, ["show", `${row.repair_commit}:.factory.json`], `record '${row.record_id}' committed config`));
119
+ } catch (error) {
120
+ fail(`record '${row.record_id}' committed config is invalid: ${error.message}`);
121
+ }
122
+ if (config.command !== row.trigger.command || config.timeoutMs !== row.trigger.timeout_ms) {
123
+ fail(`record '${row.record_id}' trigger does not match its committed config`);
124
+ }
125
+ }
126
+
127
+ function validateJournal(value, run, repo) {
128
+ if (!sameKeys(value, JOURNAL_KEYS) || value.version !== 1 || !Array.isArray(value.records) || value.records.length === 0) {
129
+ fail("journal has the wrong version, key order, or shape");
130
+ }
131
+ const records = value.records.map((row, index) => validateRecord(row, index, run));
132
+ const ids = new Set();
133
+ const chains = new Map();
134
+ let active = 0;
135
+ for (const row of records) {
136
+ if (ids.has(row.record_id)) fail(`duplicate record '${row.record_id}'`);
137
+ ids.add(row.record_id);
138
+ if (["planned", "committed"].includes(row.status)) active += 1;
139
+ const chain = chains.get(row.introducing_merge) ?? [];
140
+ const prior = chain.at(-1);
141
+ if (row.attempt !== chain.length + 1) fail(`record '${row.record_id}' is not the next contiguous attempt`);
142
+ if (prior && (prior.status !== "failed" || row.starting_head !== prior.repair_commit)) {
143
+ fail(`record '${row.record_id}' does not validly follow the prior repair attempt`);
144
+ }
145
+ chain.push(row);
146
+ chains.set(row.introducing_merge, chain);
147
+ validateBindings(repo, row, run);
148
+ }
149
+ if (active > 1) fail("more than one repair record is active");
150
+ return { records, chains };
151
+ }
152
+
153
+ function canonicalTimestamp(value) {
154
+ const parsed = typeof value === "string" ? Date.parse(value) : NaN;
155
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
156
+ }
157
+
158
+ function validateMarker(value, row, runId, attempt, label) {
159
+ if (!sameKeys(value, MARKER_KEYS) || value.version !== 1 || value.run_id !== runId
160
+ || value.record_id !== row.record_id || value.attempt !== attempt
161
+ || !DIGEST.test(value.run_sha256) || !DIGEST.test(value.journal_sha256)
162
+ || value.record_sha256 !== digest(row) || value.introducing_merge !== row.introducing_merge
163
+ || value.repair_commit !== row.repair_commit || !equal(value.trigger, row.trigger)
164
+ || !sameKeys(value.trigger, ["command", "timeout_ms"]) || !canonicalTimestamp(value.started_at)) {
165
+ fail(`${label} has an invalid marker binding or schema`);
166
+ }
167
+ }
168
+
169
+ function validateExecutionResult(value) {
170
+ return sameKeys(value, ["observed", "exit", "commit", "worktree_clean"])
171
+ && typeof value.observed === "boolean"
172
+ && ((value.observed && Number.isSafeInteger(value.exit) && value.exit >= 0) || (!value.observed && value.exit === null))
173
+ && (value.commit === null || SHA.test(value.commit)) && typeof value.worktree_clean === "boolean";
174
+ }
175
+
176
+ function validateResult(value, marker, row, runId, attempt, label) {
177
+ if (!sameKeys(value, RESULT_KEYS) || value.version !== 1 || value.run_id !== runId
178
+ || value.record_id !== row.record_id || value.attempt !== attempt || value.marker_sha256 !== digest(marker)
179
+ || value.run_sha256 !== marker.run_sha256 || value.journal_sha256 !== marker.journal_sha256
180
+ || value.record_sha256 !== marker.record_sha256 || value.introducing_merge !== marker.introducing_merge
181
+ || value.repair_commit !== marker.repair_commit || !equal(value.trigger, marker.trigger)
182
+ || !sameKeys(value.trigger, ["command", "timeout_ms"]) || !validateExecutionResult(value.result)
183
+ || !canonicalTimestamp(value.observed_at) || value.observed_at !== marker.started_at || value.observed_by !== "factory") {
184
+ fail(`${label} has an invalid result binding or schema`);
185
+ }
186
+ }
187
+
188
+ function evidenceEntries(runDir) {
189
+ const dir = join(runDir, "evidence");
190
+ let stats;
191
+ try {
192
+ stats = lstatSync(dir);
193
+ } catch (error) {
194
+ if (error?.code === "ENOENT") return [];
195
+ fail(`evidence directory could not be inspected: ${error.message}`);
196
+ }
197
+ if (!stats.isDirectory() || stats.isSymbolicLink()) fail("evidence path is not a regular directory");
198
+ try {
199
+ return readdirSync(dir).filter((name) => name.toLowerCase().startsWith(REPAIR_EVIDENCE_PREFIX)).sort();
200
+ } catch (error) {
201
+ fail(`evidence directory could not be read: ${error.message}`);
202
+ }
203
+ }
204
+
205
+ function validateInventory(runDir, runId, records) {
206
+ const rows = new Map(records.map((row) => [row.record_id, row]));
207
+ const histories = new Map();
208
+ const filename = /^repair-reverification\.(repair-[0-9a-f]{40}-[1-9][0-9]*)\.([1-9][0-9]*)(\.started)?\.json$/u;
209
+ for (const name of evidenceEntries(runDir)) {
210
+ const match = filename.exec(name);
211
+ if (!match) fail(`evidence entry '${name}' has a malformed repair re-verification name`);
212
+ const row = rows.get(match[1]);
213
+ const attempt = Number(match[2]);
214
+ if (!row || !positive(attempt) || !(row.status === "needs-human" && row.repair_commit !== null)) {
215
+ fail(`evidence entry '${name}' does not identify an eligible repair record`);
216
+ }
217
+ const history = histories.get(row.record_id) ?? { markers: new Map(), results: new Map(), names: [] };
218
+ const kind = match[3] ? "markers" : "results";
219
+ const parsed = canonicalFile(join(runDir, "evidence", name), `evidence '${name}'`).value;
220
+ if (history[kind].has(attempt)) fail(`evidence entry '${name}' duplicates an attempt`);
221
+ history[kind].set(attempt, parsed);
222
+ history.names.push(name);
223
+ histories.set(row.record_id, history);
224
+ }
225
+ for (const [recordId, history] of histories) {
226
+ const row = rows.get(recordId);
227
+ const attempts = [...history.markers.keys()].sort((a, b) => a - b);
228
+ if (attempts.some((attempt, index) => attempt !== index + 1)) fail(`evidence for '${recordId}' has a marker gap`);
229
+ if ([...history.results.keys()].some((attempt) => !history.markers.has(attempt))) fail(`evidence for '${recordId}' has a result without its marker`);
230
+ let pass = null;
231
+ for (const attempt of attempts) {
232
+ const marker = history.markers.get(attempt);
233
+ validateMarker(marker, row, runId, attempt, `evidence marker for '${recordId}' attempt ${attempt}`);
234
+ const result = history.results.get(attempt);
235
+ if (result) {
236
+ validateResult(result, marker, row, runId, attempt, `evidence result for '${recordId}' attempt ${attempt}`);
237
+ const qualifies = result.result.observed && result.result.exit === 0
238
+ && result.result.commit === row.repair_commit && result.result.worktree_clean;
239
+ if (qualifies) {
240
+ if (pass !== null) fail(`evidence for '${recordId}' contains a second pass`);
241
+ pass = attempt;
242
+ }
243
+ } else if (attempt !== attempts.at(-1)) fail(`evidence for '${recordId}' has a non-final marker-only attempt`);
244
+ }
245
+ if (pass !== null && (pass !== attempts.at(-1) || !history.results.has(pass))) fail(`evidence for '${recordId}' continues after its first pass`);
246
+ history.attempts = attempts.length;
247
+ history.tail = attempts.length > 0 && !history.results.has(attempts.at(-1));
248
+ history.pass = pass;
249
+ history.names.sort();
250
+ }
251
+ return histories;
252
+ }
253
+
254
+ export function readRepairState({ runDir, state = null, runId, repo, recordId = null } = {}) {
255
+ let runBytes;
256
+ let rawRun;
257
+ try {
258
+ runBytes = readFileSync(join(runDir, "run.json"));
259
+ rawRun = validateRun(JSON.parse(runBytes.toString("utf8")));
260
+ } catch (error) {
261
+ fail(`run.json could not be validated: ${error.message}`);
262
+ }
263
+ const run = validateRun(state ?? rawRun);
264
+ if (run.run_id !== runId || rawRun.run_id !== runId) fail(`run ID does not equal loaded run_id '${rawRun.run_id}'`);
265
+ const journalPath = join(runDir, REPAIR_JOURNAL_REF);
266
+ const journalFile = canonicalFile(journalPath, REPAIR_JOURNAL_REF, { optional: true });
267
+ if (!journalFile) {
268
+ const inventory = validateInventory(runDir, runId, []);
269
+ if (recordId !== null) fail(`repair journal is absent; record '${recordId}' does not exist`);
270
+ return { run, runBytes, journal: null, journalBytes: null, records: [], chains: new Map(), inventory, selected: null };
271
+ }
272
+ if (!repo) fail("repository is required to validate the repair journal");
273
+ const { records, chains } = validateJournal(journalFile.value, run, repo);
274
+ const inventory = validateInventory(runDir, runId, records);
275
+ let selected = null;
276
+ if (recordId !== null) {
277
+ const matches = records.filter((row) => row.record_id === recordId);
278
+ if (matches.length !== 1) fail(`record '${recordId}' does not identify exactly one journal row`);
279
+ selected = matches[0];
280
+ if (chains.get(selected.introducing_merge)?.at(-1) !== selected
281
+ || selected.status !== "needs-human" || selected.repair_commit === null) {
282
+ fail(`record '${recordId}' is not the latest eligible post-commit needs-human row`);
283
+ }
284
+ }
285
+ return { run, runBytes, journal: journalFile.value, journalBytes: journalFile.bytes,
286
+ records, chains, inventory, selected, selectedHistory: selected ? inventory.get(recordId) ?? { markers: new Map(), results: new Map(), names: [], attempts: 0, tail: false, pass: null } : null };
287
+ }
288
+
289
+ export function assertRepairPublicationReady(options) {
290
+ const observed = readRepairState(options);
291
+ let tested = null;
292
+ for (const chain of observed.chains.values()) {
293
+ const row = chain.at(-1);
294
+ const history = observed.inventory.get(row.record_id);
295
+ const effective = row.status === "verified" || (row.status === "needs-human" && history?.pass !== null && history?.pass !== undefined);
296
+ if (!effective) throw new Error(`repair record '${row.record_id}' remains ${row.status} and blocks publication`);
297
+ if (row.status === "needs-human" && history?.pass && row.repair_commit === options.head) tested = row.repair_commit;
298
+ }
299
+ return { tested, observed };
300
+ }