Skip to content

Commit ee6ce25

Browse files
author
JSONbored
committed
refactor(ai-review): give both reviewer paths one shared options type instead of a positional tail
runWorkersOpinion took 12 positional parameters ending in two adjacent, same-typed, identically-defaulted booleans (bodyTruncated, prHasTestEvidence), and runProviderReview carried the identical trailing sequence -- its own comments said 'same contract as runWorkersOpinion' on both. Both then ran the same demotion pair in the same order (demoteEvidenceAbsenceBlockers then demoteTestEvidenceAbsenceBlockers). Two places that must agree with nothing enforcing it. Transposing the booleans at any call site compiled and type-checked cleanly; doing it in only one of the two paths split Workers AI and BYOK demotion behaviour apart with no signal at all. Six call sites passed them positionally, each threading an undefined placeholder past images? -- a parameter no caller has ever supplied since #4111. One shared ReviewerDemotionContext, referenced by both signatures, makes the compiler enforce what the two comments only asserted. That is why no NAMED_TWIN_PAIRS entry (scripts/check-engine-parity.ts) is added: a drift check would be redundant against a type the compiler already checks, and a redundant guard is one more thing to keep true. Pure de-positionalisation. Every default is preserved by destructuring at the top of each function, so no body reference changed and no behaviour moved. images? is kept rather than deleted -- removing a deferred-but-designed parameter is a separate call from this one. Two test call sites used 'diagnostics as never', which let an ARRAY through where the options object now goes; the caller's array then stayed empty. Ten call sites carried that cast. Two failed loudly and are fixed. A third (line 3784) had been asserting diagnostics.some(...) === false against an array that could never be populated -- vacuously true. It now passes a real array and the assertion is genuine; verified by asserting the array is non-empty before restoring. Closes #10253
1 parent 470d417 commit ee6ce25

2 files changed

Lines changed: 62 additions & 69 deletions

File tree

src/services/ai-review.ts

Lines changed: 46 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,25 +1610,50 @@ const REVIEW_ATTEMPTS_PER_MODEL = 3;
16101610

