diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000..7aa38ad --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,69 @@ +import { defineConfig } from "oxlint"; + +// anti-slop (https://github.com/dmmulroy/anti-slop) vendored at +// tools/oxlint/anti-slop -- generic rule group only, since this project +// doesn't use the Effect framework. Per the upstream README this is meant +// to be vendored and maintained locally, not tracked as a live dependency, +// so treat tools/oxlint/anti-slop as project source to review and adjust +// over time rather than something to re-sync from upstream automatically. +export default defineConfig({ + ignorePatterns: [ + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".continue/**", + ".cursor/**", + ".gemini/**", + ".opencode/**", + ".pi/**", + ".roo/**", + ".windsurf/**", + "tools/oxlint/anti-slop/**", + // Test files are exempt: the overwhelming majority of hits here are + // `as SomeMockType` assertions on `window.electronAPI`/vi.mock setups -- + // the same test-isolation pattern no-module-mocking is already turned + // off for below. Requiring a SAFETY: comment on every mock cast (or + // rewriting them to avoid a cast at all) would mean auditing every test + // file for no real safety benefit, not fixing a real risk. + "**/*.test.ts", + "**/*.test.tsx", + ], + jsPlugins: [ + { name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" }, + ], + rules: { + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-returns": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error", + + // Off, deliberately -- each fights this codebase's actual conventions + // rather than catching a real problem here (see PR discussion/first + // lint run for the full reasoning): + // + // - no-shape-in-symbol-names: its only hit is `reshapeVideoInfo` + // (videoInfo.mjs/main.mjs), an ordinary verb unrelated to the + // schema-shape confusion this rule guards against. + // - no-runtime-typeof: wants boundary-parsed input over ad hoc `typeof` + // narrowing, which presumes a validation-library boundary layer this + // app doesn't have. Every current hit is a plain, correct null/type + // guard on an already-narrow union (e.g. `number | null | undefined`). + // - no-module-mocking: wants DI over `vi.mock`, but mocking + // `window.electronAPI` via `vi.mock`/module mocks is how this + // project's whole test suite isolates the renderer from Electron's + // main process -- enabling this would mean redesigning the test + // architecture, not fixing a lint error. + "anti-slop/no-shape-in-symbol-names": "off", + "anti-slop/no-runtime-typeof": "off", + "anti-slop/no-module-mocking": "off", + }, +}); diff --git a/package-lock.json b/package-lock.json index 09fd40c..969640d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@oxlint/plugins": "1.81.0", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.3", @@ -38,6 +39,7 @@ "globals": "^17.9.0", "husky": "^9.1.7", "jsdom": "^30.0.1", + "oxlint": "1.81.0", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^8.2.1", @@ -1583,6 +1585,342 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz", + "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz", + "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz", + "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz", + "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz", + "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz", + "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz", + "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz", + "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz", + "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz", + "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz", + "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz", + "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz", + "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz", + "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz", + "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz", + "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz", + "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz", + "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz", + "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/plugins": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/plugins/-/plugins-1.81.0.tgz", + "integrity": "sha512-HhD8kd3r6XpelZkhxhRty/po8V6yK1VV63Eq4kSsOS2IoHUywG3DV2EX+z/ZHw46aLI74Y6aA604NRiAqLKW9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", @@ -1738,9 +2076,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1758,9 +2093,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1778,9 +2110,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1798,9 +2127,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1818,9 +2144,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1838,9 +2161,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5479,9 +5799,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5503,9 +5820,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5527,9 +5841,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5551,9 +5862,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6099,6 +6407,55 @@ "node": ">= 0.8.0" } }, + "node_modules/oxlint": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", + "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.81.0", + "@oxlint/binding-android-arm64": "1.81.0", + "@oxlint/binding-darwin-arm64": "1.81.0", + "@oxlint/binding-darwin-x64": "1.81.0", + "@oxlint/binding-freebsd-x64": "1.81.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", + "@oxlint/binding-linux-arm-musleabihf": "1.81.0", + "@oxlint/binding-linux-arm64-gnu": "1.81.0", + "@oxlint/binding-linux-arm64-musl": "1.81.0", + "@oxlint/binding-linux-ppc64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-musl": "1.81.0", + "@oxlint/binding-linux-s390x-gnu": "1.81.0", + "@oxlint/binding-linux-x64-gnu": "1.81.0", + "@oxlint/binding-linux-x64-musl": "1.81.0", + "@oxlint/binding-openharmony-arm64": "1.81.0", + "@oxlint/binding-win32-arm64-msvc": "1.81.0", + "@oxlint/binding-win32-ia32-msvc": "1.81.0", + "@oxlint/binding-win32-x64-msvc": "1.81.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", diff --git a/package.json b/package.json index 2977f34..eed54b5 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "build": "tsc -b && vite build", "build:nolint": "vite build", "lint": "eslint .", + "lint:anti-slop": "oxlint .", "check:release-version": "node scripts/check-release-version.mjs", "preview": "vite preview", "electron-build": "electron-builder --publish never", @@ -85,6 +86,7 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@oxlint/plugins": "1.81.0", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.3", @@ -102,6 +104,7 @@ "globals": "^17.9.0", "husky": "^9.1.7", "jsdom": "^30.0.1", + "oxlint": "1.81.0", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^8.2.1", diff --git a/src/types.ts b/src/types.ts index f6e5e80..75ff6b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,6 +122,16 @@ export type DownloadProgressMessage = { } } +// A download's error state after useDownloadVideo.tsx's 'error' case -- the +// raw progress message on a first failure, or a { previous, current } +// wrapper once a second failure arrives while an earlier one was still +// being displayed (see useDownloadVideo.tsx's accumErr). Consumers that +// only care about the latest failure should read through to `.current` +// when this is the wrapped shape. +export type DownloadFailure = + | DownloadProgressMessage + | { previous: DownloadFailure; current: DownloadProgressMessage }; + export type DownloadVideoParams = { videoUrl: string; outputPath: string; diff --git a/src/ui/components/ClipCollectionView.tsx b/src/ui/components/ClipCollectionView.tsx index f2c712b..c13d5d3 100644 --- a/src/ui/components/ClipCollectionView.tsx +++ b/src/ui/components/ClipCollectionView.tsx @@ -17,6 +17,8 @@ import type { LibraryClip, LibraryVideoMetadata } from '../../types'; // Placeholder metadata for LibraryVideoPlayerWithTools -- overrideFilePath // takes priority over every field here, this just satisfies the required // prop without pretending a clip has a real video identity. +// SAFETY: overrideFilePath always takes priority over these fields in +// LibraryVideoPlayerWithTools, so the properties missing here are never read. const EMPTY_METADATA = { downloadedFilePath: null, thumbnail: null, videoId: '' } as LibraryVideoMetadata; function getClipExtension(fileName: string): string { diff --git a/src/ui/components/LibraryBottomBar.tsx b/src/ui/components/LibraryBottomBar.tsx index 16d5b42..f9ea0f8 100644 --- a/src/ui/components/LibraryBottomBar.tsx +++ b/src/ui/components/LibraryBottomBar.tsx @@ -88,7 +88,10 @@ export default function LibraryBottomBar({ min={THUMBNAIL_SIZE_MIN} max={THUMBNAIL_SIZE_MAX} step={THUMBNAIL_SIZE_STEP} + // SAFETY: this Slider has a single scalar `value`, never a + // [min, max] range, so MUI's value callback is always a number. onChange={(_e, value) => onThumbnailSizeChange(value as number)} + // SAFETY: same single-scalar `value` as onChange above. onChangeCommitted={(_e, value) => onThumbnailSizeCommit(value as number)} aria-label="Thumbnail size" /> diff --git a/src/ui/hooks/useBulkAddQueue.tsx b/src/ui/hooks/useBulkAddQueue.tsx index d643409..3a1772a 100644 --- a/src/ui/hooks/useBulkAddQueue.tsx +++ b/src/ui/hooks/useBulkAddQueue.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; import useDownloadVideo from './useDownloadVideo.tsx'; +import type { DownloadFailure, DownloadProgressMessage } from '../../types'; import { MAX_SIMULTANEOUS_DOWNLOADS_CEILING } from '../../utils/constants.ts'; import { cleanElectronErrorMessage } from '../../utils/utils.ts'; @@ -100,13 +101,21 @@ function pickClosestResolution(resolutions: { resolution: string }[], target: st return String(best); } +// downloadError is either the raw failure message, or a { previous, current } +// wrapper if a second failure arrived while an earlier one was still being +// displayed (see useDownloadVideo.tsx's accumErr) -- read through to the +// latest one either way. +function latestDownloadFailure(downloadError: DownloadFailure | null): DownloadProgressMessage | null { + if (downloadError == null) return null; + return 'current' in downloadError ? downloadError.current : downloadError; +} + // Best-effort extraction of the classified error message threaded through // from main.mjs's downloadErrors.mjs (see useDownloadVideo.tsx's 'error' // case) -- falls back to a generic message for failure paths (fetch/ // add-to-library) that never went through that classifier. -function extractDownloadErrorMessage(downloadError: unknown): string { - const message = (downloadError as { payload?: { message?: string } } | null)?.payload?.message; - return message || 'Download failed.'; +function extractDownloadErrorMessage(downloadError: DownloadFailure | null): string { + return latestDownloadFailure(downloadError)?.payload.message || 'Download failed.'; } // One concurrent "download worker" -- wraps a single useDownloadVideo() @@ -116,7 +125,7 @@ function extractDownloadErrorMessage(downloadError: unknown): string { // every parent re-render. function useDownloadSlot( slotIndex: number, - onDone: (slotIndex: number, result: { isError: boolean; finalFilePath: string; downloadError: unknown }) => void, + onDone: (slotIndex: number, result: { isError: boolean; finalFilePath: string; downloadError: DownloadFailure | null }) => void, onProgress: (slotIndex: number, progress: { downloadProgress: number; postprocessProgress: number }) => void, onRetrying: (slotIndex: number, isRetrying: boolean) => void, ) { @@ -331,13 +340,13 @@ function useBulkAddQueueState() { // Fired by whichever download slot's own isDone/isError just flipped (see // useDownloadSlot above) -- finishes recording that one item, independent // of the other slots. - const handleSlotDone = async (slot: number, result: { isError: boolean; finalFilePath: string; downloadError: unknown }) => { + const handleSlotDone = async (slot: number, result: { isError: boolean; finalFilePath: string; downloadError: DownloadFailure | null }) => { const itemId = slotItemRef.current[slot]; const meta = slotDownloadMetaRef.current[slot]; if (!itemId || !meta) return; slotDownloadMetaRef.current[slot] = null; if (result.isError) { - const kind = (result.downloadError as { payload?: { kind?: string } } | null)?.payload?.kind; + const kind = latestDownloadFailure(result.downloadError)?.payload.kind; // A cancellation already reads clearly from the 'Cancelled' status chip // alone -- an additional red error caption (and the generic "Download // failed." fallback extractDownloadErrorMessage would produce here) diff --git a/src/ui/hooks/useDownloadVideo.tsx b/src/ui/hooks/useDownloadVideo.tsx index 2af64d6..107961d 100644 --- a/src/ui/hooks/useDownloadVideo.tsx +++ b/src/ui/hooks/useDownloadVideo.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import type { DownloadProgressMessage, DownloadVideoParams } from '../../types' +import type { DownloadFailure, DownloadProgressMessage, DownloadVideoParams } from '../../types' function useDownloadVideo() { const [downloadProgress, setDownloadProgress] = useState(0); @@ -13,7 +13,7 @@ function useDownloadVideo() { const [finalFilePath, setFinalFilePath] = useState(''); const [isDone, setIsDone] = useState(false); const [isError, setIsError] = useState(false); - const [downloadError, setDownloadError] = useState (); + const [downloadError, setDownloadError] = useState(null); // Clean, single-purpose readout of the classified failure kind (see // src/electron/downloadErrors.mjs) -- 'cancelled' specifically lets // consumers show "Cancelled" instead of a generic "Download failed" for a @@ -66,7 +66,7 @@ function useDownloadVideo() { const listener = window.electronAPIPythonDownload.onProgressUpdate((msg: DownloadProgressMessage) => { if (msg.requestId !== requestIdRef.current) return; const { type, payload } = msg; - let accumErr: object = msg; + let accumErr: DownloadFailure = msg; // A retry that starts making progress again (or finishes) is no // longer "retrying" -- only the 'retrying' case itself should leave // this true. diff --git a/src/ui/hooks/useYtdlpUpdater.tsx b/src/ui/hooks/useYtdlpUpdater.tsx index 8e85d37..8fbcea9 100644 --- a/src/ui/hooks/useYtdlpUpdater.tsx +++ b/src/ui/hooks/useYtdlpUpdater.tsx @@ -54,6 +54,8 @@ function useYtdlpUpdaterState() { useEffect(() => { window.electronAPI.onYtdlpUpdateProgress(({ stage, verificationFailure }: { stage: string; verificationFailure?: boolean }) => { + // SAFETY: main.mjs's ytdlp:startUpdate handler only ever broadcasts + // one of YtdlpUpdateStage's known stage values on this channel. setStage(stage as YtdlpUpdateStage); if (stage === 'error') setVerificationFailure(!!verificationFailure); }); diff --git a/src/ui/screens/LibraryScreen.tsx b/src/ui/screens/LibraryScreen.tsx index 2227b15..52df76c 100644 --- a/src/ui/screens/LibraryScreen.tsx +++ b/src/ui/screens/LibraryScreen.tsx @@ -99,6 +99,11 @@ const SORT_FIELD_LABELS: Record = { quality: 'Quality', }; +// SAFETY: SORT_FIELD_LABELS is a Record, so Object.keys +// can only return SortField values. Computed once here instead of inline in +// the Select's render below, which recomputed this same array every render. +const SORT_FIELD_OPTIONS = Object.keys(SORT_FIELD_LABELS) as SortField[]; + // "Date added to library" means when the video was first tracked, not when // its latest version happened to be added -- epochs are already newest-first // (scanLibrary, library.mjs), so the earliest one is simply the last entry. @@ -977,9 +982,11 @@ function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selec labelId="library-sort-field-label" label="Order by" value={sortField} + // SAFETY: every MenuItem below is keyed by a SortField, so + // this Select can only ever emit one of those values. onChange={(e) => setSortField(e.target.value as SortField)} > - {(Object.keys(SORT_FIELD_LABELS) as SortField[]).map((field) => ( + {SORT_FIELD_OPTIONS.map((field) => ( {SORT_FIELD_LABELS[field]} ))} diff --git a/src/ui/screens/LibraryVideoDetail.tsx b/src/ui/screens/LibraryVideoDetail.tsx index bf6f6bd..1207ab0 100644 --- a/src/ui/screens/LibraryVideoDetail.tsx +++ b/src/ui/screens/LibraryVideoDetail.tsx @@ -223,11 +223,12 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on setCheckingFiles(false); if (!result.success) return; if (result.videoRepaired || result.audioRepaired) { - setMetadata((prev) => ({ - ...prev, - ...(result.videoRepaired ? { downloadedFilePath: result.metadata!.downloadedFilePath } : {}), - ...(result.audioRepaired ? { downloadedAudioFilePath: result.metadata!.downloadedAudioFilePath } : {}), - })); + setMetadata((prev) => { + const next = { ...prev }; + if (result.videoRepaired) next.downloadedFilePath = result.metadata!.downloadedFilePath; + if (result.audioRepaired) next.downloadedAudioFilePath = result.metadata!.downloadedAudioFilePath; + return next; + }); onLibraryChanged(); } const stillMissing = result.videoMissing || result.audioMissing; diff --git a/src/ui/screens/OptionsScreen.tsx b/src/ui/screens/OptionsScreen.tsx index 21511b2..898c0c3 100644 --- a/src/ui/screens/OptionsScreen.tsx +++ b/src/ui/screens/OptionsScreen.tsx @@ -34,6 +34,12 @@ import BulkDeleteConfirmDialog from '../components/BulkDeleteConfirmDialog'; // here rather than main.mjs's SUPPORTED_COOKIE_BROWSERS (the source of truth // for the actual list), which is plain lowercase keyring names, not fit for // a dropdown. +// The keys here do match a fixed, closed set (cookies.mjs's +// SUPPORTED_COOKIE_BROWSERS), but every lookup below indexes by a plain +// `browser`/`cookiesBrowser` string, not a narrowed union -- narrowing this +// properly means threading a dedicated CookieBrowser type through settings +// state too, out of scope here. +// oxlint-disable-next-line anti-slop/no-known-value-widening const COOKIE_BROWSER_LABELS: Record = { brave: 'Brave', chrome: 'Chrome', diff --git a/src/utils/utils.ts b/src/utils/utils.ts index e1436d8..0aa2ad1 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -23,6 +23,10 @@ function isValidUrl(string: string) { // worth a friendly name instead of a bare hostname. Falls back to a // capitalized first hostname segment for anything else, e.g. "vimeo.com" -> // "Vimeo". +// This dictionary is deliberately open (looked up by arbitrary hostname, +// with the documented fallback above for anything not listed), not a fixed +// set of known keys. +// oxlint-disable-next-line anti-slop/no-known-value-widening const KNOWN_PLATFORM_LABELS: Record = { 'soundcloud.com': 'SoundCloud', 'instagram.com': 'Instagram', @@ -88,6 +92,10 @@ function getBestDownloadedQuality(epochs: { metadata: LibraryVideoMetadata }[]): const videoOnly = downloaded.filter((e) => e.metadata.downloadedResolution !== 'MP3'); const pool = videoOnly.length > 0 ? videoOnly : downloaded; const best = pool.reduce((a, b) => (Number(b.metadata.downloadedResolution) > Number(a.metadata.downloadedResolution) ? b : a)); + // SAFETY: recordLibraryDownload/swapLibraryDownload (library.mjs) always + // pass a real resolution string alongside downloadedFilePath; it's only + // null for entries with no downloaded file at all, which the filter above + // already excludes. return { resolution: best.metadata.downloadedResolution as string, format: best.metadata.downloadedFormat }; } // No video download in any version -- a separately-downloaded MP3 still diff --git a/testing/mockData/electronAPIMocks.ts b/testing/mockData/electronAPIMocks.ts index 7a865f6..c827d47 100644 --- a/testing/mockData/electronAPIMocks.ts +++ b/testing/mockData/electronAPIMocks.ts @@ -28,6 +28,10 @@ const emptyLibraryVideoMetadata: LibraryVideoMetadata = { // has no global type declaration (App.tsx sets it dynamically) -- same cast // used in App.test.tsx. export function getInitialDownloaderVideoInfo() { + // SAFETY: window.mockingElectron has no global type declaration (App.tsx + // sets it dynamically); the double cast through `unknown` is required + // because `Window` doesn't structurally have this property at all. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions const mocking = (window as unknown as { mockingElectron?: unknown }).mockingElectron; return mocking === 'yes' ? videoResponseMock.data.response : null; } diff --git a/tools/oxlint/anti-slop/VENDORED.md b/tools/oxlint/anti-slop/VENDORED.md new file mode 100644 index 0000000..c378595 --- /dev/null +++ b/tools/oxlint/anti-slop/VENDORED.md @@ -0,0 +1,23 @@ +# Vendored: anti-slop (generic rules only) + +Source: https://github.com/dmmulroy/anti-slop +Vendored from commit `e8c4880471b23ab7f216fba7b27d173a6ef07d4c` (v0.1.2), 2026-09-05. + +This is a vendored copy, not a dependency — per upstream's own README, anti-slop +is meant to be copied in and maintained locally, not tracked as a live package. +There's no automated re-sync; if you want a newer upstream version, re-copy +`src/` from the anti-slop repo and re-diff against this directory by hand. + +**Dropped from upstream**: the `effect/` rule group (`no-service-constructor-imports`), +since this project doesn't use the Effect framework. If that ever changes, it's +a straightforward re-copy of `effect/index.ts` and `effect/rules/` from upstream, +registered as a second `jsPlugins` entry per the upstream README. + +**Kept as-is**: the `*.test.ts` files alongside each rule. They're upstream's own +`RuleTester` suites for the rules themselves (run via `tsx`, not this project's +Vitest), useful if these vendored rules are ever edited. They're excluded from +this project's own lint/test runs via `oxlint.config.ts`'s `ignorePatterns` and +aren't wired into `npm test`. + +See `../../../oxlint.config.ts` for how this is registered, and the top-level +`npm run lint:anti-slop` script to run it. diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 0000000..2b4ae22 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,41 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; + +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; +import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; +import { noModuleMockingRule } from "./rules/no-module-mocking.ts"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noReflectApplyRule } from "./rules/no-reflect-apply.ts"; +import { noReflectGetRule } from "./rules/no-reflect-get.ts"; +import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; +import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts"; +import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts"; +import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; +import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts"; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-module-mocking": noModuleMockingRule, + "no-object-parameters": noObjectParametersRule, + "no-reflect-apply": noReflectApplyRule, + "no-reflect-get": noReflectGetRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-returns": noUnknownReturnsRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, + }, +}); + +export default antiSlopPlugin; diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.test.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.test.ts new file mode 100644 index 0000000..c0e2b9a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.test.ts @@ -0,0 +1,32 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noChainedTypeAssertionsRule } from "./no-chained-type-assertions.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "chained" }; + +tester.run("anti-slop/no-chained-type-assertions", noChainedTypeAssertionsRule, { + valid: [ + "const value = input as User;", + "const value = (input as User);", + "const value = ({ id: 1 } as const) as const;", + ], + invalid: [ + { name: "as chain", code: "const value = input as unknown as User;", errors: [error] }, + { + name: "parenthesized chain", + code: "const value = (input as unknown) as User;", + errors: [error], + }, + { + name: "angle-bracket chain", + code: "const value = (input);", + errors: [error], + }, + { + name: "mixed const chain", + code: "const value = ({ id: 1 } as const) as User;", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 0000000..0d11852 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.test.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.test.ts new file mode 100644 index 0000000..bccfba6 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.test.ts @@ -0,0 +1,32 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noConditionalEmptyObjectSpreadRule } from "./no-conditional-empty-object-spread.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "avoid" }; + +if (noConditionalEmptyObjectSpreadRule.meta?.fixable !== undefined) { + throw new Error("The rule must not offer an unsafe semantics-changing fix."); +} + +tester.run( + "anti-slop/no-conditional-empty-object-spread", + noConditionalEmptyObjectSpreadRule, + { + valid: [ + "const result = { value };", + "const result = { ...values };", + "const result = condition ? { value } : {};", + ], + invalid: [ + { + code: "const result = { ...(value !== undefined ? { value } : {}) };", + errors: [error], + }, + { + code: "const result = { ...(condition ? {} : { value }) };", + errors: [error], + }, + ], + }, +); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 0000000..ae7248d --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.test.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.test.ts new file mode 100644 index 0000000..66dd635 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.test.ts @@ -0,0 +1,166 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noKnownValueWideningRule } from "./no-known-value-widening.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); + +const error = { messageId: "widening" }; + +const prelude = "type Command = () => void; const startCommand = () => {};"; + +tester.run("anti-slop/no-known-value-widening", noKnownValueWideningRule, { + valid: [ + `${prelude} const commands: Record = {};`, + `${prelude} type Index = Record; const commands: Index = {};`, + `${prelude} class Registry { commands: Record = {}; }`, + `${prelude} class Registry { accessor commands: Record = {}; }`, + `${prelude} let commands: Record; commands = {};`, + `${prelude} function create(): Record { return {}; }`, + `${prelude} const create = (): Record => ({});`, + `${prelude} const commands = {} as Record;`, + `${prelude} const commands = >{};`, + `${prelude} const commands = { start: startCommand };`, + `${prelude} const commands = { start: startCommand } as const;`, + `${prelude} const commands = { start: startCommand } satisfies Record;`, + `${prelude} type Commands = Record; const commands = { start: startCommand } as const satisfies Commands;`, + `${prelude} interface Commands { readonly start: Command } const commands: Commands = { start: startCommand };`, + `${prelude} type Commands = { readonly start: Command }; const commands: Commands = { start: startCommand };`, + `${prelude} type PermissionLevels = { readonly [Level in Permission]: number }; const levels: PermissionLevels = { admin: 1 };`, + `${prelude} function create() { return { start: startCommand }; }`, + `${prelude} interface Commands { readonly start: Command } function create(): Commands { return { start: startCommand }; }`, + `${prelude} declare function make(): Record; const commands: Record = make();`, + `${prelude} import { Commands } from './types'; const commands: Commands = { start: startCommand };`, + `${prelude} type Diet = 'vegan' | 'omnivore'; const labels: Record = { vegan: 'V', omnivore: 'O' };`, + `${prelude} type Diet = 'vegan' | 'omnivore'; type Labels = Record; const labels: Labels = { vegan: 'V', omnivore: 'O' };`, + `${prelude} type Diet = 'vegan' | 'omnivore'; const labels: Readonly> = { vegan: 'V', omnivore: 'O' };`, + `${prelude} const labels: Record<'a' | 'b', number> = { a: 1, b: 2 };`, + `${prelude} type Index = Record; const commands: Index<'start', Command> = { start: startCommand };`, + "function isString(value: unknown): value is string { return true; } declare const input: unknown; isString(input);", + "function isString(value: string | unknown): value is string { return true; } declare const input: string | unknown; isString(input);", + "function isString(value: unknown): value is string { return true; } declare function readInput(): unknown; isString(readInput());", + ], + invalid: [ + { code: "const value: unknown = {};", errors: [error] }, + { code: "const value: object = {};", errors: [error] }, + { code: "let value: unknown; value = {};", errors: [error] }, + { code: "function create(): unknown { return {}; }", errors: [error] }, + { + code: `${prelude} const commands: Record = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: { [key: string]: Command } = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: { [K in string]: Command } = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: { start: Command } = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands = { start: startCommand } as Record;`, + errors: [error], + }, + { + code: `${prelude} const commands = ({ start: startCommand } as Record) as object;`, + errors: 1, + }, + { + code: `${prelude} class Registry { commands: Record = { start: startCommand }; }`, + errors: [error], + }, + { + code: `${prelude} let commands: Record; commands = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} function create(): Record { return { start: startCommand }; }`, + errors: [error], + }, + { + code: `${prelude} function create(): { start: Command } { return { start: startCommand }; }`, + errors: [error], + }, + { + code: `${prelude} const source = { start: startCommand }; const commands: Record = source;`, + errors: [error], + }, + { + code: `${prelude} type Open = Record; const source = { start: startCommand }; const commands: Open = source;`, + errors: [error], + }, + { + code: `${prelude} type Open = { [key: string]: Command }; const source = { start: startCommand }; const commands: Open = source;`, + errors: [error], + }, + { + code: `${prelude} type Open = { [key in string]: Command }; const source = { start: startCommand }; const commands: Open = source;`, + errors: [error], + }, + { + code: `${prelude} type Open = Readonly>; const source = { start: startCommand }; const commands: Open = source;`, + errors: [error], + }, + { + code: `${prelude} function outer() { type Open = Record; const commands: Open = { start: startCommand }; }`, + errors: [error], + }, + { + code: `${prelude} type Index = Record; const commands: Index = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} type Identity = T; const commands: Identity> = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} type Index = Record; type CommandsByName = Index; const commands: CommandsByName = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} type Index = Record; const commands: Index = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} type Key = string; const commands: Record = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: Record = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: Record = { start: startCommand };`, + errors: [error], + }, + { code: "const value: unknown = 1;", errors: [error] }, + { code: "const value: object = [];", errors: [error] }, + { + code: "function isString(value: unknown): value is string { return true; } isString('known');", + errors: [error], + }, + { + code: "function isString(value: unknown): value is string { return true; } const known = 'known'; isString(known);", + errors: [error], + }, + { + code: "function isString(value: string | unknown): value is string { return true; } isString('known');", + errors: [error], + }, + { + code: "function isString(value: unknown): value is string { return true; } function check(known: string): boolean { return isString(known); }", + errors: [error], + }, + { + code: "const isString = (value: unknown): value is string => true; const known: string = getValue(); isString(known);", + errors: [error], + }, + { + code: "type User = { readonly id: string }; function isUser(value: unknown): value is User { return true; } function parse(): User { return { id: 'known' }; } const user = parse(); isUser(user);", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 0000000..7defff3 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,427 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionaryValue, + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.ts"; +import { + containsUnknownType, + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function isFunctionExpression(node: ESTree.Node): node is FunctionExpression { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "TSDeclareFunction" || + node.type === "TSEmptyBodyFunctionExpression" + ); +} + +function localFunctionForCall( + sourceCode: SourceCode, + callee: ESTree.Expression, +): FunctionExpression | null { + const unwrapped = unwrapExpression(callee); + if (isFunctionExpression(unwrapped)) return unwrapped; + if (unwrapped.type !== "Identifier") return null; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || variable.defs.length !== 1) return null; + const [definition] = variable.defs; + if (definition === undefined) return null; + if (definition.type === "FunctionName" && isFunctionExpression(definition.node)) { + return definition.node; + } + if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") { + return null; + } + const initializer = definition.node.init; + if (initializer === null) return null; + const unwrappedInitializer = unwrapExpression(initializer); + return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null; +} + +function variableTypeAnnotation( + sourceCode: SourceCode, + variable: Variable, +): ESTree.TSTypeAnnotation | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + if (definition === undefined) return null; + if ( + definition.type === "Variable" && + definition.node.type === "VariableDeclarator" && + definition.node.id.type === "Identifier" + ) { + return definition.node.id.typeAnnotation ?? null; + } + if (definition.type !== "Parameter" || !isFunctionExpression(definition.node)) { + return null; + } + const parameter = definition.node.params.find( + (candidate) => + functionParameterBindingName(candidate, sourceCode) === variable.name, + ); + return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null); +} + +function hasInformativeType( + type: ESTree.TSType, + environment: TypeEnvironment, +): boolean { + return classifyUnsafeDictionaryValue(type, environment) === null; +} + +function hasKnownCallArgumentEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + environment: TypeEnvironment, + visitedVariables = new Set(), +): boolean { + if (expression.type === "ParenthesizedExpression" || expression.type === "TSNonNullExpression") { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables, + ); + } + if (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") { + return hasInformativeType(expression.typeAnnotation, environment); + } + if (expression.type === "TSSatisfiesExpression") { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables, + ); + } + if (expression.type === "CallExpression") { + const owner = localFunctionForCall(sourceCode, expression.callee); + const returnType = owner?.returnType?.typeAnnotation; + return returnType !== undefined && hasInformativeType(returnType, environment); + } + if (expression.type !== "Identifier") return isKnownEvidenceExpression(expression); + const variable = resolveVariable(sourceCode, expression); + if (variable === null || visitedVariables.has(variable)) return false; + const annotation = variableTypeAnnotation(sourceCode, variable); + if (annotation !== null) { + return hasInformativeType(annotation.typeAnnotation, environment); + } + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownCallArgumentEvidence( + sourceCode, + declarator.init, + environment, + visitedVariables, + ); +} + +function typePredicateSubjectIndex( + sourceCode: SourceCode, + owner: FunctionExpression, +): number | null { + const predicate = owner.returnType?.typeAnnotation; + if (predicate?.type !== "TSTypePredicate" || predicate.parameterName.type !== "Identifier") { + return null; + } + const predicateParameterName = predicate.parameterName.name; + const index = owner.params.findIndex( + (parameter) => + functionParameterBindingName(parameter, sourceCode) === predicateParameterName, + ); + return index === -1 ? null : index; +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; + if (key.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return "anonymous function"; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") + return parent.id.name; + if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", + }, + messages: { + widening: + "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string, + ) => { + if (destination === null) return; + if ( + isDictionaryAccumulatorTarget(destination) && + isEmptyObjectExpression(expression) + ) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: "widening", + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== "Identifier") return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\``, + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== "Identifier") return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ); + }, + CallExpression(node) { + if (environment === null) return; + const owner = localFunctionForCall(context.sourceCode, node.callee); + if (owner === null) return; + const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner); + if (parameterIndex === null) return; + const parameter = owner.params[parameterIndex]; + const argument = node.arguments[parameterIndex]; + if (parameter === undefined || argument === undefined || argument.type === "SpreadElement") { + return; + } + const parameterAnnotation = functionParameterTypeAnnotation(parameter); + if ( + parameterAnnotation === null || + parameterAnnotation === undefined || + !containsUnknownType(parameterAnnotation.typeAnnotation) + ) { + return; + } + if ( + !hasKnownCallArgumentEvidence( + context.sourceCode, + argument, + environment, + ) + ) { + return; + } + context.report({ + node: argument, + messageId: "widening", + data: { + subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``, + target: "unknown", + }, + }); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === "BlockStatement") return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.test.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.test.ts new file mode 100644 index 0000000..b4c22cc --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.test.ts @@ -0,0 +1,28 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noModuleMockingRule } from "./no-module-mocking.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "moduleMock" }; + +tester.run("anti-slop/no-module-mocking", noModuleMockingRule, { + valid: [ + "const store = new InMemoryUserStore();", + "vi.spyOn(store, 'save');", + "const vi = { mock() {} }; vi.mock();", + "function test(jest: { mock(): void }) { jest.mock(); }", + "import { vi as localVi } from './helpers'; localVi.mock('./module');", + ], + invalid: [ + { code: "vi.mock('./user-store');", errors: [error] }, + { code: "jest.mock('./user-store');", errors: [error] }, + { code: "vi['doMock']('./user-store');", errors: [error] }, + { code: "jest.unstable_mockModule('./user-store');", errors: [error] }, + { code: "import { vi } from 'vitest'; vi.mock('./user-store');", errors: [error] }, + { code: "import { vi as testApi } from 'vitest'; testApi.mock('./user-store');", errors: [error] }, + { + code: "import { jest } from '@jest/globals'; jest.mock('./user-store');", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts new file mode 100644 index 0000000..d6fb5b4 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -0,0 +1,91 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function importedName(node: ESTree.Node): string | null { + if (node.type !== "ImportSpecifier") return null; + return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression, +): expression is ESTree.IdentifierReference { + if (expression.type !== "Identifier") return false; + if ( + (expression.name === "vi" || expression.name === "jest") && + sourceCode.isGlobalReference(expression) + ) { + return true; + } + + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === "vi" || expression.name === "jest"; + } + return variable.defs.some((definition) => { + if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + }); +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) return false; + const property = callee.property; + const method = callee.computed + ? property.type === "Literal" && + (property.value === "doMock" || + property.value === "mock" || + property.value === "unstable_mockModule") + ? property.value + : null + : property.type === "Identifier" + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} + +/** Ban test framework module mocking in favor of real dependency seams. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", + }, + messages: { + moduleMock: + "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: "moduleMock" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.test.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.test.ts new file mode 100644 index 0000000..3c5f08d --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.test.ts @@ -0,0 +1,63 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noObjectParametersRule } from "./no-object-parameters.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "objectParameter" }; + +tester.run("anti-slop/no-object-parameters", noObjectParametersRule, { + valid: [ + "type Alias = object;", + "function f(value: Alias) {}", + "interface Owner { readonly id: string } function f(value: Owner) {}", + "function f(value: Value) {}", + "function f(value: Value) {}", + "function f(value: Value) {}", + "type Owner = { readonly id: string }; function f(value: Value) {}", + "type Alias = object; function consume(value: Alias) {}", + "type Alias = object; type Consumer = (value: Alias) => void;", + "type Alias = object; interface Consumer { consume(value: Alias): void }", + "type Key = object; type Mapped = { [Key in keyof Input]: (value: Key) => void };", + "type Item = object; type Unpacked = Input extends Promise ? (value: Item) => void : never;", + "type Payload = object; function outer() { type Payload = { readonly id: string }; function consume(value: Payload) {} }", + "type Identity = T; function consume(value: Identity) {}", + "function one() { type Payload = object; } function two() { function consume(value: Payload) {} }", + ], + invalid: [ + { code: "function f(value: object) {}", errors: [error] }, + { code: "type Alias = object; function f(value: Alias) {}", errors: [error] }, + { code: "type Alias = (object); function f(value: Alias) {}", errors: [error] }, + { + code: "type Item = object; type Fallback = Input extends infer Item ? string : (value: Item) => void;", + errors: [error], + }, + { + code: "function consume(value: object = {}): void {}", + errors: [{ ...error, data: { parameter: "value" } }], + }, + { + code: "function consume({ value }: object = {}): void {}", + errors: [{ ...error, data: { parameter: "{ value }" } }], + }, + { + code: "type Bag = object; function consume({ value }: Bag): void {}", + errors: [{ ...error, data: { parameter: "{ value }" } }], + }, + { + code: "function outer() { type Payload = object; function consume(value: Payload) {} }", + errors: [error], + }, + { + code: "function outer() { function consume(value: Payload) {} type Payload = object; }", + errors: [error], + }, + { + code: "type Identity = T; function consume(value: Identity) {}", + errors: [error], + }, + { + code: "type Identity = T; type Wrapped = Identity; function consume(value: Wrapped) {}", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 0000000..6589ebf --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; +import { + createTypeAliasEnvironment, + resolvedTypeMatches, + type TypeAliasEnvironment, +} from "../shared/type-alias-resolution.ts"; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +/** Ban the broad object type on function inputs, including local aliases to object. */ +export const noObjectParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", + }, + }, + createOnce(context) { + let environment: TypeAliasEnvironment | null = null; + + const resolvesToObject = (type: ESTree.TSType): boolean => + environment !== null && + resolvedTypeMatches(type, environment, (resolved, matches) => { + if (resolved.type === "TSObjectKeyword") return true; + if (resolved.type === "TSParenthesizedType") { + return matches(resolved.typeAnnotation); + } + return ( + resolved.type === "TSUnionType" && resolved.types.some(matches) + ); + }); + + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = functionParameterTypeAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: functionParameterBindingName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.test.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.test.ts new file mode 100644 index 0000000..ee24df8 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.test.ts @@ -0,0 +1,19 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noReflectApplyRule } from "./no-reflect-apply.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "reflectApply" }; + +tester.run("anti-slop/no-reflect-apply", noReflectApplyRule, { + valid: [ + "const value = operation.apply(owner, args);", + "Reflect.get(owner, key);", + "const Reflect = { apply() { return 1; } }; Reflect.apply();", + "function invoke(Reflect: { apply(): number }) { return Reflect.apply(); }", + ], + invalid: [ + { code: "const value = Reflect.apply(operation, owner, args);", errors: [error] }, + { code: "const value = Reflect['apply'](operation, owner, args);", errors: [error] }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts new file mode 100644 index 0000000..2cc3045 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", + }, + messages: { + reflectApply: + "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { + context.report({ node, messageId: "reflectApply" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.test.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.test.ts new file mode 100644 index 0000000..c4d9155 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.test.ts @@ -0,0 +1,20 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noReflectGetRule } from "./no-reflect-get.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "reflectGet" }; + +tester.run("anti-slop/no-reflect-get", noReflectGetRule, { + valid: [ + "const value = owner.property;", + "const value = owner[key];", + "Reflect.set(owner, key, value);", + "const Reflect = { get() { return 1; } }; Reflect.get();", + "function read(Reflect: { get(): number }) { return Reflect.get(); }", + ], + invalid: [ + { name: "static access", code: "const value = Reflect.get(owner, key);", errors: [error] }, + { name: "computed access", code: "const value = Reflect['get'](owner, key);", errors: [error] }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 0000000..cf630ec --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", + }, + messages: { + reflectGet: + "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { + context.report({ node, messageId: "reflectGet" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.test.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.test.ts new file mode 100644 index 0000000..abeeaed --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.test.ts @@ -0,0 +1,47 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noRuntimeTypeofRule } from "./no-runtime-typeof.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "runtimeTypeof" }; +const allowInTypeGuards = [{ allowInTypeGuards: true }]; + +tester.run("anti-slop/no-runtime-typeof", noRuntimeTypeofRule, { + valid: [ + 'const isServer = typeof document === "undefined";', + 'const hasStorage = typeof localStorage !== "undefined";', + 'if (typeof globalThis.crypto === "undefined") throw new Error("no crypto");', + 'const missing = "undefined" === typeof process;', + "const value = input;", + { + code: 'function isString(value: unknown): value is string { return typeof value === "string"; }', + options: allowInTypeGuards, + }, + { + code: 'const isString = (value: unknown): value is string => typeof value === "string";', + options: allowInTypeGuards, + }, + { + code: 'function assertString(value: unknown): asserts value is string { if (typeof value !== "string") throw new Error(); }', + options: allowInTypeGuards, + }, + ], + invalid: [ + { code: 'if (typeof input === "string") use(input);', errors: [error] }, + { code: "if (typeof input === undefined) use(input);", errors: [error] }, + { + code: 'function isString(value: unknown): value is string { return typeof value === "string"; }', + errors: [error], + }, + { + code: 'function parse(value: unknown): string { if (typeof value !== "string") throw new Error(); return value; }', + options: allowInTypeGuards, + errors: [error], + }, + { + code: 'function isString(value: unknown): value is string { const check = () => typeof value === "string"; return check(); }', + options: allowInTypeGuards, + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 0000000..43259eb --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function; + +function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" + ); +} + +function isInsideTypeGuard(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isRuntimeFunction(current)) { + return current.returnType?.typeAnnotation.type === "TSTypePredicate"; + } + current = current.parent; + } + return false; +} + +/** Return whether typeof safely probes for the existence of a possibly absent binding. */ +function isExistenceProbe(node: ESTree.UnaryExpression): boolean { + const parent = node.parent; + if (parent.type !== "BinaryExpression") return false; + if (!["===", "!==", "==", "!="].includes(parent.operator)) return false; + const other = parent.left === node ? parent.right : parent.left; + return other.type === "Literal" && other.value === "undefined"; +} + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.", + }, + messages: { + runtimeTypeof: + "A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.", + }, + schema: [ + { + type: "object", + properties: { + allowInTypeGuards: { type: "boolean" }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0]; + const allowInTypeGuards = + typeof option === "object" && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true; + if ( + node.operator === "typeof" && + !isExistenceProbe(node) && + (!allowInTypeGuards || !isInsideTypeGuard(node)) + ) { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.test.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.test.ts new file mode 100644 index 0000000..4d34612 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.test.ts @@ -0,0 +1,25 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noForbiddenTermInSymbolNamesRule } from "./no-shape-in-symbol-names.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "forbiddenSymbolName" }; + +tester.run("anti-slop/no-shape-in-symbol-names", noForbiddenTermInSymbolNamesRule, { + valid: [ + "declare const schema: ExternalSchema; const field = schema.shape.id;", + "declare const outer: External; const value = outer.inner.shape;", + "declare const schema: ExternalSchema; schema.shape.id.parse('x');", + "const owner = { id: 1 }; const value = owner.id;", + ], + invalid: [ + { code: "const shape = 1;", errors: [error] }, + { code: "function shapeOf() {}", errors: [error] }, + { code: "type PayloadShape = { id: string };", errors: [error] }, + { code: "type Payload = { shape: string };", errors: [error] }, + { + code: "declare const owner: External; const shape = 'field'; const value = owner[shape];", + errors: 2, + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 0000000..436d2a2 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,46 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +const FORBIDDEN_SYMBOL_NAME = "shape"; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Return whether an identifier names a statically accessed member owned by another value. */ +function isBorrowedMemberName(node: ESTree.Node): boolean { + const parent = node.parent; + if (parent === null || parent.type !== "MemberExpression") return false; + return parent.property === node && parent.computed === false; +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name) || isBorrowedMemberName(node)) return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.test.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.test.ts new file mode 100644 index 0000000..75aca19 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.test.ts @@ -0,0 +1,36 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noUnknownParametersRule } from "./no-unknown-parameters.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "unknownParameter" }; + +tester.run("anti-slop/no-unknown-parameters", noUnknownParametersRule, { + valid: [ + "function enrich(cause: unknown): void {}", + "function enrich(cause: Error | unknown): void {}", + "function isString(value: unknown): value is string { return true; }", + "const isString = (value: unknown): value is string => true;", + "function assertString(value: unknown): asserts value is string {}", + "type Guard = (value: unknown) => value is string;", + "declare function isString(value: unknown): value is string;", + "type Guards = { isString(value: unknown): value is string };", + "function parse(value: string | number): void {}", + ], + invalid: [ + { code: "function parse(value: unknown): void {}", errors: [error] }, + { code: "function parse(value: string | unknown): void {}", errors: [error] }, + { + code: "function parse(value: string | (number | unknown)): void {}", + errors: [error], + }, + { + code: "function isString(value: unknown, context: unknown): value is string { return true; }", + errors: [{ ...error, data: { parameter: "context" } }], + }, + { + code: "export function parse({ value }: unknown = {}): void {}", + errors: [{ ...error, data: { parameter: "{ value }" } }], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 0000000..b4a1545 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,69 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +import { + containsUnknownType, + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function isTypePredicateSubject(owner: ParameterOwner, parameterName: string): boolean { + const predicate = owner.returnType?.typeAnnotation; + return ( + predicate?.type === "TSTypePredicate" && + predicate.parameterName.type === "Identifier" && + predicate.parameterName.name === parameterName + ); +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow explicitly unknown function parameters except `cause` and type-predicate subjects; decode unknown input at its I/O boundary instead.", + }, + messages: { + unknownParameter: + "Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + }, + }, + createOnce(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = functionParameterTypeAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!containsUnknownType(annotation.typeAnnotation)) continue; + const name = functionParameterBindingName(parameter, context.sourceCode); + if (name === "cause" || isTypePredicateSubject(node, name)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "unknownParameter", + data: { parameter: name }, + }); + } + }; + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.test.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.test.ts new file mode 100644 index 0000000..f0c1e79 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.test.ts @@ -0,0 +1,47 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noUnknownReturnsRule } from "./no-unknown-returns.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "unknownReturn" }; + +tester.run("anti-slop/no-unknown-returns", noUnknownReturnsRule, { + valid: [ + "type ImportedValue = unknown;", + "function parse(): ImportedValue { return input; }", + "function parse(): User { return user; }", + "function infer() { return input; }", + "function generic(): Value { return value; }", + "type Value = unknown; function generic(): Value { return value; }", + "type Key = unknown; type Mapped = { [Key in keyof Input]: () => Key };", + "type Item = unknown; type Unpacked = Input extends Promise ? () => Item : never;", + "function cause(): { cause: unknown } { return { cause: input }; }", + "type Result = { value: unknown }; function load(): Result { return result; }", + "function load(): Promise { return promise; }", + "type Identity = T; function load(): Identity { return user; }", + "type Value = unknown; function outer() { type Value = User; function load(): Value { return user; } }", + ], + invalid: [ + { code: "function load(): unknown { return input; }", errors: [error] }, + { code: "const load = (): unknown => input;", errors: [error] }, + { code: "type Loader = () => unknown;", errors: [error] }, + { code: "interface Loader { load(): unknown }", errors: [error] }, + { code: "declare function load(): unknown;", errors: [error] }, + { code: "function load(): string | unknown { return input; }", errors: [error] }, + { code: "function load(): Promise { return promise; }", errors: [error] }, + { code: "type UnknownValue = unknown; function load(): UnknownValue { return input; }", errors: [error] }, + { code: "type Item = unknown; type Fallback = Input extends infer Item ? string : () => Item;", errors: [error] }, + { + code: "function outer() { type Result = unknown; function load(): Result { return input; } }", + errors: [error], + }, + { + code: "type Identity = T; function load(): Identity { return input; }", + errors: [error], + }, + { + code: "type Identity = T; type Wrapped = Promise>; function load(): Wrapped { return promise; }", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts new file mode 100644 index 0000000..e1f43f8 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -0,0 +1,82 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + resolvedTypeMatches, + type TypeAliasEnvironment, +} from "../shared/type-alias-resolution.ts"; + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +/** Ban function contracts that return unknown instead of a parsed domain type. */ +export const noUnknownReturnsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow functions whose explicit return contract is unknown or Promise.", + }, + messages: { + unknownReturn: + "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", + }, + }, + createOnce(context) { + let environment: TypeAliasEnvironment | null = null; + + const resolvesToUnknown = (type: ESTree.TSType): boolean => + environment !== null && + resolvedTypeMatches(type, environment, (resolved, matches) => { + if (resolved.type === "TSUnknownKeyword") return true; + if (resolved.type === "TSParenthesizedType") { + return matches(resolved.typeAnnotation); + } + if (resolved.type === "TSUnionType") return resolved.types.some(matches); + if ( + resolved.type !== "TSTypeReference" || + resolved.typeName.type !== "Identifier" || + (resolved.typeName.name !== "Promise" && + resolved.typeName.name !== "PromiseLike") + ) { + return false; + } + const value = resolved.typeArguments?.params[0]; + return value !== undefined && matches(value); + }); + + const checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType; + if (annotation === null || annotation === undefined) return; + if (!resolvesToUnknown(annotation.typeAnnotation)) return; + context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" }); + }; + + return { + Program(node) { + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.test.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.test.ts new file mode 100644 index 0000000..32c1151 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.test.ts @@ -0,0 +1,37 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noUnknownTypeAliasesRule } from "./no-unknown-type-aliases.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "unknownAlias" }; + +tester.run("anti-slop/no-unknown-type-aliases", noUnknownTypeAliasesRule, { + valid: [ + "type User = { readonly id: string };", + "type Alias = string; type UserId = Alias;", + "type Payload = string | number;", + "type Box = { readonly value: T }; type Payload = Box;", + ], + invalid: [ + { code: "type Alias = unknown;", errors: [error] }, + { code: "type Current = unknown;", errors: [error] }, + { code: "type UnknownValue = unknown; type Alias = UnknownValue;", errors: [error, error] }, + { code: "type Payload = string | unknown;", errors: [error] }, + { + code: "type Payload = string | unknown; type NestedPayload = number | Payload;", + errors: [error, error], + }, + { + code: "type Identity = T; type Payload = Identity;", + errors: [error], + }, + { + code: "type Identity = T; type Wrapped = Identity; type Payload = Wrapped;", + errors: [error], + }, + { + code: "function outer() { type Payload = unknown; }", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 0000000..af5f08a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,54 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + resolvedTypeMatches, + type TypeAliasEnvironment, +} from "../shared/type-alias-resolution.ts"; + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", + }, + messages: { + unknownAlias: + "Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.", + }, + }, + createOnce(context) { + let environment: TypeAliasEnvironment | null = null; + + const resolvesToUnknown = (type: ESTree.TSType): boolean => + environment !== null && + resolvedTypeMatches(type, environment, (resolved, matches) => { + if (resolved.type === "TSUnknownKeyword") return true; + if (resolved.type === "TSParenthesizedType") { + return matches(resolved.typeAnnotation); + } + return resolved.type === "TSUnionType" && resolved.types.some(matches); + }); + + return { + Program(node) { + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + TSTypeAliasDeclaration(node) { + if (!resolvesToUnknown(node.typeAnnotation)) return; + context.report({ + node: node.id, + messageId: "unknownAlias", + data: { alias: node.id.name }, + }); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.test.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.test.ts new file mode 100644 index 0000000..c45fdac --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.test.ts @@ -0,0 +1,115 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noUnsafeDictionaryTypeRule } from "./no-unsafe-dictionary-type.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); + +const error = { messageId: "unsafeDictionary" }; + +tester.run("anti-slop/no-unsafe-dictionary-type", noUnsafeDictionaryTypeRule, { + valid: [ + "type Commands = Record;", + "type Metadata = Record;", + "type PermissionLevels = Record;", + "type Indexed = { [key: string]: Command };", + "type CompatibleIndexes = { [index: number]: Command; [key: string]: Command | OtherCommand };", + "type Exhaustive = { [K in Permission]: number };", + "type Allowed = Record;", + "type AlsoAllowed = Record>;", + "type Index = Record; type EntityIndex = Record;", + "type Safe = Index; type Index = Record;", + "type A = Map; type B = ReadonlyMap; type C = WeakMap;", + "import { Record } from './local'; type A = Record;", + "type Record = { key: K; value: V }; type A = Record;", + "type Readonly = { value: T }; type A = Record>;", + "type NonNullable = { value: T }; type A = Record>;", + "type Value = T; type Index> = Record; type A = Index;", + "interface Owner { readonly id: string } type A = Record;", + "interface Owner { readonly id: string } interface Child extends Owner {} type A = Record;", + "interface Owner { readonly id: string } interface Child extends Owner { readonly __brand?: never } type A = Record;", + "interface Escape {} interface Escape { readonly id: string } type A = Record;", + "interface Escape { readonly id: string } interface Escape {} type A = Record;", + "interface Owner { readonly id: string } type A = Record;", + "type Wrap = { readonly wrapped: T }; type Inner = { readonly value: T } & Wrap; type Outer = Record>; declare function f(): Outer;", + "type WithSchema> = (schema: T) => void;", + "function run>(input: T): T { return input; }", + "declare class Store> { read(): T }", + "type Deep>> = T;", + ], + invalid: [ + { code: "type A = Record;", errors: [error] }, + { code: "type A = { [key: string]: any };", errors: [error] }, + { code: "type A = { [index: number]: Command; [key: string]: unknown | Command };", errors: 1 }, + { code: "type A = { [K in PropertyKey]: object };", errors: [error] }, + { code: "type A = { [K in PropertyKey]: NonNullable };", errors: [error] }, + { code: "type A = { [key: string]: NonNullable };", errors: [error] }, + { code: "type A = Record;", errors: [error] }, + { code: "interface Escape {} type A = Record;", errors: [error] }, + { + code: "interface Escape { readonly __brand?: never } type A = Record;", + errors: [error], + }, + { + code: "type Escape = { readonly __brand?: never }; type A = Record;", + errors: [error], + }, + { code: "type A = Record;", errors: [error] }, + { code: "type A = Record;", errors: [error] }, + { code: "interface Escape {} type A = Record;", errors: [error] }, + { code: "type A = Record;", errors: [error] }, + { + code: "interface Owner { readonly id: string } type A = Record;", + errors: [error], + }, + { code: "type Escape = unknown; type A = Record;", errors: [error] }, + { code: "type Dict = Record;", errors: [error] }, + { code: "type A = Readonly)>>>;", errors: [error] }, + { code: "type A = { readonly [key: string]: unknown };", errors: [error] }, + { code: "type A = { readonly [K in string]: unknown };", errors: [error] }, + { code: "type Source = Record; type A = Pick;", errors: 2 }, + { code: "type Source = Record; type A = Omit;", errors: 2 }, + { code: "type Index = Record; type A = Index;", errors: 1 }, + { code: "interface A { [key: string]: unknown }", errors: [error] }, + { code: "type A = Readonly>;", errors: 1 }, + { code: "type A = Record>;", errors: [error] }, + { code: "type A = Record>;", errors: [error] }, + { code: "type A = Record>;", errors: [error] }, + { code: "type Escape = Readonly; type A = Record;", errors: 1 }, + { + code: "type Wrapped = Readonly; type A = Record>;", + errors: 1, + }, + { code: "type A = Record>;", errors: [error] }, + { code: "type Escape = NonNullable; type A = Record;", errors: 1 }, + { + code: "type Unsafe = Record; const x: Unsafe = {}; const y: Unsafe = {};", + errors: 1, + }, + { + code: "type Unsafe = Record; type AlsoUnsafe = Unsafe; const x: Unsafe = {};", + errors: 2, + }, + { code: "type Index = Record; type A = Index;", errors: 1 }, + { + code: "type Index = Record; type A = Index;", + errors: 1, + }, + { code: "type Index = Record; type A = Index;", errors: 1 }, + { + code: "type Value = T; type Index> = Record; type A = Index;", + errors: 1, + }, + { + code: "type Marker = { readonly __brand?: never }; type Index> = Record; type A = Index;", + errors: 1, + }, + { + code: "function outer() { type Identity = T; type A = Record>; }", + errors: 1, + }, + { + code: "function local() { type Record = { key: K; value: V }; type A = Record; } function global() { type A = Record; }", + errors: 1, + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 0000000..8cb615a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,154 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; +import { visibleTypeAlias } from "../shared/type-alias-resolution.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +const typeNodeKinds: ReadonlySet = new Set([ + "JSDocNonNullableType", + "JSDocNullableType", + "JSDocUnknownType", + "TSAnyKeyword", + "TSArrayType", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSConditionalType", + "TSConstructorType", + "TSFunctionType", + "TSImportType", + "TSIndexedAccessType", + "TSInferType", + "TSIntersectionType", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSMappedType", + "TSNamedTupleMember", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSParenthesizedType", + "TSStringKeyword", + "TSSymbolKeyword", + "TSTemplateLiteralType", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeOperator", + "TSTypePredicate", + "TSTypeQuery", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnionType", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return ( + name !== null && + visibleTypeAlias(name, node, environment.typeAliases) !== null && + !isInsideTypeAliasDeclaration(node) + ); +} + +function isInsideTypeParameterConstraint(node: ESTree.TSType): boolean { + let child: ESTree.Node = node; + let parent: ESTree.Node | null = child.parent; + while (parent !== null && parent.type !== "Program") { + if (parent.type === "TSTypeParameter" && parent.constraint === child) return true; + child = parent; + parent = child.parent; + } + return false; +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isInsideTypeParameterConstraint(node)) return false; + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.test.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.test.ts new file mode 100644 index 0000000..0686110 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.test.ts @@ -0,0 +1,19 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noWidenThenAssertRule } from "./no-widen-then-assert.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "widenThenAssert" }; + +tester.run("anti-slop/no-widen-then-assert", noWidenThenAssertRule, { + valid: [ + "const source = { id: 'first' }; const widened: unknown = source;", + "declare const input: unknown; const parsed = input as { readonly id: string };", + ], + invalid: [ + { + code: "const source = { id: 'second' }; const widened: unknown = source; const parsed = widened as { readonly id: string };", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 0000000..c5e07f7 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = []; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes; + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.test.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.test.ts new file mode 100644 index 0000000..bdfa7bb --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.test.ts @@ -0,0 +1,72 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { requireSafetyCommentForTypeAssertionRule } from "./require-safety-comment-for-type-assertion.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "missingSafetyComment" }; + +tester.run( + "anti-slop/require-safety-comment-for-type-assertion (custom markers)", + requireSafetyCommentForTypeAssertionRule, + { + valid: [ + { + code: "// INVARIANT: The caller parsed this value.\nconst value = input as User;", + options: [{ markers: ["INVARIANT"] }], + }, + { + code: "// SAFETY: The caller parsed this value.\nconst value = input as User;", + options: [{ markers: ["INVARIANT", "SAFETY"] }], + }, + { + code: "// SAFE+: The caller parsed this value.\nconst value = input as User;", + options: [{ markers: ["SAFE+"] }], + }, + ], + invalid: [ + { + code: "// SAFETY: This marker is not configured.\nconst value = input as User;", + options: [{ markers: ["INVARIANT"] }], + errors: [error], + }, + { + code: "// INVARIANT: \nconst value = input as User;", + options: [{ markers: ["INVARIANT"] }], + errors: [error], + }, + ], + }, +); + +tester.run( + "anti-slop/require-safety-comment-for-type-assertion", + requireSafetyCommentForTypeAssertionRule, + { + valid: [ + "const values = [1, 2] as const;", + "const value = { id: 'one' };", + "// SAFETY: The parser established the UserId invariant.\nconst id = value as UserId;", + "function parse(): UserId {\n// SAFETY: Validation above established the UserId invariant.\nreturn value as UserId;\n}", + "const id = /* SAFETY: Validation established the invariant. */ value as UserId;", + "// SAFETY: The parser established the exported UserId invariant.\nexport const id = value as UserId;", + "/* SAFETY:\n * The parser established the exported UserId invariant.\n */\nexport const id = value as UserId;", + ], + invalid: [ + { code: "const id = value as UserId;", errors: [error] }, + { code: "const id = value;", errors: [error] }, + { code: "const id = value as UserId; // SAFETY: Too late.", errors: [error] }, + { + code: "// This cast seems fine.\nconst id = value as UserId;", + errors: [error], + }, + { code: "// SAFETY:\nconst id = value as UserId;", errors: [error] }, + { code: "// SAFETY: \nconst id = value as UserId;", errors: [error] }, + { code: "const id = /* SAFETY: */ value as UserId;", errors: [error] }, + { code: "export const id = value as UserId;", errors: [error] }, + { + code: "// This is not a safety justification.\nexport const id = value as UserId;", + errors: [error], + }, + ], + }, +); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 0000000..bfea1a2 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,131 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +const DEFAULT_SAFETY_MARKERS = ["SAFETY"] as const; + +const commentOwnerKinds = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === "TSTypeReference" && + node.typeAnnotation.typeName.type === "Identifier" && + node.typeAnnotation.typeName.name === "const" + ); +} + +function configuredSafetyMarkers(option: unknown): readonly string[] { + if (typeof option !== "object" || option === null || !("markers" in option)) { + return DEFAULT_SAFETY_MARKERS; + } + const configured = option.markers; + if (!Array.isArray(configured)) return DEFAULT_SAFETY_MARKERS; + const markers = configured.flatMap((marker) => + typeof marker === "string" && marker.trim().length > 0 ? [marker.trim()] : [], + ); + return markers.length > 0 ? markers : DEFAULT_SAFETY_MARKERS; +} + +function markerPattern(markers: readonly string[]): RegExp { + const alternation = markers + .map((marker) => marker.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)) + .join("|"); + return new RegExp( + String.raw`(?:^|[^\p{L}\p{N}_])(?:${alternation})\s*:\s*\S`, + "u", + ); +} + +function hasSafetyJustificationBefore( + sourceCode: SourceCode, + owner: ESTree.Node, + assertion: TypeAssertion, + pattern: RegExp, +): boolean { + return sourceCode + .getCommentsBefore(owner) + .some( + (comment) => comment.end <= assertion.start && pattern.test(comment.value), + ); +} + +function hasSafetyComment( + sourceCode: SourceCode, + node: TypeAssertion, + pattern: RegExp, +): boolean { + let current: ESTree.Node = node; + while (true) { + if (hasSafetyJustificationBefore(sourceCode, current, node, pattern)) return true; + if (commentOwnerKinds.has(current.type)) { + const exportDeclaration = current.parent; + return ( + exportDeclaration.type === "ExportNamedDeclaration" && + exportDeclaration.declaration === current && + hasSafetyJustificationBefore(sourceCode, exportDeclaration, node, pattern) + ); + } + if (current.parent.type === "Program") return false; + current = current.parent; + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", + }, + messages: { + missingSafetyComment: + "This type assertion has no `{{marker}}:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + schema: [ + { + type: "object", + properties: { + markers: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ markers: ["SAFETY"] }], + }, + createOnce(context) { + const patterns = new Map(); + + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node)) return; + const markers = configuredSafetyMarkers(context.options?.[0]); + const patternKey = markers.join("\u0000"); + const pattern = patterns.get(patternKey) ?? markerPattern(markers); + patterns.set(patternKey, pattern); + if (hasSafetyComment(context.sourceCode, node, pattern)) return; + context.report({ + node, + messageId: "missingSafetyComment", + data: { marker: markers[0] ?? DEFAULT_SAFETY_MARKERS[0] }, + }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 0000000..8db73ff --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,515 @@ +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + hasVisibleTypeBinding, + visibleTypeAlias, + type TypeAliasEnvironment as LexicalTypeAliasEnvironment, +} from "./type-alias-resolution.ts"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly interfaces: ReadonlyMap; + readonly typeAliases: LexicalTypeAliasEnvironment; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment( + program: ESTree.Program, + visitorKeys: Readonly>, +): TypeEnvironment { + const interfaces = new Map(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type !== "TSInterfaceDeclaration") continue; + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + } + + return { + interfaces, + typeAliases: createTypeAliasEnvironment(program, visitorKeys), + }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn( + name: string, + use: ESTree.Node, + environment: TypeEnvironment, +): boolean { + return ( + BUILT_INS.has(name) && + !hasVisibleTypeBinding(name, use, environment.typeAliases) + ); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ( + (name === "Pick" || name === "Omit") && + isBuiltIn(name, unwrapped, environment) + ) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + return null; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, new Map()) + ? { kind: "open dictionary" } + : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + const resolved = + substitutions === null + ? null + : classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function hasBroadRecordKey( + type: ESTree.TSTypeReference, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const key = type.typeArguments?.params[0]; + return key === undefined || isBroadMappedKey(key, environment, substitutions); +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + visitedAliases: ReadonlySet = new Set(), +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some((member) => + isBroadMappedKey(member, environment, substitutions, visitedAliases), + ); + } + if (unwrapped.type !== "TSTypeReference") return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions, visitedAliases); + } + if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true; + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if ( + alias === null || + (alias.typeParameters?.params.length ?? 0) > 0 || + visitedAliases.has(name) + ) { + return false; + } + const nextVisited = new Set(visitedAliases); + nextVisited.add(name); + return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited); +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type === "TSMappedType") { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget( + substitution, + environment, + substitutions, + resolvingAliases, + ); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} diff --git a/tools/oxlint/anti-slop/shared/function-parameters.ts b/tools/oxlint/anti-slop/shared/function-parameters.ts new file mode 100644 index 0000000..80de91d --- /dev/null +++ b/tools/oxlint/anti-slop/shared/function-parameters.ts @@ -0,0 +1,49 @@ +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +export type FunctionParameter = ESTree.ParamPattern; + +/** Return whether a type is or contains TypeScript's absorbing unknown top type. */ +export function containsUnknownType(type: ESTree.TSType): boolean { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation); + return type.type === "TSUnionType" && type.types.some(containsUnknownType); +} + +/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */ +export function functionParameterTypeAnnotation( + parameter: FunctionParameter, +): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return functionParameterTypeAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left); + } + return parameter.typeAnnotation; +} + +/** Return only a function parameter's local binding, excluding its annotation and default value. */ +export function functionParameterBindingName( + parameter: FunctionParameter, + sourceCode: SourceCode, +): string { + if (parameter.type === "TSParameterProperty") { + return functionParameterBindingName(parameter.parameter, sourceCode); + } + if (parameter.type === "AssignmentPattern") { + return functionParameterBindingName(parameter.left, sourceCode); + } + if (parameter.type === "RestElement") { + return functionParameterBindingName(parameter.argument, sourceCode); + } + if (parameter.type === "Identifier") return parameter.name; + + const sourceText = sourceCode.getText(parameter); + const annotationStart = parameter.typeAnnotation?.start; + return annotationStart === undefined + ? sourceText + : sourceText.slice(0, annotationStart - parameter.start).trimEnd(); +} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 0000000..7cdb18c --- /dev/null +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,61 @@ +import type { ESTree } from "@oxlint/plugins"; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts new file mode 100644 index 0000000..39bc218 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -0,0 +1,35 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== "Identifier" || expression.name !== "Reflect") return false; + if (sourceCode.isGlobalReference(expression)) return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} + +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string, +): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isGlobalReflect(sourceCode, callee.object)) return false; + const property = callee.property; + return callee.computed + ? property.type === "Literal" && property.value === methodName + : property.type === "Identifier" && property.name === methodName; +} diff --git a/tools/oxlint/anti-slop/shared/type-alias-resolution.ts b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts new file mode 100644 index 0000000..4744222 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts @@ -0,0 +1,250 @@ +import type { ESTree } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts"; + +type VisitorKeys = Readonly>; +type TypeScope = ESTree.Node; + +type TypeBinding = { + readonly alias: ESTree.TSTypeAliasDeclaration | null; + readonly name: string; + readonly scope: TypeScope; +}; + +type Substitution = { + readonly substitutions: Substitutions; + readonly type: ESTree.TSType; +}; + +type Substitutions = ReadonlyMap; + +export type TypeAliasEnvironment = { + readonly aliases: readonly ESTree.TSTypeAliasDeclaration[]; + readonly bindingsByName: ReadonlyMap; + readonly visitorKeys: VisitorKeys; +}; + +export type ResolvedTypeMatcher = ( + type: ESTree.TSType, + matches: (child: ESTree.TSType) => boolean, +) => boolean; + +const environmentsByProgram = new WeakMap(); + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function enclosingTypeScope(node: ESTree.Node): TypeScope { + let current: ESTree.Node | null = node.parent; + while (current !== null) { + if ( + current.type === "Program" || + current.type === "BlockStatement" || + current.type === "TSModuleBlock" || + current.type === "StaticBlock" || + current.type === "SwitchStatement" + ) { + return current; + } + current = current.parent; + } + return node; +} + +function declaredTypeBinding(node: ESTree.Node): { + readonly alias: ESTree.TSTypeAliasDeclaration | null; + readonly name: string; +} | null { + if (node.type === "TSTypeAliasDeclaration") { + return { alias: node, name: node.id.name }; + } + if ( + node.type === "TSInterfaceDeclaration" || + node.type === "TSEnumDeclaration" || + node.type === "ClassDeclaration" || + node.type === "ClassExpression" + ) { + return node.id === null ? null : { alias: null, name: node.id.name }; + } + if ( + node.type === "ImportSpecifier" || + node.type === "ImportDefaultSpecifier" || + node.type === "ImportNamespaceSpecifier" + ) { + return { alias: null, name: node.local.name }; + } + return null; +} + +function collectTypeBindings( + node: ESTree.Node, + visitorKeys: VisitorKeys, + bindingsByName: Map, + aliases: ESTree.TSTypeAliasDeclaration[], +): void { + const declared = declaredTypeBinding(node); + if (declared !== null) { + const bindings = bindingsByName.get(declared.name) ?? []; + bindings.push({ ...declared, scope: enclosingTypeScope(node) }); + bindingsByName.set(declared.name, bindings); + if (declared.alias !== null) aliases.push(declared.alias); + } + + // SAFETY: Oxlint's visitor keys identify only ESTree child-node properties. + const fields = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = fields[key]; + if (isNode(value)) { + collectTypeBindings(value, visitorKeys, bindingsByName, aliases); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) { + collectTypeBindings(child, visitorKeys, bindingsByName, aliases); + } + } + } +} + +/** Collect every lexical type alias and competing type binding in a program. */ +export function createTypeAliasEnvironment( + program: ESTree.Program, + visitorKeys: VisitorKeys, +): TypeAliasEnvironment { + const cached = environmentsByProgram.get(program); + if (cached !== undefined) return cached; + const bindingsByName = new Map(); + const aliases: ESTree.TSTypeAliasDeclaration[] = []; + collectTypeBindings(program, visitorKeys, bindingsByName, aliases); + const environment = { aliases, bindingsByName, visitorKeys }; + environmentsByProgram.set(program, environment); + return environment; +} + +function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null { + let current: ESTree.Node | null = node; + let distance = 0; + while (current !== null) { + if (current === ancestor) return distance; + current = current.parent; + distance += 1; + } + return null; +} + +function nearestTypeBindings( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): readonly TypeBinding[] { + const candidates = environment.bindingsByName.get(name) ?? []; + let nearestDistance = Number.POSITIVE_INFINITY; + let nearest: TypeBinding[] = []; + for (const candidate of candidates) { + const distance = ancestorDistance(candidate.scope, use); + if (distance === null || distance > nearestDistance) continue; + if (distance === nearestDistance) { + nearest.push(candidate); + continue; + } + nearestDistance = distance; + nearest = [candidate]; + } + return nearest; +} + +/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */ +export function visibleTypeAlias( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): ESTree.TSTypeAliasDeclaration | null { + if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null; + const bindings = nearestTypeBindings(name, use, environment); + return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null; +} + +/** Return whether a local declaration shadows a built-in type at this use. */ +export function hasVisibleTypeBinding( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): boolean { + return ( + lexicalTypeParameterNames(use, environment.visitorKeys).has(name) || + nearestTypeBindings(name, use, environment).length > 0 + ); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function aliasSubstitutions( + alias: ESTree.TSTypeAliasDeclaration, + reference: ESTree.TSTypeReference, + base: Substitutions, +): Substitutions | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = reference.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const explicitArgument = arguments_[index]; + const argument = explicitArgument ?? parameter.default; + if (argument === null || argument === undefined) return null; + const argumentSubstitutions = explicitArgument === undefined ? next : base; + next.set(parameter.name.name, { + type: argument, + substitutions: new Map(argumentSubstitutions), + }); + } + return next; +} + +/** Match a type after resolving visible aliases and substituting their type parameters. */ +export function resolvedTypeMatches( + type: ESTree.TSType, + environment: TypeAliasEnvironment, + matcher: ResolvedTypeMatcher, +): boolean { + const evaluate = ( + current: ESTree.TSType, + substitutions: Substitutions, + resolvingAliases: ReadonlySet, + ): boolean => { + if (current.type === "TSTypeReference") { + const name = typeReferenceName(current); + if (name !== null) { + const substitution = substitutions.get(name); + if (substitution !== undefined && !current.typeArguments?.params.length) { + return evaluate( + substitution.type, + substitution.substitutions, + resolvingAliases, + ); + } + const alias = visibleTypeAlias(name, current, environment); + if (alias !== null && !resolvingAliases.has(alias)) { + const nextSubstitutions = aliasSubstitutions(alias, current, substitutions); + if (nextSubstitutions !== null) { + const nextResolving = new Set(resolvingAliases); + nextResolving.add(alias); + return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving); + } + } + } + } + return matcher(current, (child) => + evaluate(child, substitutions, resolvingAliases), + ); + }; + + return evaluate(type, new Map(), new Set()); +} diff --git a/vitest.setup.ts b/vitest.setup.ts index d3e2a51..6c04137 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -38,6 +38,9 @@ if (typeof HTMLMediaElement !== 'undefined') { // jsdom doesn't implement at all (not even a stub), unlike most of the other // gaps here. if (typeof window !== 'undefined' && !window.matchMedia) { + // SAFETY: this stub only implements the MediaQueryList members Vidstack + // actually calls in tests (addEventListener/removeEventListener and + // friends) -- the rest of the real interface is never exercised here. window.matchMedia = (query: string) => ({ matches: false, media: query, @@ -67,6 +70,11 @@ if (typeof window !== 'undefined') { unobserve() {} disconnect() {} } + // SAFETY: ResizeObserverStub only implements the observe/unobserve/ + // disconnect methods these tests call, not the full ResizeObserver + // interface -- the double cast through `unknown` is required because the + // stub's shape doesn't structurally satisfy the real one. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions window.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver; class IntersectionObserverStub { @@ -75,17 +83,28 @@ if (typeof window !== 'undefined') { this.#callback = callback; } observe(target: Element) { + // SAFETY: this stub only fills in the IntersectionObserverEntry + // fields Vidstack's "visible" load strategy actually reads + // (isIntersecting and the two rects) -- the rest are never used. const entry = { target, isIntersecting: true, intersectionRatio: 1, boundingClientRect: target.getBoundingClientRect(), intersectionRect: target.getBoundingClientRect(), rootBounds: null, time: 0, } as IntersectionObserverEntry; + // SAFETY: the real callback signature only needs `this` to identify + // the observer instance the entry came from -- this stub class is + // never used as a real IntersectionObserver beyond that. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions this.#callback([entry], this as unknown as IntersectionObserver); } unobserve() {} disconnect() {} takeRecords() { return []; } } + // SAFETY: same reasoning as the ResizeObserver stub above -- this only + // implements the subset of IntersectionObserver's interface these tests + // exercise. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions window.IntersectionObserver ??= IntersectionObserverStub as unknown as typeof IntersectionObserver; }