@kylecheng3146/agent-ops 0.3.3 → 0.3.5

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 (31) hide show
  1. package/dist/packages/cli/src/args.js +34 -1
  2. package/dist/packages/cli/src/bin.js +48 -3
  3. package/dist/packages/cli/src/cli.js +13 -4
  4. package/dist/packages/cli/src/commands/parallel.js +3 -1
  5. package/dist/packages/cli/src/commands/review.js +11 -3
  6. package/dist/packages/cli/src/commands/verify.js +1 -1
  7. package/dist/packages/cli/src/hook-process.js +10 -1
  8. package/dist/runtime/src/contracts.js +1 -1
  9. package/dist/runtime/src/install/doctor.js +31 -1
  10. package/dist/runtime/src/parallel/finish.js +32 -10
  11. package/dist/runtime/src/parallel/manage.js +30 -1
  12. package/dist/runtime/src/parallel/service.js +111 -5
  13. package/dist/runtime/src/review/execute.js +125 -9
  14. package/dist/runtime/src/review/extract.js +40 -0
  15. package/dist/runtime/src/review/invocation.js +3 -0
  16. package/dist/runtime/src/review/usage.js +19 -0
  17. package/dist/runtime/src/schema/validate.js +1 -1
  18. package/dist/runtime/src/task/completion.js +2 -0
  19. package/dist/runtime/src/task/service.js +77 -1
  20. package/dist/runtime/src/verify/command-executor.js +6 -2
  21. package/dist/runtime/src/verify/evidence.js +2 -1
  22. package/dist/runtime/src/verify/service.js +5 -2
  23. package/dist/runtime/src/verify/test-count.js +89 -89
  24. package/docs/en/guides/configuration.md +26 -4
  25. package/docs/en/spec/acceptance-and-evidence.md +5 -1
  26. package/docs/en/spec/delegation.md +1 -1
  27. package/docs/zh-TW/guides/configuration.md +20 -4
  28. package/docs/zh-TW/spec/acceptance-and-evidence.md +6 -2
  29. package/docs/zh-TW/spec/delegation.md +1 -1
  30. package/package.json +1 -1
  31. package/schemas/evidence.schema.json +1 -1
@@ -1,17 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat } from "node:fs/promises";
2
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { runVerificationCommand } from "../verify/spawn.js";
6
6
  import { redactSecrets } from "../security/redact.js";
7
- import { extractReviewObject } from "./extract.js";
7
+ import { answeredNothing, conversationIdOf, deniedToolsOf, extractFinalMessage, extractJsonObject, extractReviewObject } from "./extract.js";
8
8
  import { buildTargetInvocation } from "./invocation.js";
9
9
  import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
10
10
  import { detectHostTarget, planReviewTargets } from "./roles.js";
11
11
  import { detectHostRestriction } from "./host-sandbox.js";
12
12
  import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
13
13
  import { MAX_ADVERSARIAL_PROMPT_BYTES } from "./runner.js";
