mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
feat: phase 0 foundation — monorepo scaffold, backend, frontend, CI, docker
Establishes the skeleton every later phase builds on: Go+Gin API with zap logging, /api/healthz, SQLite+goose runner; Vite+React18+TS strict+ Tailwind v4 frontend with bento-mono theme tokens; openapi.yaml driving a generated TS client; oxlint with vendored anti-slop rules; GitHub CI; multi-stage Dockerfile -> 33.6MB distroless image; docker compose up serves the blank board. Generated with [Devin](https://devin.ai) Authored By: TDvorak <[email protected]> Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e7f32949e9
commit
b60821350a
46 files changed
+6415
-10
No files matched your search
@@ -0,0 +1,23 @@
|
||||
// Vendored from github.com/dmmulroy/anti-slop (MIT), trimmed to the adopted
|
||||
// rule set in references/architecture.md. Do not add rules here without
|
||||
// updating the adopted list.
|
||||
import { eslintCompatPlugin } from "@oxlint/plugins";
|
||||
|
||||
import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts";
|
||||
import { noKnownValueWideningRule } from "./rules/no-known-value-widening.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";
|
||||
|
||||
const antiSlopPlugin = eslintCompatPlugin({
|
||||
meta: { name: "anti-slop" },
|
||||
rules: {
|
||||
"no-chained-type-assertions": noChainedTypeAssertionsRule,
|
||||
"no-known-value-widening": noKnownValueWideningRule,
|
||||
"no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule,
|
||||
"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,415 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
classifyUnsafeDictionaryValue,
|
||||
classifyWideningTarget,
|
||||
createTypeEnvironment,
|
||||
isKnownEvidenceExpression,
|
||||
type TypeEnvironment,
|
||||
type WideningTarget,
|
||||
} from "../shared/dictionary-types.ts";
|
||||
import {
|
||||
containsUnknownType,
|
||||
functionParameterBindingName,
|
||||
functionParameterTypeAnnotation,
|
||||
} from "../shared/function-parameters.ts";
|
||||
import { resolveVariable } from "../shared/scope.ts";
|
||||
|
||||
import type { ESTree, 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 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 isFunctionExpression(node: ESTree.Node): node is FunctionExpression {
|
||||
return (
|
||||
node.type === "ArrowFunctionExpression" ||
|
||||
node.type === "FunctionDeclaration" ||
|
||||
node.type === "FunctionExpression" ||
|
||||
node.type === "TSDeclareFunction" ||
|
||||
node.type === "TSEmptyBodyFunctionExpression"
|
||||
);
|
||||
}
|
||||
|
||||
function localFunctionForCall(
|
||||
sourceCode: SourceCode,
|
||||
callee: ESTree.Expression,
|
||||
): FunctionExpression | null {
|
||||
const unwrapped = unwrapExpression(callee);
|
||||
if (isFunctionExpression(unwrapped)) return unwrapped;
|
||||
if (unwrapped.type !== "Identifier") return null;
|
||||
const variable = resolveVariable(sourceCode, unwrapped);
|
||||
if (variable === null || variable.defs.length !== 1) return null;
|
||||
const [definition] = variable.defs;
|
||||
if (definition === undefined) return null;
|
||||
if (definition.type === "FunctionName" && isFunctionExpression(definition.node)) {
|
||||
return definition.node;
|
||||
}
|
||||
if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") {
|
||||
return null;
|
||||
}
|
||||
const initializer = definition.node.init;
|
||||
if (initializer === null) return null;
|
||||
const unwrappedInitializer = unwrapExpression(initializer);
|
||||
return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null;
|
||||
}
|
||||
|
||||
function variableTypeAnnotation(
|
||||
sourceCode: SourceCode,
|
||||
variable: Variable,
|
||||
): ESTree.TSTypeAnnotation | null {
|
||||
if (variable.defs.length !== 1) return null;
|
||||
const [definition] = variable.defs;
|
||||
if (definition === undefined) return null;
|
||||
if (
|
||||
definition.type === "Variable" &&
|
||||
definition.node.type === "VariableDeclarator" &&
|
||||
definition.node.id.type === "Identifier"
|
||||
) {
|
||||
return definition.node.id.typeAnnotation ?? null;
|
||||
}
|
||||
if (definition.type !== "Parameter" || !isFunctionExpression(definition.node)) {
|
||||
return null;
|
||||
}
|
||||
const parameter = definition.node.params.find(
|
||||
(candidate) =>
|
||||
functionParameterBindingName(candidate, sourceCode) === variable.name,
|
||||
);
|
||||
return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null);
|
||||
}
|
||||
|
||||
function hasInformativeType(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
): boolean {
|
||||
return classifyUnsafeDictionaryValue(type, environment) === null;
|
||||
}
|
||||
|
||||
function hasKnownCallArgumentEvidence(
|
||||
sourceCode: SourceCode,
|
||||
expression: ESTree.Expression,
|
||||
environment: TypeEnvironment,
|
||||
visitedVariables = new Set<Variable>(),
|
||||
): boolean {
|
||||
if (expression.type === "ParenthesizedExpression" || expression.type === "TSNonNullExpression") {
|
||||
return hasKnownCallArgumentEvidence(
|
||||
sourceCode,
|
||||
expression.expression,
|
||||
environment,
|
||||
visitedVariables,
|
||||
);
|
||||
}
|
||||
if (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") {
|
||||
return hasInformativeType(expression.typeAnnotation, environment);
|
||||
}
|
||||
if (expression.type === "TSSatisfiesExpression") {
|
||||
return hasKnownCallArgumentEvidence(
|
||||
sourceCode,
|
||||
expression.expression,
|
||||
environment,
|
||||
visitedVariables,
|
||||
);
|
||||
}
|
||||
if (expression.type === "CallExpression") {
|
||||
const owner = localFunctionForCall(sourceCode, expression.callee);
|
||||
const returnType = owner?.returnType?.typeAnnotation;
|
||||
return returnType !== undefined && hasInformativeType(returnType, environment);
|
||||
}
|
||||
if (expression.type !== "Identifier") return isKnownEvidenceExpression(expression);
|
||||
const variable = resolveVariable(sourceCode, expression);
|
||||
if (variable === null || visitedVariables.has(variable)) return false;
|
||||
const annotation = variableTypeAnnotation(sourceCode, variable);
|
||||
if (annotation !== null) {
|
||||
return hasInformativeType(annotation.typeAnnotation, environment);
|
||||
}
|
||||
const declarator = variableDeclarator(variable);
|
||||
if (
|
||||
declarator === null ||
|
||||
declarator.init === null ||
|
||||
!isStableConstVariable(variable, declarator)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
visitedVariables.add(variable);
|
||||
return hasKnownCallArgumentEvidence(
|
||||
sourceCode,
|
||||
declarator.init,
|
||||
environment,
|
||||
visitedVariables,
|
||||
);
|
||||
}
|
||||
|
||||
function typePredicateSubjectIndex(
|
||||
sourceCode: SourceCode,
|
||||
owner: FunctionExpression,
|
||||
): number | null {
|
||||
const predicate = owner.returnType?.typeAnnotation;
|
||||
if (predicate?.type !== "TSTypePredicate" || predicate.parameterName.type !== "Identifier") {
|
||||
return null;
|
||||
}
|
||||
const predicateParameterName = predicate.parameterName.name;
|
||||
const index = owner.params.findIndex(
|
||||
(parameter) =>
|
||||
functionParameterBindingName(parameter, sourceCode) === predicateParameterName,
|
||||
);
|
||||
return index === -1 ? null : index;
|
||||
}
|
||||
|
||||
function annotationTarget(
|
||||
annotation: ESTree.TSTypeAnnotation | null | undefined,
|
||||
environment: TypeEnvironment,
|
||||
): WideningTarget | null {
|
||||
return annotation === null || annotation === undefined
|
||||
? null
|
||||
: classifyWideningTarget(annotation.typeAnnotation, environment);
|
||||
}
|
||||
|
||||
function enclosingFunction(node: ESTree.Node): FunctionExpression | null {
|
||||
let current: ESTree.Node | null = node.parent;
|
||||
while (current !== null && current.type !== "Program") {
|
||||
if (
|
||||
current.type === "ArrowFunctionExpression" ||
|
||||
current.type === "FunctionDeclaration" ||
|
||||
current.type === "FunctionExpression"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string {
|
||||
if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name;
|
||||
if (key.type === "Literal") return String(key.value);
|
||||
return sourceCode.getText(key);
|
||||
}
|
||||
|
||||
function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string {
|
||||
if (owner === null) return "anonymous function";
|
||||
if (owner.id !== null) return owner.id.name;
|
||||
const parent = owner.parent;
|
||||
if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier")
|
||||
return parent.id.name;
|
||||
if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key);
|
||||
return "anonymous function";
|
||||
}
|
||||
|
||||
function isEmptyObjectExpression(expression: ESTree.Expression): boolean {
|
||||
const unwrapped = unwrapExpression(expression);
|
||||
return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0;
|
||||
}
|
||||
|
||||
function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean {
|
||||
return destination.kind === "open dictionary" || destination.kind === "generic container";
|
||||
}
|
||||
|
||||
function hasParentAssertion(node: ESTree.Node): boolean {
|
||||
return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion";
|
||||
}
|
||||
|
||||
/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */
|
||||
export const noKnownValueWideningRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.",
|
||||
},
|
||||
messages: {
|
||||
widening:
|
||||
"The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
let environment: TypeEnvironment | null = null;
|
||||
|
||||
const reportFlow = (
|
||||
expression: ESTree.Expression,
|
||||
destination: WideningTarget | null,
|
||||
subject: string,
|
||||
) => {
|
||||
if (destination === null) return;
|
||||
if (
|
||||
isDictionaryAccumulatorTarget(destination) &&
|
||||
isEmptyObjectExpression(expression)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!hasKnownEvidence(context.sourceCode, expression)) return;
|
||||
context.report({
|
||||
node: expression,
|
||||
messageId: "widening",
|
||||
data: { subject, target: destination.kind },
|
||||
});
|
||||
};
|
||||
|
||||
const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) =>
|
||||
environment === null ? null : annotationTarget(annotation, environment);
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
environment = createTypeEnvironment(
|
||||
node,
|
||||
context.sourceCode.visitorKeys,
|
||||
);
|
||||
},
|
||||
VariableDeclarator(node) {
|
||||
if (node.init === null || node.id.type !== "Identifier") return;
|
||||
reportFlow(
|
||||
node.init,
|
||||
targetFromAnnotation(node.id.typeAnnotation),
|
||||
`binding \`${node.id.name}\``,
|
||||
);
|
||||
},
|
||||
PropertyDefinition(node) {
|
||||
if (node.value === null) return;
|
||||
reportFlow(
|
||||
node.value,
|
||||
targetFromAnnotation(node.typeAnnotation),
|
||||
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
||||
);
|
||||
},
|
||||
AccessorProperty(node) {
|
||||
if (node.value === null) return;
|
||||
reportFlow(
|
||||
node.value,
|
||||
targetFromAnnotation(node.typeAnnotation),
|
||||
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
||||
);
|
||||
},
|
||||
AssignmentExpression(node) {
|
||||
if (node.operator !== "=" || node.left.type !== "Identifier") return;
|
||||
const variable = resolveVariable(context.sourceCode, node.left);
|
||||
if (variable === null) return;
|
||||
const declarator = variableDeclarator(variable);
|
||||
if (declarator === null || declarator.id.type !== "Identifier") return;
|
||||
reportFlow(
|
||||
node.right,
|
||||
targetFromAnnotation(declarator.id.typeAnnotation),
|
||||
`binding \`${declarator.id.name}\``,
|
||||
);
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (environment === null) return;
|
||||
const owner = localFunctionForCall(context.sourceCode, node.callee);
|
||||
if (owner === null) return;
|
||||
const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner);
|
||||
if (parameterIndex === null) return;
|
||||
const parameter = owner.params[parameterIndex];
|
||||
const argument = node.arguments[parameterIndex];
|
||||
if (parameter === undefined || argument === undefined || argument.type === "SpreadElement") {
|
||||
return;
|
||||
}
|
||||
const parameterAnnotation = functionParameterTypeAnnotation(parameter);
|
||||
if (
|
||||
parameterAnnotation === null ||
|
||||
parameterAnnotation === undefined ||
|
||||
!containsUnknownType(parameterAnnotation.typeAnnotation)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!hasKnownCallArgumentEvidence(
|
||||
context.sourceCode,
|
||||
argument,
|
||||
environment,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
context.report({
|
||||
node: argument,
|
||||
messageId: "widening",
|
||||
data: {
|
||||
subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``,
|
||||
target: "unknown",
|
||||
},
|
||||
});
|
||||
},
|
||||
ReturnStatement(node) {
|
||||
if (node.argument === null) return;
|
||||
const owner = enclosingFunction(node);
|
||||
reportFlow(
|
||||
node.argument,
|
||||
targetFromAnnotation(owner?.returnType),
|
||||
`return value of \`${functionName(context.sourceCode, owner)}\``,
|
||||
);
|
||||
},
|
||||
ArrowFunctionExpression(node) {
|
||||
if (node.body.type === "BlockStatement") return;
|
||||
reportFlow(
|
||||
node.body,
|
||||
targetFromAnnotation(node.returnType),
|
||||
`return value of \`${functionName(context.sourceCode, node)}\``,
|
||||
);
|
||||
},
|
||||
TSAsExpression(node) {
|
||||
if (environment === null || hasParentAssertion(node)) return;
|
||||
reportFlow(
|
||||
node.expression,
|
||||
classifyWideningTarget(node.typeAnnotation, environment),
|
||||
"assertion",
|
||||
);
|
||||
},
|
||||
TSTypeAssertion(node) {
|
||||
if (environment === null || hasParentAssertion(node)) return;
|
||||
reportFlow(
|
||||
node.expression,
|
||||
classifyWideningTarget(node.typeAnnotation, environment),
|
||||
"assertion",
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
classifyUnsafeDictionary,
|
||||
classifyUnsafeDictionaryValue,
|
||||
createTypeEnvironment,
|
||||
type TypeEnvironment,
|
||||
} from "../shared/dictionary-types.ts";
|
||||
import { visibleTypeAlias } from "../shared/type-alias-resolution.ts";
|
||||
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
const typeNodeKinds: ReadonlySet<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 &&
|
||||
visibleTypeAlias(name, node, environment.typeAliases) !== null &&
|
||||
!isInsideTypeAliasDeclaration(node)
|
||||
);
|
||||
}
|
||||
|
||||
function isInsideTypeParameterConstraint(node: ESTree.TSType): boolean {
|
||||
let child: ESTree.Node = node;
|
||||
let parent: ESTree.Node | null = child.parent;
|
||||
while (parent !== null && parent.type !== "Program") {
|
||||
if (parent.type === "TSTypeParameter" && parent.constraint === child) return true;
|
||||
child = parent;
|
||||
parent = child.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean {
|
||||
if (isInsideTypeParameterConstraint(node)) return false;
|
||||
if (isPlainAliasConsumerUse(node, environment)) return false;
|
||||
if (classifyUnsafeDictionary(node, environment) === null) return false;
|
||||
let current: ESTree.Node | null = node.parent;
|
||||
while (current !== null && current.type !== "Program") {
|
||||
if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null)
|
||||
return false;
|
||||
current = current.parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */
|
||||
export const noUnsafeDictionaryTypeRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.",
|
||||
},
|
||||
messages: {
|
||||
unsafeDictionary:
|
||||
"This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.",
|
||||
},
|
||||
},
|
||||
createOnce(context) {
|
||||
let environment: TypeEnvironment | null = null;
|
||||
const report = (node: ESTree.Node, value: string) => {
|
||||
context.report({ node, messageId: "unsafeDictionary", data: { value } });
|
||||
};
|
||||
const reportIfUnsafe = (node: ESTree.TSType) => {
|
||||
if (environment === null || !shouldReportType(node, environment)) return;
|
||||
const unsafe = classifyUnsafeDictionary(node, environment);
|
||||
if (unsafe === null) return;
|
||||
report(node, unsafe.unsafeValue);
|
||||
};
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
environment = createTypeEnvironment(
|
||||
node,
|
||||
context.sourceCode.visitorKeys,
|
||||
);
|
||||
},
|
||||
TSTypeReference: reportIfUnsafe,
|
||||
TSTypeLiteral: reportIfUnsafe,
|
||||
TSMappedType: reportIfUnsafe,
|
||||
TSIndexSignature(node) {
|
||||
if (
|
||||
environment === null ||
|
||||
node.typeAnnotation === null ||
|
||||
node.parent.type === "TSTypeLiteral"
|
||||
)
|
||||
return;
|
||||
const unsafe = classifyUnsafeDictionaryValue(
|
||||
node.typeAnnotation.typeAnnotation,
|
||||
environment,
|
||||
);
|
||||
if (unsafe !== null) report(node, unsafe.unsafeValue);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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,131 @@
|
||||
import { defineRule } from "@oxlint/plugins";
|
||||
|
||||
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||
|
||||
type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
|
||||
|
||||
const DEFAULT_SAFETY_MARKERS = ["SAFETY"] as const;
|
||||
|
||||
const commentOwnerKinds = new Set([
|
||||
"ExpressionStatement",
|
||||
"PropertyDefinition",
|
||||
"ReturnStatement",
|
||||
"ThrowStatement",
|
||||
"VariableDeclaration",
|
||||
]);
|
||||
|
||||
function isConstAssertion(node: TypeAssertion): boolean {
|
||||
return (
|
||||
node.typeAnnotation.type === "TSTypeReference" &&
|
||||
node.typeAnnotation.typeName.type === "Identifier" &&
|
||||
node.typeAnnotation.typeName.name === "const"
|
||||
);
|
||||
}
|
||||
|
||||
function configuredSafetyMarkers(option: unknown): readonly string[] {
|
||||
if (typeof option !== "object" || option === null || !("markers" in option)) {
|
||||
return DEFAULT_SAFETY_MARKERS;
|
||||
}
|
||||
const configured = option.markers;
|
||||
if (!Array.isArray(configured)) return DEFAULT_SAFETY_MARKERS;
|
||||
const markers = configured.flatMap((marker) =>
|
||||
typeof marker === "string" && marker.trim().length > 0 ? [marker.trim()] : [],
|
||||
);
|
||||
return markers.length > 0 ? markers : DEFAULT_SAFETY_MARKERS;
|
||||
}
|
||||
|
||||
function markerPattern(markers: readonly string[]): RegExp {
|
||||
const alternation = markers
|
||||
.map((marker) => marker.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`))
|
||||
.join("|");
|
||||
return new RegExp(
|
||||
String.raw`(?:^|[^\p{L}\p{N}_])(?:${alternation})\s*:\s*\S`,
|
||||
"u",
|
||||
);
|
||||
}
|
||||
|
||||
function hasSafetyJustificationBefore(
|
||||
sourceCode: SourceCode,
|
||||
owner: ESTree.Node,
|
||||
assertion: TypeAssertion,
|
||||
pattern: RegExp,
|
||||
): boolean {
|
||||
return sourceCode
|
||||
.getCommentsBefore(owner)
|
||||
.some(
|
||||
(comment) => comment.end <= assertion.start && pattern.test(comment.value),
|
||||
);
|
||||
}
|
||||
|
||||
function hasSafetyComment(
|
||||
sourceCode: SourceCode,
|
||||
node: TypeAssertion,
|
||||
pattern: RegExp,
|
||||
): boolean {
|
||||
let current: ESTree.Node = node;
|
||||
while (true) {
|
||||
if (hasSafetyJustificationBefore(sourceCode, current, node, pattern)) return true;
|
||||
if (commentOwnerKinds.has(current.type)) {
|
||||
const exportDeclaration = current.parent;
|
||||
return (
|
||||
exportDeclaration.type === "ExportNamedDeclaration" &&
|
||||
exportDeclaration.declaration === current &&
|
||||
hasSafetyJustificationBefore(sourceCode, exportDeclaration, node, pattern)
|
||||
);
|
||||
}
|
||||
if (current.parent.type === "Program") return false;
|
||||
current = current.parent;
|
||||
}
|
||||
}
|
||||
|
||||
/** Require every non-const type assertion to state the invariant TypeScript cannot express. */
|
||||
export const requireSafetyCommentForTypeAssertionRule = defineRule({
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.",
|
||||
},
|
||||
messages: {
|
||||
missingSafetyComment:
|
||||
"This type assertion has no `{{marker}}:` justification. State the checked invariant immediately before the assertion or its containing statement.",
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
markers: {
|
||||
type: "array",
|
||||
items: { type: "string", minLength: 1 },
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
defaultOptions: [{ markers: ["SAFETY"] }],
|
||||
},
|
||||
createOnce(context) {
|
||||
const patterns = new Map<string, RegExp>();
|
||||
|
||||
const checkAssertion = (node: TypeAssertion) => {
|
||||
if (isConstAssertion(node)) return;
|
||||
const markers = configuredSafetyMarkers(context.options?.[0]);
|
||||
const patternKey = markers.join("\u0000");
|
||||
const pattern = patterns.get(patternKey) ?? markerPattern(markers);
|
||||
patterns.set(patternKey, pattern);
|
||||
if (hasSafetyComment(context.sourceCode, node, pattern)) return;
|
||||
context.report({
|
||||
node,
|
||||
messageId: "missingSafetyComment",
|
||||
data: { marker: markers[0] ?? DEFAULT_SAFETY_MARKERS[0] },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
TSAsExpression: checkAssertion,
|
||||
TSTypeAssertion: checkAssertion,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
import {
|
||||
createTypeAliasEnvironment,
|
||||
hasVisibleTypeBinding,
|
||||
visibleTypeAlias,
|
||||
type TypeAliasEnvironment as LexicalTypeAliasEnvironment,
|
||||
} from "./type-alias-resolution.ts";
|
||||
|
||||
const BUILT_INS = new Set([
|
||||
"Record",
|
||||
"Readonly",
|
||||
"Partial",
|
||||
"Required",
|
||||
"Pick",
|
||||
"Omit",
|
||||
"PropertyKey",
|
||||
"NonNullable",
|
||||
]);
|
||||
const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]);
|
||||
|
||||
type TypeAliasEnvironment = ReadonlyMap<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 interfaces: ReadonlyMap<string, readonly ESTree.TSInterfaceDeclaration[]>;
|
||||
readonly typeAliases: LexicalTypeAliasEnvironment;
|
||||
};
|
||||
|
||||
function declaredStatement(statement: ESTree.Statement): ESTree.Node | null {
|
||||
return statement.type === "ExportNamedDeclaration" ||
|
||||
statement.type === "ExportDefaultDeclaration"
|
||||
? (statement.declaration ?? null)
|
||||
: statement;
|
||||
}
|
||||
|
||||
export function createTypeEnvironment(
|
||||
program: ESTree.Program,
|
||||
visitorKeys: Readonly<Record<string, readonly string[]>>,
|
||||
): TypeEnvironment {
|
||||
const interfaces = new Map<string, ESTree.TSInterfaceDeclaration[]>();
|
||||
|
||||
for (const statement of program.body) {
|
||||
const declaration = declaredStatement(statement);
|
||||
if (declaration?.type !== "TSInterfaceDeclaration") continue;
|
||||
const declarations = interfaces.get(declaration.id.name) ?? [];
|
||||
declarations.push(declaration);
|
||||
interfaces.set(declaration.id.name, declarations);
|
||||
}
|
||||
|
||||
return {
|
||||
interfaces,
|
||||
typeAliases: createTypeAliasEnvironment(program, visitorKeys),
|
||||
};
|
||||
}
|
||||
|
||||
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||
}
|
||||
|
||||
function isBuiltIn(
|
||||
name: string,
|
||||
use: ESTree.Node,
|
||||
environment: TypeEnvironment,
|
||||
): boolean {
|
||||
return (
|
||||
BUILT_INS.has(name) &&
|
||||
!hasVisibleTypeBinding(name, use, environment.typeAliases)
|
||||
);
|
||||
}
|
||||
|
||||
function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
return (
|
||||
unwrapped.type === "TSTypeReference" &&
|
||||
typeReferenceName(unwrapped) === name &&
|
||||
(unwrapped.typeArguments === null ||
|
||||
unwrapped.typeArguments === undefined ||
|
||||
unwrapped.typeArguments.params.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType {
|
||||
let current = type;
|
||||
while (
|
||||
current.type === "TSParenthesizedType" ||
|
||||
(current.type === "TSTypeOperator" && current.operator === "readonly")
|
||||
) {
|
||||
current = current.typeAnnotation;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isNeverType(type: ESTree.TSType): boolean {
|
||||
return unwrapTransparentType(type).type === "TSNeverKeyword";
|
||||
}
|
||||
|
||||
function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean {
|
||||
return (
|
||||
member.type === "TSPropertySignature" &&
|
||||
member.optional === true &&
|
||||
member.typeAnnotation !== null &&
|
||||
member.typeAnnotation !== undefined &&
|
||||
isNeverType(member.typeAnnotation.typeAnnotation)
|
||||
);
|
||||
}
|
||||
|
||||
function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean {
|
||||
return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember);
|
||||
}
|
||||
|
||||
function isEffectivelyEmptyInterface(
|
||||
declarations: readonly ESTree.TSInterfaceDeclaration[],
|
||||
): boolean {
|
||||
if (declarations.length !== 1) return false;
|
||||
const [type] = declarations;
|
||||
return (
|
||||
type !== undefined &&
|
||||
type.extends.length === 0 &&
|
||||
(type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember))
|
||||
);
|
||||
}
|
||||
|
||||
function resolvedSubstitutionArgument(
|
||||
type: ESTree.TSType,
|
||||
base: TypeAliasEnvironment,
|
||||
resolving: ReadonlySet<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, unwrapped, environment)) {
|
||||
const wrapped = unwrapped.typeArguments?.params[0];
|
||||
return wrapped === undefined
|
||||
? null
|
||||
: unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
const substitution = substitutions.get(name);
|
||||
if (substitution !== undefined) {
|
||||
return isUnappliedReferenceTo(substitution, name)
|
||||
? null
|
||||
: unsafeDirectValue(substitution, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
const interfaceDeclarations = environment.interfaces.get(name);
|
||||
if (interfaceDeclarations !== undefined) {
|
||||
return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null;
|
||||
}
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (alias === null || resolvingAliases.has(name)) return null;
|
||||
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||
if (nextSubstitutions === null) return null;
|
||||
const nextResolving = new Set(resolvingAliases);
|
||||
nextResolving.add(name);
|
||||
return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
||||
}
|
||||
|
||||
function dictionaryValueTypes(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
substitutions: TypeAliasEnvironment,
|
||||
resolvingAliases: ReadonlySet<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, unwrapped, environment)) {
|
||||
const wrapped = unwrapped.typeArguments?.params[0];
|
||||
return wrapped === undefined
|
||||
? []
|
||||
: dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
|
||||
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
|
||||
const value = unwrapped.typeArguments?.params[1] ?? null;
|
||||
return value === null ? [] : [{ type: value, substitutions }];
|
||||
}
|
||||
|
||||
if (
|
||||
(name === "Pick" || name === "Omit") &&
|
||||
isBuiltIn(name, unwrapped, environment)
|
||||
) {
|
||||
const source = unwrapped.typeArguments?.params[0];
|
||||
return source === undefined
|
||||
? []
|
||||
: dictionaryValueTypes(source, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (alias === null || resolvingAliases.has(name)) return [];
|
||||
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||
if (nextSubstitutions === null) return [];
|
||||
const nextResolving = new Set(resolvingAliases);
|
||||
nextResolving.add(name);
|
||||
return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
||||
}
|
||||
|
||||
export function classifyUnsafeDictionaryValue(
|
||||
valueType: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
): UnsafeDictionary | null {
|
||||
const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set());
|
||||
return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue };
|
||||
}
|
||||
|
||||
export function classifyUnsafeDictionary(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
): UnsafeDictionary | null {
|
||||
for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) {
|
||||
const unsafeValue = unsafeDirectValue(
|
||||
valueType.type,
|
||||
environment,
|
||||
valueType.substitutions,
|
||||
new Set(),
|
||||
);
|
||||
if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyWideningTarget(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
): WideningTarget | null {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
|
||||
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
|
||||
if (unwrapped.type === "TSTypeLiteral") {
|
||||
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
|
||||
? { kind: "open dictionary" }
|
||||
: unwrapped.members.length > 0
|
||||
? { kind: "anonymous object" }
|
||||
: null;
|
||||
}
|
||||
if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" };
|
||||
if (unwrapped.type !== "TSTypeReference") return null;
|
||||
const name = typeReferenceName(unwrapped);
|
||||
if (name === null) return null;
|
||||
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
|
||||
const wrapped = unwrapped.typeArguments?.params[0];
|
||||
return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment);
|
||||
}
|
||||
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
|
||||
return hasBroadRecordKey(unwrapped, environment, new Map())
|
||||
? { kind: "open dictionary" }
|
||||
: null;
|
||||
}
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (alias === null) return null;
|
||||
if ((alias.typeParameters?.params.length ?? 0) > 0) {
|
||||
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
|
||||
const resolved =
|
||||
substitutions === null
|
||||
? null
|
||||
: classifyAliasBroadTarget(
|
||||
alias.typeAnnotation,
|
||||
environment,
|
||||
substitutions,
|
||||
new Set([name]),
|
||||
);
|
||||
return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null;
|
||||
}
|
||||
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
|
||||
if (substitutions === null) return null;
|
||||
const resolved = classifyAliasBroadTarget(
|
||||
alias.typeAnnotation,
|
||||
environment,
|
||||
substitutions,
|
||||
new Set([name]),
|
||||
);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function hasBroadRecordKey(
|
||||
type: ESTree.TSTypeReference,
|
||||
environment: TypeEnvironment,
|
||||
substitutions: TypeAliasEnvironment,
|
||||
): boolean {
|
||||
const key = type.typeArguments?.params[0];
|
||||
return key === undefined || isBroadMappedKey(key, environment, substitutions);
|
||||
}
|
||||
|
||||
function isBroadMappedKey(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
substitutions: TypeAliasEnvironment,
|
||||
visitedAliases: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
const unwrapped = unwrapTransparentType(type);
|
||||
if (
|
||||
unwrapped.type === "TSStringKeyword" ||
|
||||
unwrapped.type === "TSNumberKeyword" ||
|
||||
unwrapped.type === "TSSymbolKeyword"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (unwrapped.type === "TSUnionType") {
|
||||
return unwrapped.types.some((member) =>
|
||||
isBroadMappedKey(member, environment, substitutions, visitedAliases),
|
||||
);
|
||||
}
|
||||
if (unwrapped.type !== "TSTypeReference") return false;
|
||||
const name = typeReferenceName(unwrapped);
|
||||
if (name === null) return false;
|
||||
const substitution = substitutions.get(name);
|
||||
if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) {
|
||||
return isBroadMappedKey(substitution, environment, substitutions, visitedAliases);
|
||||
}
|
||||
if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true;
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (
|
||||
alias === null ||
|
||||
(alias.typeParameters?.params.length ?? 0) > 0 ||
|
||||
visitedAliases.has(name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const nextVisited = new Set(visitedAliases);
|
||||
nextVisited.add(name);
|
||||
return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited);
|
||||
}
|
||||
|
||||
function classifyAliasBroadTarget(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeEnvironment,
|
||||
substitutions: TypeAliasEnvironment,
|
||||
resolvingAliases: ReadonlySet<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, unwrapped, environment)) {
|
||||
const wrapped = unwrapped.typeArguments?.params[0];
|
||||
return wrapped === undefined
|
||||
? null
|
||||
: classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases);
|
||||
}
|
||||
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
|
||||
return hasBroadRecordKey(unwrapped, environment, substitutions)
|
||||
? { kind: "open dictionary" }
|
||||
: null;
|
||||
}
|
||||
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
|
||||
if (alias === null || resolvingAliases.has(name)) return null;
|
||||
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||
if (nextSubstitutions === null) return null;
|
||||
const nextResolving = new Set(resolvingAliases);
|
||||
nextResolving.add(name);
|
||||
return classifyAliasBroadTarget(
|
||||
alias.typeAnnotation,
|
||||
environment,
|
||||
nextSubstitutions,
|
||||
nextResolving,
|
||||
);
|
||||
}
|
||||
|
||||
export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean {
|
||||
let current = expression;
|
||||
while (
|
||||
current.type === "ParenthesizedExpression" ||
|
||||
current.type === "TSAsExpression" ||
|
||||
current.type === "TSTypeAssertion" ||
|
||||
current.type === "TSNonNullExpression"
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current.type === "ObjectExpression" && current.properties.length > 0;
|
||||
}
|
||||
|
||||
export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean {
|
||||
let current = expression;
|
||||
while (
|
||||
current.type === "ParenthesizedExpression" ||
|
||||
current.type === "TSAsExpression" ||
|
||||
current.type === "TSTypeAssertion" ||
|
||||
current.type === "TSNonNullExpression" ||
|
||||
current.type === "TSSatisfiesExpression"
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
if (current.type === "ObjectExpression") return true;
|
||||
return (
|
||||
current.type === "ArrayExpression" ||
|
||||
current.type === "ArrowFunctionExpression" ||
|
||||
current.type === "ClassExpression" ||
|
||||
current.type === "FunctionExpression" ||
|
||||
current.type === "NewExpression" ||
|
||||
current.type === "Literal" ||
|
||||
current.type === "TemplateLiteral" ||
|
||||
current.type === "UnaryExpression"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||
|
||||
export type FunctionParameter = ESTree.ParamPattern;
|
||||
|
||||
/** Return whether a type is or contains TypeScript's absorbing unknown top type. */
|
||||
export function containsUnknownType(type: ESTree.TSType): boolean {
|
||||
if (type.type === "TSUnknownKeyword") return true;
|
||||
if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation);
|
||||
return type.type === "TSUnionType" && type.types.some(containsUnknownType);
|
||||
}
|
||||
|
||||
/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */
|
||||
export function functionParameterTypeAnnotation(
|
||||
parameter: FunctionParameter,
|
||||
): ESTree.TSTypeAnnotation | null | undefined {
|
||||
if (parameter.type === "TSParameterProperty") {
|
||||
return functionParameterTypeAnnotation(parameter.parameter);
|
||||
}
|
||||
if (parameter.type === "RestElement") {
|
||||
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument);
|
||||
}
|
||||
if (parameter.type === "AssignmentPattern") {
|
||||
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left);
|
||||
}
|
||||
return parameter.typeAnnotation;
|
||||
}
|
||||
|
||||
/** Return only a function parameter's local binding, excluding its annotation and default value. */
|
||||
export function functionParameterBindingName(
|
||||
parameter: FunctionParameter,
|
||||
sourceCode: SourceCode,
|
||||
): string {
|
||||
if (parameter.type === "TSParameterProperty") {
|
||||
return functionParameterBindingName(parameter.parameter, sourceCode);
|
||||
}
|
||||
if (parameter.type === "AssignmentPattern") {
|
||||
return functionParameterBindingName(parameter.left, sourceCode);
|
||||
}
|
||||
if (parameter.type === "RestElement") {
|
||||
return functionParameterBindingName(parameter.argument, sourceCode);
|
||||
}
|
||||
if (parameter.type === "Identifier") return parameter.name;
|
||||
|
||||
const sourceText = sourceCode.getText(parameter);
|
||||
const annotationStart = parameter.typeAnnotation?.start;
|
||||
return annotationStart === undefined
|
||||
? sourceText
|
||||
: sourceText.slice(0, annotationStart - parameter.start).trimEnd();
|
||||
}
|
||||
@@ -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,15 @@
|
||||
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
|
||||
|
||||
/** Resolve an identifier to its binding by walking lexical scopes upward. */
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { ESTree } from "@oxlint/plugins";
|
||||
|
||||
import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts";
|
||||
|
||||
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
|
||||
type TypeScope = ESTree.Node;
|
||||
|
||||
type TypeBinding = {
|
||||
readonly alias: ESTree.TSTypeAliasDeclaration | null;
|
||||
readonly name: string;
|
||||
readonly scope: TypeScope;
|
||||
};
|
||||
|
||||
type Substitution = {
|
||||
readonly substitutions: Substitutions;
|
||||
readonly type: ESTree.TSType;
|
||||
};
|
||||
|
||||
type Substitutions = ReadonlyMap<string, Substitution>;
|
||||
|
||||
export type TypeAliasEnvironment = {
|
||||
readonly aliases: readonly ESTree.TSTypeAliasDeclaration[];
|
||||
readonly bindingsByName: ReadonlyMap<string, readonly TypeBinding[]>;
|
||||
readonly visitorKeys: VisitorKeys;
|
||||
};
|
||||
|
||||
export type ResolvedTypeMatcher = (
|
||||
type: ESTree.TSType,
|
||||
matches: (child: ESTree.TSType) => boolean,
|
||||
) => boolean;
|
||||
|
||||
const environmentsByProgram = new WeakMap<ESTree.Program, TypeAliasEnvironment>();
|
||||
|
||||
function isNode(value: unknown): value is ESTree.Node {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"type" in value &&
|
||||
typeof value.type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function enclosingTypeScope(node: ESTree.Node): TypeScope {
|
||||
let current: ESTree.Node | null = node.parent;
|
||||
while (current !== null) {
|
||||
if (
|
||||
current.type === "Program" ||
|
||||
current.type === "BlockStatement" ||
|
||||
current.type === "TSModuleBlock" ||
|
||||
current.type === "StaticBlock" ||
|
||||
current.type === "SwitchStatement"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function declaredTypeBinding(node: ESTree.Node): {
|
||||
readonly alias: ESTree.TSTypeAliasDeclaration | null;
|
||||
readonly name: string;
|
||||
} | null {
|
||||
if (node.type === "TSTypeAliasDeclaration") {
|
||||
return { alias: node, name: node.id.name };
|
||||
}
|
||||
if (
|
||||
node.type === "TSInterfaceDeclaration" ||
|
||||
node.type === "TSEnumDeclaration" ||
|
||||
node.type === "ClassDeclaration" ||
|
||||
node.type === "ClassExpression"
|
||||
) {
|
||||
return node.id === null ? null : { alias: null, name: node.id.name };
|
||||
}
|
||||
if (
|
||||
node.type === "ImportSpecifier" ||
|
||||
node.type === "ImportDefaultSpecifier" ||
|
||||
node.type === "ImportNamespaceSpecifier"
|
||||
) {
|
||||
return { alias: null, name: node.local.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectTypeBindings(
|
||||
node: ESTree.Node,
|
||||
visitorKeys: VisitorKeys,
|
||||
bindingsByName: Map<string, TypeBinding[]>,
|
||||
aliases: ESTree.TSTypeAliasDeclaration[],
|
||||
): void {
|
||||
const declared = declaredTypeBinding(node);
|
||||
if (declared !== null) {
|
||||
const bindings = bindingsByName.get(declared.name) ?? [];
|
||||
bindings.push({ ...declared, scope: enclosingTypeScope(node) });
|
||||
bindingsByName.set(declared.name, bindings);
|
||||
if (declared.alias !== null) aliases.push(declared.alias);
|
||||
}
|
||||
|
||||
// SAFETY: Oxlint's visitor keys identify only ESTree child-node properties.
|
||||
const fields = node as unknown as Readonly<Record<string, unknown>>;
|
||||
for (const key of visitorKeys[node.type] ?? []) {
|
||||
const value = fields[key];
|
||||
if (isNode(value)) {
|
||||
collectTypeBindings(value, visitorKeys, bindingsByName, aliases);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(value)) continue;
|
||||
for (const child of value) {
|
||||
if (isNode(child)) {
|
||||
collectTypeBindings(child, visitorKeys, bindingsByName, aliases);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect every lexical type alias and competing type binding in a program. */
|
||||
export function createTypeAliasEnvironment(
|
||||
program: ESTree.Program,
|
||||
visitorKeys: VisitorKeys,
|
||||
): TypeAliasEnvironment {
|
||||
const cached = environmentsByProgram.get(program);
|
||||
if (cached !== undefined) return cached;
|
||||
const bindingsByName = new Map<string, TypeBinding[]>();
|
||||
const aliases: ESTree.TSTypeAliasDeclaration[] = [];
|
||||
collectTypeBindings(program, visitorKeys, bindingsByName, aliases);
|
||||
const environment = { aliases, bindingsByName, visitorKeys };
|
||||
environmentsByProgram.set(program, environment);
|
||||
return environment;
|
||||
}
|
||||
|
||||
function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null {
|
||||
let current: ESTree.Node | null = node;
|
||||
let distance = 0;
|
||||
while (current !== null) {
|
||||
if (current === ancestor) return distance;
|
||||
current = current.parent;
|
||||
distance += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestTypeBindings(
|
||||
name: string,
|
||||
use: ESTree.Node,
|
||||
environment: TypeAliasEnvironment,
|
||||
): readonly TypeBinding[] {
|
||||
const candidates = environment.bindingsByName.get(name) ?? [];
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
let nearest: TypeBinding[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const distance = ancestorDistance(candidate.scope, use);
|
||||
if (distance === null || distance > nearestDistance) continue;
|
||||
if (distance === nearestDistance) {
|
||||
nearest.push(candidate);
|
||||
continue;
|
||||
}
|
||||
nearestDistance = distance;
|
||||
nearest = [candidate];
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */
|
||||
export function visibleTypeAlias(
|
||||
name: string,
|
||||
use: ESTree.Node,
|
||||
environment: TypeAliasEnvironment,
|
||||
): ESTree.TSTypeAliasDeclaration | null {
|
||||
if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null;
|
||||
const bindings = nearestTypeBindings(name, use, environment);
|
||||
return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null;
|
||||
}
|
||||
|
||||
/** Return whether a local declaration shadows a built-in type at this use. */
|
||||
export function hasVisibleTypeBinding(
|
||||
name: string,
|
||||
use: ESTree.Node,
|
||||
environment: TypeAliasEnvironment,
|
||||
): boolean {
|
||||
return (
|
||||
lexicalTypeParameterNames(use, environment.visitorKeys).has(name) ||
|
||||
nearestTypeBindings(name, use, environment).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||
}
|
||||
|
||||
function aliasSubstitutions(
|
||||
alias: ESTree.TSTypeAliasDeclaration,
|
||||
reference: ESTree.TSTypeReference,
|
||||
base: Substitutions,
|
||||
): Substitutions | null {
|
||||
const parameters = alias.typeParameters?.params ?? [];
|
||||
const arguments_ = reference.typeArguments?.params ?? [];
|
||||
const next = new Map(base);
|
||||
for (const [index, parameter] of parameters.entries()) {
|
||||
const explicitArgument = arguments_[index];
|
||||
const argument = explicitArgument ?? parameter.default;
|
||||
if (argument === null || argument === undefined) return null;
|
||||
const argumentSubstitutions = explicitArgument === undefined ? next : base;
|
||||
next.set(parameter.name.name, {
|
||||
type: argument,
|
||||
substitutions: new Map(argumentSubstitutions),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Match a type after resolving visible aliases and substituting their type parameters. */
|
||||
export function resolvedTypeMatches(
|
||||
type: ESTree.TSType,
|
||||
environment: TypeAliasEnvironment,
|
||||
matcher: ResolvedTypeMatcher,
|
||||
): boolean {
|
||||
const evaluate = (
|
||||
current: ESTree.TSType,
|
||||
substitutions: Substitutions,
|
||||
resolvingAliases: ReadonlySet<ESTree.TSTypeAliasDeclaration>,
|
||||
): boolean => {
|
||||
if (current.type === "TSTypeReference") {
|
||||
const name = typeReferenceName(current);
|
||||
if (name !== null) {
|
||||
const substitution = substitutions.get(name);
|
||||
if (substitution !== undefined && !current.typeArguments?.params.length) {
|
||||
return evaluate(
|
||||
substitution.type,
|
||||
substitution.substitutions,
|
||||
resolvingAliases,
|
||||
);
|
||||
}
|
||||
const alias = visibleTypeAlias(name, current, environment);
|
||||
if (alias !== null && !resolvingAliases.has(alias)) {
|
||||
const nextSubstitutions = aliasSubstitutions(alias, current, substitutions);
|
||||
if (nextSubstitutions !== null) {
|
||||
const nextResolving = new Set(resolvingAliases);
|
||||
nextResolving.add(alias);
|
||||
return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matcher(current, (child) =>
|
||||
evaluate(child, substitutions, resolvingAliases),
|
||||
);
|
||||
};
|
||||
|
||||
return evaluate(type, new Map(), new Set());
|
||||
}
|
||||
Reference in new issue
Block a user