@kylecheng3146/agent-ops 0.5.3 → 0.6.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 (93) hide show
  1. package/README.md +48 -0
  2. package/dist/packages/cli/src/args.js +165 -2
  3. package/dist/packages/cli/src/bin.js +64 -17
  4. package/dist/packages/cli/src/cli.js +33 -6
  5. package/dist/packages/cli/src/commands/advance.js +23 -3
  6. package/dist/packages/cli/src/commands/review-show.js +1 -1
  7. package/dist/packages/cli/src/commands/review.js +100 -9
  8. package/dist/packages/cli/src/commands/run.js +111 -0
  9. package/dist/packages/cli/src/commands/task.js +61 -17
  10. package/dist/packages/cli/src/commands/verify.js +1 -0
  11. package/dist/packages/cli/src/context.js +63 -1
  12. package/dist/packages/cli/src/hook-process.js +31 -1
  13. package/dist/packages/cli/src/owned-run-step.js +65 -0
  14. package/dist/packages/cli/src/parallel-deps.js +18 -5
  15. package/dist/packages/cli/src/run-deps.js +314 -0
  16. package/dist/packages/cli/src/run-entry.js +943 -0
  17. package/dist/packages/cli/src/run-setup-entry.js +32 -0
  18. package/dist/packages/cli/src/run-step-entry.js +58 -0
  19. package/dist/runtime/src/config/explain.js +6 -0
  20. package/dist/runtime/src/config/hash.js +1 -1
  21. package/dist/runtime/src/config/merge.js +9 -1
  22. package/dist/runtime/src/contracts.js +2 -2
  23. package/dist/runtime/src/hooks/completion-gate.js +8 -2
  24. package/dist/runtime/src/install/doctor.js +2 -0
  25. package/dist/runtime/src/parallel/finish.js +85 -22
  26. package/dist/runtime/src/parallel/guard.js +5 -1
  27. package/dist/runtime/src/parallel/integrate.js +288 -10
  28. package/dist/runtime/src/parallel/manage.js +4 -1
  29. package/dist/runtime/src/parallel/receipt.js +71 -5
  30. package/dist/runtime/src/parallel/service.js +41 -1
  31. package/dist/runtime/src/review/attestation.js +36 -11
  32. package/dist/runtime/src/review/execute.js +24 -1
  33. package/dist/runtime/src/review/packet.js +35 -1
  34. package/dist/runtime/src/review/runner.js +12 -1
  35. package/dist/runtime/src/review/scope.js +18 -1
  36. package/dist/runtime/src/review/slots.js +35 -10
  37. package/dist/runtime/src/run/authorization.js +60 -0
  38. package/dist/runtime/src/run/budget.js +30 -0
  39. package/dist/runtime/src/run/controls.js +139 -0
  40. package/dist/runtime/src/run/convergence.js +177 -0
  41. package/dist/runtime/src/run/hosts/claude.js +180 -0
  42. package/dist/runtime/src/run/hosts/codex.js +279 -0
  43. package/dist/runtime/src/run/hosts/index.js +4 -0
  44. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  45. package/dist/runtime/src/run/hosts/types.js +1 -0
  46. package/dist/runtime/src/run/hosts/util.js +136 -0
  47. package/dist/runtime/src/run/integration.js +204 -0
  48. package/dist/runtime/src/run/macOS.js +189 -0
  49. package/dist/runtime/src/run/ownership.js +22 -0
  50. package/dist/runtime/src/run/planning.js +25 -0
  51. package/dist/runtime/src/run/policy-transition.js +1076 -0
  52. package/dist/runtime/src/run/policy.js +176 -0
  53. package/dist/runtime/src/run/ratchet.js +71 -0
  54. package/dist/runtime/src/run/scheduler.js +260 -0
  55. package/dist/runtime/src/run/service.js +425 -0
  56. package/dist/runtime/src/run/supervisor.js +635 -0
  57. package/dist/runtime/src/run/transport.js +159 -0
  58. package/dist/runtime/src/run/types.js +1 -0
  59. package/dist/runtime/src/run/usage.js +81 -0
  60. package/dist/runtime/src/run/verification.js +64 -0
  61. package/dist/runtime/src/run/worker-gate.js +48 -0
  62. package/dist/runtime/src/schema/acceptance.js +91 -0
  63. package/dist/runtime/src/schema/validate.js +67 -7
  64. package/dist/runtime/src/task/completion.js +14 -2
  65. package/dist/runtime/src/task/contract.js +17 -0
  66. package/dist/runtime/src/task/pin-finding.js +81 -0
  67. package/dist/runtime/src/task/service.js +316 -13
  68. package/dist/runtime/src/task/store.js +57 -7
  69. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  70. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  71. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  72. package/dist/runtime/src/verify/adapters/index.js +5 -0
  73. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  74. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  75. package/dist/runtime/src/verify/adapters/node.js +254 -0
  76. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  77. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  78. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  79. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  80. package/dist/runtime/src/verify/evidence.js +5 -2
  81. package/dist/runtime/src/verify/service.js +24 -8
  82. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  83. package/dist/runtime/src/verify/spawn.js +13 -2
  84. package/docs/en/spec/acceptance-and-evidence.md +48 -0
  85. package/docs/en/spec/loop-engineering.md +49 -0
  86. package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
  87. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  88. package/package.json +1 -1
  89. package/schemas/config.schema.json +221 -19
  90. package/schemas/evidence.schema.json +129 -6
  91. package/schemas/task.schema.json +265 -6
  92. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  93. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -0,0 +1,239 @@
