diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1da89bac1..f7c0ec641 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 97cece0ec..ff552489c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/biome.json b/biome.json index 8a0a20048..4a73b2b3a 100644 --- a/biome.json +++ b/biome.json @@ -28,7 +28,8 @@ "noParameterAssign": "off" }, "suspicious": { - "noExplicitAny": "off", + "noExplicitAny": "error", + "noTsIgnore": "error", "noConfusingVoidType": "off", "noAssignInExpressions": "off", "noShadowRestrictedNames": "off" diff --git a/docs/plans/2026-09-02-1642-refactor-typescript-type-hardening-plan.md b/docs/plans/2026-09-02-1642-refactor-typescript-type-hardening-plan.md new file mode 100644 index 000000000..165bd5609 --- /dev/null +++ b/docs/plans/2026-09-02-1642-refactor-typescript-type-hardening-plan.md @@ -0,0 +1,214 @@ +--- +title: TypeScript Type Hardening - Plan +type: refactor +date: 2026-09-02 +topic: typescript-type-hardening +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +--- + +# TypeScript Type Hardening - Plan + +## Goal Capsule + +- **Objective:** Every explicit `any` and unexplained type suppression is gone from the monorepo, and the toolchain fails the build if one comes back — the `as any` ban stops being a convention and becomes enforced. +- **Means:** Bring test files under `tsc`, convert casts to commented `@ts-expect-error` or real types, enable Biome `noExplicitAny` (KTD1, KD1, KD2). +- **Product authority:** This session's dialogue (2026-09-02) plus AGENTS.md's anti-pattern list, which this work amends. +- **Stop conditions:** Any fix that would require changing runtime behavior or test assertions to satisfy the typechecker — stop and surface it; this pass is types, directives, and config only. +- **Open blockers:** None. + +--- + +## Product Contract + +**Product Contract preservation:** unchanged in meaning; Outstanding Questions resolved into KTD1/KTD2 (both were `Deferred to Planning` and planning answered them). + +### Summary + +Bring all `__tests__` files under `tsc` typecheck, replace every deliberate invalid-input cast with a commented `@ts-expect-error` directive, give mocks and fixtures real types where one exists, then enable Biome's `noExplicitAny` across the whole repo so regressions are caught mechanically. Shipped `src/` code is already strict and clean except one narrow case: `packages/google-closure-compiler/src/index.ts` has implicit-any parameters that only surface when another package's program compiles it (see U3). + +### Problem Frame + +AGENTS.md bans `as any`, `@ts-ignore`, and `@ts-expect-error`, but nothing enforces the ban: Biome's `noExplicitAny` rule is off, and every package's tsconfig includes only `src/**/*`, so test files are never typechecked at all. The result is 136 `as any` sites, 29 `as unknown as T` double-casts, and 5 bare `@ts-expect-error` directives — all in test files, accumulated invisibly because no tool ever looked. Most casts are legitimate in intent (tests deliberately passing invalid input to exercise runtime validation) but illegitimate in form: an unchecked cast can silently rot into passing valid input, at which point the test verifies nothing. + +### Key Decisions + +- KD1. **Commented `@ts-expect-error` replaces invalid-input casts in tests.** (session-settled: user-directed — chosen over a centralized `invalid()` cast helper and inline `as unknown as T`: the directive is compiler-verified, so the build fails if the type error it suppresses ever stops existing — it cannot rot.) Governs R4, R5. +- KD2. **`noExplicitAny` enforced everywhere, tests included.** (session-settled: user-directed — chosen over src-only enforcement or convention-only: all current debt lives in tests, so exempting them would let it re-accumulate.) Governs R8. +- KD3. **Test files enter typecheck coverage.** (session-settled: user-approved — forced consequence of KD1: `@ts-expect-error` only self-verifies in a file the compiler checks; in an unchecked file the directive is dead ink.) Governs R1, R2, R3. +- KD4. **Scope includes `as unknown as T` double-casts and bare directives, not just `as any`.** (session-settled: user-approved — same debt in different spelling; a pass that leaves them behind re-opens the same cleanup later.) Governs R6, R7. + +### Requirements + +**Typecheck coverage** + +- R1. Every package's `__tests__` directory and the shared `tests/` helpers (`tests/fixtures.ts`, `tests/files-path.ts`) are typechecked by `bun run typecheck`. +- R2. The two packages that explicitly exclude `__tests__` in their tsconfig (`packages/imagemin`, `packages/svgo`) are unified with the inclusion mechanism the other 20 packages adopt — no per-package divergence remains. +- R3. `bun run typecheck` passes clean across all packages, tests included, and runs in CI on the same trigger it does today. `packages/types` gains a minimal tsconfig and `typecheck` script so its `types.d.ts` — the type surface every package imports — is checked directly rather than only through downstream compile errors. + +**Cast elimination** + +- R4. No `as any` remains anywhere in the repo. Casts that deliberately pass invalid input to exercise runtime validation become `@ts-expect-error` directives; each directive carries a comment stating what invalid condition it suppresses. +- R5. The existing bare `@ts-expect-error` directives (`packages/sharp/__tests__/sharp.test.ts`, `packages/utils/__tests__/utils.test.ts`) gain the same required comment. +- R6. No `as unknown as T` double-cast remains. Each is either replaced by a commented `@ts-expect-error` (when testing invalid input) or by a real type (when the cast papers over a fixable mock or fixture type). +- R7. Shared test fixtures are properly typed: `tests/fixtures.ts` types `compressor` as the `Compressor` type from `@node-minify/types` instead of `any`, and its error-code narrowing uses a typed guard instead of `as any`. + +**Enforcement** + +- R8. Biome's `suspicious.noExplicitAny` is enabled with no override exempting tests, and `bun run lint` passes clean. +- R9. AGENTS.md's anti-pattern list is amended: the blanket ban stays for `src/`; `@ts-expect-error` with a required explanation comment becomes the sanctioned pattern for invalid-input tests. `@ts-ignore` stays banned everywhere. + +### Acceptance Examples + +- AE1. **Covers R4.** Given a test passing `{ settings: {} as any, content: "..." }` to a compressor to verify its runtime validation, when the pass lands, then the cast is gone and the line above carries `@ts-expect-error` plus a comment naming the deliberately-missing settings fields. +- AE2. **Covers R1, KD3.** Given a future contributor fixes a compressor's signature so a previously-invalid test input becomes valid, when they run `bun run typecheck`, then the now-unused `@ts-expect-error` fails the check — the stale suppression cannot survive silently. +- AE3. **Covers R8.** Given a future PR introduces `as any` in any file, src or test, when CI runs `bun run lint`, then Biome fails the build. +- AE4. **Covers R6.** Given a mock like `{ paginate, rest: {...} } as unknown as ReturnType` that exists only because the mock object is partially shaped, when a real partial-mock type (or a commented directive) can express the same intent, then the double-cast is replaced accordingly — no double-cast survives on convenience alone. + +### Scope Boundaries + +- No behavioral changes to any package — this pass changes types, directives, and config only; test assertions and runtime logic stay as they are. +- No new strictness flags beyond what the total-typescript base already provides (`strict`, `noUncheckedIndexedAccess`, `noImplicitOverride` are already on). +- No cleanup of `docs/` (separate Astro site) — scanned clean of explicit `any`, so repo-wide enforcement lands there with zero code changes. +- No changeset — internal refactor with no user-facing package changes, unless typecheck fixes force a published-type correction. + +### Success Criteria + +- `bun run ci` (build, lint, typecheck, test) passes end-to-end with tests under typecheck and `noExplicitAny` on. +- Grepping the repo for `as any` and `as unknown as` returns zero matches; every `@ts-expect-error` has an adjacent explanation comment. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **Per-package tsconfig `include` widening, not a root `tsconfig.tests.json`.** (session-settled: user-approved — chosen over a root-level test tsconfig: no new files, no CI wiring, reuses each package's existing `tsc --noEmit` script.) Widening `include` alone fails: `rootDir: "./src"` rejects test files with `TS6059` regardless of `noEmit` (verified by live repro in `packages/core` and `packages/clean-css`), so the `rootDir` override is removed from all 22 package tsconfigs alongside the widening — harmless under `noEmit: true` since declaration emit runs through tsdown, which derives its own paths. Cross-package test imports (`../..//src/index.ts`, `../../../tests/*.ts`) resolve via existing relative paths and `node_modules/@node-minify/*` workspace symlinks. Implements KD3; governs R1, R2. +- KTD2. **Plain `tsc --noEmit` performs the test typecheck, not Vitest's typecheck mode.** The per-package `typecheck` script already exists and CI already runs it (`bun run ci` → `bun run --filter '*' typecheck`); Vitest typecheck would add a second, parallel mechanism for the same guarantee. Governs R3. +- KTD3. **Delete `tests/tsconfig.json`.** (session-settled: user-approved — surfaced as a call-out: once packages typecheck their tests directly, the orphaned config with its stale `paths` mapping to `packages/types/src/types.d.ts` misleads; nothing consumes it.) +- KTD4. **Config-first sequencing within a single PR.** Widen typecheck coverage first (U2), then fix the errors it reveals area by area. An `@ts-expect-error` directive can only be verified correct in a checked file, so adding directives before coverage would be blind work. `bun run typecheck` is red between U2 and U5 — acceptable inside one branch, never merged red. + +### Assumptions + +- The exact count of type errors revealed by widening coverage will exceed the grep-visible cast count (casts were added where errors were *noticed*, not everywhere they exist). The fix categories stay the same; volume may grow. + +--- + +## Implementation Units + +### U1. Type the shared test fixtures + +- **Goal:** `tests/fixtures.ts` and `tests/files-path.ts` compile clean with real types, since every package's tests import them. +- **Requirements:** R7, R1. +- **Dependencies:** None. +- **Files:** `tests/fixtures.ts`, `tests/files-path.ts`. +- **Approach:** + 1. Type `TestOptions.compressor` and the `setCompressor` parameter as `Compressor` from `@node-minify/types` (import via the workspace package, matching how package tests import it). + 2. Replace the `(error as any).code` narrowing (lines ~185-187) with a typed guard: narrow `error` via `in`-checks to an object with a `string` `code` property — no cast. +- **Patterns to follow:** `packages/core/__tests__/core.test.ts` imports `type { Compressor, Settings } from "@node-minify/types"` — same import shape. +- **Test scenarios:** Test expectation: none — pure typing change to shared helpers; the existing full suite (`bun run test`) passing unchanged is the behavioral proof. +- **Verification:** `bun run test` passes; no `any` remains in `tests/*.ts`. + +### U2. Bring test files under typecheck + +- **Goal:** All 22 package tsconfigs typecheck `__tests__/**/*`; `packages/types` enters the typecheck gate; the orphaned test tsconfig is gone. +- **Requirements:** R1, R2, R3. Implements KTD1, KTD2, KTD3. +- **Dependencies:** U1. +- **Files:** `packages/*/tsconfig.json` (all 22), `packages/types/tsconfig.json` (create), `packages/types/package.json`, `tests/tsconfig.json` (delete). +- **Approach:** + 1. Widen every package's `include` to `["src/**/*", "__tests__/**/*"]` and remove the `rootDir: "./src"` override (per KTD1 — `rootDir` rejects test files with `TS6059` regardless of `noEmit`; tsdown derives build paths independently). + 2. Remove the `"exclude": [..., "__tests__"]` entries in `packages/imagemin/tsconfig.json` and `packages/svgo/tsconfig.json` (per R2). + 3. Add a minimal `packages/types/tsconfig.json` (extends root, includes `src/**/*`) and a `"typecheck": "tsc --noEmit"` script to `packages/types/package.json` so the `--filter '*'` loop picks it up (per R3). + 4. Delete `tests/tsconfig.json` (KTD3). Shared `tests/*.ts` helpers are checked transitively through package-test imports. +- **Execution note:** `bun run typecheck` goes red here and stays red until U5 lands — that is the expected state inside this branch (KTD4). Capture the initial error inventory per package to drive U3-U5. +- **Test scenarios:** Test expectation: none — config-only unit; proof is typecheck coverage observable via deliberate error (see Verification). +- **Verification:** `tsc --noEmit` in any package now reports errors from its `__tests__` files (coverage proof); a scratch `as any`-shaped type error added to a test file is reported and then removed; `bun run typecheck` now emits 23 per-package result lines including `@node-minify/types`; `bun run build && bun run check-exports` still passes after the `rootDir` removal (attw reads published `dist/` output). + +### U3. Convert casts in utils and core tests + +- **Goal:** The two heaviest test suites compile clean under the new coverage. +- **Requirements:** R4, R5, R6. Governed by KD1, KD4. +- **Dependencies:** U2. +- **Files:** `packages/utils/__tests__/utils.test.ts` (~54 `as any`, 3 `as unknown as string`, 1 bare directive area), `packages/utils/__tests__/getContentFromFilesAsync.test.ts`, `packages/utils/__tests__/setPublicFolder.test.ts`, `packages/core/__tests__/core.test.ts`, `packages/core/__tests__/compress_async.test.ts`, `packages/core/__tests__/compress-paths.test.ts`, `packages/core/__tests__/setup.test.ts`, `packages/google-closure-compiler/src/index.ts` (implicit-any params, cross-package visibility). +- **Approach:** + 1. Invalid-input casts (`null as any`, `123 as any`, `{} as any` settings, `undefined as unknown as string`) → `@ts-expect-error` + comment naming the invalid condition, per AE1. + 2. Partial-but-valid settings objects (`{ compressor, input, output } as any` where the object is legitimately shaped but incomplete) → prefer typing as `Settings` with only optional fields omitted; fall back to `@ts-expect-error` when the omission *is* the test. + 3. `"fake" as unknown as Compressor`-style casts (core.test.ts:35,108,161) and `setup.test.ts:18` → `@ts-expect-error` + comment (a string is deliberately not a `Compressor`). + 4. Cross-package src imports entering core's program (`core.test.ts` imports `../../google-closure-compiler/src/index.ts`): gcc's ambient `declare module` in `packages/google-closure-compiler/src/types.d.ts` is invisible to core's compile, surfacing 5 errors (`TS7016` + 4 implicit-any `TS7006`). Fix gcc's `src/index.ts` implicit-any parameters with real types so the file typechecks in any including program; apply the same treatment to any other cross-package src import U2's error inventory reveals. +- **Patterns to follow:** `packages/utils/__tests__/setPublicFolder.test.ts:7` — `// @ts-expect-error testing invalid input` is the existing in-repo model; extend its comment style with the specific condition. +- **Test scenarios:** + - All existing utils and core tests pass unchanged (`bun run test packages/utils packages/core`) — assertions untouched. + - Each added directive suppresses a real error: `tsc --noEmit` in both packages is clean, proving no directive is unused (unused `@ts-expect-error` is itself an error). +- **Verification:** `tsc --noEmit` clean in `packages/utils` and `packages/core`; zero `as any` / `as unknown as` matches in both `__tests__` directories. + +### U4. Convert casts in compressor package tests + +- **Goal:** The small per-compressor error-path tests compile clean. +- **Requirements:** R4, R5, R6. +- **Dependencies:** U2. Can proceed in parallel with U3. +- **Files:** `__tests__` files in `packages/{clean-css,cssnano,csso,html-minifier,jsonminify,minify-html,no-compress,oxc,terser,sharp,imagemin,google-closure-compiler}`. +- **Approach:** + 1. The repeated `{ settings: {} as any, content: ... }` pattern (~15 sites) → `@ts-expect-error` + comment (`settings` deliberately missing required fields); one consistent comment phrasing across all compressor tests. + 2. `"not a buffer" as unknown as Buffer` (sharp, imagemin) → `@ts-expect-error` + comment. + 3. The 4 bare `@ts-expect-error` in `packages/sharp/__tests__/sharp.test.ts` (mock implementations narrower than sharp's type) → add reason comments (R5); if the suppressed error no longer exists under checking, delete the directive instead. + 4. `mock.onRun?.(child as unknown as FakeChild, ...)` (google-closure-compiler) → give the fake child a real type or a commented directive, per AE4. +- **Test scenarios:** + - All compressor package tests pass unchanged. + - `tsc --noEmit` clean in each touched package (proves every directive is live). +- **Verification:** Zero cast matches across all compressor `__tests__` directories; per-package typecheck clean. + +### U5. Convert casts in action, cli, and benchmark tests + +- **Goal:** The mock-heavy suites compile clean; octokit mocks get a real partial-mock shape. +- **Requirements:** R3, R4, R6. AE4 is decided here. +- **Dependencies:** U2. Can proceed in parallel with U3/U4. +- **Files:** `packages/action/__tests__/{index,minify,runAutoMode,runExplicitMode,comment,compare}.test.ts`, `packages/cli/__tests__/{cli,spinner}.test.ts`, `packages/benchmark/__tests__/coverage.test.ts`. +- **Approach:** + 1. `vi.mocked(stat).mockResolvedValue({ size: N } as any)` (~15 sites) → type the partial as the minimal `Stats` shape the code under test reads; if `vi.mocked`'s signature rejects partials, a commented `@ts-expect-error` naming the partial-mock intent. + 2. `as unknown as ReturnType` (~15 sites in comment/compare tests) → extract one typed helper per test file (e.g., a function returning the partial mock with a single documented cast or directive at its definition) so the suppression exists once per file, not 15 times — per AE4, no per-site double-cast survives. + 3. `(context as any).payload = {...}` → type via the actual `@actions/github` context type's writable shape, or one commented directive. + 4. Invalid-input casts (`"invalid-compressor" as any`, `null as any` input) → `@ts-expect-error` + comment, per AE1. +- **Patterns to follow:** `packages/action/__tests__/comment.test.ts:50-53` already centralizes the octokit mock in a helper — extend that helper rather than inventing a new structure. +- **Test scenarios:** + - All action, cli, and benchmark tests pass unchanged. + - The octokit mock helper is the only suppression site for octokit shapes — grep confirms no inline `as unknown as ReturnType` remains. + - `tsc --noEmit` clean in all three packages. +- **Verification:** Zero cast matches in the three packages' `__tests__`; `bun run typecheck` now green repo-wide (first green since U2). + +### U6. Enable lint enforcement and amend AGENTS.md + +- **Goal:** Regressions are caught mechanically and the documented convention matches the enforced one. +- **Requirements:** R8, R9. Implements KD2. +- **Dependencies:** U3, U4, U5 (lint can only flip once the repo is clean). +- **Files:** `biome.json`, `AGENTS.md`. +- **Approach:** + 1. Remove `"noExplicitAny": "off"` from `biome.json` (recommended set enables it) — no test override (KD2). + 2. Amend the root AGENTS.md anti-patterns: `as any` banned everywhere; `@ts-ignore` banned everywhere; `@ts-expect-error` banned in `src/`, sanctioned in test files only with a mandatory reason comment. + 3. Run `bun run lint` and verify zero additional `any` sites exist beyond the files U1/U3-U5 enumerate — repo greps already confirm none, so this is a verification step, not open-ended fix work. +- **Test scenarios:** Test expectation: none — config and docs unit; the lint run itself is the proof (see Verification, AE3). +- **Verification:** `bun run lint` clean; a scratch `as any` added to any file fails lint and is removed (AE3 proof). + +--- + +## Verification Contract + +| Check | Command | Proves | +|---|---|---| +| Typecheck (tests included) | `bun run typecheck` | R1, R2, R3; every `@ts-expect-error` is live (AE2) | +| Lint | `bun run lint` | R8 (AE3) | +| Full test suite | `bun run test` | Behavior preserved — zero assertion changes | +| Build + exports | `bun run build && bun run check-exports` | Published type surfaces unaffected | +| Full gate | `bun run ci` | Success criteria end-to-end | +| Cast grep | grep for `as any` and `as unknown as` across all source roots (`packages/`, `tests/`, `scripts/`, `examples/`, `docs/src/`) | R4, R6 — zero matches | + +--- + +## Definition of Done + +- All six units landed; `bun run ci` green. +- Zero `as any` / `as unknown as T` in the repo; every `@ts-expect-error` carries a reason comment and suppresses a live error. +- `biome.json` enforces `noExplicitAny` with no test exemption; AGENTS.md documents the amended convention. +- `tests/tsconfig.json` deleted; no orphaned or divergent tsconfig remains. +- No test assertion, runtime logic, or published API changed; no scratch/probe edits (U2, U6 verification probes) left in the diff. diff --git a/package.json b/package.json index 50cd7f1e9..212487cda 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/action/__tests__/benchmark.test.ts b/packages/action/__tests__/benchmark.test.ts index 89e3380e6..a59c27c48 100644 --- a/packages/action/__tests__/benchmark.test.ts +++ b/packages/action/__tests__/benchmark.test.ts @@ -26,6 +26,9 @@ describe("runBenchmark", () => { minReduction: 0, includeGzip: true, workingDirectory: ".", + auto: false, + outputDir: "dist", + dryRun: false, }; beforeEach(() => { diff --git a/packages/action/__tests__/comment.test.ts b/packages/action/__tests__/comment.test.ts index 0ca2b4aa3..05e190a9e 100644 --- a/packages/action/__tests__/comment.test.ts +++ b/packages/action/__tests__/comment.test.ts @@ -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); + }; + // @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 }; } @@ -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); + 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") diff --git a/packages/action/__tests__/compare.test.ts b/packages/action/__tests__/compare.test.ts index 035af29be..f33b24cd3 100644 --- a/packages/action/__tests__/compare.test.ts +++ b/packages/action/__tests__/compare.test.ts @@ -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) { + 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 = { @@ -307,11 +323,7 @@ describe("compareWithBase", () => { data: { type: "file", size: 3500 }, }); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); const result = await compareWithBase(mockResult, "fake-token"); @@ -357,11 +369,7 @@ describe("compareWithBase", () => { data: { type: "file", size: 3500 }, }); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); const result = await compareWithBase(explicitResultWithOutput, "token"); @@ -401,11 +409,7 @@ describe("compareWithBase", () => { data: { type: "file", size: 3500 }, }); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); await compareWithBase(explicitResultWithWindowsPath, "token"); @@ -444,11 +448,7 @@ describe("compareWithBase", () => { data: { type: "file", size: 3500 }, }); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); await compareWithBase(explicitResultWithUnsafeOutput, "token"); @@ -487,11 +487,7 @@ describe("compareWithBase", () => { }; const mockGetContent = vi.fn(); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); const result = await compareWithBase(resultWithUnsafePaths, "token"); @@ -523,11 +519,7 @@ describe("compareWithBase", () => { }); const mockGetContent = vi.fn().mockRejectedValue(notFoundError); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); const result = await compareWithBase(mockResult, "fake-token"); @@ -546,11 +538,7 @@ describe("compareWithBase", () => { data: [{ type: "file", name: "index.js" }], }); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); const result = await compareWithBase(mockResult, "fake-token"); @@ -566,11 +554,7 @@ describe("compareWithBase", () => { data: { type: "symlink", target: "some-target" }, }); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); const result = await compareWithBase(mockResult, "fake-token"); @@ -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); + mockOctokit(mockGetContent); const result = await compareWithBase(mockResult, "fake-token"); @@ -608,11 +588,7 @@ describe("compareWithBase", () => { data: { type: "file", size: 0 }, }); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); const result = await compareWithBase(mockResult, "fake-token"); @@ -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); + mockOctokit(mockGetContent); const result = await compareWithBase(multiFileResult, "fake-token"); @@ -692,11 +664,7 @@ describe("compareWithBase", () => { data: { type: "file", size: 0 }, }); - vi.mocked(getOctokit).mockReturnValue({ - rest: { - repos: { getContent: mockGetContent }, - }, - } as unknown as ReturnType); + mockOctokit(mockGetContent); const result = await compareWithBase(zeroResult, "fake-token"); @@ -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); + mockOctokit(mockGetContent); const result = await compareWithBase(mockResult, "fake-token"); diff --git a/packages/action/__tests__/index.test.ts b/packages/action/__tests__/index.test.ts index add221074..7fbb5ec4e 100644 --- a/packages/action/__tests__/index.test.ts +++ b/packages/action/__tests__/index.test.ts @@ -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"); @@ -63,7 +64,7 @@ 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(); @@ -71,7 +72,7 @@ describe("run", () => { }); 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(); @@ -96,7 +97,7 @@ 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(); @@ -104,7 +105,7 @@ describe("run", () => { }); 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(); @@ -112,14 +113,14 @@ describe("run", () => { }); 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(); diff --git a/packages/action/__tests__/minify.test.ts b/packages/action/__tests__/minify.test.ts index 9150c0477..050230660 100644 --- a/packages/action/__tests__/minify.test.ts +++ b/packages/action/__tests__/minify.test.ts @@ -1,8 +1,10 @@ /*! node-minify action tests - MIT Licensed */ +import type { Stats } from "node:fs"; import { stat } from "node:fs/promises"; import path from "node:path"; import { minify } from "@node-minify/core"; +import type { CompressorResolution } from "@node-minify/utils"; import { getFilesizeGzippedRaw, resolveCompressor } from "@node-minify/utils"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { runMinification } from "../src/minify.ts"; @@ -52,12 +54,13 @@ describe("runMinification", () => { test("should perform basic minification and calculate sizes", async () => { vi.mocked(stat) - .mockResolvedValueOnce({ size: 1000 } as any) - .mockResolvedValueOnce({ size: 500 } as any); + .mockResolvedValueOnce({ size: 1000 } as Stats) + .mockResolvedValueOnce({ size: 500 } as Stats); vi.mocked(resolveCompressor).mockResolvedValue({ compressor: vi.fn(), label: "terser", - } as any); + isBuiltIn: true, + } as CompressorResolution); vi.mocked(minify).mockResolvedValue("minified content"); vi.mocked(getFilesizeGzippedRaw).mockResolvedValue(300); @@ -85,12 +88,13 @@ describe("runMinification", () => { test("should include gzip size when includeGzip is true", async () => { vi.mocked(stat) - .mockResolvedValueOnce({ size: 1000 } as any) - .mockResolvedValueOnce({ size: 500 } as any); + .mockResolvedValueOnce({ size: 1000 } as Stats) + .mockResolvedValueOnce({ size: 500 } as Stats); vi.mocked(resolveCompressor).mockResolvedValue({ compressor: vi.fn(), label: "terser", - } as any); + isBuiltIn: true, + } as CompressorResolution); vi.mocked(getFilesizeGzippedRaw).mockResolvedValue(300); const result = await runMinification({ @@ -105,12 +109,13 @@ describe("runMinification", () => { test("should skip gzip size when includeGzip is false", async () => { vi.mocked(stat) - .mockResolvedValueOnce({ size: 1000 } as any) - .mockResolvedValueOnce({ size: 500 } as any); + .mockResolvedValueOnce({ size: 1000 } as Stats) + .mockResolvedValueOnce({ size: 500 } as Stats); vi.mocked(resolveCompressor).mockResolvedValue({ compressor: vi.fn(), label: "terser", - } as any); + isBuiltIn: true, + } as CompressorResolution); const result = await runMinification({ ...mockInputs, @@ -124,12 +129,13 @@ describe("runMinification", () => { test("should calculate zero reduction when sizes are equal", async () => { vi.mocked(stat) - .mockResolvedValueOnce({ size: 1000 } as any) - .mockResolvedValueOnce({ size: 1000 } as any); + .mockResolvedValueOnce({ size: 1000 } as Stats) + .mockResolvedValueOnce({ size: 1000 } as Stats); vi.mocked(resolveCompressor).mockResolvedValue({ compressor: vi.fn(), label: "terser", - } as any); + isBuiltIn: true, + } as CompressorResolution); const result = await runMinification(mockInputs); expect(result.totalReduction).toBe(0); @@ -137,24 +143,26 @@ describe("runMinification", () => { test("should handle zero original size", async () => { vi.mocked(stat) - .mockResolvedValueOnce({ size: 0 } as any) - .mockResolvedValueOnce({ size: 0 } as any); + .mockResolvedValueOnce({ size: 0 } as Stats) + .mockResolvedValueOnce({ size: 0 } as Stats); vi.mocked(resolveCompressor).mockResolvedValue({ compressor: vi.fn(), label: "terser", - } as any); + isBuiltIn: true, + } as CompressorResolution); const result = await runMinification(mockInputs); expect(result.totalReduction).toBe(0); }); test("should pass type and options to minify", async () => { - vi.mocked(stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(stat).mockResolvedValue({ size: 100 } as Stats); const mockComp = vi.fn(); vi.mocked(resolveCompressor).mockResolvedValue({ compressor: mockComp, label: "esbuild", - } as any); + isBuiltIn: true, + } as CompressorResolution); const inputs = { ...mockInputs, @@ -176,12 +184,13 @@ describe("runMinification", () => { test("should store outputFile as repository-relative path for absolute output", async () => { vi.mocked(stat) - .mockResolvedValueOnce({ size: 1000 } as any) - .mockResolvedValueOnce({ size: 500 } as any); + .mockResolvedValueOnce({ size: 1000 } as Stats) + .mockResolvedValueOnce({ size: 500 } as Stats); vi.mocked(resolveCompressor).mockResolvedValue({ compressor: vi.fn(), label: "terser", - } as any); + isBuiltIn: true, + } as CompressorResolution); vi.mocked(minify).mockResolvedValue("minified content"); const absoluteOutput = path.resolve(process.cwd(), "dist/app.min.js"); @@ -195,12 +204,13 @@ describe("runMinification", () => { test("should include working-directory prefix in outputFile", async () => { vi.mocked(stat) - .mockResolvedValueOnce({ size: 1000 } as any) - .mockResolvedValueOnce({ size: 500 } as any); + .mockResolvedValueOnce({ size: 1000 } as Stats) + .mockResolvedValueOnce({ size: 500 } as Stats); vi.mocked(resolveCompressor).mockResolvedValue({ compressor: vi.fn(), label: "terser", - } as any); + isBuiltIn: true, + } as CompressorResolution); vi.mocked(minify).mockResolvedValue("minified content"); const result = await runMinification({ diff --git a/packages/action/__tests__/runAutoMode.test.ts b/packages/action/__tests__/runAutoMode.test.ts index 22a2acd83..db3213e2d 100644 --- a/packages/action/__tests__/runAutoMode.test.ts +++ b/packages/action/__tests__/runAutoMode.test.ts @@ -1,10 +1,12 @@ /*! node-minify action tests - MIT Licensed */ +import type { Stats } from "node:fs"; import { mkdir, stat } from "node:fs/promises"; import path from "node:path"; import * as core from "@actions/core"; import { context } from "@actions/github"; import { minify } from "@node-minify/core"; +import type { CompressorResolution } from "@node-minify/utils"; import { getFilesizeGzippedRaw, resolveCompressor } from "@node-minify/utils"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { addAnnotations } from "../src/annotations.ts"; @@ -58,11 +60,12 @@ describe("runAutoMode", () => { beforeEach(() => { vi.clearAllMocks(); (context as { payload: Record }).payload = {}; - vi.mocked(stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(stat).mockResolvedValue({ size: 100 } as Stats); vi.mocked(resolveCompressor).mockResolvedValue({ compressor: vi.fn(), label: "terser", - } as any); + isBuiltIn: true, + } as CompressorResolution); vi.mocked(selectCompressor).mockReturnValue({ compressor: "terser", package: "@node-minify/terser", @@ -170,8 +173,8 @@ describe("runAutoMode", () => { svg: [], unknown: [], }); - vi.mocked(stat).mockResolvedValueOnce({ size: 100 } as any); - vi.mocked(stat).mockResolvedValueOnce({ size: 50 } as any); + vi.mocked(stat).mockResolvedValueOnce({ size: 100 } as Stats); + vi.mocked(stat).mockResolvedValueOnce({ size: 50 } as Stats); await runAutoMode(mockInputs); @@ -324,7 +327,7 @@ describe("runAutoMode", () => { svg: [], unknown: [], }); - vi.mocked(stat).mockResolvedValue({ size: 0 } as any); + vi.mocked(stat).mockResolvedValue({ size: 0 } as Stats); await runAutoMode(mockInputs); diff --git a/packages/action/__tests__/runExplicitMode.test.ts b/packages/action/__tests__/runExplicitMode.test.ts index dba0fb13f..751a1e1d6 100644 --- a/packages/action/__tests__/runExplicitMode.test.ts +++ b/packages/action/__tests__/runExplicitMode.test.ts @@ -16,7 +16,12 @@ import { generateBenchmarkSummary, generateSummary, } from "../src/reporters/summary.ts"; -import type { ActionInputs, MinifyResult } from "../src/types.ts"; +import type { + ActionInputs, + BenchmarkResult, + ComparisonResult, + MinifyResult, +} from "../src/types.ts"; vi.mock("@actions/core"); vi.mock("@actions/github"); @@ -79,7 +84,7 @@ describe("runExplicitMode", () => { vi.mocked(runMinification).mockResolvedValue(mockResult); vi.mocked(checkThresholds).mockReturnValue(null); // Reset context - (context as any).payload = {}; + (context as { payload: Record }).payload = {}; }); test("1. Compressor validation error", async () => { @@ -112,11 +117,19 @@ describe("runExplicitMode", () => { }); test("4. PR comment posting (when in PR context + enabled)", async () => { - (context as any).payload = { pull_request: { number: 123 } }; - const comparisons = [ - { file: "src/app.js", baseSize: 1200, diff: -200 }, + (context as { payload: Record }).payload = { + pull_request: { number: 123 }, + }; + const comparisons: ComparisonResult[] = [ + { + file: "src/app.js", + baseSize: 1200, + currentSize: 1000, + change: -16.7, + isNew: false, + }, ]; - vi.mocked(compareWithBase).mockResolvedValue(comparisons as any); + vi.mocked(compareWithBase).mockResolvedValue(comparisons); await runExplicitMode({ ...mockInputs, reportPRComment: true }); @@ -142,13 +155,15 @@ describe("runExplicitMode", () => { }); test("7. Benchmark mode enabled with multiple compressors", async () => { - const benchmarkResult = { - results: [], + const benchmarkResult: BenchmarkResult = { + file: "src/app.js", + originalSize: 1000, + compressors: [], recommended: "esbuild", bestCompression: "esbuild", bestSpeed: "swc", }; - vi.mocked(runBenchmark).mockResolvedValue(benchmarkResult as any); + vi.mocked(runBenchmark).mockResolvedValue(benchmarkResult); const inputs = { ...mockInputs, benchmark: true, @@ -168,11 +183,13 @@ describe("runExplicitMode", () => { }); test("8. Benchmark winner logging", async () => { - const benchmarkResult = { - results: [], + const benchmarkResult: BenchmarkResult = { + file: "src/app.js", + originalSize: 1000, + compressors: [], recommended: "esbuild", }; - vi.mocked(runBenchmark).mockResolvedValue(benchmarkResult as any); + vi.mocked(runBenchmark).mockResolvedValue(benchmarkResult); await runExplicitMode({ ...mockInputs, benchmark: true }); @@ -203,7 +220,7 @@ describe("runExplicitMode", () => { }); test("11. No PR comment when not in PR context", async () => { - (context as any).payload = {}; // No pull_request + (context as { payload: Record }).payload = {}; // No pull_request await runExplicitMode({ ...mockInputs, reportPRComment: true }); @@ -212,7 +229,9 @@ describe("runExplicitMode", () => { }); test("12. Combined: summary + PR comment + annotations", async () => { - (context as any).payload = { pull_request: { number: 123 } }; + (context as { payload: Record }).payload = { + pull_request: { number: 123 }, + }; const inputs = { ...mockInputs, reportSummary: true, diff --git a/packages/action/tsconfig.json b/packages/action/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/action/tsconfig.json +++ b/packages/action/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/benchmark/__tests__/coverage.test.ts b/packages/benchmark/__tests__/coverage.test.ts index ee3f3091a..148e9f16d 100644 --- a/packages/benchmark/__tests__/coverage.test.ts +++ b/packages/benchmark/__tests__/coverage.test.ts @@ -1,12 +1,15 @@ +import type * as NodeFs from "node:fs"; +import type * as NodeMinifyUtils from "@node-minify/utils"; import { describe, expect, test, vi } from "vitest"; import * as compressorLoader from "../src/compressor-loader.ts"; import { loadCompressor } from "../src/compressor-loader.ts"; import { formatConsoleOutput } from "../src/reporters/console.ts"; import { formatMarkdownOutput } from "../src/reporters/markdown.ts"; import { runBenchmark } from "../src/runner.ts"; +import type { BenchmarkResult } from "../src/types.ts"; vi.mock("node:fs", async () => { - const actual = await vi.importActual("node:fs"); + const actual = await vi.importActual("node:fs"); return { ...actual, statSync: vi.fn((file) => { @@ -36,7 +39,8 @@ vi.mock("node:fs", async () => { }); vi.mock("@node-minify/utils", async () => { - const actual = await vi.importActual("@node-minify/utils"); + const actual = + await vi.importActual("@node-minify/utils"); return { ...actual, getFilesizeGzippedInBytes: vi.fn().mockResolvedValue("100 B"), @@ -107,7 +111,7 @@ describe("Coverage Gaps", () => { }); test("Reporters - handles gzip/brotli columns and errors", () => { - const mockResult = { + const mockResult: BenchmarkResult = { timestamp: "2024-01-01", options: { verbose: true }, summary: { @@ -146,7 +150,7 @@ describe("Coverage Gaps", () => { ], }; - const consoleOut = formatConsoleOutput(mockResult as any); + const consoleOut = formatConsoleOutput(mockResult); expect(consoleOut).toContain("Gzip"); expect(consoleOut).toContain("Brotli"); expect(consoleOut).toContain("0.3 KB"); @@ -154,7 +158,7 @@ describe("Coverage Gaps", () => { expect(consoleOut).toContain("Failed"); expect(consoleOut).toContain("90ms, 110ms"); - const markdownOut = formatMarkdownOutput(mockResult as any); + const markdownOut = formatMarkdownOutput(mockResult); expect(markdownOut).toContain("| Gzip |"); expect(markdownOut).toContain("| Brotli |"); expect(markdownOut).toContain("| 0.3 KB |"); @@ -163,7 +167,7 @@ describe("Coverage Gaps", () => { }); test("Reporters - verbose output", () => { - const mockResult = { + const mockResult: BenchmarkResult = { timestamp: "2024-01-01", options: { verbose: true }, summary: { @@ -190,7 +194,7 @@ describe("Coverage Gaps", () => { }, ], }; - const output = formatConsoleOutput(mockResult as any); + const output = formatConsoleOutput(mockResult); expect(output).toContain("└─ 100ms"); }); }); diff --git a/packages/benchmark/tsconfig.json b/packages/benchmark/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/benchmark/tsconfig.json +++ b/packages/benchmark/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/clean-css/__tests__/clean-css-error.test.ts b/packages/clean-css/__tests__/clean-css-error.test.ts index eb5291078..101f4668e 100644 --- a/packages/clean-css/__tests__/clean-css-error.test.ts +++ b/packages/clean-css/__tests__/clean-css-error.test.ts @@ -27,7 +27,8 @@ describe("Package: clean-css error handling", () => { const { cleanCss } = await import("../src/index.ts"); await expect( - cleanCss({ settings: {} as any, content: ".a { color: red; }" }) + // @ts-expect-error testing invalid input: settings is missing required fields + cleanCss({ settings: {}, content: ".a { color: red; }" }) ).rejects.toThrow("clean-css failed: empty result"); }); @@ -46,7 +47,8 @@ describe("Package: clean-css error handling", () => { const { cleanCss } = await import("../src/index.ts"); await expect( - cleanCss({ settings: {} as any, content: ".a { color: red; }" }) + // @ts-expect-error testing invalid input: settings is missing required fields + cleanCss({ settings: {}, content: ".a { color: red; }" }) ).rejects.toThrow("Invalid CSS syntax; Another error"); }); @@ -62,7 +64,8 @@ describe("Package: clean-css error handling", () => { const { cleanCss } = await import("../src/index.ts"); await expect( - cleanCss({ settings: {} as any, content: ".a { color: red; }" }) + // @ts-expect-error testing invalid input: settings is missing required fields + cleanCss({ settings: {}, content: ".a { color: red; }" }) ).rejects.toThrow("clean-css"); }); }); diff --git a/packages/clean-css/tsconfig.json b/packages/clean-css/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/clean-css/tsconfig.json +++ b/packages/clean-css/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/cli/__tests__/cli.test.ts b/packages/cli/__tests__/cli.test.ts index 68df09e2b..8366eb24f 100644 --- a/packages/cli/__tests__/cli.test.ts +++ b/packages/cli/__tests__/cli.test.ts @@ -30,21 +30,19 @@ describe("Package: cli", () => { }); describe("JavaScript compressors", () => { - test.each([ - ["terser"], - ["uglify-js"], - ["swc"], - ["oxc"], - ] as const)("should minify with %s", async (compressor) => { - const spy = vi.spyOn(cli, "run"); - await cli.run({ - compressor, - input: filesJS.oneFile, - output: filesJS.fileJSOut, - silence: true, - }); - expect(spy).toHaveBeenCalled(); - }); + test.each([["terser"], ["uglify-js"], ["swc"], ["oxc"]] as const)( + "should minify with %s", + async (compressor) => { + const spy = vi.spyOn(cli, "run"); + await cli.run({ + compressor, + input: filesJS.oneFile, + output: filesJS.fileJSOut, + silence: true, + }); + expect(spy).toHaveBeenCalled(); + } + ); test("should minify with esbuild (requires type)", async () => { const spy = vi.spyOn(cli, "run"); @@ -71,20 +69,19 @@ describe("JavaScript compressors", () => { }); describe("CSS compressors", () => { - test.each([ - ["clean-css"], - ["cssnano"], - ["csso"], - ] as const)("should minify with %s", async (compressor) => { - const spy = vi.spyOn(cli, "run"); - await cli.run({ - compressor, - input: filesCSS.fileCSS, - output: filesCSS.fileCSSOut, - silence: true, - }); - expect(spy).toHaveBeenCalled(); - }); + test.each([["clean-css"], ["cssnano"], ["csso"]] as const)( + "should minify with %s", + async (compressor) => { + const spy = vi.spyOn(cli, "run"); + await cli.run({ + compressor, + input: filesCSS.fileCSS, + output: filesCSS.fileCSSOut, + silence: true, + }); + expect(spy).toHaveBeenCalled(); + } + ); test("should minify with lightningcss", async () => { const spy = vi.spyOn(cli, "run"); @@ -264,7 +261,7 @@ describe("CLI Coverage", () => { describe("run dynamic import", () => { test("should throw if compressor not found", async () => { const settings = { - compressor: "invalid-compressor" as any, + compressor: "invalid-compressor", input: "foo.js", output: "bar.js", silence: true, @@ -279,7 +276,8 @@ describe("CLI Coverage", () => { await expect( cli.run({ compressor: "imagemin", - input: null as any, + // @ts-expect-error testing invalid input: input must be string | string[] | undefined, not null + input: null, output: filesImages.filePNGOut, silence: true, }) @@ -289,7 +287,7 @@ describe("CLI Coverage", () => { test("should throw if implementation is invalid (non-existent package)", async () => { const settings = { - compressor: "definitely-not-a-real-package-xyz" as any, + compressor: "definitely-not-a-real-package-xyz", input: "foo.js", output: "bar.js", silence: true, @@ -316,45 +314,45 @@ describe("CLI Coverage", () => { describe("compress default results", () => { test("should return default result if output is an array", async () => { const settings = { - compressor: () => ({ code: "minified" }), + compressor: async () => ({ code: "minified" }), content: "foo", output: ["bar.js"], }; - const result = await compress(settings as any); + const result = await compress(settings); expect(result.size).toBe("0"); }); test("should return default result if output contains $1", async () => { const settings = { - compressor: () => ({ code: "minified" }), + compressor: async () => ({ code: "minified" }), content: "foo", output: "$1.min.js", }; - const result = await compress(settings as any); + const result = await compress(settings); expect(result.size).toBe("0"); }); test("should throw if minify fails", async () => { const settings = { - compressor: () => { + compressor: async () => { throw new Error("Minify failed"); }, content: "foo", output: "bar.js", }; - await expect(compress(settings as any)).rejects.toThrow( + await expect(compress(settings)).rejects.toThrow( "Compression failed: Minify failed" ); }); test("should return default result when allowEmptyOutput skips writing", async () => { const settings = { - compressor: () => ({ code: "" }), + compressor: async () => ({ code: "" }), content: "/* comment only */", output: "/tmp/nonexistent-output-file.js", allowEmptyOutput: true, }; - const result = await compress(settings as any); + const result = await compress(settings); expect(result.size).toBe("0"); expect(result.sizeGzip).toBe("0"); }); diff --git a/packages/cli/__tests__/spinner.test.ts b/packages/cli/__tests__/spinner.test.ts index 73607c7dd..7ad2520ad 100644 --- a/packages/cli/__tests__/spinner.test.ts +++ b/packages/cli/__tests__/spinner.test.ts @@ -21,7 +21,7 @@ beforeEach(() => { describe("spinner", () => { test("spinnerStart sets a compressing message and starts", () => { - spinnerStart({ compressorLabel: "terser" } as unknown as Settings); + spinnerStart({ compressorLabel: "terser" } as Settings); expect(oraInstance.text).toContain("Compressing file(s)"); expect(oraInstance.start).toHaveBeenCalled(); }); @@ -31,13 +31,13 @@ describe("spinner", () => { compressorLabel: "terser", size: "1 kB", sizeGzip: "0.5 kB", - } as unknown as Result); + } as Result); expect(oraInstance.text).toContain("compressed successfully"); expect(oraInstance.succeed).toHaveBeenCalled(); }); test("spinnerError sets a failure message and fails", () => { - spinnerError({ compressorLabel: "terser" } as unknown as Settings); + spinnerError({ compressorLabel: "terser" } as Settings); expect(oraInstance.text).toContain("Error - file(s) not compressed"); expect(oraInstance.text).toContain("terser"); expect(oraInstance.fail).toHaveBeenCalled(); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 8ffe5db9f..7f04e9fab 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*", "../../tests/integration/**/*"] } diff --git a/packages/core/__tests__/compress-paths.test.ts b/packages/core/__tests__/compress-paths.test.ts index df41e1a33..b84bd2298 100644 --- a/packages/core/__tests__/compress-paths.test.ts +++ b/packages/core/__tests__/compress-paths.test.ts @@ -104,9 +104,10 @@ describe("compress path handling", () => { await expect( compress({ compressor, + // @ts-expect-error testing invalid input: array element is a number, not a string path input: [123, "b.js"], output: ["a.min.js", "b.min.js"], - } as unknown as Settings) + }) ).rejects.toThrow("got number"); }); }); diff --git a/packages/core/__tests__/compress_async.test.ts b/packages/core/__tests__/compress_async.test.ts index e0c5acc0d..b2a76fa17 100644 --- a/packages/core/__tests__/compress_async.test.ts +++ b/packages/core/__tests__/compress_async.test.ts @@ -35,7 +35,7 @@ describe("compress async", () => { compressor: noCompress, input: ["", "file.js"], output: ["out1.js", "out2.js"], - } as any; + }; await expect(compress(settings)).rejects.toThrow( "Invalid input at index 0: expected non-empty string, got empty string" diff --git a/packages/core/__tests__/core.test.ts b/packages/core/__tests__/core.test.ts index 610c41da5..3db839ea9 100644 --- a/packages/core/__tests__/core.test.ts +++ b/packages/core/__tests__/core.test.ts @@ -4,10 +4,10 @@ * MIT Licensed */ -import { statSync } from "node:fs"; +import { type Stats, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { Compressor, Settings } from "@node-minify/types"; +import type { Settings } from "@node-minify/types"; import { beforeEach, describe, expect, test, vi } from "vitest"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -32,7 +32,8 @@ describe("Package: core", async () => { describe("Fake binary", () => { test("should throw an error if binary does not exist", async () => { const settings: Settings = { - compressor: "fake" as unknown as Compressor, + // @ts-expect-error testing invalid input: compressor is a string, not a Compressor function + compressor: "fake", input: filesJS.oneFileWithWildcards, output: filesJS.fileJSOut, }; @@ -51,13 +52,14 @@ describe("Package: core", async () => { describe("No mandatory", () => { test("should throw an error if no compressor", async () => { - const settings: Partial = { + // @ts-expect-error testing invalid input: compressor is mandatory and intentionally omitted + const settings: Settings = { input: filesJS.oneFileWithWildcards, output: filesJS.fileJSOut, }; try { - return await minify(settings as Settings); + return await minify(settings); } catch (err: unknown) { if (err instanceof Error) { return expect(err.toString()).toEqual( @@ -104,14 +106,15 @@ describe("Package: core", async () => { describe("Mandatory", () => { test("should show throw on type option", async () => { - const settings: Partial = { - type: "uglifyjs" as unknown as "js", + const settings: Settings = { + // @ts-expect-error testing invalid input: "uglifyjs" is not a valid FileType ("js" | "css") + type: "uglifyjs", input: filesJS.oneFileWithWildcards, output: filesJS.fileJSOut, }; try { - return await minify(settings as Settings); + return await minify(settings); } catch (err: unknown) { if (err instanceof Error) { return expect(err.toString()).toEqual( @@ -158,7 +161,8 @@ describe("Package: core", async () => { test("should throw an error if binary does not exist", async () => { const settings: Settings = { - compressor: "fake" as unknown as Compressor, + // @ts-expect-error testing invalid input: compressor is a string, not a Compressor function + compressor: "fake", content: "
content
", }; @@ -212,14 +216,14 @@ describe("Package: core", async () => { }); test("should skip non-string paths in array", async () => { - const settings = { - compressor: () => ({ code: "minified" }), - input: [filesJS.oneFile], - output: [123 as any], - }; - await expect(minify(settings as any)).rejects.toThrow( - "Invalid target file path" - ); + await expect( + minify({ + compressor: async () => ({ code: "minified" }), + input: [filesJS.oneFile], + // @ts-expect-error testing invalid input: output array element is a number, not a string path + output: [123], + }) + ).rejects.toThrow("Invalid target file path"); }); test("should throw an error if an input in the array is an empty string", async () => { @@ -229,43 +233,46 @@ describe("Package: core", async () => { output: [filesJS.fileJSOut, filesJS.fileJSOut], }; - await expect(minify(settings as any)).rejects.toThrow( + await expect(minify(settings)).rejects.toThrow( "Invalid input at index 1: expected non-empty string, got empty string" ); }); test("should throw an error if an input in the array is null", async () => { - const settings = { - compressor: noCompress, - input: [filesJS.oneFile, null], - output: [filesJS.fileJSOut, filesJS.fileJSOut], - }; - - await expect(minify(settings as any)).rejects.toThrow( + await expect( + minify({ + compressor: noCompress, + // @ts-expect-error testing invalid input: array element is null, not a string path + input: [filesJS.oneFile, null], + output: [filesJS.fileJSOut, filesJS.fileJSOut], + }) + ).rejects.toThrow( "Invalid input at index 1: expected non-empty string, got object" ); }); test("should throw an error if an input in the array is undefined", async () => { - const settings = { - compressor: noCompress, - input: [filesJS.oneFile, undefined], - output: [filesJS.fileJSOut, filesJS.fileJSOut], - }; - - await expect(minify(settings as any)).rejects.toThrow( + await expect( + minify({ + compressor: noCompress, + // @ts-expect-error testing invalid input: array element is undefined, not a string path + input: [filesJS.oneFile, undefined], + output: [filesJS.fileJSOut, filesJS.fileJSOut], + }) + ).rejects.toThrow( "Invalid input at index 1: expected non-empty string, got undefined" ); }); test("should throw an error if an input in the array is a number", async () => { - const settings = { - compressor: noCompress, - input: [filesJS.oneFile, 123], - output: [filesJS.fileJSOut, filesJS.fileJSOut], - }; - - await expect(minify(settings as any)).rejects.toThrow( + await expect( + minify({ + compressor: noCompress, + // @ts-expect-error testing invalid input: array element is a number, not a string path + input: [filesJS.oneFile, 123], + output: [filesJS.fileJSOut, filesJS.fileJSOut], + }) + ).rejects.toThrow( "Invalid input at index 1: expected non-empty string, got number" ); }); @@ -285,20 +292,20 @@ describe("Package: core", async () => { test("should handle missing directory path", async () => { const settings = { - compressor: () => ({ code: "minified" }), + compressor: async () => ({ code: "minified" }), content: "foo", output: "bar.js", }; - await minify(settings as any); + await minify(settings); }); test("should handle missing filePath", async () => { const settings = { - compressor: () => ({ code: "minified" }), + compressor: async () => ({ code: "minified" }), content: "foo", output: "", }; - await minify(settings as any); + await minify(settings); }); test("should handle directoryExists returning false (catch block)", async () => { @@ -306,23 +313,23 @@ describe("Package: core", async () => { throw new Error("Not found"); }); const settings = { - compressor: () => ({ code: "minified" }), + compressor: async () => ({ code: "minified" }), content: "foo", output: "newdir/bar.js", // Must have a slash }; - await minify(settings as any); + await minify(settings); }); test("should handle directoryExists returning false (isDirectory false)", async () => { - vi.mocked(statSync).mockImplementationOnce(() => { - return { isDirectory: () => false } as any; - }); + vi.mocked(statSync).mockImplementationOnce( + () => ({ isDirectory: () => false }) as Stats + ); const settings = { - compressor: () => ({ code: "minified" }), + compressor: async () => ({ code: "minified" }), content: "foo", output: "notadir/bar.js", // Must have a slash }; - await minify(settings as any); + await minify(settings); }); }); @@ -358,15 +365,16 @@ describe("Package: core", async () => { compressor: noCompress, input: ["foo.js"], output: ["bar.js"], - } as any); + }); expect(result.output).toEqual(["bar.js"]); }); test("should handle publicFolder as non-string", async () => { - const settings: any = { + const settings: Settings = { compressor: noCompress, input: filesJS.oneFile, output: filesJS.fileJSOut, + // @ts-expect-error testing invalid input: publicFolder must be a string, not a number publicFolder: 123, }; diff --git a/packages/core/__tests__/setup.test.ts b/packages/core/__tests__/setup.test.ts index a64d98573..cd878305c 100644 --- a/packages/core/__tests__/setup.test.ts +++ b/packages/core/__tests__/setup.test.ts @@ -1,6 +1,6 @@ /*! node-minify core setup tests - MIT Licensed */ -import type { Settings } from "@node-minify/types"; +import type { Compressor } from "@node-minify/types"; import { describe, expect, test, vi } from "vitest"; // Keep input untouched so checkOutput receives the literal paths instead of @@ -15,7 +15,7 @@ vi.mock("@node-minify/utils", async (importOriginal) => { import { setup } from "../src/setup.ts"; -const compressor = (() => ({ code: "" })) as unknown as Settings["compressor"]; +const compressor: Compressor = async () => ({ code: "" }); describe("setup $1 output handling", () => { test("rewrites the $1 placeholder for a single file", () => { diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/cssnano/__tests__/cssnano-error.test.ts b/packages/cssnano/__tests__/cssnano-error.test.ts index 68e7bf9e5..d8204443c 100644 --- a/packages/cssnano/__tests__/cssnano-error.test.ts +++ b/packages/cssnano/__tests__/cssnano-error.test.ts @@ -25,7 +25,8 @@ describe("Package: cssnano error handling", () => { const { cssnano } = await import("../src/index.ts"); await expect( - cssnano({ settings: {} as any, content: ".a { color: red; }" }) + // @ts-expect-error testing invalid input: settings is missing required fields + cssnano({ settings: {}, content: ".a { color: red; }" }) ).rejects.toThrow( "cssnano minification failed: cssnano failed: empty or invalid result" ); diff --git a/packages/cssnano/tsconfig.json b/packages/cssnano/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/cssnano/tsconfig.json +++ b/packages/cssnano/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/csso/__tests__/csso-error.test.ts b/packages/csso/__tests__/csso-error.test.ts index 8a8384d60..6198e2cef 100644 --- a/packages/csso/__tests__/csso-error.test.ts +++ b/packages/csso/__tests__/csso-error.test.ts @@ -23,7 +23,8 @@ describe("Package: csso error handling", () => { const { csso } = await import("../src/index.ts"); await expect( - csso({ settings: {} as any, content: ".a { color: red; }" }) + // @ts-expect-error testing invalid input: settings is missing required fields + csso({ settings: {}, content: ".a { color: red; }" }) ).rejects.toThrow("csso failed: empty result"); }); @@ -37,7 +38,8 @@ describe("Package: csso error handling", () => { const { csso } = await import("../src/index.ts"); await expect( - csso({ settings: {} as any, content: ".a { color: red; }" }) + // @ts-expect-error testing invalid input: settings is missing required fields + csso({ settings: {}, content: ".a { color: red; }" }) ).rejects.toThrow("csso"); }); }); diff --git a/packages/csso/tsconfig.json b/packages/csso/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/csso/tsconfig.json +++ b/packages/csso/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/esbuild/tsconfig.json b/packages/esbuild/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/esbuild/tsconfig.json +++ b/packages/esbuild/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/google-closure-compiler/__tests__/runner-edge-cases.test.ts b/packages/google-closure-compiler/__tests__/runner-edge-cases.test.ts index 1e3e6eddd..476d32e49 100644 --- a/packages/google-closure-compiler/__tests__/runner-edge-cases.test.ts +++ b/packages/google-closure-compiler/__tests__/runner-edge-cases.test.ts @@ -66,9 +66,7 @@ vi.mock("google-closure-compiler", () => { run(callback: RunCallback): FakeChildProcess { const child = new FakeChildProcess(); // Defer until gcc has wired up its listeners + written stdin. - setImmediate(() => - mock.onRun?.(child as unknown as FakeChild, callback) - ); + setImmediate(() => mock.onRun?.(child, callback)); return child; } } diff --git a/packages/google-closure-compiler/src/index.ts b/packages/google-closure-compiler/src/index.ts index 3252ec5e1..afe6a117e 100644 --- a/packages/google-closure-compiler/src/index.ts +++ b/packages/google-closure-compiler/src/index.ts @@ -4,6 +4,13 @@ * MIT Licensed */ +// Load-bearing: this package's own tsconfig globs `src/**/*`, so `types.d.ts` is +// picked up without this reference and an in-package typecheck stays green +// either way. Consumers that import this file directly do not glob it — +// packages/core/__tests__/core.test.ts imports `../../google-closure-compiler/src/index.ts` +// and fails with TS7016/TS7006 if this line is removed. Do not delete. +/// + import type { CompressorResult, MinifierOptions } from "@node-minify/types"; import { ensureStringContent, wrapMinificationError } from "@node-minify/utils"; import googleClosureCompiler from "google-closure-compiler"; diff --git a/packages/google-closure-compiler/tsconfig.json b/packages/google-closure-compiler/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/google-closure-compiler/tsconfig.json +++ b/packages/google-closure-compiler/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/html-minifier/__tests__/html-minifier-error.test.ts b/packages/html-minifier/__tests__/html-minifier-error.test.ts index d55699710..ad639fc4f 100644 --- a/packages/html-minifier/__tests__/html-minifier-error.test.ts +++ b/packages/html-minifier/__tests__/html-minifier-error.test.ts @@ -24,7 +24,8 @@ describe("Package: html-minifier error handling", () => { await expect( htmlMinifier({ - settings: {} as any, + // @ts-expect-error testing invalid input: settings is missing required fields + settings: {}, content: "test", }) ).rejects.toThrow("html-minifier failed: empty result"); @@ -41,7 +42,8 @@ describe("Package: html-minifier error handling", () => { await expect( htmlMinifier({ - settings: {} as any, + // @ts-expect-error testing invalid input: settings is missing required fields + settings: {}, content: "test", }) ).rejects.toThrow("html-minifier"); diff --git a/packages/html-minifier/tsconfig.json b/packages/html-minifier/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/html-minifier/tsconfig.json +++ b/packages/html-minifier/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/imagemin/__tests__/imagemin.test.ts b/packages/imagemin/__tests__/imagemin.test.ts index 08735a05a..192d17de2 100644 --- a/packages/imagemin/__tests__/imagemin.test.ts +++ b/packages/imagemin/__tests__/imagemin.test.ts @@ -194,7 +194,7 @@ describe("Package: imagemin", () => { compressor: imagemin, options: {}, }, - content: "not a buffer" as unknown as Buffer, + content: "not a buffer", }) ).rejects.toThrow("Imagemin compressor requires Buffer content"); }); diff --git a/packages/imagemin/tsconfig.json b/packages/imagemin/tsconfig.json index 6fb3265e3..48def2d03 100644 --- a/packages/imagemin/tsconfig.json +++ b/packages/imagemin/tsconfig.json @@ -1,9 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "__tests__"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/jsonminify/__tests__/jsonminify-error.test.ts b/packages/jsonminify/__tests__/jsonminify-error.test.ts index e3c68bffb..a2b091eff 100644 --- a/packages/jsonminify/__tests__/jsonminify-error.test.ts +++ b/packages/jsonminify/__tests__/jsonminify-error.test.ts @@ -25,7 +25,8 @@ describe("Package: jsonminify error handling", () => { const { jsonMinify } = await import("../src/index.ts"); await expect( - jsonMinify({ settings: {} as any, content: '{"key": "value"}' }) + // @ts-expect-error testing invalid input: settings is missing required fields + jsonMinify({ settings: {}, content: '{"key": "value"}' }) ).rejects.toThrow("jsonminify minification failed: JSON parse error"); }); }); diff --git a/packages/jsonminify/tsconfig.json b/packages/jsonminify/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/jsonminify/tsconfig.json +++ b/packages/jsonminify/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/lightningcss/tsconfig.json b/packages/lightningcss/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/lightningcss/tsconfig.json +++ b/packages/lightningcss/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/minify-html/__tests__/minify-html-error.test.ts b/packages/minify-html/__tests__/minify-html-error.test.ts index 52c8cb6a3..2cf3c3ee3 100644 --- a/packages/minify-html/__tests__/minify-html-error.test.ts +++ b/packages/minify-html/__tests__/minify-html-error.test.ts @@ -32,7 +32,8 @@ describe("Package: minify-html error handling", () => { await expect( minifyHtml({ - settings: {} as any, + // @ts-expect-error testing invalid input: settings is missing required fields + settings: {}, content: "test", }) ).rejects.toThrow( diff --git a/packages/minify-html/tsconfig.json b/packages/minify-html/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/minify-html/tsconfig.json +++ b/packages/minify-html/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/no-compress/__tests__/no-compress.test.ts b/packages/no-compress/__tests__/no-compress.test.ts index 2a90bdaa7..49cd34570 100644 --- a/packages/no-compress/__tests__/no-compress.test.ts +++ b/packages/no-compress/__tests__/no-compress.test.ts @@ -22,13 +22,15 @@ describe("Package: no-compress", async () => { test("should return empty string when content is undefined", async () => { await expect( - noCompress({ settings: {} as any, content: undefined }) + // @ts-expect-error testing invalid input: settings is missing required fields + noCompress({ settings: {}, content: undefined }) ).resolves.toEqual({ code: "" }); }); test("should throw when content is not a string", async () => { await expect( - noCompress({ settings: {} as any, content: 123 as any }) + // @ts-expect-error testing invalid input: settings is missing required fields, content is not a string + noCompress({ settings: {}, content: 123 }) ).rejects.toThrow( "no-compress failed: content must be a string or Buffer but received number" ); @@ -37,7 +39,8 @@ describe("Package: no-compress", async () => { test("should handle Buffer content", async () => { const buffer = Buffer.from("buffer content"); const result = await noCompress({ - settings: {} as any, + // @ts-expect-error testing invalid input: settings is missing required fields + settings: {}, content: buffer, }); expect(result.code).toBe("buffer content"); diff --git a/packages/no-compress/tsconfig.json b/packages/no-compress/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/no-compress/tsconfig.json +++ b/packages/no-compress/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/oxc/__tests__/oxc-error.test.ts b/packages/oxc/__tests__/oxc-error.test.ts index 9b1c2731f..92e738b23 100644 --- a/packages/oxc/__tests__/oxc-error.test.ts +++ b/packages/oxc/__tests__/oxc-error.test.ts @@ -25,7 +25,8 @@ describe("Package: oxc error handling", () => { const { oxc } = await import("../src/index.ts"); await expect( - oxc({ settings: {} as any, content: "var x = 1;" }) + // @ts-expect-error testing invalid input: settings is missing required fields + oxc({ settings: {}, content: "var x = 1;" }) ).rejects.toThrow("oxc"); }); }); diff --git a/packages/oxc/tsconfig.json b/packages/oxc/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/oxc/tsconfig.json +++ b/packages/oxc/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/sharp/__tests__/sharp.test.ts b/packages/sharp/__tests__/sharp.test.ts index 050be451e..87a05d6ac 100644 --- a/packages/sharp/__tests__/sharp.test.ts +++ b/packages/sharp/__tests__/sharp.test.ts @@ -91,7 +91,7 @@ describe("sharp", () => { compressor: sharp, options: {}, }, - content: "not a buffer" as unknown as Buffer, + content: "not a buffer", }) ).rejects.toThrow("Sharp compressor requires Buffer content"); }); @@ -119,7 +119,7 @@ describe("sharp", () => { const pngMock = vi.fn().mockReturnThis(); const jpegMock = vi.fn().mockReturnThis(); - // @ts-expect-error + // @ts-expect-error mock implementation only provides the methods this test exercises, narrower than the real Sharp instance type mockSharp.mockImplementation(() => ({ webp: webpMock, avif: avifMock, @@ -195,7 +195,7 @@ describe("sharp", () => { const inputBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const { default: mockSharp } = await import("sharp"); const pngMock = vi.fn().mockReturnThis(); - // @ts-expect-error + // @ts-expect-error mock implementation only provides the methods this test exercises, narrower than the real Sharp instance type mockSharp.mockImplementationOnce(() => ({ png: pngMock, toBuffer: vi.fn().mockResolvedValue(Buffer.from("converted")), @@ -220,7 +220,7 @@ describe("sharp", () => { test("should wrap and rethrow sharp errors", async () => { const inputBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const { default: mockSharp } = await import("sharp"); - // @ts-expect-error + // @ts-expect-error mock implementation only provides the methods this test exercises, narrower than the real Sharp instance type mockSharp.mockImplementationOnce(() => ({ webp: vi.fn().mockImplementation(() => { throw new Error("Sharp error"); @@ -241,7 +241,7 @@ describe("sharp", () => { test("should rethrow non-Error exceptions", async () => { const inputBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const { default: mockSharp } = await import("sharp"); - // @ts-expect-error + // @ts-expect-error mock implementation only provides the methods this test exercises, narrower than the real Sharp instance type mockSharp.mockImplementationOnce(() => ({ webp: vi.fn().mockImplementation(() => { throw "string error"; diff --git a/packages/sharp/tsconfig.json b/packages/sharp/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/sharp/tsconfig.json +++ b/packages/sharp/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/svgo/tsconfig.json b/packages/svgo/tsconfig.json index 6fb3265e3..48def2d03 100644 --- a/packages/svgo/tsconfig.json +++ b/packages/svgo/tsconfig.json @@ -1,9 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "__tests__"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/swc/tsconfig.json b/packages/swc/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/swc/tsconfig.json +++ b/packages/swc/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/terser/__tests__/terser-error.test.ts b/packages/terser/__tests__/terser-error.test.ts index 8cd6f4885..1cf6adc62 100644 --- a/packages/terser/__tests__/terser-error.test.ts +++ b/packages/terser/__tests__/terser-error.test.ts @@ -15,7 +15,8 @@ describe("Package: terser error handling", async () => { const { terser } = await import("../src/index.ts"); await expect( - terser({ settings: {} as any, content: "var x = 1;" }) + // @ts-expect-error testing invalid input: settings is missing required fields + terser({ settings: {}, content: "var x = 1;" }) ).rejects.toThrow("Terser failed: empty result"); }); }); diff --git a/packages/terser/tsconfig.json b/packages/terser/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/terser/tsconfig.json +++ b/packages/terser/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/types/package.json b/packages/types/package.json index 576c516fb..70da6beea 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -26,5 +26,9 @@ }, "bugs": { "url": "https://github.com/srod/node-minify/issues" + }, + "scripts": { + "lint": "biome lint .", + "typecheck": "tsc --noEmit" } -} \ No newline at end of file +} diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 000000000..157eb5199 --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "skipLibCheck": false, + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/packages/uglify-js/tsconfig.json b/packages/uglify-js/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/uglify-js/tsconfig.json +++ b/packages/uglify-js/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/packages/utils/__tests__/getContentFromFilesAsync.test.ts b/packages/utils/__tests__/getContentFromFilesAsync.test.ts index 27fd5fe23..568e6ff10 100644 --- a/packages/utils/__tests__/getContentFromFilesAsync.test.ts +++ b/packages/utils/__tests__/getContentFromFilesAsync.test.ts @@ -44,7 +44,8 @@ describe("getContentFromFilesAsync", () => { }); test("should throw if input is null", async () => { - await expect(getContentFromFilesAsync(null as any)).rejects.toThrow( + // @ts-expect-error testing invalid input: getContentFromFilesAsync requires a string or string[] input + await expect(getContentFromFilesAsync(null)).rejects.toThrow( "Input must be a string or array of strings" ); }); diff --git a/packages/utils/__tests__/setPublicFolder.test.ts b/packages/utils/__tests__/setPublicFolder.test.ts index e12dce772..6a64fca28 100644 --- a/packages/utils/__tests__/setPublicFolder.test.ts +++ b/packages/utils/__tests__/setPublicFolder.test.ts @@ -4,7 +4,7 @@ import { setPublicFolder } from "../src/setPublicFolder.js"; describe("setPublicFolder", () => { test("should return empty object if publicFolder is not a string", () => { - // @ts-expect-error testing invalid input + // @ts-expect-error testing invalid input: publicFolder is null, not a string const result = setPublicFolder("file.js", null); expect(result).toEqual({}); }); diff --git a/packages/utils/__tests__/utils.test.ts b/packages/utils/__tests__/utils.test.ts index 92957bf40..849b1d5a4 100644 --- a/packages/utils/__tests__/utils.test.ts +++ b/packages/utils/__tests__/utils.test.ts @@ -166,7 +166,7 @@ describe("Package: utils", () => { test("should run the compressor", async () => { const compressor = vi.fn().mockResolvedValue({ code: "minified" }); const result = await run({ - settings: { compressor } as any, + settings: { compressor }, content: "content", }); expect(result).toBe("minified"); @@ -178,11 +178,13 @@ describe("Package: utils", () => { }); test("should throw if no settings", async () => { - await expect(run({} as any)).rejects.toThrow(ValidationError); + // @ts-expect-error testing invalid input: run() requires a settings object + await expect(run({})).rejects.toThrow(ValidationError); }); test("should throw if no compressor", async () => { - await expect(run({ settings: {} } as any)).rejects.toThrow( + // @ts-expect-error testing invalid input: settings is missing the required compressor + await expect(run({ settings: {} })).rejects.toThrow( ValidationError ); }); @@ -196,7 +198,7 @@ describe("Package: utils", () => { settings: { compressor, compressorLabel: "bad-compressor", - } as any, + }, content: "content", }) ).rejects.toThrow( @@ -213,7 +215,7 @@ describe("Package: utils", () => { settings: { compressor, compressorLabel: "bad-compressor", - } as any, + }, content: "content", }) ).rejects.toThrow( @@ -305,7 +307,8 @@ describe("Package: utils", () => { test("should throw if targetFile is not a string", () => { expect(() => writeFile({ - file: [null as any], + // @ts-expect-error testing invalid input: array element is null, not a string path + file: [null], content: "content", index: 0, }) @@ -381,7 +384,8 @@ describe("Package: utils", () => { test("should throw if targetFile is not a string", async () => { await expect( writeFileAsync({ - file: [null as any], + // @ts-expect-error testing invalid input: array element is null, not a string path + file: [null], content: "content", index: 0, }) @@ -408,7 +412,8 @@ describe("Package: utils", () => { ).toEqual(["--foo", "bar"])); test("should throw if options is null", () => { - expect(() => buildArgs(null as any)).toThrow(ValidationError); + // @ts-expect-error testing invalid input: buildArgs requires a non-null options object + expect(() => buildArgs(null)).toThrow(ValidationError); }); test("should filter out undefined and false values", () => { @@ -439,7 +444,7 @@ describe("Package: utils", () => { describe("pretty bytes", () => { test("should throw when not a number", () => { - // @ts-expect-error + // @ts-expect-error testing invalid input: prettyBytes requires a number, not a string expect(() => prettyBytes("a")).toThrow(); }); @@ -498,7 +503,8 @@ describe("Package: utils", () => { test("should throw if publicFolder is not a string", () => { expect(() => - setFileNameMin("foo.js", "$1.min.js", 123 as any) + // @ts-expect-error testing invalid input: publicFolder must be a string, not a number + setFileNameMin("foo.js", "$1.min.js", 123) ).toThrow(ValidationError); }); @@ -625,7 +631,8 @@ describe("Package: utils", () => { }); test("should throw if input is null", () => { - expect(() => getContentFromFiles(null as any)).toThrow(); + // @ts-expect-error testing invalid input: getContentFromFiles requires a string or string[] input + expect(() => getContentFromFiles(null)).toThrow(); }); test("should throw if one file does not exist", () => { @@ -647,7 +654,7 @@ describe("Package: utils", () => { const settings = { compressor, content: "content", - } as any; + }; const result = await compressSingleFile(settings); expect(result).toBe("minified"); }); @@ -657,7 +664,7 @@ describe("Package: utils", () => { const settings = { compressor, input: fixtureFile, - } as any; + }; const result = await compressSingleFile(settings); expect(result).toBe("minified"); }); @@ -666,7 +673,7 @@ describe("Package: utils", () => { const compressor = vi.fn().mockResolvedValue({ code: "minified" }); const settings = { compressor, - } as any; + }; await compressSingleFile(settings); expect(compressor).toHaveBeenCalledWith( expect.objectContaining({ content: "" }) @@ -679,7 +686,7 @@ describe("Package: utils", () => { compressor, input: undefined, content: undefined, - } as any; + }; const result = await compressSingleFile(settings); expect(result).toBe(""); expect(compressor).toHaveBeenCalledWith( @@ -802,7 +809,7 @@ describe("Package: utils", () => { compressor, input: fixtureFile, output: outputFile, - } as any; + }; await run({ settings, content: "content" }); @@ -825,7 +832,7 @@ describe("Package: utils", () => { options: { sourceMap: { url: mapFile }, }, - } as any; + }; await run({ settings, content: "content" }); @@ -849,7 +856,7 @@ describe("Package: utils", () => { options: { sourceMap: { filename: mapFile }, }, - } as any; + }; await run({ settings, content: "content" }); @@ -872,7 +879,7 @@ describe("Package: utils", () => { options: { _sourceMap: { url: mapFile }, }, - } as any; + }; await run({ settings, content: "content" }); @@ -893,7 +900,7 @@ describe("Package: utils", () => { options: { sourceMap: { inline: true }, }, - } as any; + }; await run({ settings, content: "content" }); @@ -905,7 +912,7 @@ describe("Package: utils", () => { const settings = { compressor, content: "source content", - } as any; + }; const result = await run({ settings, content: "source content" }); @@ -917,7 +924,7 @@ describe("Package: utils", () => { const settings = { compressor, input: fixtureFile, - } as any; + }; const result = await run({ settings, content: "content" }); @@ -935,7 +942,7 @@ describe("Package: utils", () => { compressor, input: fixtureFile, output: outputFile, - } as any; + }; await run({ settings, content: "content" }); @@ -958,7 +965,7 @@ describe("Package: utils", () => { compressor, input: `${tmpDir}/input.png`, output: outputFile, - } as any; + }; const result = await run({ settings, content: "" }); @@ -977,7 +984,7 @@ describe("Package: utils", () => { const settings = { compressor, content: "source content", - } as any; + }; const result = await run({ settings, content: "source content" }); @@ -997,7 +1004,7 @@ describe("Package: utils", () => { input: fixtureFile, output: outputFile, allowEmptyOutput: true, - } as any; + }; const result = await run({ settings, @@ -1018,7 +1025,7 @@ describe("Package: utils", () => { input: fixtureFile, output: outputFile, allowEmptyOutput: true, - } as any; + }; const result = await run({ settings, content: "content" }); @@ -1035,7 +1042,7 @@ describe("Package: utils", () => { input: fixtureFile, output: outputFile, allowEmptyOutput: false, - } as any; + }; await expect( run({ settings, content: "/* comment only */" }) @@ -1051,7 +1058,7 @@ describe("Package: utils", () => { input: fixtureFile, output: outputFile, // allowEmptyOutput not set - uses default (false) - } as any; + }; await expect( run({ settings, content: "/* comment only */" }) @@ -1075,7 +1082,7 @@ describe("Package: utils", () => { options: { sourceMap: { url: mapFile }, }, - } as any; + }; const result = await run({ settings, content: "/* comment */" }); @@ -1090,7 +1097,7 @@ describe("Package: utils", () => { compressor, content: "/* comment only */", allowEmptyOutput: true, - } as any; + }; const result = await run({ settings, @@ -1122,7 +1129,7 @@ describe("Package: utils", () => { compressor, input: `${tmpDir}/input.png`, output: [webpFile, avifFile], - } as any; + }; const result = await run({ settings, content: "" }); @@ -1151,7 +1158,7 @@ describe("Package: utils", () => { compressor, input: testFile, output: "$1", // Will generate test-image.webp and test-image.avif - } as any; + }; const result = await run({ settings, content: "" }); @@ -1176,7 +1183,7 @@ describe("Package: utils", () => { compressor, input: `${tmpDir}/input.png`, output: outputFile, - } as any; + }; await run({ settings, content: "" }); @@ -1193,7 +1200,7 @@ describe("Package: utils", () => { const settings = { compressor, input: `${tmpDir}/input.png`, - } as any; + }; const result = await run({ settings, content: "" }); @@ -1209,7 +1216,7 @@ describe("Package: utils", () => { const settings = { compressor, content: "source content", - } as any; + }; const result = await run({ settings, content: "source content" }); @@ -1225,7 +1232,7 @@ describe("Package: utils", () => { compressor, input: `${tmpDir}/input.png`, output: `${tmpDir}/output.png`, - } as any; + }; const result = await run({ settings, content: "" }); @@ -1313,7 +1320,7 @@ describe("Package: utils", () => { compressor, input: testFile, output: `${tmpDir}/$1-converted`, - } as any; + }; await run({ settings, content: "" }); @@ -1416,7 +1423,7 @@ describe("Package: utils", () => { compressor, input: testFile, output: [""], - } as any; + }; await run({ settings, content: "" }); @@ -1448,7 +1455,7 @@ describe("Package: utils", () => { compressor, input: testFile, output: [`${tmpDir}/first.webp`], // Only one explicit, two need fallback - } as any; + }; await run({ settings, content: "" }); @@ -1472,7 +1479,7 @@ describe("Package: utils", () => { compressor, input: `${tmpDir}/input.png`, output: `${tmpDir}/no-format-output`, - } as any; + }; await run({ settings, content: "" }); @@ -1489,16 +1496,13 @@ describe("Package: utils", () => { const avifContent = Buffer.from("AVIF_SPARSE"); const compressor = vi.fn().mockResolvedValue({ code: "", - outputs: [ - undefined, - { format: "avif", content: avifContent }, - ] as any, + outputs: [undefined, { format: "avif", content: avifContent }], }); const settings = { compressor, input: testFile, output: "$1", - } as any; + }; await run({ settings, content: "" }); @@ -1517,7 +1521,7 @@ describe("Package: utils", () => { compressor, input: [], output: `${tmpDir}/default-output`, - } as any; + }; filesToCleanup.add(`${tmpDir}/default-output.webp`); await run({ settings, content: "" }); @@ -1545,12 +1549,10 @@ describe("Package: utils", () => { const settings = { compressor, input: testFile, - output: [ - undefined as unknown as string, - undefined as unknown as string, - ], - } as any; + output: [undefined, undefined], + }; + // @ts-expect-error testing invalid input: output array elements are undefined instead of strings await run({ settings, content: "" }); expect( @@ -1571,7 +1573,7 @@ describe("Package: utils", () => { compressor, input: "", output: `${tmpDir}/$1-converted`, - } as any; + }; filesToCleanup.add(`${tmpDir}/output-converted.webp`); await run({ settings, content: "" }); @@ -1652,8 +1654,9 @@ describe("Package: utils", () => { compressor, input: testFile, output: { invalid: "object" }, - } as any; + }; + // @ts-expect-error testing invalid input: output is a non-string object, not a valid path await run({ settings, content: "" }); expect(readFile(`${tmpDir}/fallback-nonstring.webp`, true)).toEqual( @@ -1699,7 +1702,7 @@ describe("Package: utils", () => { compressor, input: "", output: `${tmpDir}/output`, - } as any; + }; filesToCleanup.add(`${tmpDir}/output.webp`); await run({ settings, content: "" }); @@ -1719,7 +1722,7 @@ describe("Package: utils", () => { compressor, input: "", output: `${tmpDir}/$1`, - } as any; + }; filesToCleanup.add(`${tmpDir}/output.webp`); await run({ settings, content: "" }); @@ -1737,11 +1740,12 @@ describe("Package: utils", () => { }); const settings = { compressor, - input: [undefined as unknown as string], + input: [undefined], output: `${tmpDir}/$1`, - } as any; + }; filesToCleanup.add(`${tmpDir}/output.webp`); + // @ts-expect-error testing invalid input: input array element is undefined instead of a string await run({ settings, content: "" }); expect(readFile(`${tmpDir}/output.webp`, true)).toEqual( @@ -1757,7 +1761,7 @@ describe("Package: utils", () => { const settings = { compressor, content: bufferContent, - } as any; + }; const result = await compressSingleFile(settings); expect(result).toBe("minified"); expect(compressor).toHaveBeenCalledWith( @@ -1803,7 +1807,7 @@ describe("Package: utils", () => { compressor, input: [`${tmpDir}/image.png`, `${tmpDir}/script.js`], output: `${tmpDir}/output.js`, - } as any; + }; await expect(compressSingleFile(settings)).rejects.toThrow( "Cannot mix image and text files in the same input array" @@ -1823,7 +1827,7 @@ describe("Package: utils", () => { compressor, input: [testFile1, testFile2], output: `${tmpDir}/output.png`, - } as any; + }; await compressSingleFile(settings); expect(compressor).toHaveBeenCalledWith( @@ -1843,7 +1847,7 @@ describe("Package: utils", () => { compressor, input: [testFile], output: `${tmpDir}/output-single.png`, - } as any; + }; await compressSingleFile(settings); expect(compressor).toHaveBeenCalledWith( @@ -1863,7 +1867,7 @@ describe("Package: utils", () => { compressor, input: testFile, output: `${tmpDir}/output.png`, - } as any; + }; await compressSingleFile(settings); expect(compressor).toHaveBeenCalledWith( diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 8ffe5db9f..48def2d03 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" + "outDir": "./dist" }, - "include": ["src/**/*"] + "include": ["src/**/*", "__tests__/**/*"] } diff --git a/scripts/ci-guard-type-suppressions.sh b/scripts/ci-guard-type-suppressions.sh new file mode 100755 index 000000000..d42059b59 --- /dev/null +++ b/scripts/ci-guard-type-suppressions.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Enforces the type-suppression rules documented in AGENTS.md > Anti-Patterns. +# +# Biome covers only part of this: `noTsIgnore` catches `@ts-ignore`, but it never +# sees `@ts-nocheck`, and no Biome rule requires a reason comment on +# `@ts-expect-error` or bans it from `src/`. This guard is the mechanism behind +# those claims; without it they are review-only. +# +# Three rules, all mechanical: +# 1. `@ts-nocheck` and `@ts-ignore` are banned everywhere — both disable +# checking without proving an error exists. +# 2. `@ts-expect-error` is banned in `src/` — production types get fixed. +# 3. `@ts-expect-error` in tests MUST carry a reason on the same line, so the +# suppression states which specific condition it tolerates. + +CODE_INCLUDES=( + --include="*.ts" --include="*.tsx" --include="*.mts" --include="*.cts" + --include="*.js" --include="*.jsx" --include="*.mjs" --include="*.cjs" + --include="*.astro" --include="*.svelte" --include="*.vue" +) + +# Build output, dependencies, coverage reports and test scratch space are either +# generated or vendored: a suppression there is not authored by us. +EXCLUDE_PATHS=( + --exclude-dir="node_modules" + --exclude-dir="dist" + --exclude-dir="coverage" + --exclude-dir="tmp" + --exclude-dir=".astro" + --exclude-dir=".git" +) + +# Scan the repository root so root-level files and any future source directory +# are covered without maintaining a directory list. +SCAN_ROOTS=(.) + +# 1. @ts-nocheck / @ts-ignore anywhere. +NOCHECK_MATCHES=$(grep -rnE "@ts-(nocheck|ignore)\b" \ + "${CODE_INCLUDES[@]}" "${EXCLUDE_PATHS[@]}" \ + "${SCAN_ROOTS[@]}" \ + 2>/dev/null || true) + +# 2. @ts-expect-error inside any src/ directory. Matched on the path so a +# directive in production code is caught regardless of how it is worded. +SRC_EXPECT_MATCHES=$(grep -rnE "@ts-expect-error" \ + "${CODE_INCLUDES[@]}" "${EXCLUDE_PATHS[@]}" \ + "${SCAN_ROOTS[@]}" \ + 2>/dev/null | awk -F: '$1 ~ /(^|\/)src\//' || true) + +# 3. Bare @ts-expect-error: the directive with nothing after it but optional +# whitespace and an optional block-comment terminator. A reason comment makes +# the suppression self-documenting and is required by AGENTS.md. +BARE_EXPECT_MATCHES=$(grep -rnE "@ts-expect-error[[:space:]]*(\*/)?[[:space:]]*$" \ + "${CODE_INCLUDES[@]}" "${EXCLUDE_PATHS[@]}" \ + "${SCAN_ROOTS[@]}" \ + 2>/dev/null || true) + +FAILED=0 + +if [ -n "$NOCHECK_MATCHES" ]; then + echo "ERROR: @ts-nocheck / @ts-ignore are banned — they disable checking instead of proving an error exists." + echo "$NOCHECK_MATCHES" + echo + FAILED=1 +fi + +if [ -n "$SRC_EXPECT_MATCHES" ]; then + echo "ERROR: @ts-expect-error is not allowed in src/ — fix the type instead." + echo "$SRC_EXPECT_MATCHES" + echo + FAILED=1 +fi + +if [ -n "$BARE_EXPECT_MATCHES" ]; then + echo "ERROR: @ts-expect-error must state the condition it tolerates on the same line." + echo "Example: // @ts-expect-error testing invalid input: settings is missing required fields" + echo "$BARE_EXPECT_MATCHES" + echo + FAILED=1 +fi + +if [ "$FAILED" -ne 0 ]; then + exit 1 +fi + +echo "Guard passed: no banned type suppressions found." +exit 0 diff --git a/tests/fixtures.ts b/tests/fixtures.ts index c028ee5c5..b74db0e14 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { Settings } from "@node-minify/types"; +import type { Compressor, Settings } from "@node-minify/types"; import { expect, test } from "vitest"; import { minify } from "../packages/core/src/index.ts"; import { filesCSS, filesHTML, filesJS, filesJSON } from "./files-path.ts"; @@ -17,7 +17,7 @@ interface TestOptions { interface TestConfig { options: TestOptions; compressorLabel: string; - compressor: any; + compressor: Compressor; } type MinifyResult = string; @@ -39,7 +39,7 @@ const runOneTest = async ({ const createTestOptions = ( options: TestOptions, - compressor: any + compressor: Compressor ): TestOptions => { const testOptions = structuredClone(options); testOptions.minify.compressor = compressor; @@ -183,8 +183,8 @@ function isRetriableFileSystemError( return ( error instanceof Error && "code" in error && - (error as any).code && - ["ENOENT", "EPERM", "EBUSY"].includes((error as any).code) + typeof error.code === "string" && + ["ENOENT", "EPERM", "EBUSY"].includes(error.code) ); } diff --git a/tests/tsconfig.json b/tests/tsconfig.json deleted file mode 100644 index f94ca59ef..000000000 --- a/tests/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "baseUrl": "..", - "paths": { - "@node-minify/types": ["./packages/types/src/types.d.ts"] - } - }, - "include": ["./**/*.ts"] -}