@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.
- package/LICENSE +159 -0
- package/README.md +71 -0
- package/docs/advisory-fixture.md +23 -0
- package/docs/builtin-graders-v1.md +77 -0
- package/docs/cache-ownership-validation.md +49 -0
- package/docs/claude-continuation-validation.md +47 -0
- package/docs/codex-host.md +134 -0
- package/docs/concurrency-validation.md +86 -0
- package/docs/configuration-root-validation.md +70 -0
- package/docs/development-cli.md +327 -0
- package/docs/engine-slice.md +114 -0
- package/docs/extension-protocol-v1.md +292 -0
- package/docs/fixture-cleanup-validation.md +70 -0
- package/docs/identity-v1.md +65 -0
- package/docs/native-controls.md +29 -0
- package/docs/releases.md +88 -0
- package/docs/report-v1.md +32 -0
- package/docs/results-v1.md +118 -0
- package/examples/basic/graded.json +13 -0
- package/examples/basic/host.ts +8 -0
- package/examples/basic/prompt-only.json +7 -0
- package/package.json +45 -0
- package/schemas/cli-result-v1.schema.json +184 -0
- package/schemas/extension-v1.schema.json +659 -0
- package/schemas/report-v1.schema.json +164 -0
- package/schemas/run-evidence-v1.schema.json +510 -0
- package/src/advisory-fixture.ts +282 -0
- package/src/advisory.ts +105 -0
- package/src/cli.ts +837 -0
- package/src/engine.ts +2341 -0
- package/src/extension-client.ts +257 -0
- package/src/extension-session.ts +335 -0
- package/src/fixture-bin.ts +87 -0
- package/src/fixture-cleanup.ts +38 -0
- package/src/fixture-setup.ts +163 -0
- package/src/generated-fixture.ts +220 -0
- package/src/git-hooks.ts +63 -0
- package/src/graders/git-head.ts +176 -0
- package/src/graders/output.ts +359 -0
- package/src/graders/semantic.ts +120 -0
- package/src/graders/shell.ts +277 -0
- package/src/hosts/claude-continuation.ts +94 -0
- package/src/hosts/claude-credential.ts +73 -0
- package/src/hosts/claude-events.ts +128 -0
- package/src/hosts/claude-nested-skills.ts +218 -0
- package/src/hosts/claude-repository-invocation.ts +239 -0
- package/src/hosts/claude-settings.ts +50 -0
- package/src/hosts/claude-tool-calls.ts +159 -0
- package/src/hosts/claude.ts +511 -0
- package/src/hosts/codex-configuration.ts +85 -0
- package/src/hosts/codex-events.ts +102 -0
- package/src/hosts/codex-native-calls.ts +702 -0
- package/src/hosts/codex-profile.ts +78 -0
- package/src/hosts/codex-skill-reads.ts +345 -0
- package/src/hosts/codex.ts +769 -0
- package/src/hosts/isolation-roots.ts +60 -0
- package/src/hosts/mac-sandbox.ts +108 -0
- package/src/hosts/native-controls.ts +124 -0
- package/src/hosts/protected-worktrees.ts +56 -0
- package/src/hosts/workspace-fingerprint.ts +55 -0
- package/src/identity.ts +156 -0
- package/src/instrumentation.ts +106 -0
- package/src/preparation.ts +211 -0
- package/src/provenance.ts +244 -0
- package/src/report.ts +351 -0
- package/src/repository-fixture.ts +286 -0
- package/src/results.ts +160 -0
- package/src/run-owner.ts +276 -0
- package/src/schema.ts +28 -0
- package/src/storage.ts +23 -0
- package/src/trial-scheduler.ts +27 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export interface PreparationArtifact {
|
|
10
|
+
id: string;
|
|
11
|
+
relativePath: string;
|
|
12
|
+
sha256: string;
|
|
13
|
+
contentBase64?: string;
|
|
14
|
+
sourceRef?: string;
|
|
15
|
+
gitExclude?: boolean;
|
|
16
|
+
executable?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface InlineArtifact {
|
|
20
|
+
id: string;
|
|
21
|
+
relativePath: string;
|
|
22
|
+
sha256: string;
|
|
23
|
+
bytes: Uint8Array;
|
|
24
|
+
gitExclude?: boolean;
|
|
25
|
+
executable?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PreparationSources {
|
|
29
|
+
root: string;
|
|
30
|
+
refs: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function fixtureParts(relativePath: string): string[] {
|
|
34
|
+
if (
|
|
35
|
+
!relativePath ||
|
|
36
|
+
isAbsolute(relativePath) ||
|
|
37
|
+
relativePath.includes("\\") ||
|
|
38
|
+
/:/.test(relativePath)
|
|
39
|
+
)
|
|
40
|
+
throw new Error(`invalid fixture path: ${relativePath}`);
|
|
41
|
+
const parts = relativePath.split("/");
|
|
42
|
+
if (parts.some((part) => !part || part === "." || part === ".."))
|
|
43
|
+
throw new Error(`invalid fixture path: ${relativePath}`);
|
|
44
|
+
return parts;
|
|
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
|
+
try {
|
|
57
|
+
const entry = await lstat(parent);
|
|
58
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
59
|
+
throw new Error("preparation artifact traverses a non-directory");
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
62
|
+
throw error;
|
|
63
|
+
await mkdir(parent, { mode: 0o700 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const target = join(workspace, ...parts);
|
|
67
|
+
try {
|
|
68
|
+
await lstat(target);
|
|
69
|
+
throw new Error("preparation artifact path already exists");
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validatePaths(paths: string[]): void {
|
|
78
|
+
const normalized = paths.map((path) =>
|
|
79
|
+
fixtureParts(path).join("/").toLowerCase(),
|
|
80
|
+
);
|
|
81
|
+
for (let index = 0; index < normalized.length; index++) {
|
|
82
|
+
for (let other = 0; other < index; other++) {
|
|
83
|
+
if (
|
|
84
|
+
normalized[index] === normalized[other] ||
|
|
85
|
+
normalized[index]!.startsWith(`${normalized[other]}/`) ||
|
|
86
|
+
normalized[other]!.startsWith(`${normalized[index]}/`)
|
|
87
|
+
)
|
|
88
|
+
throw new Error("fixture paths collide");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Decode declared inline artifacts before creating a candidate workspace. */
|
|
94
|
+
export function prepareInlineArtifacts(
|
|
95
|
+
declarations: PreparationArtifact[],
|
|
96
|
+
fixturePaths: string[],
|
|
97
|
+
): InlineArtifact[] {
|
|
98
|
+
validatePaths([
|
|
99
|
+
...fixturePaths,
|
|
100
|
+
...declarations.map((item) => item.relativePath),
|
|
101
|
+
]);
|
|
102
|
+
const ids = new Set<string>();
|
|
103
|
+
let totalBytes = 0;
|
|
104
|
+
return declarations.map((item) => {
|
|
105
|
+
if (!item.id || ids.has(item.id))
|
|
106
|
+
throw new Error("preparation artifact IDs must be unique and nonempty");
|
|
107
|
+
ids.add(item.id);
|
|
108
|
+
if (item.gitExclude !== undefined && typeof item.gitExclude !== "boolean")
|
|
109
|
+
throw new Error("preparation artifact gitExclude must be boolean");
|
|
110
|
+
if (item.executable !== undefined && typeof item.executable !== "boolean")
|
|
111
|
+
throw new Error("preparation artifact executable must be boolean");
|
|
112
|
+
if (item.gitExclude && /[\u0000-\u001f\u007f]/.test(item.relativePath))
|
|
113
|
+
throw new Error("Git-excluded artifact path contains control characters");
|
|
114
|
+
if (item.sourceRef !== undefined || typeof item.contentBase64 !== "string")
|
|
115
|
+
throw new Error("preparation source references are not supported");
|
|
116
|
+
const encoded = item.contentBase64;
|
|
117
|
+
if (
|
|
118
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
|
|
119
|
+
encoded,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
throw new Error("preparation artifact content is not canonical base64");
|
|
123
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
124
|
+
if (bytes.toString("base64") !== encoded)
|
|
125
|
+
throw new Error("preparation artifact content is not canonical base64");
|
|
126
|
+
totalBytes += bytes.byteLength;
|
|
127
|
+
if (bytes.byteLength > MAX_ARTIFACT_BYTES || totalBytes > MAX_TOTAL_BYTES)
|
|
128
|
+
throw new Error("preparation artifacts exceed the size limit");
|
|
129
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
130
|
+
if (actual !== item.sha256)
|
|
131
|
+
throw new Error("preparation artifact digest does not match content");
|
|
132
|
+
return {
|
|
133
|
+
id: item.id,
|
|
134
|
+
relativePath: item.relativePath,
|
|
135
|
+
sha256: item.sha256,
|
|
136
|
+
bytes,
|
|
137
|
+
...(item.gitExclude === undefined ? {} : { gitExclude: item.gitExclude }),
|
|
138
|
+
...(item.executable === undefined ? {} : { executable: item.executable }),
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Resolve opaque source IDs only through an explicitly declared case source map. */
|
|
144
|
+
export async function prepareArtifacts(
|
|
145
|
+
declarations: PreparationArtifact[],
|
|
146
|
+
fixturePaths: string[],
|
|
147
|
+
sources?: PreparationSources,
|
|
148
|
+
): Promise<InlineArtifact[]> {
|
|
149
|
+
if (!declarations.some((item) => item.sourceRef !== undefined))
|
|
150
|
+
return prepareInlineArtifacts(declarations, fixturePaths);
|
|
151
|
+
if (!sources || !isAbsolute(sources.root))
|
|
152
|
+
throw new Error("preparation source root is required and must be absolute");
|
|
153
|
+
const root = await realpath(sources.root).catch(() => {
|
|
154
|
+
throw new Error("preparation source root is unreadable");
|
|
155
|
+
});
|
|
156
|
+
const resolved: PreparationArtifact[] = [];
|
|
157
|
+
let sourceBytes = 0;
|
|
158
|
+
for (const item of declarations) {
|
|
159
|
+
if (item.sourceRef === undefined) {
|
|
160
|
+
resolved.push(item);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (item.contentBase64 !== undefined)
|
|
164
|
+
throw new Error("preparation artifact has two content sources");
|
|
165
|
+
if (!Object.hasOwn(sources.refs, item.sourceRef))
|
|
166
|
+
throw new Error("preparation source reference is not declared");
|
|
167
|
+
const url = sources.refs[item.sourceRef];
|
|
168
|
+
if (typeof url !== "string" || !url.startsWith("file:///"))
|
|
169
|
+
throw new Error("preparation source reference must be a file URL");
|
|
170
|
+
let path: string;
|
|
171
|
+
try {
|
|
172
|
+
path = await realpath(fileURLToPath(url));
|
|
173
|
+
} catch {
|
|
174
|
+
throw new Error("preparation source is unreadable");
|
|
175
|
+
}
|
|
176
|
+
const child = relative(root, path);
|
|
177
|
+
if (
|
|
178
|
+
!child ||
|
|
179
|
+
child === ".." ||
|
|
180
|
+
child.startsWith(`..${sep}`) ||
|
|
181
|
+
isAbsolute(child)
|
|
182
|
+
)
|
|
183
|
+
throw new Error("preparation source escapes its declared root");
|
|
184
|
+
let size: number;
|
|
185
|
+
try {
|
|
186
|
+
size = (await stat(path)).size;
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error("preparation source is unreadable");
|
|
189
|
+
}
|
|
190
|
+
if (size > MAX_ARTIFACT_BYTES || sourceBytes + size > MAX_TOTAL_BYTES)
|
|
191
|
+
throw new Error("preparation artifacts exceed the size limit");
|
|
192
|
+
let bytes: Buffer;
|
|
193
|
+
try {
|
|
194
|
+
bytes = await readFile(path);
|
|
195
|
+
} catch {
|
|
196
|
+
throw new Error("preparation source is unreadable");
|
|
197
|
+
}
|
|
198
|
+
sourceBytes += bytes.byteLength;
|
|
199
|
+
if (bytes.byteLength > MAX_ARTIFACT_BYTES || sourceBytes > MAX_TOTAL_BYTES)
|
|
200
|
+
throw new Error("preparation artifacts exceed the size limit");
|
|
201
|
+
resolved.push({
|
|
202
|
+
id: item.id,
|
|
203
|
+
relativePath: item.relativePath,
|
|
204
|
+
sha256: item.sha256,
|
|
205
|
+
contentBase64: bytes.toString("base64"),
|
|
206
|
+
...(item.gitExclude === undefined ? {} : { gitExclude: item.gitExclude }),
|
|
207
|
+
...(item.executable === undefined ? {} : { executable: item.executable }),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return prepareInlineArtifacts(resolved, fixturePaths);
|
|
211
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, readdir, readlink, realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import packageJson from "../package.json";
|
|
6
|
+
import { hashJson } from "./identity";
|
|
7
|
+
|
|
8
|
+
const MAX_DIRTY_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const MAX_BUILD_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
const MAX_BUILD_FILES = 4096;
|
|
11
|
+
|
|
12
|
+
function sha256(bytes: Uint8Array): string {
|
|
13
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Digest the packaged runtime and public contract files in path order. */
|
|
17
|
+
export async function packageBuildDigest(
|
|
18
|
+
root = join(import.meta.dir, ".."),
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const canonicalRoot = await realpath(root);
|
|
21
|
+
const files: { path: string; sha256: string }[] = [];
|
|
22
|
+
let totalBytes = 0;
|
|
23
|
+
async function collect(path: string): Promise<void> {
|
|
24
|
+
const absolute = join(canonicalRoot, path);
|
|
25
|
+
const info = await lstat(absolute);
|
|
26
|
+
if (info.isSymbolicLink())
|
|
27
|
+
throw new Error("package build contains a symbolic link");
|
|
28
|
+
if (info.isDirectory()) {
|
|
29
|
+
for (const entry of (await readdir(absolute)).sort())
|
|
30
|
+
await collect(join(path, entry));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!info.isFile()) throw new Error("package build contains a non-file");
|
|
34
|
+
const bytes = await readFile(absolute);
|
|
35
|
+
totalBytes += bytes.byteLength;
|
|
36
|
+
if (files.length >= MAX_BUILD_FILES || totalBytes > MAX_BUILD_BYTES)
|
|
37
|
+
throw new Error("package build exceeds the size limit");
|
|
38
|
+
files.push({ path: path.split(sep).join("/"), sha256: sha256(bytes) });
|
|
39
|
+
}
|
|
40
|
+
for (const path of [
|
|
41
|
+
"package.json",
|
|
42
|
+
"README.md",
|
|
43
|
+
"docs",
|
|
44
|
+
"examples",
|
|
45
|
+
"schemas",
|
|
46
|
+
"src",
|
|
47
|
+
])
|
|
48
|
+
await collect(path);
|
|
49
|
+
files.sort((left, right) =>
|
|
50
|
+
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
|
51
|
+
);
|
|
52
|
+
return hashJson({ format: "sevro.build.v1", files });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function projectIdentityDigest(
|
|
56
|
+
root: string,
|
|
57
|
+
provenance: { revision: string | null; dirtyPatchDigest: string | null },
|
|
58
|
+
excludedRoots: string[] = [],
|
|
59
|
+
): Promise<string> {
|
|
60
|
+
if (provenance.revision)
|
|
61
|
+
return hashJson({
|
|
62
|
+
format: "sevro.project.v1",
|
|
63
|
+
revision: provenance.revision,
|
|
64
|
+
dirtyPatchDigest: provenance.dirtyPatchDigest,
|
|
65
|
+
});
|
|
66
|
+
const projectRoot = resolve(root);
|
|
67
|
+
const excluded = new Set(
|
|
68
|
+
excludedRoots.map((path) => relative(projectRoot, resolve(path))),
|
|
69
|
+
);
|
|
70
|
+
const files: { path: string; sha256: string; executable: boolean }[] = [];
|
|
71
|
+
let totalBytes = 0;
|
|
72
|
+
async function collect(path: string): Promise<void> {
|
|
73
|
+
if (path === ".git" || excluded.has(path)) return;
|
|
74
|
+
const absolute = join(projectRoot, path);
|
|
75
|
+
const info = await lstat(absolute);
|
|
76
|
+
if (info.isSymbolicLink())
|
|
77
|
+
throw new Error("project snapshot contains a symbolic link");
|
|
78
|
+
if (info.isDirectory()) {
|
|
79
|
+
for (const entry of (await readdir(absolute)).sort())
|
|
80
|
+
await collect(path === "" ? entry : join(path, entry));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!info.isFile()) throw new Error("project snapshot contains a non-file");
|
|
84
|
+
const bytes = await readFile(absolute);
|
|
85
|
+
totalBytes += bytes.byteLength;
|
|
86
|
+
if (files.length >= MAX_BUILD_FILES || totalBytes > MAX_BUILD_BYTES)
|
|
87
|
+
throw new Error("project snapshot exceeds the size limit");
|
|
88
|
+
files.push({
|
|
89
|
+
path: path.split(sep).join("/"),
|
|
90
|
+
sha256: sha256(bytes),
|
|
91
|
+
executable: Boolean(info.mode & 0o111),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
await collect("");
|
|
95
|
+
files.sort((left, right) =>
|
|
96
|
+
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
|
97
|
+
);
|
|
98
|
+
return hashJson({ format: "sevro.project.v1", files });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function git(root: string, ...arguments_: string[]): Buffer {
|
|
102
|
+
const result = Bun.spawnSync(["git", "-C", root, ...arguments_], {
|
|
103
|
+
env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LC_ALL: "C" },
|
|
104
|
+
stdout: "pipe",
|
|
105
|
+
stderr: "ignore",
|
|
106
|
+
});
|
|
107
|
+
if (result.exitCode !== 0) throw new Error("Git identity is unavailable");
|
|
108
|
+
if (result.stdout.byteLength > MAX_DIRTY_BYTES)
|
|
109
|
+
throw new Error("Git identity exceeds the size limit");
|
|
110
|
+
return Buffer.from(result.stdout);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function dirtyPatchDigest(gitRoot: string): Promise<string | null> {
|
|
114
|
+
const trackedPatch = git(gitRoot, "diff", "--binary", "HEAD", "--");
|
|
115
|
+
const untrackedPaths = git(
|
|
116
|
+
gitRoot,
|
|
117
|
+
"ls-files",
|
|
118
|
+
"--others",
|
|
119
|
+
"--exclude-standard",
|
|
120
|
+
"-z",
|
|
121
|
+
)
|
|
122
|
+
.toString("utf8")
|
|
123
|
+
.split("\0")
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.sort();
|
|
126
|
+
let dirtyBytes = trackedPatch.byteLength;
|
|
127
|
+
const untracked: { path: string; sha256: string }[] = [];
|
|
128
|
+
for (const path of untrackedPaths) {
|
|
129
|
+
const absolute = resolve(gitRoot, path);
|
|
130
|
+
const relativePath = relative(gitRoot, absolute);
|
|
131
|
+
if (
|
|
132
|
+
isAbsolute(relativePath) ||
|
|
133
|
+
relativePath === ".." ||
|
|
134
|
+
relativePath.startsWith(`..${sep}`)
|
|
135
|
+
)
|
|
136
|
+
throw new Error("Git checkout has an unsafe untracked path");
|
|
137
|
+
const info = await lstat(absolute);
|
|
138
|
+
const bytes = info.isSymbolicLink()
|
|
139
|
+
? Buffer.from(await readlink(absolute), "utf8")
|
|
140
|
+
: info.isFile()
|
|
141
|
+
? await readFile(absolute)
|
|
142
|
+
: null;
|
|
143
|
+
if (!bytes)
|
|
144
|
+
throw new Error("Git checkout has an unsupported untracked entry");
|
|
145
|
+
dirtyBytes += bytes.byteLength;
|
|
146
|
+
if (dirtyBytes > MAX_DIRTY_BYTES)
|
|
147
|
+
throw new Error("Git identity exceeds the size limit");
|
|
148
|
+
untracked.push({ path, sha256: sha256(bytes) });
|
|
149
|
+
}
|
|
150
|
+
return trackedPatch.byteLength || untracked.length
|
|
151
|
+
? hashJson({ trackedPatch: sha256(trackedPatch), untracked })
|
|
152
|
+
: null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Local development identity; packaged use never calls Git. */
|
|
156
|
+
export async function checkoutProvenance(root: string, buildDigest: string) {
|
|
157
|
+
const canonicalRoot = await realpath(root);
|
|
158
|
+
const gitRoot = await realpath(
|
|
159
|
+
git(canonicalRoot, "rev-parse", "--show-toplevel").toString().trim(),
|
|
160
|
+
);
|
|
161
|
+
if (gitRoot !== canonicalRoot)
|
|
162
|
+
throw new Error(
|
|
163
|
+
"local Sevro checkout root does not match its Git repository",
|
|
164
|
+
);
|
|
165
|
+
const revision = git(canonicalRoot, "rev-parse", "HEAD").toString().trim();
|
|
166
|
+
if (!/^[a-f0-9]{40,64}$/.test(revision))
|
|
167
|
+
throw new Error("local Sevro checkout revision is invalid");
|
|
168
|
+
return {
|
|
169
|
+
source: "checkout" as const,
|
|
170
|
+
root: pathToFileURL(canonicalRoot).href,
|
|
171
|
+
revision,
|
|
172
|
+
dirtyPatchDigest: await dirtyPatchDigest(canonicalRoot),
|
|
173
|
+
buildDigest,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function enclosingGitRoot(root: string): Promise<string | null> {
|
|
178
|
+
let path = root;
|
|
179
|
+
while (true) {
|
|
180
|
+
try {
|
|
181
|
+
await lstat(join(path, ".git"));
|
|
182
|
+
return path;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
185
|
+
}
|
|
186
|
+
const parent = dirname(path);
|
|
187
|
+
if (parent === path) return null;
|
|
188
|
+
path = parent;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function projectProvenance(root: string) {
|
|
193
|
+
const canonicalRoot = await realpath(root);
|
|
194
|
+
const gitEntryRoot = await enclosingGitRoot(canonicalRoot);
|
|
195
|
+
if (!gitEntryRoot)
|
|
196
|
+
return {
|
|
197
|
+
root: pathToFileURL(canonicalRoot).href,
|
|
198
|
+
revision: null,
|
|
199
|
+
dirtyPatchDigest: null,
|
|
200
|
+
};
|
|
201
|
+
const gitRoot = await realpath(
|
|
202
|
+
git(canonicalRoot, "rev-parse", "--show-toplevel").toString().trim(),
|
|
203
|
+
);
|
|
204
|
+
if (gitRoot !== gitEntryRoot)
|
|
205
|
+
throw new Error("project Git root does not match its repository entry");
|
|
206
|
+
let revision: string;
|
|
207
|
+
try {
|
|
208
|
+
revision = git(gitRoot, "rev-parse", "HEAD").toString().trim();
|
|
209
|
+
} catch {
|
|
210
|
+
git(gitRoot, "status", "--porcelain");
|
|
211
|
+
return {
|
|
212
|
+
root: pathToFileURL(canonicalRoot).href,
|
|
213
|
+
revision: null,
|
|
214
|
+
dirtyPatchDigest: null,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (!/^[a-f0-9]{40,64}$/.test(revision))
|
|
218
|
+
throw new Error("project Git revision is invalid");
|
|
219
|
+
return {
|
|
220
|
+
root: pathToFileURL(canonicalRoot).href,
|
|
221
|
+
revision,
|
|
222
|
+
dirtyPatchDigest: await dirtyPatchDigest(gitRoot),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function runnerProvenance(
|
|
227
|
+
buildDigest: string,
|
|
228
|
+
checkoutRoot?: string,
|
|
229
|
+
) {
|
|
230
|
+
if (checkoutRoot) {
|
|
231
|
+
const actualRoot = await realpath(join(import.meta.dir, ".."));
|
|
232
|
+
if ((await realpath(checkoutRoot)) !== actualRoot)
|
|
233
|
+
throw new Error(
|
|
234
|
+
"local Sevro checkout root does not match the running package",
|
|
235
|
+
);
|
|
236
|
+
return checkoutProvenance(actualRoot, buildDigest);
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
source: "package" as const,
|
|
240
|
+
packageName: packageJson.name,
|
|
241
|
+
version: packageJson.version,
|
|
242
|
+
buildDigest,
|
|
243
|
+
};
|
|
244
|
+
}
|