assertledger 1.1.0 → 1.2.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/README.fr.md +14 -4
- package/README.md +14 -4
- package/conformance/schema-extensions.json +25 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +68 -20
- package/dist/cli.js.map +1 -1
- package/dist/contracts/index.d.ts +510 -4
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +230 -16
- package/dist/contracts/index.js.map +1 -1
- package/dist/contracts/runtime-doctor.d.ts +123 -0
- package/dist/contracts/runtime-doctor.d.ts.map +1 -1
- package/dist/contracts/runtime-doctor.js +64 -0
- package/dist/contracts/runtime-doctor.js.map +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +16 -6
- package/dist/core/index.js.map +1 -1
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +26 -2
- package/dist/diagnostics.js.map +1 -1
- package/dist/engine/adapters/bun-test-profile.d.ts +17 -0
- package/dist/engine/adapters/bun-test-profile.d.ts.map +1 -0
- package/dist/engine/adapters/bun-test-profile.js +17 -0
- package/dist/engine/adapters/bun-test-profile.js.map +1 -0
- package/dist/engine/git-regression.d.ts +2 -2
- package/dist/engine/git-regression.d.ts.map +1 -1
- package/dist/engine/git-regression.js.map +1 -1
- package/dist/engine/index.d.ts +11 -5
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +454 -49
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/runtime-doctor.d.ts +13 -4
- package/dist/engine/runtime-doctor.d.ts.map +1 -1
- package/dist/engine/runtime-doctor.js +80 -2
- package/dist/engine/runtime-doctor.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +36 -6
- package/dist/mcp/index.js.map +1 -1
- package/dist/proof-planner/carry-over.d.ts +21 -0
- package/dist/proof-planner/carry-over.d.ts.map +1 -0
- package/dist/proof-planner/carry-over.js +212 -0
- package/dist/proof-planner/carry-over.js.map +1 -0
- package/dist/proof-planner/index.d.ts +11 -0
- package/dist/proof-planner/index.d.ts.map +1 -0
- package/dist/proof-planner/index.js +11 -0
- package/dist/proof-planner/index.js.map +1 -0
- package/dist/proof-planner/model.d.ts +272 -0
- package/dist/proof-planner/model.d.ts.map +1 -0
- package/dist/proof-planner/model.js +215 -0
- package/dist/proof-planner/model.js.map +1 -0
- package/dist/proof-planner/plan.d.ts +140 -0
- package/dist/proof-planner/plan.d.ts.map +1 -0
- package/dist/proof-planner/plan.js +1124 -0
- package/dist/proof-planner/plan.js.map +1 -0
- package/dist/proof-planner/policy.d.ts +100 -0
- package/dist/proof-planner/policy.d.ts.map +1 -0
- package/dist/proof-planner/policy.js +407 -0
- package/dist/proof-planner/policy.js.map +1 -0
- package/dist/proof-planner/render.d.ts +4 -0
- package/dist/proof-planner/render.d.ts.map +1 -0
- package/dist/proof-planner/render.js +73 -0
- package/dist/proof-planner/render.js.map +1 -0
- package/dist/sdk/index.d.ts +7 -6
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +20 -7
- package/dist/sdk/index.js.map +1 -1
- package/docs/adapter-protocol.md +61 -0
- package/docs/architecture.md +11 -1
- package/docs/developer-experience.md +7 -5
- package/docs/distribution.md +8 -3
- package/docs/migration-timeout-discovery-inconclusive.md +126 -0
- package/docs/migration-verification-v3.md +33 -0
- package/docs/project-intent.md +7 -5
- package/docs/proof-model.md +20 -2
- package/docs/proof-planner.md +368 -0
- package/docs/reference.md +9 -6
- package/docs/repository-init.md +38 -7
- package/docs/runtime-doctor.md +12 -9
- package/integrations/bun/assertions.d.mts +4 -0
- package/integrations/bun/assertions.mjs +25 -0
- package/integrations/bun/driver.d.mts +27 -0
- package/integrations/bun/driver.mjs +383 -0
- package/integrations/bun/preload.mjs +165 -0
- package/integrations/skill/SKILL.md +4 -1
- package/package.json +8 -4
- package/schemas/evidence-manifest.v3.json +897 -0
- package/schemas/repository-init-config.v2.json +210 -0
- package/schemas/repository-init-lock.v2.json +162 -0
- package/schemas/repository-init-result.v2.json +212 -0
- package/schemas/verification-request.v3.json +484 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const ASSERTION_ERROR_NAME = "AssertLedgerBunAssertionError";
|
|
2
|
+
const ASSERTION_ERROR_MESSAGE = "AssertLedger assertSame failed";
|
|
3
|
+
const issuedErrors = new WeakSet();
|
|
4
|
+
const addIssuedError = WeakSet.prototype.add.bind(issuedErrors);
|
|
5
|
+
const hasIssuedError = WeakSet.prototype.has.bind(issuedErrors);
|
|
6
|
+
|
|
7
|
+
class AssertLedgerBunAssertionError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super(ASSERTION_ERROR_MESSAGE);
|
|
10
|
+
this.name = ASSERTION_ERROR_NAME;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function assertSame(actual, expected) {
|
|
15
|
+
if (!Object.is(actual, expected)) {
|
|
16
|
+
const error = new AssertLedgerBunAssertionError();
|
|
17
|
+
addIssuedError(error);
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @internal Identifies an error instance issued by this helper. */
|
|
23
|
+
export function isAssertSameFailure(error) {
|
|
24
|
+
return typeof error === "object" && error !== null && hasIssuedError(error);
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface BunInstrumentedResult {
|
|
2
|
+
protocolVersion: "1.0.0";
|
|
3
|
+
outcome:
|
|
4
|
+
| "PASS"
|
|
5
|
+
| "ASSERTION_FAILURE"
|
|
6
|
+
| "PROCESS_CRASH"
|
|
7
|
+
| "TIMEOUT"
|
|
8
|
+
| "INFRA_ERROR"
|
|
9
|
+
| "NO_TEST_DISCOVERED";
|
|
10
|
+
testsDiscovered: number;
|
|
11
|
+
candidateTestsDiscovered: number;
|
|
12
|
+
attributed: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type BunTestEvent =
|
|
16
|
+
| { kind: "found"; id: string; file: string }
|
|
17
|
+
| { kind: "end"; id: string; status: "pass" | "fail"; owned: boolean }
|
|
18
|
+
| { kind: "hook-error" };
|
|
19
|
+
|
|
20
|
+
export function classifyBunInstrumentedEvidence(
|
|
21
|
+
events: readonly BunTestEvent[] | undefined,
|
|
22
|
+
junit: { tests: number; failures: number; skipped: number } | undefined,
|
|
23
|
+
baseFiles: ReadonlySet<string>,
|
|
24
|
+
candidateFiles: ReadonlySet<string>,
|
|
25
|
+
exitCode: number | null,
|
|
26
|
+
operationalError?: boolean,
|
|
27
|
+
): BunInstrumentedResult;
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
const RESULT_VERSION = "1.0.0";
|
|
8
|
+
const BUN_REVISION = "1.4.2+744846f84";
|
|
9
|
+
const MAX_REPORT_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
11
|
+
const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "gu");
|
|
12
|
+
const PRELOAD_SOURCE_PATH = fileURLToPath(new URL("./preload.mjs", import.meta.url));
|
|
13
|
+
|
|
14
|
+
function report(outcome, testsDiscovered = 0, candidateTestsDiscovered = 0, attributed = false) {
|
|
15
|
+
return {
|
|
16
|
+
protocolVersion: RESULT_VERSION,
|
|
17
|
+
outcome,
|
|
18
|
+
testsDiscovered,
|
|
19
|
+
candidateTestsDiscovered,
|
|
20
|
+
attributed,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function infrastructureFailure(reason) {
|
|
25
|
+
console.error(`BUN_TEST_INFRA_ERROR:${reason}`);
|
|
26
|
+
return report("INFRA_ERROR");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeRelativeFile(value) {
|
|
30
|
+
if (
|
|
31
|
+
typeof value !== "string" ||
|
|
32
|
+
value.length === 0 ||
|
|
33
|
+
value.startsWith("-") ||
|
|
34
|
+
path.isAbsolute(value)
|
|
35
|
+
)
|
|
36
|
+
return undefined;
|
|
37
|
+
const normalized = path.normalize(value);
|
|
38
|
+
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) return undefined;
|
|
39
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeAbsoluteFile(value, root) {
|
|
43
|
+
if (typeof value !== "string" || !path.isAbsolute(value)) return undefined;
|
|
44
|
+
const relative = path.relative(root, path.resolve(value));
|
|
45
|
+
return normalizeRelativeFile(relative);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function strictInteger(value) {
|
|
49
|
+
if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/u.test(value)) return undefined;
|
|
50
|
+
const parsed = Number(value);
|
|
51
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseJunitSummary(xml) {
|
|
55
|
+
if (
|
|
56
|
+
typeof xml !== "string" ||
|
|
57
|
+
!xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>') ||
|
|
58
|
+
!xml.trimEnd().endsWith("</testsuites>") ||
|
|
59
|
+
xml.includes("<!DOCTYPE")
|
|
60
|
+
)
|
|
61
|
+
return undefined;
|
|
62
|
+
const root = xml.match(/<testsuites\b([^>]*)>/u);
|
|
63
|
+
if (root === null) return undefined;
|
|
64
|
+
const attributes = new Map();
|
|
65
|
+
const matches = [...root[1].matchAll(/\s+([A-Za-z][A-Za-z0-9-]*)="([^"]*)"/gu)];
|
|
66
|
+
if (root[1].replace(/\s+([A-Za-z][A-Za-z0-9-]*)="([^"]*)"/gu, "").trim() !== "") {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
for (const match of matches) {
|
|
70
|
+
if (attributes.has(match[1])) return undefined;
|
|
71
|
+
attributes.set(match[1], match[2]);
|
|
72
|
+
}
|
|
73
|
+
if (attributes.get("name") !== "bun test") return undefined;
|
|
74
|
+
const tests = strictInteger(attributes.get("tests"));
|
|
75
|
+
const failures = strictInteger(attributes.get("failures"));
|
|
76
|
+
const skipped = strictInteger(attributes.get("skipped"));
|
|
77
|
+
if (
|
|
78
|
+
tests === undefined ||
|
|
79
|
+
failures === undefined ||
|
|
80
|
+
skipped === undefined ||
|
|
81
|
+
failures > tests ||
|
|
82
|
+
skipped > tests
|
|
83
|
+
) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return { tests, failures, skipped };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseEvents(content, root, allowedFiles, evidenceKey) {
|
|
90
|
+
if (!content.endsWith("\n")) return undefined;
|
|
91
|
+
const lines = content.trimEnd().split("\n");
|
|
92
|
+
if (lines.length === 1 && lines[0] === "") return [];
|
|
93
|
+
const events = [];
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
let envelope;
|
|
96
|
+
try {
|
|
97
|
+
envelope = JSON.parse(line);
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
typeof envelope !== "object" ||
|
|
103
|
+
envelope === null ||
|
|
104
|
+
Array.isArray(envelope) ||
|
|
105
|
+
Object.keys(envelope).sort().join(",") !== "event,mac" ||
|
|
106
|
+
typeof envelope.mac !== "string" ||
|
|
107
|
+
!/^[0-9a-f]{64}$/u.test(envelope.mac)
|
|
108
|
+
)
|
|
109
|
+
return undefined;
|
|
110
|
+
const expectedMac = createHmac("sha256", evidenceKey)
|
|
111
|
+
.update(JSON.stringify(envelope.event))
|
|
112
|
+
.digest();
|
|
113
|
+
if (!timingSafeEqual(expectedMac, Buffer.from(envelope.mac, "hex"))) return undefined;
|
|
114
|
+
const value = envelope.event;
|
|
115
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
if (value.kind === "hook-error") {
|
|
119
|
+
if (Object.keys(value).join(",") !== "kind") return undefined;
|
|
120
|
+
events.push({ kind: "hook-error" });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (typeof value.id !== "string" || !/^[0-9a-f-]{36}$/u.test(value.id)) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (value.kind === "found") {
|
|
127
|
+
if (Object.keys(value).sort().join(",") !== "file,id,kind") return undefined;
|
|
128
|
+
const file = normalizeAbsoluteFile(value.file, root);
|
|
129
|
+
if (file === undefined || !allowedFiles.has(file)) return undefined;
|
|
130
|
+
events.push({ kind: "found", id: value.id, file });
|
|
131
|
+
} else if (value.kind === "end") {
|
|
132
|
+
if (value.status !== "pass" && value.status !== "fail") return undefined;
|
|
133
|
+
const expectedKeys = value.status === "pass" ? "id,kind,status" : "id,kind,owned,status";
|
|
134
|
+
if (Object.keys(value).sort().join(",") !== expectedKeys) return undefined;
|
|
135
|
+
if (value.status === "fail" && typeof value.owned !== "boolean") return undefined;
|
|
136
|
+
events.push({ kind: "end", id: value.id, status: value.status, owned: value.owned ?? false });
|
|
137
|
+
} else return undefined;
|
|
138
|
+
}
|
|
139
|
+
return events;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function classifyBunInstrumentedEvidence(
|
|
143
|
+
events,
|
|
144
|
+
junit,
|
|
145
|
+
baseFiles,
|
|
146
|
+
candidateFiles,
|
|
147
|
+
exitCode,
|
|
148
|
+
operationalError = false,
|
|
149
|
+
) {
|
|
150
|
+
if (
|
|
151
|
+
events === undefined ||
|
|
152
|
+
junit === undefined ||
|
|
153
|
+
exitCode === null ||
|
|
154
|
+
junit.skipped !== 0 ||
|
|
155
|
+
operationalError ||
|
|
156
|
+
events.some((event) => event.kind === "hook-error")
|
|
157
|
+
) {
|
|
158
|
+
return infrastructureFailure("INCOMPLETE_CONTROLLED_REPORT");
|
|
159
|
+
}
|
|
160
|
+
const found = new Map();
|
|
161
|
+
const ended = new Map();
|
|
162
|
+
for (const event of events) {
|
|
163
|
+
if (event.kind === "found") {
|
|
164
|
+
if (found.has(event.id)) return infrastructureFailure("DUPLICATE_TEST_ID");
|
|
165
|
+
found.set(event.id, event.file);
|
|
166
|
+
} else {
|
|
167
|
+
if (!found.has(event.id)) return infrastructureFailure("UNMATCHED_TEST_END");
|
|
168
|
+
if (ended.has(event.id)) return infrastructureFailure("DUPLICATE_TEST_END");
|
|
169
|
+
ended.set(event.id, event);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const completed = [...ended.values()];
|
|
173
|
+
if (found.size === 0 || found.size !== ended.size || completed.length !== junit.tests) {
|
|
174
|
+
return infrastructureFailure("TEST_COUNT_MISMATCH");
|
|
175
|
+
}
|
|
176
|
+
const files = new Set(found.values());
|
|
177
|
+
if ([...baseFiles].some((file) => !files.has(file))) {
|
|
178
|
+
return infrastructureFailure("BASE_TEST_FILE_NOT_STARTED");
|
|
179
|
+
}
|
|
180
|
+
if ([...files].some((file) => !baseFiles.has(file) && !candidateFiles.has(file))) {
|
|
181
|
+
return infrastructureFailure("UNEXPECTED_TEST_FILE");
|
|
182
|
+
}
|
|
183
|
+
const failures = completed.filter((entry) => entry.status === "fail");
|
|
184
|
+
if (failures.length !== junit.failures || (exitCode === 0) !== (junit.failures === 0)) {
|
|
185
|
+
return infrastructureFailure("BUN_STATUS_MISMATCH");
|
|
186
|
+
}
|
|
187
|
+
const candidateIds = completed
|
|
188
|
+
.filter((entry) => candidateFiles.has(found.get(entry.id)))
|
|
189
|
+
.map((entry) => entry.id);
|
|
190
|
+
if (exitCode === 0) {
|
|
191
|
+
return candidateFiles.size > 0 && candidateIds.length === 0
|
|
192
|
+
? report("NO_TEST_DISCOVERED", completed.length, 0, false)
|
|
193
|
+
: report("PASS", completed.length, candidateIds.length, candidateFiles.size > 0);
|
|
194
|
+
}
|
|
195
|
+
const candidateFailures = failures.filter((entry) => candidateFiles.has(found.get(entry.id)));
|
|
196
|
+
const baseFailures = failures.filter((entry) => baseFiles.has(found.get(entry.id)));
|
|
197
|
+
if (baseFailures.length > 0 || candidateIds.length === 0) {
|
|
198
|
+
return report("PROCESS_CRASH", completed.length, candidateIds.length, false);
|
|
199
|
+
}
|
|
200
|
+
const attributed =
|
|
201
|
+
candidateFailures.length > 0 &&
|
|
202
|
+
failures.length === candidateFailures.length &&
|
|
203
|
+
candidateFailures.every((entry) => entry.owned);
|
|
204
|
+
return report(
|
|
205
|
+
attributed ? "ASSERTION_FAILURE" : "PROCESS_CRASH",
|
|
206
|
+
completed.length,
|
|
207
|
+
candidateIds.length,
|
|
208
|
+
attributed,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function readBoundedRegularFile(file, root) {
|
|
213
|
+
const details = await lstat(file);
|
|
214
|
+
if (!details.isFile() || details.size > MAX_REPORT_BYTES) throw new Error("INVALID_REPORT_FILE");
|
|
215
|
+
const resolved = await realpath(file);
|
|
216
|
+
const relative = path.relative(root, resolved);
|
|
217
|
+
if (
|
|
218
|
+
relative === "" ||
|
|
219
|
+
relative === ".." ||
|
|
220
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
221
|
+
path.isAbsolute(relative)
|
|
222
|
+
) {
|
|
223
|
+
throw new Error("REPORT_PATH_ESCAPE");
|
|
224
|
+
}
|
|
225
|
+
return readFile(file, "utf8");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function installHelper(helperSource, root) {
|
|
229
|
+
const packageRoot = path.join(root, "node_modules", "assertledger");
|
|
230
|
+
await mkdir(packageRoot, { recursive: true });
|
|
231
|
+
await writeFile(path.join(packageRoot, "bun.mjs"), helperSource, { flag: "wx" });
|
|
232
|
+
await writeFile(
|
|
233
|
+
path.join(packageRoot, "preload.mjs"),
|
|
234
|
+
await readFile(PRELOAD_SOURCE_PATH, "utf8"),
|
|
235
|
+
{ flag: "wx" },
|
|
236
|
+
);
|
|
237
|
+
await writeFile(
|
|
238
|
+
path.join(packageRoot, "package.json"),
|
|
239
|
+
`${JSON.stringify({ name: "assertledger", type: "module", exports: { "./bun": "./bun.mjs" } }, null, 2)}\n`,
|
|
240
|
+
{ flag: "wx" },
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function runBun(executable, files, junitFile, root, evidenceKey) {
|
|
245
|
+
const exactPath = (file) => `./${file.replaceAll(path.sep, "/")}`;
|
|
246
|
+
const childEnvironment = { ...process.env };
|
|
247
|
+
delete childEnvironment.TESTFORGE_RESULT_FILE;
|
|
248
|
+
delete childEnvironment.TESTFORGE_CANDIDATE_FILES;
|
|
249
|
+
delete childEnvironment.ASSERTLEDGER_BUN_EVENTS_FILE;
|
|
250
|
+
childEnvironment.ASSERTLEDGER_BUN_ROOT = root;
|
|
251
|
+
const child = spawn(
|
|
252
|
+
executable,
|
|
253
|
+
[
|
|
254
|
+
"test",
|
|
255
|
+
"--max-concurrency=1",
|
|
256
|
+
"--retry=0",
|
|
257
|
+
"--preload",
|
|
258
|
+
"./node_modules/assertledger/preload.mjs",
|
|
259
|
+
"--reporter=junit",
|
|
260
|
+
`--reporter-outfile=${junitFile}`,
|
|
261
|
+
...files.map(exactPath),
|
|
262
|
+
],
|
|
263
|
+
{
|
|
264
|
+
cwd: root,
|
|
265
|
+
env: childEnvironment,
|
|
266
|
+
shell: false,
|
|
267
|
+
stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
let keyDeliveryError = false;
|
|
271
|
+
child.stdio[4].on("error", () => {
|
|
272
|
+
keyDeliveryError = true;
|
|
273
|
+
});
|
|
274
|
+
child.stdio[4].end(evidenceKey);
|
|
275
|
+
let outputBytes = 0;
|
|
276
|
+
let overflow = false;
|
|
277
|
+
let stderr = "";
|
|
278
|
+
let evidenceBytes = 0;
|
|
279
|
+
const evidenceChunks = [];
|
|
280
|
+
const collect = (chunk) => {
|
|
281
|
+
outputBytes += chunk.length;
|
|
282
|
+
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
283
|
+
overflow = true;
|
|
284
|
+
child.kill();
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
child.stdout.on("data", collect);
|
|
288
|
+
child.stderr.on("data", (chunk) => {
|
|
289
|
+
collect(chunk);
|
|
290
|
+
if (!overflow) stderr += chunk.toString("utf8");
|
|
291
|
+
});
|
|
292
|
+
child.stdio[3].on("data", (chunk) => {
|
|
293
|
+
evidenceBytes += chunk.length;
|
|
294
|
+
if (evidenceBytes > MAX_REPORT_BYTES) {
|
|
295
|
+
overflow = true;
|
|
296
|
+
child.kill();
|
|
297
|
+
} else {
|
|
298
|
+
evidenceChunks.push(chunk);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
const exitCode = await new Promise((resolve) => {
|
|
302
|
+
child.once("error", () => resolve(null));
|
|
303
|
+
child.once("close", (code) => resolve(code));
|
|
304
|
+
});
|
|
305
|
+
const plainStderr = stderr.replace(ANSI_SGR, "");
|
|
306
|
+
const operationalError =
|
|
307
|
+
plainStderr.includes("Unhandled error between tests") ||
|
|
308
|
+
/(?:^|\r?\n)\s*[1-9][0-9]*\s+errors?\s*(?:\r?\n|$)/u.test(plainStderr);
|
|
309
|
+
return {
|
|
310
|
+
exitCode: overflow || keyDeliveryError ? null : exitCode,
|
|
311
|
+
operationalError,
|
|
312
|
+
eventContent: overflow ? undefined : Buffer.concat(evidenceChunks).toString("utf8"),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function main() {
|
|
317
|
+
const root = await realpath(process.cwd());
|
|
318
|
+
const [executable, helperSourcePath, ...baseTests] = process.argv.slice(2);
|
|
319
|
+
if (
|
|
320
|
+
!path.isAbsolute(executable ?? "") ||
|
|
321
|
+
!path.isAbsolute(helperSourcePath ?? "") ||
|
|
322
|
+
baseTests.length === 0
|
|
323
|
+
) {
|
|
324
|
+
throw new Error("INVALID_ARGUMENTS");
|
|
325
|
+
}
|
|
326
|
+
const revision = spawnSync(executable, ["--revision"], {
|
|
327
|
+
cwd: process.cwd(),
|
|
328
|
+
encoding: "utf8",
|
|
329
|
+
shell: false,
|
|
330
|
+
windowsHide: true,
|
|
331
|
+
timeout: 5_000,
|
|
332
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
333
|
+
});
|
|
334
|
+
if (revision.status !== 0 || revision.stderr !== "" || revision.stdout.trim() !== BUN_REVISION) {
|
|
335
|
+
throw new Error("UNSUPPORTED_BUN_REVISION");
|
|
336
|
+
}
|
|
337
|
+
const candidates = JSON.parse(process.env.TESTFORGE_CANDIDATE_FILES ?? "[]");
|
|
338
|
+
if (!Array.isArray(candidates)) throw new Error("INVALID_CANDIDATES");
|
|
339
|
+
const normalizedBase = baseTests.map(normalizeRelativeFile);
|
|
340
|
+
const normalizedCandidates = candidates.map(normalizeRelativeFile);
|
|
341
|
+
if ([...normalizedBase, ...normalizedCandidates].some((file) => file === undefined)) {
|
|
342
|
+
throw new Error("INVALID_PATH");
|
|
343
|
+
}
|
|
344
|
+
const files = [...normalizedBase, ...normalizedCandidates];
|
|
345
|
+
if (new Set(files).size !== files.length) throw new Error("DUPLICATE_TEST_FILE");
|
|
346
|
+
await installHelper(await readFile(helperSourcePath, "utf8"), root);
|
|
347
|
+
const junitFile = path.join(root, `__assertledger_bun_junit_${randomUUID()}.xml`);
|
|
348
|
+
const evidenceKey = randomBytes(32);
|
|
349
|
+
const execution = await runBun(
|
|
350
|
+
executable,
|
|
351
|
+
[...baseTests, ...candidates],
|
|
352
|
+
junitFile,
|
|
353
|
+
root,
|
|
354
|
+
evidenceKey,
|
|
355
|
+
);
|
|
356
|
+
let outcome;
|
|
357
|
+
try {
|
|
358
|
+
const junitContent = await readBoundedRegularFile(junitFile, root);
|
|
359
|
+
const allowed = new Set(files);
|
|
360
|
+
outcome = classifyBunInstrumentedEvidence(
|
|
361
|
+
execution.eventContent === undefined
|
|
362
|
+
? undefined
|
|
363
|
+
: parseEvents(execution.eventContent, root, allowed, evidenceKey),
|
|
364
|
+
parseJunitSummary(junitContent),
|
|
365
|
+
new Set(normalizedBase),
|
|
366
|
+
new Set(normalizedCandidates),
|
|
367
|
+
execution.exitCode,
|
|
368
|
+
execution.operationalError,
|
|
369
|
+
);
|
|
370
|
+
} catch {
|
|
371
|
+
outcome = infrastructureFailure("MISSING_OR_INVALID_REPORT");
|
|
372
|
+
}
|
|
373
|
+
process.stdout.write(`${JSON.stringify(outcome)}\n`);
|
|
374
|
+
process.exitCode = outcome.outcome === "PASS" ? 0 : 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
378
|
+
main().catch((error) => {
|
|
379
|
+
console.error(error instanceof Error ? error.message : "UNKNOWN_DRIVER_ERROR");
|
|
380
|
+
process.stdout.write(`${JSON.stringify(report("INFRA_ERROR"))}\n`);
|
|
381
|
+
process.exitCode = 1;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, readSync, writeSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import * as assertionHelper from "./bun.mjs";
|
|
7
|
+
import * as bunTest from "bun:test";
|
|
8
|
+
|
|
9
|
+
const root = process.env.ASSERTLEDGER_BUN_ROOT;
|
|
10
|
+
if (!path.isAbsolute(root ?? "")) {
|
|
11
|
+
throw new Error("ASSERTLEDGER_BUN_PRELOAD_CONFIGURATION_INVALID");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const preloadPath = fileURLToPath(import.meta.url);
|
|
15
|
+
const safeApply = Reflect.apply.bind(Reflect);
|
|
16
|
+
const safeGet = Reflect.get.bind(Reflect);
|
|
17
|
+
const rawAssertSame = assertionHelper.assertSame;
|
|
18
|
+
const verifyIssuedError = assertionHelper.isAssertSameFailure;
|
|
19
|
+
const issuedDuringTest = new WeakMap();
|
|
20
|
+
const markIssuingTest = WeakMap.prototype.set.bind(issuedDuringTest);
|
|
21
|
+
const issuingTest = WeakMap.prototype.get.bind(issuedDuringTest);
|
|
22
|
+
const activeTestExecution = new AsyncLocalStorage();
|
|
23
|
+
const currentTestId = AsyncLocalStorage.prototype.getStore.bind(activeTestExecution);
|
|
24
|
+
const runInTest = AsyncLocalStorage.prototype.run.bind(activeTestExecution);
|
|
25
|
+
function scopedAssertSame(...arguments_) {
|
|
26
|
+
try {
|
|
27
|
+
return safeApply(rawAssertSame, undefined, arguments_);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const id = currentTestId();
|
|
30
|
+
if (id !== undefined && verifyIssuedError(error)) markIssuingTest(error, id);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const stringify = JSON.stringify.bind(JSON);
|
|
35
|
+
const evidenceKey = Buffer.alloc(32);
|
|
36
|
+
let received = 0;
|
|
37
|
+
while (received < evidenceKey.length) {
|
|
38
|
+
const length = readSync(4, evidenceKey, received, evidenceKey.length - received, null);
|
|
39
|
+
if (length === 0) throw new Error("ASSERTLEDGER_BUN_EVIDENCE_KEY_MISSING");
|
|
40
|
+
received += length;
|
|
41
|
+
}
|
|
42
|
+
closeSync(4);
|
|
43
|
+
function record(event) {
|
|
44
|
+
const body = stringify(event);
|
|
45
|
+
const mac = createHmac("sha256", evidenceKey).update(body).digest("hex");
|
|
46
|
+
writeSync(3, `${stringify({ event, mac })}\n`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function registrationFile() {
|
|
50
|
+
const lines = new Error().stack?.split(/\r?\n/u).slice(1) ?? [];
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
let location = line.trim().replace(/^at\s+/u, "");
|
|
53
|
+
if (location.endsWith(")") && location.includes("(")) {
|
|
54
|
+
location = location.slice(location.lastIndexOf("(") + 1, -1);
|
|
55
|
+
}
|
|
56
|
+
const match = location.match(/^(.+?):\d+(?::\d+)?$/u);
|
|
57
|
+
if (match === null) continue;
|
|
58
|
+
let file = match[1];
|
|
59
|
+
if (file.startsWith("file:")) {
|
|
60
|
+
try {
|
|
61
|
+
file = fileURLToPath(file);
|
|
62
|
+
} catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!path.isAbsolute(file)) continue;
|
|
67
|
+
const absolute = path.resolve(file);
|
|
68
|
+
if (absolute === preloadPath) continue;
|
|
69
|
+
return absolute;
|
|
70
|
+
}
|
|
71
|
+
throw new Error("ASSERTLEDGER_BUN_REGISTRATION_FILE_UNAVAILABLE");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function wrapRegistration(native, cache) {
|
|
75
|
+
if (cache.has(native)) return cache.get(native);
|
|
76
|
+
const wrapper = new Proxy(native, {
|
|
77
|
+
apply(target, thisArg, arguments_) {
|
|
78
|
+
const callbackIndex = arguments_.findIndex(
|
|
79
|
+
(value, index) => typeof value === "function" && (index > 0 || arguments_.length === 1),
|
|
80
|
+
);
|
|
81
|
+
if (callbackIndex < 0) {
|
|
82
|
+
const result = safeApply(target, thisArg, arguments_);
|
|
83
|
+
return typeof result === "function" ? wrapRegistration(result, cache) : result;
|
|
84
|
+
}
|
|
85
|
+
const callback = arguments_[callbackIndex];
|
|
86
|
+
const file = registrationFile();
|
|
87
|
+
const wrappedArguments = [...arguments_];
|
|
88
|
+
wrappedArguments[callbackIndex] = function (...callbackArguments) {
|
|
89
|
+
const id = randomUUID();
|
|
90
|
+
record({ kind: "found", id, file });
|
|
91
|
+
return runInTest(id, () => {
|
|
92
|
+
const passed = (value) => {
|
|
93
|
+
record({ kind: "end", id, status: "pass" });
|
|
94
|
+
return value;
|
|
95
|
+
};
|
|
96
|
+
const failed = (error) => {
|
|
97
|
+
record({
|
|
98
|
+
kind: "end",
|
|
99
|
+
id,
|
|
100
|
+
status: "fail",
|
|
101
|
+
owned: verifyIssuedError(error) && issuingTest(error) === id,
|
|
102
|
+
});
|
|
103
|
+
throw error;
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
const result = safeApply(callback, this, callbackArguments);
|
|
107
|
+
return result && typeof result.then === "function"
|
|
108
|
+
? Promise.resolve(result).then(passed, failed)
|
|
109
|
+
: passed(result);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return failed(error);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
return safeApply(target, thisArg, wrappedArguments);
|
|
116
|
+
},
|
|
117
|
+
get(target, property, receiver) {
|
|
118
|
+
const value = safeGet(target, property, receiver);
|
|
119
|
+
return typeof value === "function" && property !== "constructor"
|
|
120
|
+
? wrapRegistration(value.bind(target), cache)
|
|
121
|
+
: value;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
cache.set(native, wrapper);
|
|
125
|
+
return wrapper;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function wrapHook(nativeHook) {
|
|
129
|
+
return (callback, ...options) => {
|
|
130
|
+
if (typeof callback !== "function")
|
|
131
|
+
return safeApply(nativeHook, bunTest, [callback, ...options]);
|
|
132
|
+
const wrappedCallback = function (...arguments_) {
|
|
133
|
+
const failed = (error) => {
|
|
134
|
+
record({ kind: "hook-error" });
|
|
135
|
+
throw error;
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
const result = safeApply(callback, this, arguments_);
|
|
139
|
+
return result && typeof result.then === "function"
|
|
140
|
+
? Promise.resolve(result).catch(failed)
|
|
141
|
+
: result;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return failed(error);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
return safeApply(nativeHook, bunTest, [wrappedCallback, ...options]);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const cache = new WeakMap();
|
|
151
|
+
const wrappedTest = wrapRegistration(bunTest.test, cache);
|
|
152
|
+
const wrappedIt = wrapRegistration(bunTest.it, cache);
|
|
153
|
+
bunTest.mock.module("bun:test", () => ({
|
|
154
|
+
...bunTest,
|
|
155
|
+
test: wrappedTest,
|
|
156
|
+
it: wrappedIt,
|
|
157
|
+
beforeAll: wrapHook(bunTest.beforeAll),
|
|
158
|
+
afterAll: wrapHook(bunTest.afterAll),
|
|
159
|
+
beforeEach: wrapHook(bunTest.beforeEach),
|
|
160
|
+
afterEach: wrapHook(bunTest.afterEach),
|
|
161
|
+
}));
|
|
162
|
+
bunTest.mock.module("assertledger/bun", () => ({
|
|
163
|
+
...assertionHelper,
|
|
164
|
+
assertSame: scopedAssertSame,
|
|
165
|
+
}));
|
|
@@ -16,7 +16,10 @@ window.
|
|
|
16
16
|
## Preferred path: a committed regression
|
|
17
17
|
|
|
18
18
|
1. Run `assertledger doctor <repo> --json` for static readiness. Runtime probes require the separate
|
|
19
|
-
`doctor --runtime --allow-unsafe-execution` command and prior operator authorization.
|
|
19
|
+
`doctor --runtime --allow-unsafe-execution` command and prior operator authorization. A
|
|
20
|
+
`UNSUPPORTED_REPOSITORY_SYMLINK` conflict stays a refusal: only the operator may declare the
|
|
21
|
+
local-only entry that holds the link with `--exclude NAME`. Never add names to
|
|
22
|
+
`repository.exclude` yourself; the configured list never removes files from campaign evidence.
|
|
20
23
|
2. Ask the operator or harness for the buggy, corrected and neutral revisions, the neutral reason,
|
|
21
24
|
candidate path and unchanged base tests. Do not invent a neutral control's meaning.
|
|
22
25
|
3. For committed dependency-free JavaScript `node:test`, use `assertledger check` or the MCP
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "assertledger",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Verify that regression tests detect a declared bug, with deterministic verdicts and replayable evidence.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"./core": {
|
|
32
32
|
"types": "./dist/core/index.d.ts",
|
|
33
33
|
"import": "./dist/core/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./bun": {
|
|
36
|
+
"types": "./integrations/bun/assertions.d.mts",
|
|
37
|
+
"import": "./integrations/bun/assertions.mjs"
|
|
34
38
|
}
|
|
35
39
|
},
|
|
36
40
|
"files": [
|
|
@@ -73,10 +77,10 @@
|
|
|
73
77
|
"zod": "4.4.3"
|
|
74
78
|
},
|
|
75
79
|
"devDependencies": {
|
|
76
|
-
"@biomejs/biome": "2.5.
|
|
80
|
+
"@biomejs/biome": "2.5.14",
|
|
77
81
|
"@modelcontextprotocol/client": "2.0.0",
|
|
78
|
-
"@types/node": "22.20.
|
|
79
|
-
"tsx": "4.23.
|
|
82
|
+
"@types/node": "22.20.4",
|
|
83
|
+
"tsx": "4.23.13"
|
|
80
84
|
},
|
|
81
85
|
"directories": {
|
|
82
86
|
"doc": "docs",
|