14
- import { extractUsage } from "./usage.js";
14
+ import { extractUsage, sumUsage } from "./usage.js";
15
15
  /**
16
16
  * Full repository reviews need far more headroom than the lightweight auth
17
17
  * probe. Five minutes was not enough: reviewing a real working tree, codex and
@@ -298,6 +298,43 @@ async function snapshotRepository(request, destination, options) {
298
298
  }
299
299
  return undefined;
300
300
  }
301
+ /**
302
+ * What a lost turn is asked once, in its own conversation. The turn ended
303
+ * because a tool call was refused, and the reviewer already holds everything
304
+ * it inspected, so the cheapest recovery is to ask for the answer.
305
+ */
306
+ /** A resumed turn only has to state an answer it already holds. */
307
+ const NUDGE_TIMEOUT_MS = 300_000;
308
+ const NUDGE_PROMPT = "Your previous turn ended without an answer because a tool call was refused. " +
309
+ "Do not call any tool. Using only what you have already inspected, reply now " +
310
+ "with the JSON object the schema requires and nothing else.";
311
+ /**
312
+ * Keeps what a lost turn left behind. The attempt directory is removed on the
313
+ * way out and its log is the only record of what the turn spent its tokens on,
314
+ * so without this a lost turn cannot be diagnosed. Best effort: it returns the
315
+ * repository-relative directory, or undefined, and never changes the outcome.
316
+ */
317
+ async function keepLostTurn(repositoryRoot, sessionId, log, stdout, nudge) {
318
+ // Named in the diagnostic, so it keeps `/` on every platform; `join` below
319
+ // turns it into the native path.
320
+ const relative = `.agent-ops/reviews/lost-turns/${sessionId}`;
321
+ try {
322
+ const directory = join(repositoryRoot, relative);
323
+ await mkdir(directory, { recursive: true });
324
+ await writeFile(join(directory, "stdout.json"), stdout);
325
+ if (log !== undefined) {
326
+ await copyFile(log, join(directory, "agy.log"));
327
+ }
328
+ if (nudge !== undefined) {
329
+ await writeFile(join(directory, "nudge-stdout.json"), nudge.stdout);
330
+ await copyFile(nudge.log, join(directory, "agy-nudge.log")).catch(() => { });
331
+ }
332
+ return relative;
333
+ }
334
+ catch {
335
+ return undefined;
336
+ }
337
+ }
301
338
  /**
302
339
  * One target's attempt at one prompt, in a throwaway home directory. Returns a
303
340
  * validated report or the reason this target produced no usable verdict; the
@@ -521,10 +558,88 @@ async function attemptTarget(request, options) {
521
558
  payload !== undefined
522
559
  ? ` (fields: ${Object.keys(payload).sort().join(", ")})`
523
560
  : "";
524
- return skip(reason, errors.length > 0
525
- ? `${errors}${fields}`
526
- : firstComplaint(spawned.stdout, spawned.stderr) ??
527
- "the answer carried no review report");
561
+ const lostTurn = payload === undefined && answeredNothing(target, spawned.stdout);
562
+ const denied = lostTurn ? deniedToolsOf(target, spawned.stdout) : [];
563
+ const conversationId = lostTurn ? conversationIdOf(target, spawned.stdout) : undefined;
564
+ let nudged;
565
+ let recovered;
566
+ if (conversationId !== undefined && request.resumeLostTurn !== false &&
567
+ !budgetSpent(options)) {
568
+ const nudgeLog = join(attemptDirectory, "agy-nudge.log");
569
+ const nudge = buildTargetInvocation({
570
+ ...invocationRequest,
571
+ prompt: NUDGE_PROMPT,
572
+ conversationId,
573
+ logFile: nudgeLog,
574
+ repositoryRoot: snapshotRoot
575
+ });
576
+ if (nudge !== undefined) {
577
+ options.onProgress?.(`${target}: turn ended without answering${denied.length > 0 ? ` (denied ${denied.join(", ")})` : ""} → resuming the conversation once`);
578
+ const nudgeWatch = watchForStall(nudgeLog, options.signal, stallIdleMs);
579
+ let second;
580
+ try {
581
+ second = await runVerificationCommand({
582
+ id: `review-${request.label}-nudge`,
583
+ command: nudge.command,
584
+ args: [...nudge.args],
585
+ cwd: executionDirectory,
586
+ required: true,
587
+ evidence: { kind: "exit-code" },
588
+ timeoutMs: stageTimeout(options, NUDGE_TIMEOUT_MS)
589
+ }, {
590
+ cwd: executionDirectory,
591
+ ...(options.runner === undefined ? {} : { runner: options.runner }),
592
+ ...(options.outputLimitBytes === undefined
593
+ ? {}
594
+ : { outputLimitBytes: options.outputLimitBytes }),
595
+ stdin: nudge.stdin,
596
+ env: environment,
597
+ replaceEnv: true,
598
+ signal: nudgeWatch.signal,
599
+ onActivity: nudgeWatch.beat
600
+ });
601
+ }
602
+ finally {
603
+ nudgeWatch.stop();
604
+ }
605
+ usage = sumUsage(usage, extractUsage(target, second.stdout, second.stderr));
606
+ throwIfInterrupted(target, options, second.failureClass);
607
+ nudged = { log: nudgeLog, stdout: second.stdout };
608
+ if (!nudgeWatch.stalled() && second.failureClass === "none" &&
609
+ !second.stdoutTruncated) {
610
+ // A resumed agy turn states its answer in `response` and carries no
611
+ // `structured_output`; validateReviewReport stays the authority.
612
+ const finalMessage = extractFinalMessage(target, second.stdout);
613
+ const answer = extractReviewObject(target, second.stdout) ??
614
+ (finalMessage === undefined ? undefined : extractJsonObject(finalMessage));
615
+ const checked = answer === undefined
616
+ ? undefined
617
+ : validateReviewReport(answer, request.expectedCriterionIds, request.changedFiles);
618
+ if (checked?.ok) {
619
+ recovered = checked.value;
620
+ }
621
+ }
622
+ }
623
+ }
624
+ const kept = lostTurn
625
+ ? await keepLostTurn(request.repositoryRoot, sessionId, agyLog, spawned.stdout, nudged)
626
+ : undefined;
627
+ if (recovered !== undefined) {
628
+ options.onProgress?.(`${target}: the resumed conversation answered${kept === undefined ? "" : `; lost turn kept at ${kept}`}`);
629
+ return { kind: "verdict", report: recovered, sessionId, metrics: metrics() };
630
+ }
631
+ return {
632
+ ...skip(reason, lostTurn
633
+ ? "the reviewer ended its turn without answering (empty response, no structured output" +
634
+ (denied.length > 0 ? `; denied ${denied.join(", ")}` : "") +
635
+ ")" +
636
+ (kept === undefined ? "" : `; kept ${kept}`)
637
+ : errors.length > 0
638
+ ? `${errors}${fields}`
639
+ : firstComplaint(spawned.stdout, spawned.stderr) ??
640
+ "the answer carried no review report"),
641
+ ...(lostTurn ? { transient: true } : {})
642
+ };
528
643
  }
529
644
  return {
530
645
  kind: "verdict",
@@ -539,7 +654,8 @@ async function attemptTarget(request, options) {
539
654
  }
540
655
  /**
541
656
  * `attemptTarget`, retried once in a fresh session when the service dropped
542
- * the call. Only once: a service that is down stays down.
657
+ * the call or agy ended its turn without answering. Only once: a service that
658
+ * is down stays down.
543
659
  */
