@bjoernrochel/sevro 0.1.0-rc.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/CONTRIBUTING.md +22 -6
  2. package/README.md +5 -1
  3. package/docs/README.md +19 -15
  4. package/docs/architecture.md +138 -17
  5. package/docs/assets/README.md +5 -4
  6. package/docs/builtin-graders-v1.md +9 -2
  7. package/docs/codex-host.md +155 -57
  8. package/docs/contracts.md +2 -1
  9. package/docs/creating-cases.md +9 -2
  10. package/docs/development-cli.md +187 -19
  11. package/docs/documentation-quality.md +12 -2
  12. package/docs/evaluation-workflow.md +110 -0
  13. package/docs/extension-protocol-v1.md +26 -3
  14. package/docs/getting-started.md +23 -2
  15. package/docs/guide-evaluation.md +33 -1
  16. package/docs/identity-v1.md +25 -2
  17. package/docs/installing.md +11 -11
  18. package/docs/native-controls.md +76 -5
  19. package/docs/native-hosts.md +196 -7
  20. package/docs/reading-results.md +102 -0
  21. package/docs/recipes.md +489 -0
  22. package/docs/releases.md +31 -12
  23. package/docs/report-v1.md +29 -1
  24. package/docs/results-v1.md +122 -4
  25. package/docs/run-settings-v1.md +147 -0
  26. package/docs/runtime-v1.md +255 -22
  27. package/docs/troubleshooting.md +12 -12
  28. package/docs/typescript-quality.md +89 -26
  29. package/examples/recipes/candidate.ts +26 -0
  30. package/examples/recipes/cases/coding-change.json +21 -0
  31. package/examples/recipes/cases/host-tools.json +21 -0
  32. package/examples/recipes/cases/mise.json +21 -0
  33. package/examples/recipes/cases/node.json +21 -0
  34. package/examples/recipes/cases/python-uv.json +21 -0
  35. package/examples/recipes/cases/semantic.json +17 -0
  36. package/examples/recipes/coding-change/check.ts +23 -0
  37. package/examples/recipes/extension-artifacts.ts +65 -0
  38. package/examples/recipes/extension-methods.ts +174 -0
  39. package/examples/recipes/extension.ts +37 -0
  40. package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
  41. package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
  42. package/examples/recipes/repository-skill/README.md +3 -0
  43. package/examples/recipes/run-inputs.ts +188 -0
  44. package/examples/recipes/run-options.ts +131 -0
  45. package/examples/recipes/run.ts +63 -0
  46. package/examples/recipes/runtime/empty.json +1 -0
  47. package/examples/recipes/runtime/goals.json +1 -0
  48. package/examples/recipes/runtime/mise.json +6 -0
  49. package/examples/recipes/runtime/path.json +1 -0
  50. package/examples/recipes/runtime/plugins.json +4 -0
  51. package/examples/recipes/runtime/python-uv.json +10 -0
  52. package/examples/recipes/semantic-judge.ts +29 -0
  53. package/examples/templates/coding/adapter.ts +17 -0
  54. package/examples/templates/coding/case.json +15 -0
  55. package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
  56. package/examples/templates/repository-skill/extension-data.ts +64 -0
  57. package/examples/templates/repository-skill/extension.ts +31 -0
  58. package/examples/templates/response/adapter.ts +12 -0
  59. package/examples/templates/response/case.json +13 -0
  60. package/package.json +11 -5
  61. package/schemas/report-v1.schema.json +9 -0
  62. package/schemas/run-evidence-v1.schema.json +2 -1
  63. package/schemas/runtime-v1.schema.json +43 -7
  64. package/src/advisory.ts +2 -2
  65. package/src/candidate-artifacts.ts +42 -11
  66. package/src/candidate-codex-state.ts +185 -0
  67. package/src/case-validation.ts +73 -50
  68. package/src/cli/cancellation.ts +20 -0
  69. package/src/cli/command.ts +87 -0
  70. package/src/cli/completion.ts +15 -0
  71. package/src/cli/demo.ts +60 -0
  72. package/src/cli/evaluation.ts +55 -0
  73. package/src/cli/extension.ts +79 -0
  74. package/src/cli/help.ts +198 -0
  75. package/src/cli/hosts.ts +55 -0
  76. package/src/cli/init-settings.ts +30 -0
  77. package/src/cli/init.ts +111 -0
  78. package/src/{cli-invocation.ts → cli/invocation.ts} +214 -218
  79. package/src/cli/loading.ts +151 -0
  80. package/src/cli/preparation.ts +154 -0
  81. package/src/cli/presentation.ts +93 -0
  82. package/src/cli/progress.ts +6 -0
  83. package/src/cli/run-manifest.ts +128 -0
  84. package/src/cli/run-options.ts +403 -0
  85. package/src/cli/run-overrides.ts +69 -0
  86. package/src/cli/settings.ts +154 -0
  87. package/src/cli/summary.ts +43 -0
  88. package/src/cli.ts +13 -591
  89. package/src/contract-types.ts +94 -0
  90. package/src/engine.ts +9 -5
  91. package/src/evaluation/advisory.ts +150 -0
  92. package/src/{evaluation-check-plan.ts → evaluation/check-plan.ts} +103 -67
  93. package/src/evaluation/collection.ts +88 -0
  94. package/src/evaluation/context.ts +10 -0
  95. package/src/{evaluation-grading-host.ts → evaluation/grading-host.ts} +5 -5
  96. package/src/evaluation/grading.ts +235 -0
  97. package/src/{evaluation-invocation-plan.ts → evaluation/invocation-plan.ts} +147 -58
  98. package/src/{evaluation-plan.ts → evaluation/plan.ts} +208 -141
  99. package/src/evaluation/progress.ts +19 -0
  100. package/src/evaluation/resources.ts +241 -0
  101. package/src/evaluation/run.ts +206 -0
  102. package/src/{evaluation-semantic.ts → evaluation/semantic.ts} +77 -87
  103. package/src/evaluation/trial.ts +800 -0
  104. package/src/{evaluation-types.ts → evaluation/types.ts} +79 -29
  105. package/src/evidence/host-results.ts +85 -0
  106. package/src/evidence/retained-artifacts.ts +18 -0
  107. package/src/evidence/trial-writer.ts +203 -0
  108. package/src/evidence/usage.ts +17 -0
  109. package/src/extension-client.ts +34 -96
  110. package/src/extension-session.ts +24 -48
  111. package/src/{advisory-fixture.ts → fixtures/advisory.ts} +12 -72
  112. package/src/{fixture-bin.ts → fixtures/bin.ts} +1 -1
  113. package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
  114. package/src/fixtures/create.ts +183 -0
  115. package/src/{generated-fixture.ts → fixtures/generated.ts} +10 -53
  116. package/src/{git-hooks.ts → fixtures/git-hooks.ts} +1 -1
  117. package/src/{preparation.ts → fixtures/preparation.ts} +6 -16
  118. package/src/{repository-fixture.ts → fixtures/repository.ts} +30 -64
  119. package/src/{fixture-setup.ts → fixtures/setup.ts} +33 -51
  120. package/src/{fixture-tools.ts → fixtures/tools.ts} +1 -1
  121. package/src/{workspace-state.ts → fixtures/workspace-state.ts} +5 -5
  122. package/src/generated/cli-result.d.cts +2 -1
  123. package/src/generated/extension.d.cts +2 -1
  124. package/src/generated/report.cjs +1 -1
  125. package/src/generated/report.d.cts +2 -1
  126. package/src/generated/run-evidence.cjs +1 -1
  127. package/src/generated/run-evidence.d.cts +2 -1
  128. package/src/generated/runtime.cjs +1 -1
  129. package/src/generated/runtime.d.cts +2 -1
  130. package/src/git.ts +93 -0
  131. package/src/graders/git-head.ts +29 -84
  132. package/src/graders/shell.ts +50 -68
  133. package/src/host-diagnostic-redaction.ts +52 -0
  134. package/src/host-diagnostics.ts +331 -0
  135. package/src/hosts/claude-continuation.ts +57 -40
  136. package/src/hosts/claude-credential.ts +160 -63
  137. package/src/hosts/claude-events.ts +34 -18
  138. package/src/hosts/claude-keychain.ts +78 -21
  139. package/src/hosts/claude-login-recovery.ts +85 -0
  140. package/src/hosts/claude-login-refresh.ts +178 -0
  141. package/src/hosts/claude-login-store.ts +162 -0
  142. package/src/hosts/claude-login.ts +107 -0
  143. package/src/hosts/claude-native-goal.ts +15 -12
  144. package/src/hosts/claude-process.ts +10 -50
  145. package/src/hosts/claude-refresh-lock.ts +81 -0
  146. package/src/hosts/claude-repository-invocation.ts +9 -34
  147. package/src/hosts/claude-settings.ts +46 -9
  148. package/src/hosts/claude-skill-activations.ts +169 -0
  149. package/src/hosts/claude-state.ts +28 -15
  150. package/src/hosts/claude-tool-calls.ts +13 -10
  151. package/src/hosts/claude.ts +121 -81
  152. package/src/hosts/codex-app-server.ts +78 -52
  153. package/src/hosts/codex-configuration.ts +5 -10
  154. package/src/hosts/codex-exec-controls.ts +516 -0
  155. package/src/hosts/codex-marketplace.ts +12 -10
  156. package/src/hosts/codex-native-calls.ts +15 -122
  157. package/src/hosts/codex-process.ts +10 -183
  158. package/src/hosts/codex-profile.ts +36 -21
  159. package/src/hosts/codex-session-files.ts +1 -9
  160. package/src/hosts/codex-state.ts +134 -18
  161. package/src/hosts/codex.ts +210 -183
  162. package/src/hosts/isolation-common.ts +8 -11
  163. package/src/hosts/isolation-roots.ts +2 -2
  164. package/src/hosts/linux-sandbox.ts +15 -2
  165. package/src/hosts/mac-sandbox.ts +87 -18
  166. package/src/hosts/native-controls.ts +72 -22
  167. package/src/hosts/native-request.ts +55 -0
  168. package/src/hosts/process-lifecycle.ts +216 -48
  169. package/src/hosts/protected-worktrees.ts +1 -1
  170. package/src/hosts/skill-hook-catalog.ts +131 -0
  171. package/src/hosts/skill-hook-handler.ts +374 -0
  172. package/src/hosts/skill-hooks.ts +577 -0
  173. package/src/instrumentation.ts +7 -8
  174. package/src/native-transcript-state.ts +22 -3
  175. package/src/native-transcript-view.ts +52 -13
  176. package/src/native-transcripts.ts +26 -11
  177. package/src/paths.ts +22 -0
  178. package/src/process-lifecycle.ts +150 -0
  179. package/src/provenance.ts +27 -7
  180. package/src/prune.ts +300 -0
  181. package/src/report.ts +63 -36
  182. package/src/resolved-case.ts +1 -1
  183. package/src/results-lock.ts +42 -0
  184. package/src/results.ts +14 -18
  185. package/src/{runtime-config.ts → runtime/config.ts} +160 -46
  186. package/src/{runtime-discovery-apple.ts → runtime/discovery-apple.ts} +3 -3
  187. package/src/{runtime-discovery-providers.ts → runtime/discovery-providers.ts} +3 -9
  188. package/src/{runtime-discovery.ts → runtime/discovery.ts} +4 -4
  189. package/src/{runtime-hook-processes.ts → runtime/hook-processes.ts} +1 -1
  190. package/src/{runtime-hooks.ts → runtime/hooks.ts} +32 -22
  191. package/src/{runtime-paths.ts → runtime/paths.ts} +5 -19
  192. package/src/{runtime-query.ts → runtime/query.ts} +5 -5
  193. package/src/{runtime-seeds.ts → runtime/seeds.ts} +3 -3
  194. package/src/runtime/shared-cache.ts +130 -0
  195. package/src/{runtime-state.ts → runtime/state.ts} +28 -11
  196. package/src/schema-types.ts +73 -161
  197. package/src/shell.ts +4 -0
  198. package/src/transcript-retention.ts +146 -0
  199. package/src/value-guards.ts +22 -0
  200. package/docs/assets/sevro-logo.png +0 -0
  201. package/docs/assets/sevro-symbol.svg +0 -1
  202. package/docs/assets/sevro-wordmark.svg +0 -1
  203. package/docs/cache-ownership-validation.md +0 -49
  204. package/docs/claude-continuation-validation.md +0 -47
  205. package/docs/concurrency-validation.md +0 -86
  206. package/docs/configuration-root-validation.md +0 -70
  207. package/docs/engine-slice.md +0 -114
  208. package/docs/evidence/runtime-startup-30/README.md +0 -185
  209. package/docs/evidence/runtime-startup-30/after.json +0 -319
  210. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +0 -2
  211. package/docs/evidence/runtime-startup-30/before.json +0 -403
  212. package/docs/evidence/runtime-startup-30/consumer-smoke.json +0 -63
  213. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +0 -3
  214. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +0 -3
  215. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +0 -20
  216. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +0 -2
  217. package/docs/evidence.md +0 -17
  218. package/docs/fixture-cleanup-validation.md +0 -73
  219. package/docs/guide-verification.json +0 -239
  220. package/src/evaluation-context.ts +0 -259
  221. package/src/evaluation-fixture.ts +0 -302
  222. package/src/evaluation-run.ts +0 -200
  223. package/src/evaluation-trial.ts +0 -1016
  224. package/src/hosts/claude-nested-skills.ts +0 -272
  225. package/src/hosts/codex-command-output.ts +0 -510
  226. package/src/hosts/codex-executor-command.ts +0 -291
  227. package/src/hosts/codex-skill-reads.ts +0 -783
  228. /package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +0 -0
