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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user