@bjoernrochel/sevro 0.1.0-rc.1

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 (71) hide show
  1. package/LICENSE +159 -0
  2. package/README.md +71 -0
  3. package/docs/advisory-fixture.md +23 -0
  4. package/docs/builtin-graders-v1.md +77 -0
  5. package/docs/cache-ownership-validation.md +49 -0
  6. package/docs/claude-continuation-validation.md +47 -0
  7. package/docs/codex-host.md +134 -0
  8. package/docs/concurrency-validation.md +86 -0
  9. package/docs/configuration-root-validation.md +70 -0
  10. package/docs/development-cli.md +327 -0
  11. package/docs/engine-slice.md +114 -0
  12. package/docs/extension-protocol-v1.md +292 -0
  13. package/docs/fixture-cleanup-validation.md +70 -0
  14. package/docs/identity-v1.md +65 -0
  15. package/docs/native-controls.md +29 -0
  16. package/docs/releases.md +88 -0
  17. package/docs/report-v1.md +32 -0
  18. package/docs/results-v1.md +118 -0
  19. package/examples/basic/graded.json +13 -0
  20. package/examples/basic/host.ts +8 -0
  21. package/examples/basic/prompt-only.json +7 -0
  22. package/package.json +45 -0
  23. package/schemas/cli-result-v1.schema.json +184 -0
  24. package/schemas/extension-v1.schema.json +659 -0
  25. package/schemas/report-v1.schema.json +164 -0
  26. package/schemas/run-evidence-v1.schema.json +510 -0
  27. package/src/advisory-fixture.ts +282 -0
  28. package/src/advisory.ts +105 -0
  29. package/src/cli.ts +837 -0
  30. package/src/engine.ts +2341 -0
  31. package/src/extension-client.ts +257 -0
  32. package/src/extension-session.ts +335 -0
  33. package/src/fixture-bin.ts +87 -0
  34. package/src/fixture-cleanup.ts +38 -0
  35. package/src/fixture-setup.ts +163 -0
  36. package/src/generated-fixture.ts +220 -0
  37. package/src/git-hooks.ts +63 -0
  38. package/src/graders/git-head.ts +176 -0
  39. package/src/graders/output.ts +359 -0
  40. package/src/graders/semantic.ts +120 -0
  41. package/src/graders/shell.ts +277 -0
  42. package/src/hosts/claude-continuation.ts +94 -0
  43. package/src/hosts/claude-credential.ts +73 -0
  44. package/src/hosts/claude-events.ts +128 -0
  45. package/src/hosts/claude-nested-skills.ts +218 -0
  46. package/src/hosts/claude-repository-invocation.ts +239 -0
  47. package/src/hosts/claude-settings.ts +50 -0
  48. package/src/hosts/claude-tool-calls.ts +159 -0
  49. package/src/hosts/claude.ts +511 -0
  50. package/src/hosts/codex-configuration.ts +85 -0
  51. package/src/hosts/codex-events.ts +102 -0
  52. package/src/hosts/codex-native-calls.ts +702 -0
  53. package/src/hosts/codex-profile.ts +78 -0
  54. package/src/hosts/codex-skill-reads.ts +345 -0
  55. package/src/hosts/codex.ts +769 -0
  56. package/src/hosts/isolation-roots.ts +60 -0
  57. package/src/hosts/mac-sandbox.ts +108 -0
  58. package/src/hosts/native-controls.ts +124 -0
  59. package/src/hosts/protected-worktrees.ts +56 -0
  60. package/src/hosts/workspace-fingerprint.ts +55 -0
  61. package/src/identity.ts +156 -0
  62. package/src/instrumentation.ts +106 -0
  63. package/src/preparation.ts +211 -0
  64. package/src/provenance.ts +244 -0
  65. package/src/report.ts +351 -0
  66. package/src/repository-fixture.ts +286 -0
  67. package/src/results.ts +160 -0
  68. package/src/run-owner.ts +276 -0
  69. package/src/schema.ts +28 -0
  70. package/src/storage.ts +23 -0
  71. package/src/trial-scheduler.ts +27 -0