@@ -4,9 +4,20 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { createReadStream } from "node:fs";
5
5
  import { isAbsolute } from "node:path";
6
6
  import { canonicalJson, hashJson } from "./identity";
7
- import type { GeneratedFixture } from "./generated-fixture";
8
- import type { RepositoryFixture } from "./repository-fixture";
9
- import type { FixtureSetupDeclaration } from "./fixture-setup";
7
+ import type { GeneratedFixture } from "./fixtures/generated";
8
+ import type { RepositoryFixture } from "./fixtures/repository";
9
+ import type { FixtureSetupDeclaration } from "./fixtures/setup";
10
+ import type {
11
+ ArtifactReference,
12
+ Assessment,
13
+ CheckDeclaration,
14
+ DomainOutcome,
15
+ ExtensionCheckOutcome,
16
+ MetricEvidence,
17
+ ObservationEvidence,
18
+ TaskVerdict,
19
+ } from "./contract-types";
20
+ import type { InstrumentationRequest } from "./instrumentation";
10
21
  import {
11
22
  ExtensionProtocolError,
12
23
  exchangeExtension,
@@ -32,11 +43,7 @@ export interface ExtensionCase {
32
43
  | { kind: "inline"; files: Record<string, string> }
33
44
  | RepositoryFixture
34
45
  | GeneratedFixture;
35
- checks: {
36
- id: string;
37
- grader: string;
38
- configuration: Record<string, unknown>;
39
- }[];
46
+ checks: CheckDeclaration[];
40
47
  requiredEvidence: string[];
41
48
  extensionData: Record<string, unknown>;
42
49
  }
@@ -51,10 +58,7 @@ export interface PreparationResult {
51
58
  gitExclude?: boolean;
52
59
  executable?: boolean;
53
60
  }[];
54
- requestedInstrumentation: {
55
- id: string;
56
- configuration: Record<string, unknown>;
57
- }[];
61
+ requestedInstrumentation: InstrumentationRequest[];
58
62
  fixtureSetup?: FixtureSetupDeclaration;
59
63
  codexMarketplace?: {
60
64
  artifactRoot: string;
@@ -77,48 +81,20 @@ export interface PreparationResult {
77
81
 
78
82
  export interface EvaluationRequest {
79
83
  caseId: string;
80
- execution: {
81
- status: "completed" | "failed" | "cancelled" | "not_run";
84
+ execution: Assessment["execution"] & {
82
85
  errorCode?: string;
83
86
  };
84
- observations: {
85
- id: string;
86
- source: string;
87
- completeness: "complete" | "partial" | "unavailable";
88
- data: Record<string, unknown>;
89
- }[];
90
- builtinChecks: {
91
- id: string;
92
- status: "passed" | "failed" | "unavailable";
93
- detail?: string;
94
- evidenceRefs: string[];
95
- }[];
96
- artifacts: {
97
- id: string;
98
- path: string;
99
- sha256: string;
100
- gitExclude?: boolean;
101
- executable?: boolean;
102
- }[];
87
+ observations: ObservationEvidence[];
88
+ builtinChecks: ExtensionCheckOutcome[];
89
+ artifacts: ArtifactReference[];
103
90
  extensionData: Record<string, unknown>;
104
91
  }
105
92
 
106
93
  export interface EvaluationResult {
107
- checks: {
108
- id: string;
109
- status: "passed" | "failed" | "unavailable";
110
- detail?: string;
111
- evidenceRefs: string[];
112
- }[];
113
- metrics: { id: string; value: number | null; unit: string }[];
114
- domainOutcomes?: {
115
- id: string;
116
- status: "passed" | "failed" | "unavailable";
117
- detail?: string;
118
- evidenceRefs: string[];
119
- data?: Record<string, unknown>;
120
- }[];
121
- taskVerdictRecommendation?: "passed" | "failed" | "not_assessed";
94
+ checks: ExtensionCheckOutcome[];
95
+ metrics: MetricEvidence[];
96
+ domainOutcomes?: DomainOutcome[];
97
+ taskVerdictRecommendation?: TaskVerdict;
122
98
  }
123
99
 
124
100
  export interface ExtensionSessionOptions extends ExchangeOptions {
@@ -1,5 +1,5 @@
1
1
  import type { Dirent, Stats } from "node:fs";
2
- import { isMissingFile } from "./fixture-tools";
2
+ import { isMissingFile } from "./tools";
3
3
  import {
4
4
  lstat,
5
5
  mkdir,
@@ -11,7 +11,9 @@ import {
11
11
  copyFile,
12
12
  } from "node:fs/promises";
13
13
  import { allocateWorkspace, removeWorkspace } from "./workspace-state";
14
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
+ import { dirname, isAbsolute, join, resolve } from "node:path";
15
+ import { insideRoot as contained } from "../paths";
16
+ import { runGit } from "../git";
15
17
  import { fixtureParts } from "./preparation";
16
18
 
17
19
  const MAX_GIT_OUTPUT = 16 * 1024 * 1024;
@@ -19,87 +21,25 @@ const MAX_UNTRACKED_BYTES = 32 * 1024 * 1024;
19
21
  const MAX_UNTRACKED_FILES = 1024;
20
22
  const HIDDEN_ROOTS = [".agents", ".claude", ".codex", ".git"];
21
23
 
22
- function contained(root: string, path: string): boolean {
23
- const child = relative(root, path);
24
- return (
25
- child === "" ||
26
- (child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child))
27
- );
28
- }
29
-
30
24
  function excluded(path: string, roots: string[]): boolean {
31
25
  return roots.some((root) => path === root || path.startsWith(`${root}/`));
32
26
  }
33
27
 
34
- async function bounded(stream: ReadableStream<Uint8Array>): Promise<Buffer> {
35
- const chunks: Uint8Array[] = [];
36
- let size = 0;
37
- for await (const chunk of stream) {
38
- size += chunk.byteLength;
39
- if (size > MAX_GIT_OUTPUT)
40
- throw new Error("advisory fixture Git output is too large");
41
- chunks.push(chunk);
42
- }
43
- return Buffer.concat(chunks);
44
- }
45
-
46
- function nullGitConfig(): string {
47
- return process.platform === "win32" ? "NUL" : "/dev/null";
48
- }
49
- function gitEnvironment(): Record<string, string> {
50
- return {
51
- PATH: process.env.PATH ?? "/usr/bin:/bin",
52
- ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
53
- GIT_CONFIG_NOSYSTEM: "1",
54
- GIT_CONFIG_GLOBAL: nullGitConfig(),
55
- GIT_TERMINAL_PROMPT: "0",
56
- GIT_AUTHOR_NAME: "Sevro Advisory",
57
- GIT_AUTHOR_EMAIL: "advisory@sevro.invalid",
58
- GIT_COMMITTER_NAME: "Sevro Advisory",
59
- GIT_COMMITTER_EMAIL: "advisory@sevro.invalid",
60
- GIT_AUTHOR_DATE: "2000-01-01T00:00:00+00:00",
61
- GIT_COMMITTER_DATE: "2000-01-01T00:00:00+00:00",
62
- };
63
- }
64
-
65
28
  async function git(
66
29
  cwd: string,
67
30
  args: string[],
68
31
  input?: Uint8Array,
69
32
  ): Promise<Buffer> {
70
- const proc = Bun.spawn(
71
- ["git", "-c", `core.hooksPath=${nullGitConfig()}`, ...args],
72
- {
73
- cwd,
74
- stdin: input ? "pipe" : "ignore",
75
- stdout: "pipe",
76
- stderr: "pipe",
77
- env: gitEnvironment(),
78
- },
79
- );
80
33
  try {
81
- const write = (async () => {
82
- if (!input) return;
83
- if (!proc.stdin || typeof proc.stdin === "number")
84
- throw new Error("advisory fixture Git input unavailable");
85
- await proc.stdin.write(input);
86
- await proc.stdin.end();
87
- })();
88
- const [out, , code] = await Promise.all([
89
- bounded(proc.stdout),
90
- bounded(proc.stderr),
91
- proc.exited,
92
- write,
93
- ]);
94
- if (code !== 0) throw new Error("advisory fixture Git operation failed");
95
- return out;
34
+ return await runGit({
35
+ cwd,
36
+ args,
37
+ input,
38
+ identity: "advisory",
39
+ label: "advisory fixture",
40
+ maxOutputBytes: MAX_GIT_OUTPUT,
41
+ });
96
42
  } catch (cause) {
97
- try {
98
- proc.kill("SIGKILL");
99
- } catch {
100
- // The process may already have exited.
101
- }
102
- await proc.exited;
103
43
  throw new Error("advisory fixture Git operation failed", { cause });
104
44
  }
105
45
  }
@@ -2,7 +2,7 @@ import {
2
2
  prepareFixtureTools,
3
3
  installFixtureTools,
4
4
  isMissingFile,
5
- } from "./fixture-tools";
5
+ } from "./tools";
6
6
  import { lstat, mkdir, realpath } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
8
 
@@ -15,6 +15,18 @@ async function restoreDirectoryAccess(path: string): Promise<void> {
15
15
  await restoreDirectoryAccess(join(path, child.name));
16
16
  }
17
17
 
18
+ /** Remove owned disposable state without following cache or fixture symlinks. */
19
+ export async function removeDisposableDirectory(path: string): Promise<void> {
20
+ try {
21
+ await rm(path, { recursive: true, force: true });
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code === "ENOTDIR") return;
24
+ if (!permissionFailure(error)) throw error;
25
+ await restoreDirectoryAccess(path);
26
+ await rm(path, { recursive: true, force: true });
27
+ }
28
+ }
29
+
18
30
  /** Clear a completed fixture while preserving its reserved root for active peers. */
19
31
  export async function clearFixtureContents(workspace: string): Promise<void> {
20
32
  const root = await lstat(workspace);
@@ -26,13 +38,7 @@ export async function clearFixtureContents(workspace: string): Promise<void> {
26
38
  await Promise.all(
27
39
  names.map(async (name) => {
28
40
  const path = join(workspace, name);
29
- try {
30
- await rm(path, { recursive: true, force: true });
31
- } catch (error) {
32
- if (!permissionFailure(error)) throw error;
33
- await restoreDirectoryAccess(path);
34
- await rm(path, { recursive: true, force: true });
35
- }
41
+ await removeDisposableDirectory(path);
36
42
  }),
37
43
  );
38
44
  }
@@ -0,0 +1,183 @@
1
+ import { appendFile, mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { allocateWorkspace, removeWorkspace } from "./workspace-state";
4
+ import { fixtureBinDirectory, installFixtureBin } from "./bin";
5
+ import { installGitHooks } from "./git-hooks";
6
+ import {
7
+ materializeGeneratedFixture,
8
+ type GeneratedFixture,
9
+ } from "./generated";
10
+ import { runFixtureSetup, type FixtureSetup } from "./setup";
11
+ import {
12
+ fixtureParts,
13
+ safePreparationTarget,
14
+ type InlineArtifact,
15
+ type PreparationSources,
16
+ } from "./preparation";
17
+ import {
18
+ applyRepositoryOverlay,
19
+ cloneRepositorySource,
20
+ type RepositoryFixture,
21
+ type RepositorySource,
22
+ } from "./repository";
23
+ import {
24
+ EvaluationConfigurationError,
25
+ type ResolvedCase,
26
+ } from "../evaluation/types";
27
+
28
+ export type FixtureSource<Repository = RepositorySource> =
29
+ | { kind: "inline"; files: ResolvedCase["fixture"]["files"] }
30
+ | { kind: "generated"; fixture: GeneratedFixture }
31
+ | { kind: "repository"; fixture: RepositoryFixture; repository: Repository };
32
+
33
+ interface FixtureOptions {
34
+ artifacts?: InlineArtifact[];
35
+ sources?: PreparationSources;
36
+ setup?: FixtureSetup | null;
37
+ projectRoot: string;
38
+ signal?: AbortSignal;
39
+ reservedWorkspace?: string;
40
+ candidateRuntimeRoot?: string;
41
+ }
42
+
43
+ export async function createFixture(
44
+ source: FixtureSource,
45
+ options: FixtureOptions,
46
+ ): Promise<string> {
47
+ const paths = inlineFixturePaths(source);
48
+ const artifacts = options.artifacts ?? [];
49
+ const workspace = options.reservedWorkspace ?? (await allocateWorkspace());
50
+ try {
51
+ await initializeFixtureHistory(source, options.sources, workspace);
52
+ await installDeclaredFixtureTools(source, workspace);
53
+ await writeInlineFixtureFiles(paths, workspace);
54
+ await setupFixture(
55
+ options.setup,
56
+ workspace,
57
+ options.projectRoot,
58
+ options.signal,
59
+ options.candidateRuntimeRoot,
60
+ );
61
+ await mountPreparationArtifacts(artifacts, workspace);
62
+ await excludePreparationArtifacts(artifacts, workspace);
63
+ await fixtureBinDirectory(workspace);
64
+ return workspace;
65
+ } catch (error) {
66
+ if (!options.reservedWorkspace) await removeWorkspace(workspace);
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function inlineFixturePaths(source: FixtureSource) {
72
+ return Object.entries(
73
+ source.kind === "inline" ? (source.files ?? {}) : {},
74
+ ).map(([path, content]) => ({ path, parts: fixtureParts(path), content }));
75
+ }
76
+
77
+ async function cloneFixtureRepository(
78
+ source: Extract<FixtureSource, { kind: "repository" }>,
79
+ sources: PreparationSources | undefined,
80
+ workspace: string,
81
+ ): Promise<void> {
82
+ if (!sources)
83
+ throw new EvaluationConfigurationError(
84
+ "repository source root is required and must be absolute",
85
+ );
86
+ await cloneRepositorySource(
87
+ source.fixture.sourceRef,
88
+ sources,
89
+ source.repository,
90
+ workspace,
91
+ );
92
+ }
93
+
94
+ async function initializeFixtureHistory(
95
+ source: FixtureSource,
96
+ sources: PreparationSources | undefined,
97
+ workspace: string,
98
+ ): Promise<void> {
99
+ switch (source.kind) {
100
+ case "repository":
101
+ await cloneFixtureRepository(source, sources, workspace);
102
+ await applyRepositoryOverlay(source.fixture, workspace);
103
+ break;
104
+ case "generated":
105
+ await materializeGeneratedFixture(source.fixture, workspace);
106
+ break;
107
+ case "inline":
108
+ break;
109
+ }
110
+ }
111
+
112
+ async function installDeclaredFixtureTools(
113
+ source: FixtureSource,
114
+ workspace: string,
115
+ ): Promise<void> {
116
+ const fixture = source.kind === "inline" ? undefined : source.fixture;
117
+ await installFixtureBin(fixture?.bin, workspace);
118
+ await installGitHooks(fixture?.hooks, workspace);
119
+ }
120
+
121
+ async function writeInlineFixtureFiles(
122
+ files: ReturnType<typeof inlineFixturePaths>,
123
+ workspace: string,
124
+ ): Promise<void> {
125
+ for (const file of files) {
126
+ const target = join(workspace, ...file.parts);
127
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
128
+ await writeFile(target, file.content, { flag: "wx", mode: 0o600 });
129
+ }
130
+ }
131
+
132
+ async function setupFixture(
133
+ setup: FixtureSetup | null | undefined,
134
+ workspace: string,
135
+ projectRoot: string,
136
+ signal?: AbortSignal,
137
+ candidateRuntimeRoot?: string,
138
+ ): Promise<void> {
139
+ if (!setup) return;
140
+ await runFixtureSetup(setup, {
141
+ workspace,
142
+ projectRoot,
143
+ fixtureBinDir: (await fixtureBinDirectory(workspace)) ?? undefined,
144
+ signal,
145
+ candidateRuntimeRoot,
146
+ });
147
+ }
148
+
149
+ async function mountPreparationArtifacts(
150
+ artifacts: InlineArtifact[],
151
+ workspace: string,
152
+ ): Promise<void> {
153
+ for (const artifact of artifacts) {
154
+ const target = await safePreparationTarget(
155
+ workspace,
156
+ artifact.relativePath,
157
+ );
158
+ await writeFile(target, artifact.bytes, {
159
+ flag: "wx",
160
+ mode: artifact.executable ? 0o700 : 0o600,
161
+ });
162
+ }
163
+ }
164
+
165
+ async function excludePreparationArtifacts(
166
+ artifacts: InlineArtifact[],
167
+ workspace: string,
168
+ ): Promise<void> {
169
+ const gitExcluded = artifacts.filter((artifact) => artifact.gitExclude);
170
+ if (!gitExcluded.length) return;
171
+ const special = new Set(["\\", "*", "?", "[", "]", "#", "!", " "]);
172
+ const patterns = gitExcluded.map((artifact) =>
173
+ Array.from(artifact.relativePath)
174
+ .map((character) =>
175
+ special.has(character) ? `\\${character}` : character,
176
+ )
177
+ .join(""),
178
+ );
179
+ await appendFile(
180
+ join(workspace, ".git", "info", "exclude"),
181
+ `\n${patterns.map((path) => `/${path}`).join("\n")}\n`,
182
+ );
183
+ }
@@ -1,9 +1,10 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
- import { canonicalJson } from "./identity";
4
- import { prepareFixtureBin } from "./fixture-bin";
3
+ import { canonicalJson } from "../identity";
4
+ import { prepareFixtureBin } from "./bin";
5
5
  import { prepareGitHooks } from "./git-hooks";
6
6
  import { fixtureParts } from "./preparation";
7
+ import { runGit } from "../git";
7
8
 
8
9
  const MAX_COMMITS = 128;
9
10
  const MAX_FILES = 1024;
@@ -197,60 +198,16 @@ export function prepareGeneratedFixture(value: unknown): GeneratedFixture {
197
198
  return JSON.parse(canonicalJson(fixture)) as GeneratedFixture;
198
199
  }
199
200
 
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
201
  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
202
  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();
203
+ await runGit({
204
+ cwd: workspace,
205
+ args,
206
+ identity: "fixture",
207
+ label: "generated fixture",
208
+ maxOutputBytes: MAX_GIT_OUTPUT,
209
+ });
247
210
  } 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
211
  throw new Error("generated fixture Git operation failed", { cause });
255
212
  }
256
213
  }
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { prepareFixtureTools, installFixtureTools } from "./fixture-tools";
2
+ import { prepareFixtureTools, installFixtureTools } from "./tools";
3
3
 
4
4
  /** Bound hook declarations and keep every name inside Git's hooks directory. */
5
5
  export function prepareGitHooks(value: unknown): Record<string, string> | null {
@@ -1,7 +1,9 @@
1
- import { isMissingFile } from "./fixture-tools";
2
- import { sha256 } from "./identity";
1
+ import { isMissingFile } from "./tools";
2
+ import { sha256 } from "../identity";
3
3
  import { lstat, mkdir, readFile, realpath, stat } from "node:fs/promises";
4
- import { isAbsolute, join, relative, sep } from "node:path";
4
+ import { isAbsolute, join } from "node:path";
5
+ import { insideRoot } from "../paths";
6
+ import { containsControl as containsControlCharacter } from "../value-guards";
5
7
  import { fileURLToPath } from "node:url";
6
8
 
7
9
  const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024;
@@ -114,12 +116,6 @@ function requireArtifactFlags(item: PreparationArtifact): void {
114
116
  if (item.executable !== undefined && typeof item.executable !== "boolean")
115
117
  throw new Error("preparation artifact executable must be boolean");
116
118
  }
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
119
  function requireInlineSource(
124
120
  item: PreparationArtifact,
125
121
  ): asserts item is PreparationArtifact & { contentBase64: string } {
@@ -220,13 +216,7 @@ async function preparationSourcePath(url: string): Promise<string> {
220
216
  }
221
217
  }
222
218
  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
- )
219
+ if (root === path || !insideRoot(root, path))
230
220
  throw new Error("preparation source escapes its declared root");
231
221
  }
232
222
  async function preparationSourceSize(path: string): Promise<number> {