chore: enforce type evidence lint rules

This commit is contained in:
Amp
2026-08-12 21:49:50 +00:00
parent f9316c4697
commit 4d55cff030
55 changed files with 696 additions and 99 deletions
+441
View File
@@ -0,0 +1,441 @@
// Adapted from dmmulroy/anti-slop at abaeb63b29e63062f778771d5447bd2e9c3c680f.
// See THIRD_PARTY_NOTICES.md for the upstream MIT license.
const functionBoundaryTypes = new Set([
"ArrowFunctionExpression",
"FunctionDeclaration",
"FunctionExpression",
"TSDeclareFunction",
"TSEmptyBodyFunctionExpression",
]);
function unwrapExpressionParentheses(expression) {
let current = expression;
while (current.type === "ParenthesizedExpression") {
current = current.expression;
}
return current;
}
function unwrapTypeParentheses(type) {
let current = type;
while (current.type === "TSParenthesizedType") {
current = current.typeAnnotation;
}
return current;
}
function typeReferenceName(type) {
return type.typeName.type === "Identifier" ? type.typeName.name : null;
}
function isUnknownOrAnyType(type) {
const unwrapped = unwrapTypeParentheses(type);
return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword";
}
function isBroadRecordKeyType(type) {
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) {
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) {
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) {
return unwrapExpressionParentheses(node.expression);
}
function assertionFromExpression(expression) {
const unwrapped = unwrapExpressionParentheses(expression);
return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion"
? unwrapped
: null;
}
function normalizedTypeText(sourceText, type) {
return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, "");
}
function typesHaveSameSyntax(sourceText, left, right) {
return (
left !== null &&
normalizedTypeText(sourceText, unwrapTypeParentheses(left)) ===
normalizedTypeText(sourceText, unwrapTypeParentheses(right))
);
}
function isDefinitelyObjectType(type) {
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) {
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) {
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, identifier) {
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) {
for (const definition of variable.defs) {
if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") {
return definition.node;
}
}
return null;
}
function knownValueEvidence(expression, scopes, boundary, visitedVariables) {
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, scopes) {
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, broadKind, evidence, assertedType) {
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);
}
const noUnknownTypeAliasesRule = {
meta: {
type: "problem",
docs: {
description:
"Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.",
},
messages: {
unknownAlias:
"Type alias `{{alias}}` only renames `unknown`. Keep `unknown` explicit at the boundary or replace it with the parsed owner type.",
},
},
create(context) {
const aliases = new Map();
const resolvesToUnknown = (type, visited = new Set()) => {
if (type.type === "TSUnknownKeyword") {
return true;
}
if (type.type === "TSParenthesizedType") {
return resolvesToUnknown(type.typeAnnotation, visited);
}
const name =
type.type === "TSTypeReference" &&
type.typeName.type === "Identifier" &&
(type.typeArguments === null ||
type.typeArguments === undefined ||
type.typeArguments.params.length === 0)
? type.typeName.name
: null;
if (name === null || visited.has(name)) {
return false;
}
const alias = aliases.get(name);
if (
alias === undefined ||
(alias.typeParameters !== null && alias.typeParameters !== undefined)
) {
return false;
}
const nextVisited = new Set(visited);
nextVisited.add(name);
return resolvesToUnknown(alias.typeAnnotation, nextVisited);
};
return {
Program(node) {
for (const statement of node.body) {
const declaration =
statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
if (declaration?.type === "TSTypeAliasDeclaration") {
aliases.set(declaration.id.name, declaration);
}
}
for (const alias of aliases.values()) {
if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) {
continue;
}
context.report({
node: alias.id,
messageId: "unknownAlias",
data: { alias: alias.id.name },
});
}
},
};
},
};
const noWidenThenAssertRule = {
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}}" erases established type evidence by widening the value, then reconstructs that evidence with a type assertion. Preserve the precise type end-to-end; if the input is genuinely unknown, parse it once at the boundary instead.',
},
},
create(context) {
const scopes = context.sourceCode.scopeManager.scopes;
const checkAssertion = (node) => {
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 {
TSAsExpression: checkAssertion,
TSTypeAssertion: checkAssertion,
};
},
};
export default {
meta: { name: "openclaw-type-evidence" },
rules: {
"no-unknown-type-aliases": noUnknownTypeAliasesRule,
"no-widen-then-assert": noWidenThenAssertRule,
},
};