mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
chore: enforce type evidence lint rules
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user