assertledger 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.fr.md +34 -4
  2. package/README.md +33 -4
  3. package/conformance/schema-extensions.json +25 -0
  4. package/dist/build-info.json +1 -1
  5. package/dist/cli.d.ts +8 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +292 -25
  8. package/dist/cli.js.map +1 -1
  9. package/dist/contracts/index.d.ts +510 -4
  10. package/dist/contracts/index.d.ts.map +1 -1
  11. package/dist/contracts/index.js +230 -16
  12. package/dist/contracts/index.js.map +1 -1
  13. package/dist/contracts/runtime-doctor.d.ts +123 -0
  14. package/dist/contracts/runtime-doctor.d.ts.map +1 -1
  15. package/dist/contracts/runtime-doctor.js +64 -0
  16. package/dist/contracts/runtime-doctor.js.map +1 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +5 -2
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/diagnostics.d.ts.map +1 -1
  21. package/dist/diagnostics.js +26 -2
  22. package/dist/diagnostics.js.map +1 -1
  23. package/dist/engine/adapters/bun-test-profile.d.ts +17 -0
  24. package/dist/engine/adapters/bun-test-profile.d.ts.map +1 -0
  25. package/dist/engine/adapters/bun-test-profile.js +17 -0
  26. package/dist/engine/adapters/bun-test-profile.js.map +1 -0
  27. package/dist/engine/connection.d.ts +25 -1
  28. package/dist/engine/connection.d.ts.map +1 -1
  29. package/dist/engine/connection.js +144 -31
  30. package/dist/engine/connection.js.map +1 -1
  31. package/dist/engine/demo.d.ts +16 -0
  32. package/dist/engine/demo.d.ts.map +1 -0
  33. package/dist/engine/demo.js +51 -0
  34. package/dist/engine/demo.js.map +1 -0
  35. package/dist/engine/git-regression.d.ts +2 -2
  36. package/dist/engine/git-regression.d.ts.map +1 -1
  37. package/dist/engine/git-regression.js.map +1 -1
  38. package/dist/engine/index.d.ts +26 -5
  39. package/dist/engine/index.d.ts.map +1 -1
  40. package/dist/engine/index.js +539 -58
  41. package/dist/engine/index.js.map +1 -1
  42. package/dist/engine/runtime-doctor.d.ts +13 -4
  43. package/dist/engine/runtime-doctor.d.ts.map +1 -1
  44. package/dist/engine/runtime-doctor.js +80 -2
  45. package/dist/engine/runtime-doctor.js.map +1 -1
  46. package/dist/engine/setup.d.ts +39 -0
  47. package/dist/engine/setup.d.ts.map +1 -0
  48. package/dist/engine/setup.js +411 -0
  49. package/dist/engine/setup.js.map +1 -0
  50. package/dist/mcp/index.d.ts.map +1 -1
  51. package/dist/mcp/index.js +36 -6
  52. package/dist/mcp/index.js.map +1 -1
  53. package/dist/sdk/index.d.ts +7 -6
  54. package/dist/sdk/index.d.ts.map +1 -1
  55. package/dist/sdk/index.js +20 -7
  56. package/dist/sdk/index.js.map +1 -1
  57. package/docs/adapter-protocol.md +61 -0
  58. package/docs/architecture.md +4 -1
  59. package/docs/developer-experience.md +39 -5
  60. package/docs/distribution.md +8 -3
  61. package/docs/migration-verification-v3.md +33 -0
  62. package/docs/project-intent.md +7 -5
  63. package/docs/reference.md +9 -5
  64. package/docs/repository-init.md +52 -7
  65. package/docs/runtime-doctor.md +12 -9
  66. package/integrations/bun/assertions.d.mts +4 -0
  67. package/integrations/bun/assertions.mjs +25 -0
  68. package/integrations/bun/driver.d.mts +27 -0
  69. package/integrations/bun/driver.mjs +383 -0
  70. package/integrations/bun/preload.mjs +165 -0
  71. package/integrations/skill/SKILL.md +4 -1
  72. package/package.json +7 -3
  73. package/schemas/evidence-manifest.v3.json +897 -0
  74. package/schemas/repository-init-config.v2.json +210 -0
  75. package/schemas/repository-init-lock.v2.json +162 -0
  76. package/schemas/repository-init-result.v2.json +212 -0
  77. package/schemas/verification-request.v3.json +484 -0
