diff --git a/README.md b/README.md index 7337f05..3ad84f3 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ ANTHROPIC_CUSTOM_HEADERS=X-Workspace: code-review #### Outputs -`pass`, `flagged`, `neutral`, and `total-cost`. Result counts distinguish findings (`flagged`) from operational misses (`neutral`). `total-cost` is empty when any contributing cost is unknown. Reviews are posted inline as `COMMENT` reviews; resolution and deduplication judges always run when they have work. These behaviors are fixed, not configurable. There are no per-invocation cost ceilings. +`pass`, `flagged`, `neutral`, and `total-cost`. Result counts distinguish findings (`flagged`) from non-failing outcomes (`neutral`), including unusable model output; each neutral check result includes a `cause` of `infrastructure` or `invalid_output`. The aggregate check fails only for infrastructure errors. `total-cost` is empty when any contributing cost is unknown. Reviews are posted inline as `COMMENT` reviews; resolution and deduplication judges always run when they have work. These behaviors are fixed, not configurable. There are no per-invocation cost ceilings. ### Local CLI @@ -233,7 +233,7 @@ WEAVE_CHECKS_PROVIDER_ENV='ANTHROPIC_BASE_URL=https://llm-gateway.example.com' \ npx @weave-os/checks run --provider anthropic --checks-dir .weave-checks --base origin/main --format markdown ``` -`run` exits 1 when at least one check flags findings, 2 for invalid options or setup, and 0 when there are no findings. Use `--no-fail` to report findings without a non-zero exit. Neutral operational misses do not fail the run. `--format` supports `text`, `markdown`, and `json`; `--output ` writes the JSON result as well. Prompts, transcripts, and per-check results are stored in a temporary directory by default; `--artifacts-dir` keeps them at the path you choose. These artifacts contain the diff and agent output, so handle them as repository data. +`run` exits 1 when at least one check flags findings, 2 for invalid options or setup, and 0 when there are no findings. Use `--no-fail` to report findings without a non-zero exit. Neutral outcomes do not fail the run. `--format` supports `text`, `markdown`, and `json`; `--output ` writes the JSON result as well. Prompts, transcripts, and per-check results are stored in a temporary directory by default; `--artifacts-dir` keeps them at the path you choose. These artifacts contain the diff and agent output, so handle them as repository data. `list --format json` prints the validated matrix. `print-schema` prints the result schema; `print-schema --kind resolution` and `--kind dedup` print the judge schemas. diff --git a/src/ci.mjs b/src/ci.mjs index a251d49..c4d6aec 100644 --- a/src/ci.mjs +++ b/src/ci.mjs @@ -3,7 +3,7 @@ // // Each is a small function over an injected REST client so the contract the // action relies on -- creation before checkout, a cleanup that never -// overwrites the worker's own close, and failure on incomplete reviews -- is +// overwrites the worker's own close, and failure on infrastructure errors -- is // unit tested (ci.test.mjs). import { appendFileSync, existsSync, readFileSync } from "node:fs"; @@ -34,9 +34,10 @@ export async function createAggregate({ rest, repository, headSha, aggregateName // When the worker reached its own close (success or its top-level catch), it // wrote `completePath` and this is a no-op, so the worker's specific message // is never overwritten with this generic one. Otherwise -- a crash in -// checkout, preparation, discovery, or before the worker could PATCH -- -// close it as failure so branch protection cannot accept a run that never -// produced a review. Findings remain neutral; only incomplete reviews fail. +// checkout, preparation, or before the worker could PATCH because of an +// infrastructure/setup failure -- close it as failure so branch protection +// cannot accept a run that never reached the coordinator. Findings and +// unusable model output remain neutral when the worker finishes normally. export async function closeAggregate({ rest, repository, diff --git a/src/parse.mjs b/src/parse.mjs index c1873c2..8261291 100644 --- a/src/parse.mjs +++ b/src/parse.mjs @@ -599,6 +599,11 @@ export const OUTCOME = Object.freeze({ FAIL: "fail", }); +export const NEUTRAL_CAUSE = Object.freeze({ + INFRASTRUCTURE: "infrastructure", + INVALID_OUTPUT: "invalid_output", +}); + // The outcome names published in JSON results and action outputs. Internally // a FAIL verdict is OUTCOME.FAIL, but it never publishes as a failed check // (see worker.mjs's header), so the public vocabulary says what it is: the @@ -659,8 +664,8 @@ export const CLI_RESULT_SUBTYPE = Object.freeze({ // Closed set of interpretResult() outcomes. OK means the verdict object is // ready to validate; RETRYABLE means the CLI succeeded but skipped -// structured output (worth one more ask); DEFINITE is a crash or parse -// error that would just repeat. +// structured output (worth one more ask); DEFINITE is not worth retrying. +// `cause` separates infrastructure failures from unusable model output. export const INTERPRET_OUTCOME = Object.freeze({ OK: "ok", RETRYABLE: "retryable", @@ -676,8 +681,8 @@ export const GITHUB_CONCLUSION = Object.freeze({ // HTTP verbs the worker's GitHub REST wrapper actually sends. Kept next to // GITHUB_CONCLUSION so callers (worker.mjs's github()) restate nothing as // raw strings -- a typo "GETT" would silently 404 instead of failing loudly. -// Operational misses and coordinator failures publish `failure`, while a -// completed review with findings remains `neutral`. +// Infrastructure and coordinator failures publish `failure`; findings and +// unusable model output publish `neutral`. export const HTTP_METHOD = Object.freeze({ GET: "GET", POST: "POST", @@ -807,6 +812,7 @@ export function interpretResult(cli) { ) { return { outcome: INTERPRET_OUTCOME.DEFINITE, + cause: NEUTRAL_CAUSE.INFRASTRUCTURE, value: { error: `Claude reported ${cli?.subtype ?? cli?.api_error_status ?? "an error"}` }, }; } @@ -817,6 +823,7 @@ export function interpretResult(cli) { } catch (error) { return { outcome: INTERPRET_OUTCOME.DEFINITE, + cause: NEUTRAL_CAUSE.INVALID_OUTPUT, value: { error: `structured_output was a string but was not valid JSON: ${error.message}` }, }; } @@ -835,6 +842,7 @@ export function interpretResult(cli) { } catch { return { outcome: INTERPRET_OUTCOME.RETRYABLE, + cause: NEUTRAL_CAUSE.INVALID_OUTPUT, value: { error: "structured_output was missing and the prose result did not contain a verdict JSON object", @@ -846,6 +854,7 @@ export function interpretResult(cli) { return { outcome: INTERPRET_OUTCOME.RETRYABLE, + cause: NEUTRAL_CAUSE.INVALID_OUTPUT, value: { error: "structured_output was missing and the prose result had no verdict", rawResult: cli.result, diff --git a/src/parse.test.mjs b/src/parse.test.mjs index 7d28c91..6c46a71 100644 --- a/src/parse.test.mjs +++ b/src/parse.test.mjs @@ -30,6 +30,7 @@ import { isCheckFile, parseIgnoreList, OUTCOME, + NEUTRAL_CAUSE, parseAddedLines, parseCheckFile, parseStructuredOutput, @@ -667,6 +668,7 @@ describe("interpretResult", () => { }; const interpreted = interpretResult(cli); assert.equal(interpreted.outcome, "retryable"); + assert.equal(interpreted.cause, NEUTRAL_CAUSE.INVALID_OUTPUT); assert.match(interpreted.value.error, /structured_output was missing/); assert.equal(interpreted.value.rawResult, cli.result); }); @@ -677,7 +679,9 @@ describe("interpretResult", () => { // a schema/serialization bug, not a skipped tool call, so a retry isn't // expected to help. const cli = { subtype: "success", structured_output: "{not json" }; - assert.equal(interpretResult(cli).outcome, "definite"); + const interpreted = interpretResult(cli); + assert.equal(interpreted.outcome, "definite"); + assert.equal(interpreted.cause, NEUTRAL_CAUSE.INVALID_OUTPUT); }); it("recovers a verdict JSON literal embedded in prose as ok", () => { @@ -728,17 +732,21 @@ describe("interpretResult", () => { const cli = { subtype: "error_max_turns" }; const interpreted = interpretResult(cli); assert.equal(interpreted.outcome, "definite"); + assert.equal(interpreted.cause, NEUTRAL_CAUSE.INFRASTRUCTURE); assert.match(interpreted.value.error, /error_max_turns/); }); it("is definite when is_error is true even with subtype success", () => { const cli = { subtype: "success", is_error: true }; assert.equal(interpretResult(cli).outcome, "definite"); + assert.equal(interpretResult(cli).cause, NEUTRAL_CAUSE.INFRASTRUCTURE); }); - it("is definite for a null or non-object cli", () => { + it("is definite for a null or non-object cli and marks it as infrastructure", () => { assert.equal(interpretResult(null).outcome, "definite"); + assert.equal(interpretResult(null).cause, NEUTRAL_CAUSE.INFRASTRUCTURE); assert.equal(interpretResult(undefined).outcome, "definite"); + assert.equal(interpretResult(undefined).cause, NEUTRAL_CAUSE.INFRASTRUCTURE); }); it("is retryable when structured_output is an array, not an object", () => { diff --git a/src/runner.mjs b/src/runner.mjs index d0d5c6c..96eb46d 100644 --- a/src/runner.mjs +++ b/src/runner.mjs @@ -30,6 +30,7 @@ import process from "node:process"; import { CLI_RESULT_SUBTYPE, INTERPRET_OUTCOME, + NEUTRAL_CAUSE, OUTCOME, VERDICT, interpretResult, @@ -294,6 +295,7 @@ export function normalize(invocation, addedLines) { // failed invocation as free. return { outcome: OUTCOME.NEUTRAL, + cause: NEUTRAL_CAUSE.INFRASTRUCTURE, error: exitError(invocation), cost: invocationCost(invocation), duration: invocationDuration(invocation), @@ -306,6 +308,7 @@ export function normalize(invocation, addedLines) { if (invocation.cli === null) { return { outcome: OUTCOME.NEUTRAL, + cause: NEUTRAL_CAUSE.INFRASTRUCTURE, error: "Claude stream-json output had no terminal result event", cost: invocationCost(invocation), duration: null, @@ -328,12 +331,19 @@ export function normalize(invocation, addedLines) { return interpreted.outcome === INTERPRET_OUTCOME.RETRYABLE ? { outcome: OUTCOME.NEUTRAL, + cause: interpreted.cause, ...interpreted.value, retryable: true, cost, duration, } - : { outcome: OUTCOME.NEUTRAL, ...interpreted.value, cost, duration }; + : { + outcome: OUTCOME.NEUTRAL, + cause: interpreted.cause, + ...interpreted.value, + cost, + duration, + }; } // The structured_output route (the common case once the model cooperates) // needs the full result validated against the diff. interpretResult already @@ -361,6 +371,7 @@ export function validateAndFinalize(resultObject, invocation, cli, addedLines) { // neutral run from a parse-time miss doesn't look free. return { outcome: OUTCOME.NEUTRAL, + cause: NEUTRAL_CAUSE.INVALID_OUTPUT, error: `Invalid structured result: ${error.message}`, cost, duration, @@ -370,6 +381,7 @@ export function validateAndFinalize(resultObject, invocation, cli, addedLines) { if (validated.verdict === VERDICT.FAIL && validated.accepted.length === 0) { return { outcome: OUTCOME.NEUTRAL, + cause: NEUTRAL_CAUSE.INVALID_OUTPUT, error: "FAIL had no valid anchored findings after diff validation.", proseFallbacks: validated.proseFallbacks, rejected: validated.rejected, diff --git a/src/runner.test.mjs b/src/runner.test.mjs index 18b1890..9e578e1 100644 --- a/src/runner.test.mjs +++ b/src/runner.test.mjs @@ -5,7 +5,14 @@ import path from "node:path"; import process from "node:process"; import { afterEach, describe, it } from "node:test"; -import { CLI_RESULT_SUBTYPE, CLUSTER_LOW, MODEL_HAIKU, OUTCOME, VERDICT } from "./parse.mjs"; +import { + CLI_RESULT_SUBTYPE, + CLUSTER_LOW, + MODEL_HAIKU, + NEUTRAL_CAUSE, + OUTCOME, + VERDICT, +} from "./parse.mjs"; import { evaluateCheck, formatDuration, @@ -414,6 +421,7 @@ describe("runClaude", () => { assert.equal(invocation.code, 3); assert.equal(result.outcome, OUTCOME.NEUTRAL); + assert.equal(result.cause, NEUTRAL_CAUSE.INFRASTRUCTURE); assert.equal(result.error, "Claude CLI exited 3"); }); @@ -436,6 +444,7 @@ describe("runClaude", () => { const result = normalize(invocation, new Map()); assert.equal(result.outcome, OUTCOME.NEUTRAL); + assert.equal(result.cause, NEUTRAL_CAUSE.INFRASTRUCTURE); assert.equal(result.error, "Claude CLI exited 1: Prompt is too long"); }); }); @@ -459,6 +468,14 @@ describe("normalize", () => { }; } + it("keeps malformed structured output neutral and classifies it as output", () => { + const result = normalize(invocation(cli("{not json")), addedLines); + + assert.equal(result.outcome, OUTCOME.NEUTRAL); + assert.equal(result.cause, NEUTRAL_CAUSE.INVALID_OUTPUT); + assert.match(result.error, /was not valid JSON/); + }); + it("accepts a PASS verdict", () => { const result = normalize( invocation(cli({ verdict: VERDICT.PASS, reason: "no violations" })), @@ -533,6 +550,7 @@ describe("normalize", () => { addedLines, ); assert.equal(result.outcome, OUTCOME.NEUTRAL); + assert.equal(result.cause, NEUTRAL_CAUSE.INVALID_OUTPUT); assert.match(result.error, /no valid anchored findings/); assert.equal(result.proseFallbacks.length, 0); assert.equal(result.rejected.length, 1); @@ -543,6 +561,7 @@ describe("normalize", () => { it("treats a missing terminal result event as neutral with an unknown duration", () => { const result = normalize(invocation(null), addedLines); assert.equal(result.outcome, OUTCOME.NEUTRAL); + assert.equal(result.cause, NEUTRAL_CAUSE.INFRASTRUCTURE); assert.match(result.error, /no terminal result event/); assert.equal(result.duration, null); }); @@ -561,6 +580,7 @@ describe("normalize", () => { addedLines, ); assert.equal(result.outcome, OUTCOME.NEUTRAL); + assert.equal(result.cause, NEUTRAL_CAUSE.INVALID_OUTPUT); assert.equal(result.retryable, true); }); }); @@ -574,6 +594,7 @@ describe("validateAndFinalize", () => { new Map(), ); assert.equal(result.outcome, OUTCOME.NEUTRAL); + assert.equal(result.cause, NEUTRAL_CAUSE.INVALID_OUTPUT); assert.match(result.error, /Invalid structured result/); assert.equal(result.cost, 0.25); assert.equal(result.duration, 400); diff --git a/src/worker.mjs b/src/worker.mjs index dc55373..96958ed 100644 --- a/src/worker.mjs +++ b/src/worker.mjs @@ -1,10 +1,9 @@ // Runs all Weave Checks from one GitHub Actions job. // -// Per-check runs are `success` only for a PASS verdict; findings and -// individual operational misses are `neutral`. The aggregate is `failure` if -// any check could not complete its review, including coordinator failures. A -// completed review with findings remains neutral so dismissed findings never -// leave a permanently-red status row. +// Per-check runs are `success` only for a PASS verdict; every other outcome is +// `neutral`. The aggregate is `failure` only for infrastructure errors, +// including coordinator failures. A completed review with findings stays +// neutral so dismissed findings never leave a permanently-red status row. // // The action creates one aggregate Check Run before starting this script. // This worker creates the per-check runs, runs the agents in a bounded local @@ -45,6 +44,7 @@ import { RUN_MARKER, STATUS, OUTCOME, + NEUTRAL_CAUSE, GITHUB_CONCLUSION, CHECK_RUN_STATUS, HTTP_METHOD, @@ -403,6 +403,7 @@ export async function runWorker(config, deps = {}) { check, status: STATUS.QUEUED, outcome: null, + cause: null, note: "Waiting for an agent slot.", checkRunId: null, cost: 0, @@ -1020,17 +1021,22 @@ export async function runWorker(config, deps = {}) { }; if (final) { payload.status = CHECK_RUN_STATUS.COMPLETED; - // A completed FAIL verdict stays neutral: the finding is already visible - // on the child's own run and should not leave a red status after a human - // resolves it. A check that could not complete is different -- branch - // protection must not pass when part of the PR went unread. + // A finding or unusable model output is not an infrastructure failure. + // Keep those neutral; fail only when a check hit an infrastructure error + // (or has an unknown/incomplete state, which defaults to infrastructure). + const infrastructureFailure = all.some(state => + state.outcome === OUTCOME.NEUTRAL ? + state.cause !== NEUTRAL_CAUSE.INVALID_OUTPUT + : state.outcome !== OUTCOME.PASS && state.outcome !== OUTCOME.FAIL, + ); payload.conclusion = pass === all.length && all.length > 0 ? GITHUB_CONCLUSION.SUCCESS - : !everyCheckReviewed(all) ? GITHUB_CONCLUSION.FAILURE + : infrastructureFailure ? GITHUB_CONCLUSION.FAILURE : GITHUB_CONCLUSION.NEUTRAL; - // This marker records which check set completed, not merely the head - // commit. Findings count as completed reviews; any operational miss - // withholds it so the next run widens back to the merge base. + // This marker records which check set completed a usable review, not + // merely the head commit. Findings count as completed reviews; any miss, + // including invalid model output, withholds it so the next run widens back + // to the merge base. if (everyCheckReviewed(all)) { payload.external_id = formatReviewedMarker(HEAD_SHA, CHECK_DIGEST); } @@ -1203,6 +1209,9 @@ export async function runWorker(config, deps = {}) { // already happened. result = { outcome: OUTCOME.NEUTRAL, + // A rejected review POST can mean rate limiting as well as a bad + // payload, so every API failure here is infrastructure. + cause: NEUTRAL_CAUSE.INFRASTRUCTURE, error: `Could not post review: ${error.message}`, cost: result.cost, duration: result.duration, @@ -1237,6 +1246,8 @@ export async function runWorker(config, deps = {}) { ); state.result = result; state.outcome = result.outcome; + state.cause = + result.outcome === OUTCOME.NEUTRAL ? (result.cause ?? NEUTRAL_CAUSE.INFRASTRUCTURE) : null; state.status = STATUS.COMPLETE; state.cost = totalCost([result.cost, judgment.cost]); state.durationMs = totalDuration([result.duration, judgment.duration]); @@ -1249,9 +1260,11 @@ export async function runWorker(config, deps = {}) { } catch (error) { state.result = { outcome: OUTCOME.NEUTRAL, + cause: NEUTRAL_CAUSE.INFRASTRUCTURE, error: error.message ?? String(error), }; state.outcome = OUTCOME.NEUTRAL; + state.cause = NEUTRAL_CAUSE.INFRASTRUCTURE; state.status = STATUS.COMPLETE; state.note = state.result.error; // The throw can land after the agent phases already ran (e.g. a @@ -1282,6 +1295,7 @@ export async function runWorker(config, deps = {}) { } catch (error) { // Keep the master current even if an individual check-run update failed. state.outcome = OUTCOME.NEUTRAL; + state.cause = NEUTRAL_CAUSE.INFRASTRUCTURE; state.note = `Could not complete check run: ${error.message}`; } } @@ -1306,6 +1320,7 @@ export async function runWorker(config, deps = {}) { ); state.status = STATUS.COMPLETE; state.outcome = OUTCOME.NEUTRAL; + state.cause = NEUTRAL_CAUSE.INFRASTRUCTURE; state.note = `Coordinator error: ${error.message ?? error}`; } }); @@ -1352,6 +1367,10 @@ export async function runWorker(config, deps = {}) { return { ok: true, marker: RUN_MARKER.NO_REVIEWABLE_CHANGES }; } + if (CHECKS.length === 0) { + throw new Error("No checks are configured to review this non-empty diff."); + } + states = buildStates(); await updateMasterBestEffort(); @@ -1414,6 +1433,7 @@ export async function runWorker(config, deps = {}) { cost: state.cost ?? null, durationMs: state.durationMs ?? null, detail: state.note ?? null, + ...(state.cause === null ? {} : { cause: state.cause }), })), totals: { pass: count(OUTCOME.PASS), diff --git a/src/worker.test.mjs b/src/worker.test.mjs index 1ad62cc..613b24c 100644 --- a/src/worker.test.mjs +++ b/src/worker.test.mjs @@ -4,7 +4,13 @@ import os from "node:os"; import path from "node:path"; import { after, describe, it } from "node:test"; -import { OUTCOME, RESULT_SCHEMA, checkSetDigest, formatReviewedMarker } from "./parse.mjs"; +import { + NEUTRAL_CAUSE, + OUTCOME, + RESULT_SCHEMA, + checkSetDigest, + formatReviewedMarker, +} from "./parse.mjs"; import { appendMarker } from "./history.mjs"; import { readWorkerConfig, runWorker } from "./worker.mjs"; @@ -108,6 +114,7 @@ function fakeGitHub({ failMaster = false, failThreads = false, failResolve = false, + reviewStatus = null, liveHead = HEAD_SHA, } = {}) { const calls = []; @@ -136,6 +143,9 @@ function fakeGitHub({ } return reply(200, { data: {} }); } + if (method === "POST" && route === `repos/${REPO}/pulls/7/reviews` && reviewStatus !== null) { + return reply(reviewStatus, { message: "review validation failed" }); + } if (method === "POST" && route === `repos/${REPO}/check-runs`) { nextId += 1; return reply(201, { id: nextId }); @@ -321,7 +331,7 @@ describe("runWorker", () => { const { env } = workerEnv(); const github = fakeGitHub(); - await run(env, github, async ({ check }) => + const outcome = await run(env, github, async ({ check }) => check.slug === "first-check" ? { outcome: OUTCOME.NEUTRAL, error: "Claude CLI exited 1", cost: null, duration: null } : pass(), @@ -330,6 +340,81 @@ describe("runWorker", () => { const final = github.masterPatches().at(-1).body; assert.equal(final.conclusion, "failure"); assert.equal(final.external_id, undefined); + assert.equal( + outcome.states.find(state => state.check.slug === "first-check").cause, + NEUTRAL_CAUSE.INFRASTRUCTURE, + ); + const results = JSON.parse(readFileSync(env.RESULTS_PATH, "utf8")); + assert.equal( + results.checks.find(check => check.slug === "first-check").cause, + "infrastructure", + ); + }); + + it("keeps invalid model output neutral without marking the head reviewed", async () => { + const { env } = workerEnv(); + const github = fakeGitHub(); + + const outcome = await run(env, github, async ({ check }) => + check.slug === "first-check" ? + { + outcome: OUTCOME.NEUTRAL, + cause: NEUTRAL_CAUSE.INVALID_OUTPUT, + error: "Invalid structured result: missing verdict", + cost: 0.01, + duration: 100, + } + : pass(), + ); + + const final = github.masterPatches().at(-1).body; + assert.equal(final.conclusion, "neutral"); + assert.equal(final.external_id, undefined); + assert.equal( + outcome.states.find(state => state.check.slug === "first-check").cause, + NEUTRAL_CAUSE.INVALID_OUTPUT, + ); + const results = JSON.parse(readFileSync(env.RESULTS_PATH, "utf8")); + assert.equal( + results.checks.find(check => check.slug === "first-check").cause, + "invalid_output", + ); + }); + + it("fails the aggregate when GitHub rejects a review POST", async () => { + const { env } = workerEnv(); + const github = fakeGitHub({ reviewStatus: 422 }); + + const outcome = await run(env, github, async ({ check }) => + check.slug === "first-check" ? fail() : pass(), + ); + + const final = github.masterPatches().at(-1).body; + assert.equal(final.conclusion, "failure"); + assert.equal(final.external_id, undefined); + assert.equal( + outcome.states.find(state => state.check.slug === "first-check").cause, + NEUTRAL_CAUSE.INFRASTRUCTURE, + ); + }); + + it("fails the aggregate when a non-empty diff has no configured checks", async () => { + const { env, root } = workerEnv(); + const emptyMatrixPath = path.join(root, "empty-matrix.json"); + writeFileSync(emptyMatrixPath, JSON.stringify({ check: [] })); + env.MATRIX_PATH = emptyMatrixPath; + const github = fakeGitHub(); + + const workerResult = await run(env, github, async () => { + throw new Error("no checks should run"); + }); + + const finalCheckRun = github.masterPatches().at(-1).body; + assert.equal(workerResult.ok, false); + assert.equal(finalCheckRun.conclusion, "failure"); + assert.equal(finalCheckRun.external_id, undefined); + assert.match(finalCheckRun.output.summary, /No checks are configured/); + assert.equal(github.childRuns().length, 0); }); it("fails the aggregate without running checks when review history is unavailable", async () => {