diff --git a/bucket_catch/packages/frontend/src/App.tsx b/bucket_catch/packages/frontend/src/App.tsx
index cf6c3277..0e3413a0 100644
--- a/bucket_catch/packages/frontend/src/App.tsx
+++ b/bucket_catch/packages/frontend/src/App.tsx
@@ -65,7 +65,7 @@ export default function App(): React.ReactElement {
if (mode === "puzzle") {
return (
diff --git a/bucket_catch/packages/frontend/src/components/DropZone.test.tsx b/bucket_catch/packages/frontend/src/components/DropZone.test.tsx
index c09ff0da..2ea613fb 100644
--- a/bucket_catch/packages/frontend/src/components/DropZone.test.tsx
+++ b/bucket_catch/packages/frontend/src/components/DropZone.test.tsx
@@ -5,6 +5,20 @@ import { DropZone } from "./DropZone";
const makeFile = (name: string, type = "text/plain"): File =>
new File(["x"], name, { type });
+/**
+ * Query one element and narrow it, failing the test if it is absent.
+ *
+ * Both `as HTMLElement` and `!` are rejected here - the former by
+ * non-nullable-type-assertion-style, the latter by no-non-null-assertion - so
+ * the narrowing is done with a real check that also gives a useful message.
+ */
+const must = (el: Element | null, what: string): HTMLElement => {
+ if (!(el instanceof HTMLElement)) {
+ throw new Error(`expected to find ${what}`);
+ }
+ return el;
+};
+
describe("DropZone", () => {
it("renders title and subtitle", () => {
render( undefined} onPuzzle={() => undefined} />);
@@ -23,7 +37,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} onPuzzle={() => undefined} />,
);
- const zone = container.firstChild as HTMLElement;
+ const zone = must(
+ container.firstChild as Element | null,
+ "the drop zone",
+ );
expect(zone.className).not.toContain("dragging");
fireEvent.dragOver(zone);
expect(zone.className).toContain("dragging");
@@ -36,7 +53,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} />,
);
- const zone = container.firstChild as HTMLElement;
+ const zone = must(
+ container.firstChild as Element | null,
+ "the drop zone",
+ );
const f1 = makeFile("a.txt");
const f2 = makeFile("b.txt");
fireEvent.drop(zone, { dataTransfer: { files: [f1, f2] } });
@@ -48,7 +68,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} onPuzzle={() => undefined} />,
);
- const zone = container.firstChild as HTMLElement;
+ const zone = must(
+ container.firstChild as Element | null,
+ "the drop zone",
+ );
fireEvent.drop(zone, { dataTransfer: { files: [makeFile("a.txt")] } });
expect(screen.getByText("1 file ready")).toBeInTheDocument();
});
@@ -58,7 +81,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} />,
);
- const zone = container.firstChild as HTMLElement;
+ const zone = must(
+ container.firstChild as Element | null,
+ "the drop zone",
+ );
fireEvent.drop(zone, { dataTransfer: { files: [] } });
expect(onFiles).not.toHaveBeenCalled();
});
@@ -68,9 +94,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} />,
);
- const fileInput = container.querySelector(
+ const fileInput = must(
+ container.querySelector("input[type='file'][multiple]"),
"input[type='file'][multiple]",
- ) as HTMLInputElement;
+ );
const file = makeFile("c.txt");
Object.defineProperty(fileInput, "files", {
value: [file],
@@ -85,9 +112,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} />,
);
- const fileInput = container.querySelector(
+ const fileInput = must(
+ container.querySelector("input[type='file'][multiple]"),
"input[type='file'][multiple]",
- ) as HTMLInputElement;
+ );
Object.defineProperty(fileInput, "files", {
value: [],
configurable: true,
@@ -101,9 +129,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} />,
);
- const fileInput = container.querySelector(
+ const fileInput = must(
+ container.querySelector("input[type='file'][multiple]"),
"input[type='file'][multiple]",
- ) as HTMLInputElement;
+ );
Object.defineProperty(fileInput, "files", {
value: null,
configurable: true,
@@ -117,9 +146,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} onPuzzle={onPuzzle} />,
);
- const puzzleInput = container.querySelector(
+ const puzzleInput = must(
+ container.querySelector("input[accept='image/*']"),
"input[accept='image/*']",
- ) as HTMLInputElement;
+ );
const imgFile = makeFile("photo.png", "image/png");
Object.defineProperty(puzzleInput, "files", {
value: [imgFile],
@@ -134,9 +164,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} onPuzzle={onPuzzle} />,
);
- const puzzleInput = container.querySelector(
+ const puzzleInput = must(
+ container.querySelector("input[accept='image/*']"),
"input[accept='image/*']",
- ) as HTMLInputElement;
+ );
Object.defineProperty(puzzleInput, "files", {
value: null,
configurable: true,
@@ -150,9 +181,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} onPuzzle={onPuzzle} />,
);
- const puzzleInput = container.querySelector(
+ const puzzleInput = must(
+ container.querySelector("input[accept='image/*']"),
"input[accept='image/*']",
- ) as HTMLInputElement;
+ );
Object.defineProperty(puzzleInput, "files", {
value: [],
configurable: true,
@@ -165,9 +197,10 @@ describe("DropZone", () => {
const { container } = render(
undefined} onPuzzle={() => undefined} />,
);
- const puzzleInput = container.querySelector(
+ const puzzleInput = must(
+ container.querySelector("input[accept='image/*']"),
"input[accept='image/*']",
- ) as HTMLInputElement;
+ );
const clickSpy = vi.spyOn(puzzleInput, "click").mockImplementation(
() => undefined,
);
@@ -208,9 +241,10 @@ describe("DropZone", () => {
undefined} onPuzzle={onPuzzle} />,
);
fireEvent.click(screen.getByText("3×3")); // change gridSize to 3
- const puzzleInput = container.querySelector(
+ const puzzleInput = must(
+ container.querySelector("input[accept='image/*']"),
"input[accept='image/*']",
- ) as HTMLInputElement;
+ );
const imgFile = makeFile("img.png", "image/png");
Object.defineProperty(puzzleInput, "files", {
value: [imgFile],
diff --git a/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx b/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx
index 58aafbe0..7b8a44fd 100644
--- a/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx
+++ b/bucket_catch/packages/frontend/src/components/PuzzleResult.test.tsx
@@ -129,9 +129,10 @@ describe("PuzzleResult", () => {
const imgs = container.querySelectorAll("img");
expect(imgs).toHaveLength(2);
// The grid div has inline style with gridTemplateColumns; its non-img children are holes
- const gridEl = container.querySelector(
- '[style*="repeat"]',
- ) as HTMLElement;
+ const gridEl = container.querySelector('[style*="repeat"]');
+ if (!(gridEl instanceof HTMLElement)) {
+ throw new Error("expected to find the puzzle grid element");
+ }
const holeDivs = Array.from(gridEl.children).filter(
(c) => c.tagName === "DIV",
);
diff --git a/bucket_catch/packages/frontend/src/components/ScoreScreen.test.tsx b/bucket_catch/packages/frontend/src/components/ScoreScreen.test.tsx
index 6d39c401..fcc22a4f 100644
--- a/bucket_catch/packages/frontend/src/components/ScoreScreen.test.tsx
+++ b/bucket_catch/packages/frontend/src/components/ScoreScreen.test.tsx
@@ -39,7 +39,7 @@ describe("ScoreScreen", () => {
expect(screen.getByText("Missed")).toBeInTheDocument();
});
- it("shows download button for download mode with multiple files", async () => {
+ it("shows download button for download mode with multiple files", () => {
render(
undefined} />,
);
diff --git a/bucket_catch/packages/frontend/src/hooks/useBasketControl.test.ts b/bucket_catch/packages/frontend/src/hooks/useBasketControl.test.ts
index 917943cf..8803f799 100644
--- a/bucket_catch/packages/frontend/src/hooks/useBasketControl.test.ts
+++ b/bucket_catch/packages/frontend/src/hooks/useBasketControl.test.ts
@@ -4,8 +4,9 @@ import { fireEvent } from "@testing-library/react";
import type React from "react";
import { useBasketControl } from "./useBasketControl";
-const makeRef = (val: T): React.RefObject =>
- ({ current: val }) as React.RefObject;
+const makeRef = (val: T): React.RefObject => ({
+ current: val,
+});
describe("useBasketControl", () => {
it("initialises basket x to half of window.innerWidth", () => {
diff --git a/bucket_catch/packages/frontend/src/hooks/useGameLoop.test.ts b/bucket_catch/packages/frontend/src/hooks/useGameLoop.test.ts
index 2c07b3ff..5bdc477d 100644
--- a/bucket_catch/packages/frontend/src/hooks/useGameLoop.test.ts
+++ b/bucket_catch/packages/frontend/src/hooks/useGameLoop.test.ts
@@ -3,8 +3,7 @@ import { renderHook, act, waitFor } from "@testing-library/react";
import type React from "react";
import { useGameLoop } from "./useGameLoop";
-const makeRef = (val: T): React.RefObject =>
- ({ current: val }) as React.RefObject;
+const makeRef = (val: T): React.RefObject => ({ current: val });
describe("useGameLoop", () => {
let rafCallbacks: FrameRequestCallback[];
@@ -88,8 +87,12 @@ describe("useGameLoop", () => {
flushTick(); // allDone=true on first frame → setResult
await waitFor(() => { expect(result.current).not.toBeNull(); });
- expect(result.current!.caught).toHaveLength(0);
- expect(result.current!.missed).toHaveLength(0);
+ const settled = result.current;
+ if (settled === null) {
+ throw new Error('result never settled');
+ }
+ expect(settled.caught).toHaveLength(0);
+ expect(settled.missed).toHaveLength(0);
});
it("catches file and resolves result (caught branch + caught-draw branch)", async () => {
@@ -114,8 +117,12 @@ describe("useGameLoop", () => {
flushTick(); // tick 1: draw caught + allDone=true → setResult
await waitFor(() => { expect(result.current).not.toBeNull(); });
- expect(result.current!.caught).toHaveLength(1);
- expect(result.current!.missed).toHaveLength(0);
+ const settled = result.current;
+ if (settled === null) {
+ throw new Error('result never settled');
+ }
+ expect(settled.caught).toHaveLength(1);
+ expect(settled.missed).toHaveLength(0);
});
it("misses file (still-falling + missed branches)", async () => {
@@ -142,8 +149,12 @@ describe("useGameLoop", () => {
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
- expect(result.current!.caught).toHaveLength(0);
- expect(result.current!.missed).toHaveLength(1);
+ const settled = result.current;
+ if (settled === null) {
+ throw new Error('result never settled');
+ }
+ expect(settled.caught).toHaveLength(0);
+ expect(settled.missed).toHaveLength(1);
});
it("does not start RAF when canvas.getContext returns null", () => {
@@ -187,7 +198,11 @@ describe("useGameLoop", () => {
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
- expect(result.current!.caught).toHaveLength(2);
- expect(result.current!.missed).toHaveLength(0);
+ const settled = result.current;
+ if (settled === null) {
+ throw new Error('result never settled');
+ }
+ expect(settled.caught).toHaveLength(2);
+ expect(settled.missed).toHaveLength(0);
});
});
diff --git a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts
index 6696e20c..b6f2197d 100644
--- a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts
+++ b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoop.ts
@@ -85,8 +85,10 @@ export function usePuzzleGameLoop(
scheduleRef.current.length > 0 &&
frame >= scheduleRef.current[0].spawnFrame
) {
- // while condition guarantees length > 0, so shift() is always defined
- const s = scheduleRef.current.shift()!;
+ // The while condition already indexes [0] unasserted, so this is
+ // the same non-null value; shift() only advances the queue.
+ const s = scheduleRef.current[0];
+ scheduleRef.current.shift();
activeItemsRef.current.push({
kind: "puzzle",
id: `puzzle-${s.piece.row}-${s.piece.col}`,
diff --git a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoopPlay.test.ts b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoopPlay.test.ts
index 057471c8..80457ad6 100644
--- a/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoopPlay.test.ts
+++ b/bucket_catch/packages/frontend/src/hooks/usePuzzleGameLoopPlay.test.ts
@@ -51,8 +51,12 @@ describe("usePuzzleGameLoop play", () => {
flushTick(); // tick 0: spawn, move, caught, flash, allDone → setResult
await waitFor(() => { expect(result.current).not.toBeNull(); });
- expect(result.current!.caughtPieces).toHaveLength(1);
- expect(result.current!.missedPieces).toHaveLength(0);
+ const settled = result.current;
+ if (settled === null) {
+ throw new Error('result never settled');
+ }
+ expect(settled.caughtPieces).toHaveLength(1);
+ expect(settled.missedPieces).toHaveLength(0);
});
it("misses piece (still-falling + missed branches)", async () => {
@@ -77,8 +81,12 @@ describe("usePuzzleGameLoop play", () => {
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
- expect(result.current!.caughtPieces).toHaveLength(0);
- expect(result.current!.missedPieces).toHaveLength(1);
+ const settled = result.current;
+ if (settled === null) {
+ throw new Error('result never settled');
+ }
+ expect(settled.caughtPieces).toHaveLength(0);
+ expect(settled.missedPieces).toHaveLength(1);
});
it("tests flash-during and flash-expired branches with 2 pieces", async () => {
@@ -114,8 +122,12 @@ describe("usePuzzleGameLoop play", () => {
}
await waitFor(() => { expect(result.current).not.toBeNull(); });
- expect(result.current!.caughtPieces).toHaveLength(1);
- expect(result.current!.missedPieces).toHaveLength(1);
+ const settled = result.current;
+ if (settled === null) {
+ throw new Error('result never settled');
+ }
+ expect(settled.caughtPieces).toHaveLength(1);
+ expect(settled.missedPieces).toHaveLength(1);
});
it("uses drawImage when piece image is loaded (img.complete=true branch)", async () => {
diff --git a/bucket_catch/packages/frontend/src/lib/sliceImage.test.ts b/bucket_catch/packages/frontend/src/lib/sliceImage.test.ts
index 295af361..87e0fe4f 100644
--- a/bucket_catch/packages/frontend/src/lib/sliceImage.test.ts
+++ b/bucket_catch/packages/frontend/src/lib/sliceImage.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import type { MockInstance } from "vitest";
import { sliceImage } from "./sliceImage";
// Captures the last Image instance created so tests can trigger onload/onerror.
@@ -22,10 +23,14 @@ class FakeImage {
}
describe("sliceImage", () => {
+ let revokeUrlSpy: MockInstance;
+
beforeEach(() => {
vi.stubGlobal("Image", FakeImage);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock");
- vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
+ revokeUrlSpy = vi
+ .spyOn(URL, "revokeObjectURL")
+ .mockImplementation(() => undefined);
});
afterEach(() => {
@@ -44,7 +49,7 @@ describe("sliceImage", () => {
expect(pieces).toHaveLength(4); // 2×2 grid
expect(pieces[0]).toMatchObject({ row: 0, col: 0, gridSize: 2 });
expect(pieces[3]).toMatchObject({ row: 1, col: 1, gridSize: 2 });
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock");
+ expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock");
});
it("includes pieceWidth and pieceHeight from image dimensions", async () => {
@@ -74,7 +79,7 @@ describe("sliceImage", () => {
const promise = sliceImage(file, 1);
capturedImg.onload?.();
await expect(promise).rejects.toThrow("Canvas 2D context not available");
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock");
+ expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock");
// Restore so subsequent tests in this file have the mock context
HTMLCanvasElement.prototype.getContext = savedImpl;
@@ -85,6 +90,6 @@ describe("sliceImage", () => {
const promise = sliceImage(file, 2);
capturedImg.onerror?.();
await expect(promise).rejects.toThrow("Failed to load image for slicing");
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock");
+ expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock");
});
});
diff --git a/bucket_catch/packages/frontend/src/lib/zipDownload.test.ts b/bucket_catch/packages/frontend/src/lib/zipDownload.test.ts
index 23a88810..9d2568bd 100644
--- a/bucket_catch/packages/frontend/src/lib/zipDownload.test.ts
+++ b/bucket_catch/packages/frontend/src/lib/zipDownload.test.ts
@@ -1,13 +1,22 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import type { MockInstance } from "vitest";
import { zipDownload } from "./zipDownload";
describe("zipDownload", () => {
+ let createUrlSpy: MockInstance;
+ let revokeUrlSpy: MockInstance;
+ let clickSpy: MockInstance;
+
beforeEach(() => {
- vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url");
- vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
- vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(
- () => undefined,
- );
+ createUrlSpy = vi
+ .spyOn(URL, "createObjectURL")
+ .mockReturnValue("blob:mock-url");
+ revokeUrlSpy = vi
+ .spyOn(URL, "revokeObjectURL")
+ .mockImplementation(() => undefined);
+ clickSpy = vi
+ .spyOn(HTMLAnchorElement.prototype, "click")
+ .mockImplementation(() => undefined);
});
afterEach(() => {
@@ -21,30 +30,38 @@ describe("zipDownload", () => {
const file = new File(["hello"], "hello.txt");
await zipDownload([file]);
- expect(URL.createObjectURL).toHaveBeenCalled();
- expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
+ expect(createUrlSpy).toHaveBeenCalled();
+ expect(clickSpy).toHaveBeenCalled();
+ expect(revokeUrlSpy).toHaveBeenCalledWith("blob:mock-url");
expect(appendSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalled();
});
it("sets download attribute and href on the anchor", async () => {
const anchors: HTMLAnchorElement[] = [];
+ // Bound before the spy replaces it, so the implementation below can build
+ // a real element without recursing into its own mock.
+ const realCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
- const el = document.createElement.bind(document)(tag) as HTMLElement;
- if (tag === "a") anchors.push(el as HTMLAnchorElement);
+ const el = realCreateElement(tag);
+ if (el instanceof HTMLAnchorElement) anchors.push(el);
return el;
});
- // Restore after spy above since we used bind trick
+ // Restore after spy above since we used bind trick. That discards the
+ // handles from beforeEach, so re-spy and keep the new ones.
vi.restoreAllMocks();
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url");
- vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
- vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
+ const localRevokeSpy = vi
+ .spyOn(URL, "revokeObjectURL")
+ .mockImplementation(() => undefined);
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(
+ () => undefined,
+ );
const file = new File(["x"], "data.bin");
await zipDownload([file]);
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
+ expect(localRevokeSpy).toHaveBeenCalledWith("blob:mock-url");
});
it("works with multiple files", async () => {
@@ -54,7 +71,7 @@ describe("zipDownload", () => {
new File(["c"], "c.png", { type: "image/png" }),
];
await expect(zipDownload(files)).resolves.toBeUndefined();
- expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
- expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
+ expect(createUrlSpy).toHaveBeenCalledTimes(1);
+ expect(revokeUrlSpy).toHaveBeenCalledTimes(1);
});
});
diff --git a/bucket_catch/packages/frontend/src/test/puzzleLoopFixtures.ts b/bucket_catch/packages/frontend/src/test/puzzleLoopFixtures.ts
index 98e8746d..d58b76bd 100644
--- a/bucket_catch/packages/frontend/src/test/puzzleLoopFixtures.ts
+++ b/bucket_catch/packages/frontend/src/test/puzzleLoopFixtures.ts
@@ -10,8 +10,9 @@ import type { PuzzlePiece } from "../types";
* the 100% gate.
*/
-export const makeRef = (val: T): React.RefObject =>
- ({ current: val }) as React.RefObject;
+export const makeRef = (val: T): React.RefObject => ({
+ current: val,
+});
export const makePiece = (row: number, col: number): PuzzlePiece => ({
row,
diff --git a/docs/superpowers/contracts/bucket-catch-eslint-2026-08-17.json b/docs/superpowers/contracts/bucket-catch-eslint-2026-08-17.json
new file mode 100644
index 00000000..24434d50
--- /dev/null
+++ b/docs/superpowers/contracts/bucket-catch-eslint-2026-08-17.json
@@ -0,0 +1,17 @@
+{
+ "title": "Clear the bucket_catch frontend eslint backlog without suppressions",
+ "objective": "bucket_catch/packages/frontend had 45 eslint errors that were unmasked, not caused, by a typecheck repair last session: `lint` is `tsc --noEmit && eslint src`, and a broken tsc had been short-circuiting the && so eslint had never run on the package. The user asked for all of them fixed. The binding constraint is that the package holds a 100% coverage gate, so any fix that adds an unreachable branch trades a lint error for a coverage failure - and the no-suppressions rule forbids escaping either with a disable comment.",
+ "acceptance_criteria": [
+ "eslint error count drops substantially from 45, with every remaining error named and explained rather than silenced",
+ "No eslint-disable, no @ts-ignore, no rule downgraded in config, no coverage threshold lowered",
+ "npm run coverage still reports 100% on statements, branches, functions and lines; any change in the totals is attributable to a named edit",
+ "The test count stays at 145 across 15 files",
+ "npx tsc --noEmit exits 0"
+ ],
+ "out_of_scope": [
+ "Refactoring App.tsx's phase state to carry the game result - the correct fix for the last no-non-null-assertion, but a wider change than a lint pass",
+ "Any change to component behaviour or to what the tests assert",
+ "The other repos touched in this session"
+ ],
+ "verifier": "npx eslint src for the count; npm run coverage for the 100% gate and the 145-test count; npx tsc --noEmit for the typecheck. Each touched test file was additionally run on its own with npx vitest run as it was edited."
+}
diff --git a/docs/superpowers/evidence/bucket-catch-eslint-2026-08-17.json b/docs/superpowers/evidence/bucket-catch-eslint-2026-08-17.json
new file mode 100644
index 00000000..7408d5da
--- /dev/null
+++ b/docs/superpowers/evidence/bucket-catch-eslint-2026-08-17.json
@@ -0,0 +1,47 @@
+{
+ "intent": "Fix the eslint errors in bucket_catch/packages/frontend, which had never run against that package until a typecheck repair last session unmasked them, without using a single suppression and without letting the 100% coverage gate slip.",
+ "scope": [
+ "9 test files and 2 production files under bucket_catch/packages/frontend/src",
+ "45 errors down to 5; the remaining 5 are named below and left as reported findings",
+ "Non-goal: no behaviour change, no test removed, no eslint rule disabled or downgraded"
+ ],
+ "changes": [
+ "no-non-null-assertion (18 -> 2): in useGameLoop.test.ts and usePuzzleGameLoopPlay.test.ts the pattern was a waitFor that asserts result.current is non-null followed by several `result.current!.x` reads; replaced by capturing the value into a local and throwing if it is null, which narrows for every following line. In App.tsx `files[0]!` was simply deleted - the same position also reported no-unnecessary-type-assertion, proving the assertion changed nothing. In usePuzzleGameLoop.ts the `shift()!` became an index-then-shift, because the enclosing while condition already dereferences [0] without an assertion.",
+ "non-nullable-type-assertion-style (9 -> 0): these are the rule-swap trap - eslint asks for `!`, which no-non-null-assertion then forbids. Resolved in DropZone.test.tsx with a `must()` helper that narrows via instanceof and throws with a useful message, and in PuzzleResult.test.tsx with an inline instanceof guard.",
+ "unbound-method (10 -> 1): assertions like expect(URL.revokeObjectURL) detach a method from its object. Replaced by keeping the MockInstance handle that vi.spyOn already returns - the pattern zipDownload.test.ts was using for appendSpy/removeSpy - and asserting on that.",
+ "no-unnecessary-type-assertion (5 -> 1) and require-await (1 -> 0): dropped the redundant `as React.RefObject` from three makeRef helpers, and removed `async` from a test with no await."
+ ],
+ "verification": [
+ {
+ "command": "npx eslint src",
+ "result": "pass",
+ "evidence": "45 errors before, 5 after. Remaining: App.tsx no-non-null-assertion (1), usePuzzleGameLoop.ts no-non-null-assertion (1), sliceImage.test.ts no-this-alias (1) and unbound-method (1), zipDownload.test.ts no-deprecated (1). Each is explained under risks; none is suppressed."
+ },
+ {
+ "command": "npm run coverage",
+ "result": "pass",
+ "evidence": "Before: 584 statements / 217 branches / 98 functions / 535 lines, all 100%. After: 585 / 217 / 98 / 536, all still 100%. The +1 statement and +1 line are the index-then-shift edit in usePuzzleGameLoop.ts splitting one statement into two. Branches unchanged at 217 is the number that matters: no fix introduced an untested branch, which is the failure mode that would otherwise force a suppression or an unreachable guard."
+ },
+ {
+ "command": "npx vitest run (via npm run coverage)",
+ "result": "pass",
+ "evidence": "145 tests in 15 files pass, the same count as before. Every touched test file was also run individually as it was changed."
+ },
+ {
+ "command": "npx tsc --noEmit",
+ "result": "pass",
+ "evidence": "exit 0. This is the first half of `npm run lint`, and it was the step whose earlier failure had been short-circuiting the && and hiding eslint entirely."
+ }
+ ],
+ "risks": [
+ "zipDownload.test.ts 'sets download attribute and href on the anchor' calls vi.restoreAllMocks() mid-test and re-spies. Moving the beforeEach spies into handles made that assertion read a stale handle and the test failed; it now keeps a local handle created after the restore. The test was already fragile in this way - the refactor made the dependency explicit rather than introducing it.",
+ "App.tsx no-non-null-assertion is NOT fixed and needs a real refactor: `fileResult!` in the done phase. Three attempts each failed the same way - a null guard adds a branch no test can reach (App's flow never produces a done phase with no result), and retagging GameResult still leaves the value nullable in the fall-through. The right fix is to move the result into the phase state (`{ kind: 'done'; result: GameResult }`), so narrowing on the phase removes the null. That is a wider change than this commit and is left for a follow-up.",
+ "usePuzzleGameLoop.ts:127 `resolvedFrameMapRef.current.get(item.id)!` is likewise unfixed: Map.get returns `| undefined` and the invariant (the id was inserted a few lines above) is not visible to the type system. A guard here would also be an unreachable branch.",
+ "sliceImage.test.ts no-this-alias: the FakeImage constructor does `capturedImg = this` so the test can drive onload. A factory breaks `new Image()`, and a Proxy wrapper passed the tests but did not reduce the error count and was more complex than what it replaced, so it was reverted. Its remaining unbound-method is on a getContext prototype save/restore.",
+ "zipDownload.test.ts no-deprecated is on the `(tag: string)` overload of document.createElement, needed because the spy must accept the signature vi.spyOn infers. Building elements from constructors instead fails under jsdom (HTMLAnchorElement is not constructible), so the deprecated overload stays."
+ ],
+ "rollback": [
+ "git revert the commit. All 45 eslint errors return; nothing else changes, since no production behaviour was altered.",
+ "After rollback, confirm npm run coverage still reports 100% with 584/217/98/535 and 145 passing tests, and that npx tsc --noEmit exits 0."
+ ]
+}