package/docs/reference.md CHANGED
@@ -14,6 +14,9 @@ For a first run, use `assertledger doctor .` and the [Git qualification guide](g
14
14
  ```sh
15
15
  assertledger init . --dry-run --json
16
16
  assertledger init . --json
17
+ assertledger setup . --client codex --dry-run --json
18
+ assertledger setup . --client codex --write --json
19
+ assertledger demo --allow-unsafe-execution --json
17
20
  assertledger audit . --json
18
21
  assertledger analyze . --json
19
22
  assertledger schema verification-request --json
@@ -310,20 +313,21 @@ caller decide whether the capability exists. The server resolves repository root
310
313
  confines them to the server process's current working directory by default. Programmatic operators
311
314
  may supply a different `allowedRepositoryRoots` allowlist.
312
315
 
313
- The doctor pair accepts a strict `{ "root": "..." }` input and returns the existing
314
- `repository-init-result` contract. It is read-only in both the default and operator-enabled server;
316
+ The doctor pair accepts a strict `{ "root": "...", "exclude": ["..."] }` input, where the optional
317
+ `exclude` entry names follow the [repository initialization](repository-init.md) exclusion rules,
318
+ and returns the existing `repository-init-result` contract. It is read-only in both the default and operator-enabled server;
315
319
  enabling unsafe execution does not change doctor behavior. Dynamic runtime and client diagnostics
316
320
  remain outside this static readiness result.
317
321
 
318
- `doctor_runtime` accepts the same strict root input and returns the separate
319
- [runtime diagnostic contract](runtime-doctor.md). `check` accepts the
322
+ `doctor_runtime` accepts only the strict root input and returns the separate
323
+ [runtime diagnostic contract](runtime-doctor.md), v2 for a generated Bun configuration. `check` accepts the
320
324
  [high-level Git options](git-regression.md), without a permission field, and returns the existing
321
325
  evidence manifest. The operator's capability is required for both tools.
322
326
 
323
327
  ## Continuous integration
324
328
 
325
329
  Run `pnpm check` on every change. The included GitHub Actions workflow runs this gate on Node.js 22
326
- and 24 on Ubuntu, Windows and macOS. A separate matrix installs and exercises the packed artifact
330
+ and 24 with Bun 1.4.2 on Ubuntu, Windows and macOS. A separate matrix installs and exercises the packed artifact
327
331
  on Ubuntu and Windows with Node.js 22.15.0 and 24. On Ubuntu, the gate also runs the real-daemon
328
332
  [container isolation](container-isolation.md) suite. A CI job that executes campaigns must
329
333
  also treat `trusted-local` as `UNSANDBOXED`: use an isolated runner without secrets or host
@@ -11,6 +11,20 @@ assertledger init . --json
11
11
  assertledger audit . --json
12
12
  ```
13
13
 
14
+ To initialize and install a project-local read-only agent connection through one preflight, use:
15
+
16
+ ```sh
17
+ assertledger setup . --client codex --dry-run --json
18
+ assertledger setup . --client codex --write --json
19
+ ```
20
+
21
+ `setup` previews by default. It composes the existing `init` and `connect` checks and refuses the
22
+ whole operation before its first managed-file write if either side is blocked or conflicting. Use
23
+ `--client claude-code` for Claude Code. It does not authorize execution, reload a client, or prove
24
+ repository behavior. If a new connection conflict appears after init, rollback removes only regular
25
+ init files created by this setup call whose bytes still equal the plan. A regenerated or changed
26
+ file is preserved and reported under `PARTIAL_FAILURE` with exit code 5.
27
+
14
28
  The dry run emits the exact canonical bytes and SHA-256 digests that a subsequent write plans to
15
29
  use. Writes use a same-directory temporary file followed by an atomic rename. A second run returns
16
30
  `UNCHANGED` without rewriting matching files. A missing lock or a stale, structurally valid lock
@@ -22,13 +36,45 @@ plausible test frameworks, composite shell scripts, contradictory overrides, and
22
36
  adapter configurations return `CONFLICT`. Multiple CI providers are only sorted evidence and do not
23
37
  block initialization.
24
38
 
39
+ The static inventory walks the filesystem, not the Git index, and never follows or copies a
40
+ symbolic link: a link inside it returns `CONFLICT` with `UNSUPPORTED_REPOSITORY_SYMLINK` and writes
41
+ nothing. It always skips entries named `.git`, `.testforge`, and `node_modules`, at any depth.
42
+ When a local-only entry holds a link that no campaign needs, the operator can declare its name:
43
+
44
+ ```sh
45
+ assertledger doctor . --exclude .claude --exclude .omx --json
46
+ assertledger init . --exclude .claude --exclude .omx --json
47
+ ```
48
+
49
+ Each `--exclude` value is one portable entry name without separators; every file or directory with
50
+ that name is skipped at any depth. Anything else returns `CONFLICT` with
51
+ `INVALID_REPOSITORY_EXCLUDE`. The declared names are written to `repository.exclude` beside the
52
+ defaults. `init` and `doctor` without `--exclude`, `analyze` and runtime doctor's configuration check
53
+ then reuse that list from a valid `assertledger.config.json`; an unreadable or invalid file leaves
54
+ only the defaults, which widens the inventory. A configured entry that contains a separator matches
55
+ nothing, as in a verification request. An explicit list, including an empty MCP `exclude` array,
56
+ replaces the configured one, so a different declaration never silently widens or narrows the
57
+ inventory: it fails closed, for example with `CONFIG_CONFLICT`, or with
58
+ `UNSUPPORTED_REPOSITORY_SYMLINK` when a narrower list exposes a link again. Links outside the declared names stay fail-closed.
59
+
60
+ The configured list governs only these static diagnostics and the evidence digests of
61
+ `assertledger.lock.json`; it produces no campaign evidence. `audit`, a campaign's repository copy and
62
+ its manifest `repositoryDigest` keep their own exclusions: the defaults, plus the verification
63
+ request's `repository.exclude` for a campaign. `audit` therefore still refuses a linked local-only
64
+ entry, and a request must declare the same names to leave it out of its copy; a committed
65
+ configuration can never remove files from campaign evidence. Declaring a name is an operator decision
66
+ recorded in a reviewable file, not a sandbox.
67
+
25
68
  Files at or below the managed `candidateRoots` are deliberately excluded from framework inference,
26
69
  evidence, built-in control tests, and repository-change comparison. Candidate generation therefore
27
70
  cannot silently redefine initialization facts or invalidate an otherwise unchanged lock.
28
71
 
29
- The built-in ready adapter is currently `node-test`. Bun, pytest, Vitest, and Jest can be detected,
30
- but initialization returns `BLOCKED` with `OFFICIAL_ADAPTER_UNAVAILABLE` unless the operator supplies
31
- an existing structured adapter configuration:
72
+ The built-in ready adapters are `node-test` and `bun-test`. Bun initialization writes v2 config,
73
+ lock, and result contracts; Node initialization stays v1. Bun's static plan does not qualify the
74
+ installed runtime. Run runtime doctor and use a v3 verification request before claiming campaign
75
+ evidence. Pytest, Vitest, and Jest can be detected, but initialization returns `BLOCKED` with
76
+ `OFFICIAL_ADAPTER_UNAVAILABLE` unless the operator supplies an existing structured adapter
77
+ configuration:
32
78
 
33
79
  ```sh
34
80
  assertledger init . --adapter-config integrations/my-adapter.json --json
@@ -37,8 +83,8 @@ assertledger init . --adapter-config integrations/my-adapter.json --json
37
83
  The adapter document is parsed through the public adapter contract and is operator-owned. Its
38
84
  executable is recorded as argv but is not resolved or executed by `init`. This is not an official
39
85
  adapter endorsement and does not reduce the later `trusted-local` execution boundary.
40
- `node-test` adapters are accepted only for the `node:test` framework; every other framework requires
41
- an operator-owned `testforge-command` adapter.
86
+ `node-test` adapters are accepted only for `node:test`; `bun-test` adapters are accepted only for
87
+ `bun:test`. Pytest, Vitest, and Jest require an operator-owned `testforge-command` adapter.
42
88
 
43
89
  All detections and evidence digests come from one byte snapshot. Immediately before returning or
44
90
  writing managed files, initialization rechecks the in-scope inventory and every evidence byte. A
@@ -54,7 +100,6 @@ Exit codes are `0` for `CREATED`, `UNCHANGED`, or `WOULD_CREATE`; `3` for `BLOCK
54
100
  ambiguity, invalid overrides, conflicts, or contract validation; `5` for unexpected I/O; and `64`
55
101
  for CLI usage errors.
56
102
 
57
- The public `repository-init-config.v1`, `repository-init-lock.v1`, and
58
- `repository-init-result.v1` contracts contain no timestamps, absolute repository roots, environment
103
+ The public v1 and v2 initialization contracts contain no timestamps, absolute repository roots, environment
59
104
  values, worlds, or candidates. The lock binds normalized detections and sorted evidence digests; it
60
105
  does not authenticate the detector, repository, adapter, or later execution evidence.
@@ -23,20 +23,23 @@ configuration inspection or executable probes. Unknown flags, repeated flags, an
23
23
 
24
24
  ## What it checks
25
25
 
26
- Version 1.0 supports the generated official `node:test` adapter. It fails closed for other
27
- frameworks and operator-supplied adapters. In order, it checks:
26
+ Version 1.0 supports the generated official `node:test` adapter. Version 2.0 supports the
27
+ generated official `bun:test` adapter on the qualified Bun 1.4.2 revision. Both fail closed for
28
+ other frameworks and operator-supplied adapters. In order, they check:
28
29
 
29
30
  1. explicit trusted-local authorization;
30
31
  2. a current AssertLedger configuration and evidence lock;
31
- 3. the supported generated `node:test` adapter;
32
- 4. a stable Node.js executable at version 22.15 or newer;
33
- 5. availability of Node's built-in `node:test` module;
32
+ 3. the supported generated adapter;
33
+ 4. a stable Node.js executable at version 22.15 or newer, or the exact Bun 1.4.2 revision;
34
+ 5. availability of the runner's built-in test module;
34
35
  6. permission to create, write, and clean up an operating-system temporary workspace;
35
36
  7. controlled reporter discovery, liveness, and attribution probes.
36
37
 
37
38
  The final probes use disposable synthetic tests. One assertion failure must be attributed to the
38
39
  candidate; a generic throw with a nested assertion cause must remain a process crash and must not
39
- be attributed. Malformed reporter output, missing discovery, cleanup failure, timeout, or process
40
+ be attributed. The Bun probes also check its owned `assertSame` helper against a plain throw,
41
+ a caught assertion, an operand error, and a native `expect` failure. Malformed reporter output,
42
+ missing discovery, cleanup failure, timeout, or process
40
43
  failure blocks readiness. AssertLedger does not write to the repository during runtime doctor.
41
44
 
42
45
  ## Result contract
@@ -44,15 +47,15 @@ failure blocks readiness. AssertLedger does not write to the repository during r
44
47
  The SDK exposes the same strict additive contract:
45
48
 
46
49
  ```ts
47
- import { AssertLedger, parseRuntimeDoctorResult } from "assertledger";
50
+ import { AssertLedger, parseVersionedRuntimeDoctorResult } from "assertledger";
48
51
 
49
52
  const result = await new AssertLedger().doctorRuntime(repositoryRoot, {
50
53
  allowUnsafeExecution: true,
51
54
  });
52
- parseRuntimeDoctorResult(result);
55
+ parseVersionedRuntimeDoctorResult(result);
53
56
  ```
54
57
 
55
- `schemaVersion` is `1.0.0`. Each check has `PASS`, `BLOCKED`, or `LIMITATION`, a stable reason code
58
+ `schemaVersion` is `1.0.0` for Node and `2.0.0` for Bun. Each check has `PASS`, `BLOCKED`, or `LIMITATION`, a stable reason code
56
59
  when relevant, a concise summary, and a safe next action. The top-level status is `READY` only when
57
60
  every supported runtime boundary passes. JSON results contain no subprocess output, environment
58
61
  values, credentials, or repository source.
@@ -0,0 +1,4 @@
1
+ export declare function assertSame(actual: unknown, expected: unknown): void;
2
+
3
+ /** @internal Identifies an error instance issued by this helper. */
4
+ export declare function isAssertSameFailure(error: unknown): boolean;
@@ -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
+ }