16111611
/** One reviewer opinion (whichever provider `env.AI` resolves to — self-host Codex/Claude Code/etc, or the
16121612
* legacy Workers-AI pair) with a per-slot reliable fallback and a 3× retry on the primary. */
1613+
/**
1614+
* The reviewer inputs BOTH provider paths must agree on (#10253).
1615+
*
1616+
* `runWorkersOpinion` (Workers AI) and `runProviderReview` (BYOK) each ran the same demotion sequence off the
1617+
* same trailing arguments, kept in step by nothing but two comments reading "same contract as
1618+
* runWorkersOpinion". Two adjacent, same-typed, identically-defaulted booleans meant transposing them at any
1619+
* call site compiled cleanly, type-checked cleanly, and silently armed the wrong demotion — and doing it in
1620+
* only ONE of the two paths split Workers AI and BYOK behaviour apart with no signal at all.
1621+
*
1622+
* One shared type referenced by both signatures makes the compiler enforce what the comments only asserted, so
1623+
* the pair cannot drift and needs no entry in `NAMED_TWIN_PAIRS` (scripts/check-engine-parity.ts) to guard it.
1624+
*/
1625+
type ReviewerDemotionContext = {
1626+
/** Pixel-diff-confirmed screenshot(s) for a visual-vision pass (#4111). Absent for every existing caller —
1627+
* wiring a real caller (source images, invoke with them) is a deliberately deferred follow-up; see
1628+
* review/visual/visual-findings.ts. Kept rather than deleted: removing a deferred-but-designed parameter is
1629+
* a separate call from de-positionalising this signature. */
1630+
images?: readonly AiContentBlock[] | undefined;
1631+
/** #8961: true when the PR description exceeded the prompt window — arms the evidence-absence demotion. */
1632+
bodyTruncated?: boolean | undefined;
1633+
/** #8833: true when the PR changes at least one test path — arms the test-absence demotion. */
1634+
prHasTestEvidence?: boolean | undefined;
1635+
};
1636+
1637+
/** {@link runWorkersOpinion}'s own accidental tail, on top of the shared context above. `env` through
1638+
* `maxTokens` stay positional — those are the genuine arguments. */
1639+
type WorkersOpinionOptions = ReviewerDemotionContext & {
1640+
diagnostics?: AiReviewDiagnostic[] | undefined;
1641+
systemAppend?: string | undefined;
1642+
correlation?: AiRunCorrelation | undefined;
1643+
};
1644+
16131645
async function runWorkersOpinion(
16141646
env: Env,
16151647
primary: string,
16161648
fallback: string,
16171649
system: string,
16181650
user: string,
16191651
maxTokens: number,
1620-
diagnostics: AiReviewDiagnostic[] = [],
1621-
systemAppend = "",
1622-
correlation?: AiRunCorrelation,
1623-
// Pixel-diff-confirmed screenshot(s) for a visual-vision pass (#4111). Absent for every existing caller —
1624-
// wiring a real caller (source images, invoke with them) is a deliberately deferred follow-up; see
1625-
// review/visual/visual-findings.ts.
1626-
images?: readonly AiContentBlock[] | undefined,
1627-
// #8961: true when the PR description exceeded the prompt window — arms the evidence-absence demotion.
1628-
bodyTruncated = false,
1629-
// #8833: true when the PR changes at least one test path — arms the test-absence demotion.
1630-
prHasTestEvidence = false,
1652+
options: WorkersOpinionOptions = {},
16311653
): Promise<ReviewerOpinionOutcome> {
1654+
// Destructured with the identical defaults the positional signature carried, so every body reference below
1655+
// is unchanged and this stays a pure de-positionalisation.
1656+
const { diagnostics = [], systemAppend = "", correlation, images, bodyTruncated = false, prHasTestEvidence = false } = options;
16321657
const ai = env.AI as unknown as AiRunner | undefined;
16331658
if (!ai || typeof ai.run !== "function") return { review: null };
16341659
// Route through Cloudflare AI Gateway when configured (caching, rate-limiting, logging, fallback). The
@@ -2174,15 +2199,16 @@ export async function regeneratePublicSafeSummary(
21742199
return toPublicSafeBySentence(trimmed, options);
21752200
}
21762201

2202+
/** The BYOK half of the pair {@link ReviewerDemotionContext} documents. It now shares that type with
2203+
* `runWorkersOpinion` rather than restating the same three parameters positionally, so the two cannot drift. */
21772204
async function runProviderReview(
21782205
providerKey: AiReviewProviderKey,
21792206
system: string,
21802207
user: string,
21812208
maxTokens: number,
2182-
images?: readonly AiContentBlock[] | undefined,
2183-
bodyTruncated = false, // #8961: arms the evidence-absence demotion, same contract as runWorkersOpinion
2184-
prHasTestEvidence = false, // #8833: arms the test-absence demotion, same contract as runWorkersOpinion
2209+
options: ReviewerDemotionContext = {},
21852210
): Promise<ProviderReviewOutcome> {
2211+
const { images, bodyTruncated = false, prHasTestEvidence = false } = options;
21862212
const { text, usage, failure } = await callAiProvider(
21872213
providerKey,
21882214
system,
@@ -3217,15 +3243,7 @@ export async function runLoopOverAiReview(
32173243
anthropicModel: input.reviewKnobs?.model ?? input.anthropicModel ?? undefined,
32183244
};
32193245
if (input.providerKey) {
3220-
const outcome = await runProviderReview(
3221-
input.providerKey,
3222-
system,
3223-
user,
3224-
maxTokens,
3225-
undefined,
3226-
bodyTruncated,
3227-
prHasTestEvidence,
3228-
);
3246+
const outcome = await runProviderReview(input.providerKey, system, user, maxTokens, { bodyTruncated, prHasTestEvidence });
32293247
advisoryReview = outcome.review;
32303248
byokFailure = outcome.failure;
32313249
if (outcome.fallbackNote) fallbackNotes.push(outcome.fallbackNote);
@@ -3238,12 +3256,7 @@ export async function runLoopOverAiReview(
32383256
system,
32393257
user,
32403258
maxTokens,
3241-
reviewDiagnostics,
3242-
repoInstructionsSystemAppend,
3243-
aiRunCorrelation,
3244-
undefined,
3245-
bodyTruncated,
3246-
prHasTestEvidence,
3259+
{ diagnostics: reviewDiagnostics, systemAppend: repoInstructionsSystemAppend, correlation: aiRunCorrelation, bodyTruncated, prHasTestEvidence },
32473260
);
32483261
advisoryReview = outcome.review;
32493262
if (outcome.fallbackNote) fallbackNotes.push(outcome.fallbackNote);
@@ -3269,12 +3282,7 @@ export async function runLoopOverAiReview(
32693282
system,
32703283
user,
32713284
maxTokens,
3272-
reviewDiagnostics,
3273-
repoInstructionsSystemAppend,
3274-
aiRunCorrelation,
3275-
undefined,
3276-
bodyTruncated,
3277-
prHasTestEvidence,
3285+
{ diagnostics: reviewDiagnostics, systemAppend: repoInstructionsSystemAppend, correlation: aiRunCorrelation, bodyTruncated, prHasTestEvidence },
32783286
)
32793287
: Promise.resolve<ReviewerOpinionOutcome>({ review: advisoryReview }),
32803288
runWorkersOpinion(
@@ -3284,12 +3292,7 @@ export async function runLoopOverAiReview(
32843292
system,
32853293
user,
32863294
maxTokens,
3287-
reviewDiagnostics,
3288-
repoInstructionsSystemAppend,
3289-
aiRunCorrelation,
3290-
undefined,
3291-
bodyTruncated,
3292-
prHasTestEvidence,
3295+
{ diagnostics: reviewDiagnostics, systemAppend: repoInstructionsSystemAppend, correlation: aiRunCorrelation, bodyTruncated, prHasTestEvidence },
32933296
),
32943297
]);
32953298
if (a.fallbackNote) fallbackNotes.push(a.fallbackNote);
@@ -3353,12 +3356,7 @@ export async function runLoopOverAiReview(
33533356
system,
33543357
user,
33553358
maxTokens,
3356-
reviewDiagnostics,
3357-
repoInstructionsSystemAppend,
3358-
aiRunCorrelation,
3359-
undefined,
3360-
bodyTruncated,
3361-
prHasTestEvidence,
3359+
{ diagnostics: reviewDiagnostics, systemAppend: repoInstructionsSystemAppend, correlation: aiRunCorrelation, bodyTruncated, prHasTestEvidence },
33623360
)
33633361
: ({ review: advisoryReview } as ReviewerOpinionOutcome);
33643362
if (a.fallbackNote) fallbackNotes.push(a.fallbackNote);
@@ -3406,12 +3404,7 @@ export async function runLoopOverAiReview(
34063404
system + rotatedExemplarSuffix(rotationSeed, runIndex),
34073405
user,
34083406
maxTokens,
3409-
reviewDiagnostics,
3410-
repoInstructionsSystemAppend,
3411-
aiRunCorrelation,
3412-
undefined,
3413-
bodyTruncated,
3414-
prHasTestEvidence,
3407+
{ diagnostics: reviewDiagnostics, systemAppend: repoInstructionsSystemAppend, correlation: aiRunCorrelation, bodyTruncated, prHasTestEvidence },
34153408
);
34163409
// No fallbackNote handling: runWorkersOpinion never produces one (that field is the BYOK provider
34173410
// path's). A failed extra simply contributes no stance -- recorded below as spend, never fabricated.

test/unit/ai-review.test.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3571,7 +3571,7 @@ describe("pure helpers", () => {
35713571
response: '{"assessment":"looks off","blockers":["No before/after screenshots provided for this visual change","Null deref in src/a.ts"],"nits":[],"suggestions":[]}',
35723572
}));
35733573
const env = createTestEnv({ AI: { run } as unknown as Ai });
3574-
const truncated = await runWorkersOpinion(env, "@cf/x/model", "@cf/x/model", "sys", "user", 256, [], "", undefined, undefined, true);
3574+
const truncated = await runWorkersOpinion(env, "@cf/x/model", "@cf/x/model", "sys", "user", 256, { bodyTruncated: true });
35753575
expect(truncated.review?.blockers).toEqual(["Null deref in src/a.ts"]);
35763576
expect(truncated.review?.nits.some((nit) => nit.includes("absence of evidence inside the truncated window"))).toBe(true);
35773577
expect(warn.mock.calls.some(([line]) => String(line).includes("ai_review_evidence_absence_demoted"))).toBe(true);
@@ -3590,7 +3590,7 @@ describe("pure helpers", () => {
35903590
});
35913591
const env = createTestEnv({ AI: { run } as unknown as Ai });
35923592
const images = [{ type: "image" as const, data: "QUJD", mimeType: "image/png" }];
3593-
await runWorkersOpinion(env, "m", "m", "sys", "user text", 256, [], "", undefined, images);
3593+
await runWorkersOpinion(env, "m", "m", "sys", "user text", 256, { images });
35943594
expect(seenContents[0]).toEqual([
35953595
{ type: "text", text: "user text" },
35963596
{ type: "image", data: "QUJD", mimeType: "image/png" },
@@ -3608,7 +3608,7 @@ describe("pure helpers", () => {
36083608
});
36093609
const env = createTestEnv({ AI: { run } as unknown as Ai });
36103610
const diagnostics: Array<{ status: string; model: string }> = [];
3611-
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
3611+
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
36123612
expect(parsed.review?.assessment).toContain("reasonable");
36133613
expect(primaryAttempts).toBe(1); // NOT 3 -- the timeout short-circuits further retries of this model.
36143614
expect(run).toHaveBeenCalledTimes(2); // 1 primary (timed out) + 1 fallback (succeeded on its first try).
@@ -3633,7 +3633,7 @@ describe("pure helpers", () => {
36333633
});
36343634
const env = createTestEnv({ AI: { run } as unknown as Ai });
36353635
const diagnostics: Array<{ status: string; model: string }> = [];
3636-
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
3636+
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
36373637
expect(parsed.review?.assessment).toContain("reasonable");
36383638
expect(primaryAttempts).toBe(1); // NOT 3 -- the stall short-circuits further retries of this model.
36393639
expect(run).toHaveBeenCalledTimes(2); // 1 primary (stalled) + 1 fallback (succeeded on its first try).
@@ -3650,7 +3650,7 @@ describe("pure helpers", () => {
36503650
});
36513651
const env = createTestEnv({ AI: { run } as unknown as Ai });
36523652
const diagnostics: Array<{ status: string; model: string }> = [];
3653-
await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
3653+
await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
36543654
expect(primaryAttempts).toBe(3);
36553655
});
36563656

@@ -3665,7 +3665,7 @@ describe("pure helpers", () => {
36653665
});
36663666
const env = createTestEnv({ AI: { run } as unknown as Ai });
36673667
const diagnostics: Array<{ status: string; model: string }> = [];
3668-
await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
3668+
await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
36693669
expect(primaryAttempts).toBe(3);
36703670
});
36713671

