mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41: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,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,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user