@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
@@ -0,0 +1,38 @@
1
+ import { chmod, lstat, readdir, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ function permissionFailure(error: unknown): boolean {
5
+ const code = (error as NodeJS.ErrnoException).code;
6
+ return code === "EACCES" || code === "EPERM";
7
+ }
8
+
9
+ async function restoreDirectoryAccess(path: string): Promise<void> {
10
+ const entry = await lstat(path);
11
+ if (!entry.isDirectory()) return;
12
+ await chmod(path, (entry.mode & 0o7777) | 0o700);
13
+ for (const child of await readdir(path, { withFileTypes: true }))
14
+ if (child.isDirectory())
15
+ await restoreDirectoryAccess(join(path, child.name));
16
+ }
17
+
18
+ /** Clear a completed fixture while preserving its reserved root for active peers. */
19
+ export async function clearFixtureContents(workspace: string): Promise<void> {
20
+ const root = await lstat(workspace);
21
+ if (!root.isDirectory())
22
+ throw new Error("fixture workspace is not a directory");
23
+ if ((root.mode & 0o700) !== 0o700)
24
+ await chmod(workspace, (root.mode & 0o7777) | 0o700);
25
+ const names = await readdir(workspace);
26
+ await Promise.all(
27
+ names.map(async (name) => {
28
+ 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
+ }
36
+ }),
37
+ );
38
+ }
@@ -0,0 +1,163 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { delimiter, isAbsolute, join } from "node:path";
4
+
5
+ const MAX_COMMAND_BYTES = 64 * 1024;
6
+ const MAX_ENV_BYTES = 16 * 1024;
7
+ const TIMEOUT_MS = 120_000;
8
+ const RESERVED_ENV = new Set([
9
+ "PATH",
10
+ "HOME",
11
+ "TMPDIR",
12
+ "GIT_CONFIG_GLOBAL",
13
+ "GIT_CONFIG_NOSYSTEM",
14
+ "GIT_TERMINAL_PROMPT",
15
+ ]);
16
+
17
+ export interface FixtureSetupDeclaration {
18
+ command: string[];
19
+ environment?: Record<string, string>;
20
+ }
21
+
22
+ export interface FixtureSetup extends FixtureSetupDeclaration {
23
+ environment: Record<string, string>;
24
+ }
25
+
26
+ /** Validate a trusted extension's bounded fixture setup command. */
27
+ export function prepareFixtureSetup(value: unknown): FixtureSetup | null {
28
+ if (value === undefined) return null;
29
+ if (!value || typeof value !== "object" || Array.isArray(value))
30
+ throw new Error("invalid fixture setup");
31
+ const input = value as Record<string, unknown>;
32
+ if (
33
+ Object.keys(input).some((key) => key !== "command" && key !== "environment")
34
+ )
35
+ throw new Error("unsupported fixture setup field");
36
+ const command = input.command;
37
+ if (
38
+ !Array.isArray(command) ||
39
+ !command.length ||
40
+ command.length > 16 ||
41
+ command.some(
42
+ (arg) => typeof arg !== "string" || !arg.length || arg.includes("\0"),
43
+ ) ||
44
+ !isAbsolute(command[0]) ||
45
+ Buffer.byteLength(command.join("\0"), "utf8") > MAX_COMMAND_BYTES
46
+ )
47
+ throw new Error("invalid fixture setup command");
48
+ const environment = input.environment ?? {};
49
+ if (
50
+ !environment ||
51
+ typeof environment !== "object" ||
52
+ Array.isArray(environment)
53
+ )
54
+ throw new Error("invalid fixture setup environment");
55
+ const entries = Object.entries(environment);
56
+ if (
57
+ entries.length > 32 ||
58
+ entries.some(
59
+ ([name, value]) =>
60
+ !/^[A-Z][A-Z0-9_]*$/.test(name) ||
61
+ RESERVED_ENV.has(name) ||
62
+ typeof value !== "string" ||
63
+ value.includes("\0") ||
64
+ /\{\{(?!sevro\.(?:project|workspace)\}\})/.test(value),
65
+ ) ||
66
+ Buffer.byteLength(JSON.stringify(environment), "utf8") > MAX_ENV_BYTES
67
+ )
68
+ throw new Error("invalid fixture setup environment");
69
+ return { command, environment: environment as Record<string, string> };
70
+ }
71
+
72
+ function stopProcess(proc: Bun.Subprocess): void {
73
+ if (process.platform !== "win32") {
74
+ try {
75
+ process.kill(-proc.pid, "SIGKILL");
76
+ return;
77
+ } catch {
78
+ // The process group may already be gone.
79
+ }
80
+ }
81
+ try {
82
+ proc.kill("SIGKILL");
83
+ } catch {
84
+ // The process already exited.
85
+ }
86
+ }
87
+
88
+ /** Run setup after fixture history and before evaluator artifacts are mounted. */
89
+ export async function runFixtureSetup(
90
+ setup: FixtureSetup,
91
+ options: {
92
+ workspace: string;
93
+ projectRoot: string;
94
+ fixtureBinDir?: string;
95
+ signal?: AbortSignal;
96
+ },
97
+ ): Promise<void> {
98
+ if (options.signal?.aborted) throw new Error("fixture setup cancelled");
99
+ const home = await mkdtemp(join(tmpdir(), "sevro-setup-"));
100
+ let proc: Bun.Subprocess | undefined;
101
+ let timer: ReturnType<typeof setTimeout> | undefined;
102
+ let cancel: (() => void) | undefined;
103
+ try {
104
+ const environment = Object.fromEntries(
105
+ Object.entries(setup.environment).map(([name, value]) => [
106
+ name,
107
+ value
108
+ .replaceAll("{{sevro.project}}", options.projectRoot)
109
+ .replaceAll("{{sevro.workspace}}", options.workspace),
110
+ ]),
111
+ );
112
+ proc = Bun.spawn(setup.command, {
113
+ cwd: options.workspace,
114
+ env: {
115
+ PATH: options.fixtureBinDir
116
+ ? `${options.fixtureBinDir}${delimiter}${process.env.PATH ?? "/usr/bin:/bin"}`
117
+ : (process.env.PATH ?? "/usr/bin:/bin"),
118
+ HOME: home,
119
+ TMPDIR: home,
120
+ LANG: process.env.LANG ?? "C",
121
+ GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
122
+ GIT_CONFIG_NOSYSTEM: "1",
123
+ GIT_TERMINAL_PROMPT: "0",
124
+ GIT_AUTHOR_NAME: "Sevro Fixture",
125
+ GIT_AUTHOR_EMAIL: "fixture@sevro.invalid",
126
+ GIT_COMMITTER_NAME: "Sevro Fixture",
127
+ GIT_COMMITTER_EMAIL: "fixture@sevro.invalid",
128
+ GIT_AUTHOR_DATE: "2000-01-01T00:00:00+00:00",
129
+ GIT_COMMITTER_DATE: "2000-01-01T00:00:00+00:00",
130
+ ...environment,
131
+ },
132
+ detached: process.platform !== "win32",
133
+ stdin: "ignore",
134
+ stdout: "ignore",
135
+ stderr: "ignore",
136
+ });
137
+ const running = proc;
138
+ const timeout = new Promise<never>((_resolve, reject) => {
139
+ timer = setTimeout(
140
+ () => reject(new Error("fixture setup timed out")),
141
+ TIMEOUT_MS,
142
+ );
143
+ });
144
+ const aborted = new Promise<never>((_resolve, reject) => {
145
+ if (!options.signal) return;
146
+ cancel = () => reject(new Error("fixture setup cancelled"));
147
+ if (options.signal.aborted) cancel();
148
+ else options.signal.addEventListener("abort", cancel, { once: true });
149
+ });
150
+ const code = await Promise.race([running.exited, timeout, aborted]);
151
+ if (code !== 0) throw new Error(`fixture setup failed (${code})`);
152
+ } catch (error) {
153
+ if (proc) {
154
+ stopProcess(proc);
155
+ await proc.exited;
156
+ }
157
+ throw error;
158
+ } finally {
159
+ if (timer) clearTimeout(timer);
160
+ if (cancel) options.signal?.removeEventListener("abort", cancel);
161
+ await rm(home, { recursive: true, force: true });
162
+ }
163
+ }
@@ -0,0 +1,220 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { canonicalJson } from "./identity";
4
+ import { prepareFixtureBin } from "./fixture-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
+ /** Freeze a bounded declarative history before any host or fixture work. */
37
+ export function prepareGeneratedFixture(value: unknown): GeneratedFixture {
38
+ if (!value || typeof value !== "object" || Array.isArray(value))
39
+ throw new Error("invalid generated fixture");
40
+ const fixture = value as Record<string, unknown>;
41
+ if (
42
+ fixture.kind !== "generated" ||
43
+ Object.keys(fixture).some(
44
+ (key) =>
45
+ ![
46
+ "kind",
47
+ "commits",
48
+ "files",
49
+ "staged",
50
+ "commitFiles",
51
+ "hooks",
52
+ "bin",
53
+ ].includes(key),
54
+ ) ||
55
+ !Array.isArray(fixture.commits) ||
56
+ fixture.commits.length > MAX_COMMITS ||
57
+ (fixture.files !== undefined && !validFiles(fixture.files)) ||
58
+ (fixture.staged !== undefined &&
59
+ (!Array.isArray(fixture.staged) ||
60
+ !fixture.staged.every((item: unknown) => typeof item === "string"))) ||
61
+ (fixture.commitFiles !== undefined &&
62
+ typeof fixture.commitFiles !== "boolean")
63
+ )
64
+ throw new Error("invalid generated fixture");
65
+ prepareGitHooks(fixture.hooks);
66
+ prepareFixtureBin(fixture.bin);
67
+ let bytes = 0;
68
+ let files = 0;
69
+ const paths: string[] = [];
70
+ for (const commit of fixture.commits) {
71
+ if (
72
+ !commit ||
73
+ typeof commit !== "object" ||
74
+ Array.isArray(commit) ||
75
+ Object.keys(commit).some((key) => key !== "message" && key !== "files") ||
76
+ typeof commit.message !== "string" ||
77
+ !commit.message.trim() ||
78
+ Buffer.byteLength(commit.message, "utf8") > 4096 ||
79
+ !validFiles(commit.files) ||
80
+ Object.keys(commit.files).length === 0
81
+ )
82
+ throw new Error("invalid generated fixture commit");
83
+ bytes += Buffer.byteLength(commit.message, "utf8");
84
+ for (const [path, content] of Object.entries(
85
+ commit.files as Record<string, string>,
86
+ )) {
87
+ validPath(path);
88
+ paths.push(path);
89
+ files++;
90
+ bytes +=
91
+ Buffer.byteLength(path, "utf8") + Buffer.byteLength(content, "utf8");
92
+ }
93
+ }
94
+ const overlay = (fixture.files ?? {}) as Record<string, string>;
95
+ for (const [path, content] of Object.entries(overlay)) {
96
+ validPath(path);
97
+ paths.push(path);
98
+ files++;
99
+ bytes +=
100
+ Buffer.byteLength(path, "utf8") + Buffer.byteLength(content, "utf8");
101
+ }
102
+ bytes += ((fixture.staged ?? []) as string[]).reduce(
103
+ (size, path) => size + Buffer.byteLength(path, "utf8"),
104
+ 0,
105
+ );
106
+ if (files > MAX_FILES || bytes > MAX_BYTES)
107
+ throw new Error("generated fixture exceeds the size limit");
108
+ const unique = [...new Set(paths)];
109
+ const normalized = unique.map((path) => path.toLowerCase());
110
+ for (let index = 0; index < normalized.length; index++) {
111
+ for (let other = 0; other < index; other++) {
112
+ if (
113
+ normalized[index] === normalized[other] ||
114
+ normalized[index]!.startsWith(`${normalized[other]}/`) ||
115
+ normalized[other]!.startsWith(`${normalized[index]}/`)
116
+ )
117
+ throw new Error("generated fixture paths collide");
118
+ }
119
+ }
120
+ const staged = (fixture.staged ?? []) as string[];
121
+ if (
122
+ new Set(staged.map((path) => path.toLowerCase())).size !== staged.length ||
123
+ staged.some((path) => !Object.hasOwn(overlay, path)) ||
124
+ (fixture.commitFiles === true && Object.keys(overlay).length === 0)
125
+ )
126
+ throw new Error("invalid generated fixture staging");
127
+ return JSON.parse(canonicalJson(fixture)) as GeneratedFixture;
128
+ }
129
+
130
+ async function git(workspace: string, args: string[]): Promise<void> {
131
+ async function bounded(stream: ReadableStream<Uint8Array>): Promise<void> {
132
+ let size = 0;
133
+ for await (const chunk of stream) {
134
+ size += chunk.byteLength;
135
+ if (size > MAX_GIT_OUTPUT)
136
+ throw new Error("generated fixture Git output is too large");
137
+ }
138
+ }
139
+ const proc = Bun.spawn(
140
+ [
141
+ "git",
142
+ "-c",
143
+ `core.hooksPath=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
144
+ ...args,
145
+ ],
146
+ {
147
+ cwd: workspace,
148
+ stdout: "pipe",
149
+ stderr: "pipe",
150
+ env: {
151
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
152
+ ...(process.env.SystemRoot
153
+ ? { SystemRoot: process.env.SystemRoot }
154
+ : {}),
155
+ GIT_CONFIG_NOSYSTEM: "1",
156
+ GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
157
+ GIT_TERMINAL_PROMPT: "0",
158
+ GIT_AUTHOR_NAME: "Sevro Fixture",
159
+ GIT_AUTHOR_EMAIL: "fixture@sevro.invalid",
160
+ GIT_COMMITTER_NAME: "Sevro Fixture",
161
+ GIT_COMMITTER_EMAIL: "fixture@sevro.invalid",
162
+ GIT_AUTHOR_DATE: "2000-01-01T00:00:00+00:00",
163
+ GIT_COMMITTER_DATE: "2000-01-01T00:00:00+00:00",
164
+ },
165
+ },
166
+ );
167
+ try {
168
+ const [, , code] = await Promise.all([
169
+ bounded(proc.stdout),
170
+ bounded(proc.stderr),
171
+ proc.exited,
172
+ ]);
173
+ if (code !== 0) throw new Error();
174
+ } catch {
175
+ try {
176
+ proc.kill("SIGKILL");
177
+ } catch {
178
+ // The process may have exited before the failure was observed.
179
+ }
180
+ await proc.exited;
181
+ throw new Error("generated fixture Git operation failed");
182
+ }
183
+ }
184
+
185
+ async function writeFiles(
186
+ workspace: string,
187
+ files: Record<string, string>,
188
+ ): Promise<void> {
189
+ for (const [path, content] of Object.entries(files)) {
190
+ const target = join(workspace, ...fixtureParts(path));
191
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
192
+ await writeFile(target, content, { flag: "w", mode: 0o600 });
193
+ }
194
+ }
195
+
196
+ /** Build a deterministic local Git history, then apply working-tree state. */
197
+ export async function materializeGeneratedFixture(
198
+ fixture: GeneratedFixture,
199
+ workspace: string,
200
+ ): Promise<void> {
201
+ await git(workspace, ["init", "--quiet", "--initial-branch=main"]);
202
+ for (const commit of fixture.commits) {
203
+ await writeFiles(workspace, commit.files);
204
+ await git(workspace, ["add", "--", ...Object.keys(commit.files)]);
205
+ await git(workspace, ["commit", "--quiet", "-m", commit.message]);
206
+ }
207
+ const overlay = fixture.files ?? {};
208
+ await writeFiles(workspace, overlay);
209
+ if (fixture.commitFiles) {
210
+ await git(workspace, ["add", "--", ...Object.keys(overlay)]);
211
+ await git(workspace, [
212
+ "commit",
213
+ "--quiet",
214
+ "-m",
215
+ "Add evaluation scaffolding",
216
+ ]);
217
+ }
218
+ if (fixture.staged?.length)
219
+ await git(workspace, ["add", "--", ...fixture.staged]);
220
+ }
@@ -0,0 +1,63 @@
1
+ import { chmod, lstat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const MAX_HOOKS = 32;
5
+ const MAX_HOOK_BYTES = 1024 * 1024;
6
+
7
+ /** Bound hook declarations and keep every name inside Git's hooks directory. */
8
+ export function prepareGitHooks(value: unknown): Record<string, string> | null {
9
+ if (value === undefined) return null;
10
+ if (!value || typeof value !== "object" || Array.isArray(value))
11
+ throw new Error("invalid fixture hooks");
12
+ const hooks = value as Record<string, unknown>;
13
+ const entries = Object.entries(hooks);
14
+ if (
15
+ entries.length > MAX_HOOKS ||
16
+ entries.some(
17
+ ([name, content]) =>
18
+ !/^[a-z][a-z0-9-]*$/.test(name) ||
19
+ name.length > 64 ||
20
+ typeof content !== "string" ||
21
+ !content ||
22
+ content.includes("\0"),
23
+ ) ||
24
+ entries.reduce(
25
+ (size, [name, content]) =>
26
+ size +
27
+ Buffer.byteLength(name, "utf8") +
28
+ Buffer.byteLength(content as string, "utf8"),
29
+ 0,
30
+ ) > MAX_HOOK_BYTES
31
+ )
32
+ throw new Error("invalid fixture hooks");
33
+ return hooks as Record<string, string>;
34
+ }
35
+
36
+ /** Install executable hooks only after fixture commits and overlays are ready. */
37
+ export async function installGitHooks(
38
+ hooks: Record<string, string> | undefined,
39
+ workspace: string,
40
+ ): Promise<void> {
41
+ if (!hooks) return;
42
+ const directory = join(workspace, ".git", "hooks");
43
+ const root = await lstat(directory);
44
+ if (!root.isDirectory() || root.isSymbolicLink())
45
+ throw new Error("fixture Git hooks directory is invalid");
46
+ for (const [name, content] of Object.entries(hooks)) {
47
+ const target = join(directory, name);
48
+ try {
49
+ const existing = await lstat(target);
50
+ if (!existing.isFile() || existing.isSymbolicLink())
51
+ throw new Error("fixture Git hook target is invalid");
52
+ } catch (error) {
53
+ if (!(
54
+ error instanceof Error &&
55
+ "code" in error &&
56
+ error.code === "ENOENT"
57
+ ))
58
+ throw error;
59
+ }
60
+ await writeFile(target, content, { mode: 0o755 });
61
+ await chmod(target, 0o755);
62
+ }
63
+ }
@@ -0,0 +1,176 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const MAX_OUTPUT_BYTES = 128;
5
+ const TIMEOUT_MS = 10_000;
6
+ const REVISION = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
7
+
8
+ export interface GitHeadCheckDeclaration {
9
+ id: string;
10
+ grader: "sevro.git-head";
11
+ configuration: Record<string, unknown>;
12
+ }
13
+
14
+ export interface PreparedGitHeadCheck {
15
+ id: string;
16
+ kind: "changed" | "unchanged" | "base-ancestor";
17
+ }
18
+
19
+ export function prepareGitHeadChecks(
20
+ declarations: GitHeadCheckDeclaration[],
21
+ ): PreparedGitHeadCheck[] {
22
+ return declarations.map(({ id, configuration }) => {
23
+ if (
24
+ !id ||
25
+ !configuration ||
26
+ typeof configuration !== "object" ||
27
+ Object.keys(configuration).some((key) => key !== "kind") ||
28
+ (configuration.kind !== "changed" &&
29
+ configuration.kind !== "unchanged" &&
30
+ configuration.kind !== "base-ancestor")
31
+ )
32
+ throw new Error("invalid Git HEAD check declaration");
33
+ return { id, kind: configuration.kind };
34
+ });
35
+ }
36
+
37
+ async function boundedOutput(
38
+ stream: ReadableStream<Uint8Array>,
39
+ ): Promise<string> {
40
+ const reader = stream.getReader();
41
+ const chunks: Uint8Array[] = [];
42
+ let size = 0;
43
+ while (true) {
44
+ const { value, done } = await reader.read();
45
+ if (done) break;
46
+ size += value.byteLength;
47
+ if (size > MAX_OUTPUT_BYTES)
48
+ throw new Error("Git HEAD output is oversized");
49
+ chunks.push(value);
50
+ }
51
+ return new TextDecoder("utf-8", { fatal: true }).decode(
52
+ Buffer.concat(chunks),
53
+ );
54
+ }
55
+
56
+ function stopProcess(proc: Bun.Subprocess): void {
57
+ if (process.platform !== "win32") {
58
+ try {
59
+ process.kill(-proc.pid, "SIGKILL");
60
+ return;
61
+ } catch {
62
+ // The process group may already have exited.
63
+ }
64
+ }
65
+ try {
66
+ proc.kill("SIGKILL");
67
+ } catch {
68
+ // The process already exited.
69
+ }
70
+ }
71
+
72
+ async function git(
73
+ workspace: string,
74
+ args: string[],
75
+ signal?: AbortSignal,
76
+ ): Promise<{ code: number; output: string }> {
77
+ if (signal?.aborted) throw new Error("Git HEAD check cancelled");
78
+ const gitDirectory = await lstat(join(workspace, ".git"));
79
+ if (!gitDirectory.isDirectory() || gitDirectory.isSymbolicLink())
80
+ throw new Error("Git fixture metadata is not a directory");
81
+ const proc = Bun.spawn(["git", ...args], {
82
+ cwd: workspace,
83
+ stdin: "ignore",
84
+ stdout: "pipe",
85
+ stderr: "ignore",
86
+ detached: process.platform !== "win32",
87
+ env: {
88
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
89
+ ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
90
+ GIT_CONFIG_NOSYSTEM: "1",
91
+ GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
92
+ GIT_NO_REPLACE_OBJECTS: "1",
93
+ GIT_TERMINAL_PROMPT: "0",
94
+ },
95
+ });
96
+ let timer: ReturnType<typeof setTimeout> | undefined;
97
+ let cancel: (() => void) | undefined;
98
+ try {
99
+ const complete = Promise.all([
100
+ boundedOutput(proc.stdout),
101
+ proc.exited,
102
+ ]).then(([output, code]) => ({ output: output.trim(), code }));
103
+ const timeout = new Promise<never>((_resolve, reject) => {
104
+ timer = setTimeout(
105
+ () => reject(new Error("Git HEAD check timed out")),
106
+ TIMEOUT_MS,
107
+ );
108
+ });
109
+ const aborted = new Promise<never>((_resolve, reject) => {
110
+ if (!signal) return;
111
+ cancel = () => reject(new Error("Git HEAD check cancelled"));
112
+ signal.addEventListener("abort", cancel, { once: true });
113
+ if (signal.aborted) cancel();
114
+ });
115
+ return await Promise.race([complete, timeout, aborted]);
116
+ } catch (error) {
117
+ stopProcess(proc);
118
+ await proc.exited;
119
+ throw error;
120
+ } finally {
121
+ if (timer) clearTimeout(timer);
122
+ if (cancel) signal?.removeEventListener("abort", cancel);
123
+ }
124
+ }
125
+
126
+ export async function gitHeadRevision(
127
+ workspace: string,
128
+ signal?: AbortSignal,
129
+ ): Promise<string> {
130
+ const result = await git(
131
+ workspace,
132
+ ["rev-parse", "--verify", "HEAD^{commit}"],
133
+ signal,
134
+ );
135
+ if (result.code !== 0 || !REVISION.test(result.output))
136
+ throw new Error("Git HEAD revision is unavailable");
137
+ return result.output;
138
+ }
139
+
140
+ export async function gitHeadState(
141
+ workspace: string,
142
+ baseRevision: string,
143
+ signal?: AbortSignal,
144
+ ): Promise<{ currentRevision: string; baseAncestor: boolean }> {
145
+ if (!REVISION.test(baseRevision)) throw new Error("invalid base revision");
146
+ const currentRevision = await gitHeadRevision(workspace, signal);
147
+ const ancestry = await git(
148
+ workspace,
149
+ ["merge-base", "--is-ancestor", baseRevision, currentRevision],
150
+ signal,
151
+ );
152
+ if (ancestry.code !== 0 && ancestry.code !== 1)
153
+ throw new Error("Git HEAD ancestry is unavailable");
154
+ return { currentRevision, baseAncestor: ancestry.code === 0 };
155
+ }
156
+
157
+ export function assessGitHeadCheck(
158
+ check: PreparedGitHeadCheck,
159
+ baseRevision: string,
160
+ state: { currentRevision: string; baseAncestor: boolean },
161
+ ): { passed: boolean; detail: string } {
162
+ if (check.kind === "unchanged")
163
+ return {
164
+ passed: state.currentRevision === baseRevision,
165
+ detail: "candidate created or switched to a different commit",
166
+ };
167
+ if (check.kind === "changed")
168
+ return {
169
+ passed: state.currentRevision !== baseRevision,
170
+ detail: "candidate did not create the expected commit",
171
+ };
172
+ return {
173
+ passed: state.baseAncestor,
174
+ detail: "candidate replaced or diverged from the fixture history",
175
+ };
176
+ }