chore(lint): vendor anti-slop oxlint plugin and add batched cleanup pipeline

Vendor the anti-slop Oxlint plugin at tools/oxlint/anti-slop and register it
in oxlint.config.ts, with Oxlint's own rule categories disabled so ESLint
stays the general-purpose linter.

Add scripts/anti-slop.mjs (bun run deslop) mirroring the React Doctor batch
interface: next-batch, check-batch, active, release, top, file. Batch handoff
directories now double as file claims shared across clones via
~/.openchamber/maintenance-claims, so concurrent maintenance batches from
either pipeline never select the same file.

Harden both scheduled maintenance flows: stop on a dirty worktree, stop on
NO BATCH AVAILABLE, validate per package instead of workspace-wide, and pin
react-doctor to 0.9.12. The anti-slop task command documents concrete
good and bad fixes and forbids laundering types to satisfy a rule.
This commit is contained in:
Bohdan Triapitsyn
2026-08-16 15:55:08 +03:00
parent e3094ee676
commit 51aef5e316
30 changed files with 3512 additions and 33 deletions
+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,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,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,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,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,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,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,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,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,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,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,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,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;
}