Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

repair-flywheel-wiring.test.tsBlame343 lines · 1 contributor
bc519aeClaude1/**
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
19import { describe, it, expect } from "bun:test";
20import { readFileSync } from "node:fs";
21import { join } from "node:path";
22
23import {
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";
33import type { CachedRepair, RecordRepairInput } from "../lib/repair-flywheel";
34
35// ---------------------------------------------------------------------------
36// Fixtures
37// ---------------------------------------------------------------------------
38
39const 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
46const 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
53const FAILURE_TEXT =
54 "error TS2322: Type 'number' is not assignable to type 'string' at src/foo.ts:1:7";
55
56const PATTERN_ID = "11111111-2222-3333-4444-555555555555";
57const NEW_ENTRY_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
58
59function 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
74interface Harness {
75 deps: CiAutofixDeps;
76 aiCalls: number;
77 recorded: RecordRepairInput[];
78 outcomes: Array<{ id: string; outcome: string }>;
79 generateAiFix: () => Promise<ClaudeAutofixResponse | null>;
80}
81
82function 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
122function 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
135describe("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
185describe("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
245describe("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
278describe("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
307describe("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
323describe("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});