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.
- package/README.fr.md +34 -4
- package/README.md +33 -4
- package/conformance/schema-extensions.json +25 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.d.ts +8 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +292 -25
- 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 +5 -2
- 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/connection.d.ts +25 -1
- package/dist/engine/connection.d.ts.map +1 -1
- package/dist/engine/connection.js +144 -31
- package/dist/engine/connection.js.map +1 -1
- package/dist/engine/demo.d.ts +16 -0
- package/dist/engine/demo.d.ts.map +1 -0
- package/dist/engine/demo.js +51 -0
- package/dist/engine/demo.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 +26 -5
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +539 -58
- 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/engine/setup.d.ts +39 -0
- package/dist/engine/setup.d.ts.map +1 -0
- package/dist/engine/setup.js +411 -0
- package/dist/engine/setup.js.map +1 -0
- 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/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 +4 -1
- package/docs/developer-experience.md +39 -5
- package/docs/distribution.md +8 -3
- package/docs/migration-verification-v3.md +33 -0
- package/docs/project-intent.md +7 -5
- package/docs/reference.md +9 -5
- package/docs/repository-init.md +52 -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 +7 -3
- 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
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
|
|
314
|
-
`
|
|
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
|
|
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
|
package/docs/repository-init.md
CHANGED
|
@@ -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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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.
|
package/docs/runtime-doctor.md
CHANGED
|
@@ -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.
|
|
27
|
-
|
|
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
|
|
32
|
-
4. a stable Node.js executable at version 22.15 or newer;
|
|
33
|
-
5. availability of
|
|
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.
|
|
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,
|
|
50
|
+
import { AssertLedger, parseVersionedRuntimeDoctorResult } from "assertledger";
|
|
48
51
|
|
|
49
52
|
const result = await new AssertLedger().doctorRuntime(repositoryRoot, {
|
|
50
53
|
allowUnsafeExecution: true,
|
|
51
54
|
});
|
|
52
|
-
|
|
55
|
+
parseVersionedRuntimeDoctorResult(result);
|
|
53
56
|
```
|
|
54
57
|
|
|
55
|
-
`schemaVersion` is `1.0.0
|
|
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,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
|
+
}
|