Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ jobs:
if: runner.os == 'Linux' && matrix.node-version == '22.x'
run: bash scripts/ci-guard-removed-compressors.sh

- name: Guard - no banned type suppressions
# Static grep over hand-written code: covers @ts-nocheck (invisible to
# Biome), @ts-expect-error in src/, and reason-less directives. OS- and
# Node-version independent, so one leg is enough.
if: runner.os == 'Linux' && matrix.node-version == '22.x'
run: bash scripts/ci-guard-type-suppressions.sh

- name: Upload coverage to Codecov
if: runner.os == 'Linux'
uses: codecov/codecov-action@v5
Expand Down
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,20 @@ Custom `scripts/publish.ts` resolves `workspace:*` → concrete versions before

## Anti-Patterns (DO NOT)

- **Never** suppress types: `as any`, `@ts-ignore`, `@ts-expect-error`
- **Never** use `any` — not as `as any`, not as an annotation, not as a type argument. Biome's `noExplicitAny` is set to `error` and fires on every explicit `any` in any position, so the gate is broader than the `as any` symptom it was introduced for.
- **Never** use `as unknown as T` anywhere. No linter catches this shape, so it is enforced by review: use a real type, or `@ts-expect-error` where the invalidity is the point
- **Never** use `@ts-ignore` or `@ts-nocheck` anywhere — both silently disable checking rather than proving an error exists
- **Never** use `@ts-expect-error` in `src/` — fix the type instead
- In test files only, `@ts-expect-error` is sanctioned for two cases, each of which **must** carry a comment on the same line naming the specific condition:
```ts
// @ts-expect-error testing invalid input: settings is missing required fields
// @ts-expect-error mock is narrower than the real type it stands in for
```
Test files are typechecked (`bun run typecheck`), so an unused directive fails the build — the suppression cannot rot. This covers every `__tests__/` directory plus the shared `tests/` helpers and `tests/integration/`.

Place the directive on the line the compiler reports, which is not always the offending property: a missing required field is reported on the object-literal or declaration line, so the directive belongs there rather than on a property. Where several errors would be reported for one literal, TypeScript surfaces only the first — keep the comment to the error actually being suppressed instead of listing every latent one.

