Files
openclaw/scripts/oxlint-boundary-guards.mjs
Peter Steinberger 203aafc415 feat(lint): SAFETY-comment assertion ratchet + repo deslop skill (#124359)
* 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
2026-08-15 22:03:48 -07:00

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,
}),
},
};