544
660
  async function attemptTargetWithRetry(request, options) {
545
661
  const first = await attemptTarget(request, options);
@@ -547,7 +663,7 @@ async function attemptTargetWithRetry(request, options) {
547
663
  return first;
548
664
  }
549
665
  options.onProgress?.(`${request.target}: ${first.diagnostic} → retrying once`);
550
- return attemptTarget(request, options);
666
+ return attemptTarget({ ...request, resumeLostTurn: false }, options);
551
667
  }
552
668
  /** Builds the `execute` callback with one necessary and one adversarial review. */
553
669
  export function createReviewExecutor(options) {
@@ -50,6 +50,46 @@ export function extractFinalMessage(target, stdout) {
50
50
  export function extractJsonObject(text) {
51
51
  return parseObject(text.trim());
52
52
  }
53
+ /**
54
+ * agy's plan mode can end its one `--print` turn without answering: a valid
55
+ * envelope with no `structured_output` and an empty `response`. That is a lost
56
+ * turn, not a verdict, so a fresh session is worth one retry. Anything the
57
+ * target did say, or any other target, is a real protocol failure instead.
58
+ */
59
+ export function answeredNothing(target, stdout) {
60
+ if (target !== "agy") {
61
+ return false;
62
+ }
63
+ const envelope = parseObject(stdout);
64
+ if (envelope === undefined || envelope.structured_output !== undefined) {
65
+ return false;
66
+ }
67
+ const response = envelope.response;
68
+ return response === undefined ||
69
+ (typeof response === "string" && response.trim().length === 0);
70
+ }
71
+ /** The conversation a lost agy turn can be resumed in; agy alone reports one. */
72
+ export function conversationIdOf(target, stdout) {
73
+ const id = target === "agy" ? parseObject(stdout)?.conversation_id : undefined;
74
+ return typeof id === "string" && id.length > 0 ? id : undefined;
75
+ }
76
+ /**
77
+ * The tools agy's print mode refused because nobody could confirm them. A turn
78
+ * that calls one ends there, which is what a lost turn is.
79
+ */
80
+ export function deniedToolsOf(target, stdout) {
81
+ const denied = target === "agy" ? parseObject(stdout)?.denied_actions : undefined;
82
+ if (!Array.isArray(denied)) {
83
+ return [];
84
+ }
85
+ return denied.flatMap((action) => {
86
+ if (!isRecord(action)) {
87
+ return [];
88
+ }
89
+ const name = action.display_name ?? action.action;
90
+ return typeof name === "string" && name.length > 0 ? [name] : [];
91
+ });
92
+ }
53
93
  /**
54
94
  * Review results use a strict native structured-output transport. Unlike the
55
95
  * legacy probe parser above, this path neither recovers fenced JSON nor accepts
@@ -149,6 +149,9 @@ export function buildTargetInvocation(request) {
149
149
  ? []
150
150
  : ["--add-dir", request.repositoryRoot]),
151
151
  ...logArgs(request.target, request.logFile),
152
+ ...(request.target === "agy" && request.conversationId !== undefined
153
+ ? ["--conversation", request.conversationId]
154
+ : []),
152
155
  ...isolationArgs(request.target),
153
156
  ...shared
154
157
  ],
@@ -69,3 +69,22 @@ export function extractUsage(target, stdout, stderr) {
69
69
  ...defined("costUsd", count(envelope?.total_cost_usd))
70
70
  });
71
71
  }
72
+ /** Two rounds of one attempt add up; a field only one round reported stays. */
73
+ export function sumUsage(first, second) {
74
+ if (first === undefined || second === undefined) {
75
+ return first ?? second;
76
+ }
77
+ const add = (key) => {
78
+ const a = first[key];
79
+ const b = second[key];
80
+ return a === undefined && b === undefined ? {} : { [key]: (a ?? 0) + (b ?? 0) };
81
+ };
82
+ return {
83
+ ...add("inputTokens"),
84
+ ...add("outputTokens"),
85
+ ...add("cacheReadTokens"),
86
+ ...add("cacheWriteTokens"),
87
+ ...add("totalTokens"),
88
+ ...add("costUsd")
89
+ };
90
+ }
@@ -593,7 +593,7 @@ export function validateEvidence(value) {
593
593
  "taskId",
594
594
  "testCount",
595
595
  "toolVersions"
596
- ], EVIDENCE_SCHEMA_VERSION);
596
+ ], isRecord(value) && value.schemaVersion === 2 ? 2 : EVIDENCE_SCHEMA_VERSION);
597
597
  if (isFailure(root)) {
598
598
  return root;
599
599
  }