Enforcement of these four rules is split. Biome's `noTsIgnore` catches `@ts-ignore`, and root lint covers workspace packages plus `tests/`, `scripts/`, and `examples/` via `lint:root`. `scripts/ci-guard-type-suppressions.sh` is the mechanism for what no Biome rule covers: it bans `@ts-nocheck` and `@ts-ignore` across the whole repository, bans `@ts-expect-error` under any `src/`, and rejects a directive with no reason after it. Run it locally with `bash scripts/ci-guard-type-suppressions.sh`; CI runs it on the Linux/Node 22 leg.
- **Never** remove JSDoc from exported functions
- **Never** use deprecated packages in new code
- **Never** commit without `bun run lint`
Expand Down
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"noParameterAssign": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noExplicitAny": "error",
"noTsIgnore": "error",
"noConfusingVoidType": "off",
"noAssignInExpressions": "off",
"noShadowRestrictedNames": "off"
Expand Down
214 changes: 214 additions & 0 deletions docs/plans/2026-09-02-1642-refactor-typescript-type-hardening-plan.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"clean:build": "bunx rimraf packages/*/dist docs/dist docs/.astro",
"clean:test": "bunx rimraf tests/tmp/*.{js,js.map,css,html,json}",
"dev": "bun run --filter '*' dev",
"lint": "bun run --filter '*' lint",
"lint": "bun run --filter '*' lint && bun run lint:root",
"lint:root": "biome lint tests scripts examples",
"local-release": "bun run changeset:version && bun run changeset:release",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
3 changes: 3 additions & 0 deletions packages/action/__tests__/benchmark.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ describe("runBenchmark", () => {
minReduction: 0,
includeGzip: true,
workingDirectory: ".",
auto: false,
outputDir: "dist",
dryRun: false,
};

beforeEach(() => {
Expand Down
16 changes: 9 additions & 7 deletions packages/action/__tests__/comment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,21 @@ const result: MinifyResult = {

/**
* Wire up a fake octokit for the mocked getOctokit.
*
* postPRComment only reads `paginate` and `rest.issues.{listComments,updateComment,createComment}`,
* so the mock intentionally implements just that subset rather than the full Octokit surface.
*/
function mockOctokit(comments: { id: number; body?: string }[] = []) {
const listComments = vi.fn();
const paginate = vi.fn().mockResolvedValue(comments);
const updateComment = vi.fn().mockResolvedValue({ data: { id: 1 } });
const createComment = vi.fn().mockResolvedValue({ data: { id: 99 } });
vi.mocked(getOctokit).mockReturnValue({
const octokit = {
paginate,
rest: { issues: { listComments, updateComment, createComment } },
} as unknown as ReturnType<typeof getOctokit>);
};
// @ts-expect-error mocked octokit only implements the paginate/issues subset postPRComment reads, not the full Octokit type
vi.mocked(getOctokit).mockReturnValue(octokit);
return { paginate, updateComment, createComment };
}

Expand Down Expand Up @@ -157,11 +162,8 @@ describe("postPRComment", () => {
});

test("warns instead of throwing when the GitHub API fails", async () => {
const paginate = vi.fn().mockRejectedValue(new Error("boom"));
vi.mocked(getOctokit).mockReturnValue({
paginate,
rest: { issues: { listComments: vi.fn() } },
} as unknown as ReturnType<typeof getOctokit>);
const { paginate } = mockOctokit([]);
paginate.mockRejectedValue(new Error("boom"));
await expect(postPRComment(result, "token")).resolves.toBeUndefined();
expect(warning).toHaveBeenCalledWith(
expect.stringContaining("Failed to post PR comment")
Expand Down
94 changes: 29 additions & 65 deletions packages/action/__tests__/compare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ import {
} from "../src/compare.ts";
import type { ComparisonResult, MinifyResult } from "../src/types.ts";

/**
* Wire up a fake octokit for the mocked getOctokit.
*
* compareWithBase only reads `rest.repos.getContent`, so the mock intentionally
* implements just that subset rather than the full Octokit surface.
*/
function mockOctokit(getContent: ReturnType<typeof vi.fn>) {
const octokit = {
rest: {
repos: { getContent },
},
};
// @ts-expect-error mocked octokit only implements the rest.repos.getContent subset compareWithBase reads, not the full Octokit type
vi.mocked(getOctokit).mockReturnValue(octokit);
}

describe("formatChange", () => {
test("formats size increase with warning emoji", () => {
const comparison: ComparisonResult = {
Expand Down Expand Up @@ -307,11 +323,7 @@ describe("compareWithBase", () => {
data: { type: "file", size: 3500 },
});

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(mockResult, "fake-token");

Expand Down Expand Up @@ -357,11 +369,7 @@ describe("compareWithBase", () => {
data: { type: "file", size: 3500 },
});

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(explicitResultWithOutput, "token");

Expand Down Expand Up @@ -401,11 +409,7 @@ describe("compareWithBase", () => {
data: { type: "file", size: 3500 },
});

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

await compareWithBase(explicitResultWithWindowsPath, "token");

Expand Down Expand Up @@ -444,11 +448,7 @@ describe("compareWithBase", () => {
data: { type: "file", size: 3500 },
});

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

await compareWithBase(explicitResultWithUnsafeOutput, "token");

Expand Down Expand Up @@ -487,11 +487,7 @@ describe("compareWithBase", () => {
};

const mockGetContent = vi.fn();
vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(resultWithUnsafePaths, "token");

Expand Down Expand Up @@ -523,11 +519,7 @@ describe("compareWithBase", () => {
});
const mockGetContent = vi.fn().mockRejectedValue(notFoundError);

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(mockResult, "fake-token");

Expand All @@ -546,11 +538,7 @@ describe("compareWithBase", () => {
data: [{ type: "file", name: "index.js" }],
});

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(mockResult, "fake-token");

Expand All @@ -566,11 +554,7 @@ describe("compareWithBase", () => {
data: { type: "symlink", target: "some-target" },
});

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(mockResult, "fake-token");

Expand All @@ -585,11 +569,7 @@ describe("compareWithBase", () => {
const unexpectedError = new Error("Network error");
const mockGetContent = vi.fn().mockRejectedValue(unexpectedError);

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(mockResult, "fake-token");

Expand All @@ -608,11 +588,7 @@ describe("compareWithBase", () => {
data: { type: "file", size: 0 },
});

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(mockResult, "fake-token");

Expand Down Expand Up @@ -654,11 +630,7 @@ describe("compareWithBase", () => {
.mockResolvedValueOnce({ data: { type: "file", size: 1600 } })
.mockResolvedValueOnce({ data: { type: "file", size: 1400 } });

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(multiFileResult, "fake-token");

Expand Down Expand Up @@ -692,11 +664,7 @@ describe("compareWithBase", () => {
data: { type: "file", size: 0 },
});

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(zeroResult, "fake-token");

Expand All @@ -711,11 +679,7 @@ describe("compareWithBase", () => {

const mockGetContent = vi.fn().mockRejectedValue("string error");

vi.mocked(getOctokit).mockReturnValue({
rest: {
repos: { getContent: mockGetContent },
},
} as unknown as ReturnType<typeof getOctokit>);
mockOctokit(mockGetContent);

const result = await compareWithBase(mockResult, "fake-token");

Expand Down
13 changes: 7 additions & 6 deletions packages/action/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { setFailed } from "@actions/core";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { _internal, chunkArray, run } from "../src/index.ts";
import { parseInputs } from "../src/inputs.ts";
import type { ActionInputs } from "../src/types.ts";

vi.mock("@actions/core");
vi.mock("../src/inputs.ts");
Expand Down Expand Up @@ -63,15 +64,15 @@ describe("run", () => {
});

test("calls runAutoMode when auto is true", async () => {
vi.mocked(parseInputs).mockReturnValue({ auto: true } as any);
vi.mocked(parseInputs).mockReturnValue({ auto: true } as ActionInputs);
await run();
expect(_internal.runAutoMode).toHaveBeenCalledWith({ auto: true });
expect(_internal.runExplicitMode).not.toHaveBeenCalled();
expect(setFailed).not.toHaveBeenCalled();
});

test("calls runExplicitMode when auto is false", async () => {
vi.mocked(parseInputs).mockReturnValue({ auto: false } as any);
vi.mocked(parseInputs).mockReturnValue({ auto: false } as ActionInputs);
await run();
expect(_internal.runExplicitMode).toHaveBeenCalledWith({ auto: false });
expect(_internal.runAutoMode).not.toHaveBeenCalled();
Expand All @@ -96,30 +97,30 @@ describe("run", () => {
});

test("calls setFailed when runAutoMode fails", async () => {
vi.mocked(parseInputs).mockReturnValue({ auto: true } as any);
vi.mocked(parseInputs).mockReturnValue({ auto: true } as ActionInputs);
const error = new Error("Auto mode failed");
vi.mocked(_internal.runAutoMode).mockRejectedValue(error);
await run();
expect(setFailed).toHaveBeenCalledWith("Auto mode failed");
});

test("calls setFailed when runExplicitMode fails", async () => {
vi.mocked(parseInputs).mockReturnValue({ auto: false } as any);
vi.mocked(parseInputs).mockReturnValue({ auto: false } as ActionInputs);
const error = new Error("Explicit mode failed");
vi.mocked(_internal.runExplicitMode).mockRejectedValue(error);
await run();
expect(setFailed).toHaveBeenCalledWith("Explicit mode failed");
});

test("no error on success (auto mode)", async () => {
vi.mocked(parseInputs).mockReturnValue({ auto: true } as any);
vi.mocked(parseInputs).mockReturnValue({ auto: true } as ActionInputs);
vi.mocked(_internal.runAutoMode).mockResolvedValue(undefined);
await run();
expect(setFailed).not.toHaveBeenCalled();
});

test("no error on success (explicit mode)", async () => {
vi.mocked(parseInputs).mockReturnValue({ auto: false } as any);
vi.mocked(parseInputs).mockReturnValue({ auto: false } as ActionInputs);
vi.mocked(_internal.runExplicitMode).mockResolvedValue(undefined);
await run();
expect(setFailed).not.toHaveBeenCalled();
Expand Down
Loading
Loading