|
| 1 | +import { readFileSync } from "node:fs"; |
| 2 | +import { describe, expect, it } from "vitest"; |
| 3 | +import { advisorySpendStopReason } from "../../src/queue/advisory-spend-gate"; |
| 4 | + |
| 5 | +// #9541 (deliverable 2) / #9491: three features each make a paid LLM call per PR head, each grew its own |
| 6 | +// hand-written guard line, and they drifted — the linked-issue advisory was the one member of the family with |
| 7 | +// NO per-PR commit cap, so a long-lived PR kept paying on every push after its siblings had stopped. Nobody |
| 8 | +// could have caught that by reading one function: the guards lived in three files and the incomplete one |
| 9 | +// looked complete on its own. |
| 10 | +describe("advisorySpendStopReason (#9541)", () => { |
| 11 | + const proceed = { mode: "live" as const, headSha: "abc123", commitThresholdReached: false }; |
| 12 | + |
| 13 | + it("returns null when every universal precondition passes", () => { |
| 14 | + expect(advisorySpendStopReason(proceed)).toBeNull(); |
| 15 | + }); |
| 16 | + |
| 17 | + it("REGRESSION: stops on the per-PR commit cap — the stop the linked-issue advisory shipped without", () => { |
| 18 | + expect(advisorySpendStopReason({ ...proceed, commitThresholdReached: true })).toBe("commit_threshold_reached"); |
| 19 | + }); |
| 20 | + |
| 21 | + it("stops a paused repo, independent of the feature's own mode setting", () => { |
| 22 | + expect(advisorySpendStopReason({ ...proceed, mode: "paused" })).toBe("paused"); |
| 23 | + }); |
| 24 | + |
| 25 | + it("stops when there is no head SHA to attribute the spend to", () => { |
| 26 | + for (const headSha of [null, undefined, ""]) { |
| 27 | + expect(advisorySpendStopReason({ ...proceed, headSha }), String(headSha)).toBe("no_head_sha"); |
| 28 | + } |
| 29 | + }); |
| 30 | + |
| 31 | + it("INVARIANT: precedence is paused → no_head_sha → commit cap, matching the hand-written guards it replaces", () => { |
| 32 | + // Order is load-bearing, not cosmetic: adopting this function had to be behaviour-preserving, and the |
| 33 | + // reason a given PR reports decides which audit event it emits. A re-ordering would silently relabel |
| 34 | + // real production history. |
| 35 | + expect(advisorySpendStopReason({ mode: "paused", headSha: null, commitThresholdReached: true })).toBe("paused"); |
| 36 | + expect(advisorySpendStopReason({ mode: "live", headSha: null, commitThresholdReached: true })).toBe("no_head_sha"); |
| 37 | + }); |
| 38 | + |
| 39 | + it("is PURE — same input, same answer, and the input is never mutated", () => { |
| 40 | + const input = { ...proceed, commitThresholdReached: true }; |
| 41 | + const snapshot = JSON.stringify(input); |
| 42 | + expect(advisorySpendStopReason(input)).toBe(advisorySpendStopReason(input)); |
| 43 | + expect(JSON.stringify(input)).toBe(snapshot); |
| 44 | + }); |
| 45 | + |
| 46 | + // The point of the module is that a FOURTH advisory cannot be written without these stops. That only holds |
| 47 | + // while the existing three actually route through it, so the adoption itself is pinned at the source — |
| 48 | + // the same producer-drift-guard convention db-parsers.test.ts uses for STALE_RECHECK_DENIAL_DETAIL_PATTERN. |
| 49 | + describe("adoption is real, not aspirational", () => { |
| 50 | + it.each([ |
| 51 | + ["src/queue/slop-detection.ts", "runAiSlopForAdvisory"], |
| 52 | + ["src/queue/processors.ts", "runLinkedIssueSatisfactionForAdvisory"], |
| 53 | + ])("%s routes its paid advisory through the shared gate", (file) => { |
| 54 | + expect(readFileSync(file, "utf8")).toContain("advisorySpendStopReason("); |
| 55 | + }); |
| 56 | + |
| 57 | + it("neither adopter re-implements the commit-cap stop inline, which is what allowed the two to disagree", () => { |
| 58 | + for (const file of ["src/queue/slop-detection.ts", "src/queue/processors.ts"]) { |
| 59 | + const source = readFileSync(file, "utf8"); |
| 60 | + expect(source, file).not.toContain("if (args.commitThresholdReached) return"); |
| 61 | + } |
| 62 | + }); |
| 63 | + |
| 64 | + it("INVARIANT: the author rule is deliberately NOT unified — ai_review reviews some unconfirmed authors", () => { |
| 65 | + // Folding `confirmedContributor` in would either narrow ai_review's audience or widen the other two's. |
| 66 | + // A 'shared' rule that is wrong for one caller is how the next #9491 gets written, so the module must |
| 67 | + // keep saying so and must not grow the field. |
| 68 | + const gate = readFileSync("src/queue/advisory-spend-gate.ts", "utf8"); |
| 69 | + expect(gate).not.toContain("confirmedContributor:"); |
| 70 | + expect(gate).toContain("resolveAiReviewableAuthor"); |
| 71 | + }); |
| 72 | + }); |
| 73 | +}); |
0 commit comments