@@ -25,6 +25,8 @@ export async function checkTaskCompletionEvidence(stored, options) {
25
25
  if (!validation.ok)
26
26
  continue;
27
27
  const evidence = validation.value;
28
+ if (command.evidence.kind === "test-count" && evidence.schemaVersion === 2)
29
+ continue;
28
30
  if (evidence.taskId === stored.task.id && evidence.criterionId === criterion.id &&
29
31
  evidence.commandId === command.id && evidence.configHash === configHash &&
30
32
  evidence.sourceFingerprint === sourceFingerprint &&
@@ -4,7 +4,7 @@ import { AgentOpsError } from "../fs/paths.js";
4
4
  import { validateEvidence, validateTask } from "../schema/validate.js";
5
5
  import { renderTaskMarkdown } from "./render.js";
6
6
  import { checkTaskCompletionEvidence, findIncompleteSubtask } from "./completion.js";
7
- import { calculateConfigHash, FileEvidenceStore } from "../verify/evidence.js";
7
+ import { calculateConfigHash, FileEvidenceStore, isPassingVerificationEvidence } from "../verify/evidence.js";
8
8
  import { resolveReviewScope } from "../review/scope.js";
9
9
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
10
10
  import { advanceFailureFingerprint } from "../verify/fingerprint.js";
@@ -353,6 +353,82 @@ export class TaskService {
353
353
  return cloneRecord(updated);
354
354
  });
355
355
  }
