assertledger 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.
- package/CONTRIBUTING.md +31 -0
- package/LICENSE +21 -0
- package/README.fr.md +236 -0
- package/README.md +224 -0
- package/SECURITY.md +51 -0
- package/benchmarks/agentic-profile/README.md +15 -0
- package/benchmarks/agentic-profile/public/README.md +5 -0
- package/benchmarks/self-hosted-core/README.md +113 -0
- package/benchmarks/self-hosted-core/adapter.mjs +293 -0
- package/benchmarks/self-hosted-core/builder.ts +193 -0
- package/benchmarks/self-hosted-core/campaign.ts +233 -0
- package/benchmarks/self-hosted-core/existing-tests-builder.ts +217 -0
- package/benchmarks/self-hosted-core/existing-tests.ts +146 -0
- package/benchmarks/self-hosted-core/liveness.test.mjs +8 -0
- package/conformance/v1/bundle.json +104 -0
- package/conformance/v1/expected/canonical-order-a.json +4 -0
- package/conformance/v1/expected/canonical-order-b.json +4 -0
- package/conformance/v1/expected/create-benchmark-v1-measured.json +575 -0
- package/conformance/v1/expected/create-profile-v1-qualified.json +280 -0
- package/conformance/v1/expected/decide-collection-failure-non-kill.json +192 -0
- package/conformance/v1/expected/decide-compile-failure-non-kill.json +192 -0
- package/conformance/v1/expected/decide-infra-error-non-kill.json +192 -0
- package/conformance/v1/expected/decide-no-test-discovered-non-kill.json +192 -0
- package/conformance/v1/expected/decide-process-crash-non-kill.json +192 -0
- package/conformance/v1/expected/decide-timeout-non-kill.json +192 -0
- package/conformance/v1/expected/decide-verified.json +192 -0
- package/conformance/v1/expected/replay-benchmark-v1-resealed-summary-forgery.json +12 -0
- package/conformance/v1/expected/replay-evidence-raw-tamper.json +6 -0
- package/conformance/v1/expected/replay-evidence-resealed-semantic-forgery.json +6 -0
- package/conformance/v1/inputs/canonical-order-a.json +8 -0
- package/conformance/v1/inputs/canonical-order-b.json +8 -0
- package/conformance/v1/inputs/create-benchmark-v1-measured.json +459 -0
- package/conformance/v1/inputs/create-profile-v1-qualified.json +228 -0
- package/conformance/v1/inputs/decide-collection-failure-non-kill.json +143 -0
- package/conformance/v1/inputs/decide-compile-failure-non-kill.json +143 -0
- package/conformance/v1/inputs/decide-infra-error-non-kill.json +143 -0
- package/conformance/v1/inputs/decide-no-test-discovered-non-kill.json +143 -0
- package/conformance/v1/inputs/decide-process-crash-non-kill.json +143 -0
- package/conformance/v1/inputs/decide-timeout-non-kill.json +143 -0
- package/conformance/v1/inputs/decide-verified.json +143 -0
- package/conformance/v1/inputs/replay-benchmark-v1-resealed-summary-forgery.json +575 -0
- package/conformance/v1/inputs/replay-evidence-raw-tamper.json +201 -0
- package/conformance/v1/inputs/replay-evidence-resealed-semantic-forgery.json +192 -0
- package/conformance/v1/schemas/expected-digests.json +175 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +951 -0
- package/dist/cli.js.map +1 -0
- package/dist/contracts/diagnostics.d.ts +18 -0
- package/dist/contracts/diagnostics.d.ts.map +1 -0
- package/dist/contracts/diagnostics.js +13 -0
- package/dist/contracts/diagnostics.js.map +1 -0
- package/dist/contracts/index.d.ts +3908 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +2569 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/contracts/runtime-doctor.d.ts +107 -0
- package/dist/contracts/runtime-doctor.d.ts.map +1 -0
- package/dist/contracts/runtime-doctor.js +91 -0
- package/dist/contracts/runtime-doctor.js.map +1 -0
- package/dist/core/index.d.ts +200 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +2587 -0
- package/dist/core/index.js.map +1 -0
- package/dist/diagnostics.d.ts +7 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +252 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/engine/adapters/node-test-profile.d.ts +14 -0
- package/dist/engine/adapters/node-test-profile.d.ts.map +1 -0
- package/dist/engine/adapters/node-test-profile.js +14 -0
- package/dist/engine/adapters/node-test-profile.js.map +1 -0
- package/dist/engine/adapters/node-test-runtime.d.ts +39 -0
- package/dist/engine/adapters/node-test-runtime.d.ts.map +1 -0
- package/dist/engine/adapters/node-test-runtime.js +173 -0
- package/dist/engine/adapters/node-test-runtime.js.map +1 -0
- package/dist/engine/adapters/runtime-facts.d.ts +26 -0
- package/dist/engine/adapters/runtime-facts.d.ts.map +1 -0
- package/dist/engine/adapters/runtime-facts.js +73 -0
- package/dist/engine/adapters/runtime-facts.js.map +1 -0
- package/dist/engine/connection.d.ts +22 -0
- package/dist/engine/connection.d.ts.map +1 -0
- package/dist/engine/connection.js +343 -0
- package/dist/engine/connection.js.map +1 -0
- package/dist/engine/git-regression.d.ts +25 -0
- package/dist/engine/git-regression.d.ts.map +1 -0
- package/dist/engine/git-regression.js +803 -0
- package/dist/engine/git-regression.js.map +1 -0
- package/dist/engine/index.d.ts +55 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +2782 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/node-test-reporter.d.ts +2 -0
- package/dist/engine/node-test-reporter.d.ts.map +1 -0
- package/dist/engine/node-test-reporter.js +70 -0
- package/dist/engine/node-test-reporter.js.map +1 -0
- package/dist/engine/runtime-doctor.d.ts +16 -0
- package/dist/engine/runtime-doctor.d.ts.map +1 -0
- package/dist/engine/runtime-doctor.js +100 -0
- package/dist/engine/runtime-doctor.js.map +1 -0
- package/dist/evaluation/agentic-corpus.d.ts +161 -0
- package/dist/evaluation/agentic-corpus.d.ts.map +1 -0
- package/dist/evaluation/agentic-corpus.js +710 -0
- package/dist/evaluation/agentic-corpus.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +391 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/stdio.d.ts +3 -0
- package/dist/mcp/stdio.d.ts.map +1 -0
- package/dist/mcp/stdio.js +13 -0
- package/dist/mcp/stdio.js.map +1 -0
- package/dist/sdk/index.d.ts +52 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +224 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/docs/adapter-protocol.md +196 -0
- package/docs/agentic-benchmark.md +118 -0
- package/docs/agentic-corpus-experiment-h3.md +89 -0
- package/docs/agentic-corpus-plan.md +105 -0
- package/docs/agentic-corpus-provenance.md +59 -0
- package/docs/agentic-test-profile-pilot.md +57 -0
- package/docs/agentic-test-profile-v2.md +116 -0
- package/docs/agentic-test-profile.md +274 -0
- package/docs/architecture.md +157 -0
- package/docs/ci.md +37 -0
- package/docs/client-connections.md +61 -0
- package/docs/conformance-v1.md +72 -0
- package/docs/decisions/0001-typescript-runtime.md +24 -0
- package/docs/developer-experience.md +55 -0
- package/docs/diagnostics.md +35 -0
- package/docs/distribution.md +40 -0
- package/docs/git-regression.md +39 -0
- package/docs/migration-repository-validation-order.md +35 -0
- package/docs/migration-testforge-to-assertledger.md +64 -0
- package/docs/project-intent.md +173 -0
- package/docs/proof-model.md +116 -0
- package/docs/reference.md +336 -0
- package/docs/release-1.0.md +63 -0
- package/docs/repository-audit.md +52 -0
- package/docs/repository-init.md +60 -0
- package/docs/research-basis.md +27 -0
- package/docs/roadmap.md +74 -0
- package/docs/runtime-doctor.md +65 -0
- package/docs/testexplora-calibration.md +71 -0
- package/examples/agentic-benchmark/benchmark-request.mjs +19 -0
- package/examples/agentic-benchmark/structured-phase-adapter-fixture.mjs +35 -0
- package/examples/agentic-profile/profile-benchmark.mjs +34 -0
- package/examples/agentic-profile/profile-manifest.mjs +28 -0
- package/examples/git-history/README.md +44 -0
- package/examples/git-history/create-demo.mjs +128 -0
- package/examples/git-history/escape-string-regexp/LICENSE +9 -0
- package/examples/git-history/escape-string-regexp/before.cjs.txt +11 -0
- package/examples/git-history/escape-string-regexp/fixed.cjs.txt +13 -0
- package/examples/git-history/escape-string-regexp/provenance.json +28 -0
- package/examples/node-test/repository/package.json +5 -0
- package/examples/node-test/repository/src/is-even.js +3 -0
- package/examples/node-test/repository/tests/base.test.js +6 -0
- package/examples/node-test/request.json +93 -0
- package/integrations/skill/SKILL.md +51 -0
- package/package.json +88 -0
- package/schemas/agentic-benchmark-acquisition-replay-result.v1.json +70 -0
- package/schemas/agentic-benchmark-acquisition-request.v1.json +564 -0
- package/schemas/agentic-benchmark-acquisition-result.v1.json +1409 -0
- package/schemas/agentic-benchmark-artifact.v1.json +1251 -0
- package/schemas/agentic-benchmark-replay-result.v1.json +84 -0
- package/schemas/agentic-benchmark-request.v1.json +1034 -0
- package/schemas/agentic-corpus-allocation-commitment-replay-result.v1.json +58 -0
- package/schemas/agentic-corpus-allocation-commitment.v1.json +141 -0
- package/schemas/agentic-corpus-allocation-replay-result.v1.json +34 -0
- package/schemas/agentic-corpus-allocation-request.v1.json +65 -0
- package/schemas/agentic-corpus-allocation-reveal.v1.json +66 -0
- package/schemas/agentic-corpus-allocation.v1.json +167 -0
- package/schemas/agentic-corpus-experiment-artifact.v1.json +329 -0
- package/schemas/agentic-corpus-experiment-plan-replay-result.v1.json +50 -0
- package/schemas/agentic-corpus-experiment-plan.v1.json +424 -0
- package/schemas/agentic-corpus-experiment-replay-request.v1.json +336 -0
- package/schemas/agentic-corpus-experiment-replay-result.v1.json +106 -0
- package/schemas/agentic-corpus-experiment-request.v1.json +204 -0
- package/schemas/agentic-corpus-provenance.v1.json +143 -0
- package/schemas/agentic-corpus-trust-policy.v1.json +133 -0
- package/schemas/agentic-profile-replay-result.v1.json +56 -0
- package/schemas/agentic-profile-replay-result.v2.json +63 -0
- package/schemas/agentic-profile-report.v1.json +961 -0
- package/schemas/agentic-profile-report.v2.json +1674 -0
- package/schemas/agentic-profile-request.v1.json +671 -0
- package/schemas/agentic-profile-request.v2.json +1338 -0
- package/schemas/evidence-manifest.v1.json +636 -0
- package/schemas/replay-result.v1.json +49 -0
- package/schemas/repository-analysis.v1.json +119 -0
- package/schemas/repository-audit.v1.json +811 -0
- package/schemas/repository-init-config.v1.json +183 -0
- package/schemas/repository-init-lock.v1.json +162 -0
- package/schemas/repository-init-result.v1.json +212 -0
- package/schemas/verification-request.v1.json +389 -0
|
@@ -0,0 +1,2782 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { cp, lstat, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { createScanner, SyntaxKind } from "typescript/unstable/ast";
|
|
9
|
+
import { AdapterSchema, parseAgenticBenchmarkAcquisitionRequest, parseAgenticBenchmarkAcquisitionResult, parseEvidenceManifest, parseRepositoryAudit, parseRepositoryInitConfig, parseRepositoryInitLock, parseRepositoryInitResult, parseVerificationRequest, repositoryInitConfigDigest, repositoryInitLockDigest, } from "../contracts/index.js";
|
|
10
|
+
import { assertSafeRelativePath, canonicalize, createAgenticBenchmark, decideEvidence, portablePathKey, replayAgenticBenchmark, replayEvidenceManifest, sealManifestArtifact, sha256Canonical, } from "../core/index.js";
|
|
11
|
+
import { NODE_TEST_ADAPTER_PROFILE } from "./adapters/node-test-profile.js";
|
|
12
|
+
import { runNodeTestRuntimePreflight, } from "./adapters/node-test-runtime.js";
|
|
13
|
+
import { normalizeRuntimeFacts, RUNTIME_FACTS_VERSION } from "./adapters/runtime-facts.js";
|
|
14
|
+
import { NODE_TEST_REPORTER_SOURCE } from "./node-test-reporter.js";
|
|
15
|
+
import { runRuntimeDoctorChecks } from "./runtime-doctor.js";
|
|
16
|
+
const DEFAULT_EXCLUDES = new Set([".git", ".testforge", "node_modules"]);
|
|
17
|
+
const SHA256_PREFIX = "sha256:";
|
|
18
|
+
const PROCESS_TERMINATION_GRACE_MS = 250;
|
|
19
|
+
const CONTROLLED_REPORT_MAXIMUM_BYTES = 64 * 1024;
|
|
20
|
+
const TEMPORARY_CLEANUP_OPTIONS = {
|
|
21
|
+
recursive: true,
|
|
22
|
+
force: true,
|
|
23
|
+
maxRetries: 5,
|
|
24
|
+
retryDelay: 100,
|
|
25
|
+
};
|
|
26
|
+
const INIT_CONFIG_FILE = "assertledger.config.json";
|
|
27
|
+
const INIT_LOCK_FILE = "assertledger.lock.json";
|
|
28
|
+
const INIT_MANAGED_FILES = new Set([INIT_CONFIG_FILE, INIT_LOCK_FILE]);
|
|
29
|
+
const INIT_PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun", "uv", "poetry", "pip"];
|
|
30
|
+
const INIT_FRAMEWORKS = ["node:test", "vitest", "jest", "bun:test", "pytest"];
|
|
31
|
+
async function removeTemporaryDirectory(directory) {
|
|
32
|
+
await rm(directory, TEMPORARY_CLEANUP_OPTIONS);
|
|
33
|
+
}
|
|
34
|
+
class OutputAccumulator {
|
|
35
|
+
maximumBytes;
|
|
36
|
+
#hash = createHash("sha256");
|
|
37
|
+
#parts = [];
|
|
38
|
+
#capturedBytes = 0;
|
|
39
|
+
#totalBytes = 0;
|
|
40
|
+
constructor(maximumBytes) {
|
|
41
|
+
this.maximumBytes = maximumBytes;
|
|
42
|
+
}
|
|
43
|
+
add(chunk) {
|
|
44
|
+
this.#hash.update(chunk);
|
|
45
|
+
this.#totalBytes += chunk.byteLength;
|
|
46
|
+
const remaining = this.maximumBytes - this.#capturedBytes;
|
|
47
|
+
if (remaining > 0) {
|
|
48
|
+
const captured = chunk.subarray(0, remaining);
|
|
49
|
+
this.#parts.push(captured);
|
|
50
|
+
this.#capturedBytes += captured.byteLength;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
finish() {
|
|
54
|
+
const captured = Buffer.concat(this.#parts);
|
|
55
|
+
const decoded = captured.toString("utf8");
|
|
56
|
+
let text = "";
|
|
57
|
+
let encodedBytes = 0;
|
|
58
|
+
for (const character of decoded) {
|
|
59
|
+
const characterBytes = Buffer.byteLength(character);
|
|
60
|
+
if (encodedBytes + characterBytes > this.maximumBytes)
|
|
61
|
+
break;
|
|
62
|
+
text += character;
|
|
63
|
+
encodedBytes += characterBytes;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
text,
|
|
67
|
+
totalBytes: this.#totalBytes,
|
|
68
|
+
truncated: this.#totalBytes > this.#capturedBytes,
|
|
69
|
+
digest: `${SHA256_PREFIX}${this.#hash.digest("hex")}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function isRecord(value) {
|
|
74
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
function requireRecord(value, name) {
|
|
77
|
+
if (!isRecord(value))
|
|
78
|
+
throw new TypeError(`INVALID_${name.toUpperCase()}`);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
function requireString(value, name) {
|
|
82
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
83
|
+
throw new TypeError(`INVALID_${name.toUpperCase()}`);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
function requireText(value, name) {
|
|
88
|
+
if (typeof value !== "string")
|
|
89
|
+
throw new TypeError(`INVALID_${name.toUpperCase()}`);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
function requirePositiveInteger(value, name) {
|
|
93
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
94
|
+
throw new TypeError(`INVALID_${name.toUpperCase()}`);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function requireNonNegativeInteger(value, name) {
|
|
99
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
100
|
+
throw new TypeError(`INVALID_${name.toUpperCase()}`);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
function requireStringArray(value, name) {
|
|
105
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
106
|
+
throw new TypeError(`INVALID_${name.toUpperCase()}`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
function parseNodeTestExtraArguments(value) {
|
|
111
|
+
if (value === undefined)
|
|
112
|
+
return undefined;
|
|
113
|
+
const arguments_ = requireStringArray(value, "extra_arguments");
|
|
114
|
+
if (arguments_.length > 0)
|
|
115
|
+
throw new Error("UNSAFE_NODE_TEST_ARGUMENT");
|
|
116
|
+
return arguments_;
|
|
117
|
+
}
|
|
118
|
+
function parseProcessInput(value) {
|
|
119
|
+
const input = requireRecord(value, "process_input");
|
|
120
|
+
const environment = requireRecord(input.environment, "environment");
|
|
121
|
+
const normalizedEnvironment = {};
|
|
122
|
+
for (const [key, item] of Object.entries(environment)) {
|
|
123
|
+
if (typeof item !== "string" || key.includes("="))
|
|
124
|
+
throw new TypeError("INVALID_ENVIRONMENT");
|
|
125
|
+
normalizedEnvironment[key] = item;
|
|
126
|
+
}
|
|
127
|
+
const args = requireStringArray(input.args, "args");
|
|
128
|
+
return {
|
|
129
|
+
executable: requireString(input.executable, "executable"),
|
|
130
|
+
args,
|
|
131
|
+
cwd: requireString(input.cwd, "cwd"),
|
|
132
|
+
environment: normalizedEnvironment,
|
|
133
|
+
timeoutMs: requirePositiveInteger(input.timeoutMs, "timeout_ms"),
|
|
134
|
+
maximumOutputBytes: requirePositiveInteger(input.maximumOutputBytes, "maximum_output_bytes"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function terminateProcessTree(pid) {
|
|
138
|
+
if (pid === undefined)
|
|
139
|
+
return;
|
|
140
|
+
if (process.platform === "win32") {
|
|
141
|
+
await new Promise((resolve) => {
|
|
142
|
+
const killer = spawn("taskkill.exe", ["/pid", String(pid), "/T", "/F"], {
|
|
143
|
+
shell: false,
|
|
144
|
+
stdio: "ignore",
|
|
145
|
+
windowsHide: true,
|
|
146
|
+
});
|
|
147
|
+
killer.once("error", () => resolve());
|
|
148
|
+
killer.once("close", () => resolve());
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
process.kill(-pid, "SIGKILL");
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
try {
|
|
157
|
+
process.kill(pid, "SIGKILL");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// The child may already have exited between the timeout and termination.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export async function runProcess(value) {
|
|
165
|
+
const input = parseProcessInput(value);
|
|
166
|
+
const cwd = await realpath(input.cwd);
|
|
167
|
+
const cwdStats = await stat(cwd);
|
|
168
|
+
if (!cwdStats.isDirectory())
|
|
169
|
+
throw new TypeError("INVALID_CWD");
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
const startedAt = Date.now();
|
|
172
|
+
const stdout = new OutputAccumulator(input.maximumOutputBytes);
|
|
173
|
+
const stderr = new OutputAccumulator(input.maximumOutputBytes);
|
|
174
|
+
let timedOut = false;
|
|
175
|
+
let spawnError;
|
|
176
|
+
let settled = false;
|
|
177
|
+
let terminationTimer;
|
|
178
|
+
const child = spawn(input.executable, input.args, {
|
|
179
|
+
cwd,
|
|
180
|
+
detached: process.platform !== "win32",
|
|
181
|
+
env: input.environment,
|
|
182
|
+
shell: false,
|
|
183
|
+
windowsHide: true,
|
|
184
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
});
|
|
186
|
+
child.stdout.on("data", (chunk) => stdout.add(chunk));
|
|
187
|
+
child.stderr.on("data", (chunk) => stderr.add(chunk));
|
|
188
|
+
child.once("error", (error) => {
|
|
189
|
+
spawnError = error;
|
|
190
|
+
});
|
|
191
|
+
const finish = (outcome, exitCode, signal) => {
|
|
192
|
+
if (settled)
|
|
193
|
+
return;
|
|
194
|
+
settled = true;
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
if (terminationTimer !== undefined)
|
|
197
|
+
clearTimeout(terminationTimer);
|
|
198
|
+
resolve({
|
|
199
|
+
outcome,
|
|
200
|
+
exitCode,
|
|
201
|
+
signal,
|
|
202
|
+
timedOut,
|
|
203
|
+
durationMs: Date.now() - startedAt,
|
|
204
|
+
stdout: stdout.finish(),
|
|
205
|
+
stderr: stderr.finish(),
|
|
206
|
+
...(spawnError ? { error: spawnError.message } : {}),
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
timedOut = true;
|
|
211
|
+
try {
|
|
212
|
+
child.kill("SIGKILL");
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// The direct child may already have exited while a descendant keeps the pipes open.
|
|
216
|
+
}
|
|
217
|
+
void terminateProcessTree(child.pid);
|
|
218
|
+
terminationTimer = setTimeout(() => {
|
|
219
|
+
child.stdout.removeAllListeners("data");
|
|
220
|
+
child.stderr.removeAllListeners("data");
|
|
221
|
+
child.stdout.destroy();
|
|
222
|
+
child.stderr.destroy();
|
|
223
|
+
finish("TIMEOUT", child.exitCode, child.signalCode);
|
|
224
|
+
}, PROCESS_TERMINATION_GRACE_MS);
|
|
225
|
+
}, input.timeoutMs);
|
|
226
|
+
child.once("close", (exitCode, signal) => {
|
|
227
|
+
const outcome = timedOut
|
|
228
|
+
? "TIMEOUT"
|
|
229
|
+
: spawnError
|
|
230
|
+
? "INFRA_ERROR"
|
|
231
|
+
: exitCode === 0
|
|
232
|
+
? "PASS"
|
|
233
|
+
: "PROCESS_CRASH";
|
|
234
|
+
finish(outcome, exitCode, signal);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function effectiveExcludes(requestExcludes) {
|
|
239
|
+
return new Set([...DEFAULT_EXCLUDES, ...requestExcludes].map(portablePathKey));
|
|
240
|
+
}
|
|
241
|
+
function comparePortablePaths(left, right) {
|
|
242
|
+
const leftKey = portablePathKey(left);
|
|
243
|
+
const rightKey = portablePathKey(right);
|
|
244
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
245
|
+
}
|
|
246
|
+
function registerPortablePath(spellings, relative) {
|
|
247
|
+
const key = portablePathKey(relative);
|
|
248
|
+
const existing = spellings.get(key);
|
|
249
|
+
if (existing !== undefined && existing !== relative)
|
|
250
|
+
throw new Error("PORTABLE_PATH_COLLISION");
|
|
251
|
+
spellings.set(key, relative);
|
|
252
|
+
}
|
|
253
|
+
async function walkFiles(root, excludes) {
|
|
254
|
+
const files = [];
|
|
255
|
+
const spellings = new Map();
|
|
256
|
+
let totalBytes = 0;
|
|
257
|
+
async function visit(relativeDirectory) {
|
|
258
|
+
const directory = path.join(root, relativeDirectory);
|
|
259
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
260
|
+
entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (excludes.has(portablePathKey(entry.name)))
|
|
263
|
+
continue;
|
|
264
|
+
const relative = path.posix.join(relativeDirectory.split(path.sep).join("/"), entry.name);
|
|
265
|
+
const entryStats = await lstat(path.join(root, ...relative.split("/")));
|
|
266
|
+
if (entryStats.isSymbolicLink())
|
|
267
|
+
throw new Error("UNSUPPORTED_REPOSITORY_SYMLINK");
|
|
268
|
+
registerPortablePath(spellings, relative);
|
|
269
|
+
if (entryStats.isDirectory())
|
|
270
|
+
await visit(relative);
|
|
271
|
+
else if (entryStats.isFile()) {
|
|
272
|
+
files.push(relative);
|
|
273
|
+
totalBytes += entryStats.size;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
await visit("");
|
|
278
|
+
files.sort(comparePortablePaths);
|
|
279
|
+
return { files, spellings, totalBytes };
|
|
280
|
+
}
|
|
281
|
+
function rawSha256(content) {
|
|
282
|
+
return `${SHA256_PREFIX}${createHash("sha256").update(content).digest("hex")}`;
|
|
283
|
+
}
|
|
284
|
+
function initJsonBytes(value) {
|
|
285
|
+
return `${canonicalize(value)}\n`;
|
|
286
|
+
}
|
|
287
|
+
function isInitPackageManager(value) {
|
|
288
|
+
return INIT_PACKAGE_MANAGERS.includes(value);
|
|
289
|
+
}
|
|
290
|
+
function isInitFramework(value) {
|
|
291
|
+
return INIT_FRAMEWORKS.includes(value);
|
|
292
|
+
}
|
|
293
|
+
function safeInitCommand(value) {
|
|
294
|
+
if (typeof value !== "object" ||
|
|
295
|
+
value === null ||
|
|
296
|
+
Array.isArray(value) ||
|
|
297
|
+
!("executable" in value) ||
|
|
298
|
+
typeof value.executable !== "string" ||
|
|
299
|
+
!("arguments" in value) ||
|
|
300
|
+
!Array.isArray(value.arguments) ||
|
|
301
|
+
!value.arguments.every((argument) => typeof argument === "string") ||
|
|
302
|
+
value.executable.length === 0 ||
|
|
303
|
+
value.executable.length > 512 ||
|
|
304
|
+
value.arguments.length > 100 ||
|
|
305
|
+
value.arguments.some((argument) => argument.length > 1_024) ||
|
|
306
|
+
/^(?:[a-zA-Z]:|[\\/]{1,2})/u.test(value.executable) ||
|
|
307
|
+
path.isAbsolute(value.executable) ||
|
|
308
|
+
/[\s;&|<>`]/u.test(value.executable) ||
|
|
309
|
+
[...value.executable].some((character) => {
|
|
310
|
+
const codePoint = character.codePointAt(0);
|
|
311
|
+
return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f);
|
|
312
|
+
}) ||
|
|
313
|
+
value.arguments.some((argument) => /^(?:[a-zA-Z]:|[\\/]{1,2})/u.test(argument) ||
|
|
314
|
+
path.isAbsolute(argument) ||
|
|
315
|
+
[...argument].some((character) => {
|
|
316
|
+
const codePoint = character.codePointAt(0);
|
|
317
|
+
return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f);
|
|
318
|
+
}))) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
return { executable: value.executable, arguments: [...value.arguments] };
|
|
322
|
+
}
|
|
323
|
+
function parseInitScript(script) {
|
|
324
|
+
if (typeof script !== "string" ||
|
|
325
|
+
script.trim() !== script ||
|
|
326
|
+
script.length === 0 ||
|
|
327
|
+
/[;&|<>`\r\n'"]/u.test(script)) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
const [executable, ...arguments_] = script.split(/\s+/u);
|
|
331
|
+
return safeInitCommand({ executable, arguments: arguments_ });
|
|
332
|
+
}
|
|
333
|
+
function initPackageDocument(text) {
|
|
334
|
+
if (text === undefined)
|
|
335
|
+
return undefined;
|
|
336
|
+
try {
|
|
337
|
+
const value = JSON.parse(text);
|
|
338
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
339
|
+
? value
|
|
340
|
+
: undefined;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function initDependencies(document) {
|
|
347
|
+
const result = new Set();
|
|
348
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
349
|
+
const value = document?.[field];
|
|
350
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
351
|
+
for (const key of Object.keys(value))
|
|
352
|
+
result.add(key);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
function initCiProvider(file) {
|
|
358
|
+
if (file.startsWith(".github/workflows/"))
|
|
359
|
+
return "github-actions";
|
|
360
|
+
if (file === ".gitlab-ci.yml")
|
|
361
|
+
return "gitlab-ci";
|
|
362
|
+
if (file === "azure-pipelines.yml")
|
|
363
|
+
return "azure-pipelines";
|
|
364
|
+
if (file === ".circleci/config.yml")
|
|
365
|
+
return "circleci";
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
function initEvidenceKind(file) {
|
|
369
|
+
if (file === "package.json" || file === "pyproject.toml" || file === "requirements.txt")
|
|
370
|
+
return "PACKAGE_MANIFEST";
|
|
371
|
+
if ([
|
|
372
|
+
"pnpm-lock.yaml",
|
|
373
|
+
"package-lock.json",
|
|
374
|
+
"npm-shrinkwrap.json",
|
|
375
|
+
"yarn.lock",
|
|
376
|
+
"bun.lock",
|
|
377
|
+
"bun.lockb",
|
|
378
|
+
"uv.lock",
|
|
379
|
+
"poetry.lock",
|
|
380
|
+
"Pipfile.lock",
|
|
381
|
+
].includes(file))
|
|
382
|
+
return "LOCKFILE";
|
|
383
|
+
if (initCiProvider(file) !== undefined)
|
|
384
|
+
return "CI_CONFIG";
|
|
385
|
+
if (/((^|\/)test[^/]*|\.test|\.spec)\.(?:[cm]?[jt]s|tsx?|py)$/u.test(file))
|
|
386
|
+
return "TEST_SOURCE";
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
function initNodeTestSource(file) {
|
|
390
|
+
return /(?:^|\/)[^/]+\.test\.[cm]?[jt]s$/u.test(file);
|
|
391
|
+
}
|
|
392
|
+
function initSafePathPattern(value) {
|
|
393
|
+
if (value.startsWith("-") || value.includes("\\"))
|
|
394
|
+
return false;
|
|
395
|
+
try {
|
|
396
|
+
assertSafeRelativePath(value.replaceAll("*", "x").replaceAll("?", "x"), ["."]);
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function initPathPatternMatcher(pattern) {
|
|
404
|
+
if (!/[*?]/u.test(pattern)) {
|
|
405
|
+
return (file) => file === pattern || file.startsWith(`${pattern}/`);
|
|
406
|
+
}
|
|
407
|
+
let expression = "^";
|
|
408
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
409
|
+
const character = pattern[index] ?? "";
|
|
410
|
+
if (character === "*" && pattern[index + 1] === "*") {
|
|
411
|
+
if (pattern[index + 2] === "/") {
|
|
412
|
+
expression += "(?:[^/]+/)*";
|
|
413
|
+
index += 2;
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
expression += ".*";
|
|
417
|
+
index += 1;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else if (character === "*") {
|
|
421
|
+
expression += "[^/]*";
|
|
422
|
+
}
|
|
423
|
+
else if (character === "?") {
|
|
424
|
+
expression += "[^/]";
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
expression += character.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const matcher = new RegExp(`${expression}$`, "u");
|
|
431
|
+
return (file) => matcher.test(file);
|
|
432
|
+
}
|
|
433
|
+
function initCommandPathMatchers(command, testSources) {
|
|
434
|
+
const conventionalDirectories = new Set(["test", "tests", "spec", "specs", "__tests__"]);
|
|
435
|
+
return command.arguments
|
|
436
|
+
.filter((argument) => initSafePathPattern(argument) &&
|
|
437
|
+
(/[*?]/u.test(argument) ||
|
|
438
|
+
argument.includes("/") ||
|
|
439
|
+
initNodeTestSource(argument) ||
|
|
440
|
+
conventionalDirectories.has(argument) ||
|
|
441
|
+
testSources.some((file) => file === argument || file.startsWith(`${argument}/`))))
|
|
442
|
+
.map(initPathPatternMatcher);
|
|
443
|
+
}
|
|
444
|
+
function initJavaScriptModuleSpecifiers(source) {
|
|
445
|
+
const tokens = [];
|
|
446
|
+
const scanner = createScanner(true, undefined, source);
|
|
447
|
+
const templateExpressionBraceDepths = [];
|
|
448
|
+
for (let kind = scanner.scan(); kind !== SyntaxKind.EndOfFile; kind = scanner.scan()) {
|
|
449
|
+
const tokenStart = scanner.getTokenStart();
|
|
450
|
+
if (scanner.getTokenEnd() <= tokenStart) {
|
|
451
|
+
// Context-free scanning can leave a private identifier empty (for example '#' in a regex).
|
|
452
|
+
// Never collect a non-EOF token without consuming source text.
|
|
453
|
+
scanner.resetTokenState(Math.min(source.length, tokenStart + 1));
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const templateDepthIndex = templateExpressionBraceDepths.length - 1;
|
|
457
|
+
if (kind === SyntaxKind.CloseBraceToken &&
|
|
458
|
+
templateDepthIndex >= 0 &&
|
|
459
|
+
templateExpressionBraceDepths[templateDepthIndex] === 0) {
|
|
460
|
+
kind = scanner.reScanTemplateToken(false);
|
|
461
|
+
tokens.push({ kind, text: scanner.getTokenText(), value: scanner.getTokenValue() });
|
|
462
|
+
if (kind === SyntaxKind.TemplateTail)
|
|
463
|
+
templateExpressionBraceDepths.pop();
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
tokens.push({ kind, text: scanner.getTokenText(), value: scanner.getTokenValue() });
|
|
467
|
+
if (kind === SyntaxKind.TemplateHead) {
|
|
468
|
+
templateExpressionBraceDepths.push(0);
|
|
469
|
+
}
|
|
470
|
+
else if (templateDepthIndex >= 0 && kind === SyntaxKind.OpenBraceToken) {
|
|
471
|
+
templateExpressionBraceDepths[templateDepthIndex] =
|
|
472
|
+
(templateExpressionBraceDepths[templateDepthIndex] ?? 0) + 1;
|
|
473
|
+
}
|
|
474
|
+
else if (templateDepthIndex >= 0 && kind === SyntaxKind.CloseBraceToken) {
|
|
475
|
+
templateExpressionBraceDepths[templateDepthIndex] =
|
|
476
|
+
(templateExpressionBraceDepths[templateDepthIndex] ?? 0) - 1;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const specifiers = new Set();
|
|
480
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
481
|
+
const token = tokens[index];
|
|
482
|
+
if (token === undefined)
|
|
483
|
+
continue;
|
|
484
|
+
if ((token.kind === SyntaxKind.Identifier || token.kind === SyntaxKind.RequireKeyword) &&
|
|
485
|
+
token.text === "require" &&
|
|
486
|
+
tokens[index - 1]?.kind !== SyntaxKind.DotToken &&
|
|
487
|
+
tokens[index + 1]?.kind === SyntaxKind.OpenParenToken &&
|
|
488
|
+
tokens[index + 2]?.kind === SyntaxKind.StringLiteral) {
|
|
489
|
+
specifiers.add(tokens[index + 2]?.value ?? "");
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (token.kind !== SyntaxKind.ImportKeyword && token.kind !== SyntaxKind.ExportKeyword) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (tokens[index + 1]?.kind === SyntaxKind.StringLiteral) {
|
|
496
|
+
specifiers.add(tokens[index + 1]?.value ?? "");
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (token.kind === SyntaxKind.ImportKeyword &&
|
|
500
|
+
tokens[index + 1]?.kind === SyntaxKind.OpenParenToken &&
|
|
501
|
+
tokens[index + 2]?.kind === SyntaxKind.StringLiteral) {
|
|
502
|
+
specifiers.add(tokens[index + 2]?.value ?? "");
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
|
506
|
+
const candidate = tokens[cursor];
|
|
507
|
+
if (candidate?.kind === SyntaxKind.SemicolonToken ||
|
|
508
|
+
candidate?.kind === SyntaxKind.ImportKeyword ||
|
|
509
|
+
candidate?.kind === SyntaxKind.ExportKeyword) {
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
if (candidate?.text === "from" && tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
|
|
513
|
+
specifiers.add(tokens[cursor + 1]?.value ?? "");
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
specifiers.delete("");
|
|
519
|
+
return specifiers;
|
|
520
|
+
}
|
|
521
|
+
async function atomicInitWrite(root, relative, content) {
|
|
522
|
+
const target = path.join(root, relative);
|
|
523
|
+
const temporary = path.join(root, `.${relative}.${process.pid}.${Date.now()}.tmp`);
|
|
524
|
+
try {
|
|
525
|
+
await writeFile(temporary, content, { flag: "wx" });
|
|
526
|
+
await rename(temporary, target);
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
await rm(temporary, { force: true });
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function initEmptyDetections(reasonCodes) {
|
|
534
|
+
return {
|
|
535
|
+
packageManager: null,
|
|
536
|
+
framework: null,
|
|
537
|
+
testCommand: null,
|
|
538
|
+
ciProviders: [],
|
|
539
|
+
adapterRecommendation: "unavailable",
|
|
540
|
+
reasonCodes: [...reasonCodes].sort(),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
function initTerminalResult(status, detections, reasonCodes) {
|
|
544
|
+
return parseRepositoryInitResult({
|
|
545
|
+
schemaVersion: "1.0.0",
|
|
546
|
+
status,
|
|
547
|
+
reasonCodes: [...new Set(reasonCodes)].sort(),
|
|
548
|
+
detections: { ...detections, reasonCodes: [...new Set(detections.reasonCodes)].sort() },
|
|
549
|
+
actions: [],
|
|
550
|
+
files: [],
|
|
551
|
+
requiredOperatorInputs: ["worlds", "candidates"],
|
|
552
|
+
nextCommands: [{ executable: "assertledger", arguments: ["audit", ".", "--json"] }],
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
export async function initializeRepository(requestedRoot, options = {}) {
|
|
556
|
+
let root;
|
|
557
|
+
try {
|
|
558
|
+
root = await realpath(requestedRoot);
|
|
559
|
+
if (!(await stat(root)).isDirectory())
|
|
560
|
+
throw new Error("REPOSITORY_ROOT_NOT_DIRECTORY");
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return initTerminalResult("CONFLICT", initEmptyDetections(["REPOSITORY_ROOT_INVALID"]), [
|
|
564
|
+
"REPOSITORY_ROOT_INVALID",
|
|
565
|
+
]);
|
|
566
|
+
}
|
|
567
|
+
const candidateRoots = ["tests/candidates"];
|
|
568
|
+
const outsideCandidateRoots = (file) => !candidateRoots.some((candidateRoot) => {
|
|
569
|
+
const rootKey = portablePathKey(candidateRoot);
|
|
570
|
+
const fileKey = portablePathKey(file);
|
|
571
|
+
return fileKey === rootKey || fileKey.startsWith(`${rootKey}/`);
|
|
572
|
+
});
|
|
573
|
+
const inventory = await walkFiles(root, effectiveExcludes([...INIT_MANAGED_FILES]));
|
|
574
|
+
const inScopeFiles = inventory.files.filter(outsideCandidateRoots);
|
|
575
|
+
const contents = new Map();
|
|
576
|
+
const evidenceBytes = new Map();
|
|
577
|
+
for (const file of inScopeFiles) {
|
|
578
|
+
const kind = initEvidenceKind(file);
|
|
579
|
+
if (kind !== undefined || file === "package.json" || file === "pyproject.toml") {
|
|
580
|
+
const bytes = await readFile(path.join(root, ...file.split("/")));
|
|
581
|
+
evidenceBytes.set(file, bytes);
|
|
582
|
+
contents.set(file, bytes.toString("utf8"));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const packageDocument = initPackageDocument(contents.get("package.json"));
|
|
586
|
+
if (contents.has("package.json") && packageDocument === undefined) {
|
|
587
|
+
return initTerminalResult("CONFLICT", initEmptyDetections(["PACKAGE_MANIFEST_INVALID"]), [
|
|
588
|
+
"PACKAGE_MANIFEST_INVALID",
|
|
589
|
+
]);
|
|
590
|
+
}
|
|
591
|
+
const managerFacts = new Set();
|
|
592
|
+
const packageManagerField = packageDocument?.packageManager;
|
|
593
|
+
if (typeof packageManagerField === "string") {
|
|
594
|
+
const id = packageManagerField.split("@")[0] ?? "";
|
|
595
|
+
if (!isInitPackageManager(id)) {
|
|
596
|
+
return initTerminalResult("CONFLICT", initEmptyDetections(["PACKAGE_MANAGER_INVALID"]), [
|
|
597
|
+
"PACKAGE_MANAGER_INVALID",
|
|
598
|
+
]);
|
|
599
|
+
}
|
|
600
|
+
managerFacts.add(id);
|
|
601
|
+
}
|
|
602
|
+
const managerLockfiles = [
|
|
603
|
+
["pnpm", ["pnpm-lock.yaml"]],
|
|
604
|
+
["npm", ["package-lock.json", "npm-shrinkwrap.json"]],
|
|
605
|
+
["yarn", ["yarn.lock"]],
|
|
606
|
+
["bun", ["bun.lock", "bun.lockb"]],
|
|
607
|
+
["uv", ["uv.lock"]],
|
|
608
|
+
["poetry", ["poetry.lock"]],
|
|
609
|
+
["pip", ["Pipfile.lock", "requirements.txt"]],
|
|
610
|
+
];
|
|
611
|
+
for (const [manager, files] of managerLockfiles) {
|
|
612
|
+
if (files.some((file) => inScopeFiles.includes(file)))
|
|
613
|
+
managerFacts.add(manager);
|
|
614
|
+
}
|
|
615
|
+
const pyproject = contents.get("pyproject.toml") ?? "";
|
|
616
|
+
if (/\[tool\.uv\]/u.test(pyproject))
|
|
617
|
+
managerFacts.add("uv");
|
|
618
|
+
if (/\[tool\.poetry\]/u.test(pyproject))
|
|
619
|
+
managerFacts.add("poetry");
|
|
620
|
+
const managerOverride = options.packageManager;
|
|
621
|
+
if (managerOverride !== undefined && !isInitPackageManager(managerOverride)) {
|
|
622
|
+
return initTerminalResult("CONFLICT", initEmptyDetections(["PACKAGE_MANAGER_OVERRIDE_INVALID"]), ["PACKAGE_MANAGER_OVERRIDE_INVALID"]);
|
|
623
|
+
}
|
|
624
|
+
if (managerFacts.size > 1 ||
|
|
625
|
+
(managerOverride !== undefined && managerFacts.size === 1 && !managerFacts.has(managerOverride))) {
|
|
626
|
+
return initTerminalResult("CONFLICT", initEmptyDetections(["PACKAGE_MANAGER_AMBIGUOUS"]), [
|
|
627
|
+
"PACKAGE_MANAGER_AMBIGUOUS",
|
|
628
|
+
]);
|
|
629
|
+
}
|
|
630
|
+
const packageManager = managerOverride ?? [...managerFacts][0];
|
|
631
|
+
if (packageManager === undefined) {
|
|
632
|
+
return initTerminalResult("CONFLICT", initEmptyDetections(["PACKAGE_MANAGER_UNDETECTED"]), [
|
|
633
|
+
"PACKAGE_MANAGER_UNDETECTED",
|
|
634
|
+
]);
|
|
635
|
+
}
|
|
636
|
+
const frameworkFacts = new Set();
|
|
637
|
+
const dependencies = initDependencies(packageDocument);
|
|
638
|
+
if (dependencies.has("vitest"))
|
|
639
|
+
frameworkFacts.add("vitest");
|
|
640
|
+
if (dependencies.has("jest") || dependencies.has("@jest/globals"))
|
|
641
|
+
frameworkFacts.add("jest");
|
|
642
|
+
if (dependencies.has("pytest"))
|
|
643
|
+
frameworkFacts.add("pytest");
|
|
644
|
+
for (const [file, content] of contents) {
|
|
645
|
+
if (initEvidenceKind(file) !== "TEST_SOURCE" &&
|
|
646
|
+
file !== "pyproject.toml" &&
|
|
647
|
+
file !== "requirements.txt")
|
|
648
|
+
continue;
|
|
649
|
+
if (/\.[cm]?[jt]sx?$/u.test(file)) {
|
|
650
|
+
const specifiers = initJavaScriptModuleSpecifiers(content);
|
|
651
|
+
if (specifiers.has("node:test"))
|
|
652
|
+
frameworkFacts.add("node:test");
|
|
653
|
+
if ([...specifiers].some((name) => name === "vitest" || name.startsWith("@vitest/"))) {
|
|
654
|
+
frameworkFacts.add("vitest");
|
|
655
|
+
}
|
|
656
|
+
if ([...specifiers].some((name) => name === "jest" || name.startsWith("@jest/"))) {
|
|
657
|
+
frameworkFacts.add("jest");
|
|
658
|
+
}
|
|
659
|
+
if (specifiers.has("bun:test"))
|
|
660
|
+
frameworkFacts.add("bun:test");
|
|
661
|
+
}
|
|
662
|
+
else if (file.endsWith(".py") && /(^|\/)test[^/]*\.py$/u.test(file)) {
|
|
663
|
+
frameworkFacts.add("pytest");
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const scripts = packageDocument?.scripts;
|
|
667
|
+
const testScript = scripts !== null && typeof scripts === "object" && !Array.isArray(scripts)
|
|
668
|
+
? scripts.test
|
|
669
|
+
: undefined;
|
|
670
|
+
if (typeof testScript === "string") {
|
|
671
|
+
if (/\bnode\s+--test\b/u.test(testScript))
|
|
672
|
+
frameworkFacts.add("node:test");
|
|
673
|
+
if (/\bvitest\b/u.test(testScript))
|
|
674
|
+
frameworkFacts.add("vitest");
|
|
675
|
+
if (/\bjest\b/u.test(testScript))
|
|
676
|
+
frameworkFacts.add("jest");
|
|
677
|
+
if (/\bbun\s+test\b/u.test(testScript))
|
|
678
|
+
frameworkFacts.add("bun:test");
|
|
679
|
+
if (/\bpytest\b/u.test(testScript))
|
|
680
|
+
frameworkFacts.add("pytest");
|
|
681
|
+
}
|
|
682
|
+
if (/\[tool\.pytest(?:\.|\])/u.test(pyproject) ||
|
|
683
|
+
(contents.has("requirements.txt") &&
|
|
684
|
+
/(^|\n)pytest(?:[=<>!~]|$)/u.test(contents.get("requirements.txt") ?? "")))
|
|
685
|
+
frameworkFacts.add("pytest");
|
|
686
|
+
const frameworkOverride = options.framework;
|
|
687
|
+
if (frameworkOverride !== undefined && !isInitFramework(frameworkOverride)) {
|
|
688
|
+
const detections = initEmptyDetections(["FRAMEWORK_OVERRIDE_INVALID"]);
|
|
689
|
+
detections.packageManager = packageManager;
|
|
690
|
+
return initTerminalResult("CONFLICT", detections, ["FRAMEWORK_OVERRIDE_INVALID"]);
|
|
691
|
+
}
|
|
692
|
+
if ((frameworkFacts.size > 1 && frameworkOverride === undefined) ||
|
|
693
|
+
(frameworkOverride !== undefined &&
|
|
694
|
+
frameworkFacts.size > 0 &&
|
|
695
|
+
!frameworkFacts.has(frameworkOverride))) {
|
|
696
|
+
const detections = initEmptyDetections(["FRAMEWORK_AMBIGUOUS"]);
|
|
697
|
+
detections.packageManager = packageManager;
|
|
698
|
+
return initTerminalResult("CONFLICT", detections, ["FRAMEWORK_AMBIGUOUS"]);
|
|
699
|
+
}
|
|
700
|
+
const framework = frameworkOverride ?? [...frameworkFacts][0];
|
|
701
|
+
if (framework === undefined) {
|
|
702
|
+
const detections = initEmptyDetections(["FRAMEWORK_UNDETECTED"]);
|
|
703
|
+
detections.packageManager = packageManager;
|
|
704
|
+
return initTerminalResult("CONFLICT", detections, ["FRAMEWORK_UNDETECTED"]);
|
|
705
|
+
}
|
|
706
|
+
let testCommand = options.testCommand === undefined ? undefined : safeInitCommand(options.testCommand);
|
|
707
|
+
if (options.testCommand !== undefined && testCommand === undefined) {
|
|
708
|
+
const detections = initEmptyDetections(["TEST_COMMAND_INVALID"]);
|
|
709
|
+
detections.packageManager = packageManager;
|
|
710
|
+
detections.framework = framework;
|
|
711
|
+
return initTerminalResult("CONFLICT", detections, ["TEST_COMMAND_INVALID"]);
|
|
712
|
+
}
|
|
713
|
+
if (testCommand === undefined && testScript !== undefined)
|
|
714
|
+
testCommand = parseInitScript(testScript);
|
|
715
|
+
if (testCommand === undefined && testScript !== undefined) {
|
|
716
|
+
const detections = initEmptyDetections(["TEST_COMMAND_UNSAFE_OR_AMBIGUOUS"]);
|
|
717
|
+
detections.packageManager = packageManager;
|
|
718
|
+
detections.framework = framework;
|
|
719
|
+
return initTerminalResult("CONFLICT", detections, ["TEST_COMMAND_UNSAFE_OR_AMBIGUOUS"]);
|
|
720
|
+
}
|
|
721
|
+
testCommand ??=
|
|
722
|
+
framework === "pytest"
|
|
723
|
+
? packageManager === "uv"
|
|
724
|
+
? { executable: "uv", arguments: ["run", "pytest"] }
|
|
725
|
+
: packageManager === "poetry"
|
|
726
|
+
? { executable: "poetry", arguments: ["run", "pytest"] }
|
|
727
|
+
: { executable: "python", arguments: ["-m", "pytest"] }
|
|
728
|
+
: framework === "bun:test"
|
|
729
|
+
? { executable: "bun", arguments: ["test"] }
|
|
730
|
+
: framework === "vitest"
|
|
731
|
+
? { executable: "vitest", arguments: ["run"] }
|
|
732
|
+
: framework === "jest"
|
|
733
|
+
? { executable: "jest", arguments: [] }
|
|
734
|
+
: { executable: "node", arguments: ["--test"] };
|
|
735
|
+
const ciProviders = [
|
|
736
|
+
...new Set(inScopeFiles
|
|
737
|
+
.map(initCiProvider)
|
|
738
|
+
.filter((value) => value !== undefined)),
|
|
739
|
+
].sort();
|
|
740
|
+
let adapter;
|
|
741
|
+
let adapterRecommendation = "unavailable";
|
|
742
|
+
let adapterEvidencePath;
|
|
743
|
+
if (options.adapterConfigPath !== undefined) {
|
|
744
|
+
try {
|
|
745
|
+
const resolvedAdapter = await realpath(path.resolve(root, options.adapterConfigPath));
|
|
746
|
+
const relative = path.relative(root, resolvedAdapter).split(path.sep).join("/");
|
|
747
|
+
if (relative.startsWith("../") || relative === "" || path.isAbsolute(relative))
|
|
748
|
+
throw new Error("ADAPTER_CONFIG_OUTSIDE_REPOSITORY");
|
|
749
|
+
const adapterBytes = await readFile(resolvedAdapter);
|
|
750
|
+
const parsed = AdapterSchema.safeParse(JSON.parse(adapterBytes.toString("utf8")));
|
|
751
|
+
if (!parsed.success)
|
|
752
|
+
throw new Error("ADAPTER_CONFIG_INVALID");
|
|
753
|
+
if (safeInitCommand({
|
|
754
|
+
executable: parsed.data.executable,
|
|
755
|
+
arguments: parsed.data.kind === "testforge-command" ? parsed.data.arguments : [],
|
|
756
|
+
}) === undefined) {
|
|
757
|
+
throw new Error("ADAPTER_CONFIG_INVALID");
|
|
758
|
+
}
|
|
759
|
+
if (parsed.data.kind === "node-test" && framework !== "node:test") {
|
|
760
|
+
const incompatible = {
|
|
761
|
+
packageManager,
|
|
762
|
+
framework,
|
|
763
|
+
testCommand,
|
|
764
|
+
ciProviders,
|
|
765
|
+
adapterRecommendation: "unavailable",
|
|
766
|
+
reasonCodes: ["ADAPTER_FRAMEWORK_INCOMPATIBLE"],
|
|
767
|
+
};
|
|
768
|
+
return initTerminalResult("CONFLICT", incompatible, ["ADAPTER_FRAMEWORK_INCOMPATIBLE"]);
|
|
769
|
+
}
|
|
770
|
+
adapter = parsed.data;
|
|
771
|
+
adapterRecommendation = "operator-supplied";
|
|
772
|
+
adapterEvidencePath = relative;
|
|
773
|
+
evidenceBytes.set(relative, adapterBytes);
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
const detections = {
|
|
777
|
+
packageManager,
|
|
778
|
+
framework,
|
|
779
|
+
testCommand,
|
|
780
|
+
ciProviders,
|
|
781
|
+
adapterRecommendation: "unavailable",
|
|
782
|
+
reasonCodes: ["ADAPTER_CONFIG_INVALID"],
|
|
783
|
+
};
|
|
784
|
+
return initTerminalResult("CONFLICT", detections, ["ADAPTER_CONFIG_INVALID"]);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
else if (framework === "node:test") {
|
|
788
|
+
const testSources = inScopeFiles.filter(initNodeTestSource);
|
|
789
|
+
const pathMatchers = initCommandPathMatchers(testCommand, testSources);
|
|
790
|
+
const baseTestFiles = pathMatchers.length === 0
|
|
791
|
+
? testSources
|
|
792
|
+
: testSources.filter((file) => pathMatchers.some((matcher) => matcher(file)));
|
|
793
|
+
if (baseTestFiles.length > 0) {
|
|
794
|
+
adapter = { kind: "node-test", executable: "node", baseTestFiles };
|
|
795
|
+
adapterRecommendation = "node-test";
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const reasonCodes = adapter === undefined
|
|
799
|
+
? [framework === "node:test" ? "BASE_TEST_FILES_UNAVAILABLE" : "OFFICIAL_ADAPTER_UNAVAILABLE"]
|
|
800
|
+
: [];
|
|
801
|
+
const detections = {
|
|
802
|
+
packageManager,
|
|
803
|
+
framework,
|
|
804
|
+
testCommand,
|
|
805
|
+
ciProviders,
|
|
806
|
+
adapterRecommendation,
|
|
807
|
+
reasonCodes,
|
|
808
|
+
};
|
|
809
|
+
if (adapter === undefined)
|
|
810
|
+
return initTerminalResult("BLOCKED", detections, reasonCodes);
|
|
811
|
+
await options.afterEvidenceSnapshot?.();
|
|
812
|
+
const config = parseRepositoryInitConfig({
|
|
813
|
+
schemaVersion: "1.0.0",
|
|
814
|
+
repository: { root: ".", exclude: [".git", ".testforge", "node_modules"] },
|
|
815
|
+
packageManager,
|
|
816
|
+
framework,
|
|
817
|
+
testCommand,
|
|
818
|
+
adapter,
|
|
819
|
+
candidateRoots: [...candidateRoots],
|
|
820
|
+
});
|
|
821
|
+
const configContent = initJsonBytes(config);
|
|
822
|
+
const configDigest = repositoryInitConfigDigest(config);
|
|
823
|
+
const evidenceFiles = inScopeFiles.filter((file) => initEvidenceKind(file) !== undefined);
|
|
824
|
+
if (adapterEvidencePath !== undefined && !evidenceFiles.includes(adapterEvidencePath))
|
|
825
|
+
evidenceFiles.push(adapterEvidencePath);
|
|
826
|
+
evidenceFiles.sort(comparePortablePaths);
|
|
827
|
+
const evidence = [];
|
|
828
|
+
for (const file of evidenceFiles) {
|
|
829
|
+
const content = evidenceBytes.get(file);
|
|
830
|
+
if (content === undefined)
|
|
831
|
+
throw new Error("INIT_EVIDENCE_SNAPSHOT_INCONSISTENT");
|
|
832
|
+
evidence.push({
|
|
833
|
+
path: file,
|
|
834
|
+
digest: rawSha256(content),
|
|
835
|
+
kind: file === adapterEvidencePath
|
|
836
|
+
? "ADAPTER_CONFIG"
|
|
837
|
+
: (initEvidenceKind(file) ?? "ADAPTER_CONFIG"),
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const lockBase = {
|
|
841
|
+
schemaVersion: "1.0.0",
|
|
842
|
+
configDigest,
|
|
843
|
+
detector: { name: "assertledger-init", version: "1.0.0" },
|
|
844
|
+
evidence,
|
|
845
|
+
detections,
|
|
846
|
+
};
|
|
847
|
+
const lock = parseRepositoryInitLock({
|
|
848
|
+
...lockBase,
|
|
849
|
+
lockDigest: repositoryInitLockDigest(lockBase),
|
|
850
|
+
});
|
|
851
|
+
const lockContent = initJsonBytes(lock);
|
|
852
|
+
const plannedFiles = [
|
|
853
|
+
{ path: INIT_CONFIG_FILE, digest: rawSha256(configContent), content: configContent },
|
|
854
|
+
{ path: INIT_LOCK_FILE, digest: rawSha256(lockContent), content: lockContent },
|
|
855
|
+
];
|
|
856
|
+
let finalInScopeFiles;
|
|
857
|
+
try {
|
|
858
|
+
const finalInventory = await walkFiles(root, effectiveExcludes([...INIT_MANAGED_FILES]));
|
|
859
|
+
finalInScopeFiles = finalInventory.files.filter(outsideCandidateRoots);
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
finalInScopeFiles = [];
|
|
863
|
+
}
|
|
864
|
+
let repositoryChanged = JSON.stringify(finalInScopeFiles) !== JSON.stringify(inScopeFiles);
|
|
865
|
+
if (!repositoryChanged) {
|
|
866
|
+
for (const [file, snapshot] of evidenceBytes) {
|
|
867
|
+
try {
|
|
868
|
+
const current = await readFile(path.join(root, ...file.split("/")));
|
|
869
|
+
if (!current.equals(Buffer.from(snapshot))) {
|
|
870
|
+
repositoryChanged = true;
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
repositoryChanged = true;
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (repositoryChanged) {
|
|
881
|
+
return initTerminalResult("CONFLICT", { ...detections, reasonCodes: ["REPOSITORY_CHANGED_DURING_INIT"] }, ["REPOSITORY_CHANGED_DURING_INIT"]);
|
|
882
|
+
}
|
|
883
|
+
const readManaged = async (file) => {
|
|
884
|
+
const managedPath = path.join(root, file);
|
|
885
|
+
try {
|
|
886
|
+
const metadata = await lstat(managedPath);
|
|
887
|
+
if (!metadata.isFile())
|
|
888
|
+
return { kind: "UNSAFE" };
|
|
889
|
+
return { kind: "FILE", content: await readFile(managedPath, "utf8") };
|
|
890
|
+
}
|
|
891
|
+
catch (error) {
|
|
892
|
+
if (error.code === "ENOENT")
|
|
893
|
+
return { kind: "ABSENT" };
|
|
894
|
+
throw error;
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
const configRead = await readManaged(INIT_CONFIG_FILE);
|
|
898
|
+
const lockRead = await readManaged(INIT_LOCK_FILE);
|
|
899
|
+
if (configRead.kind === "UNSAFE" || lockRead.kind === "UNSAFE") {
|
|
900
|
+
return initTerminalResult("CONFLICT", { ...detections, reasonCodes: ["INIT_MANAGED_PATH_UNSAFE"] }, ["INIT_MANAGED_PATH_UNSAFE"]);
|
|
901
|
+
}
|
|
902
|
+
const existingConfig = configRead.kind === "FILE" ? configRead.content : undefined;
|
|
903
|
+
const existingLock = lockRead.kind === "FILE" ? lockRead.content : undefined;
|
|
904
|
+
if (existingConfig !== undefined && existingConfig !== configContent) {
|
|
905
|
+
return initTerminalResult("CONFLICT", { ...detections, reasonCodes: ["CONFIG_CONFLICT"] }, [
|
|
906
|
+
"CONFIG_CONFLICT",
|
|
907
|
+
]);
|
|
908
|
+
}
|
|
909
|
+
if (existingLock !== undefined) {
|
|
910
|
+
let structural;
|
|
911
|
+
try {
|
|
912
|
+
const value = JSON.parse(existingLock);
|
|
913
|
+
structural = parseRepositoryInitLock(value);
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
return initTerminalResult("CONFLICT", { ...detections, reasonCodes: ["LOCK_INVALID"] }, [
|
|
917
|
+
"LOCK_INVALID",
|
|
918
|
+
]);
|
|
919
|
+
}
|
|
920
|
+
if (structural.configDigest !== configDigest) {
|
|
921
|
+
return initTerminalResult("CONFLICT", { ...detections, reasonCodes: ["LOCK_CONFIG_MISMATCH"] }, ["LOCK_CONFIG_MISMATCH"]);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (existingConfig === configContent && existingLock === lockContent) {
|
|
925
|
+
return parseRepositoryInitResult({
|
|
926
|
+
schemaVersion: "1.0.0",
|
|
927
|
+
status: "UNCHANGED",
|
|
928
|
+
reasonCodes: [],
|
|
929
|
+
detections,
|
|
930
|
+
actions: [],
|
|
931
|
+
files: plannedFiles,
|
|
932
|
+
requiredOperatorInputs: ["worlds", "candidates"],
|
|
933
|
+
nextCommands: [{ executable: "assertledger", arguments: ["audit", ".", "--json"] }],
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
const actions = [];
|
|
937
|
+
if (existingConfig === undefined)
|
|
938
|
+
actions.push({ kind: "CREATE", path: INIT_CONFIG_FILE });
|
|
939
|
+
if (existingLock === undefined)
|
|
940
|
+
actions.push({ kind: "CREATE", path: INIT_LOCK_FILE });
|
|
941
|
+
else if (existingLock !== lockContent)
|
|
942
|
+
actions.push({ kind: "REGENERATE", path: INIT_LOCK_FILE });
|
|
943
|
+
actions.sort((left, right) => left.path.localeCompare(right.path));
|
|
944
|
+
const status = options.dryRun ? "WOULD_CREATE" : "CREATED";
|
|
945
|
+
const result = parseRepositoryInitResult({
|
|
946
|
+
schemaVersion: "1.0.0",
|
|
947
|
+
status,
|
|
948
|
+
reasonCodes: [],
|
|
949
|
+
detections,
|
|
950
|
+
actions,
|
|
951
|
+
files: plannedFiles,
|
|
952
|
+
requiredOperatorInputs: ["worlds", "candidates"],
|
|
953
|
+
nextCommands: [{ executable: "assertledger", arguments: ["audit", ".", "--json"] }],
|
|
954
|
+
});
|
|
955
|
+
if (!options.dryRun) {
|
|
956
|
+
for (const action of actions) {
|
|
957
|
+
const file = plannedFiles.find((candidate) => candidate.path === action.path);
|
|
958
|
+
if (file === undefined)
|
|
959
|
+
throw new Error("INIT_PLAN_INCONSISTENT");
|
|
960
|
+
await atomicInitWrite(root, file.path, file.content);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return result;
|
|
964
|
+
}
|
|
965
|
+
async function probeRuntimeTemporaryWorkspace() {
|
|
966
|
+
let temporaryRoot;
|
|
967
|
+
let failure;
|
|
968
|
+
try {
|
|
969
|
+
temporaryRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "assertledger-runtime-doctor-")));
|
|
970
|
+
await writeFile(path.join(temporaryRoot, "permission-probe"), "assertledger\n", {
|
|
971
|
+
flag: "wx",
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
failure = error;
|
|
976
|
+
}
|
|
977
|
+
if (temporaryRoot !== undefined) {
|
|
978
|
+
try {
|
|
979
|
+
await removeTemporaryDirectory(temporaryRoot);
|
|
980
|
+
let removed = false;
|
|
981
|
+
try {
|
|
982
|
+
await lstat(temporaryRoot);
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
if (error.code === "ENOENT")
|
|
986
|
+
removed = true;
|
|
987
|
+
else
|
|
988
|
+
failure ??= error;
|
|
989
|
+
}
|
|
990
|
+
if (!removed)
|
|
991
|
+
failure ??= new Error("RUNTIME_TEMPORARY_WORKSPACE_CLEANUP_FAILED");
|
|
992
|
+
}
|
|
993
|
+
catch (error) {
|
|
994
|
+
failure ??= error;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (failure !== undefined)
|
|
998
|
+
throw failure;
|
|
999
|
+
}
|
|
1000
|
+
export async function doctorRepositoryRuntime(requestedRoot, options) {
|
|
1001
|
+
const repositoryRoot = path.resolve(requestedRoot);
|
|
1002
|
+
let resolvedExecutable = process.execPath;
|
|
1003
|
+
return runRuntimeDoctorChecks(repositoryRoot, options.allowUnsafeExecution, {
|
|
1004
|
+
inspectConfiguration: () => initializeRepository(repositoryRoot, { dryRun: true }),
|
|
1005
|
+
probeExecutable: async () => {
|
|
1006
|
+
const identity = await probeNodeTestExecutable(process.execPath, repositoryRoot, [], 5_000, CONTROLLED_REPORT_MAXIMUM_BYTES);
|
|
1007
|
+
resolvedExecutable = identity.resolvedExecutable;
|
|
1008
|
+
return { nodeVersion: identity.nodeVersion };
|
|
1009
|
+
},
|
|
1010
|
+
probeDependencies: async () => {
|
|
1011
|
+
const dependencyProbe = await runProcess({
|
|
1012
|
+
executable: resolvedExecutable,
|
|
1013
|
+
args: [
|
|
1014
|
+
"-e",
|
|
1015
|
+
'import("node:test").then(() => process.exit(0)).catch(() => process.exit(1))',
|
|
1016
|
+
],
|
|
1017
|
+
cwd: repositoryRoot,
|
|
1018
|
+
environment: environmentFromAllowlist([]),
|
|
1019
|
+
timeoutMs: 5_000,
|
|
1020
|
+
maximumOutputBytes: CONTROLLED_REPORT_MAXIMUM_BYTES,
|
|
1021
|
+
});
|
|
1022
|
+
if (dependencyProbe.outcome !== "PASS" ||
|
|
1023
|
+
dependencyProbe.stdout.totalBytes !== 0 ||
|
|
1024
|
+
dependencyProbe.stderr.totalBytes !== 0) {
|
|
1025
|
+
throw new Error("NODE_TEST_DEPENDENCY_UNAVAILABLE");
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
probeTemporaryWorkspace: probeRuntimeTemporaryWorkspace,
|
|
1029
|
+
runSyntheticPreflight: async () => {
|
|
1030
|
+
await runNodeTestRuntimePreflight({
|
|
1031
|
+
executable: resolvedExecutable,
|
|
1032
|
+
reporterSource: NODE_TEST_REPORTER_SOURCE,
|
|
1033
|
+
environment: environmentFromAllowlist([]),
|
|
1034
|
+
timeoutMs: 5_000,
|
|
1035
|
+
maximumOutputBytes: CONTROLLED_REPORT_MAXIMUM_BYTES,
|
|
1036
|
+
processRunner: runProcess,
|
|
1037
|
+
});
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
function languageForFile(file) {
|
|
1042
|
+
const extension = path.extname(file).toLowerCase();
|
|
1043
|
+
return {
|
|
1044
|
+
".js": "JavaScript",
|
|
1045
|
+
".cjs": "JavaScript",
|
|
1046
|
+
".mjs": "JavaScript",
|
|
1047
|
+
".ts": "TypeScript",
|
|
1048
|
+
".cts": "TypeScript",
|
|
1049
|
+
".mts": "TypeScript",
|
|
1050
|
+
".py": "Python",
|
|
1051
|
+
".go": "Go",
|
|
1052
|
+
".rs": "Rust",
|
|
1053
|
+
".java": "Java",
|
|
1054
|
+
}[extension];
|
|
1055
|
+
}
|
|
1056
|
+
function packageDependencyNames(text) {
|
|
1057
|
+
try {
|
|
1058
|
+
const parsed = JSON.parse(text);
|
|
1059
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1060
|
+
return [];
|
|
1061
|
+
const document = parsed;
|
|
1062
|
+
const names = [];
|
|
1063
|
+
for (const field of [
|
|
1064
|
+
"dependencies",
|
|
1065
|
+
"devDependencies",
|
|
1066
|
+
"peerDependencies",
|
|
1067
|
+
"optionalDependencies",
|
|
1068
|
+
]) {
|
|
1069
|
+
const dependencies = document[field];
|
|
1070
|
+
if (dependencies === null ||
|
|
1071
|
+
typeof dependencies !== "object" ||
|
|
1072
|
+
Array.isArray(dependencies)) {
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
names.push(...Object.keys(dependencies));
|
|
1076
|
+
}
|
|
1077
|
+
return names;
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
return [];
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
function isTestSourcePath(file) {
|
|
1084
|
+
const segments = file.toLowerCase().split("/");
|
|
1085
|
+
const basename = segments.at(-1) ?? "";
|
|
1086
|
+
return (segments.some((segment) => segment === "test" || segment === "tests" || segment === "__tests__") || /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(basename));
|
|
1087
|
+
}
|
|
1088
|
+
function javascriptTokens(source) {
|
|
1089
|
+
const tokens = [];
|
|
1090
|
+
let index = 0;
|
|
1091
|
+
while (index < source.length) {
|
|
1092
|
+
const current = source[index];
|
|
1093
|
+
const next = source[index + 1];
|
|
1094
|
+
if (/\s/u.test(current)) {
|
|
1095
|
+
index += 1;
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
if (current === "/" && next === "/") {
|
|
1099
|
+
index = source.indexOf("\n", index + 2);
|
|
1100
|
+
if (index === -1)
|
|
1101
|
+
break;
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
if (current === "/" && next === "*") {
|
|
1105
|
+
const end = source.indexOf("*/", index + 2);
|
|
1106
|
+
if (end === -1)
|
|
1107
|
+
break;
|
|
1108
|
+
index = end + 2;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (current === "/" &&
|
|
1112
|
+
(tokens.length === 0 ||
|
|
1113
|
+
/^[([{,:;=!?&|+*%^~<>-]$/u.test(tokens.at(-1)?.value ?? "") ||
|
|
1114
|
+
[
|
|
1115
|
+
"await",
|
|
1116
|
+
"case",
|
|
1117
|
+
"delete",
|
|
1118
|
+
"in",
|
|
1119
|
+
"instanceof",
|
|
1120
|
+
"of",
|
|
1121
|
+
"return",
|
|
1122
|
+
"throw",
|
|
1123
|
+
"typeof",
|
|
1124
|
+
"void",
|
|
1125
|
+
"yield",
|
|
1126
|
+
].includes(tokens.at(-1)?.value ?? ""))) {
|
|
1127
|
+
index += 1;
|
|
1128
|
+
let inCharacterClass = false;
|
|
1129
|
+
while (index < source.length) {
|
|
1130
|
+
if (source[index] === "\\") {
|
|
1131
|
+
index += 2;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
if (source[index] === "[")
|
|
1135
|
+
inCharacterClass = true;
|
|
1136
|
+
if (source[index] === "]")
|
|
1137
|
+
inCharacterClass = false;
|
|
1138
|
+
if (source[index] === "/" && !inCharacterClass) {
|
|
1139
|
+
index += 1;
|
|
1140
|
+
while (index < source.length && /[A-Za-z]/u.test(source[index]))
|
|
1141
|
+
index += 1;
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
index += 1;
|
|
1145
|
+
}
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
if (current === '"' || current === "'") {
|
|
1149
|
+
const quote = current;
|
|
1150
|
+
let value = "";
|
|
1151
|
+
index += 1;
|
|
1152
|
+
while (index < source.length && source[index] !== quote) {
|
|
1153
|
+
if (source[index] === "\\") {
|
|
1154
|
+
index += 2;
|
|
1155
|
+
value = "";
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
value += source[index];
|
|
1159
|
+
index += 1;
|
|
1160
|
+
}
|
|
1161
|
+
if (index >= source.length)
|
|
1162
|
+
break;
|
|
1163
|
+
tokens.push({ kind: "string", value });
|
|
1164
|
+
index += 1;
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
if (current === "`") {
|
|
1168
|
+
index += 1;
|
|
1169
|
+
while (index < source.length && source[index] !== "`") {
|
|
1170
|
+
index += source[index] === "\\" ? 2 : 1;
|
|
1171
|
+
}
|
|
1172
|
+
index += 1;
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (/[A-Za-z0-9_$]/u.test(current)) {
|
|
1176
|
+
const start = index;
|
|
1177
|
+
index += 1;
|
|
1178
|
+
while (index < source.length && /[A-Za-z0-9_$]/u.test(source[index])) {
|
|
1179
|
+
index += 1;
|
|
1180
|
+
}
|
|
1181
|
+
tokens.push({ kind: "identifier", value: source.slice(start, index) });
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
tokens.push({ kind: "punctuator", value: current });
|
|
1185
|
+
index += 1;
|
|
1186
|
+
}
|
|
1187
|
+
return tokens;
|
|
1188
|
+
}
|
|
1189
|
+
function importsNodeTest(text) {
|
|
1190
|
+
const tokens = javascriptTokens(text);
|
|
1191
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
1192
|
+
const token = tokens[index];
|
|
1193
|
+
const previous = tokens[index - 1];
|
|
1194
|
+
if (token?.kind !== "identifier" || previous?.value === ".")
|
|
1195
|
+
continue;
|
|
1196
|
+
if (token.value === "require" &&
|
|
1197
|
+
tokens[index + 1]?.value === "(" &&
|
|
1198
|
+
tokens[index + 2]?.kind === "string" &&
|
|
1199
|
+
tokens[index + 2]?.value === "node:test") {
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
if (token.value !== "import")
|
|
1203
|
+
continue;
|
|
1204
|
+
if (tokens[index + 1]?.kind === "string") {
|
|
1205
|
+
if (tokens[index + 1]?.value === "node:test")
|
|
1206
|
+
return true;
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
if (tokens[index + 1]?.value === "(" && tokens[index + 2]?.kind === "string") {
|
|
1210
|
+
if (tokens[index + 2]?.value === "node:test")
|
|
1211
|
+
return true;
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
|
1215
|
+
const candidate = tokens[cursor];
|
|
1216
|
+
if (candidate?.value === ";")
|
|
1217
|
+
break;
|
|
1218
|
+
if (candidate?.kind === "identifier" && candidate.value === "from") {
|
|
1219
|
+
if (tokens[cursor + 1]?.kind === "string" && tokens[cursor + 1]?.value === "node:test") {
|
|
1220
|
+
return true;
|
|
1221
|
+
}
|
|
1222
|
+
break;
|
|
1223
|
+
}
|
|
1224
|
+
if (candidate?.kind === "identifier" &&
|
|
1225
|
+
["class", "const", "export", "function", "import", "let", "var"].includes(candidate.value)) {
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return false;
|
|
1231
|
+
}
|
|
1232
|
+
function dependencyArrayIncludesPytest(value) {
|
|
1233
|
+
return [...value.matchAll(/["']([^"']+)["']/gu)].some((match) => /^pytest(?:\[[^\]]+\])?(?:\s*[<>=!~].*)?$/iu.test(match[1]?.trim() ?? ""));
|
|
1234
|
+
}
|
|
1235
|
+
function tomlArrayAssignments(body, keyPattern) {
|
|
1236
|
+
const starts = new RegExp(`^\\s*${keyPattern}\\s*=\\s*\\[`, "gimu");
|
|
1237
|
+
const arrays = [];
|
|
1238
|
+
for (const match of body.matchAll(starts)) {
|
|
1239
|
+
let index = (match.index ?? 0) + match[0].length;
|
|
1240
|
+
const start = index;
|
|
1241
|
+
let quote;
|
|
1242
|
+
let depth = 1;
|
|
1243
|
+
while (index < body.length && depth > 0) {
|
|
1244
|
+
const current = body[index];
|
|
1245
|
+
if (quote !== undefined) {
|
|
1246
|
+
if (current === "\\" && quote === '"')
|
|
1247
|
+
index += 2;
|
|
1248
|
+
else {
|
|
1249
|
+
if (current === quote)
|
|
1250
|
+
quote = undefined;
|
|
1251
|
+
index += 1;
|
|
1252
|
+
}
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
if (current === '"' || current === "'")
|
|
1256
|
+
quote = current;
|
|
1257
|
+
else if (current === "[")
|
|
1258
|
+
depth += 1;
|
|
1259
|
+
else if (current === "]")
|
|
1260
|
+
depth -= 1;
|
|
1261
|
+
index += 1;
|
|
1262
|
+
}
|
|
1263
|
+
if (depth === 0)
|
|
1264
|
+
arrays.push(body.slice(start, index - 1));
|
|
1265
|
+
}
|
|
1266
|
+
return arrays;
|
|
1267
|
+
}
|
|
1268
|
+
function pyprojectDeclaresPytest(text) {
|
|
1269
|
+
const sections = [...text.matchAll(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/gmu)];
|
|
1270
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
1271
|
+
const section = sections[index];
|
|
1272
|
+
if (section === undefined)
|
|
1273
|
+
continue;
|
|
1274
|
+
const name = (section[1] ?? "").trim().toLowerCase();
|
|
1275
|
+
if (name === "tool.pytest" || name.startsWith("tool.pytest."))
|
|
1276
|
+
return true;
|
|
1277
|
+
const bodyStart = (section.index ?? 0) + section[0].length;
|
|
1278
|
+
const bodyEnd = sections[index + 1]?.index ?? text.length;
|
|
1279
|
+
const body = text.slice(bodyStart, bodyEnd);
|
|
1280
|
+
if ((name === "tool.poetry.dependencies" ||
|
|
1281
|
+
/^tool\.poetry\.group\.[^.]+\.dependencies$/u.test(name)) &&
|
|
1282
|
+
/^\s*pytest(?:\[[^\]]+\])?\s*=/imu.test(body)) {
|
|
1283
|
+
return true;
|
|
1284
|
+
}
|
|
1285
|
+
const arrayKeyPattern = name === "project"
|
|
1286
|
+
? "dependencies"
|
|
1287
|
+
: name === "project.optional-dependencies" ||
|
|
1288
|
+
name === "dependency-groups" ||
|
|
1289
|
+
name === "tool.pdm.dev-dependencies"
|
|
1290
|
+
? "[A-Za-z0-9_-]+"
|
|
1291
|
+
: name === "tool.uv"
|
|
1292
|
+
? "dev-dependencies"
|
|
1293
|
+
: undefined;
|
|
1294
|
+
if (arrayKeyPattern !== undefined &&
|
|
1295
|
+
tomlArrayAssignments(body, arrayKeyPattern).some(dependencyArrayIncludesPytest)) {
|
|
1296
|
+
return true;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
return false;
|
|
1300
|
+
}
|
|
1301
|
+
function detectFrameworkEvidence(file, text, frameworks) {
|
|
1302
|
+
const normalized = file.toLowerCase();
|
|
1303
|
+
const basename = path.posix.basename(normalized);
|
|
1304
|
+
if (basename === "package.json") {
|
|
1305
|
+
for (const dependency of packageDependencyNames(text)) {
|
|
1306
|
+
const name = dependency.toLowerCase();
|
|
1307
|
+
if (name === "vitest" || name.startsWith("@vitest/"))
|
|
1308
|
+
frameworks.add("vitest");
|
|
1309
|
+
if (name === "jest" || name.startsWith("@jest/"))
|
|
1310
|
+
frameworks.add("jest");
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
if (/^vitest\.config\.(?:[cm]?[jt]s|json)$/u.test(basename))
|
|
1314
|
+
frameworks.add("vitest");
|
|
1315
|
+
if (/^jest\.config\.(?:[cm]?[jt]s|json)$/u.test(basename))
|
|
1316
|
+
frameworks.add("jest");
|
|
1317
|
+
if (basename === "pytest.ini")
|
|
1318
|
+
frameworks.add("pytest");
|
|
1319
|
+
if ((basename === "setup.cfg" && /^\s*\[tool:pytest\]\s*$/imu.test(text)) ||
|
|
1320
|
+
(basename === "tox.ini" && /^\s*\[pytest\]\s*$/imu.test(text)) ||
|
|
1321
|
+
(basename === "pyproject.toml" && pyprojectDeclaresPytest(text)) ||
|
|
1322
|
+
(/^requirements(?:[-_.][a-z0-9_-]+)?\.txt$/u.test(basename) &&
|
|
1323
|
+
/^\s*pytest(?:\[[^\]]+\])?\s*(?:[<>=!~].*)?(?:#.*)?$/imu.test(text))) {
|
|
1324
|
+
frameworks.add("pytest");
|
|
1325
|
+
}
|
|
1326
|
+
if (isTestSourcePath(normalized) && importsNodeTest(text))
|
|
1327
|
+
frameworks.add("node:test");
|
|
1328
|
+
}
|
|
1329
|
+
async function resolveRepositoryRoot(rootInput) {
|
|
1330
|
+
const requestedRoot = requireString(rootInput, "repository_root");
|
|
1331
|
+
try {
|
|
1332
|
+
const root = await realpath(requestedRoot);
|
|
1333
|
+
if (!(await stat(root)).isDirectory())
|
|
1334
|
+
throw new TypeError("not a directory");
|
|
1335
|
+
return root;
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
throw new TypeError("INVALID_REPOSITORY_ROOT");
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
export async function analyzeRepository(rootInput) {
|
|
1342
|
+
const root = await resolveRepositoryRoot(rootInput);
|
|
1343
|
+
const { files } = await walkFiles(root, effectiveExcludes([]));
|
|
1344
|
+
const digest = createHash("sha256");
|
|
1345
|
+
const languageCounts = new Map();
|
|
1346
|
+
const frameworks = new Set();
|
|
1347
|
+
for (const file of files) {
|
|
1348
|
+
const content = await readFile(path.join(root, ...file.split("/")));
|
|
1349
|
+
digest.update(Buffer.from(`${file}\0${content.byteLength}\0`, "utf8"));
|
|
1350
|
+
digest.update(content);
|
|
1351
|
+
const language = languageForFile(file);
|
|
1352
|
+
if (language)
|
|
1353
|
+
languageCounts.set(language, (languageCounts.get(language) ?? 0) + 1);
|
|
1354
|
+
const text = content.toString("utf8");
|
|
1355
|
+
detectFrameworkEvidence(file, text, frameworks);
|
|
1356
|
+
}
|
|
1357
|
+
return {
|
|
1358
|
+
root,
|
|
1359
|
+
fileCount: files.length,
|
|
1360
|
+
files,
|
|
1361
|
+
languages: [...languageCounts.entries()]
|
|
1362
|
+
.map(([name, count]) => ({ name, files: count }))
|
|
1363
|
+
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)),
|
|
1364
|
+
detectedTestFrameworks: [...frameworks].sort(),
|
|
1365
|
+
repositoryDigest: `${SHA256_PREFIX}${digest.digest("hex")}`,
|
|
1366
|
+
capabilities: {
|
|
1367
|
+
canExecuteCandidates: true,
|
|
1368
|
+
supportedAdapters: ["node-test", "testforge-command"],
|
|
1369
|
+
isolationLevels: ["UNSANDBOXED"],
|
|
1370
|
+
},
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
function isAuditJavaScript(file) {
|
|
1374
|
+
return /\.(?:[cm]?[jt]sx?)$/u.test(file);
|
|
1375
|
+
}
|
|
1376
|
+
function isAuditTest(file) {
|
|
1377
|
+
return (/(?:^|\/)(?:tests?|__tests__)(?:\/|$)/u.test(file) ||
|
|
1378
|
+
/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(file));
|
|
1379
|
+
}
|
|
1380
|
+
function auditAst(text) {
|
|
1381
|
+
let decisionPoints = 0;
|
|
1382
|
+
const exports = new Set();
|
|
1383
|
+
const identifiers = new Set();
|
|
1384
|
+
const weak = new Map();
|
|
1385
|
+
const lexicalTokens = [];
|
|
1386
|
+
const addWeak = (category) => weak.set(category, (weak.get(category) ?? 0) + 1);
|
|
1387
|
+
const scanner = createScanner(true, undefined, text);
|
|
1388
|
+
const decisionKinds = new Set([
|
|
1389
|
+
SyntaxKind.IfKeyword,
|
|
1390
|
+
SyntaxKind.ForKeyword,
|
|
1391
|
+
SyntaxKind.WhileKeyword,
|
|
1392
|
+
SyntaxKind.DoKeyword,
|
|
1393
|
+
SyntaxKind.CatchKeyword,
|
|
1394
|
+
SyntaxKind.CaseKeyword,
|
|
1395
|
+
SyntaxKind.QuestionToken,
|
|
1396
|
+
SyntaxKind.AmpersandAmpersandToken,
|
|
1397
|
+
SyntaxKind.BarBarToken,
|
|
1398
|
+
SyntaxKind.QuestionQuestionToken,
|
|
1399
|
+
]);
|
|
1400
|
+
for (let kind = scanner.scan(); kind !== SyntaxKind.EndOfFile; kind = scanner.scan()) {
|
|
1401
|
+
if (kind === SyntaxKind.Identifier)
|
|
1402
|
+
identifiers.add(scanner.getTokenValue());
|
|
1403
|
+
if (decisionKinds.has(kind))
|
|
1404
|
+
decisionPoints += 1;
|
|
1405
|
+
lexicalTokens.push(kind === SyntaxKind.StringLiteral ||
|
|
1406
|
+
kind === SyntaxKind.NoSubstitutionTemplateLiteral ||
|
|
1407
|
+
kind === SyntaxKind.TemplateHead ||
|
|
1408
|
+
kind === SyntaxKind.TemplateMiddle ||
|
|
1409
|
+
kind === SyntaxKind.TemplateTail
|
|
1410
|
+
? '"__LITERAL__"'
|
|
1411
|
+
: scanner.getTokenText());
|
|
1412
|
+
}
|
|
1413
|
+
const lexicalText = lexicalTokens.join(" ");
|
|
1414
|
+
for (const match of lexicalText.matchAll(/\bexport\s+(?:declare\s+)?(?:async\s+)?(?:const|let|var|function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gu)) {
|
|
1415
|
+
if (match[1])
|
|
1416
|
+
exports.add(match[1]);
|
|
1417
|
+
}
|
|
1418
|
+
for (const match of lexicalText.matchAll(/\bexport\s*\{([^}]*)\}/gu)) {
|
|
1419
|
+
for (const item of (match[1] ?? "").split(",")) {
|
|
1420
|
+
const name = item
|
|
1421
|
+
.trim()
|
|
1422
|
+
.split(/\s+as\s+/u)
|
|
1423
|
+
.at(-1);
|
|
1424
|
+
if (name && /^[A-Za-z_$][\w$]*$/u.test(name))
|
|
1425
|
+
exports.add(name);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
if (/\bexport\s+default\b/u.test(lexicalText))
|
|
1429
|
+
exports.add("default");
|
|
1430
|
+
for (const _ of lexicalText.matchAll(/(?:\bassert(?:\s*\.\s*ok)?|\bexpect\s*\([^;]*?\)\s*\.\s*(?:toBeTruthy|toBeFalsy))\s*\(/gu))
|
|
1431
|
+
addWeak("TRUTHINESS_ONLY");
|
|
1432
|
+
for (const _ of lexicalText.matchAll(/(?:\bassert\s*\.\s*doesNotThrow\s*\(|\bexpect\s*\([^;]*?\)\s*\.\s*not\s*\.\s*toThrow\s*\()/gu))
|
|
1433
|
+
addWeak("NON_THROW_ONLY");
|
|
1434
|
+
for (const _ of lexicalText.matchAll(/\bexpect\s*\([^;]*?\)\s*\.\s*toBeDefined\s*\(/gu))
|
|
1435
|
+
addWeak("DEFINEDNESS_ONLY");
|
|
1436
|
+
for (const _ of lexicalText.matchAll(/(?:\bassert\s*\.\s*(?:equal|strictEqual)\s*\(\s*typeof\b[^,]+,\s*"__LITERAL__"|\bexpect\s*\(\s*typeof\b[^)]*\)\s*\.\s*toBe\s*\(\s*"__LITERAL__")/gu))
|
|
1437
|
+
addWeak("TYPE_ONLY");
|
|
1438
|
+
for (const _ of lexicalText.matchAll(/(?:\bassert\s*\.\s*(?:equal|strictEqual)\s*\([^,]+,\s*(?:true|false)|\bexpect\s*\([^)]*\)\s*\.\s*toBe\s*\(\s*(?:true|false))/gu))
|
|
1439
|
+
addWeak("CONSTANT_BOOLEAN_EQUALITY");
|
|
1440
|
+
return { decisionPoints, exports: [...exports].sort(), identifiers, weak };
|
|
1441
|
+
}
|
|
1442
|
+
async function auditGit(root, args) {
|
|
1443
|
+
return new Promise((resolve) => {
|
|
1444
|
+
const child = spawn("git", args, {
|
|
1445
|
+
cwd: root,
|
|
1446
|
+
shell: false,
|
|
1447
|
+
windowsHide: true,
|
|
1448
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1449
|
+
});
|
|
1450
|
+
const chunks = [];
|
|
1451
|
+
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
1452
|
+
child.on("error", () => resolve({ ok: false, stdout: "" }));
|
|
1453
|
+
child.on("close", (code) => resolve({ ok: code === 0, stdout: Buffer.concat(chunks).toString("utf8").trim() }));
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
function associatedAuditTests(source, tests) {
|
|
1457
|
+
const basename = path.posix.basename(source).replace(/\.[^.]+$/u, "");
|
|
1458
|
+
return tests
|
|
1459
|
+
.filter((test) => path.posix.basename(test).replace(/\.(?:test|spec)?\.[^.]+$/u, "") === basename)
|
|
1460
|
+
.sort();
|
|
1461
|
+
}
|
|
1462
|
+
function auditModulePrefix(file) {
|
|
1463
|
+
const segments = file.split("/");
|
|
1464
|
+
return segments.length > 2 ? segments.slice(0, 2).join("/") : (segments[0] ?? file);
|
|
1465
|
+
}
|
|
1466
|
+
export async function auditRepository(rootInput, options = {}) {
|
|
1467
|
+
const root = await resolveRepositoryRoot(rootInput);
|
|
1468
|
+
const initial = (await analyzeRepository(root));
|
|
1469
|
+
const fileContents = new Map();
|
|
1470
|
+
let repositoryBytes = 0;
|
|
1471
|
+
for (const file of initial.files) {
|
|
1472
|
+
const content = await readFile(path.join(root, ...file.split("/")));
|
|
1473
|
+
fileContents.set(file, content);
|
|
1474
|
+
repositoryBytes += content.byteLength;
|
|
1475
|
+
}
|
|
1476
|
+
await options.afterInitialInventory?.();
|
|
1477
|
+
const testIdentifiers = new Set();
|
|
1478
|
+
const astByFile = new Map();
|
|
1479
|
+
for (const [file, content] of fileContents) {
|
|
1480
|
+
if (!isAuditJavaScript(file))
|
|
1481
|
+
continue;
|
|
1482
|
+
const ast = auditAst(content.toString("utf8"));
|
|
1483
|
+
astByFile.set(file, ast);
|
|
1484
|
+
if (isAuditTest(file))
|
|
1485
|
+
for (const identifier of ast.identifiers)
|
|
1486
|
+
testIdentifiers.add(identifier);
|
|
1487
|
+
}
|
|
1488
|
+
const reasonCodes = new Set();
|
|
1489
|
+
let gitSupported = false;
|
|
1490
|
+
let headTimestamp = null;
|
|
1491
|
+
let shallow = null;
|
|
1492
|
+
let truncated = null;
|
|
1493
|
+
const commitCounts = new Map();
|
|
1494
|
+
const fixCounts = new Map();
|
|
1495
|
+
const latestTimes = new Map();
|
|
1496
|
+
let trackedFiles = null;
|
|
1497
|
+
if (options.noGit)
|
|
1498
|
+
reasonCodes.add("GIT_DISABLED");
|
|
1499
|
+
else {
|
|
1500
|
+
const worktree = await auditGit(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
1501
|
+
if (!worktree.ok || worktree.stdout !== "true")
|
|
1502
|
+
reasonCodes.add("GIT_UNAVAILABLE");
|
|
1503
|
+
else {
|
|
1504
|
+
const head = await auditGit(root, ["show", "-s", "--format=%ct", "HEAD"]);
|
|
1505
|
+
const shallowResult = await auditGit(root, ["rev-parse", "--is-shallow-repository"]);
|
|
1506
|
+
if (head.ok && /^\d+$/u.test(head.stdout) && shallowResult.ok) {
|
|
1507
|
+
headTimestamp = Number(head.stdout);
|
|
1508
|
+
shallow = shallowResult.stdout === "true";
|
|
1509
|
+
if (shallow)
|
|
1510
|
+
reasonCodes.add("GIT_HISTORY_SHALLOW");
|
|
1511
|
+
else {
|
|
1512
|
+
gitSupported = true;
|
|
1513
|
+
const tracked = await auditGit(root, ["ls-files", "-z"]);
|
|
1514
|
+
if (tracked.ok) {
|
|
1515
|
+
trackedFiles = new Set(tracked.stdout
|
|
1516
|
+
.split("\0")
|
|
1517
|
+
.filter(Boolean)
|
|
1518
|
+
.map((file) => file.replaceAll("\\", "/")));
|
|
1519
|
+
}
|
|
1520
|
+
else {
|
|
1521
|
+
gitSupported = false;
|
|
1522
|
+
reasonCodes.add("GIT_INDEX_UNAVAILABLE");
|
|
1523
|
+
}
|
|
1524
|
+
const since = String(headTimestamp - 90 * 24 * 60 * 60);
|
|
1525
|
+
const history = await auditGit(root, [
|
|
1526
|
+
"log",
|
|
1527
|
+
"-n",
|
|
1528
|
+
"1001",
|
|
1529
|
+
`--since=@${since}`,
|
|
1530
|
+
"--format=@@%ct%x09%s",
|
|
1531
|
+
"--name-only",
|
|
1532
|
+
"--",
|
|
1533
|
+
]);
|
|
1534
|
+
if (!history.ok) {
|
|
1535
|
+
gitSupported = false;
|
|
1536
|
+
reasonCodes.add("GIT_HISTORY_UNAVAILABLE");
|
|
1537
|
+
}
|
|
1538
|
+
else {
|
|
1539
|
+
const commits = history.stdout.split(/^@@/mu).filter(Boolean);
|
|
1540
|
+
truncated = commits.length > 1000;
|
|
1541
|
+
if (truncated)
|
|
1542
|
+
reasonCodes.add("GIT_HISTORY_TRUNCATED");
|
|
1543
|
+
for (const block of commits.slice(0, 1000)) {
|
|
1544
|
+
const [header = "", ...changed] = block.split(/\r?\n/u).filter(Boolean);
|
|
1545
|
+
const separator = header.indexOf("\t");
|
|
1546
|
+
const message = separator < 0 ? header : header.slice(separator + 1);
|
|
1547
|
+
const fix = /(?:^|\b)(?:fix|bug|repair|hotfix|regression)(?:\b|:)/iu.test(message);
|
|
1548
|
+
for (const file of new Set(changed.map((entry) => entry.replaceAll("\\", "/")))) {
|
|
1549
|
+
commitCounts.set(file, (commitCounts.get(file) ?? 0) + 1);
|
|
1550
|
+
if (fix)
|
|
1551
|
+
fixCounts.set(file, (fixCounts.get(file) ?? 0) + 1);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const latestHistory = await auditGit(root, [
|
|
1555
|
+
"log",
|
|
1556
|
+
"-n",
|
|
1557
|
+
"1001",
|
|
1558
|
+
"--format=@@%ct",
|
|
1559
|
+
"--name-only",
|
|
1560
|
+
"--",
|
|
1561
|
+
]);
|
|
1562
|
+
if (!latestHistory.ok)
|
|
1563
|
+
reasonCodes.add("GIT_LATEST_HISTORY_UNAVAILABLE");
|
|
1564
|
+
else {
|
|
1565
|
+
const latestCommits = latestHistory.stdout.split(/^@@/mu).filter(Boolean);
|
|
1566
|
+
if (latestCommits.length > 1000) {
|
|
1567
|
+
truncated = true;
|
|
1568
|
+
reasonCodes.add("GIT_HISTORY_TRUNCATED");
|
|
1569
|
+
}
|
|
1570
|
+
for (const block of latestCommits.slice(0, 1000)) {
|
|
1571
|
+
const [timestamp = "", ...changed] = block.split(/\r?\n/u).filter(Boolean);
|
|
1572
|
+
if (!/^\d+$/u.test(timestamp))
|
|
1573
|
+
continue;
|
|
1574
|
+
for (const file of new Set(changed.map((entry) => entry.replaceAll("\\", "/")))) {
|
|
1575
|
+
if (!latestTimes.has(file))
|
|
1576
|
+
latestTimes.set(file, Number(timestamp));
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
if (truncated)
|
|
1581
|
+
gitSupported = false;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
else
|
|
1586
|
+
reasonCodes.add("GIT_HEAD_UNAVAILABLE");
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const testFiles = initial.files.filter(isAuditTest);
|
|
1590
|
+
const files = initial.files.map((file) => {
|
|
1591
|
+
const ast = astByFile.get(file);
|
|
1592
|
+
const associatedTests = isAuditTest(file) ? [] : associatedAuditTests(file, testFiles);
|
|
1593
|
+
const sourceTime = latestTimes.get(file);
|
|
1594
|
+
const testTimes = associatedTests
|
|
1595
|
+
.map((test) => latestTimes.get(test))
|
|
1596
|
+
.filter((value) => value !== undefined);
|
|
1597
|
+
return {
|
|
1598
|
+
path: file,
|
|
1599
|
+
language: languageForFile(file) ?? null,
|
|
1600
|
+
supported: ast !== undefined,
|
|
1601
|
+
bytes: fileContents.get(file)?.byteLength ?? 0,
|
|
1602
|
+
decisionPoints: ast?.decisionPoints ?? null,
|
|
1603
|
+
declaredExports: ast?.exports ?? null,
|
|
1604
|
+
noObservedTestReference: ast
|
|
1605
|
+
? ast.exports.filter((name) => name !== "default" && !testIdentifiers.has(name))
|
|
1606
|
+
: null,
|
|
1607
|
+
associatedTests,
|
|
1608
|
+
sourceChangedAfterAssociatedTest: !gitSupported || sourceTime === undefined || testTimes.length === 0
|
|
1609
|
+
? null
|
|
1610
|
+
: sourceTime > Math.max(...testTimes),
|
|
1611
|
+
commitsInWindow: gitSupported && trackedFiles?.has(file) ? (commitCounts.get(file) ?? 0) : null,
|
|
1612
|
+
fixCommitsInWindow: gitSupported && trackedFiles?.has(file) ? (fixCounts.get(file) ?? 0) : null,
|
|
1613
|
+
weakAssertions: ast
|
|
1614
|
+
? [...ast.weak.entries()]
|
|
1615
|
+
.map(([category, count]) => ({ category, count }))
|
|
1616
|
+
.sort((left, right) => left.category < right.category ? -1 : left.category > right.category ? 1 : 0)
|
|
1617
|
+
: null,
|
|
1618
|
+
};
|
|
1619
|
+
});
|
|
1620
|
+
const moduleMap = new Map();
|
|
1621
|
+
for (const file of files) {
|
|
1622
|
+
const prefix = auditModulePrefix(file.path);
|
|
1623
|
+
moduleMap.set(prefix, [...(moduleMap.get(prefix) ?? []), file]);
|
|
1624
|
+
}
|
|
1625
|
+
const ranked = [...moduleMap]
|
|
1626
|
+
.map(([pathPrefix, members]) => ({
|
|
1627
|
+
pathPrefix,
|
|
1628
|
+
files: members.length,
|
|
1629
|
+
factualTuple: [
|
|
1630
|
+
members.reduce((sum, file) => sum + (file.decisionPoints ?? 0), 0),
|
|
1631
|
+
members.reduce((sum, file) => sum + (file.noObservedTestReference?.length ?? 0), 0),
|
|
1632
|
+
members.reduce((sum, file) => sum + (file.fixCommitsInWindow ?? 0), 0),
|
|
1633
|
+
members.reduce((sum, file) => sum + (file.commitsInWindow ?? 0), 0),
|
|
1634
|
+
members.filter((file) => file.sourceChangedAfterAssociatedTest === true).length,
|
|
1635
|
+
members.reduce((sum, file) => sum + (file.weakAssertions ?? []).reduce((inner, item) => inner + item.count, 0), 0),
|
|
1636
|
+
],
|
|
1637
|
+
}))
|
|
1638
|
+
.sort((left, right) => {
|
|
1639
|
+
for (let index = 0; index < left.factualTuple.length; index += 1) {
|
|
1640
|
+
const delta = (right.factualTuple[index] ?? 0) - (left.factualTuple[index] ?? 0);
|
|
1641
|
+
if (delta !== 0)
|
|
1642
|
+
return delta;
|
|
1643
|
+
}
|
|
1644
|
+
const leftKey = portablePathKey(left.pathPrefix);
|
|
1645
|
+
const rightKey = portablePathKey(right.pathPrefix);
|
|
1646
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
1647
|
+
})
|
|
1648
|
+
.map((module, index) => ({ ...module, rank: index + 1 }));
|
|
1649
|
+
let verificationRequest = null;
|
|
1650
|
+
if (options.verificationRequest !== undefined) {
|
|
1651
|
+
verificationRequest = parseVerificationRequest(options.verificationRequest);
|
|
1652
|
+
const requestRoot = await resolveRepositoryRoot(path.isAbsolute(verificationRequest.repository.root)
|
|
1653
|
+
? verificationRequest.repository.root
|
|
1654
|
+
: path.resolve(root, verificationRequest.repository.root));
|
|
1655
|
+
if (requestRoot !== root)
|
|
1656
|
+
throw new TypeError("VERIFICATION_REQUEST_REPOSITORY_MISMATCH");
|
|
1657
|
+
verificationRequest = {
|
|
1658
|
+
...verificationRequest,
|
|
1659
|
+
repository: { ...verificationRequest.repository, root },
|
|
1660
|
+
isolation: { ...verificationRequest.isolation, acknowledgedUnsafeExecution: false },
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
else
|
|
1664
|
+
reasonCodes.add("VERIFICATION_REQUEST_UNAVAILABLE");
|
|
1665
|
+
const candidates = verificationRequest?.candidates.length ?? 0;
|
|
1666
|
+
const worlds = verificationRequest?.worlds.length ?? 0;
|
|
1667
|
+
const attempts = verificationRequest?.policy.requiredAttempts ?? 0;
|
|
1668
|
+
const C = BigInt(candidates), W = BigInt(worlds), A = BigInt(attempts);
|
|
1669
|
+
const executions = (C + 1n) * W * A;
|
|
1670
|
+
const sumWorldBytes = BigInt(verificationRequest?.worlds.reduce((sum, world) => sum + world.files.reduce((inner, file) => inner + Buffer.byteLength(file.content), 0), 0) ?? 0);
|
|
1671
|
+
const sumCandidateBytes = BigInt(verificationRequest?.candidates.reduce((sum, candidate) => sum + candidate.files.reduce((inner, file) => inner + Buffer.byteLength(file.content), 0), 0) ?? 0);
|
|
1672
|
+
const overlayBytes = A * ((C + 1n) * sumWorldBytes + W * sumCandidateBytes);
|
|
1673
|
+
const final = (await analyzeRepository(root));
|
|
1674
|
+
if (final.repositoryDigest !== initial.repositoryDigest)
|
|
1675
|
+
throw new Error("REPOSITORY_CHANGED_DURING_AUDIT");
|
|
1676
|
+
return parseRepositoryAudit({
|
|
1677
|
+
schemaVersion: "1.0.0",
|
|
1678
|
+
root,
|
|
1679
|
+
repositoryDigest: initial.repositoryDigest,
|
|
1680
|
+
fileCount: initial.files.length,
|
|
1681
|
+
repositoryBytes,
|
|
1682
|
+
reasonCodes: [...reasonCodes].sort(),
|
|
1683
|
+
git: {
|
|
1684
|
+
supported: gitSupported,
|
|
1685
|
+
headTimestamp,
|
|
1686
|
+
windowDays: 90,
|
|
1687
|
+
maximumCommits: 1000,
|
|
1688
|
+
shallow,
|
|
1689
|
+
truncated,
|
|
1690
|
+
},
|
|
1691
|
+
files,
|
|
1692
|
+
modules: ranked,
|
|
1693
|
+
cost: {
|
|
1694
|
+
candidates,
|
|
1695
|
+
worlds,
|
|
1696
|
+
attempts,
|
|
1697
|
+
executions: executions.toString(),
|
|
1698
|
+
controlExecutions: (W * A).toString(),
|
|
1699
|
+
candidateExecutions: (C * W * A).toString(),
|
|
1700
|
+
overlayBytes: overlayBytes.toString(),
|
|
1701
|
+
materializationBytes: (BigInt(repositoryBytes) * (executions + 1n) + overlayBytes).toString(),
|
|
1702
|
+
timeoutLimitMs: (executions * BigInt(verificationRequest?.budgets.timeoutMsPerExecution ?? 0)).toString(),
|
|
1703
|
+
capturedStreamLimitBytes: (executions *
|
|
1704
|
+
BigInt(verificationRequest?.budgets.maximumOutputBytes ?? 0) *
|
|
1705
|
+
2n).toString(),
|
|
1706
|
+
},
|
|
1707
|
+
verificationRequest,
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
function parseOverlay(value, forbiddenCode, roots) {
|
|
1711
|
+
const record = requireRecord(value, "file_overlay");
|
|
1712
|
+
const rawPath = requireString(record.path, "overlay_path");
|
|
1713
|
+
let safePath;
|
|
1714
|
+
try {
|
|
1715
|
+
safePath = assertSafeRelativePath(rawPath, roots);
|
|
1716
|
+
}
|
|
1717
|
+
catch {
|
|
1718
|
+
throw new Error(forbiddenCode);
|
|
1719
|
+
}
|
|
1720
|
+
return { path: safePath, content: requireText(record.content, "overlay_content") };
|
|
1721
|
+
}
|
|
1722
|
+
function normalizePortableCollection(values, duplicateCode) {
|
|
1723
|
+
const spellings = new Map();
|
|
1724
|
+
for (const value of values) {
|
|
1725
|
+
const key = portablePathKey(value.path);
|
|
1726
|
+
const existing = spellings.get(key);
|
|
1727
|
+
if (existing !== undefined) {
|
|
1728
|
+
if (existing === value.path)
|
|
1729
|
+
throw new TypeError(duplicateCode);
|
|
1730
|
+
throw new Error("PORTABLE_PATH_COLLISION");
|
|
1731
|
+
}
|
|
1732
|
+
spellings.set(key, value.path);
|
|
1733
|
+
}
|
|
1734
|
+
return [...values].sort((left, right) => comparePortablePaths(left.path, right.path));
|
|
1735
|
+
}
|
|
1736
|
+
function assertPortableStringCollection(values, duplicateCode) {
|
|
1737
|
+
normalizePortableCollection(values.map((value) => ({ path: value })), duplicateCode);
|
|
1738
|
+
}
|
|
1739
|
+
function parseRequest(value) {
|
|
1740
|
+
const request = parseVerificationRequest(value);
|
|
1741
|
+
const repository = requireRecord(request.repository, "repository");
|
|
1742
|
+
const adapter = requireRecord(request.adapter, "adapter");
|
|
1743
|
+
const isolation = requireRecord(request.isolation, "isolation");
|
|
1744
|
+
const budgets = requireRecord(request.budgets, "budgets");
|
|
1745
|
+
const policy = requireRecord(request.policy, "policy");
|
|
1746
|
+
const candidateRoots = requireStringArray(request.candidateRoots, "candidate_roots").map((root) => assertSafeRelativePath(root, [root]));
|
|
1747
|
+
if (candidateRoots.length === 0)
|
|
1748
|
+
throw new TypeError("INVALID_CANDIDATE_ROOTS");
|
|
1749
|
+
assertPortableStringCollection(candidateRoots, "DUPLICATE_CANDIDATE_ROOT");
|
|
1750
|
+
if (adapter.kind !== "node-test" && adapter.kind !== "testforge-command") {
|
|
1751
|
+
throw new TypeError("UNSUPPORTED_ADAPTER");
|
|
1752
|
+
}
|
|
1753
|
+
if (isolation.kind !== "trusted-local")
|
|
1754
|
+
throw new TypeError("UNSUPPORTED_ISOLATION");
|
|
1755
|
+
if (isolation.acknowledgedUnsafeExecution !== true) {
|
|
1756
|
+
throw new Error("UNSAFE_LOCAL_EXECUTION_NOT_ACKNOWLEDGED");
|
|
1757
|
+
}
|
|
1758
|
+
const parsedBudgets = {
|
|
1759
|
+
maximumCandidates: requirePositiveInteger(budgets.maximumCandidates, "maximum_candidates"),
|
|
1760
|
+
maximumWorlds: requirePositiveInteger(budgets.maximumWorlds, "maximum_worlds"),
|
|
1761
|
+
maximumExecutions: requirePositiveInteger(budgets.maximumExecutions, "maximum_executions"),
|
|
1762
|
+
maximumRepositoryFiles: requirePositiveInteger(budgets.maximumRepositoryFiles, "maximum_repository_files"),
|
|
1763
|
+
maximumRepositoryBytes: requirePositiveInteger(budgets.maximumRepositoryBytes, "maximum_repository_bytes"),
|
|
1764
|
+
maximumWorldOverlayBytes: requirePositiveInteger(budgets.maximumWorldOverlayBytes, "maximum_world_overlay_bytes"),
|
|
1765
|
+
maximumCandidateBytes: requirePositiveInteger(budgets.maximumCandidateBytes, "maximum_candidate_bytes"),
|
|
1766
|
+
maximumTotalCandidateBytes: requirePositiveInteger(budgets.maximumTotalCandidateBytes, "maximum_total_candidate_bytes"),
|
|
1767
|
+
timeoutMsPerExecution: requirePositiveInteger(budgets.timeoutMsPerExecution, "timeout_ms_per_execution"),
|
|
1768
|
+
maximumOutputBytes: requirePositiveInteger(budgets.maximumOutputBytes, "maximum_output_bytes"),
|
|
1769
|
+
};
|
|
1770
|
+
if (!Array.isArray(request.worlds) || !Array.isArray(request.candidates)) {
|
|
1771
|
+
throw new TypeError("INVALID_CAMPAIGN_COLLECTIONS");
|
|
1772
|
+
}
|
|
1773
|
+
if (request.worlds.length === 0)
|
|
1774
|
+
throw new TypeError("INVALID_WORLDS");
|
|
1775
|
+
if (request.worlds.length > parsedBudgets.maximumWorlds)
|
|
1776
|
+
throw new Error("WORLD_BUDGET_EXCEEDED");
|
|
1777
|
+
if (request.candidates.length > parsedBudgets.maximumCandidates) {
|
|
1778
|
+
throw new Error("CANDIDATE_BUDGET_EXCEEDED");
|
|
1779
|
+
}
|
|
1780
|
+
const worlds = request.worlds.map((value) => {
|
|
1781
|
+
const world = requireRecord(value, "world");
|
|
1782
|
+
if (!["REFERENCE", "TARGET", "NEUTRAL"].includes(world.kind)) {
|
|
1783
|
+
throw new TypeError("INVALID_WORLD_KIND");
|
|
1784
|
+
}
|
|
1785
|
+
if (!Array.isArray(world.files))
|
|
1786
|
+
throw new TypeError("INVALID_WORLD_FILES");
|
|
1787
|
+
if (typeof world.required !== "boolean")
|
|
1788
|
+
throw new TypeError("INVALID_WORLD_REQUIRED");
|
|
1789
|
+
const weight = world.weight;
|
|
1790
|
+
if (!Number.isSafeInteger(weight) || weight < 0)
|
|
1791
|
+
throw new TypeError("INVALID_WORLD_WEIGHT");
|
|
1792
|
+
const files = normalizePortableCollection(world.files.map((file) => parseOverlay(file, "FORBIDDEN_WORLD_PATH", ["."])), "DUPLICATE_WORLD_FILE_PATH");
|
|
1793
|
+
return {
|
|
1794
|
+
id: requireString(world.id, "world_id"),
|
|
1795
|
+
kind: world.kind,
|
|
1796
|
+
required: world.required,
|
|
1797
|
+
weight: weight,
|
|
1798
|
+
...(typeof world.provenance === "string" ? { provenance: world.provenance } : {}),
|
|
1799
|
+
files,
|
|
1800
|
+
};
|
|
1801
|
+
});
|
|
1802
|
+
const candidates = request.candidates.map((value) => {
|
|
1803
|
+
const candidate = requireRecord(value, "candidate");
|
|
1804
|
+
if (!Array.isArray(candidate.files) || candidate.files.length === 0) {
|
|
1805
|
+
throw new TypeError("INVALID_CANDIDATE_FILES");
|
|
1806
|
+
}
|
|
1807
|
+
const files = normalizePortableCollection(candidate.files.map((file) => parseOverlay(file, "FORBIDDEN_CANDIDATE_PATH", candidateRoots)), "DUPLICATE_CANDIDATE_FILE_PATH");
|
|
1808
|
+
const size = files.reduce((total, file) => total + Buffer.byteLength(file.content), 0);
|
|
1809
|
+
if (size > parsedBudgets.maximumCandidateBytes)
|
|
1810
|
+
throw new Error("CANDIDATE_BYTES_EXCEEDED");
|
|
1811
|
+
return { id: requireString(candidate.id, "candidate_id"), files };
|
|
1812
|
+
});
|
|
1813
|
+
const worldOverlayBytes = worlds.reduce((total, world) => total +
|
|
1814
|
+
world.files.reduce((fileTotal, file) => fileTotal + Buffer.byteLength(file.content), 0), 0);
|
|
1815
|
+
if (worldOverlayBytes > parsedBudgets.maximumWorldOverlayBytes) {
|
|
1816
|
+
throw new Error("WORLD_OVERLAY_BYTES_EXCEEDED");
|
|
1817
|
+
}
|
|
1818
|
+
const totalCandidateBytes = candidates.reduce((total, candidate) => total +
|
|
1819
|
+
candidate.files.reduce((fileTotal, file) => fileTotal + Buffer.byteLength(file.content), 0), 0);
|
|
1820
|
+
if (totalCandidateBytes > parsedBudgets.maximumTotalCandidateBytes) {
|
|
1821
|
+
throw new Error("TOTAL_CANDIDATE_BYTES_EXCEEDED");
|
|
1822
|
+
}
|
|
1823
|
+
const requiredAttempts = requirePositiveInteger(policy.requiredAttempts, "required_attempts");
|
|
1824
|
+
if (new Set(worlds.map((world) => world.id)).size !== worlds.length) {
|
|
1825
|
+
throw new TypeError("DUPLICATE_WORLD_ID");
|
|
1826
|
+
}
|
|
1827
|
+
if (new Set(candidates.map((candidate) => candidate.id)).size !== candidates.length) {
|
|
1828
|
+
throw new TypeError("DUPLICATE_CANDIDATE_ID");
|
|
1829
|
+
}
|
|
1830
|
+
const executionCount = worlds.length * requiredAttempts * (candidates.length + 1);
|
|
1831
|
+
if (executionCount > parsedBudgets.maximumExecutions)
|
|
1832
|
+
throw new Error("EXECUTION_BUDGET_EXCEEDED");
|
|
1833
|
+
const environmentAllowlist = requireStringArray(isolation.environmentAllowlist, "environment_allowlist");
|
|
1834
|
+
if (new Set(environmentAllowlist).size !== environmentAllowlist.length) {
|
|
1835
|
+
throw new TypeError("DUPLICATE_ENVIRONMENT_ALLOWLIST_ENTRY");
|
|
1836
|
+
}
|
|
1837
|
+
if (environmentAllowlist.some((key) => key.length === 0 || key.includes("="))) {
|
|
1838
|
+
throw new TypeError("INVALID_ENVIRONMENT_ALLOWLIST_ENTRY");
|
|
1839
|
+
}
|
|
1840
|
+
const reservedEnvironmentVariable = environmentAllowlist.find((key) => {
|
|
1841
|
+
const normalized = key.toUpperCase();
|
|
1842
|
+
return (normalized === "NODE_OPTIONS" ||
|
|
1843
|
+
normalized.startsWith("TESTFORGE_") ||
|
|
1844
|
+
normalized.startsWith("NODE_TEST_"));
|
|
1845
|
+
});
|
|
1846
|
+
if (reservedEnvironmentVariable !== undefined) {
|
|
1847
|
+
throw new Error("RESERVED_ENVIRONMENT_VARIABLE");
|
|
1848
|
+
}
|
|
1849
|
+
const minimumTargetWeightPermille = requireNonNegativeInteger(policy.minimumTargetWeightPermille, "minimum_target_weight_permille");
|
|
1850
|
+
if (minimumTargetWeightPermille > 1_000) {
|
|
1851
|
+
throw new TypeError("INVALID_MINIMUM_TARGET_WEIGHT_PERMILLE");
|
|
1852
|
+
}
|
|
1853
|
+
const normalizedAdapter = adapter.kind === "node-test"
|
|
1854
|
+
? (() => {
|
|
1855
|
+
const extraArguments = parseNodeTestExtraArguments(adapter.extraArguments);
|
|
1856
|
+
const baseTestFiles = requireStringArray(adapter.baseTestFiles, "base_test_files").map((file) => assertSafeRelativePath(file, ["."]));
|
|
1857
|
+
assertPortableStringCollection(baseTestFiles, "DUPLICATE_BASE_TEST_FILE");
|
|
1858
|
+
return {
|
|
1859
|
+
kind: "node-test",
|
|
1860
|
+
executable: requireString(adapter.executable, "adapter_executable"),
|
|
1861
|
+
baseTestFiles,
|
|
1862
|
+
...(extraArguments === undefined ? {} : { extraArguments }),
|
|
1863
|
+
};
|
|
1864
|
+
})()
|
|
1865
|
+
: {
|
|
1866
|
+
kind: "testforge-command",
|
|
1867
|
+
executable: requireString(adapter.executable, "adapter_executable"),
|
|
1868
|
+
arguments: requireStringArray(adapter.arguments, "adapter_arguments"),
|
|
1869
|
+
protocolVersion: "1.0.0",
|
|
1870
|
+
};
|
|
1871
|
+
return {
|
|
1872
|
+
schemaVersion: requireString(request.schemaVersion, "schema_version"),
|
|
1873
|
+
repository: {
|
|
1874
|
+
root: requireString(repository.root, "repository_root"),
|
|
1875
|
+
exclude: requireStringArray(repository.exclude, "repository_exclude"),
|
|
1876
|
+
},
|
|
1877
|
+
adapter: normalizedAdapter,
|
|
1878
|
+
isolation: {
|
|
1879
|
+
kind: "trusted-local",
|
|
1880
|
+
acknowledgedUnsafeExecution: true,
|
|
1881
|
+
environmentAllowlist,
|
|
1882
|
+
},
|
|
1883
|
+
candidateRoots,
|
|
1884
|
+
budgets: parsedBudgets,
|
|
1885
|
+
policy: {
|
|
1886
|
+
policyVersion: requireString(policy.policyVersion, "policy_version"),
|
|
1887
|
+
requiredAttempts,
|
|
1888
|
+
minimumTargetWeightPermille,
|
|
1889
|
+
maximumSelectedCandidates: requireNonNegativeInteger(policy.maximumSelectedCandidates, "maximum_selected_candidates"),
|
|
1890
|
+
acceptedTargetOutcomes: requireStringArray(policy.acceptedTargetOutcomes, "accepted_target_outcomes"),
|
|
1891
|
+
},
|
|
1892
|
+
worlds,
|
|
1893
|
+
candidates,
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
function environmentFromAllowlist(allowlist) {
|
|
1897
|
+
const environment = {};
|
|
1898
|
+
for (const key of allowlist) {
|
|
1899
|
+
const value = process.env[key];
|
|
1900
|
+
if (value !== undefined)
|
|
1901
|
+
environment[key] = value;
|
|
1902
|
+
}
|
|
1903
|
+
return environment;
|
|
1904
|
+
}
|
|
1905
|
+
async function applyFiles(workspace, files) {
|
|
1906
|
+
const sortedFiles = [...files].sort((left, right) => comparePortablePaths(left.path, right.path));
|
|
1907
|
+
for (const file of sortedFiles) {
|
|
1908
|
+
const segments = file.path.split("/");
|
|
1909
|
+
const target = path.join(workspace, ...segments);
|
|
1910
|
+
let cursor = workspace;
|
|
1911
|
+
for (const segment of segments.slice(0, -1)) {
|
|
1912
|
+
cursor = path.join(cursor, segment);
|
|
1913
|
+
try {
|
|
1914
|
+
if ((await lstat(cursor)).isSymbolicLink())
|
|
1915
|
+
throw new Error("SYMLINK_OVERLAY_PATH");
|
|
1916
|
+
}
|
|
1917
|
+
catch (error) {
|
|
1918
|
+
if (isRecord(error) && error.code === "ENOENT")
|
|
1919
|
+
break;
|
|
1920
|
+
throw error;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1924
|
+
try {
|
|
1925
|
+
if ((await lstat(target)).isSymbolicLink())
|
|
1926
|
+
throw new Error("SYMLINK_OVERLAY_PATH");
|
|
1927
|
+
}
|
|
1928
|
+
catch (error) {
|
|
1929
|
+
if (!(isRecord(error) && error.code === "ENOENT"))
|
|
1930
|
+
throw error;
|
|
1931
|
+
}
|
|
1932
|
+
await writeFile(target, file.content, { flag: "w" });
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
const STRUCTURED_OUTCOMES = new Set([
|
|
1936
|
+
"PASS",
|
|
1937
|
+
"ASSERTION_FAILURE",
|
|
1938
|
+
"COLLECTION_FAILURE",
|
|
1939
|
+
"COMPILE_FAILURE",
|
|
1940
|
+
"PROCESS_CRASH",
|
|
1941
|
+
"TIMEOUT",
|
|
1942
|
+
"INFRA_ERROR",
|
|
1943
|
+
"NO_TEST_DISCOVERED",
|
|
1944
|
+
]);
|
|
1945
|
+
function nonNegativeInteger(value) {
|
|
1946
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
1947
|
+
}
|
|
1948
|
+
function parseStructuredCommandReport(value, processResult) {
|
|
1949
|
+
if (!isRecord(value))
|
|
1950
|
+
return undefined;
|
|
1951
|
+
const expectedKeys = [
|
|
1952
|
+
"attributed",
|
|
1953
|
+
"candidateTestsDiscovered",
|
|
1954
|
+
"outcome",
|
|
1955
|
+
"protocolVersion",
|
|
1956
|
+
"testsDiscovered",
|
|
1957
|
+
];
|
|
1958
|
+
if (Object.keys(value).sort().join("\0") !== expectedKeys.join("\0"))
|
|
1959
|
+
return undefined;
|
|
1960
|
+
if (value.protocolVersion !== "1.0.0")
|
|
1961
|
+
return undefined;
|
|
1962
|
+
if (typeof value.outcome !== "string" ||
|
|
1963
|
+
!STRUCTURED_OUTCOMES.has(value.outcome)) {
|
|
1964
|
+
return undefined;
|
|
1965
|
+
}
|
|
1966
|
+
if (!nonNegativeInteger(value.testsDiscovered) ||
|
|
1967
|
+
!nonNegativeInteger(value.candidateTestsDiscovered) ||
|
|
1968
|
+
value.candidateTestsDiscovered > value.testsDiscovered ||
|
|
1969
|
+
typeof value.attributed !== "boolean") {
|
|
1970
|
+
return undefined;
|
|
1971
|
+
}
|
|
1972
|
+
const attributableOutcome = value.outcome === "PASS" || value.outcome === "ASSERTION_FAILURE";
|
|
1973
|
+
if (value.attributed && (!attributableOutcome || value.candidateTestsDiscovered === 0)) {
|
|
1974
|
+
return undefined;
|
|
1975
|
+
}
|
|
1976
|
+
if (value.outcome === "TIMEOUT")
|
|
1977
|
+
return undefined;
|
|
1978
|
+
const pass = value.outcome === "PASS";
|
|
1979
|
+
if ((pass && processResult.exitCode !== 0) ||
|
|
1980
|
+
(!pass && (processResult.exitCode === null || processResult.exitCode === 0))) {
|
|
1981
|
+
return undefined;
|
|
1982
|
+
}
|
|
1983
|
+
return {
|
|
1984
|
+
protocolVersion: "1.0.0",
|
|
1985
|
+
outcome: value.outcome,
|
|
1986
|
+
testsDiscovered: value.testsDiscovered,
|
|
1987
|
+
candidateTestsDiscovered: value.candidateTestsDiscovered,
|
|
1988
|
+
attributed: value.attributed,
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
function parseNodeTestReport(value) {
|
|
1992
|
+
if (!isRecord(value))
|
|
1993
|
+
return undefined;
|
|
1994
|
+
const expectedKeys = [
|
|
1995
|
+
"candidateFailureCount",
|
|
1996
|
+
"candidateFailuresAllAssertions",
|
|
1997
|
+
"candidateSyntaxFailureCount",
|
|
1998
|
+
"candidateTestsDiscovered",
|
|
1999
|
+
"nonCandidateFailureCount",
|
|
2000
|
+
"protocolVersion",
|
|
2001
|
+
"testsDiscovered",
|
|
2002
|
+
];
|
|
2003
|
+
if (Object.keys(value).sort().join("\0") !== expectedKeys.join("\0"))
|
|
2004
|
+
return undefined;
|
|
2005
|
+
if (value.protocolVersion !== "1.0.0")
|
|
2006
|
+
return undefined;
|
|
2007
|
+
if (!nonNegativeInteger(value.testsDiscovered) ||
|
|
2008
|
+
!nonNegativeInteger(value.candidateTestsDiscovered) ||
|
|
2009
|
+
value.candidateTestsDiscovered > value.testsDiscovered ||
|
|
2010
|
+
!nonNegativeInteger(value.candidateFailureCount) ||
|
|
2011
|
+
!nonNegativeInteger(value.nonCandidateFailureCount) ||
|
|
2012
|
+
!nonNegativeInteger(value.candidateSyntaxFailureCount) ||
|
|
2013
|
+
value.candidateSyntaxFailureCount > value.candidateFailureCount ||
|
|
2014
|
+
typeof value.candidateFailuresAllAssertions !== "boolean") {
|
|
2015
|
+
return undefined;
|
|
2016
|
+
}
|
|
2017
|
+
return {
|
|
2018
|
+
protocolVersion: "1.0.0",
|
|
2019
|
+
testsDiscovered: value.testsDiscovered,
|
|
2020
|
+
candidateTestsDiscovered: value.candidateTestsDiscovered,
|
|
2021
|
+
candidateFailureCount: value.candidateFailureCount,
|
|
2022
|
+
nonCandidateFailureCount: value.nonCandidateFailureCount,
|
|
2023
|
+
candidateSyntaxFailureCount: value.candidateSyntaxFailureCount,
|
|
2024
|
+
candidateFailuresAllAssertions: value.candidateFailuresAllAssertions,
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
async function readBoundedJsonFile(resultFile, maximumBytes) {
|
|
2028
|
+
let handle;
|
|
2029
|
+
try {
|
|
2030
|
+
handle = await open(resultFile, "r");
|
|
2031
|
+
const buffer = Buffer.alloc(maximumBytes + 1);
|
|
2032
|
+
let bytesRead = 0;
|
|
2033
|
+
while (bytesRead < buffer.byteLength) {
|
|
2034
|
+
const chunk = await handle.read(buffer, bytesRead, buffer.byteLength - bytesRead, null);
|
|
2035
|
+
if (chunk.bytesRead === 0)
|
|
2036
|
+
break;
|
|
2037
|
+
bytesRead += chunk.bytesRead;
|
|
2038
|
+
}
|
|
2039
|
+
if (bytesRead > maximumBytes)
|
|
2040
|
+
return undefined;
|
|
2041
|
+
return JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
2042
|
+
}
|
|
2043
|
+
catch {
|
|
2044
|
+
return undefined;
|
|
2045
|
+
}
|
|
2046
|
+
finally {
|
|
2047
|
+
await handle?.close();
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
async function readStructuredCommandReport(resultFile, processResult, maximumBytes) {
|
|
2051
|
+
if (processResult.outcome === "TIMEOUT" || processResult.outcome === "INFRA_ERROR") {
|
|
2052
|
+
return undefined;
|
|
2053
|
+
}
|
|
2054
|
+
return parseStructuredCommandReport(await readBoundedJsonFile(resultFile, maximumBytes), processResult);
|
|
2055
|
+
}
|
|
2056
|
+
async function readNodeTestReport(resultFile, processResult, maximumBytes) {
|
|
2057
|
+
if (processResult.outcome === "TIMEOUT" || processResult.outcome === "INFRA_ERROR") {
|
|
2058
|
+
return undefined;
|
|
2059
|
+
}
|
|
2060
|
+
return parseNodeTestReport(await readBoundedJsonFile(resultFile, maximumBytes));
|
|
2061
|
+
}
|
|
2062
|
+
async function sha256File(file) {
|
|
2063
|
+
const digest = createHash("sha256");
|
|
2064
|
+
for await (const chunk of createReadStream(file))
|
|
2065
|
+
digest.update(chunk);
|
|
2066
|
+
return `${SHA256_PREFIX}${digest.digest("hex")}`;
|
|
2067
|
+
}
|
|
2068
|
+
function nodeVersionIsSupported(version) {
|
|
2069
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-|$)/u.exec(version);
|
|
2070
|
+
if (!match)
|
|
2071
|
+
return false;
|
|
2072
|
+
const major = Number(match[1]);
|
|
2073
|
+
const minor = Number(match[2]);
|
|
2074
|
+
return major > 22 || (major === 22 && minor >= 15);
|
|
2075
|
+
}
|
|
2076
|
+
async function runNodeExecutableProbe(executable, repositoryRoot, environmentAllowlist, timeoutMs, maximumOutputBytes) {
|
|
2077
|
+
const result = await runProcess({
|
|
2078
|
+
executable,
|
|
2079
|
+
args: ["-p", "JSON.stringify({execPath:process.execPath,node:process.versions.node})"],
|
|
2080
|
+
cwd: repositoryRoot,
|
|
2081
|
+
environment: environmentFromAllowlist(environmentAllowlist),
|
|
2082
|
+
timeoutMs: Math.min(timeoutMs, 5_000),
|
|
2083
|
+
maximumOutputBytes: Math.min(maximumOutputBytes, CONTROLLED_REPORT_MAXIMUM_BYTES),
|
|
2084
|
+
});
|
|
2085
|
+
if (result.outcome !== "PASS" || result.stdout.truncated || result.stderr.totalBytes > 0) {
|
|
2086
|
+
throw new Error("NODE_TEST_EXECUTABLE_PROBE_FAILED");
|
|
2087
|
+
}
|
|
2088
|
+
let report;
|
|
2089
|
+
try {
|
|
2090
|
+
report = JSON.parse(result.stdout.text.trim());
|
|
2091
|
+
}
|
|
2092
|
+
catch {
|
|
2093
|
+
throw new Error("NODE_TEST_EXECUTABLE_PROBE_FAILED");
|
|
2094
|
+
}
|
|
2095
|
+
if (!isRecord(report) ||
|
|
2096
|
+
Object.keys(report).sort().join("\0") !== "execPath\0node" ||
|
|
2097
|
+
typeof report.execPath !== "string" ||
|
|
2098
|
+
report.execPath.length === 0 ||
|
|
2099
|
+
typeof report.node !== "string") {
|
|
2100
|
+
throw new Error("NODE_TEST_EXECUTABLE_PROBE_FAILED");
|
|
2101
|
+
}
|
|
2102
|
+
if (!nodeVersionIsSupported(report.node))
|
|
2103
|
+
throw new Error("NODE_TEST_VERSION_UNSUPPORTED");
|
|
2104
|
+
return { execPath: report.execPath, nodeVersion: report.node };
|
|
2105
|
+
}
|
|
2106
|
+
async function probeNodeTestExecutable(requestedExecutable, repositoryRoot, environmentAllowlist, timeoutMs, maximumOutputBytes) {
|
|
2107
|
+
const firstProbe = await runNodeExecutableProbe(requestedExecutable, repositoryRoot, environmentAllowlist, timeoutMs, maximumOutputBytes);
|
|
2108
|
+
let resolvedExecutable;
|
|
2109
|
+
try {
|
|
2110
|
+
resolvedExecutable = await realpath(firstProbe.execPath);
|
|
2111
|
+
if (!(await stat(resolvedExecutable)).isFile())
|
|
2112
|
+
throw new Error("not a file");
|
|
2113
|
+
}
|
|
2114
|
+
catch {
|
|
2115
|
+
throw new Error("NODE_TEST_EXECUTABLE_PROBE_FAILED");
|
|
2116
|
+
}
|
|
2117
|
+
const secondProbe = await runNodeExecutableProbe(resolvedExecutable, repositoryRoot, environmentAllowlist, timeoutMs, maximumOutputBytes);
|
|
2118
|
+
let secondResolvedExecutable;
|
|
2119
|
+
try {
|
|
2120
|
+
secondResolvedExecutable = await realpath(secondProbe.execPath);
|
|
2121
|
+
}
|
|
2122
|
+
catch {
|
|
2123
|
+
throw new Error("NODE_TEST_EXECUTABLE_PROBE_FAILED");
|
|
2124
|
+
}
|
|
2125
|
+
if (secondResolvedExecutable !== resolvedExecutable ||
|
|
2126
|
+
secondProbe.nodeVersion !== firstProbe.nodeVersion) {
|
|
2127
|
+
throw new Error("NODE_TEST_EXECUTABLE_PROBE_FAILED");
|
|
2128
|
+
}
|
|
2129
|
+
return {
|
|
2130
|
+
requestedExecutable,
|
|
2131
|
+
resolvedExecutable,
|
|
2132
|
+
executableDigest: await sha256File(resolvedExecutable),
|
|
2133
|
+
nodeVersion: secondProbe.nodeVersion,
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
function classifyNodeTest(result, report, hasCandidate) {
|
|
2137
|
+
if (result.outcome === "TIMEOUT")
|
|
2138
|
+
return { outcome: "TIMEOUT", attributed: false };
|
|
2139
|
+
return normalizeRuntimeFacts({
|
|
2140
|
+
factsVersion: RUNTIME_FACTS_VERSION,
|
|
2141
|
+
reportValid: result.outcome !== "INFRA_ERROR" && report !== undefined,
|
|
2142
|
+
hasCandidate,
|
|
2143
|
+
processExitedZero: result.exitCode === 0,
|
|
2144
|
+
candidateTestsDiscovered: report?.candidateTestsDiscovered ?? 0,
|
|
2145
|
+
nonCandidateFailureCount: report?.nonCandidateFailureCount ?? 0,
|
|
2146
|
+
candidateCollectionFailureCount: 0,
|
|
2147
|
+
candidateCompileFailureCount: report?.candidateSyntaxFailureCount ?? 0,
|
|
2148
|
+
candidateFailureCount: report?.candidateFailureCount ?? 0,
|
|
2149
|
+
candidateFailuresAllAssertions: report?.candidateFailuresAllAssertions ?? false,
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
async function copyRepository(source, destination, excludes) {
|
|
2153
|
+
await cp(source, destination, {
|
|
2154
|
+
recursive: true,
|
|
2155
|
+
force: false,
|
|
2156
|
+
errorOnExist: true,
|
|
2157
|
+
filter: async (sourcePath) => {
|
|
2158
|
+
if (sourcePath === source)
|
|
2159
|
+
return true;
|
|
2160
|
+
const relative = path.relative(source, sourcePath);
|
|
2161
|
+
if (relative.split(path.sep).some((segment) => excludes.has(portablePathKey(segment)))) {
|
|
2162
|
+
return false;
|
|
2163
|
+
}
|
|
2164
|
+
if ((await lstat(sourcePath)).isSymbolicLink()) {
|
|
2165
|
+
throw new Error("UNSUPPORTED_REPOSITORY_SYMLINK");
|
|
2166
|
+
}
|
|
2167
|
+
return true;
|
|
2168
|
+
},
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
async function executeObservation(request, repositoryRoot, world, candidate, attempt, nodeTestReporterPath) {
|
|
2172
|
+
const temporaryRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "testforge-run-")));
|
|
2173
|
+
const workspace = path.join(temporaryRoot, "repository");
|
|
2174
|
+
try {
|
|
2175
|
+
await copyRepository(repositoryRoot, workspace, effectiveExcludes(request.repository.exclude));
|
|
2176
|
+
await applyFiles(workspace, world.files);
|
|
2177
|
+
if (candidate)
|
|
2178
|
+
await applyFiles(workspace, candidate.files);
|
|
2179
|
+
const candidateFiles = candidate ? candidate.files.map((file) => file.path) : [];
|
|
2180
|
+
const resultFile = path.join(temporaryRoot, "structured-command-result.json");
|
|
2181
|
+
const environment = environmentFromAllowlist(request.isolation.environmentAllowlist);
|
|
2182
|
+
if (request.adapter.kind === "testforge-command") {
|
|
2183
|
+
environment.TESTFORGE_RESULT_FILE = resultFile;
|
|
2184
|
+
environment.TESTFORGE_CANDIDATE_FILES = JSON.stringify(candidateFiles);
|
|
2185
|
+
}
|
|
2186
|
+
else {
|
|
2187
|
+
if (nodeTestReporterPath === undefined)
|
|
2188
|
+
throw new Error("NODE_TEST_REPORTER_MISSING");
|
|
2189
|
+
environment.TESTFORGE_NODE_CANDIDATE_FILES = JSON.stringify(candidateFiles.map((file) => path.resolve(workspace, ...file.split("/"))));
|
|
2190
|
+
}
|
|
2191
|
+
const args = request.adapter.kind === "node-test"
|
|
2192
|
+
? [
|
|
2193
|
+
"--test",
|
|
2194
|
+
`--test-reporter=${pathToFileURL(nodeTestReporterPath).href}`,
|
|
2195
|
+
`--test-reporter-destination=${resultFile}`,
|
|
2196
|
+
...(request.adapter.extraArguments ?? []),
|
|
2197
|
+
"--",
|
|
2198
|
+
...request.adapter.baseTestFiles,
|
|
2199
|
+
...candidateFiles,
|
|
2200
|
+
]
|
|
2201
|
+
: request.adapter.arguments;
|
|
2202
|
+
const result = await runProcess({
|
|
2203
|
+
executable: request.adapter.executable,
|
|
2204
|
+
args,
|
|
2205
|
+
cwd: workspace,
|
|
2206
|
+
environment,
|
|
2207
|
+
timeoutMs: request.budgets.timeoutMsPerExecution,
|
|
2208
|
+
maximumOutputBytes: request.budgets.maximumOutputBytes,
|
|
2209
|
+
});
|
|
2210
|
+
const structuredReport = request.adapter.kind === "testforge-command"
|
|
2211
|
+
? await readStructuredCommandReport(resultFile, result, CONTROLLED_REPORT_MAXIMUM_BYTES)
|
|
2212
|
+
: undefined;
|
|
2213
|
+
const nodeTestReport = request.adapter.kind === "node-test"
|
|
2214
|
+
? await readNodeTestReport(resultFile, result, CONTROLLED_REPORT_MAXIMUM_BYTES)
|
|
2215
|
+
: undefined;
|
|
2216
|
+
const nodeClassification = request.adapter.kind === "node-test"
|
|
2217
|
+
? classifyNodeTest(result, nodeTestReport, candidate !== null)
|
|
2218
|
+
: undefined;
|
|
2219
|
+
const outcome = nodeClassification?.outcome ??
|
|
2220
|
+
(result.outcome === "TIMEOUT" ? "TIMEOUT" : (structuredReport?.outcome ?? "INFRA_ERROR"));
|
|
2221
|
+
return {
|
|
2222
|
+
runId: `${candidate?.id ?? "control"}:${world.id}:${attempt}`,
|
|
2223
|
+
candidateId: candidate?.id ?? null,
|
|
2224
|
+
worldId: world.id,
|
|
2225
|
+
attempt,
|
|
2226
|
+
outcome,
|
|
2227
|
+
testsDiscovered: request.adapter.kind === "node-test"
|
|
2228
|
+
? (nodeTestReport?.testsDiscovered ?? 0)
|
|
2229
|
+
: (structuredReport?.testsDiscovered ?? 0),
|
|
2230
|
+
candidateTestsDiscovered: request.adapter.kind === "node-test"
|
|
2231
|
+
? (nodeTestReport?.candidateTestsDiscovered ?? 0)
|
|
2232
|
+
: (structuredReport?.candidateTestsDiscovered ?? 0),
|
|
2233
|
+
attributed: nodeClassification?.attributed ?? structuredReport?.attributed ?? false,
|
|
2234
|
+
durationMs: result.durationMs,
|
|
2235
|
+
exitCode: result.exitCode,
|
|
2236
|
+
stdoutDigest: result.stdout.digest,
|
|
2237
|
+
stderrDigest: result.stderr.digest,
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
finally {
|
|
2241
|
+
await removeTemporaryDirectory(temporaryRoot);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
export async function verifyCampaign(value) {
|
|
2245
|
+
const request = parseRequest(value);
|
|
2246
|
+
const repositoryRoot = await resolveRepositoryRoot(request.repository.root);
|
|
2247
|
+
const excludes = effectiveExcludes(request.repository.exclude);
|
|
2248
|
+
const sourceInventory = await walkFiles(repositoryRoot, excludes);
|
|
2249
|
+
if (sourceInventory.files.length > request.budgets.maximumRepositoryFiles) {
|
|
2250
|
+
throw new Error("REPOSITORY_FILE_BUDGET_EXCEEDED");
|
|
2251
|
+
}
|
|
2252
|
+
if (sourceInventory.totalBytes > request.budgets.maximumRepositoryBytes) {
|
|
2253
|
+
throw new Error("REPOSITORY_BYTES_BUDGET_EXCEEDED");
|
|
2254
|
+
}
|
|
2255
|
+
const nodeIdentity = request.adapter.kind === "node-test"
|
|
2256
|
+
? await probeNodeTestExecutable(request.adapter.executable, repositoryRoot, request.isolation.environmentAllowlist, request.budgets.timeoutMsPerExecution, request.budgets.maximumOutputBytes)
|
|
2257
|
+
: undefined;
|
|
2258
|
+
if (request.adapter.kind === "node-test" && nodeIdentity !== undefined) {
|
|
2259
|
+
request.adapter.executable = nodeIdentity.resolvedExecutable;
|
|
2260
|
+
}
|
|
2261
|
+
const nodeRuntimePreflight = request.adapter.kind === "node-test" && nodeIdentity !== undefined
|
|
2262
|
+
? await runNodeTestRuntimePreflight({
|
|
2263
|
+
executable: nodeIdentity.resolvedExecutable,
|
|
2264
|
+
reporterSource: NODE_TEST_REPORTER_SOURCE,
|
|
2265
|
+
environment: environmentFromAllowlist(request.isolation.environmentAllowlist),
|
|
2266
|
+
timeoutMs: request.budgets.timeoutMsPerExecution,
|
|
2267
|
+
maximumOutputBytes: request.budgets.maximumOutputBytes,
|
|
2268
|
+
processRunner: runProcess,
|
|
2269
|
+
})
|
|
2270
|
+
: undefined;
|
|
2271
|
+
const campaignRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "testforge-campaign-")));
|
|
2272
|
+
const repositorySnapshot = path.join(campaignRoot, "repository");
|
|
2273
|
+
try {
|
|
2274
|
+
await copyRepository(repositoryRoot, repositorySnapshot, excludes);
|
|
2275
|
+
const snapshotInventory = await walkFiles(repositorySnapshot, excludes);
|
|
2276
|
+
if (snapshotInventory.files.length > request.budgets.maximumRepositoryFiles) {
|
|
2277
|
+
throw new Error("REPOSITORY_FILE_BUDGET_EXCEEDED");
|
|
2278
|
+
}
|
|
2279
|
+
if (snapshotInventory.totalBytes > request.budgets.maximumRepositoryBytes) {
|
|
2280
|
+
throw new Error("REPOSITORY_BYTES_BUDGET_EXCEEDED");
|
|
2281
|
+
}
|
|
2282
|
+
const inputPaths = [
|
|
2283
|
+
...request.worlds.flatMap((world) => world.files.map((file) => file.path)),
|
|
2284
|
+
...request.candidates.flatMap((candidate) => candidate.files.map((file) => file.path)),
|
|
2285
|
+
...(request.adapter.kind === "node-test" ? request.adapter.baseTestFiles : []),
|
|
2286
|
+
...request.candidateRoots,
|
|
2287
|
+
];
|
|
2288
|
+
for (const inputPath of inputPaths) {
|
|
2289
|
+
const segments = inputPath.split("/");
|
|
2290
|
+
for (let length = 1; length <= segments.length; length += 1) {
|
|
2291
|
+
const spelling = segments.slice(0, length).join("/");
|
|
2292
|
+
const inventorySpelling = snapshotInventory.spellings.get(portablePathKey(spelling));
|
|
2293
|
+
if (inventorySpelling !== undefined && inventorySpelling !== spelling) {
|
|
2294
|
+
throw new Error("PORTABLE_PATH_COLLISION");
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
const nodeTestReporterPath = request.adapter.kind === "node-test"
|
|
2299
|
+
? path.join(campaignRoot, "node-test-reporter.mjs")
|
|
2300
|
+
: undefined;
|
|
2301
|
+
if (nodeTestReporterPath !== undefined) {
|
|
2302
|
+
await writeFile(nodeTestReporterPath, NODE_TEST_REPORTER_SOURCE, { flag: "wx" });
|
|
2303
|
+
}
|
|
2304
|
+
const analysis = (await analyzeRepository(repositorySnapshot));
|
|
2305
|
+
const observations = [];
|
|
2306
|
+
for (const world of request.worlds) {
|
|
2307
|
+
for (let attempt = 1; attempt <= request.policy.requiredAttempts; attempt += 1) {
|
|
2308
|
+
observations.push(await executeObservation(request, repositorySnapshot, world, null, attempt, nodeTestReporterPath));
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
for (const candidate of request.candidates) {
|
|
2312
|
+
for (const world of request.worlds) {
|
|
2313
|
+
for (let attempt = 1; attempt <= request.policy.requiredAttempts; attempt += 1) {
|
|
2314
|
+
observations.push(await executeObservation(request, repositorySnapshot, world, candidate, attempt, nodeTestReporterPath));
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
const evidence = {
|
|
2319
|
+
schemaVersion: request.schemaVersion,
|
|
2320
|
+
repositoryDigest: analysis.repositoryDigest,
|
|
2321
|
+
evidenceContext: {
|
|
2322
|
+
engine: { name: "testforge", version: "0.1.0" },
|
|
2323
|
+
adapter: {
|
|
2324
|
+
name: request.adapter.kind,
|
|
2325
|
+
version: request.adapter.kind === "testforge-command"
|
|
2326
|
+
? request.adapter.protocolVersion
|
|
2327
|
+
: (nodeIdentity?.nodeVersion ?? "unprobed"),
|
|
2328
|
+
configuration: request.adapter.kind === "node-test" && nodeIdentity !== undefined
|
|
2329
|
+
? {
|
|
2330
|
+
kind: "node-test",
|
|
2331
|
+
profile: {
|
|
2332
|
+
profileId: NODE_TEST_ADAPTER_PROFILE.profileId,
|
|
2333
|
+
profileVersion: NODE_TEST_ADAPTER_PROFILE.profileVersion,
|
|
2334
|
+
official: NODE_TEST_ADAPTER_PROFILE.official,
|
|
2335
|
+
capabilities: NODE_TEST_ADAPTER_PROFILE.capabilities,
|
|
2336
|
+
reporterDigest: NODE_TEST_ADAPTER_PROFILE.reporterDigest,
|
|
2337
|
+
},
|
|
2338
|
+
requestedExecutable: nodeIdentity.requestedExecutable,
|
|
2339
|
+
resolvedExecutable: nodeIdentity.resolvedExecutable,
|
|
2340
|
+
executableDigest: nodeIdentity.executableDigest,
|
|
2341
|
+
nodeVersion: nodeIdentity.nodeVersion,
|
|
2342
|
+
runtimePreflight: nodeRuntimePreflight,
|
|
2343
|
+
arguments: [
|
|
2344
|
+
"--test",
|
|
2345
|
+
...(request.adapter.extraArguments ?? []),
|
|
2346
|
+
"--",
|
|
2347
|
+
...request.adapter.baseTestFiles,
|
|
2348
|
+
],
|
|
2349
|
+
}
|
|
2350
|
+
: request.adapter,
|
|
2351
|
+
},
|
|
2352
|
+
execution: {
|
|
2353
|
+
isolation: "UNSANDBOXED",
|
|
2354
|
+
environmentAllowlist: request.isolation.environmentAllowlist,
|
|
2355
|
+
budgets: request.budgets,
|
|
2356
|
+
candidateRoots: request.candidateRoots,
|
|
2357
|
+
},
|
|
2358
|
+
worlds: request.worlds.map((world) => ({
|
|
2359
|
+
id: world.id,
|
|
2360
|
+
provenance: world.provenance ?? "unspecified",
|
|
2361
|
+
digest: sha256Canonical({
|
|
2362
|
+
files: world.files,
|
|
2363
|
+
provenance: world.provenance ?? null,
|
|
2364
|
+
}),
|
|
2365
|
+
})),
|
|
2366
|
+
},
|
|
2367
|
+
policy: request.policy,
|
|
2368
|
+
worlds: request.worlds.map(({ files: _files, provenance: _provenance, ...world }) => world),
|
|
2369
|
+
candidates: request.candidates.map((candidate) => ({
|
|
2370
|
+
id: candidate.id,
|
|
2371
|
+
digest: sha256Canonical(candidate.files),
|
|
2372
|
+
sizeBytes: candidate.files.reduce((total, file) => total + Buffer.byteLength(file.content), 0),
|
|
2373
|
+
})),
|
|
2374
|
+
observations,
|
|
2375
|
+
};
|
|
2376
|
+
const manifest = decideEvidence(evidence);
|
|
2377
|
+
const finalManifest = {
|
|
2378
|
+
...manifest,
|
|
2379
|
+
adapter: request.adapter.kind === "testforge-command"
|
|
2380
|
+
? { kind: request.adapter.kind, protocolVersion: request.adapter.protocolVersion }
|
|
2381
|
+
: { kind: request.adapter.kind },
|
|
2382
|
+
isolation: {
|
|
2383
|
+
kind: "trusted-local",
|
|
2384
|
+
level: "UNSANDBOXED",
|
|
2385
|
+
acknowledgedUnsafeExecution: true,
|
|
2386
|
+
},
|
|
2387
|
+
limitations: [
|
|
2388
|
+
"UNSANDBOXED trusted-local execution cannot safely contain hostile candidate code.",
|
|
2389
|
+
"Process-tree termination is best effort and depends on host operating-system facilities.",
|
|
2390
|
+
],
|
|
2391
|
+
};
|
|
2392
|
+
return sealManifestArtifact(finalManifest);
|
|
2393
|
+
}
|
|
2394
|
+
finally {
|
|
2395
|
+
await removeTemporaryDirectory(campaignRoot);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
function parseBenchmarkPhaseReport(value) {
|
|
2399
|
+
if (!isRecord(value))
|
|
2400
|
+
return undefined;
|
|
2401
|
+
const expectedKeys = [
|
|
2402
|
+
"attributed",
|
|
2403
|
+
"candidateTestsDiscovered",
|
|
2404
|
+
"cpuTimeUs",
|
|
2405
|
+
"outcome",
|
|
2406
|
+
"phases",
|
|
2407
|
+
"protocolVersion",
|
|
2408
|
+
"testsDiscovered",
|
|
2409
|
+
];
|
|
2410
|
+
if (Object.keys(value).sort().join("\0") !== expectedKeys.join("\0"))
|
|
2411
|
+
return undefined;
|
|
2412
|
+
if (value.protocolVersion !== "1.0.0" ||
|
|
2413
|
+
typeof value.outcome !== "string" ||
|
|
2414
|
+
!STRUCTURED_OUTCOMES.has(value.outcome) ||
|
|
2415
|
+
!nonNegativeInteger(value.testsDiscovered) ||
|
|
2416
|
+
!nonNegativeInteger(value.candidateTestsDiscovered) ||
|
|
2417
|
+
value.candidateTestsDiscovered > value.testsDiscovered ||
|
|
2418
|
+
typeof value.attributed !== "boolean" ||
|
|
2419
|
+
!(value.cpuTimeUs === null || nonNegativeInteger(value.cpuTimeUs))) {
|
|
2420
|
+
return undefined;
|
|
2421
|
+
}
|
|
2422
|
+
if (value.outcome !== "PASS") {
|
|
2423
|
+
if (value.phases !== null || value.cpuTimeUs !== null)
|
|
2424
|
+
return undefined;
|
|
2425
|
+
return {
|
|
2426
|
+
outcome: value.outcome,
|
|
2427
|
+
testsDiscovered: value.testsDiscovered,
|
|
2428
|
+
candidateTestsDiscovered: value.candidateTestsDiscovered,
|
|
2429
|
+
attributed: value.attributed,
|
|
2430
|
+
phases: null,
|
|
2431
|
+
cpuTimeUs: null,
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
if (value.candidateTestsDiscovered < 1 ||
|
|
2435
|
+
value.attributed !== true ||
|
|
2436
|
+
!Array.isArray(value.phases) ||
|
|
2437
|
+
value.phases.length !== 3) {
|
|
2438
|
+
return undefined;
|
|
2439
|
+
}
|
|
2440
|
+
const expectedPhases = ["STARTUP", "COMPILE_OR_COLLECTION", "EXECUTION"];
|
|
2441
|
+
const phases = [];
|
|
2442
|
+
for (const [index, rawPhase] of value.phases.entries()) {
|
|
2443
|
+
if (!isRecord(rawPhase) ||
|
|
2444
|
+
Object.keys(rawPhase).sort().join("\0") !== "durationUs\0phase" ||
|
|
2445
|
+
rawPhase.phase !== expectedPhases[index] ||
|
|
2446
|
+
!nonNegativeInteger(rawPhase.durationUs)) {
|
|
2447
|
+
return undefined;
|
|
2448
|
+
}
|
|
2449
|
+
phases.push({
|
|
2450
|
+
phase: expectedPhases[index],
|
|
2451
|
+
durationUs: rawPhase.durationUs,
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
return {
|
|
2455
|
+
outcome: "PASS",
|
|
2456
|
+
testsDiscovered: value.testsDiscovered,
|
|
2457
|
+
candidateTestsDiscovered: value.candidateTestsDiscovered,
|
|
2458
|
+
attributed: true,
|
|
2459
|
+
phases: phases,
|
|
2460
|
+
cpuTimeUs: value.cpuTimeUs,
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
function benchmarkFailureOutcome(processResult, report) {
|
|
2464
|
+
if (processResult.outcome === "TIMEOUT")
|
|
2465
|
+
return "TIMEOUT";
|
|
2466
|
+
if (processResult.outcome === "INFRA_ERROR")
|
|
2467
|
+
return "INFRA_ERROR";
|
|
2468
|
+
if (report !== undefined && report.outcome !== "PASS") {
|
|
2469
|
+
return processResult.exitCode === 0 ? "INFRA_ERROR" : report.outcome;
|
|
2470
|
+
}
|
|
2471
|
+
return processResult.exitCode === 0 ? "INFRA_ERROR" : "PROCESS_CRASH";
|
|
2472
|
+
}
|
|
2473
|
+
function elapsedMicroseconds(start) {
|
|
2474
|
+
const elapsed = (process.hrtime.bigint() - start) / 1000n;
|
|
2475
|
+
return elapsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(elapsed) : undefined;
|
|
2476
|
+
}
|
|
2477
|
+
async function executeBenchmarkMeasurement(request, snapshotRoot, referenceWorld, candidate, candidateDigest, cacheDirectory, regime, role, ordinal) {
|
|
2478
|
+
const temporaryRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "testforge-benchmark-run-")));
|
|
2479
|
+
const workspace = path.join(temporaryRoot, "repository");
|
|
2480
|
+
const resultFile = path.join(temporaryRoot, "benchmark-result.json");
|
|
2481
|
+
const preparationStartedAt = process.hrtime.bigint();
|
|
2482
|
+
let preparationUs;
|
|
2483
|
+
try {
|
|
2484
|
+
await copyRepository(snapshotRoot, workspace, effectiveExcludes(request.verificationRequest.repository.exclude));
|
|
2485
|
+
await applyFiles(workspace, referenceWorld.files);
|
|
2486
|
+
await applyFiles(workspace, candidate.files);
|
|
2487
|
+
preparationUs = elapsedMicroseconds(preparationStartedAt);
|
|
2488
|
+
const environment = environmentFromAllowlist(request.verificationRequest.isolation.environmentAllowlist);
|
|
2489
|
+
environment.TESTFORGE_BENCHMARK_RESULT_FILE = resultFile;
|
|
2490
|
+
environment.TESTFORGE_CANDIDATE_FILES = JSON.stringify(candidate.files.map((file) => file.path));
|
|
2491
|
+
environment.TESTFORGE_BENCHMARK_CACHE_DIR = cacheDirectory;
|
|
2492
|
+
environment.TESTFORGE_BENCHMARK_REGIME = regime;
|
|
2493
|
+
environment.TESTFORGE_BENCHMARK_ROLE = role;
|
|
2494
|
+
environment.TESTFORGE_BENCHMARK_ORDINAL = String(ordinal);
|
|
2495
|
+
const adapter = request.verificationRequest.adapter;
|
|
2496
|
+
if (adapter.kind !== "testforge-command") {
|
|
2497
|
+
throw new Error("BENCHMARK_PHASE_ACQUISITION_UNSUPPORTED_NODE_TEST");
|
|
2498
|
+
}
|
|
2499
|
+
const processResult = await runProcess({
|
|
2500
|
+
executable: adapter.executable,
|
|
2501
|
+
args: adapter.arguments,
|
|
2502
|
+
cwd: workspace,
|
|
2503
|
+
environment,
|
|
2504
|
+
timeoutMs: request.verificationRequest.budgets.timeoutMsPerExecution,
|
|
2505
|
+
maximumOutputBytes: request.verificationRequest.budgets.maximumOutputBytes,
|
|
2506
|
+
});
|
|
2507
|
+
const phaseReport = parseBenchmarkPhaseReport(await readBoundedJsonFile(resultFile, CONTROLLED_REPORT_MAXIMUM_BYTES));
|
|
2508
|
+
const common = { candidateId: candidate.id, candidateDigest, regime, role, ordinal };
|
|
2509
|
+
if (processResult.outcome !== "PASS" ||
|
|
2510
|
+
processResult.exitCode !== 0 ||
|
|
2511
|
+
phaseReport?.outcome !== "PASS" ||
|
|
2512
|
+
phaseReport.phases === null ||
|
|
2513
|
+
preparationUs === undefined) {
|
|
2514
|
+
return {
|
|
2515
|
+
...common,
|
|
2516
|
+
status: "INCOMPLETE",
|
|
2517
|
+
outcome: benchmarkFailureOutcome(processResult, phaseReport),
|
|
2518
|
+
phases: null,
|
|
2519
|
+
totalUs: null,
|
|
2520
|
+
cpuTimeUs: null,
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
const phases = [
|
|
2524
|
+
{ phase: "PREPARATION", durationUs: preparationUs },
|
|
2525
|
+
...phaseReport.phases,
|
|
2526
|
+
];
|
|
2527
|
+
const totalUs = phases.reduce((total, phase) => total + phase.durationUs, 0);
|
|
2528
|
+
if (!Number.isSafeInteger(totalUs)) {
|
|
2529
|
+
return {
|
|
2530
|
+
...common,
|
|
2531
|
+
status: "INCOMPLETE",
|
|
2532
|
+
outcome: "INFRA_ERROR",
|
|
2533
|
+
phases: null,
|
|
2534
|
+
totalUs: null,
|
|
2535
|
+
cpuTimeUs: null,
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
return {
|
|
2539
|
+
...common,
|
|
2540
|
+
status: "COMPLETE",
|
|
2541
|
+
outcome: "PASS",
|
|
2542
|
+
phases,
|
|
2543
|
+
totalUs,
|
|
2544
|
+
...(phaseReport.cpuTimeUs === null ? {} : { cpuTimeUs: phaseReport.cpuTimeUs }),
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
finally {
|
|
2548
|
+
await removeTemporaryDirectory(temporaryRoot);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
async function resolveSnapshotIdentityFile(snapshotRoot, inputPath) {
|
|
2552
|
+
const safePath = assertSafeRelativePath(inputPath, ["."]);
|
|
2553
|
+
const target = path.join(snapshotRoot, ...safePath.split("/"));
|
|
2554
|
+
const resolved = await realpath(target);
|
|
2555
|
+
const relative = path.relative(snapshotRoot, resolved);
|
|
2556
|
+
if (relative === ".." ||
|
|
2557
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
2558
|
+
path.isAbsolute(relative) ||
|
|
2559
|
+
!(await stat(resolved)).isFile()) {
|
|
2560
|
+
throw new Error("AGENTIC_BENCHMARK_ACQUISITION_IDENTITY_INVALID");
|
|
2561
|
+
}
|
|
2562
|
+
return resolved;
|
|
2563
|
+
}
|
|
2564
|
+
function acquisitionResultProjection(result) {
|
|
2565
|
+
return result;
|
|
2566
|
+
}
|
|
2567
|
+
function assertAcquisitionIdentityIsolation(request) {
|
|
2568
|
+
const identityPaths = [
|
|
2569
|
+
request.identityFiles.phaseReporter,
|
|
2570
|
+
...request.identityFiles.dependencyGraph,
|
|
2571
|
+
].map((identityPath) => assertSafeRelativePath(identityPath, ["."]));
|
|
2572
|
+
const identityKeys = identityPaths.map(portablePathKey);
|
|
2573
|
+
if (new Set(identityKeys).size !== identityKeys.length) {
|
|
2574
|
+
throw new Error("AGENTIC_BENCHMARK_ACQUISITION_IDENTITY_INVALID");
|
|
2575
|
+
}
|
|
2576
|
+
const overlayPaths = request.verificationRequest.worlds
|
|
2577
|
+
.flatMap((world) => world.files)
|
|
2578
|
+
.concat(request.verificationRequest.candidates.flatMap((candidate) => candidate.files))
|
|
2579
|
+
.map((file) => portablePathKey(file.path));
|
|
2580
|
+
for (const identityKey of identityKeys) {
|
|
2581
|
+
if (overlayPaths.some((overlayKey) => overlayKey === identityKey ||
|
|
2582
|
+
overlayKey.startsWith(`${identityKey}/`) ||
|
|
2583
|
+
identityKey.startsWith(`${overlayKey}/`))) {
|
|
2584
|
+
throw new Error("AGENTIC_BENCHMARK_ACQUISITION_IDENTITY_OVERLAY_FORBIDDEN");
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
export function benchmarkCacheDirectoryKey(_candidateId, candidateOrdinal, regime, runOrdinal) {
|
|
2589
|
+
const subject = `subject-${String(candidateOrdinal).padStart(6, "0")}`;
|
|
2590
|
+
return regime === "COLD"
|
|
2591
|
+
? `${subject}-cold-${String(runOrdinal).padStart(6, "0")}`
|
|
2592
|
+
: `${subject}-warm`;
|
|
2593
|
+
}
|
|
2594
|
+
export async function acquireAgenticBenchmark(value) {
|
|
2595
|
+
const request = parseAgenticBenchmarkAcquisitionRequest(value);
|
|
2596
|
+
assertAcquisitionIdentityIsolation(request);
|
|
2597
|
+
if (request.verificationRequest.adapter.kind !== "testforge-command") {
|
|
2598
|
+
throw new Error("BENCHMARK_PHASE_ACQUISITION_UNSUPPORTED_NODE_TEST");
|
|
2599
|
+
}
|
|
2600
|
+
const requestDigest = sha256Canonical(request);
|
|
2601
|
+
const repositoryRoot = await resolveRepositoryRoot(request.verificationRequest.repository.root);
|
|
2602
|
+
const excludes = effectiveExcludes(request.verificationRequest.repository.exclude);
|
|
2603
|
+
const sourceInventory = await walkFiles(repositoryRoot, excludes);
|
|
2604
|
+
if (sourceInventory.files.length > request.verificationRequest.budgets.maximumRepositoryFiles) {
|
|
2605
|
+
throw new Error("REPOSITORY_FILE_BUDGET_EXCEEDED");
|
|
2606
|
+
}
|
|
2607
|
+
if (sourceInventory.totalBytes > request.verificationRequest.budgets.maximumRepositoryBytes) {
|
|
2608
|
+
throw new Error("REPOSITORY_BYTES_BUDGET_EXCEEDED");
|
|
2609
|
+
}
|
|
2610
|
+
const acquisitionRoot = await realpath(await mkdtemp(path.join(os.tmpdir(), "testforge-benchmark-acquisition-")));
|
|
2611
|
+
const snapshotRoot = path.join(acquisitionRoot, "repository");
|
|
2612
|
+
try {
|
|
2613
|
+
await copyRepository(repositoryRoot, snapshotRoot, excludes);
|
|
2614
|
+
const initialAnalysis = (await analyzeRepository(snapshotRoot));
|
|
2615
|
+
const phaseReporterFile = await resolveSnapshotIdentityFile(snapshotRoot, request.identityFiles.phaseReporter);
|
|
2616
|
+
const dependencyFiles = await Promise.all(request.identityFiles.dependencyGraph.map(async (dependencyPath) => ({
|
|
2617
|
+
path: dependencyPath,
|
|
2618
|
+
digest: await sha256File(await resolveSnapshotIdentityFile(snapshotRoot, dependencyPath)),
|
|
2619
|
+
})));
|
|
2620
|
+
const resolvedExecutable = await realpath(request.verificationRequest.adapter.executable);
|
|
2621
|
+
if (!(await stat(resolvedExecutable)).isFile()) {
|
|
2622
|
+
throw new Error("AGENTIC_BENCHMARK_ACQUISITION_EXECUTABLE_INVALID");
|
|
2623
|
+
}
|
|
2624
|
+
const executableDigest = await sha256File(resolvedExecutable);
|
|
2625
|
+
const identityDigest = await sha256File(phaseReporterFile);
|
|
2626
|
+
const dependencyGraphDigest = sha256Canonical(dependencyFiles);
|
|
2627
|
+
const adapterArguments = [...request.verificationRequest.adapter.arguments];
|
|
2628
|
+
const baseContext = {
|
|
2629
|
+
snapshotRepositoryDigest: initialAnalysis.repositoryDigest,
|
|
2630
|
+
referenceWorldId: request.referenceWorldId,
|
|
2631
|
+
candidateIds: [],
|
|
2632
|
+
adapter: {
|
|
2633
|
+
executable: resolvedExecutable,
|
|
2634
|
+
arguments: adapterArguments,
|
|
2635
|
+
executableDigest,
|
|
2636
|
+
identityFilePath: request.identityFiles.phaseReporter,
|
|
2637
|
+
identityDigest,
|
|
2638
|
+
},
|
|
2639
|
+
dependencyFiles,
|
|
2640
|
+
dependencyGraphDigest,
|
|
2641
|
+
cachePolicy: {
|
|
2642
|
+
cold: "UNIQUE_EMPTY_DIRECTORY_PER_RUN",
|
|
2643
|
+
warm: "ONE_INITIALLY_EMPTY_DIRECTORY_PER_CANDIDATE",
|
|
2644
|
+
sharedAcrossCandidates: false,
|
|
2645
|
+
},
|
|
2646
|
+
};
|
|
2647
|
+
const sourceManifest = parseEvidenceManifest(await verifyCampaign({
|
|
2648
|
+
...request.verificationRequest,
|
|
2649
|
+
repository: { ...request.verificationRequest.repository, root: snapshotRoot },
|
|
2650
|
+
adapter: { ...request.verificationRequest.adapter, executable: resolvedExecutable },
|
|
2651
|
+
}));
|
|
2652
|
+
const postVerificationAnalysis = (await analyzeRepository(snapshotRoot));
|
|
2653
|
+
if (postVerificationAnalysis.repositoryDigest !== initialAnalysis.repositoryDigest) {
|
|
2654
|
+
throw new Error("AGENTIC_BENCHMARK_ACQUISITION_SNAPSHOT_DRIFT");
|
|
2655
|
+
}
|
|
2656
|
+
const sourceReplay = replayEvidenceManifest(sourceManifest);
|
|
2657
|
+
if (!sourceReplay.valid || sourceManifest.decision.status !== "VERIFIED") {
|
|
2658
|
+
const base = {
|
|
2659
|
+
schemaVersion: "1.0.0",
|
|
2660
|
+
requestDigest,
|
|
2661
|
+
status: "SOURCE_NOT_VERIFIED",
|
|
2662
|
+
reasonCodes: [sourceReplay.valid ? "SOURCE_NOT_VERIFIED" : "SOURCE_REPLAY_INVALID"],
|
|
2663
|
+
sourceManifest,
|
|
2664
|
+
benchmarkArtifact: null,
|
|
2665
|
+
acquisitionContext: baseContext,
|
|
2666
|
+
limitations: [
|
|
2667
|
+
"No benchmark process runs unless the fresh source campaign is VERIFIED and replay-valid.",
|
|
2668
|
+
],
|
|
2669
|
+
};
|
|
2670
|
+
return parseAgenticBenchmarkAcquisitionResult({
|
|
2671
|
+
...base,
|
|
2672
|
+
resultDigest: sha256Canonical(acquisitionResultProjection(base)),
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
2675
|
+
const referenceWorld = request.verificationRequest.worlds.find((world) => world.id === request.referenceWorldId && world.kind === "REFERENCE" && world.required);
|
|
2676
|
+
if (referenceWorld === undefined) {
|
|
2677
|
+
throw new Error("AGENTIC_BENCHMARK_ACQUISITION_REFERENCE_INVALID");
|
|
2678
|
+
}
|
|
2679
|
+
const selectedCandidates = sourceManifest.decision.selectedCandidateIds.map((id) => {
|
|
2680
|
+
const assessment = sourceManifest.candidates.find((candidate) => candidate.id === id && candidate.status === "ELIGIBLE");
|
|
2681
|
+
const candidate = request.verificationRequest.candidates.find((item) => item.id === id);
|
|
2682
|
+
if (assessment === undefined || candidate === undefined) {
|
|
2683
|
+
throw new Error("AGENTIC_BENCHMARK_ACQUISITION_SOURCE_BINDING_INVALID");
|
|
2684
|
+
}
|
|
2685
|
+
return { candidate: candidate, digest: assessment.digest };
|
|
2686
|
+
});
|
|
2687
|
+
const context = {
|
|
2688
|
+
...baseContext,
|
|
2689
|
+
candidateIds: selectedCandidates.map(({ candidate }) => candidate.id),
|
|
2690
|
+
};
|
|
2691
|
+
const cacheRoot = path.join(acquisitionRoot, "cache");
|
|
2692
|
+
await mkdir(cacheRoot, { recursive: false });
|
|
2693
|
+
const runs = [];
|
|
2694
|
+
for (const [candidateIndex, { candidate, digest }] of selectedCandidates.entries()) {
|
|
2695
|
+
for (let ordinal = 1; ordinal <= request.policy.coldMeasuredSamples; ordinal += 1) {
|
|
2696
|
+
const cacheDirectory = path.join(cacheRoot, benchmarkCacheDirectoryKey(candidate.id, candidateIndex + 1, "COLD", ordinal));
|
|
2697
|
+
await mkdir(cacheDirectory, { recursive: false });
|
|
2698
|
+
runs.push(await executeBenchmarkMeasurement(request, snapshotRoot, referenceWorld, candidate, digest, cacheDirectory, "COLD", "MEASUREMENT", ordinal));
|
|
2699
|
+
}
|
|
2700
|
+
const warmCacheDirectory = path.join(cacheRoot, benchmarkCacheDirectoryKey(candidate.id, candidateIndex + 1, "WARM", 1));
|
|
2701
|
+
await mkdir(warmCacheDirectory, { recursive: false });
|
|
2702
|
+
for (let ordinal = 1; ordinal <= request.policy.warmupSamples; ordinal += 1) {
|
|
2703
|
+
runs.push(await executeBenchmarkMeasurement(request, snapshotRoot, referenceWorld, candidate, digest, warmCacheDirectory, "WARM", "WARMUP", ordinal));
|
|
2704
|
+
}
|
|
2705
|
+
for (let ordinal = 1; ordinal <= request.policy.warmMeasuredSamples; ordinal += 1) {
|
|
2706
|
+
runs.push(await executeBenchmarkMeasurement(request, snapshotRoot, referenceWorld, candidate, digest, warmCacheDirectory, "WARM", "MEASUREMENT", ordinal));
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
const finalAnalysis = (await analyzeRepository(snapshotRoot));
|
|
2710
|
+
if (finalAnalysis.repositoryDigest !== initialAnalysis.repositoryDigest) {
|
|
2711
|
+
throw new Error("AGENTIC_BENCHMARK_ACQUISITION_SNAPSHOT_DRIFT");
|
|
2712
|
+
}
|
|
2713
|
+
const cpus = os.cpus();
|
|
2714
|
+
const benchmarkArtifact = createAgenticBenchmark({
|
|
2715
|
+
schemaVersion: "1.0.0",
|
|
2716
|
+
sourceManifest,
|
|
2717
|
+
referenceWorldId: request.referenceWorldId,
|
|
2718
|
+
policy: request.policy,
|
|
2719
|
+
protocol: request.protocol,
|
|
2720
|
+
fingerprint: {
|
|
2721
|
+
environmentId: request.environment.environmentId,
|
|
2722
|
+
os: { platform: os.platform(), release: os.release(), arch: os.arch() },
|
|
2723
|
+
cpu: {
|
|
2724
|
+
arch: os.arch(),
|
|
2725
|
+
model: cpus[0]?.model ?? "unknown",
|
|
2726
|
+
logicalCores: Math.max(1, cpus.length),
|
|
2727
|
+
},
|
|
2728
|
+
logicalCpuLimit: request.environment.logicalCpuLimit,
|
|
2729
|
+
memoryLimitBytes: request.environment.memoryLimitBytes,
|
|
2730
|
+
executionBoundary: "UNSANDBOXED",
|
|
2731
|
+
tools: [
|
|
2732
|
+
{
|
|
2733
|
+
role: "phase-adapter",
|
|
2734
|
+
name: "testforge-command",
|
|
2735
|
+
version: request.verificationRequest.adapter.protocolVersion,
|
|
2736
|
+
digest: executableDigest,
|
|
2737
|
+
configurationDigest: sha256Canonical({
|
|
2738
|
+
arguments: adapterArguments,
|
|
2739
|
+
identityFilePath: request.identityFiles.phaseReporter,
|
|
2740
|
+
identityDigest,
|
|
2741
|
+
}),
|
|
2742
|
+
},
|
|
2743
|
+
],
|
|
2744
|
+
dependencyGraphDigest,
|
|
2745
|
+
phaseReporterDigest: identityDigest,
|
|
2746
|
+
},
|
|
2747
|
+
runs,
|
|
2748
|
+
});
|
|
2749
|
+
if (!replayAgenticBenchmark(benchmarkArtifact).valid) {
|
|
2750
|
+
throw new Error("AGENTIC_BENCHMARK_ACQUISITION_REPLAY_FAILED");
|
|
2751
|
+
}
|
|
2752
|
+
const summaryStatuses = benchmarkArtifact.summaries.map((summary) => summary.status);
|
|
2753
|
+
const status = summaryStatuses.some((status) => status === "OBSERVED_RUN_FAILURE")
|
|
2754
|
+
? "OBSERVED_RUN_FAILURE"
|
|
2755
|
+
: summaryStatuses.some((status) => status === "INSUFFICIENT_SAMPLES")
|
|
2756
|
+
? "INSUFFICIENT_SAMPLES"
|
|
2757
|
+
: "COMPLETE";
|
|
2758
|
+
const base = {
|
|
2759
|
+
schemaVersion: "1.0.0",
|
|
2760
|
+
requestDigest,
|
|
2761
|
+
status,
|
|
2762
|
+
reasonCodes: [status === "COMPLETE" ? "ACQUISITION_COMPLETE" : status],
|
|
2763
|
+
sourceManifest,
|
|
2764
|
+
benchmarkArtifact,
|
|
2765
|
+
acquisitionContext: context,
|
|
2766
|
+
limitations: [
|
|
2767
|
+
"The phase adapter is part of the trusted computing base and its declarations are not independently authenticated.",
|
|
2768
|
+
"STARTUP is adapter-observed initialization and excludes operating-system process spawn latency.",
|
|
2769
|
+
"A benchmark cache directory is provided, but the adapter may ignore it.",
|
|
2770
|
+
"Benchmark timing outcomes never alter the embedded source VERIFIED decision or its digests.",
|
|
2771
|
+
],
|
|
2772
|
+
};
|
|
2773
|
+
return parseAgenticBenchmarkAcquisitionResult({
|
|
2774
|
+
...base,
|
|
2775
|
+
resultDigest: sha256Canonical(acquisitionResultProjection(base)),
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
finally {
|
|
2779
|
+
await removeTemporaryDirectory(acquisitionRoot);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
//# sourceMappingURL=index.js.map
|