package/src/report.ts ADDED
@@ -0,0 +1,351 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isAbsolute } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { canonicalJson } from "./identity";
5
+ import { assertCliResult, assertReport, assertRunEvidence } from "./schema";
6
+
7
+ type State = { status: string };
8
+ type Verdict = { verdict: string };
9
+ type DomainOutcome = { id: string; status: string };
10
+ type Trial = {
11
+ trial: number;
12
+ execution: State;
13
+ grading: State;
14
+ task: Verdict;
15
+ domainOutcomes?: DomainOutcome[];
16
+ };
17
+ type Case = {
18
+ caseId: string;
19
+ execution: State;
20
+ grading: State;
21
+ task: Verdict;
22
+ trials: Trial[];
23
+ };
24
+ type CliResult = {
25
+ format: string;
26
+ runId: string | null;
27
+ execution: State;
28
+ grading: State;
29
+ task: Verdict;
30
+ exitCode: number;
31
+ evidencePath: string | null;
32
+ cases: Case[];
33
+ };
34
+ type Usage = {
35
+ inputTokens: number | null;
36
+ outputTokens: number | null;
37
+ costUsd: number | null;
38
+ complete: boolean;
39
+ };
40
+ type TrialEvidence = {
41
+ caseId: string;
42
+ trial: number;
43
+ executionMode: string;
44
+ candidateDurationMs?: number | null;
45
+ usage: Usage;
46
+ };
47
+ type Evidence = {
48
+ runId: string;
49
+ result: CliResult;
50
+ runner: { buildDigest: string };
51
+ evaluationIdentity: { digest: string };
52
+ condition: { requested: string; actual: string };
53
+ routes: Array<{ role: string; host: string; model: string; effort: string }>;
54
+ trials: TrialEvidence[];
55
+ };
56
+
57
+ export type ReportRow = {
58
+ resultFile: string;
59
+ evidencePath: string | null;
60
+ runId: string | null;
61
+ caseId: string;
62
+ execution: string;
63
+ grading: string;
64
+ task: string;
65
+ exitCode: number;
66
+ trialCount: number;
67
+ taskPassRate: number | null;
68
+ candidateDurationMs: number | null;
69
+ inputTokens: number | null;
70
+ outputTokens: number | null;
71
+ costUsd: number | null;
72
+ runnerBuildDigest: string | null;
73
+ evaluationDigest: string | null;
74
+ requestedCondition: string | null;
75
+ actualCondition: string | null;
76
+ candidateRoute: { host: string; model: string; effort: string } | null;
77
+ routes: Array<{ role: string; host: string; model: string; effort: string }>;
78
+ domainOutcomes: Array<{ trial: number; id: string; status: string }>;
79
+ };
80
+
81
+ function sumKnown(values: Array<number | null | undefined>): number | null {
82
+ return values.length && values.every((value) => value != null)
83
+ ? values.reduce<number>((total, value) => total + value!, 0)
84
+ : null;
85
+ }
86
+
87
+ function passRate(trials: Trial[]): number | null {
88
+ if (
89
+ !trials.length ||
90
+ trials.some(
91
+ (trial) =>
92
+ trial.execution.status !== "completed" ||
93
+ trial.grading.status !== "completed" ||
94
+ trial.task.verdict === "not_assessed",
95
+ )
96
+ )
97
+ return null;
98
+ return (
99
+ trials.filter((trial) => trial.task.verdict === "passed").length /
100
+ trials.length
101
+ );
102
+ }
103
+
104
+ async function loadResult(path: string) {
105
+ if (!isAbsolute(path))
106
+ throw new Error("report result paths must be absolute");
107
+ const result = JSON.parse(await readFile(path, "utf8")) as unknown;
108
+ assertCliResult(result);
109
+ return result as CliResult;
110
+ }
111
+
112
+ async function loadEvidence(result: CliResult): Promise<Evidence | null> {
113
+ if (!result.evidencePath) {
114
+ if (result.cases.length || result.exitCode === 0)
115
+ throw new Error("completed result has no retained evidence");
116
+ return null;
117
+ }
118
+ if (!isAbsolute(result.evidencePath))
119
+ throw new Error("evidence path must be absolute");
120
+ const value = JSON.parse(
121
+ await readFile(result.evidencePath, "utf8"),
122
+ ) as unknown;
123
+ assertRunEvidence(value);
124
+ const evidence = value as Evidence;
125
+ if (
126
+ evidence.runId !== result.runId ||
127
+ canonicalJson(evidence.result) !== canonicalJson(result)
128
+ )
129
+ throw new Error("retained evidence differs from report result");
130
+ return evidence;
131
+ }
132
+
133
+ function trialEvidence(
134
+ caseId: string,
135
+ trials: Trial[],
136
+ evidence: Evidence | null,
137
+ ) {
138
+ if (!evidence) return [];
139
+ const matching = evidence.trials.filter((item) => item.caseId === caseId);
140
+ if (
141
+ matching.length !== trials.length ||
142
+ trials.some(
143
+ (trial) =>
144
+ matching.filter((item) => item.trial === trial.trial).length !== 1,
145
+ )
146
+ )
147
+ throw new Error("retained trial evidence differs from report result");
148
+ return matching;
149
+ }
150
+
151
+ function reportRow(
152
+ path: string,
153
+ result: CliResult,
154
+ selected: Case,
155
+ evidence: Evidence | null,
156
+ ): ReportRow {
157
+ const retained = trialEvidence(selected.caseId, selected.trials, evidence);
158
+ const candidateRoutes =
159
+ evidence?.routes.filter((route) => route.role === "candidate") ?? [];
160
+ if (evidence && candidateRoutes.length !== 1)
161
+ throw new Error("retained candidate route is ambiguous");
162
+ const completeUsage =
163
+ retained.length > 0 && retained.every((trial) => trial.usage.complete);
164
+ const route = candidateRoutes[0];
165
+ return {
166
+ resultFile: path,
167
+ evidencePath: result.evidencePath,
168
+ runId: result.runId,
169
+ caseId: selected.caseId,
170
+ execution: selected.execution.status,
171
+ grading: selected.grading.status,
172
+ task: selected.task.verdict,
173
+ exitCode: result.exitCode,
174
+ trialCount: selected.trials.length,
175
+ taskPassRate: passRate(selected.trials),
176
+ candidateDurationMs: sumKnown(
177
+ retained.map((trial) => trial.candidateDurationMs),
178
+ ),
179
+ inputTokens: completeUsage
180
+ ? sumKnown(retained.map((trial) => trial.usage.inputTokens))
181
+ : null,
182
+ outputTokens: completeUsage
183
+ ? sumKnown(retained.map((trial) => trial.usage.outputTokens))
184
+ : null,
185
+ costUsd: completeUsage
186
+ ? sumKnown(retained.map((trial) => trial.usage.costUsd))
187
+ : null,
188
+ runnerBuildDigest: evidence?.runner.buildDigest ?? null,
189
+ evaluationDigest: evidence?.evaluationIdentity.digest ?? null,
190
+ requestedCondition: evidence?.condition.requested ?? null,
191
+ actualCondition: evidence?.condition.actual ?? null,
192
+ candidateRoute: route
193
+ ? { host: route.host, model: route.model, effort: route.effort }
194
+ : null,
195
+ routes: evidence?.routes ?? [],
196
+ domainOutcomes: selected.trials.flatMap((trial) =>
197
+ (trial.domainOutcomes ?? []).map((outcome) => ({
198
+ trial: trial.trial,
199
+ id: outcome.id,
200
+ status: outcome.status,
201
+ })),
202
+ ),
203
+ };
204
+ }
205
+
206
+ export async function createReport(paths: string[]) {
207
+ if (!paths.length || new Set(paths).size !== paths.length)
208
+ throw new Error("report needs distinct result files");
209
+ const rows: ReportRow[] = [];
210
+ const inputs: Array<{
211
+ resultFile: string;
212
+ execution: string;
213
+ grading: string;
214
+ task: string;
215
+ exitCode: number;
216
+ caseCount: number;
217
+ }> = [];
218
+ for (const path of paths) {
219
+ const result = await loadResult(path);
220
+ const evidence = await loadEvidence(result);
221
+ inputs.push({
222
+ resultFile: path,
223
+ execution: result.execution.status,
224
+ grading: result.grading.status,
225
+ task: result.task.verdict,
226
+ exitCode: result.exitCode,
227
+ caseCount: result.cases.length,
228
+ });
229
+ for (const selected of result.cases)
230
+ rows.push(reportRow(path, result, selected, evidence));
231
+ }
232
+ const report = {
233
+ format: "sevro.report.v1",
234
+ inputs,
235
+ rows,
236
+ summary: {
237
+ cases: rows.length,
238
+ passed: rows.filter((row) => row.task === "passed").length,
239
+ failed: rows.filter((row) => row.task === "failed").length,
240
+ notAssessed: rows.filter((row) => row.task === "not_assessed").length,
241
+ },
242
+ };
243
+ assertReport(report);
244
+ return report;
245
+ }
246
+
247
+ function cell(value: string): string {
248
+ return value.replaceAll("|", "\\|").replaceAll(/[\r\n]/g, " ");
249
+ }
250
+
251
+ function measured(value: number | null, digits = 0): string {
252
+ return value === null ? "unknown" : value.toFixed(digits);
253
+ }
254
+
255
+ function seconds(value: number | null): string {
256
+ return value === null ? "unknown" : `${(value / 1000).toFixed(2)}s`;
257
+ }
258
+
259
+ export function renderReport(
260
+ report: Awaited<ReturnType<typeof createReport>>,
261
+ ): string {
262
+ const lines = [
263
+ "# Sevro evaluation report",
264
+ "",
265
+ "Task verdict, execution, and grading are separate. Unknown measurements are not zero. Rows do not claim matched comparisons.",
266
+ "",
267
+ `Cases: ${report.summary.cases}; passed: ${report.summary.passed}; failed: ${report.summary.failed}; not assessed: ${report.summary.notAssessed}.`,
268
+ "",
269
+ "## Input runs",
270
+ "",
271
+ "| Result file | Exit | Execution | Grading | Task | Cases |",
272
+ "| --- | ---: | --- | --- | --- | ---: |",
273
+ ...report.inputs.map(
274
+ (input) =>
275
+ `| ${cell(input.resultFile)} | ${input.exitCode} | ${input.execution} | ${input.grading} | ${input.task} | ${input.caseCount} |`,
276
+ ),
277
+ "",
278
+ "## Case outcomes",
279
+ "",
280
+ "| Case | Task | Execution | Grading | Pass rate | Candidate time | Candidate tokens | Candidate cost | Condition | Candidate route |",
281
+ "| --- | --- | --- | --- | ---: | ---: | ---: | ---: | --- | --- |",
282
+ ...report.rows.map(
283
+ (row) =>
284
+ `| ${cell(row.caseId)} | ${row.task} | ${row.execution} | ${row.grading} | ${row.taskPassRate === null ? "unknown" : `${(row.taskPassRate * 100).toFixed(0)}%`} | ${seconds(row.candidateDurationMs)} | ${measured(row.inputTokens)} / ${measured(row.outputTokens)} | ${row.costUsd === null ? "unknown" : `$${row.costUsd.toFixed(4)}`} | ${row.requestedCondition ?? "unknown"} / ${row.actualCondition ?? "unknown"} | ${row.candidateRoute ? cell(`${row.candidateRoute.host}/${row.candidateRoute.model}@${row.candidateRoute.effort}`) : "unknown"} |`,
285
+ ),
286
+ "",
287
+ "## Retained evidence",
288
+ "",
289
+ ...report.rows.map(
290
+ (row) => `- ${cell(row.caseId)}: ${row.evidencePath ?? "unavailable"}`,
291
+ ),
292
+ "",
293
+ ];
294
+ const outcomes = report.rows.flatMap((row) =>
295
+ row.domainOutcomes.map((outcome) => ({ caseId: row.caseId, ...outcome })),
296
+ );
297
+ if (outcomes.length)
298
+ lines.push(
299
+ "## Domain outcomes",
300
+ "",
301
+ "Domain outcomes are independent of the task verdict.",
302
+ "",
303
+ "| Case | Trial | Outcome | Status |",
304
+ "| --- | ---: | --- | --- |",
305
+ ...outcomes.map(
306
+ (outcome) =>
307
+ `| ${cell(outcome.caseId)} | ${outcome.trial} | ${cell(outcome.id)} | ${outcome.status} |`,
308
+ ),
309
+ "",
310
+ );
311
+ lines.push(
312
+ "## Routes",
313
+ "",
314
+ "| Case | Role | Host | Model | Effort |",
315
+ "| --- | --- | --- | --- | --- |",
316
+ ...report.rows.flatMap((row) =>
317
+ row.routes.map(
318
+ (route) =>
319
+ `| ${cell(row.caseId)} | ${route.role} | ${cell(route.host)} | ${cell(route.model)} | ${cell(route.effort)} |`,
320
+ ),
321
+ ),
322
+ "",
323
+ );
324
+ return lines.join("\n");
325
+ }
326
+
327
+ export async function reportCommand(argv: string[]): Promise<number> {
328
+ try {
329
+ const { values, positionals } = parseArgs({
330
+ args: argv,
331
+ allowPositionals: true,
332
+ strict: true,
333
+ options: {
334
+ json: { type: "boolean" },
335
+ "result-file": { type: "string", multiple: true },
336
+ },
337
+ });
338
+ if (positionals.length !== 1 || positionals[0] !== "report")
339
+ throw new Error("expected the report command");
340
+ const report = await createReport(values["result-file"] ?? []);
341
+ process.stdout.write(
342
+ values.json ? `${JSON.stringify(report)}\n` : renderReport(report),
343
+ );
344
+ return 0;
345
+ } catch (error) {
346
+ process.stderr.write(
347
+ `${error instanceof Error ? error.message : "invalid report input"}\n`,
348
+ );
349
+ return 64;
350
+ }
351
+ }
@@ -0,0 +1,286 @@
1
+ import { lstat, mkdir, realpath, stat, writeFile } from "node:fs/promises";
2
+ import { isAbsolute, join, relative, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { canonicalJson } from "./identity";
5
+ import { prepareFixtureBin } from "./fixture-bin";
6
+ import { prepareGitHooks } from "./git-hooks";
7
+ import type { PreparationSources } from "./preparation";
8
+ import { fixtureParts } from "./preparation";
9
+
10
+ const MAX_GIT_OUTPUT_BYTES = 64 * 1024;
11
+ const MAX_OVERLAY_FILES = 1024;
12
+ const MAX_OVERLAY_BYTES = 32 * 1024 * 1024;
13
+
14
+ export interface RepositoryFixture {
15
+ kind: "repository";
16
+ sourceRef: string;
17
+ files?: Record<string, string>;
18
+ staged?: string[];
19
+ commitFiles?: boolean;
20
+ hooks?: Record<string, string>;
21
+ bin?: Record<string, string>;
22
+ }
23
+
24
+ /** Validate repository source identity and bounded working-tree changes. */
25
+ export function prepareRepositoryFixture(value: unknown): RepositoryFixture {
26
+ if (!value || typeof value !== "object" || Array.isArray(value))
27
+ throw new Error("invalid repository fixture");
28
+ const fixture = value as Record<string, unknown>;
29
+ if (
30
+ fixture.kind !== "repository" ||
31
+ Object.keys(fixture).some(
32
+ (key) =>
33
+ ![
34
+ "kind",
35
+ "sourceRef",
36
+ "files",
37
+ "staged",
38
+ "commitFiles",
39
+ "hooks",
40
+ "bin",
41
+ ].includes(key),
42
+ ) ||
43
+ typeof fixture.sourceRef !== "string" ||
44
+ !fixture.sourceRef ||
45
+ (fixture.files !== undefined &&
46
+ (!fixture.files ||
47
+ typeof fixture.files !== "object" ||
48
+ Array.isArray(fixture.files) ||
49
+ Object.values(fixture.files).some(
50
+ (item) => typeof item !== "string",
51
+ ))) ||
52
+ (fixture.staged !== undefined &&
53
+ (!Array.isArray(fixture.staged) ||
54
+ fixture.staged.some((item) => typeof item !== "string"))) ||
55
+ (fixture.commitFiles !== undefined &&
56
+ typeof fixture.commitFiles !== "boolean")
57
+ )
58
+ throw new Error("invalid repository fixture");
59
+ prepareGitHooks(fixture.hooks);
60
+ prepareFixtureBin(fixture.bin);
61
+ const files = (fixture.files ?? {}) as Record<string, string>;
62
+ const staged = (fixture.staged ?? []) as string[];
63
+ const paths = Object.keys(files);
64
+ if (paths.length > MAX_OVERLAY_FILES)
65
+ throw new Error("repository fixture exceeds the file limit");
66
+ let bytes = 0;
67
+ for (const [path, content] of Object.entries(files)) {
68
+ const parts = fixtureParts(path);
69
+ if (parts.some((part) => part.toLowerCase() === ".git"))
70
+ throw new Error("repository fixture cannot write repository metadata");
71
+ bytes +=
72
+ Buffer.byteLength(path, "utf8") + Buffer.byteLength(content, "utf8");
73
+ }
74
+ bytes += staged.reduce(
75
+ (size, path) => size + Buffer.byteLength(path, "utf8"),
76
+ 0,
77
+ );
78
+ if (bytes > MAX_OVERLAY_BYTES)
79
+ throw new Error("repository fixture exceeds the size limit");
80
+ const normalized = paths.map((path) => path.toLowerCase());
81
+ for (let index = 0; index < normalized.length; index++) {
82
+ for (let other = 0; other < index; other++) {
83
+ if (
84
+ normalized[index] === normalized[other] ||
85
+ normalized[index]!.startsWith(`${normalized[other]}/`) ||
86
+ normalized[other]!.startsWith(`${normalized[index]}/`)
87
+ )
88
+ throw new Error("repository fixture overlay paths collide");
89
+ }
90
+ }
91
+ if (
92
+ new Set(staged.map((path) => path.toLowerCase())).size !== staged.length ||
93
+ staged.some((path) => !Object.hasOwn(files, path)) ||
94
+ (fixture.commitFiles === true && paths.length === 0)
95
+ )
96
+ throw new Error("invalid repository fixture staging");
97
+ return JSON.parse(canonicalJson(fixture)) as RepositoryFixture;
98
+ }
99
+
100
+ export interface RepositorySource {
101
+ path: string;
102
+ revision: string;
103
+ }
104
+
105
+ async function boundedText(
106
+ stream: ReadableStream<Uint8Array>,
107
+ ): Promise<string> {
108
+ const chunks: Uint8Array[] = [];
109
+ let size = 0;
110
+ for await (const chunk of stream) {
111
+ size += chunk.byteLength;
112
+ if (size > MAX_GIT_OUTPUT_BYTES)
113
+ throw new Error("repository fixture Git output is too large");
114
+ chunks.push(chunk);
115
+ }
116
+ return new TextDecoder("utf-8", { fatal: true }).decode(
117
+ Buffer.concat(chunks),
118
+ );
119
+ }
120
+
121
+ async function git(args: string[], cwd: string): Promise<string> {
122
+ const proc = Bun.spawn(["git", ...args], {
123
+ cwd,
124
+ stdout: "pipe",
125
+ stderr: "pipe",
126
+ env: {
127
+ ...process.env,
128
+ GIT_CONFIG_NOSYSTEM: "1",
129
+ GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
130
+ GIT_TERMINAL_PROMPT: "0",
131
+ GIT_AUTHOR_NAME: "Sevro Fixture",
132
+ GIT_AUTHOR_EMAIL: "fixture@sevro.invalid",
133
+ GIT_COMMITTER_NAME: "Sevro Fixture",
134
+ GIT_COMMITTER_EMAIL: "fixture@sevro.invalid",
135
+ GIT_AUTHOR_DATE: "2000-01-01T00:00:00+00:00",
136
+ GIT_COMMITTER_DATE: "2000-01-01T00:00:00+00:00",
137
+ },
138
+ });
139
+ try {
140
+ const [stdout, _stderr, code] = await Promise.all([
141
+ boundedText(proc.stdout),
142
+ boundedText(proc.stderr),
143
+ proc.exited,
144
+ ]);
145
+ if (code !== 0) throw new Error("repository fixture Git operation failed");
146
+ return stdout.trim();
147
+ } catch (error) {
148
+ proc.kill("SIGKILL");
149
+ await proc.exited;
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ /** Resolve a declared local repository without accepting a dirty source tree. */
155
+ export async function resolveRepositorySource(
156
+ sourceRef: string,
157
+ sources?: PreparationSources,
158
+ ): Promise<RepositorySource> {
159
+ if (!sources || !isAbsolute(sources.root))
160
+ throw new Error("repository source root is required and must be absolute");
161
+ if (!Object.hasOwn(sources.refs, sourceRef))
162
+ throw new Error("repository source reference is not declared");
163
+ const url = sources.refs[sourceRef];
164
+ if (typeof url !== "string" || !url.startsWith("file:///"))
165
+ throw new Error("repository source reference must be a file URL");
166
+ let root: string;
167
+ let path: string;
168
+ try {
169
+ [root, path] = await Promise.all([
170
+ realpath(sources.root),
171
+ realpath(fileURLToPath(url)),
172
+ ]);
173
+ if (!(await stat(path)).isDirectory()) throw new Error();
174
+ } catch {
175
+ throw new Error("repository source is unreadable");
176
+ }
177
+ const child = relative(root, path);
178
+ if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child))
179
+ throw new Error("repository source escapes its declared root");
180
+ const top = await git(["rev-parse", "--show-toplevel"], path);
181
+ if ((await realpath(top)) !== path)
182
+ throw new Error("repository source must name the repository root");
183
+ const revision = await git(["rev-parse", "--verify", "HEAD^{commit}"], path);
184
+ if (!/^[a-f0-9]{40,64}$/.test(revision))
185
+ throw new Error("repository source has no valid commit");
186
+ if (await git(["status", "--porcelain=v1", "--untracked-files=all"], path))
187
+ throw new Error("repository source has uncommitted changes");
188
+ if (await git(["submodule", "status"], path))
189
+ throw new Error("repository submodules are not supported");
190
+ return { path, revision };
191
+ }
192
+
193
+ /** Clone a fixed commit without hardlinks or a remote back to the source. */
194
+ export async function cloneRepositorySource(
195
+ sourceRef: string,
196
+ sources: PreparationSources,
197
+ expected: RepositorySource,
198
+ workspace: string,
199
+ ): Promise<void> {
200
+ const current = await resolveRepositorySource(sourceRef, sources);
201
+ if (current.path !== expected.path || current.revision !== expected.revision)
202
+ throw new Error("repository source changed during run");
203
+ await git(
204
+ [
205
+ "clone",
206
+ "--local",
207
+ "--no-hardlinks",
208
+ "--no-checkout",
209
+ "--quiet",
210
+ current.path,
211
+ workspace,
212
+ ],
213
+ workspace,
214
+ );
215
+ await git(["checkout", "--detach", "--quiet", expected.revision], workspace);
216
+ if ((await git(["rev-parse", "HEAD"], workspace)) !== expected.revision)
217
+ throw new Error("repository fixture revision changed during clone");
218
+ for (const remote of (await git(["remote"], workspace))
219
+ .split("\n")
220
+ .filter(Boolean))
221
+ await git(["remote", "remove", remote], workspace);
222
+ const after = await resolveRepositorySource(sourceRef, sources);
223
+ if (after.path !== expected.path || after.revision !== expected.revision)
224
+ throw new Error("repository source changed during clone");
225
+ }
226
+
227
+ async function safeOverlayTarget(
228
+ workspace: string,
229
+ path: string,
230
+ ): Promise<string> {
231
+ const parts = fixtureParts(path);
232
+ let parent = workspace;
233
+ for (const part of parts.slice(0, -1)) {
234
+ parent = join(parent, part);
235
+ try {
236
+ const entry = await lstat(parent);
237
+ if (!entry.isDirectory() || entry.isSymbolicLink())
238
+ throw new Error("repository fixture overlay traverses a non-directory");
239
+ } catch (error) {
240
+ if (!(
241
+ error instanceof Error &&
242
+ "code" in error &&
243
+ error.code === "ENOENT"
244
+ ))
245
+ throw error;
246
+ await mkdir(parent, { mode: 0o700 });
247
+ }
248
+ }
249
+ const target = join(workspace, ...parts);
250
+ try {
251
+ const entry = await lstat(target);
252
+ if (!entry.isFile() || entry.isSymbolicLink())
253
+ throw new Error("repository fixture overlay targets a non-file");
254
+ } catch (error) {
255
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
256
+ throw error;
257
+ }
258
+ return target;
259
+ }
260
+
261
+ /** Apply declared files after cloning, optionally committing or staging them. */
262
+ export async function applyRepositoryOverlay(
263
+ fixture: RepositoryFixture,
264
+ workspace: string,
265
+ ): Promise<void> {
266
+ const files = fixture.files ?? {};
267
+ for (const [path, content] of Object.entries(files))
268
+ await writeFile(await safeOverlayTarget(workspace, path), content, {
269
+ flag: "w",
270
+ mode: 0o600,
271
+ });
272
+ const paths = Object.keys(files);
273
+ const noHooks = [
274
+ "-c",
275
+ `core.hooksPath=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
276
+ ];
277
+ if (fixture.commitFiles) {
278
+ await git([...noHooks, "add", "--", ...paths], workspace);
279
+ await git(
280
+ [...noHooks, "commit", "--quiet", "-m", "Add evaluation scaffolding"],
281
+ workspace,
282
+ );
283
+ }
284
+ if (fixture.staged?.length)
285
+ await git([...noHooks, "add", "--", ...fixture.staged], workspace);
286
+ }