356
+ /** Append a fresh verification to a completed task without reopening it. */
357
+ async recordVerificationEvidence(snapshot, evidenceInput, sourceFingerprint) {
358
+ if (snapshot.status === "active") {
359
+ const result = await this.recordEvidence(snapshot.task.id, evidenceInput);
360
+ await this.clearFailure(snapshot.task.id);
361
+ return result;
362
+ }
363
+ const completion = this.#completion;
364
+ if (snapshot.status !== "complete" || completion === undefined) {
365
+ throw taskError("TASK_NOT_ACTIVE", "Completed reverification requires repository context; archived tasks are immutable.");
366
+ }
367
+ const config = await completion.loadConfig();
368
+ const configHash = calculateConfigHash(config);
369
+ if (snapshot.policyConfigHash !== configHash) {
370
+ throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task config changed; create a new task for the new scope.");
371
+ }
372
+ const store = new FileEvidenceStore(completion.root, completion.root);
373
+ const matches = (value, criterionId, commandId) => {
374
+ const validation = validateEvidence(value);
375
+ return validation.ok && validation.value.taskId === snapshot.task.id &&
376
+ validation.value.criterionId === criterionId && validation.value.commandId === commandId &&
377
+ validation.value.configHash === configHash && validation.value.sourceFingerprint === sourceFingerprint
378
+ ? validation.value : null;
379
+ };
380
+ for (const criterion of snapshot.task.criteria) {
381
+ const commands = config.verification.commands.filter(({ id, required }) => required && criterion.verifierIds.includes(id));
382
+ if (commands.length === 0)
383
+ throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task criteria lack required coverage.");
384
+ for (const command of commands) {
385
+ let original = false;
386
+ let fresh = false;
387
+ for (const reference of snapshot.evidence[criterion.id] ?? []) {
388
+ if (reference.startsWith("review:"))
389
+ continue;
390
+ const evidence = matches(await store.load(reference), criterion.id, command.id);
391
+ // Legacy counts identify the original scope only; they do not prove fresh PASS.
392
+ if (evidence !== null && isPassingVerificationEvidence({ ...command, evidence: { kind: "exit-code" } }, evidence))
393
+ original = true;
394
+ }
395
+ for (const reference of evidenceInput[criterion.id] ?? []) {
396
+ const evidence = matches(await store.load(reference), criterion.id, command.id);
397
+ if (evidence !== null && evidence.schemaVersion === 3 &&
398
+ !snapshot.evidence[criterion.id]?.includes(reference) && isPassingVerificationEvidence(command, evidence))
399
+ fresh = true;
400
+ }
401
+ if (!original || !fresh)
402
+ throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task source or evidence changed; fresh PASS must cover the original scope and every required verifier.");
403
+ }
404
+ }
405
+ const appended = normalizeEvidence(snapshot.task, evidenceInput);
406
+ for (const [criterionId, references] of Object.entries(appended)) {
407
+ for (const reference of references) {
408
+ const validation = validateEvidence(await store.load(reference));
409
+ if (!validation.ok || !snapshot.task.criteria.find(({ id }) => id === criterionId)?.verifierIds.includes(validation.value.commandId) || matches(validation.value, criterionId, validation.value.commandId) === null) {
410
+ throw taskError("TASK_EVIDENCE_INVALID", "Fresh verification references must belong to this task, criterion, source and config.");
411
+ }
412
+ }
413
+ }
414
+ const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
415
+ ...(completion.base === undefined ? {} : { base: completion.base }) });
416
+ if (sourceFingerprint !== await calculateSourceFingerprint(completion.root, scope, completion.gitRunner) ||
417
+ configHash !== calculateConfigHash(await completion.loadConfig())) {
418
+ throw taskError("TASK_REVERIFICATION_CHANGED", "Source or config changed during reverification.");
419
+ }
420
+ const now = assertTimestamp(this.#now());
421
+ return await this.#store.mutate((state) => {
422
+ if (JSON.stringify(findTask(state, snapshot.task.id)) !== JSON.stringify(snapshot)) {
423
+ throw taskError("TASK_REVERIFICATION_STATE_CHANGED", "Task changed during reverification; retry without discarding the concurrent state.");
424
+ }
425
+ const updated = { ...snapshot, updatedAt: now, failureFingerprint: null,
426
+ evidence: Object.fromEntries(snapshot.task.criteria.map(({ id }) => [id,
427
+ [...new Set([...(snapshot.evidence[id] ?? []), ...appended[id]])]])) };
428
+ replaceTask(state, updated);
429
+ return cloneRecord(updated);
430
+ });
431
+ }
356
432
  async archive(taskId) {
357
433
  const now = assertTimestamp(this.#now());
358
434
  return await this.#store.mutate((state) => {
@@ -1,5 +1,5 @@
1
1
  import { runVerificationCommand } from "./spawn.js";
2
- import { evaluateTestCount, parseTestCount } from "./test-count.js";
2
+ import { evaluateTestCount, parseTestSummary } from "./test-count.js";
3
3
  function classifyTestCountCode(code) {
4
4
  const classes = {
5
5
  TEST_COUNT_BELOW_MINIMUM: "test-count-below-minimum",
@@ -43,7 +43,8 @@ function classifyCommand(command, spawned) {
43
43
  testCount: null
44
44
  };
45
45
  }
46
- const testCount = parseTestCount(`${spawned.stdout}\n${spawned.stderr}`);
46
+ const summary = parseTestSummary(`${spawned.stdout}\n${spawned.stderr}`);
47
+ const testCount = summary.passed;
47
48
  if (spawned.status !== "PASS") {
48
49
  return {
49
50
  status: spawned.status,
@@ -51,6 +52,9 @@ function classifyCommand(command, spawned) {
51
52
  testCount
52
53
  };
53
54
  }
55
+ if (summary.failed > 0) {
56
+ return { status: "FAIL", failureClass: "test-failures", testCount };
57
+ }
54
58
  const evaluation = evaluateTestCount(testCount, command.evidence.minimum);
55
59
  return {
56
60
  status: evaluation.status,
@@ -54,7 +54,8 @@ export function isPassingVerificationEvidence(command, evidence) {
54
54
  return false;
55
55
  }
56
56
  return command.evidence.kind !== "test-count" ||
57
- evaluateTestCount(evidence.testCount, command.evidence.minimum).status === "PASS";
57
+ (evidence.schemaVersion === EVIDENCE_SCHEMA_VERSION &&
58
+ evaluateTestCount(evidence.testCount, command.evidence.minimum).status === "PASS");
58
59
  }
59
60
  export class FileEvidenceStore {
60
61
  #root;
@@ -113,6 +113,10 @@ export class VerificationService {
113
113
  throw verificationError("VERIFICATION_INPUT_INVALID", validation.errors[0]?.message ??
114
114
  "Task and verification configuration are incompatible.");
115
115
  }
116
+ const missingCriteria = validation.value.criteria.filter((criterion) => !this.#options.config.verification.commands.some(({ id, required }) => required && criterion.verifierIds.includes(id)));
117
+ if (missingCriteria.length > 0) {
118
+ throw verificationError("VERIFICATION_UNKNOWN", `Criteria without required coverage: ${missingCriteria.map(({ id }) => id).join(", ")}. Configure at least one required verifier for each criterion and recreate the task; no verifier commands were run.`);
119
+ }
116
120
  const reviewScope = await resolveReviewScope({
117
121
  root: this.#options.root,
118
122
  runner: this.#options.gitRunner,
@@ -167,9 +171,8 @@ export class VerificationService {
167
171
  }
168
172
  }
169
173
  if (Object.keys(taskEvidence).length > 0) {
170
- await this.#options.taskService.recordEvidence(taskId, taskEvidence);
174
+ await this.#options.taskService.recordVerificationEvidence(stored, taskEvidence, sourceFingerprint);
171
175
  }
172
- await this.#options.taskService.clearFailure(taskId);
173
176
  }
174
177
  else {
175
178
  const required = results.filter((result) => result.required);
@@ -1,108 +1,108 @@
1
1
  const MAX_SUMMARY_BYTES = 1024 * 1024;
2
- const NUMBER_SOURCE = String.raw `\d{1,16}`;
2
+ const NUMBER_SOURCE = String.raw `\d+`;
3
3
  function safeCount(value) {
4
4
  const parsed = Number(value);
5
5
  return Number.isSafeInteger(parsed) && parsed >= 0
6
6
  ? parsed
7
7
  : null;
8
8
  }
9
- function addCandidate(candidates, source) {
10
- const parsed = safeCount(source);
11
- if (parsed !== null) {
12
- candidates.push(parsed);
9
+ /** Counts outcomes, never discovery totals. Conflicting summaries fail closed. */
10
+ export function parseTestSummary(output) {
11
+ if (output.includes("\0") || Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) {
12
+ return { passed: null, failed: 0 };
13
13
  }
14
- }
15
- function parsePytestSummary(line) {
16
- if (!/\bin \d+(?:\.\d+)?s(?:\s|$)/u.test(line)) {
17
- return null;
18
- }
19
- const statusPattern = new RegExp(`(${NUMBER_SOURCE}) (passed|failed|skipped|error|errors|xfailed|xpassed|deselected)`, "gu");
20
- let total = 0;
21
- let matched = false;
22
- for (const match of line.matchAll(statusPattern)) {
23
- const source = match[1];
24
- const status = match[2];
25
- if (source === undefined || status === undefined) {
26
- return null;
14
+ const summaries = [];
15
+ let nodePassed = null;
16
+ let nodeFailed = 0;
17
+ let nodeSeen = false;
18
+ let invalid = false;
19
+ const nodeCounts = new Map();
20
+ for (const raw of output.replace(/\u001b\[[0-9;]*m/gu, "").split(/\r?\n/u)) {
21
+ const line = raw.trim();
22
+ const node = new RegExp(`^(?:#|ℹ) (tests|pass|fail|cancelled|skipped|todo) (${NUMBER_SOURCE})$`, "u").exec(line);
23
+ if (node !== null) {
24
+ nodeSeen = true;
25
+ const count = safeCount(node[2]);
26
+ if (count === null || (nodeCounts.has(node[1]) && nodeCounts.get(node[1]) !== count))
27
+ invalid = true;
28
+ else
29
+ nodeCounts.set(node[1], count);
30
+ continue;
27
31
  }
28
- const count = safeCount(source);
29
- if (count === null ||
30
- (status !== "deselected" &&
31
- total > Number.MAX_SAFE_INTEGER - count)) {
32
- return null;
32
+ if (/^(?:#|ℹ) (?:tests|pass|fail|cancelled|skipped|todo)\b/u.test(line)) {
33
+ invalid = true;
34
+ continue;
33
35
  }
34
- if (status !== "deselected") {
35
- total += count;
36
+ const rust = new RegExp(`^test result: (ok|FAILED)\\. (${NUMBER_SOURCE}) passed; (${NUMBER_SOURCE}) failed;`, "u").exec(line);
37
+ if (rust !== null) {
38
+ const passed = safeCount(rust[2]);
39
+ const failed = safeCount(rust[3]);
40
+ if (passed === null || failed === null)
41
+ invalid = true;
42
+ summaries.push({ passed, failed: Math.max(failed ?? 0, rust[1] === "FAILED" ? 1 : 0) });
43
+ continue;
36
44
  }
37
- matched = true;
38
- }
39
- return matched ? total : null;
40
- }
41
- function parseRustSummary(line) {
42
- const match = new RegExp(`^test result: (?:ok|FAILED)\\. (${NUMBER_SOURCE}) passed; (${NUMBER_SOURCE}) failed; ${NUMBER_SOURCE} ignored; ${NUMBER_SOURCE} measured; ${NUMBER_SOURCE} filtered out$`, "u").exec(line);
43
- if (match === null) {
44
- return null;
45
+ const isJest = /^Tests:/u.test(line);
46
+ const isVitest = /^Tests\s+/u.test(line) && /\(\d+\)\s*$/u.test(line);
47
+ const isPytest = /\bin \d+(?:\.\d+)?s(?:\s|=|$)/u.test(line);
48
+ if (isJest || isVitest || isPytest) {
49
+ let passed = 0;
50
+ let failed = 0;
51
+ let matched = false;
52
+ let total = 0;
53
+ const seen = new Set();
54
+ for (const match of line.matchAll(new RegExp(`(${NUMBER_SOURCE}) (passed|failed|skipped|todo|error|errors|cancelled|xfailed|xpassed|deselected)\\b`, "gu"))) {
55
+ matched = true;
56
+ const count = safeCount(match[1]);
57
+ const status = match[2];
58
+ if (count === null || seen.has(status)) {
59
+ invalid = true;
60
+ continue;
61
+ }
62
+ seen.add(status);
63
+ if (status !== "deselected")
64
+ total += count;
65
+ if (!Number.isSafeInteger(total))
66
+ invalid = true;
67
+ if (status === "passed")
68
+ passed = count;
69
+ if (["failed", "error", "errors", "cancelled"].includes(status)) {
70
+ failed += count;
71
+ if (!Number.isSafeInteger(failed))
72
+ invalid = true;
73
+ }
74
+ }
75
+ const totalMatch = isJest ? /\b(\d+) total(?:\s|$)/u.exec(line) : isVitest ? /\((\d+)\)\s*$/u.exec(line) : null;
76
+ if (matched && totalMatch !== null && safeCount(totalMatch[1]) !== total)
77
+ invalid = true;
78
+ // Missing passed in a recognized outcome summary means zero passes;
79
+ // a discovery/total-only line gives no outcome evidence.
80
+ summaries.push({ passed: matched ? passed : null, failed });
81
+ continue;
82
+ }
83
+ if (/^collected 0 items?$/u.test(line))
84
+ summaries.push({ passed: 0, failed: 0 });
45
85
  }
46
- const passedSource = match[1];
47
- const failedSource = match[2];
48
- if (passedSource === undefined || failedSource === undefined) {
49
- return null;
86
+ if (nodeSeen) {
87
+ nodePassed = nodeCounts.get("pass") ?? (nodeCounts.get("tests") === 0 ? 0 : null);
88
+ nodeFailed = (nodeCounts.get("fail") ?? 0) + (nodeCounts.get("cancelled") ?? 0);
89
+ if (!Number.isSafeInteger(nodeFailed))
90
+ invalid = true;
91
+ const total = nodeCounts.get("tests");
92
+ const outcomes = (nodePassed ?? 0) + nodeFailed + (nodeCounts.get("skipped") ?? 0) + (nodeCounts.get("todo") ?? 0);
93
+ if (!Number.isSafeInteger(outcomes) || (total !== undefined && outcomes > total))
94
+ invalid = true;
95
+ summaries.push({ passed: nodePassed, failed: nodeFailed });
50
96
  }
51
- const passed = safeCount(passedSource);
52
- const failed = safeCount(failedSource);
53
- if (passed === null ||
54
- failed === null ||
55
- passed > Number.MAX_SAFE_INTEGER - failed) {
56
- return null;
97
+ const first = summaries[0];
98
+ const failed = summaries.some((summary) => summary.failed > 0) ? 1 : 0;
99
+ if (invalid || first === undefined || summaries.some((summary) => summary.passed !== first.passed || summary.failed !== first.failed)) {
100
+ return { passed: null, failed };
57
101
  }
58
- return passed + failed;
102
+ return { passed: first.passed, failed: first.failed };
59
103
  }
60
104
  export function parseTestCount(output) {
61
- if (output.includes("\0") ||
62
- Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) {
63
- return null;
64
- }
65
- const candidates = [];
66
- const nodePattern = new RegExp(`^# tests (${NUMBER_SOURCE})$`, "u");
67
- const collectedPattern = new RegExp(`^collected (${NUMBER_SOURCE}) items?$`, "u");
68
- const jestPattern = new RegExp(`^Tests:\\s+.*\\b(${NUMBER_SOURCE}) total(?:\\s|$)`, "u");
69
- const vitestPattern = new RegExp(`^Tests\\s+.*\\((${NUMBER_SOURCE})\\)\\s*$`, "u");
70
- for (const rawLine of output.split(/\r?\n/u)) {
71
- const line = rawLine.trim();
72
- if (line.length === 0) {
73
- continue;
74
- }
75
- const node = nodePattern.exec(line);
76
- if (node?.[1] !== undefined) {
77
- addCandidate(candidates, node[1]);
78
- }
79
- const collected = collectedPattern.exec(line);
80
- if (collected?.[1] !== undefined) {
81
- addCandidate(candidates, collected[1]);
82
- }
83
- const jest = jestPattern.exec(line);
84
- if (jest?.[1] !== undefined) {
85
- addCandidate(candidates, jest[1]);
86
- }
87
- const vitest = vitestPattern.exec(line);
88
- if (vitest?.[1] !== undefined) {
89
- addCandidate(candidates, vitest[1]);
90
- }
91
- const pytest = parsePytestSummary(line);
92
- if (pytest !== null) {
93
- candidates.push(pytest);
94
- }
95
- const rust = parseRustSummary(line);
96
- if (rust !== null) {
97
- candidates.push(rust);
98
- }
99
- }
100
- const first = candidates[0];
101
- if (first === undefined ||
102
- candidates.some((candidate) => candidate !== first)) {
103
- return null;
104
- }
105
- return first;
105
+ return parseTestSummary(output).passed;
106
106
  }
107
107
  export function evaluateTestCount(testCount, minimum = 1) {
108
108
  if (!Number.isSafeInteger(minimum) || minimum < 0) {