mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
203aafc415
* feat(lint): add SAFETY-comment assertion ratchet * feat(skills): add repo deslop skill * fix(lint): refresh initial assertion baseline * chore(lint): align assertion baseline with main * chore(lint): sync baseline with current assertion ledger * test(ui): stabilize Control UI E2E waits * chore(lint): sync narrowed assertion ledger * chore(lint): sync fully drained assertion ledger * fix(lint): close assertion ratchet bypasses
601 lines
18 KiB
JavaScript
601 lines
18 KiB
JavaScript
import {
|
|
BOUNDARY_GUARD_FIXTURE_ROOT,
|
|
CHAINED_ASSERTION_EXCLUDED_ROOTS,
|
|
TYPE_ASSERTION_PRODUCTION_ROOTS,
|
|
TYPE_ASSERTION_TEST_FILE_SUFFIXES,
|
|
isSkippedTypeAssertionTestPath,
|
|
pathMatchesTypeAssertionRoot,
|
|
} from "./lib/type-assertion-guard-scope.mjs";
|
|
|
|
const EXPRESSION_WRAPPER_RE =
|
|
/^(?:ChainExpression|ParenthesizedExpression|TSAsExpression|TSNonNullExpression|TSTypeAssertion)$/;
|
|
|
|
function unwrapExpression(node) {
|
|
let current = node;
|
|
while (EXPRESSION_WRAPPER_RE.test(current.type)) {
|
|
current = current.expression;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function restrictedCallRule({ allowedFiles = [], message, objects, property, roots }) {
|
|
return {
|
|
create(context) {
|
|
const filename = context.physicalFilename.replaceAll("\\", "/");
|
|
const cwd = context.cwd.replaceAll("\\", "/");
|
|
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
|
|
if (
|
|
!filename.endsWith(".ts") ||
|
|
!roots.some((root) => pathMatchesTypeAssertionRoot(repoPath, root)) ||
|
|
TYPE_ASSERTION_TEST_FILE_SUFFIXES.some((suffix) => filename.endsWith(suffix)) ||
|
|
allowedFiles.includes(repoPath)
|
|
) {
|
|
return {};
|
|
}
|
|
return {
|
|
CallExpression(node) {
|
|
const callee = unwrapExpression(node.callee);
|
|
if (
|
|
callee.type !== "MemberExpression" ||
|
|
callee.computed ||
|
|
callee.property.type !== "Identifier" ||
|
|
callee.property.name !== property
|
|
) {
|
|
return;
|
|
}
|
|
const receiver = unwrapExpression(callee.object);
|
|
if (objects && (receiver.type !== "Identifier" || !objects.includes(receiver.name))) {
|
|
return;
|
|
}
|
|
context.report({ message, node: node.callee });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
// Adapted from dmmulroy/anti-slop@446268e5d15baa968eaec669ff65358d36ae6259, MIT.
|
|
function isTypeAssertionExpression(node) {
|
|
return node.type === "TSAsExpression" || node.type === "TSTypeAssertion";
|
|
}
|
|
|
|
function isConstAssertion(node) {
|
|
const { typeAnnotation } = node;
|
|
return (
|
|
typeAnnotation.type === "TSTypeReference" &&
|
|
typeAnnotation.typeName.type === "Identifier" &&
|
|
typeAnnotation.typeName.name === "const"
|
|
);
|
|
}
|
|
|
|
function isOutermostAssertionInChain(node) {
|
|
let current = 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) {
|
|
let assertionCount = 0;
|
|
let hasNonConstAssertion = false;
|
|
let current = node;
|
|
|
|
while (isTypeAssertionExpression(current)) {
|
|
assertionCount += 1;
|
|
hasNonConstAssertion ||= !isConstAssertion(current);
|
|
current = unwrapExpressionParentheses(current.expression);
|
|
}
|
|
|
|
return assertionCount > 1 && hasNonConstAssertion;
|
|
}
|
|
|
|
function noChainedTypeAssertionsRule({ excludedRoots = [], roots }) {
|
|
return {
|
|
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.",
|
|
},
|
|
},
|
|
create(context) {
|
|
const filename = context.physicalFilename.replaceAll("\\", "/");
|
|
const cwd = context.cwd.replaceAll("\\", "/");
|
|
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
|
|
if (
|
|
!roots.some((root) => pathMatchesTypeAssertionRoot(repoPath, root)) ||
|
|
excludedRoots.some((root) => pathMatchesTypeAssertionRoot(repoPath, root)) ||
|
|
isSkippedTypeAssertionTestPath(repoPath)
|
|
) {
|
|
return {};
|
|
}
|
|
|
|
const checkTypeAssertion = (node) => {
|
|
if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) {
|
|
return;
|
|
}
|
|
context.report({ node, messageId: "chained" });
|
|
};
|
|
|
|
return {
|
|
TSAsExpression: checkTypeAssertion,
|
|
TSTypeAssertion: checkTypeAssertion,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
// Adapted from dmmulroy/anti-slop, MIT.
|
|
const FUNCTION_BOUNDARY_TYPES = new Set([
|
|
"ArrowFunctionExpression",
|
|
"FunctionDeclaration",
|
|
"FunctionExpression",
|
|
"TSDeclareFunction",
|
|
"TSEmptyBodyFunctionExpression",
|
|
]);
|
|
const MAX_TRANSPARENT_ALIAS_DEPTH = 32;
|
|
|
|
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 assertedIdentifier(node) {
|
|
let expression = assertedExpression(node);
|
|
while (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") {
|
|
expression = assertedExpression(expression);
|
|
}
|
|
return expression.type === "Identifier" ? expression : null;
|
|
}
|
|
|
|
function isNestedAssertion(node) {
|
|
let parent = node.parent;
|
|
while (parent?.type === "ParenthesizedExpression") {
|
|
parent = parent.parent;
|
|
}
|
|
return (
|
|
(parent?.type === "TSAsExpression" || parent?.type === "TSTypeAssertion") &&
|
|
assertedExpression(parent) === node
|
|
);
|
|
}
|
|
|
|
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 (FUNCTION_BOUNDARY_TYPES.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 resolveWidenedBinding(variable, scopes, boundary, assertedAt) {
|
|
const visitedVariables = new Set();
|
|
let current = variable;
|
|
for (let depth = 0; depth < MAX_TRANSPARENT_ALIAS_DEPTH; depth += 1) {
|
|
if (visitedVariables.has(current)) {
|
|
return null;
|
|
}
|
|
visitedVariables.add(current);
|
|
|
|
const widened = widenedBinding(current, scopes);
|
|
if (widened !== null) {
|
|
return widened;
|
|
}
|
|
|
|
const declarator = variableDeclarator(current);
|
|
if (
|
|
declarator === null ||
|
|
declarator.parent.type !== "VariableDeclaration" ||
|
|
declarator.parent.kind !== "const" ||
|
|
declarator.id.type !== "Identifier" ||
|
|
(declarator.id.typeAnnotation !== null && declarator.id.typeAnnotation !== undefined) ||
|
|
declarator.init === null ||
|
|
declarator.end >= assertedAt ||
|
|
current.references.some((reference) => reference.isWrite() && !reference.init) ||
|
|
functionBoundary(declarator) !== boundary
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const initializer = unwrapExpressionParentheses(declarator.init);
|
|
if (initializer.type !== "Identifier") {
|
|
return null;
|
|
}
|
|
current = resolvedVariableForIdentifier(scopes, initializer);
|
|
if (current === null) {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function noWidenThenAssertRule({ roots }) {
|
|
return {
|
|
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.',
|
|
},
|
|
},
|
|
create(context) {
|
|
const filename = context.physicalFilename.replaceAll("\\", "/");
|
|
const cwd = context.cwd.replaceAll("\\", "/");
|
|
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
|
|
if (!roots.some((root) => repoPath === root || repoPath.startsWith(`${root}/`))) {
|
|
return {};
|
|
}
|
|
|
|
let scopes = [];
|
|
const checkAssertion = (node) => {
|
|
if (isNestedAssertion(node)) {
|
|
return;
|
|
}
|
|
const expression = assertedIdentifier(node);
|
|
if (expression === null) {
|
|
return;
|
|
}
|
|
|
|
const variable = resolvedVariableForIdentifier(scopes, expression);
|
|
if (variable === null) {
|
|
return;
|
|
}
|
|
const boundary = functionBoundary(node);
|
|
const widened = resolveWidenedBinding(variable, scopes, boundary, node.start);
|
|
if (
|
|
widened === null ||
|
|
node.start <= widened.declaredAt ||
|
|
boundary !== 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,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export default {
|
|
meta: { name: "openclaw-boundaries" },
|
|
rules: {
|
|
"no-raw-window-open-call": restrictedCallRule({
|
|
allowedFiles: ["ui/src/lib/editor-links.ts", "ui/src/lib/open-external-url.ts"],
|
|
roots: ["ui/src", "test/fixtures/oxlint-boundary-guards"],
|
|
property: "open",
|
|
objects: ["window", "globalThis"],
|
|
message: "Use openExternalUrlSafe(...) from ui/src/lib/open-external-url.ts instead.",
|
|
}),
|
|
"no-register-http-handler-call": restrictedCallRule({
|
|
roots: ["src", "extensions", "test/fixtures/oxlint-boundary-guards"],
|
|
property: "registerHttpHandler",
|
|
message:
|
|
"Use registerHttpRoute({ path, auth, match, handler }) and registerPluginHttpRoute for dynamic webhook paths.",
|
|
}),
|
|
"no-widen-then-assert": noWidenThenAssertRule({
|
|
roots: [...TYPE_ASSERTION_PRODUCTION_ROOTS, BOUNDARY_GUARD_FIXTURE_ROOT],
|
|
}),
|
|
"no-chained-type-assertions": noChainedTypeAssertionsRule({
|
|
roots: [...TYPE_ASSERTION_PRODUCTION_ROOTS, BOUNDARY_GUARD_FIXTURE_ROOT],
|
|
excludedRoots: CHAINED_ASSERTION_EXCLUDED_ROOTS,
|
|
}),
|
|
},
|
|
};
|