@bjoernrochel/sevro 0.1.0-rc.2 → 0.1.0-rc.21

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 (243) hide show
  1. package/CONTRIBUTING.md +170 -0
  2. package/LICENSE +63 -136
  3. package/README.md +83 -66
  4. package/docs/README.md +44 -0
  5. package/docs/acknowledgements.md +14 -0
  6. package/docs/architecture.md +146 -0
  7. package/docs/assets/README.md +27 -0
  8. package/docs/assets/sevro-logo.png +0 -0
  9. package/docs/assets/sevro-symbol.svg +1 -0
  10. package/docs/assets/sevro-wordmark.svg +1 -0
  11. package/docs/builtin-graders-v1.md +41 -13
  12. package/docs/codex-host.md +184 -52
  13. package/docs/contracts.md +19 -0
  14. package/docs/creating-cases.md +139 -0
  15. package/docs/development-cli.md +190 -30
  16. package/docs/documentation-quality.md +61 -0
  17. package/docs/evaluation-workflow.md +110 -0
  18. package/docs/evidence/code-mode-controls-71.md +64 -0
  19. package/docs/evidence/code-mode-programs-85.md +64 -0
  20. package/docs/evidence/codex-state-73/README.md +204 -0
  21. package/docs/evidence/codex-state-73/darrow-migration.patch +182 -0
  22. package/docs/evidence/codex-state-73/results.json +661 -0
  23. package/docs/evidence/local-http-74.md +104 -0
  24. package/docs/evidence/runtime-startup-30/README.md +185 -0
  25. package/docs/evidence/runtime-startup-30/after.json +319 -0
  26. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +2 -0
  27. package/docs/evidence/runtime-startup-30/before.json +403 -0
  28. package/docs/evidence/runtime-startup-30/consumer-smoke.json +63 -0
  29. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +3 -0
  30. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +3 -0
  31. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +20 -0
  32. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +2 -0
  33. package/docs/evidence/skill-reads-70/results.json +823 -0
  34. package/docs/evidence.md +21 -0
  35. package/docs/extension-protocol-v1.md +50 -16
  36. package/docs/fixture-cleanup-validation.md +3 -0
  37. package/docs/getting-started.md +102 -0
  38. package/docs/guide-evaluation.md +89 -0
  39. package/docs/guide-verification.json +239 -0
  40. package/docs/identity-v1.md +35 -1
  41. package/docs/installing.md +88 -0
  42. package/docs/licenses/BUSL-1.1-0.1.0-rc.2.txt +159 -0
  43. package/docs/licensing.md +42 -0
  44. package/docs/native-controls.md +76 -5
  45. package/docs/native-hosts.md +228 -0
  46. package/docs/reading-results.md +141 -0
  47. package/docs/recipes.md +489 -0
  48. package/docs/releases.md +81 -40
  49. package/docs/report-v1.md +29 -1
  50. package/docs/results-v1.md +180 -0
  51. package/docs/run-settings-v1.md +147 -0
  52. package/docs/running-evaluations.md +31 -0
  53. package/docs/runtime-v1.md +614 -0
  54. package/docs/specs/repository-guide.md +58 -0
  55. package/docs/troubleshooting.md +30 -0
  56. package/docs/typescript-quality.md +234 -0
  57. package/docs/usability-study.md +114 -0
  58. package/examples/basic/host.ts +2 -2
  59. package/examples/recipes/candidate.ts +26 -0
  60. package/examples/recipes/cases/coding-change.json +21 -0
  61. package/examples/recipes/cases/host-tools.json +21 -0
  62. package/examples/recipes/cases/mise.json +21 -0
  63. package/examples/recipes/cases/node.json +21 -0
  64. package/examples/recipes/cases/python-uv.json +21 -0
  65. package/examples/recipes/cases/semantic.json +17 -0
  66. package/examples/recipes/coding-change/check.ts +23 -0
  67. package/examples/recipes/extension-artifacts.ts +65 -0
  68. package/examples/recipes/extension-methods.ts +174 -0
  69. package/examples/recipes/extension.ts +37 -0
  70. package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
  71. package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
  72. package/examples/recipes/repository-skill/README.md +3 -0
  73. package/examples/recipes/run-inputs.ts +188 -0
  74. package/examples/recipes/run-options.ts +131 -0
  75. package/examples/recipes/run.ts +63 -0
  76. package/examples/recipes/runtime/empty.json +1 -0
  77. package/examples/recipes/runtime/goals.json +1 -0
  78. package/examples/recipes/runtime/mise.json +6 -0
  79. package/examples/recipes/runtime/path.json +1 -0
  80. package/examples/recipes/runtime/plugins.json +4 -0
  81. package/examples/recipes/runtime/python-uv.json +10 -0
  82. package/examples/recipes/semantic-judge.ts +29 -0
  83. package/examples/templates/coding/adapter.ts +17 -0
  84. package/examples/templates/coding/case.json +15 -0
  85. package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
  86. package/examples/templates/repository-skill/extension-data.ts +64 -0
  87. package/examples/templates/repository-skill/extension.ts +31 -0
  88. package/examples/templates/response/adapter.ts +12 -0
  89. package/examples/templates/response/case.json +13 -0
  90. package/package.json +39 -6
  91. package/schemas/report-v1.schema.json +9 -0
  92. package/schemas/run-evidence-v1.schema.json +2 -1
  93. package/schemas/runtime-v1.schema.json +193 -0
  94. package/src/advisory.ts +10 -5
  95. package/src/candidate-artifacts.ts +208 -0
  96. package/src/candidate-codex-state.ts +185 -0
  97. package/src/case-validation.ts +105 -0
  98. package/src/cli/cancellation.ts +20 -0
  99. package/src/cli/command.ts +81 -0
  100. package/src/cli/completion.ts +15 -0
  101. package/src/cli/demo.ts +59 -0
  102. package/src/cli/evaluation.ts +55 -0
  103. package/src/cli/extension.ts +79 -0
  104. package/src/cli/help.ts +222 -0
  105. package/src/cli/hosts.ts +59 -0
  106. package/src/cli/init-settings.ts +30 -0
  107. package/src/cli/init.ts +111 -0
  108. package/src/cli/invocation.ts +627 -0
  109. package/src/cli/loading.ts +151 -0
  110. package/src/cli/preparation.ts +163 -0
  111. package/src/cli/presentation.ts +91 -0
  112. package/src/cli/progress.ts +6 -0
  113. package/src/cli/run-manifest.ts +133 -0
  114. package/src/cli/run-options.ts +101 -0
  115. package/src/cli/run-overrides.ts +83 -0
  116. package/src/cli/settings.ts +160 -0
  117. package/src/cli/summary.ts +43 -0
  118. package/src/cli.ts +13 -843
  119. package/src/contract-types.ts +94 -0
  120. package/src/engine.ts +19 -2373
  121. package/src/evaluation/advisory.ts +150 -0
  122. package/src/evaluation/check-plan.ts +510 -0
  123. package/src/evaluation/collection.ts +88 -0
  124. package/src/evaluation/context.ts +10 -0
  125. package/src/evaluation/grading-host.ts +39 -0
  126. package/src/evaluation/grading.ts +258 -0
  127. package/src/evaluation/invocation-plan.ts +548 -0
  128. package/src/evaluation/plan.ts +736 -0
  129. package/src/evaluation/progress.ts +19 -0
  130. package/src/evaluation/resources.ts +241 -0
  131. package/src/evaluation/run.ts +206 -0
  132. package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +3 -1
  133. package/src/evaluation/semantic.ts +312 -0
  134. package/src/evaluation/trial.ts +866 -0
  135. package/src/evaluation/types.ts +207 -0
  136. package/src/evidence/host-results.ts +92 -0
  137. package/src/evidence/retained-artifacts.ts +18 -0
  138. package/src/evidence/trial-writer.ts +203 -0
  139. package/src/evidence/usage.ts +17 -0
  140. package/src/extension-client.ts +228 -148
  141. package/src/extension-session.ts +326 -195
  142. package/src/fixtures/advisory.ts +338 -0
  143. package/src/fixtures/bin.ts +57 -0
  144. package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
  145. package/src/fixtures/create.ts +187 -0
  146. package/src/fixtures/generated.ts +300 -0
  147. package/src/fixtures/git-hooks.ts +24 -0
  148. package/src/fixtures/preparation.ts +270 -0
  149. package/src/{repository-fixture.ts → fixtures/repository.ts} +207 -85
  150. package/src/fixtures/setup.ts +224 -0
  151. package/src/fixtures/tools.ts +88 -0
  152. package/src/fixtures/workspace-state.ts +212 -0
  153. package/src/generated/cli-result.cjs +2 -0
  154. package/src/generated/cli-result.d.cts +3 -0
  155. package/src/generated/extension.cjs +2 -0
  156. package/src/generated/extension.d.cts +3 -0
  157. package/src/generated/report.cjs +2 -0
  158. package/src/generated/report.d.cts +3 -0
  159. package/src/generated/run-evidence.cjs +2 -0
  160. package/src/generated/run-evidence.d.cts +3 -0
  161. package/src/generated/runtime.cjs +2 -0
  162. package/src/generated/runtime.d.cts +3 -0
  163. package/src/graders/artifact.ts +56 -23
  164. package/src/graders/git-head.ts +79 -75
  165. package/src/graders/output.ts +222 -140
  166. package/src/graders/semantic.ts +102 -43
  167. package/src/graders/shell.ts +379 -207
  168. package/src/host-diagnostic-redaction.ts +52 -0
  169. package/src/host-diagnostics.ts +319 -0
  170. package/src/hosts/claude-continuation.ts +63 -18
  171. package/src/hosts/claude-credential.ts +43 -62
  172. package/src/hosts/claude-events.ts +86 -51
  173. package/src/hosts/claude-keychain.ts +59 -0
  174. package/src/hosts/claude-native-goal.ts +57 -33
  175. package/src/hosts/claude-process.ts +111 -0
  176. package/src/hosts/claude-repository-invocation.ts +208 -117
  177. package/src/hosts/claude-settings.ts +240 -21
  178. package/src/hosts/claude-skill-activations.ts +166 -0
  179. package/src/hosts/claude-state.ts +403 -0
  180. package/src/hosts/claude-tool-calls.ts +171 -106
  181. package/src/hosts/claude.ts +561 -451
  182. package/src/hosts/codex-app-server.ts +326 -178
  183. package/src/hosts/codex-configuration.ts +44 -18
  184. package/src/hosts/codex-events.ts +97 -60
  185. package/src/hosts/codex-exec-controls.ts +516 -0
  186. package/src/hosts/codex-invocation.ts +70 -0
  187. package/src/hosts/codex-marketplace.ts +283 -0
  188. package/src/hosts/codex-native-calls.ts +546 -500
  189. package/src/hosts/codex-process.ts +202 -0
  190. package/src/hosts/codex-profile.ts +257 -28
  191. package/src/hosts/codex-session-files.ts +106 -0
  192. package/src/hosts/codex-state.ts +492 -0
  193. package/src/hosts/codex.ts +783 -832
  194. package/src/hosts/isolation-common.ts +94 -0
  195. package/src/hosts/isolation-roots.ts +54 -27
  196. package/src/hosts/isolation.ts +10 -0
  197. package/src/hosts/linux-sandbox.ts +159 -0
  198. package/src/hosts/mac-sandbox.ts +187 -61
  199. package/src/hosts/native-controls.ts +152 -62
  200. package/src/hosts/process-lifecycle.ts +37 -0
  201. package/src/hosts/protected-worktrees.ts +51 -19
  202. package/src/hosts/skill-hook-catalog.ts +131 -0
  203. package/src/hosts/skill-hook-handler.ts +374 -0
  204. package/src/hosts/skill-hooks.ts +580 -0
  205. package/src/hosts/workspace-fingerprint.ts +55 -35
  206. package/src/identity.ts +102 -48
  207. package/src/instrumentation.ts +109 -48
  208. package/src/native-transcript-state.ts +131 -0
  209. package/src/native-transcript-view.ts +90 -0
  210. package/src/native-transcripts.ts +286 -0
  211. package/src/process-lifecycle.ts +150 -0
  212. package/src/provenance.ts +161 -81
  213. package/src/prune.ts +215 -0
  214. package/src/report.ts +172 -140
  215. package/src/resolved-case.ts +33 -0
  216. package/src/results-lock.ts +29 -0
  217. package/src/results.ts +133 -86
  218. package/src/run-owner.ts +154 -63
  219. package/src/runtime/config.ts +456 -0
  220. package/src/runtime/discovery-apple.ts +90 -0
  221. package/src/runtime/discovery-providers.ts +63 -0
  222. package/src/runtime/discovery.ts +70 -0
  223. package/src/runtime/hook-processes.ts +97 -0
  224. package/src/runtime/hooks.ts +476 -0
  225. package/src/runtime/paths.ts +313 -0
  226. package/src/runtime/query.ts +85 -0
  227. package/src/runtime/seeds.ts +134 -0
  228. package/src/runtime/shared-cache.ts +143 -0
  229. package/src/runtime/state.ts +143 -0
  230. package/src/schema-types.ts +198 -0
  231. package/src/schema.ts +15 -19
  232. package/src/transcript-retention.ts +146 -0
  233. package/src/value-guards.ts +20 -0
  234. package/src/advisory-fixture.ts +0 -282
  235. package/src/fixture-bin.ts +0 -87
  236. package/src/fixture-setup.ts +0 -163
  237. package/src/generated-fixture.ts +0 -220
  238. package/src/git-hooks.ts +0 -63
  239. package/src/hosts/claude-nested-skills.ts +0 -218
  240. package/src/hosts/codex-command-output.ts +0 -399
  241. package/src/hosts/codex-executor-command.ts +0 -195
  242. package/src/hosts/codex-skill-reads.ts +0 -391
  243. package/src/preparation.ts +0 -211
