bucket_catch: 45 eslint errors -> 5, no suppressions

These were unmasked, not caused, last session: `lint` is
`tsc --noEmit && eslint src`, and a broken tsc had been short-circuiting
the && so eslint had never run on this package.

The binding constraint is the 100% coverage gate, not the linter. Most
idiomatic fixes for no-non-null-assertion add a branch that no test can
reach, which trades a lint error for a coverage failure - and the
no-suppressions rule forbids escaping either way. So every fix here is
one that adds no branch:

- no-non-null-assertion 18 -> 2: a waitFor already proves result.current
  is non-null, so capture it into a local instead of asserting on each
  read. App.tsx's files[0]! was pure noise (the same position also
  reported no-unnecessary-type-assertion). usePuzzleGameLoop's shift()!
  became index-then-shift, since the while condition already indexes [0]
  unasserted.
- non-nullable-type-assertion-style 9 -> 0: the rule-swap trap - eslint
  asks for `!`, which the other rule forbids. Narrowed with instanceof.
- unbound-method 10 -> 1: keep the MockInstance vi.spyOn already returns
  rather than detaching the method. This surfaced a latent bug: one test
  calls restoreAllMocks() mid-test, so the beforeEach handle was stale.
- no-unnecessary-type-assertion 5 -> 1, require-await 1 -> 0.

Coverage held at 100%: 584/217/98/535 -> 585/217/98/536. The +1 statement
and +1 line are the index-then-shift split; branches unchanged at 217 is
the number that proves no fix added an untested path. 145 tests, tsc 0.

The 5 remaining are reported, not silenced. App.tsx's fileResult! needs
the phase state to carry the result ({ kind: "done"; result }) - three
smaller structures all failed the same way. The rest are documented in
the evidence file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-17 11:48:45 +02:00
parent efb471fc8b
commit e165ae8987
13 changed files with 219 additions and 67 deletions

View File

@ -65,7 +65,7 @@ export default function App(): React.ReactElement {
if (mode === "puzzle") {
return (
<PuzzleCanvas
imageFile={files[0]!}
imageFile={files[0]}
gridSize={puzzleGridSize}
onDone={handlePuzzleDone}
/>

View File

@ -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(<DropZone onFiles={() => undefined} onPuzzle={() => undefined} />);
@ -23,7 +37,10 @@ describe("DropZone", () => {
const { container } = render(
<DropZone onFiles={() => 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(
<DropZone onFiles={onFiles} onPuzzle={() => 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(
<DropZone onFiles={() => 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(
<DropZone onFiles={onFiles} onPuzzle={() => 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(
<DropZone onFiles={onFiles} onPuzzle={() => 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(
<DropZone onFiles={onFiles} onPuzzle={() => 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(
<DropZone onFiles={onFiles} onPuzzle={() => 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(
<DropZone onFiles={() => 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(
<DropZone onFiles={() => 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(
<DropZone onFiles={() => 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(
<DropZone onFiles={() => 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", () => {
<DropZone onFiles={() => 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],

View File

@ -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",
);

View File

@ -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(
<ScoreScreen result={result1} mode="download" onRestart={() => undefined} />,
);

View File

@ -4,8 +4,9 @@ import { fireEvent } from "@testing-library/react";
import type React from "react";
import { useBasketControl } from "./useBasketControl";
const makeRef = <T>(val: T): React.RefObject<T> =>
({ current: val }) as React.RefObject<T>;
const makeRef = <T>(val: T): React.RefObject<T> => ({
current: val,
});
describe("useBasketControl", () => {
it("initialises basket x to half of window.innerWidth", () => {

View File

@ -3,8 +3,7 @@ import { renderHook, act, waitFor } from "@testing-library/react";
import type React from "react";
import { useGameLoop } from "./useGameLoop";
const makeRef = <T>(val: T): React.RefObject<T> =>
({ current: val }) as React.RefObject<T>;
const makeRef = <T>(val: T): React.RefObject<T> => ({ 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);
});
});

View File

@ -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}`,

View File

@ -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 () => {

View File

@ -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<typeof URL.revokeObjectURL>;
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");
});
});

View File

@ -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<typeof URL.createObjectURL>;
let revokeUrlSpy: MockInstance<typeof URL.revokeObjectURL>;
let clickSpy: MockInstance<typeof HTMLAnchorElement.prototype.click>;
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);
});
});

View File

@ -10,8 +10,9 @@ import type { PuzzlePiece } from "../types";
* the 100% gate.
*/
export const makeRef = <T>(val: T): React.RefObject<T> =>
({ current: val }) as React.RefObject<T>;
export const makeRef = <T>(val: T): React.RefObject<T> => ({
current: val,
});
export const makePiece = (row: number, col: number): PuzzlePiece => ({
row,

View File

@ -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."
}

View File

@ -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<T>` 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."
]
}