CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
repair-flywheel-wiring.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| bc519ae | 1 | /** |
| 2 | * Repair-flywheel ↔ ci-autofix wiring tests (BUILD_BIBLE §7 finding 1). | |
| 3 | * | |
| 4 | * Drives `resolveAutofix` / `recordAutofixOutcome` through their | |
| 5 | * dependency-injection seam (`CiAutofixDeps`) so no DB, git, or Anthropic | |
| 6 | * call is touched — same pattern as the auto-merge fast-lane suite. | |
| 7 | * | |
| 8 | * Covers the closed-loop contract: | |
| 9 | * - Tier-0 cache hit serves the cached patch WITHOUT an AI call and | |
| 10 | * records a 'cached'-tier pending row with cache lineage + the patch | |
| 11 | * - cache miss / low success rate / missing patch fall through to AI | |
| 12 | * - outcomes settle via updateOutcome on apply success AND failure | |
| 13 | * - flywheel errors (lookup, record, settle) never break the autofix | |
| 14 | * path — everything fails open to the AI tier | |
| 15 | * - isAiAvailable() gating: no key + cache miss → no fix, AI never called; | |
| 16 | * no key + cache hit → cached fix still served (graceful degradation) | |
| 17 | */ | |
| 18 | ||
| 19 | import { describe, it, expect } from "bun:test"; | |
| 20 | import { readFileSync } from "node:fs"; | |
| 21 | import { join } from "node:path"; | |
| 22 | ||
| 23 | import { | |
| 24 | resolveAutofix, | |
| 25 | recordAutofixOutcome, | |
| 26 | extractFlywheelEntryId, | |
| 27 | CACHE_MIN_SUCCESS_RATE, | |
| 28 | FLYWHEEL_MARKER_PREFIX, | |
| 29 | type AutofixPlan, | |
| 30 | type CiAutofixDeps, | |
| 31 | type ClaudeAutofixResponse, | |
| 32 | } from "../lib/ci-autofix"; | |
| 33 | import type { CachedRepair, RecordRepairInput } from "../lib/repair-flywheel"; | |
| 34 | ||
| 35 | // --------------------------------------------------------------------------- | |
| 36 | // Fixtures | |
| 37 | // --------------------------------------------------------------------------- | |
| 38 | ||
| 39 | const CACHED_PATCH = `--- a/src/foo.ts | |
| 40 | +++ b/src/foo.ts | |
| 41 | @@ -1,3 +1,3 @@ | |
| 42 | -const x: string = 42; | |
| 43 | +const x = 42; | |
| 44 | `; | |
| 45 | ||
| 46 | const AI_FIX: ClaudeAutofixResponse = { | |
| 47 | patch: "--- a/src/bar.ts\n+++ b/src/bar.ts\n@@ -1 +1 @@\n-old\n+new\n", | |
| 48 | explanation: "Fixes the type error in bar.ts.", | |
| 49 | confidence: "high", | |
| 50 | affectedFiles: ["src/bar.ts"], | |
| 51 | }; | |
| 52 | ||
| 53 | const FAILURE_TEXT = | |
| 54 | "error TS2322: Type 'number' is not assignable to type 'string' at src/foo.ts:1:7"; | |
| 55 | ||
| 56 | const PATTERN_ID = "11111111-2222-3333-4444-555555555555"; | |
| 57 | const NEW_ENTRY_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; | |
| 58 | ||
| 59 | function makeCached(overrides: Partial<CachedRepair> = {}): CachedRepair { | |
| 60 | return { | |
| 61 | id: PATTERN_ID, | |
| 62 | patchSummary: "Drop the bogus string annotation on x.", | |
| 63 | patch: CACHED_PATCH, | |
| 64 | filesChanged: ["src/foo.ts"], | |
| 65 | commitSha: null, | |
| 66 | hitCount: 3, | |
| 67 | successRate: 1, | |
| 68 | classification: null, | |
| 69 | appliedCount: 4, | |
| 70 | ...overrides, | |
| 71 | }; | |
| 72 | } | |
| 73 | ||
| 74 | interface Harness { | |
| 75 | deps: CiAutofixDeps; | |
| 76 | aiCalls: number; | |
| 77 | recorded: RecordRepairInput[]; | |
| 78 | outcomes: Array<{ id: string; outcome: string }>; | |
| 79 | generateAiFix: () => Promise<ClaudeAutofixResponse | null>; | |
| 80 | } | |
| 81 | ||
| 82 | function makeHarness(opts: { | |
| 83 | cached?: CachedRepair | null; | |
| 84 | aiFix?: ClaudeAutofixResponse | null; | |
| 85 | aiAvailable?: boolean; | |
| 86 | // Throw-injection hooks (default false everywhere) | |
| 87 | lookupThrows?: boolean; | |
| 88 | recordThrows?: boolean; | |
| 89 | updateOutcomeThrows?: boolean; | |
| 90 | } = {}): Harness { | |
| 91 | const recorded: RecordRepairInput[] = []; | |
| 92 | const outcomes: Array<{ id: string; outcome: string }> = []; | |
| 93 | ||
| 94 | const harness: Harness = { | |
| 95 | aiCalls: 0, | |
| 96 | recorded, | |
| 97 | outcomes, | |
| 98 | generateAiFix: async () => { | |
| 99 | harness.aiCalls += 1; | |
| 100 | return opts.aiFix === undefined ? AI_FIX : opts.aiFix; | |
| 101 | }, | |
| 102 | deps: { | |
| 103 | findCachedRepair: async () => { | |
| 104 | if (opts.lookupThrows) throw new Error("flywheel db down"); | |
| 105 | return opts.cached ?? null; | |
| 106 | }, | |
| 107 | recordRepair: async (input) => { | |
| 108 | if (opts.recordThrows) throw new Error("insert failed"); | |
| 109 | recorded.push(input); | |
| 110 | return NEW_ENTRY_ID; | |
| 111 | }, | |
| 112 | updateOutcome: async (id, outcome) => { | |
| 113 | if (opts.updateOutcomeThrows) throw new Error("update failed"); | |
| 114 | outcomes.push({ id, outcome }); | |
| 115 | }, | |
| 116 | aiAvailable: () => opts.aiAvailable ?? true, | |
| 117 | }, | |
| 118 | }; | |
| 119 | return harness; | |
| 120 | } | |
| 121 | ||
| 122 | function run(h: Harness): Promise<AutofixPlan | null> { | |
| 123 | return resolveAutofix({ | |
| 124 | repositoryId: "repo-1", | |
| 125 | failureText: FAILURE_TEXT, | |
| 126 | generateAiFix: h.generateAiFix, | |
| 127 | deps: h.deps, | |
| 128 | }); | |
| 129 | } | |
| 130 | ||
| 131 | // --------------------------------------------------------------------------- | |
| 132 | // Tier 0: cache hit | |
| 133 | // --------------------------------------------------------------------------- | |
| 134 | ||
| 135 | describe("resolveAutofix — cache hit (Tier 0)", () => { | |
| 136 | it("serves the cached patch without calling the AI", async () => { | |
| 137 | const h = makeHarness({ cached: makeCached() }); | |
| 138 | const plan = await run(h); | |
| 139 | ||
| 140 | expect(plan).not.toBeNull(); | |
| 141 | expect(plan!.source).toBe("cache"); | |
| 142 | expect(plan!.fix.patch).toBe(CACHED_PATCH); | |
| 143 | expect(plan!.cachedPatternId).toBe(PATTERN_ID); | |
| 144 | expect(h.aiCalls).toBe(0); | |
| 145 | }); | |
| 146 | ||
| 147 | it("records a 'cached'-tier pending row with cache lineage + the patch", async () => { | |
| 148 | const h = makeHarness({ cached: makeCached() }); | |
| 149 | const plan = await run(h); | |
| 150 | ||
| 151 | expect(h.recorded.length).toBe(1); | |
| 152 | expect(h.recorded[0]!.tier).toBe("cached"); | |
| 153 | expect(h.recorded[0]!.parentPatternId).toBe(PATTERN_ID); | |
| 154 | expect(h.recorded[0]!.repositoryId).toBe("repo-1"); | |
| 155 | expect(h.recorded[0]!.failureText).toBe(FAILURE_TEXT); | |
| 156 | // Carried forward so the new row is itself replayable once it settles | |
| 157 | expect(h.recorded[0]!.patch).toBe(CACHED_PATCH); | |
| 158 | // outcome defaults to 'pending' inside recordRepair — must not be forced | |
| 159 | expect(h.recorded[0]!.outcome).toBeUndefined(); | |
| 160 | expect(plan!.flywheelEntryId).toBe(NEW_ENTRY_ID); | |
| 161 | }); | |
| 162 | ||
| 163 | it("maps success rate to confidence (>=0.9 high, else medium)", async () => { | |
| 164 | const high = await run(makeHarness({ cached: makeCached({ successRate: 0.95 }) })); | |
| 165 | expect(high!.fix.confidence).toBe("high"); | |
| 166 | ||
| 167 | const med = await run(makeHarness({ cached: makeCached({ successRate: 0.6 }) })); | |
| 168 | expect(med!.fix.confidence).toBe("medium"); | |
| 169 | }); | |
| 170 | ||
| 171 | it("works even when the AI is unavailable (graceful degradation)", async () => { | |
| 172 | const h = makeHarness({ cached: makeCached(), aiAvailable: false }); | |
| 173 | const plan = await run(h); | |
| 174 | ||
| 175 | expect(plan).not.toBeNull(); | |
| 176 | expect(plan!.source).toBe("cache"); | |
| 177 | expect(h.aiCalls).toBe(0); | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | // --------------------------------------------------------------------------- | |
| 182 | // Cache miss / unusable hit → fall through to the AI tier | |
| 183 | // --------------------------------------------------------------------------- | |
| 184 | ||
| 185 | describe("resolveAutofix — fall-through to AI (Tier 2)", () => { | |
| 186 | it("cache miss falls through to the AI and records an 'ai-sonnet' row", async () => { | |
| 187 | const h = makeHarness({ cached: null }); | |
| 188 | const plan = await run(h); | |
| 189 | ||
| 190 | expect(h.aiCalls).toBe(1); | |
| 191 | expect(plan!.source).toBe("ai"); | |
| 192 | expect(plan!.fix).toEqual(AI_FIX); | |
| 193 | expect(plan!.cachedPatternId).toBeNull(); | |
| 194 | expect(h.recorded.length).toBe(1); | |
| 195 | expect(h.recorded[0]!.tier).toBe("ai-sonnet"); | |
| 196 | // The AI patch is stored so the entry is replayable once it succeeds | |
| 197 | expect(h.recorded[0]!.patch).toBe(AI_FIX.patch); | |
| 198 | expect(plan!.flywheelEntryId).toBe(NEW_ENTRY_ID); | |
| 199 | }); | |
| 200 | ||
| 201 | it("hit below CACHE_MIN_SUCCESS_RATE is not replayed", async () => { | |
| 202 | const h = makeHarness({ | |
| 203 | cached: makeCached({ successRate: CACHE_MIN_SUCCESS_RATE - 0.01 }), | |
| 204 | }); | |
| 205 | const plan = await run(h); | |
| 206 | ||
| 207 | expect(h.aiCalls).toBe(1); | |
| 208 | expect(plan!.source).toBe("ai"); | |
| 209 | }); | |
| 210 | ||
| 211 | it("hit with no stored patch (pre-0105 row) is not replayed", async () => { | |
| 212 | const h = makeHarness({ cached: makeCached({ patch: null }) }); | |
| 213 | const plan = await run(h); | |
| 214 | ||
| 215 | expect(h.aiCalls).toBe(1); | |
| 216 | expect(plan!.source).toBe("ai"); | |
| 217 | }); | |
| 218 | ||
| 219 | it("returns null when the AI declines (no patch)", async () => { | |
| 220 | const h = makeHarness({ cached: null, aiFix: null }); | |
| 221 | expect(await run(h)).toBeNull(); | |
| 222 | expect(h.recorded.length).toBe(0); | |
| 223 | }); | |
| 224 | ||
| 225 | it("returns null on a low-confidence AI fix without recording it", async () => { | |
| 226 | const h = makeHarness({ | |
| 227 | cached: null, | |
| 228 | aiFix: { ...AI_FIX, confidence: "low" }, | |
| 229 | }); | |
| 230 | expect(await run(h)).toBeNull(); | |
| 231 | expect(h.recorded.length).toBe(0); | |
| 232 | }); | |
| 233 | ||
| 234 | it("returns null (and never calls the AI) when no key + cache miss", async () => { | |
| 235 | const h = makeHarness({ cached: null, aiAvailable: false }); | |
| 236 | expect(await run(h)).toBeNull(); | |
| 237 | expect(h.aiCalls).toBe(0); | |
| 238 | }); | |
| 239 | }); | |
| 240 | ||
| 241 | // --------------------------------------------------------------------------- | |
| 242 | // Fail-open: flywheel errors never break the autofix path | |
| 243 | // --------------------------------------------------------------------------- | |
| 244 | ||
| 245 | describe("resolveAutofix — flywheel errors fail open", () => { | |
| 246 | it("a throwing findCachedRepair degrades to the AI tier", async () => { | |
| 247 | const h = makeHarness({ lookupThrows: true }); | |
| 248 | const plan = await run(h); | |
| 249 | ||
| 250 | expect(plan!.source).toBe("ai"); | |
| 251 | expect(h.aiCalls).toBe(1); | |
| 252 | }); | |
| 253 | ||
| 254 | it("a throwing recordRepair still serves the cached fix (entry id null)", async () => { | |
| 255 | const h = makeHarness({ cached: makeCached(), recordThrows: true }); | |
| 256 | const plan = await run(h); | |
| 257 | ||
| 258 | expect(plan!.source).toBe("cache"); | |
| 259 | expect(plan!.fix.patch).toBe(CACHED_PATCH); | |
| 260 | expect(plan!.flywheelEntryId).toBeNull(); | |
| 261 | expect(h.aiCalls).toBe(0); | |
| 262 | }); | |
| 263 | ||
| 264 | it("a throwing recordRepair still serves the AI fix (entry id null)", async () => { | |
| 265 | const h = makeHarness({ cached: null, recordThrows: true }); | |
| 266 | const plan = await run(h); | |
| 267 | ||
| 268 | expect(plan!.source).toBe("ai"); | |
| 269 | expect(plan!.fix).toEqual(AI_FIX); | |
| 270 | expect(plan!.flywheelEntryId).toBeNull(); | |
| 271 | }); | |
| 272 | }); | |
| 273 | ||
| 274 | // --------------------------------------------------------------------------- | |
| 275 | // Outcome settling (recordAutofixOutcome ← applyAutofix) | |
| 276 | // --------------------------------------------------------------------------- | |
| 277 | ||
| 278 | describe("recordAutofixOutcome", () => { | |
| 279 | const bodyWithMarker = `<!-- gluecron:ci-autofix:v1 -->\n${FLYWHEEL_MARKER_PREFIX}${NEW_ENTRY_ID} -->\n\n## 🔧 AI Auto-Fix`; | |
| 280 | ||
| 281 | it("settles 'success' for the entry referenced by the comment", async () => { | |
| 282 | const h = makeHarness(); | |
| 283 | await recordAutofixOutcome(bodyWithMarker, "success", h.deps); | |
| 284 | expect(h.outcomes).toEqual([{ id: NEW_ENTRY_ID, outcome: "success" }]); | |
| 285 | }); | |
| 286 | ||
| 287 | it("settles 'failed' when the apply blows up", async () => { | |
| 288 | const h = makeHarness(); | |
| 289 | await recordAutofixOutcome(bodyWithMarker, "failed", h.deps); | |
| 290 | expect(h.outcomes).toEqual([{ id: NEW_ENTRY_ID, outcome: "failed" }]); | |
| 291 | }); | |
| 292 | ||
| 293 | it("is a no-op when the comment carries no flywheel marker", async () => { | |
| 294 | const h = makeHarness(); | |
| 295 | await recordAutofixOutcome("<!-- gluecron:ci-autofix:v1 -->", "success", h.deps); | |
| 296 | expect(h.outcomes.length).toBe(0); | |
| 297 | }); | |
| 298 | ||
| 299 | it("never throws even when updateOutcome throws", async () => { | |
| 300 | const h = makeHarness({ updateOutcomeThrows: true }); | |
| 301 | await expect( | |
| 302 | recordAutofixOutcome(bodyWithMarker, "success", h.deps) | |
| 303 | ).resolves.toBeUndefined(); | |
| 304 | }); | |
| 305 | }); | |
| 306 | ||
| 307 | describe("extractFlywheelEntryId", () => { | |
| 308 | it("round-trips an id through the comment marker", () => { | |
| 309 | const body = `hello\n${FLYWHEEL_MARKER_PREFIX}${PATTERN_ID} -->\nworld`; | |
| 310 | expect(extractFlywheelEntryId(body)).toBe(PATTERN_ID); | |
| 311 | }); | |
| 312 | ||
| 313 | it("returns null when absent", () => { | |
| 314 | expect(extractFlywheelEntryId("no markers here")).toBeNull(); | |
| 315 | }); | |
| 316 | }); | |
| 317 | ||
| 318 | // --------------------------------------------------------------------------- | |
| 319 | // Wiring regression guards — the seam must actually be plumbed into the | |
| 320 | // live paths (same readFileSync technique as the fast-lane suite). | |
| 321 | // --------------------------------------------------------------------------- | |
| 322 | ||
| 323 | describe("ci-autofix source wiring", () => { | |
| 324 | const src = readFileSync( | |
| 325 | join(import.meta.dir, "../lib/ci-autofix.ts"), | |
| 326 | "utf8" | |
| 327 | ); | |
| 328 | ||
| 329 | it("_runAutofix routes through resolveAutofix (cache before AI)", () => { | |
| 330 | expect(src).toContain("const plan = await resolveAutofix({"); | |
| 331 | }); | |
| 332 | ||
| 333 | it("applyAutofix settles both success and failed outcomes", () => { | |
| 334 | expect(src).toContain('recordAutofixOutcome(comment.body, "success"'); | |
| 335 | expect(src).toContain('recordAutofixOutcome(comment.body, "failed"'); | |
| 336 | }); | |
| 337 | ||
| 338 | it("imports the flywheel cache + outcome functions", () => { | |
| 339 | expect(src).toContain("findCachedRepair"); | |
| 340 | expect(src).toContain("updateOutcome"); | |
| 341 | expect(src).toContain('from "./repair-flywheel"'); | |
| 342 | }); | |
| 343 | }); |