chore: add oxlint and a vendored anti-slop fence

Enable the high-signal anti-slop rules as errors and fix the real slop
(chained assertions, object parameters). SQLite/CLI/MCP typeof/unknown
stays off. bun.lock changes for the new devDependencies; flake FOD
hashes are not updated.
This commit is contained in:
Tobias Lütke
2026-08-18 14:24:46 +00:00
parent facd35e013
commit 75f6d5ee82
48 changed files with 2990 additions and 13 deletions
+3
View File
@@ -33,6 +33,9 @@ jobs:
- run: npm install
- name: Lint
run: npx oxlint
- name: Tests
run: npx vitest run --reporter=verbose --testTimeout 60000 test/
env:
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added Oxlint lint fence.
## [2.8.3] - 2026-08-16
### Security
+44
View File
@@ -20,7 +20,9 @@
"zod": "4.2.1",
},
"devDependencies": {
"@oxlint/plugins": "1.78.0",
"@types/better-sqlite3": "7.6.13",
"oxlint": "1.78.0",
"tsx": "4.23.12",
"vitest": "3.2.7",
},
@@ -137,6 +139,46 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="],
"@oxlint/plugins": ["@oxlint/plugins@1.78.0", "", {}, "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug=="],
"@reflink/reflink": ["@reflink/reflink@0.1.19", "", { "optionalDependencies": { "@reflink/reflink-darwin-arm64": "0.1.19", "@reflink/reflink-darwin-x64": "0.1.19", "@reflink/reflink-linux-arm64-gnu": "0.1.19", "@reflink/reflink-linux-arm64-musl": "0.1.19", "@reflink/reflink-linux-x64-gnu": "0.1.19", "@reflink/reflink-linux-x64-musl": "0.1.19", "@reflink/reflink-win32-arm64-msvc": "0.1.19", "@reflink/reflink-win32-x64-msvc": "0.1.19" } }, "sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA=="],
"@reflink/reflink-darwin-arm64": ["@reflink/reflink-darwin-arm64@0.1.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA=="],
@@ -391,6 +433,8 @@
"ora": ["ora@9.3.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.1", "string-width": "^8.1.0" } }, "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw=="],
"oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+57
View File
@@ -0,0 +1,57 @@
import { defineConfig } from "oxlint";
export default defineConfig({
// Built-in oxlint categories stay off: this fence is anti-slop, not an unused-var sweep.
categories: {
correctness: "off",
suspicious: "off",
pedantic: "off",
perf: "off",
style: "off",
restriction: "off",
nursery: "off",
},
plugins: [],
ignorePatterns: [
"dist/**",
"node_modules/**",
"skills/**",
"tools/oxlint/anti-slop/**",
".agent/**",
".agents/**",
".claude/**",
".claude-plugin/**",
".codex/**",
".continue/**",
".cursor/**",
".gemini/**",
".github/copilot/**",
".opencode/**",
".pi/**",
".roo/**",
".windsurf/**",
],
jsPlugins: [
{ name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" },
],
rules: {
"anti-slop/no-module-mocking": "error",
"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-widen-then-assert": "error",
"anti-slop/no-reflect-get": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-unknown-type-aliases": "error",
"anti-slop/no-shape-in-symbol-names": "error",
"anti-slop/no-object-parameters": "error",
// SQLite row casts and CLI/MCP boundaries are full of these; enabling
// them now would be hundreds of noisy hits rather than a useful fence.
"anti-slop/require-safety-comment-for-type-assertion": "off",
"anti-slop/no-runtime-typeof": "off",
"anti-slop/no-unknown-parameters": "off",
"anti-slop/no-unknown-returns": "off",
"anti-slop/no-conditional-empty-object-spread": "off",
"anti-slop/no-known-value-widening": "off",
"anti-slop/no-unsafe-dictionary-type": "off",
},
});
+3
View File
@@ -28,6 +28,7 @@
"scripts": {
"prepare": "node scripts/install-hooks.mjs && node scripts/build.mjs",
"build": "node scripts/build.mjs",
"lint": "oxlint",
"test": "node scripts/test-all.mjs",
"test:types": "node ./node_modules/typescript/bin/tsc -p tsconfig.build.json --noEmit",
"test:node": "node ./node_modules/vitest/vitest.mjs run --reporter=verbose --testTimeout 60000",
@@ -78,7 +79,9 @@
"sqlite-vec-windows-x64": "0.1.9"
},
"devDependencies": {
"@oxlint/plugins": "1.78.0",
"@types/better-sqlite3": "7.6.13",
"oxlint": "1.78.0",
"tsx": "4.23.12",
"vitest": "3.2.7"
},
+2 -7
View File
@@ -2406,17 +2406,12 @@ function getEditorUriTemplate(): string {
if (envTemplate) return envTemplate;
try {
const config = loadConfig() as unknown as {
editor_uri?: string;
editor_uri_template?: string;
editorUri?: string;
[key: string]: unknown;
};
const config = loadConfig();
const configTemplate = (
config.editor_uri
|| config.editor_uri_template
|| config.editorUri
|| (typeof config["editor-uri"] === "string" ? config["editor-uri"] : undefined)
|| config["editor-uri"]
)?.trim();
if (configTemplate) return configTemplate;
+2
View File
@@ -49,6 +49,8 @@ export interface CollectionConfig {
global_context?: string; // Context applied to all collections
editor_uri?: string; // Editor URI template for terminal hyperlinks
editor_uri_template?: string; // Alias for editor_uri
editorUri?: string; // camelCase alias for editor_uri
"editor-uri"?: string; // kebab-case alias for editor_uri
collections: Record<string, Collection>; // Collection name -> config
models?: ModelsConfig;
}
+2 -1
View File
@@ -56,7 +56,8 @@ if (isBun) {
_sqliteVecLoad = null;
}
} else {
_Database = (await import("better-sqlite3")).default as unknown as DatabaseConstructor;
// Dual-runtime: better-sqlite3 matches Database at runtime; published types do not share an interface with bun:sqlite.
_Database = (await import("better-sqlite3")).default as DatabaseConstructor;
const sqliteVec = await import("sqlite-vec");
_sqliteVecLoad = (db: LoadableSqliteDatabase) => sqliteVec.load(db as Parameters<typeof sqliteVec.load>[0]);
}
+8 -1
View File
@@ -2636,7 +2636,14 @@ export function getIndexHealth(db: Database, model: string = DEFAULT_EMBED_MODEL
// Caching
// =============================================================================
export function getCacheKey(url: string, body: object): string {
export type CacheKeyBody = {
query?: string;
model?: string;
chunk?: string;
file?: string;
};
export function getCacheKey(url: string, body: CacheKeyBody): string {
const hash = createHash("sha256");
hash.update(url);
hash.update(JSON.stringify(body));
+1 -1
View File
@@ -105,7 +105,7 @@ describe("CLI successful-exit lifecycle", () => {
},
});
Object.assign(llm as unknown as Record<string, unknown>, {
Object.assign(llm, {
embedContexts: [disposable("embed-context")],
rerankContexts: [disposable("rerank-context")],
embedModel: disposable("embed-model"),
+6 -1
View File
@@ -1211,7 +1211,12 @@ describe("MCP HTTP Transport — 2026-07-28 protocol", () => {
});
async function postMcp(
body: object,
body: {
jsonrpc: string;
id?: number | string;
method: string;
params?: Record<string, unknown>;
},
headers: Record<string, string>,
): Promise<{ status: number; json: any; rawHeaders: Headers }> {
const res = await fetch(`${baseUrl}/mcp`, {
+4 -2
View File
@@ -3157,14 +3157,16 @@ describe("cleanupOrphanedVectors atomicity", () => {
function makeFailingDb(db: Database): Database {
return {
prepare: (sql: string) => db.prepare(sql),
transaction: (fn: () => unknown) => db.transaction(fn),
transaction: (fn) => db.transaction(fn),
exec: (sql: string) => {
if (sql.includes("DELETE FROM content_vectors")) {
throw new Error("injected failure between deletes");
}
return db.exec(sql);
},
} as unknown as Database;
loadExtension: (path: string) => db.loadExtension(path),
close: () => db.close(),
};
}
test("removes orphaned chunks from both tables and returns the count", async () => {
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dillon Mulroy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2
View File
@@ -0,0 +1,2 @@
Vendored from https://github.com/dmmulroy/anti-slop (MIT).
Copy of src/ as of 6d538555cb151d4121ed51a27db81890eacf8ae9.
+13
View File
@@ -0,0 +1,13 @@
import { eslintCompatPlugin } from "@oxlint/plugins";
import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts";
/** Opt-in Oxlint rules for Effect service and Layer architecture. */
const antiSlopEffectPlugin = eslintCompatPlugin({
meta: { name: "anti-slop-effect" },
rules: {
"no-service-constructor-imports": noServiceConstructorImportsRule,
},
});
export default antiSlopEffectPlugin;
@@ -0,0 +1,56 @@
import { RuleTester } from "oxlint/plugins-dev";
import { noServiceConstructorImportsRule } from "./no-service-constructor-imports.ts";
new RuleTester().run(
"no-service-constructor-imports",
noServiceConstructorImportsRule,
{
valid: [
{
filename: "src/issue-service.test.ts",
code: 'import { makeIssueService } from "./issue-service.ts";',
},
{
filename: "src/issue-service.spec.tsx",
code: 'import { makeIssueService } from "../issue-service.ts";',
},
{
filename: "src/runtime.ts",
code: 'import { makeExecutionMemo } from "alchemy/Runtime/ExecutionMemo";',
},
{
filename: "src/runtime.ts",
code: 'import { issueServiceLayer } from "./issue-service.ts";\nWorkspaceName.make("name");',
},
{
filename: "src/runtime.ts",
code: 'import { makeissueService } from "./issue-service.ts";',
},
],
invalid: [
{
filename: "src/runtime.ts",
code: 'import { makeIssueService } from "./issue-service.ts";',
errors: [
{
messageId: "serviceConstructorImport",
data: { name: "makeIssueService" },
},
],
output: null,
},
{
filename: "src/runtime.ts",
code: 'import { makeIssueService as createIssueService } from "../issue-service.ts";',
errors: [
{
messageId: "serviceConstructorImport",
data: { name: "makeIssueService" },
},
],
output: null,
},
],
},
);
@@ -0,0 +1,52 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u;
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
function isProjectLocalImport(source: string): boolean {
return source.startsWith("./") || source.startsWith("../");
}
function getImportedName(specifier: ESTree.ImportSpecifier): string {
if (specifier.imported.type === "Identifier") return specifier.imported.name;
return specifier.imported.value;
}
/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */
export const noServiceConstructorImportsRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow project-local make<CapabilityName> imports outside test and spec files.",
},
messages: {
serviceConstructorImport:
'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.',
},
},
create(context) {
const isTestFile = TEST_FILE.test(context.filename.replaceAll("\\", "/"));
return {
ImportDeclaration(node) {
if (isTestFile || !isProjectLocalImport(node.source.value)) return;
for (const specifier of node.specifiers) {
if (specifier.type !== "ImportSpecifier") continue;
const importedName = getImportedName(specifier);
if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue;
context.report({
node: specifier,
messageId: "serviceConstructorImport",
data: { name: importedName },
});
}
},
};
},
});
+41
View File
@@ -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;
@@ -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,
};
},
});
@@ -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],
},
],
},
);
@@ -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" });
}
},
};
},
});
@@ -0,0 +1,114 @@
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<string, Command> = {};`,
`${prelude} type Index<T> = Record<string, T>; const commands: Index<Command> = {};`,
`${prelude} class Registry { commands: Record<string, Command> = {}; }`,
`${prelude} class Registry { accessor commands: Record<string, Command> = {}; }`,
`${prelude} let commands: Record<string, Command>; commands = {};`,
`${prelude} function create(): Record<string, Command> { return {}; }`,
`${prelude} const create = (): Record<string, Command> => ({});`,
`${prelude} const commands = {} as Record<string, Command>;`,
`${prelude} const commands = <Record<string, Command>>{};`,
`${prelude} const commands = { start: startCommand };`,
`${prelude} const commands = { start: startCommand } as const;`,
`${prelude} const commands = { start: startCommand } satisfies Record<string, Command>;`,
`${prelude} type Commands = Record<string, Command>; 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<string, Command>; const commands: Record<string, Command> = make();`,
`${prelude} import { Commands } from './types'; const commands: Commands = { start: startCommand };`,
],
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<string, Command> = { 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<string, Command>;`,
errors: [error],
},
{
code: `${prelude} const commands = ({ start: startCommand } as Record<string, Command>) as object;`,
errors: 1,
},
{
code: `${prelude} class Registry { commands: Record<string, Command> = { start: startCommand }; }`,
errors: [error],
},
{
code: `${prelude} let commands: Record<string, Command>; commands = { start: startCommand };`,
errors: [error],
},
{
code: `${prelude} function create(): Record<string, Command> { 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<string, Command> = source;`,
errors: [error],
},
{
code: `${prelude} type Open = Record<string, Command>; 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<Record<string, Command>>; const source = { start: startCommand }; const commands: Open = source;`,
errors: [error],
},
{
code: `${prelude} type Index<T> = Record<string, T>; const commands: Index<Command> = { start: startCommand };`,
errors: [error],
},
{
code: `${prelude} type Index<T> = Record<string, T>; type CommandsByName = Index<Command>; const commands: CommandsByName = { start: startCommand };`,
errors: [error],
},
{
code: `${prelude} type Index<T = Command> = Record<string, T>; const commands: Index = { start: startCommand };`,
errors: [error],
},
{ code: "const value: unknown = 1;", errors: [error] },
{ code: "const value: object = [];", errors: [error] },
],
});
@@ -0,0 +1,247 @@
import { defineRule } from "@oxlint/plugins";
import {
classifyWideningTarget,
createTypeEnvironment,
isKnownEvidenceExpression,
type TypeEnvironment,
type WideningTarget,
} from "../shared/dictionary-types.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<Variable>(),
): 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 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);
},
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}\``,
);
},
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",
);
},
};
},
});
@@ -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],
},
],
});
@@ -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" });
}
},
};
},
});
@@ -0,0 +1,32 @@
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: Value) {}",
"function f<Value extends object>(value: Value) {}",
"function f<Value extends Owner, Owner extends { readonly id: string }>(value: Value) {}",
"type Owner = { readonly id: string }; function f<Value extends Owner>(value: Value) {}",
"type Alias = object; function consume<Alias>(value: Alias) {}",
"type Alias = object; type Consumer<Alias> = (value: Alias) => void;",
"type Alias = object; interface Consumer<Alias> { consume(value: Alias): void }",
"type Key = object; type Mapped<Input> = { [Key in keyof Input]: (value: Key) => void };",
"type Item = object; type Unpacked<Input> = Input extends Promise<infer Item> ? (value: Item) => void : never;",
],
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> = Input extends infer Item ? string : (value: Item) => void;",
errors: [error],
},
],
});
@@ -0,0 +1,126 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree, SourceCode } from "@oxlint/plugins";
import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts";
type Parameter = ESTree.ParamPattern;
type ParameterOwner =
| ESTree.ArrowFunctionExpression
| ESTree.Function
| ESTree.TSCallSignatureDeclaration
| ESTree.TSConstructSignatureDeclaration
| ESTree.TSConstructorType
| ESTree.TSFunctionType
| ESTree.TSMethodSignature;
function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined {
if (parameter.type === "TSParameterProperty") {
return parameterAnnotation(parameter.parameter);
}
if (parameter.type === "RestElement") {
return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument);
}
if (parameter.type === "AssignmentPattern") {
return parameter.typeAnnotation ?? parameter.left.typeAnnotation;
}
return parameter.typeAnnotation;
}
function parameterName(parameter: Parameter, sourceCode: SourceCode): string {
return parameter.type === "Identifier"
? parameter.name
: sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, "");
}
/** 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) {
const aliases = new Map<string, ESTree.TSType>();
const resolvesToObject = (
type: ESTree.TSType,
shadowedAliases: ReadonlySet<string>,
visited = new Set<string>(),
): boolean => {
if (type.type === "TSObjectKeyword") return true;
if (type.type === "TSParenthesizedType")
return resolvesToObject(type.typeAnnotation, shadowedAliases, visited);
if (type.type === "TSUnionType") {
return type.types.some((member) =>
resolvesToObject(member, shadowedAliases, visited),
);
}
if (
type.type !== "TSTypeReference" ||
type.typeName.type !== "Identifier" ||
(type.typeArguments !== null &&
type.typeArguments !== undefined &&
type.typeArguments.params.length > 0) ||
visited.has(type.typeName.name) ||
shadowedAliases.has(type.typeName.name)
) {
return false;
}
const alias = aliases.get(type.typeName.name);
if (alias === undefined) return false;
const nextVisited = new Set(visited);
nextVisited.add(type.typeName.name);
return resolvesToObject(alias, shadowedAliases, nextVisited);
};
const checkParameters = (node: ParameterOwner) => {
const shadowedAliases = lexicalTypeParameterNames(
node,
context.sourceCode.visitorKeys,
);
for (const parameter of node.params) {
const annotation = parameterAnnotation(parameter);
if (annotation === null || annotation === undefined) continue;
if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue;
context.report({
node: annotation.typeAnnotation,
messageId: "objectParameter",
data: { parameter: parameterName(parameter, context.sourceCode) },
});
}
};
return {
Program(node) {
aliases.clear();
for (const statement of node.body) {
const declaration =
statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
if (
declaration?.type === "TSTypeAliasDeclaration" &&
(declaration.typeParameters === null || declaration.typeParameters === undefined)
) {
aliases.set(declaration.id.name, declaration.typeAnnotation);
}
}
},
ArrowFunctionExpression: checkParameters,
FunctionDeclaration: checkParameters,
FunctionExpression: checkParameters,
TSCallSignatureDeclaration: checkParameters,
TSConstructSignatureDeclaration: checkParameters,
TSConstructorType: checkParameters,
TSDeclareFunction: checkParameters,
TSEmptyBodyFunctionExpression: checkParameters,
TSFunctionType: checkParameters,
TSMethodSignature: checkParameters,
};
},
});
@@ -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] },
],
});
@@ -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" });
}
},
};
},
});
@@ -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] },
],
});
@@ -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" });
}
},
};
},
});
@@ -0,0 +1,42 @@
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 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: '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],
},
],
});
@@ -0,0 +1,67 @@
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;
}
/** 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" &&
(!allowInTypeGuards || !isInsideTypeGuard(node))
) {
context.report({ node, messageId: "runtimeTypeof" });
}
},
};
},
});
@@ -0,0 +1,39 @@
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);
}
/** 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)) return;
context.report({
node,
messageId: "forbiddenSymbolName",
data: { name: node.name },
});
};
return {
Identifier: reportForbiddenSymbolName,
PrivateIdentifier: reportForbiddenSymbolName,
JSXIdentifier: reportForbiddenSymbolName,
};
},
});
@@ -0,0 +1,83 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
type Parameter = ESTree.ParamPattern;
type ParameterOwner =
| ESTree.ArrowFunctionExpression
| ESTree.Function
| ESTree.TSCallSignatureDeclaration
| ESTree.TSConstructSignatureDeclaration
| ESTree.TSConstructorType
| ESTree.TSFunctionType
| ESTree.TSMethodSignature;
function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined {
if (parameter.type === "TSParameterProperty") {
return parameterAnnotation(parameter.parameter);
}
if (parameter.type === "RestElement") {
return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument);
}
if (parameter.type === "AssignmentPattern") {
return parameter.typeAnnotation ?? parameter.left.typeAnnotation;
}
return parameter.typeAnnotation;
}
function parameterName(parameter: Parameter, sourceText: string): string {
if (parameter.type === "TSParameterProperty") {
return parameterName(parameter.parameter, sourceText);
}
if (parameter.type === "AssignmentPattern") {
return parameterName(parameter.left, sourceText);
}
if (parameter.type === "RestElement") {
return parameterName(parameter.argument, sourceText);
}
return parameter.type === "Identifier"
? parameter.name
: sourceText.replace(/\s*:\s*unknown\s*$/u, "");
}
/** 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`; 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 = parameterAnnotation(parameter);
if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") continue;
const name = parameterName(parameter, context.sourceCode.getText(parameter));
if (name === "cause") 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,
};
},
});
@@ -0,0 +1,33 @@
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>(): Value { return value; }",
"type Value = unknown; function generic<Value>(): Value { return value; }",
"type Key = unknown; type Mapped<Input> = { [Key in keyof Input]: () => Key };",
"type Item = unknown; type Unpacked<Input> = Input extends Promise<infer Item> ? () => Item : never;",
"function cause(): { cause: unknown } { return { cause: input }; }",
"type Result = { value: unknown }; function load(): Result { return result; }",
"function load(): Promise<User> { return promise; }",
],
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<unknown> { return promise; }", errors: [error] },
{ code: "type UnknownValue = unknown; function load(): UnknownValue { return input; }", errors: [error] },
{ code: "type Item = unknown; type Fallback<Input> = Input extends infer Item ? string : () => Item;", errors: [error] },
],
});
@@ -0,0 +1,115 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts";
type FunctionWithReturnType =
| ESTree.ArrowFunctionExpression
| ESTree.Function
| ESTree.TSCallSignatureDeclaration
| ESTree.TSConstructSignatureDeclaration
| ESTree.TSConstructorType
| ESTree.TSFunctionType
| ESTree.TSMethodSignature;
function referencedAliasName(type: ESTree.TSType): string | null {
if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation);
if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null;
return type.typeArguments === null ||
type.typeArguments === undefined ||
type.typeArguments.params.length === 0
? type.typeName.name
: null;
}
/** 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<unknown>.",
},
messages: {
unknownReturn:
"This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.",
},
},
createOnce(context) {
const aliases = new Map<string, ESTree.TSTypeAliasDeclaration>();
const resolvesToUnknown = (
type: ESTree.TSType,
shadowedAliases: ReadonlySet<string>,
visited = new Set<string>(),
): boolean => {
if (type.type === "TSUnknownKeyword") return true;
if (type.type === "TSParenthesizedType") {
return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited);
}
if (type.type === "TSUnionType") {
return type.types.some((member) =>
resolvesToUnknown(member, shadowedAliases, visited),
);
}
if (
type.type === "TSTypeReference" &&
type.typeName.type === "Identifier" &&
(type.typeName.name === "Promise" || type.typeName.name === "PromiseLike")
) {
const value = type.typeArguments?.params[0];
return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited);
}
const name = referencedAliasName(type);
if (name === null || visited.has(name) || shadowedAliases.has(name)) return false;
const alias = aliases.get(name);
if (
alias === undefined ||
(alias.typeParameters !== null && alias.typeParameters !== undefined)
) {
return false;
}
const nextVisited = new Set(visited);
nextVisited.add(name);
return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited);
};
const checkReturnType = (node: FunctionWithReturnType) => {
const annotation = node.returnType;
if (annotation === null || annotation === undefined) return;
if (
!resolvesToUnknown(
annotation.typeAnnotation,
lexicalTypeParameterNames(node, context.sourceCode.visitorKeys),
)
) {
return;
}
context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" });
};
return {
Program(node) {
aliases.clear();
for (const statement of node.body) {
const declaration =
statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
if (declaration?.type === "TSTypeAliasDeclaration") {
aliases.set(declaration.id.name, declaration);
}
}
},
ArrowFunctionExpression: checkReturnType,
FunctionDeclaration: checkReturnType,
FunctionExpression: checkReturnType,
TSCallSignatureDeclaration: checkReturnType,
TSConstructSignatureDeclaration: checkReturnType,
TSConstructorType: checkReturnType,
TSDeclareFunction: checkReturnType,
TSEmptyBodyFunctionExpression: checkReturnType,
TSFunctionType: checkReturnType,
TSMethodSignature: checkReturnType,
};
},
});
@@ -0,0 +1,18 @@
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;",
],
invalid: [
{ code: "type Alias = unknown;", errors: [error] },
{ code: "type Current = unknown;", errors: [error] },
{ code: "type UnknownValue = unknown; type Alias = UnknownValue;", errors: [error, error] },
],
});
@@ -0,0 +1,70 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
function referencedAliasName(type: ESTree.TSType): string | null {
if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation);
if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null;
return type.typeArguments === null ||
type.typeArguments === undefined ||
type.typeArguments.params.length === 0
? type.typeName.name
: null;
}
/** 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) {
const aliases = new Map<string, ESTree.TSTypeAliasDeclaration>();
const resolvesToUnknown = (type: ESTree.TSType, visited = new Set<string>()): boolean => {
if (type.type === "TSUnknownKeyword") return true;
if (type.type === "TSParenthesizedType")
return resolvesToUnknown(type.typeAnnotation, visited);
const name = referencedAliasName(type);
if (name === null || visited.has(name)) return false;
const alias = aliases.get(name);
if (
alias === undefined ||
(alias.typeParameters !== null && alias.typeParameters !== undefined)
) {
return false;
}
const nextVisited = new Set(visited);
nextVisited.add(name);
return resolvesToUnknown(alias.typeAnnotation, nextVisited);
};
return {
Program(node) {
aliases.clear();
for (const statement of node.body) {
const declaration =
statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
if (declaration?.type === "TSTypeAliasDeclaration") {
aliases.set(declaration.id.name, declaration);
}
}
for (const alias of aliases.values()) {
if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue;
context.report({
node: alias.id,
messageId: "unknownAlias",
data: { alias: alias.id.name },
});
}
},
};
},
});
@@ -0,0 +1,103 @@
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<string, Command>;",
"type Metadata = Record<PropertyKey, JsonValue>;",
"type PermissionLevels = Record<Permission, number>;",
"type Indexed = { [key: string]: Command };",
"type CompatibleIndexes = { [index: number]: Command; [key: string]: Command | OtherCommand };",
"type Exhaustive = { [K in Permission]: number };",
"type Allowed = Record<string, { payload: unknown }>;",
"type AlsoAllowed = Record<string, Result<Data, unknown>>;",
"type Index<T> = Record<string, T>; type EntityIndex<T extends Entity> = Record<string, T>;",
"type Safe = Index<Command>; type Index<T> = Record<string, T>;",
"type A = Map<string, unknown>; type B = ReadonlyMap<string, unknown>; type C = WeakMap<object, unknown>;",
"import { Record } from './local'; type A = Record<string, unknown>;",
"type Record<K, V> = { key: K; value: V }; type A = Record<string, unknown>;",
"type Readonly<T> = { value: T }; type A = Record<string, Readonly<unknown>>;",
"type NonNullable<T> = { value: T }; type A = Record<string, NonNullable<unknown>>;",
"type Value<T> = T; type Index<T = Command, U = Value<T>> = Record<string, U>; type A = Index;",
"interface Owner { readonly id: string } type A = Record<string, unknown & Owner>;",
"interface Owner { readonly id: string } interface Child extends Owner {} type A = Record<string, Child>;",
"interface Owner { readonly id: string } interface Child extends Owner { readonly __brand?: never } type A = Record<string, Child>;",
"interface Escape {} interface Escape { readonly id: string } type A = Record<string, Escape>;",
"interface Escape { readonly id: string } interface Escape {} type A = Record<string, Escape>;",
"interface Owner { readonly id: string } type A = Record<string, object & Owner>;",
"type Wrap<T> = { readonly wrapped: T }; type Inner<T, U> = { readonly value: T } & Wrap<U>; type Outer<T, U> = Record<string, Inner<T, U>>; declare function f<T, U>(): Outer<T, U>;",
],
invalid: [
{ code: "type A = Record<string, unknown>;", 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<unknown> };", errors: [error] },
{ code: "type A = { [key: string]: NonNullable<unknown> };", errors: [error] },
{ code: "type A = Record<string, {}>;", errors: [error] },
{ code: "interface Escape {} type A = Record<string, Escape>;", errors: [error] },
{
code: "interface Escape { readonly __brand?: never } type A = Record<string, Escape>;",
errors: [error],
},
{
code: "type Escape = { readonly __brand?: never }; type A = Record<string, Escape>;",
errors: [error],
},
{ code: "type A = Record<string, { readonly __brand?: never }>;", errors: [error] },
{ code: "type A = Record<string, string | unknown>;", errors: [error] },
{ code: "interface Escape {} type A = Record<string, string | Escape>;", errors: [error] },
{ code: "type A = Record<string, unknown & {}>;", errors: [error] },
{
code: "interface Owner { readonly id: string } type A = Record<string, any & Owner>;",
errors: [error],
},
{ code: "type Escape = unknown; type A = Record<string, Escape>;", errors: [error] },
{ code: "type Dict = Record<string, unknown>;", errors: [error] },
{ code: "type A = Readonly<Partial<Required<(Record<string, unknown>)>>>;", 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<string, unknown>; type A = Pick<Source, string>;", errors: 2 },
{ code: "type Source = Record<string, unknown>; type A = Omit<Source, never>;", errors: 2 },
{ code: "type Index<T> = Record<string, T>; type A = Index<unknown>;", errors: 1 },
{ code: "interface A { [key: string]: unknown }", errors: [error] },
{ code: "type A = Readonly<Record<string, unknown>>;", errors: 1 },
{ code: "type A = Record<string, Readonly<unknown>>;", errors: [error] },
{ code: "type A = Record<string, Partial<unknown>>;", errors: [error] },
{ code: "type A = Record<string, Required<unknown>>;", errors: [error] },
{ code: "type Escape = Readonly<unknown>; type A = Record<string, Escape>;", errors: 1 },
{
code: "type Wrapped<T> = Readonly<T>; type A = Record<string, Wrapped<unknown>>;",
errors: 1,
},
{ code: "type A = Record<string, NonNullable<unknown>>;", errors: [error] },
{ code: "type Escape = NonNullable<unknown>; type A = Record<string, Escape>;", errors: 1 },
{
code: "type Unsafe = Record<string, unknown>; const x: Unsafe = {}; const y: Unsafe = {};",
errors: 1,
},
{
code: "type Unsafe = Record<string, unknown>; type AlsoUnsafe = Unsafe; const x: Unsafe = {};",
errors: 2,
},
{ code: "type Index<T = unknown> = Record<string, T>; type A = Index;", errors: 1 },
{
code: "type Index<T, U = T> = Record<string, U>; type A = Index<string, unknown>;",
errors: 1,
},
{ code: "type Index<T, U = T> = Record<string, U>; type A = Index<unknown>;", errors: 1 },
{
code: "type Value<T> = T; type Index<T, U = Value<T>> = Record<string, U>; type A = Index<unknown>;",
errors: 1,
},
{
code: "type Marker<T> = { readonly __brand?: never }; type Index<T, U = Marker<T>> = Record<string, U>; type A = Index<Item>;",
errors: 1,
},
],
});
@@ -0,0 +1,134 @@
import { defineRule } from "@oxlint/plugins";
import {
classifyUnsafeDictionary,
classifyUnsafeDictionaryValue,
createTypeEnvironment,
type TypeEnvironment,
} from "../shared/dictionary-types.ts";
import type { ESTree } from "@oxlint/plugins";
const typeNodeKinds: ReadonlySet<string> = 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 && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node);
}
function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean {
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);
},
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);
},
};
},
});
@@ -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],
},
],
});
@@ -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<typeof resolvedVariableForIdentifier>[0],
boundary: ESTree.Node | null,
visitedVariables: ReadonlySet<Variable>,
): 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<typeof resolvedVariableForIdentifier>[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<typeof resolvedVariableForIdentifier>[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,
};
},
});
@@ -0,0 +1,29 @@
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",
requireSafetyCommentForTypeAssertionRule,
{
valid: [
"const values = [1, 2] as const;",
"const value = <const>{ 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;",
],
invalid: [
{ code: "const id = value as UserId;", errors: [error] },
{ code: "const id = <UserId>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],
},
],
},
);
@@ -0,0 +1,62 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree, SourceCode } from "@oxlint/plugins";
type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
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 hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean {
let current: ESTree.Node = node;
while (true) {
if (
sourceCode
.getCommentsBefore(current)
.some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value))
) {
return true;
}
if (commentOwnerKinds.has(current.type) || 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 `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.",
},
},
createOnce(context) {
const checkAssertion = (node: TypeAssertion) => {
if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return;
context.report({ node, messageId: "missingSafetyComment" });
};
return {
TSAsExpression: checkAssertion,
TSTypeAssertion: checkAssertion,
};
},
});
@@ -0,0 +1,502 @@
import type { ESTree } from "@oxlint/plugins";
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<string, ESTree.TSType>;
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 aliases: ReadonlyMap<string, ESTree.TSTypeAliasDeclaration>;
readonly interfaces: ReadonlyMap<string, readonly ESTree.TSInterfaceDeclaration[]>;
readonly shadowedBuiltIns: ReadonlySet<string>;
};
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): TypeEnvironment {
const aliases = new Map<string, ESTree.TSTypeAliasDeclaration>();
const interfaces = new Map<string, ESTree.TSInterfaceDeclaration[]>();
const shadowedBuiltIns = new Set<string>();
for (const statement of program.body) {
const declaration = declaredStatement(statement);
if (declaration?.type === "ImportDeclaration") {
for (const specifier of declaration.specifiers) {
if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name);
}
continue;
}
if (declaration?.type === "TSTypeAliasDeclaration") {
const existing = aliases.get(declaration.id.name);
if (existing === undefined) aliases.set(declaration.id.name, declaration);
else shadowedBuiltIns.add(declaration.id.name);
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
continue;
}
if (declaration?.type === "TSInterfaceDeclaration") {
const declarations = interfaces.get(declaration.id.name) ?? [];
declarations.push(declaration);
interfaces.set(declaration.id.name, declarations);
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
continue;
}
if (declaration?.type === "TSEnumDeclaration") {
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
continue;
}
if (
(declaration?.type === "ClassDeclaration" ||
declaration?.type === "FunctionDeclaration") &&
declaration.id !== null
) {
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
}
}
return { aliases, interfaces, shadowedBuiltIns };
}
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
return type.typeName.type === "Identifier" ? type.typeName.name : null;
}
function isBuiltIn(name: string, environment: TypeEnvironment): boolean {
return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name);
}
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<string> = 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<string>,
): 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, 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 = environment.aliases.get(name);
if (alias === undefined || 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<string>,
): 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, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? []
: dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases);
}
if (name === "Record" && isBuiltIn(name, environment)) {
const value = unwrapped.typeArguments?.params[1] ?? null;
return value === null ? [] : [{ type: value, substitutions }];
}
if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) {
const source = unwrapped.typeArguments?.params[0];
return source === undefined
? []
: dictionaryValueTypes(source, environment, substitutions, resolvingAliases);
}
const alias = environment.aliases.get(name);
if (alias === undefined || 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;
}
function resolvesToDictionary(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
resolvingAliases: ReadonlySet<string>,
): boolean {
return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0;
}
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, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment);
}
if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" };
const alias = environment.aliases.get(name);
if (alias === undefined) return null;
if ((alias.typeParameters?.params.length ?? 0) > 0) {
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
return substitutions !== null &&
resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name]))
? { 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 isBroadMappedKey(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
): boolean {
const unwrapped = unwrapTransparentType(type);
if (
unwrapped.type === "TSStringKeyword" ||
unwrapped.type === "TSNumberKeyword" ||
unwrapped.type === "TSSymbolKeyword"
) {
return true;
}
if (unwrapped.type === "TSUnionType") {
return unwrapped.types.every((member) =>
isBroadMappedKey(member, environment, substitutions),
);
}
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);
}
return name === "PropertyKey" && isBuiltIn(name, environment);
}
function classifyAliasBroadTarget(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
resolvingAliases: ReadonlySet<string>,
): 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, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? null
: classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases);
}
if (name === "Record" && isBuiltIn(name, environment)) {
return { kind: "open dictionary" };
}
const alias = environment.aliases.get(name);
if (alias === undefined || 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"
);
}
@@ -0,0 +1,61 @@
import type { ESTree } from "@oxlint/plugins";
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
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<string>,
): void {
if (node.type === "TSInferType") names.add(node.typeParameter.name.name);
const record = node as unknown as Readonly<Record<string, unknown>>;
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<string> {
const names = new Set<string>();
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;
}
@@ -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;
}