1
+ import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile, chmod } from "node:fs/promises";
2
+ import { join, delimiter, isAbsolute } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { writePrivateFile } from "../security/permissions.js";
5
+ import { redactSecrets } from "../security/redact.js";
6
+ import { randomUUID } from "node:crypto";
7
+ import { canonicalJson } from "../config/hash.js";
8
+ import { sha256 } from "../fs/hash.js";
9
+ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
10
+ import { criterionContractHash, taskContractHash } from "../task/contract.js";
11
+ import { aggregateAcceptanceRun, makeAcceptanceCheck, makeAcceptanceRun } from "./acceptance-protocol.js";
12
+ import { collectNodeAcceptance, collectJestAcceptance, collectVitestAcceptance, collectPytestAcceptance, collectRustAcceptance } from "./adapters/index.js";
13
+ import { buildVerificationEvidence } from "./evidence.js";
14
+ import { collectChangeSurface, resolveGitCommit } from "./change-surface.js";
15
+ import { runVerificationCommand } from "./spawn.js";
16
+ async function executableIdentity(command, cwd, env) {
17
+ const paths = isAbsolute(command) ? [command] : command.includes("/") ? [join(cwd, command)] :
18
+ (env.PATH ?? "").split(delimiter).filter(Boolean).map(path => join(path, command));
19
+ for (const path of paths) {
20
+ try {
21
+ const resolved = await realpath(path);
22
+ const entry = await lstat(resolved);
23
+ if (!entry.isFile() || (process.platform !== "win32" && (entry.mode & 0o111) === 0))
24
+ continue;
25
+ return sha256(canonicalJson({ path: resolved, bytes: sha256(await readFile(resolved)) }));
26
+ }
27
+ catch { /* Try the next fixed PATH entry. */ }
28
+ }
29
+ return "unavailable";
30
+ }
31
+ function replayEnvironment(checkout, phase, executionId) {
32
+ const env = Object.fromEntries(Object.entries(process.env).filter((pair) => pair[1] !== undefined && !/^(?:NODE_OPTIONS|NODE_PATH|PYTHONPATH|PYTHONHOME|CARGO_TARGET_DIR|RUSTC_WRAPPER|RUSTC_WORKSPACE_WRAPPER|AGENT_OPS_|CLAUDE_|CODEX_)/u.test(pair[0])));
33
+ env.PATH = [join(checkout, "node_modules/.bin"), join(checkout, ".venv/bin"),
34
+ ...(process.env.PATH ?? "").split(delimiter).filter(path => !path.includes("node_modules/.bin"))].join(delimiter);
35
+ env.AGENT_OPS_ACCEPTANCE_PHASE = phase;
36
+ env.AGENT_OPS_ACCEPTANCE_EXECUTION_ID = executionId;
37
+ return env;
38
+ }
39
+ function collected(runner, output, checkout, options) {
40
+ if (output.timedOut || output.signal !== null || output.stdoutTruncated || output.stderrTruncated || output.status === "UNKNOWN")
41
+ return makeAcceptanceRun({ ...options, framework: runner.adapter, results: [], completed: false, diagnostics: [output.failureClass] });
42
+ // Framework file identities must be repository-relative across disposable checkouts.
43
+ const normalize = (value) => typeof value === "string" && value.startsWith(checkout + "/") ? value.slice(checkout.length + 1) :
44
+ Array.isArray(value) ? value.map(normalize) : typeof value === "object" && value !== null ?
45
+ Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalize(item)])) : value;
46
+ let structured = output.stdout;
47
+ try {
48
+ structured = JSON.stringify(normalize(JSON.parse(structured)));
49
+ }
50
+ catch {
51
+ structured = structured.split("\n").map(line => { try {
52
+ return JSON.stringify(normalize(JSON.parse(line)));
53
+ }
54
+ catch {
55
+ return line;
56
+ } }).join("\n");
57
+ }
58
+ switch (runner.adapter) {
59
+ case "node": return collectNodeAcceptance(structured, options);
60
+ case "jest": return collectJestAcceptance(structured, options);
61
+ case "vitest": return collectVitestAcceptance(structured, options);
62
+ case "pytest": return collectPytestAcceptance(structured, options);
63
+ case "rust": return collectRustAcceptance(structured, options);
64
+ case "generic": {
65
+ const value = JSON.parse(structured);
66
+ if (value.protocolVersion !== 1 || value.executionId !== options.executionId || value.phase !== options.phase)
67
+ throw new TypeError("Generic output does not match this execution.");
68
+ return makeAcceptanceRun({ ...value, results: value.results.map(makeAcceptanceCheck) });
69
+ }
70
+ }
71
+ }
72
+ /** Clean disposable clones are verification checkouts, never managed writer worktrees. */
73
+ export async function replayAcceptance(options) {
74
+ if (!options.trusted)
75
+ throw new AgentOpsError("VERIFICATION_UNTRUSTED", "Acceptance runners require the repository's current trust binding.");
76
+ if ((await collectChangeSurface(options.gitRunner)).paths.length > 0)
77
+ throw new AgentOpsError("ACCEPTANCE_COMMITTED_SOURCE_REQUIRED", "Formal acceptance replay requires a clean committed candidate.");
78
+ const candidate = await resolveGitCommit(options.gitRunner, "HEAD");
79
+ const groups = new Map();
80
+ for (const criterion of options.task.criteria) {
81
+ if (criterion.acceptance === undefined || criterion.acceptance.mode === "review-only")
82
+ continue;
83
+ for (const binding of criterion.acceptance.bindings) {
84
+ const runner = options.config.verification.acceptanceRunners?.find(r => r.id === binding.runnerId);
85
+ if (runner === undefined)
86
+ throw new AgentOpsError("ACCEPTANCE_RUNNER_MISSING", "The acceptance runner is not configured.");
87
+ const key = canonicalJson({ baseline: criterion.acceptance.baselineCommit, runner, materials: binding.materials });
88
+ const group = groups.get(key);
89
+ if (group === undefined)
90
+ groups.set(key, { baseline: criterion.acceptance.baselineCommit, runner, binding, criteria: [criterion] });
91
+ else if (!group.criteria.some(c => c.id === criterion.id))
92
+ group.criteria.push(criterion);
93
+ }
94
+ }
95
+ const reports = [];
96
+ for (const group of groups.values()) {
97
+ const materials = [];
98
+ for (const material of group.binding.materials) {
99
+ const metadata = await options.gitRunner.run(["ls-tree", "-z", candidate, "--", material.path]);
100
+ const entry = Buffer.from(metadata.stdout).toString("utf8").match(/^(100644|100755) blob ([a-f0-9]+)\t([^\0]+)\0$/u);
101
+ if (metadata.exitCode !== 0 || entry === null || entry[3] !== material.path)
102
+ throw new AgentOpsError("ACCEPTANCE_UNSAFE_MATERIAL", "Acceptance materials must be committed regular files.");
103
+ const blob = await options.gitRunner.run(["cat-file", "blob", entry[2]]);
104
+ if (blob.exitCode !== 0 || blob.stdout.byteLength > 1024 * 1024)
105
+ throw new AgentOpsError("ACCEPTANCE_MATERIAL_UNAVAILABLE", "Acceptance material exceeds its bound or is unavailable.");
106
+ materials.push({ ...material, bytes: blob.stdout, mode: entry[1] === "100755" ? 0o755 : 0o644 });
107
+ }
108
+ const materialDigest = sha256(canonicalJson(materials.map(m => ({ path: m.path, role: m.role, mode: m.mode, hash: sha256(m.bytes) }))));
109
+ for (const phase of ["baseline", "candidate"]) {
110
+ const commit = phase === "baseline" ? group.baseline : candidate;
111
+ const executionId = randomUUID();
112
+ const startedAt = new Date().toISOString();
113
+ const temp = await mkdtemp(join(tmpdir(), "agent-ops-acceptance-"));
114
+ const checkout = join(temp, "repository");
115
+ const env = replayEnvironment(checkout, phase, executionId);
116
+ const command = async (executable, args, cwd, timeoutMs = 60_000) => await runVerificationCommand({ id: group.runner.id, command: executable, args: [...args], cwd: ".", required: true,
117
+ evidence: { kind: "exit-code" }, timeoutMs }, { cwd, runner: options.processRunner, env, replaceEnv: true,
118
+ outputLimitBytes: 1024 * 1024, ...(options.signal === undefined ? {} : { signal: options.signal }) });
119
+ let aggregate;
120
+ let failure = "replay-unavailable";
121
+ let output;
122
+ let run;
123
+ const steps = [];
124
+ let executionDigest = sha256(executionId);
125
+ let argv = [];
126
+ const requested = [...new Set(group.criteria.flatMap(c => c.acceptance.bindings
127
+ .filter(b => b.runnerId === group.runner.id && canonicalJson(b.materials) === canonicalJson(group.binding.materials)).flatMap(b => b.checkIds)))];
128
+ try {
129
+ for (const [cwd, args] of [[temp, ["clone", "--no-hardlinks", "--quiet", "--", options.root, checkout]],
130
+ [checkout, ["fetch", "--quiet", "--", options.root, commit]], [checkout, ["checkout", "--detach", "--quiet", commit]]]) {
131
+ const result = await command("git", args, cwd);
132
+ if (result.status !== "PASS")
133
+ throw new Error("checkout-unavailable");
134
+ }
135
+ const cwd = group.runner.cwd === "." ? checkout : await resolveContainedPath(checkout, group.runner.cwd);
136
+ for (const step of [...(group.runner.setup ?? []), ...(group.runner.build ?? [])]) {
137
+ const result = await command(step.command, step.args, cwd, step.timeoutMs);
138
+ steps.push({ command: step.command, args: step.args, status: result.status, failureClass: result.failureClass, stderr: result.stderr });
139
+ if (result.status !== "PASS")
140
+ throw new Error("setup-or-build-unavailable");
141
+ }
142
+ // Both sides receive exactly the candidate's explicit test material, after their own build.
143
+ for (const material of materials) {
144
+ let parent = checkout;
145
+ for (const segment of material.path.split("/").slice(0, -1)) {
146
+ parent = join(parent, segment);
147
+ try {
148
+ if (!(await lstat(parent)).isDirectory())
149
+ throw new Error("unsafe-material-parent");
150
+ }
151
+ catch (error) {
152
+ if (error.code !== "ENOENT")
153
+ throw error;
154
+ await mkdir(parent);
155
+ }
156
+ }
157
+ const path = join(checkout, ...material.path.split("/"));
158
+ try {
159
+ if (!(await lstat(path)).isFile())
160
+ throw new Error("unsafe-material-target");
161
+ }
162
+ catch (error) {
163
+ if (error.code !== "ENOENT")
164
+ throw error;
165
+ }
166
+ await writeFile(path, material.bytes);
167
+ await chmod(path, material.mode);
168
+ }
169
+ for (const step of group.runner.testBuild ?? []) {
170
+ const result = await command(step.command, step.args, cwd, step.timeoutMs);
171
+ steps.push({ command: step.command, args: step.args, status: result.status, failureClass: result.failureClass, stderr: result.stderr });
172
+ if (result.status !== "PASS")
173
+ throw new Error("test-build-unavailable");
174
+ }
175
+ argv = group.runner.args.flatMap(arg => arg === "{materials}" ?
176
+ materials.filter(m => m.role === "test").map(m => m.path) : [arg]);
177
+ const dependencies = [];
178
+ for (const path of ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.toml", "Cargo.lock", "pyproject.toml", "requirements.txt"]) {
179
+ try {
180
+ dependencies.push({ path, hash: sha256(await readFile(join(checkout, path))) });
181
+ }
182
+ catch (error) {
183
+ if (error.code !== "ENOENT")
184
+ throw error;
185
+ }
186
+ }
187
+ const identity = await executableIdentity(group.runner.command, cwd, env);
188
+ if (identity === "unavailable")
189
+ throw new Error("missing-executable");
190
+ executionDigest = sha256(canonicalJson({ commit, materialDigest, runner: group.runner, argv, dependencies,
191
+ executable: identity, platform: process.platform, arch: process.arch, environment: sha256(canonicalJson(env)) }));
192
+ output = await command(group.runner.command, argv, cwd, group.runner.timeoutMs);
193
+ run = collected(group.runner, output, checkout, { executionId, phase,
194
+ frameworkVersion: group.runner.adapter === "node" ? process.version : "dependency-lock:" + sha256(canonicalJson(dependencies)) });
195
+ aggregate = aggregateAcceptanceRun(run, requested);
196
+ // Process errors never become assertion red, even if stdout claims a result.
197
+ if (output.exitCode === null || output.status === "UNKNOWN" ||
198
+ (aggregate.status === "PASS" && output.exitCode !== 0) || (aggregate.status === "FAIL" && output.exitCode === 0))
199
+ aggregate = undefined;
200
+ failure = aggregate?.failureClass ?? output.failureClass;
201
+ }
202
+ catch (error) {
203
+ failure = error instanceof Error && /^[a-z-]+$/u.test(error.message) ? error.message : "replay-unavailable";
204
+ }
205
+ finally {
206
+ await rm(temp, { recursive: true, force: true });
207
+ }
208
+ const finishedAt = new Date().toISOString();
209
+ const artifactContent = redactSecrets(JSON.stringify({ schemaVersion: 1, executionId, phase, commit,
210
+ pairedCommit: phase === "baseline" ? candidate : group.baseline, materialDigest, executionDigest,
211
+ runner: group.runner, argv, run: run ?? null, aggregate: aggregate ?? null, output: output ?? null, steps, failure, startedAt, finishedAt }));
212
+ const executionArtifact = ".agent-ops/tasks/acceptance/" + sha256(artifactContent) + ".json";
213
+ await writePrivateFile(join(options.root, ...executionArtifact.split("/")), artifactContent, options.root);
214
+ for (const criterion of group.criteria) {
215
+ const bindings = criterion.acceptance.bindings.filter(b => b.runnerId === group.runner.id && canonicalJson(b.materials) === canonicalJson(group.binding.materials));
216
+ const binding = { checkIds: [...new Set(bindings.flatMap(b => b.checkIds))], redCheckIds: [...new Set(bindings.flatMap(b => b.redCheckIds ?? []))] };
217
+ const checks = binding.checkIds.map(checkId => {
218
+ const check = aggregate?.results.find(result => result.checkId === checkId);
219
+ return { checkId, status: check?.status ?? "UNKNOWN",
220
+ failureClass: check?.failureClass === "assertion-failed" ? "assertion" : check?.failureClass ?? failure };
221
+ });
222
+ const red = binding.redCheckIds ?? [];
223
+ const status = aggregate === undefined || aggregate.status === "UNKNOWN" ? "UNKNOWN" :
224
+ phase === "candidate" ? checks.every(c => c.status === "PASS") ? "PASS" : "FAIL" :
225
+ red.every(id => checks.some(c => c.checkId === id && c.status === "FAIL" && c.failureClass === "assertion")) ? "PASS" : "FAIL";
226
+ const evidence = buildVerificationEvidence({ taskId: options.task.id, criterionId: criterion.id,
227
+ command: { id: group.runner.id, command: group.runner.command, args: argv, cwd: group.runner.cwd, required: true, evidence: { kind: "exit-code" } },
228
+ scope: options.scope, startedAt, finishedAt, exitCode: output?.exitCode ?? null, testCount: checks.length,
229
+ status, failureClass: status === "PASS" ? "none" : phase === "baseline" && aggregate?.status === "PASS" ? "non-discriminating" : failure,
230
+ sourceFingerprint: options.sourceFingerprint, toolVersions: { node: process.version }, config: options.config,
231
+ taskContractHash: taskContractHash(options.task), acceptance: { criterionContractHash: criterionContractHash(criterion),
232
+ bindingHash: sha256(canonicalJson({ runnerId: group.runner.id, materials: group.binding.materials })), phase, commit, pairedCommit: phase === "baseline" ? candidate : group.baseline, materialDigest, executionDigest, executionArtifact, checks } });
233
+ const reference = await options.evidenceStore.save(evidence);
234
+ reports.push({ criterionId: criterion.id, runnerId: group.runner.id, phase, status, failureClass: evidence.failureClass, reference });
235
+ }
236
+ }
237
+ }
238
+ return reports;
239
+ }
@@ -0,0 +1,5 @@
1
+ export { collectJestAcceptance } from "./jest.js";
2
+ export { collectNodeAcceptance } from "./node.js";
3
+ export { collectPytestAcceptance } from "./pytest.js";
4
+ export { collectRustAcceptance } from "./rust.js";
5
+ export { collectVitestAcceptance } from "./vitest.js";
@@ -0,0 +1,142 @@
1
+ import { parseJsonDocument, parseJsonLines, stableCheckId } from "../acceptance-protocol.js";
2
+ import { array, hasRetry, lower, number, object, result, run, string } from "./shared.js";
3
+ function reportFromInput(input) {
4
+ if (typeof input === "string") {
5
+ const document = parseJsonDocument(input);
6
+ if (object(document) !== undefined)
7
+ return { report: object(document), diagnostics: [] };
8
+ const lines = parseJsonLines(input);
9
+ if (lines.values.length === 1 && object(lines.values[0]) !== undefined) {
10
+ return { report: object(lines.values[0]), diagnostics: lines.invalidLines.map((line) => `invalid-json-line:${line}`) };
11
+ }
12
+ return { report: undefined, diagnostics: ["malformed-output", ...lines.invalidLines.map((line) => `invalid-json-line:${line}`)] };
13
+ }
14
+ return object(input) === undefined
15
+ ? { report: undefined, diagnostics: ["malformed-report"] }
16
+ : { report: object(input), diagnostics: [] };
17
+ }
18
+ function suiteResults(suite) {
19
+ return array(suite.assertionResults) ?? array(suite.assertions) ?? array(suite.tests) ?? [];
20
+ }
21
+ function suiteFile(suite) {
22
+ return string(suite.testFilePath ?? suite.filePath ?? suite.file ?? suite.name) ?? "<jest-suite>";
23
+ }
24
+ function assertionId(suite, assertion) {
25
+ const ancestors = array(assertion.ancestorTitles)
26
+ ?.filter((item) => typeof item === "string") ?? [];
27
+ // Jest's fullName already contains ancestorTitles; prefer the leaf title so
28
+ // real JSON reports and reporter events share one identity format.
29
+ const name = string(assertion.title ?? assertion.name) ??
30
+ (ancestors.length === 0 ? string(assertion.fullName) : undefined);
31
+ if (name === undefined)
32
+ return undefined;
33
+ const location = object(assertion.location);
34
+ const line = number(location?.line);
35
+ const column = number(location?.column);
36
+ try {
37
+ return stableCheckId([
38
+ suiteFile(suite),
39
+ ...ancestors,
40
+ name,
41
+ ...(line === undefined || column === undefined ? [] : [`@${line}:${column}`])
42
+ ]);
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ function infrastructure(suite, report) {
49
+ const present = (value) => value !== undefined && value !== null &&
50
+ (!Array.isArray(value) || value.length > 0);
51
+ if (present(suite.testExecError) || present(suite.runtimeError) ||
52
+ present(report.runExecError) || present(report.unhandledErrors) || present(report.errors) ||
53
+ report.wasInterrupted === true)
54
+ return true;
55
+ const runtimeSuites = number(report.numRuntimeErrorTestSuites);
56
+ return runtimeSuites !== undefined && runtimeSuites > 0;
57
+ }
58
+ function evidence(suite, id) {
59
+ const file = suiteFile(suite);
60
+ return [`jest:assertion:${file}:${id}`];
61
+ }
62
+ /** Collect a final Jest JSON report or an equivalent structured reporter document. */
63
+ export function collectJestAcceptance(input, options) {
64
+ const parsed = reportFromInput(input);
65
+ const report = parsed.report;
66
+ if (report === undefined) {
67
+ return run(options, "jest", [], false, [], parsed.diagnostics);
68
+ }
69
+ const suites = array(report.testResults) ?? array(report.testFiles) ?? array(report.files);
70
+ if (suites === undefined) {
71
+ return run(options, "jest", [], false, [], [
72
+ ...parsed.diagnostics,
73
+ "missing-test-results"
74
+ ]);
75
+ }
76
+ const results = [];
77
+ const diagnostics = [...parsed.diagnostics];
78
+ for (const rawSuite of suites) {
79
+ const suite = object(rawSuite);
80
+ if (suite === undefined) {
81
+ diagnostics.push("malformed-suite");
82
+ continue;
83
+ }
84
+ const suiteHasInfrastructure = infrastructure(suite, report);
85
+ const assertions = suiteResults(suite);
86
+ if (assertions.length === 0 && suiteHasInfrastructure) {
87
+ diagnostics.push(`suite-infrastructure:${suiteFile(suite)}`);
88
+ }
89
+ for (const rawAssertion of assertions) {
90
+ const assertion = object(rawAssertion);
91
+ if (assertion === undefined) {
92
+ diagnostics.push(`malformed-assertion:${suiteFile(suite)}`);
93
+ continue;
94
+ }
95
+ const id = assertionId(suite, assertion);
96
+ if (id === undefined) {
97
+ diagnostics.push(`missing-check-id:${suiteFile(suite)}`);
98
+ continue;
99
+ }
100
+ const status = lower(assertion.status ?? assertion.state);
101
+ const observedAttempts = number(assertion.attempts ?? assertion.invocations);
102
+ const attemptCount = observedAttempts !== undefined && observedAttempts > 0 ? observedAttempts : null;
103
+ const evidenceRefs = evidence(suite, id);
104
+ const hooks = object(assertion.hooks);
105
+ const hookFailure = (Array.isArray(assertion.hookErrors) && assertion.hookErrors.length > 0) ||
106
+ (assertion.hookErrors !== undefined && assertion.hookErrors !== null && !Array.isArray(assertion.hookErrors)) ||
107
+ (hooks !== undefined && Object.values(hooks).some(state => state !== "pass" && state !== "passed"));
108
+ if (hasRetry(assertion)) {
109
+ results.push(result(options, "jest", id, "UNKNOWN", evidenceRefs, "retry", attemptCount));
110
+ }
111
+ else if (suiteHasInfrastructure) {
112
+ results.push(result(options, "jest", id, "UNKNOWN", evidenceRefs, "infrastructure-error", attemptCount));
113
+ }
114
+ else if (hookFailure) {
115
+ results.push(result(options, "jest", id, "UNKNOWN", evidenceRefs, "hook-error", attemptCount));
116
+ }
117
+ else if (status === "passed" || status === "pass") {
118
+ results.push(result(options, "jest", id, "PASS", evidenceRefs, "none", attemptCount));
119
+ }
120
+ else if (status === "failed" || status === "fail") {
121
+ // Native Jest JSON does not distinguish a test-call assertion from an
122
+ // assertion thrown by before/after hooks. Only classified collector
123
+ // metadata can establish behavioral red; failureMessages cannot.
124
+ const assertionFailure = assertion.assertion === true || assertion.failureClass === "assertion-failed";
125
+ results.push(assertionFailure
126
+ ? result(options, "jest", id, "FAIL", evidenceRefs, "assertion-failed", attemptCount)
127
+ : result(options, "jest", id, "UNKNOWN", evidenceRefs, "infrastructure-error", attemptCount));
128
+ }
129
+ else if (status === "pending" || status === "todo" || status === "skipped" || status === "disabled") {
130
+ results.push(result(options, "jest", id, "UNKNOWN", evidenceRefs, status === "todo" ? "todo" : "skipped", attemptCount));
131
+ }
132
+ else {
133
+ results.push(result(options, "jest", id, "UNKNOWN", evidenceRefs, "malformed-output", attemptCount));
134
+ }
135
+ }
136
+ }
137
+ if ((report.success === false || (number(report.numFailedTestSuites) ?? 0) > 0) &&
138
+ !results.some(item => item.status !== "PASS"))
139
+ diagnostics.push("unexplained-failed-report");
140
+ const completed = report.completed !== false && report.interrupted !== true;
141
+ return run(options, "jest", results, completed, completed ? ["jest:report:complete"] : [], diagnostics);
142
+ }
@@ -0,0 +1,20 @@
1
+ /** JSONL reporter for node --test; preserves assertion Error metadata. */
2
+ function errorData(value, seen = new Set()) {
3
+ if (value === null || typeof value !== "object")
4
+ return value;
5
+ if (seen.has(value))
6
+ return "[circular]";
7
+ seen.add(value);
8
+ if (value instanceof Error)
9
+ return { name: value.name,
10
+ code: value.code,
11
+ failureType: value.failureType,
12
+ cause: errorData(value.cause, seen) };
13
+ if (Array.isArray(value))
14
+ return value.map(item => errorData(item, seen));
15
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, errorData(item, seen)]));
16
+ }
17
+ export default async function* nodeAcceptanceReporter(events) {
18
+ for await (const event of events)
19
+ yield JSON.stringify(errorData(event)) + "\n";
20
+ }