@@ -0,0 +1,300 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { canonicalJson } from "../identity";
4
+ import { prepareFixtureBin } from "./bin";
5
+ import { prepareGitHooks } from "./git-hooks";
6
+ import { fixtureParts } from "./preparation";
7
+
8
+ const MAX_COMMITS = 128;
9
+ const MAX_FILES = 1024;
10
+ const MAX_BYTES = 32 * 1024 * 1024;
11
+ const MAX_GIT_OUTPUT = 64 * 1024;
12
+
13
+ export interface GeneratedFixture {
14
+ kind: "generated";
15
+ commits: { message: string; files: Record<string, string> }[];
16
+ files?: Record<string, string>;
17
+ staged?: string[];
18
+ commitFiles?: boolean;
19
+ hooks?: Record<string, string>;
20
+ bin?: Record<string, string>;
21
+ }
22
+
23
+ function validFiles(value: unknown): value is Record<string, string> {
24
+ return (
25
+ Boolean(value && typeof value === "object" && !Array.isArray(value)) &&
26
+ Object.values(value as object).every((item) => typeof item === "string")
27
+ );
28
+ }
29
+
30
+ function validPath(path: string): void {
31
+ const parts = fixtureParts(path);
32
+ if (parts.some((part) => part.toLowerCase() === ".git"))
33
+ throw new Error("generated fixture cannot write repository metadata");
34
+ }
35
+
36
+ interface FixtureContents {
37
+ bytes: number;
38
+ files: number;
39
+ paths: string[];
40
+ }
41
+
42
+ function fixtureRecord(value: unknown): Record<string, unknown> {
43
+ if (!value || typeof value !== "object" || Array.isArray(value))
44
+ throw new Error("invalid generated fixture");
45
+ return value as Record<string, unknown>;
46
+ }
47
+
48
+ function fixtureKeys(fixture: Record<string, unknown>): void {
49
+ const keys = [
50
+ "kind",
51
+ "commits",
52
+ "files",
53
+ "staged",
54
+ "commitFiles",
55
+ "hooks",
56
+ "bin",
57
+ ];
58
+ if (
59
+ fixture.kind !== "generated" ||
60
+ Object.keys(fixture).some((key) => !keys.includes(key))
61
+ )
62
+ throw new Error("invalid generated fixture");
63
+ }
64
+
65
+ function stringArray(value: unknown): value is string[] {
66
+ return (
67
+ Array.isArray(value) &&
68
+ value.every((item: unknown) => typeof item === "string")
69
+ );
70
+ }
71
+
72
+ function fixtureOptionalFields(fixture: Record<string, unknown>): void {
73
+ if (fixture.files !== undefined && !validFiles(fixture.files))
74
+ throw new Error("invalid generated fixture");
75
+ validateStagedField(fixture.staged);
76
+ if (
77
+ fixture.commitFiles !== undefined &&
78
+ typeof fixture.commitFiles !== "boolean"
79
+ )
80
+ throw new Error("invalid generated fixture");
81
+ }
82
+
83
+ function validateStagedField(value: unknown): void {
84
+ if (value !== undefined && !stringArray(value))
85
+ throw new Error("invalid generated fixture");
86
+ }
87
+
88
+ function fixtureCommits(value: unknown): unknown[] {
89
+ if (!Array.isArray(value) || value.length > MAX_COMMITS)
90
+ throw new Error("invalid generated fixture");
91
+ return value;
92
+ }
93
+
94
+ function commitMessage(value: unknown): value is string {
95
+ return (
96
+ typeof value === "string" &&
97
+ Boolean(value.trim()) &&
98
+ Buffer.byteLength(value, "utf8") <= 4096
99
+ );
100
+ }
101
+
102
+ function commitRecord(value: unknown): Record<string, unknown> {
103
+ if (!value || typeof value !== "object" || Array.isArray(value))
104
+ throw new Error("invalid generated fixture commit");
105
+ return value as Record<string, unknown>;
106
+ }
107
+
108
+ function prepareCommit(value: unknown): GeneratedFixture["commits"][number] {
109
+ const commit = commitRecord(value);
110
+ if (
111
+ Object.keys(commit).some((key) => key !== "message" && key !== "files") ||
112
+ !commitMessage(commit.message)
113
+ )
114
+ throw new Error("invalid generated fixture commit");
115
+ if (!validFiles(commit.files) || Object.keys(commit.files).length === 0)
116
+ throw new Error("invalid generated fixture commit");
117
+ return { message: commit.message, files: commit.files };
118
+ }
119
+
120
+ function countFiles(
121
+ contents: FixtureContents,
122
+ files: Record<string, string>,
123
+ ): void {
124
+ for (const [path, content] of Object.entries(files)) {
125
+ validPath(path);
126
+ contents.paths.push(path);
127
+ contents.files++;
128
+ contents.bytes +=
129
+ Buffer.byteLength(path, "utf8") + Buffer.byteLength(content, "utf8");
130
+ }
131
+ }
132
+
133
+ function validateContents(contents: FixtureContents): void {
134
+ if (contents.files > MAX_FILES || contents.bytes > MAX_BYTES)
135
+ throw new Error("generated fixture exceeds the size limit");
136
+ const normalized = [...new Set(contents.paths)].map((path) =>
137
+ path.toLowerCase(),
138
+ );
139
+ for (const [index, path] of normalized.entries()) {
140
+ if (normalized.slice(0, index).some((other) => pathsCollide(path, other)))
141
+ throw new Error("generated fixture paths collide");
142
+ }
143
+ }
144
+
145
+ function pathsCollide(path: string, other: string): boolean {
146
+ return (
147
+ path === other ||
148
+ path.startsWith(`${other}/`) ||
149
+ other.startsWith(`${path}/`)
150
+ );
151
+ }
152
+
153
+ function validateStaging(
154
+ staged: string[],
155
+ overlay: Record<string, string>,
156
+ commitFiles: unknown,
157
+ ): void {
158
+ if (
159
+ new Set(staged.map((path) => path.toLowerCase())).size !== staged.length ||
160
+ staged.some((path) => !Object.hasOwn(overlay, path)) ||
161
+ emptyOverlayCommit(commitFiles, overlay)
162
+ )
163
+ throw new Error("invalid generated fixture staging");
164
+ }
165
+
166
+ function emptyOverlayCommit(
167
+ commitFiles: unknown,
168
+ overlay: Record<string, string>,
169
+ ): boolean {
170
+ return commitFiles === true && Object.keys(overlay).length === 0;
171
+ }
172
+
173
+ /** Freeze a bounded declarative history before any host or fixture work. */
174
+ export function prepareGeneratedFixture(value: unknown): GeneratedFixture {
175
+ const fixture = fixtureRecord(value);
176
+ fixtureKeys(fixture);
177
+ const commits = fixtureCommits(fixture.commits);
178
+ fixtureOptionalFields(fixture);
179
+ prepareGitHooks(fixture.hooks);
180
+ prepareFixtureBin(fixture.bin);
181
+ const contents: FixtureContents = { bytes: 0, files: 0, paths: [] };
182
+ for (const value of commits) {
183
+ const commit = prepareCommit(value);
184
+ contents.bytes += Buffer.byteLength(commit.message, "utf8");
185
+ countFiles(contents, commit.files);
186
+ }
187
+ // The optional fields were checked above; preserve absent fields in the frozen declaration.
188
+ const overlay = validFiles(fixture.files) ? fixture.files : {};
189
+ const staged = stringArray(fixture.staged) ? fixture.staged : [];
190
+ countFiles(contents, overlay);
191
+ contents.bytes += staged.reduce(
192
+ (size, path) => size + Buffer.byteLength(path, "utf8"),
193
+ 0,
194
+ );
195
+ validateContents(contents);
196
+ validateStaging(staged, overlay, fixture.commitFiles);
197
+ return JSON.parse(canonicalJson(fixture)) as GeneratedFixture;
198
+ }
199
+
200
+ function gitEnvironment() {
201
+ const nullDevice = process.platform === "win32" ? "NUL" : "/dev/null";
202
+ return {
203
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
204
+ ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
205
+ GIT_CONFIG_NOSYSTEM: "1",
206
+ GIT_CONFIG_GLOBAL: nullDevice,
207
+ GIT_TERMINAL_PROMPT: "0",
208
+ GIT_AUTHOR_NAME: "Sevro Fixture",
209
+ GIT_AUTHOR_EMAIL: "fixture@sevro.invalid",
210
+ GIT_COMMITTER_NAME: "Sevro Fixture",
211
+ GIT_COMMITTER_EMAIL: "fixture@sevro.invalid",
212
+ GIT_AUTHOR_DATE: "2000-01-01T00:00:00+00:00",
213
+ GIT_COMMITTER_DATE: "2000-01-01T00:00:00+00:00",
214
+ };
215
+ }
216
+
217
+ async function git(workspace: string, args: string[]): Promise<void> {
218
+ async function bounded(stream: ReadableStream<Uint8Array>): Promise<void> {
219
+ let size = 0;
220
+ for await (const chunk of stream) {
221
+ size += chunk.byteLength;
222
+ if (size > MAX_GIT_OUTPUT)
223
+ throw new Error("generated fixture Git output is too large");
224
+ }
225
+ }
226
+ const proc = Bun.spawn(
227
+ [
228
+ "git",
229
+ "-c",
230
+ `core.hooksPath=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
231
+ ...args,
232
+ ],
233
+ {
234
+ cwd: workspace,
235
+ stdout: "pipe",
236
+ stderr: "pipe",
237
+ env: gitEnvironment(),
238
+ },
239
+ );
240
+ try {
241
+ const [, , code] = await Promise.all([
242
+ bounded(proc.stdout),
243
+ bounded(proc.stderr),
244
+ proc.exited,
245
+ ]);
246
+ if (code !== 0) throw new Error();
247
+ } catch (cause) {
248
+ try {
249
+ proc.kill("SIGKILL");
250
+ } catch {
251
+ // The process may have exited before the failure was observed.
252
+ }
253
+ await proc.exited;
254
+ throw new Error("generated fixture Git operation failed", { cause });
255
+ }
256
+ }
257
+
258
+ async function writeFiles(
259
+ workspace: string,
260
+ files: Record<string, string>,
261
+ ): Promise<void> {
262
+ for (const [path, content] of Object.entries(files)) {
263
+ const target = join(workspace, ...fixtureParts(path));
264
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
265
+ await writeFile(target, content, { flag: "w", mode: 0o600 });
266
+ }
267
+ }
268
+
269
+ /** Build a deterministic local Git history, then apply working-tree state. */
270
+ export async function materializeGeneratedFixture(
271
+ fixture: GeneratedFixture,
272
+ workspace: string,
273
+ ): Promise<void> {
274
+ await git(workspace, ["init", "--quiet", "--initial-branch=main"]);
275
+ for (const commit of fixture.commits) {
276
+ await writeFiles(workspace, commit.files);
277
+ await git(workspace, ["add", "--", ...Object.keys(commit.files)]);
278
+ await git(workspace, ["commit", "--quiet", "-m", commit.message]);
279
+ }
280
+ await applyGeneratedOverlay(fixture, workspace);
281
+ }
282
+
283
+ async function applyGeneratedOverlay(
284
+ fixture: GeneratedFixture,
285
+ workspace: string,
286
+ ): Promise<void> {
287
+ const overlay = fixture.files ?? {};
288
+ await writeFiles(workspace, overlay);
289
+ if (fixture.commitFiles) {
290
+ await git(workspace, ["add", "--", ...Object.keys(overlay)]);
291
+ await git(workspace, [
292
+ "commit",
293
+ "--quiet",
294
+ "-m",
295
+ "Add evaluation scaffolding",
296
+ ]);
297
+ }
298
+ if (fixture.staged?.length)
299
+ await git(workspace, ["add", "--", ...fixture.staged]);
300
+ }
@@ -0,0 +1,24 @@
1
+ import { join } from "node:path";
2
+ import { prepareFixtureTools, installFixtureTools } from "./tools";
3
+
4
+ /** Bound hook declarations and keep every name inside Git's hooks directory. */
5
+ export function prepareGitHooks(value: unknown): Record<string, string> | null {
6
+ return prepareFixtureTools(
7
+ value,
8
+ /^[a-z][a-z0-9-]*$/,
9
+ 32,
10
+ "invalid fixture hooks",
11
+ );
12
+ }
13
+
14
+ /** Install executable hooks only after fixture commits and overlays are ready. */
15
+ export async function installGitHooks(
16
+ hooks: Record<string, string> | undefined,
17
+ workspace: string,
18
+ ): Promise<void> {
19
+ if (!hooks) return;
20
+ await installFixtureTools(hooks, join(workspace, ".git", "hooks"), {
21
+ directory: "fixture Git hooks directory is invalid",
22
+ target: "fixture Git hook target is invalid",
23
+ });
24
+ }
@@ -0,0 +1,270 @@
1
+ import { isMissingFile } from "./tools";
2
+ import { sha256 } from "../identity";
3
+ import { lstat, mkdir, readFile, realpath, stat } from "node:fs/promises";
4
+ import { isAbsolute, join, relative, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024;
8
+ const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
9
+
10
+ export interface PreparationArtifact {
11
+ id: string;
12
+ relativePath: string;
13
+ sha256: string;
14
+ contentBase64?: string;
15
+ sourceRef?: string;
16
+ gitExclude?: boolean;
17
+ executable?: boolean;
18
+ }
19
+
20
+ export interface InlineArtifact {
21
+ id: string;
22
+ relativePath: string;
23
+ sha256: string;
24
+ bytes: Uint8Array;
25
+ gitExclude?: boolean;
26
+ executable?: boolean;
27
+ }
28
+
29
+ export interface PreparationSources {
30
+ root: string;
31
+ refs: Record<string, string>;
32
+ }
33
+
34
+ export function fixtureParts(relativePath: string): string[] {
35
+ if (!validFixturePath(relativePath))
36
+ throw new Error(`invalid fixture path: ${relativePath}`);
37
+ const parts = relativePath.split("/");
38
+ if (parts.some((part) => !part || part === "." || part === ".."))
39
+ throw new Error(`invalid fixture path: ${relativePath}`);
40
+ return parts;
41
+ }
42
+
43
+ function validFixturePath(path: string): boolean {
44
+ return !!path && !isAbsolute(path) && !path.includes("\\") && !/:/.test(path);
45
+ }
46
+
47
+ /** Refuse fixture or setup-created links before mounting trusted artifact bytes. */
48
+ export async function safePreparationTarget(
49
+ workspace: string,
50
+ relativePath: string,
51
+ ): Promise<string> {
52
+ const parts = fixtureParts(relativePath);
53
+ let parent = workspace;
54
+ for (const part of parts.slice(0, -1)) {
55
+ parent = join(parent, part);
56
+ await preparationParent(parent);
57
+ }
58
+ const target = join(workspace, ...parts);
59
+ await requireUnusedPreparationTarget(target);
60
+ return target;
61
+ }
62
+
63
+ async function preparationParent(path: string): Promise<void> {
64
+ try {
65
+ const entry = await lstat(path);
66
+ if (!entry.isDirectory() || entry.isSymbolicLink())
67
+ throw new Error("preparation artifact traverses a non-directory");
68
+ } catch (error) {
69
+ if (!isMissingFile(error)) throw error;
70
+ await mkdir(path, { mode: 0o700 });
71
+ }
72
+ }
73
+ async function requireUnusedPreparationTarget(path: string): Promise<void> {
74
+ try {
75
+ await lstat(path);
76
+ throw new Error("preparation artifact path already exists");
77
+ } catch (error) {
78
+ if (!isMissingFile(error)) throw error;
79
+ }
80
+ }
81
+ function collidingPaths(left: string, right: string): boolean {
82
+ return (
83
+ left === right ||
84
+ left.startsWith(`${right}/`) ||
85
+ right.startsWith(`${left}/`)
86
+ );
87
+ }
88
+
89
+ function validatePaths(paths: string[]): void {
90
+ const normalized = paths.map((path) =>
91
+ fixtureParts(path).join("/").toLowerCase(),
92
+ );
93
+ for (const [index, path] of normalized.entries())
94
+ for (const other of normalized.slice(0, index))
95
+ if (collidingPaths(path, other)) throw new Error("fixture paths collide");
96
+ }
97
+
98
+ /** Decode declared inline artifacts before creating a candidate workspace. */
99
+ export function prepareInlineArtifacts(
100
+ declarations: PreparationArtifact[],
101
+ fixturePaths: string[],
102
+ ): InlineArtifact[] {
103
+ validatePaths([
104
+ ...fixturePaths,
105
+ ...declarations.map((item) => item.relativePath),
106
+ ]);
107
+ const state = { ids: new Set<string>(), bytes: 0 };
108
+ return declarations.map((item) => inlineArtifact(item, state));
109
+ }
110
+
111
+ function requireArtifactFlags(item: PreparationArtifact): void {
112
+ if (item.gitExclude !== undefined && typeof item.gitExclude !== "boolean")
113
+ throw new Error("preparation artifact gitExclude must be boolean");
114
+ if (item.executable !== undefined && typeof item.executable !== "boolean")
115
+ throw new Error("preparation artifact executable must be boolean");
116
+ }
117
+ function containsControlCharacter(path: string): boolean {
118
+ return Array.from(path).some(
119
+ (character) =>
120
+ character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f,
121
+ );
122
+ }
123
+ function requireInlineSource(
124
+ item: PreparationArtifact,
125
+ ): asserts item is PreparationArtifact & { contentBase64: string } {
126
+ if (item.gitExclude && containsControlCharacter(item.relativePath))
127
+ throw new Error("Git-excluded artifact path contains control characters");
128
+ if (item.sourceRef !== undefined || typeof item.contentBase64 !== "string")
129
+ throw new Error("preparation source references are not supported");
130
+ }
131
+ function canonicalBase64Bytes(encoded: string): Buffer {
132
+ if (
133
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
134
+ encoded,
135
+ )
136
+ )
137
+ throw new Error("preparation artifact content is not canonical base64");
138
+ const bytes = Buffer.from(encoded, "base64");
139
+ if (bytes.toString("base64") !== encoded)
140
+ throw new Error("preparation artifact content is not canonical base64");
141
+ return bytes;
142
+ }
143
+ function requireArtifactSize(bytes: number, total: number): void {
144
+ if (bytes > MAX_ARTIFACT_BYTES || total > MAX_TOTAL_BYTES)
145
+ throw new Error("preparation artifacts exceed the size limit");
146
+ }
147
+ function artifactFlags(item: PreparationArtifact) {
148
+ return {
149
+ ...(item.gitExclude === undefined ? {} : { gitExclude: item.gitExclude }),
150
+ ...(item.executable === undefined ? {} : { executable: item.executable }),
151
+ };
152
+ }
153
+ function inlineArtifact(
154
+ item: PreparationArtifact,
155
+ state: { ids: Set<string>; bytes: number },
156
+ ): InlineArtifact {
157
+ if (!item.id || state.ids.has(item.id))
158
+ throw new Error("preparation artifact IDs must be unique and nonempty");
159
+ state.ids.add(item.id);
160
+ requireArtifactFlags(item);
161
+ requireInlineSource(item);
162
+ const bytes = canonicalBase64Bytes(item.contentBase64);
163
+ state.bytes += bytes.byteLength;
164
+ requireArtifactSize(bytes.byteLength, state.bytes);
165
+ if (sha256(bytes) !== item.sha256)
166
+ throw new Error("preparation artifact digest does not match content");
167
+ return {
168
+ id: item.id,
169
+ relativePath: item.relativePath,
170
+ sha256: item.sha256,
171
+ bytes,
172
+ ...artifactFlags(item),
173
+ };
174
+ }
175
+
176
+ /** Resolve opaque source IDs only through an explicitly declared case source map. */
177
+ export async function prepareArtifacts(
178
+ declarations: PreparationArtifact[],
179
+ fixturePaths: string[],
180
+ sources?: PreparationSources,
181
+ ): Promise<InlineArtifact[]> {
182
+ if (!declarations.some((item) => item.sourceRef !== undefined))
183
+ return prepareInlineArtifacts(declarations, fixturePaths);
184
+ const context = await preparationSourceContext(sources);
185
+ const resolved: PreparationArtifact[] = [];
186
+ const state = { bytes: 0 };
187
+ for (const item of declarations)
188
+ resolved.push(await resolveSourceArtifact(item, context, state));
189
+ return prepareInlineArtifacts(resolved, fixturePaths);
190
+ }
191
+
192
+ async function preparationSourceContext(
193
+ sources: PreparationSources | undefined,
194
+ ) {
195
+ if (!sources || !isAbsolute(sources.root))
196
+ throw new Error("preparation source root is required and must be absolute");
197
+ const root = await realpath(sources.root).catch((cause: unknown) => {
198
+ throw new Error("preparation source root is unreadable", { cause });
199
+ });
200
+ return { root, refs: sources.refs };
201
+ }
202
+ function sourceArtifactUrl(
203
+ item: PreparationArtifact & { sourceRef: string },
204
+ refs: Record<string, string>,
205
+ ): string {
206
+ if (item.contentBase64 !== undefined)
207
+ throw new Error("preparation artifact has two content sources");
208
+ if (!Object.hasOwn(refs, item.sourceRef))
209
+ throw new Error("preparation source reference is not declared");
210
+ const url = refs[item.sourceRef];
211
+ if (typeof url !== "string" || !url.startsWith("file:///"))
212
+ throw new Error("preparation source reference must be a file URL");
213
+ return url;
214
+ }
215
+ async function preparationSourcePath(url: string): Promise<string> {
216
+ try {
217
+ return await realpath(fileURLToPath(url));
218
+ } catch (cause) {
219
+ throw new Error("preparation source is unreadable", { cause });
220
+ }
221
+ }
222
+ function requireContainedSource(root: string, path: string): void {
223
+ const child = relative(root, path);
224
+ if (
225
+ !child ||
226
+ child === ".." ||
227
+ child.startsWith(`..${sep}`) ||
228
+ isAbsolute(child)
229
+ )
230
+ throw new Error("preparation source escapes its declared root");
231
+ }
232
+ async function preparationSourceSize(path: string): Promise<number> {
233
+ try {
234
+ return (await stat(path)).size;
235
+ } catch (cause) {
236
+ throw new Error("preparation source is unreadable", { cause });
237
+ }
238
+ }
239
+ async function preparationSourceBytes(path: string): Promise<Buffer> {
240
+ try {
241
+ return await readFile(path);
242
+ } catch (cause) {
243
+ throw new Error("preparation source is unreadable", { cause });
244
+ }
245
+ }
246
+ async function resolveSourceArtifact(
247
+ item: PreparationArtifact,
248
+ context: Awaited<ReturnType<typeof preparationSourceContext>>,
249
+ state: { bytes: number },
250
+ ): Promise<PreparationArtifact> {
251
+ if (item.sourceRef === undefined) return item;
252
+ const url = sourceArtifactUrl(
253
+ { ...item, sourceRef: item.sourceRef },
254
+ context.refs,
255
+ );
256
+ const path = await preparationSourcePath(url);
257
+ requireContainedSource(context.root, path);
258
+ const size = await preparationSourceSize(path);
259
+ requireArtifactSize(size, state.bytes + size);
260
+ const bytes = await preparationSourceBytes(path);
261
+ state.bytes += bytes.byteLength;
262
+ requireArtifactSize(bytes.byteLength, state.bytes);
263
+ return {
264
+ id: item.id,
265
+ relativePath: item.relativePath,
266
+ sha256: item.sha256,
267
+ contentBase64: bytes.toString("base64"),
268
+ ...artifactFlags(item),
269
+ };
270
+ }