@@ -3678,7 +3678,7 @@ describe("pure helpers", () => {
36783678
});
36793679
const env = createTestEnv({ AI: { run } as unknown as Ai });
36803680
const diagnostics: Array<{ status: string; model: string }> = [];
3681-
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
3681+
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
36823682
expect(parsed.review?.assessment).toContain("reasonable");
36833683
expect(primaryAttempts).toBe(1); // NOT 3 -- the 429 short-circuits further retries of this model.
36843684
expect(run).toHaveBeenCalledTimes(2); // 1 primary (rate-limited) + 1 fallback (succeeded on its first try).
@@ -3693,7 +3693,7 @@ describe("pure helpers", () => {
36933693
});
36943694
const env = createTestEnv({ AI: { run } as unknown as Ai });
36953695
const diagnostics: Array<{ status: string; model: string }> = [];
3696-
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
3696+
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
36973697
expect(parsed.review?.assessment).toContain("reasonable");
36983698
expect(primaryAttempts).toBe(1); // NOT 3 -- a structural config error is deterministic, so retrying is pointless.
36993699
expect(run).toHaveBeenCalledTimes(2); // 1 primary (structural failure) + 1 fallback (succeeded on its first try).
@@ -3708,7 +3708,7 @@ describe("pure helpers", () => {
37083708
});
37093709
const env = createTestEnv({ AI: { run } as unknown as Ai });
37103710
const diagnostics: Array<{ status: string; model: string }> = [];
3711-
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
3711+
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
37123712
expect(parsed.review?.assessment).toContain("reasonable");
37133713
expect(primaryAttempts).toBe(1); // NOT 3 -- the model's own deliberate bail will not change on a same-model retry.
37143714
expect(run).toHaveBeenCalledTimes(2); // 1 primary (incoherent-diff bail) + 1 fallback (succeeded on its first try).
@@ -3736,7 +3736,7 @@ describe("pure helpers", () => {
37363736
});
37373737
const env = createTestEnv({ AI: { run } as unknown as Ai });
37383738
const diagnostics: Array<{ status: string; model: string; attempt: number }> = [];
3739-
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
3739+
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
37403740
expect(parsed.review?.assessment).toBe("The change looks reasonable and focused.");
37413741
expect(attempts).toBe(2); // 1 missing-assessment attempt, then a real one -- same model, no fallback needed.
37423742
expect(diagnostics[0]).toMatchObject({ model: "primary", attempt: 0, status: "missing_assessment" });
@@ -3781,7 +3781,7 @@ describe("pure helpers", () => {
37813781
}));
37823782
const env = createTestEnv({ AI: { run } as unknown as Ai });
37833783
const diagnostics: Array<{ status: string }> = [];
3784-
const parsed = await runWorkersOpinion(env, "m", "m", "sys", "user", 256, diagnostics as never);
3784+
const parsed = await runWorkersOpinion(env, "m", "m", "sys", "user", 256, { diagnostics: diagnostics as never });
37853785
expect(parsed.review).toBeNull(); // INCOHERENT_DIFF_ASSESSMENT parses to null (see parseModelReview)
37863786
expect(diagnostics.some((d) => d.status === "missing_assessment")).toBe(false);
37873787
});
@@ -3855,15 +3855,15 @@ describe("pure helpers", () => {
38553855
return { response: reviewJson() };
38563856
});
38573857
const env = createTestEnv({ AI: { run } as unknown as Ai });
3858-
await runWorkersOpinion(env, "@cf/x/model", "@cf/x/model", "sys", "user", 256, [], "", {
3858+
await runWorkersOpinion(env, "@cf/x/model", "@cf/x/model", "sys", "user", 256, { correlation: {
38593859
jobId: "job-1",
38603860
repoFullName: "acme/widgets",
38613861
pullNumber: 7,
38623862
claudeModel: "claude-haiku-4-5",
38633863
claudeEffort: "low",
38643864
codexModel: "gpt-5.4-mini",
38653865
codexEffort: "high",
3866-
});
3866+
} });
38673867
expect(seenOptions).toMatchObject({
38683868
jobId: "job-1",
38693869
repoFullName: "acme/widgets",
@@ -3973,7 +3973,7 @@ describe("pure helpers", () => {
39733973
const run = vi.fn(async () => ({ response: longResponse }));
39743974
const env = createTestEnv({ AI: { run } as unknown as Ai });
39753975
const diagnostics: AiReviewDiagnostic[] = [];
3976-
await runWorkersOpinion(env, "primary-model", "primary-model", "sys", "user", 256, diagnostics);
3976+
await runWorkersOpinion(env, "primary-model", "primary-model", "sys", "user", 256, { diagnostics });
39773977
// reviewDiagnostics flows into result/Sentry context that must never carry raw provider text (see the
39783978
// "withholds unsafe provider and reviewer fallback text" test) -- the snippet only ever reaches the log.
39793979
expect(diagnostics[0]).not.toHaveProperty("responseSnippet");
@@ -5593,7 +5593,7 @@ describe("reviewer vote attribution (#9478)", () => {
55935593
});
55945594
const env = createTestEnv({ AI: { run } as unknown as Ai });
55955595
const diagnostics: Array<{ status: string; model: string }> = [];
5596-
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
5596+
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
55975597

55985598
expect(parsed.review).not.toBeNull();
55995599
expect(parsed.producedBy).toBe("fallback"); // NOT "primary"
@@ -5603,7 +5603,7 @@ describe("reviewer vote attribution (#9478)", () => {
56035603
const run = vi.fn(async () => ({ response: reviewJson() }));
56045604
const env = createTestEnv({ AI: { run } as unknown as Ai });
56055605
const diagnostics: Array<{ status: string; model: string }> = [];
5606-
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, diagnostics as never);
5606+
const parsed = await runWorkersOpinion(env, "primary", "fallback", "sys", "user", 256, { diagnostics: diagnostics as never });
56075607

56085608
expect(parsed.producedBy).toBe("primary");
56095609
});

0 commit comments

Comments
 (0)