mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
feat(lint): enforce no-chained-type-assertions (#124119)
This commit is contained in:
committed by
GitHub
parent
4a6f99c2fc
commit
fe5fa802fa
@@ -1,6 +1,49 @@
|
||||
const EXPRESSION_WRAPPER_RE =
|
||||
/^(?:ChainExpression|ParenthesizedExpression|TSAsExpression|TSNonNullExpression|TSTypeAssertion)$/;
|
||||
const TEST_FILE_SUFFIXES = [".test.ts", ".test-utils.ts", ".test-harness.ts", ".e2e-harness.ts"];
|
||||
const BOUNDARY_GUARD_FIXTURE_ROOT = "test/fixtures/oxlint-boundary-guards";
|
||||
// Shared test-path policy for guards that intentionally exclude fixture, mock, and harness code.
|
||||
const TEST_FILE_SUFFIXES = [
|
||||
".test.ts",
|
||||
".test.tsx",
|
||||
".spec.ts",
|
||||
".spec.tsx",
|
||||
".test-utils.ts",
|
||||
".test-utils.tsx",
|
||||
".test-harness.ts",
|
||||
".test-harness.tsx",
|
||||
".e2e-harness.ts",
|
||||
".e2e-harness.tsx",
|
||||
];
|
||||
const TEST_PATH_MARKERS = [
|
||||
"/test/",
|
||||
"/tests/",
|
||||
"__tests__",
|
||||
"/e2e/",
|
||||
"test-helpers",
|
||||
"test-support",
|
||||
"test-fixtures",
|
||||
"test-mocks",
|
||||
"test-utils",
|
||||
"mock-http",
|
||||
"-harness.",
|
||||
".test-utils.",
|
||||
"/mocks/",
|
||||
];
|
||||
|
||||
function pathMatchesRoot(repoPath, root) {
|
||||
return repoPath === root || repoPath.startsWith(`${root}/`);
|
||||
}
|
||||
|
||||
function isSkippedTestPath(repoPath) {
|
||||
if (pathMatchesRoot(repoPath, BOUNDARY_GUARD_FIXTURE_ROOT)) {
|
||||
return false;
|
||||
}
|
||||
const slashPrefixedPath = `/${repoPath}`;
|
||||
return (
|
||||
TEST_FILE_SUFFIXES.some((suffix) => repoPath.endsWith(suffix)) ||
|
||||
TEST_PATH_MARKERS.some((marker) => slashPrefixedPath.includes(marker))
|
||||
);
|
||||
}
|
||||
|
||||
function unwrapExpression(node) {
|
||||
let current = node;
|
||||
@@ -18,7 +61,7 @@ function restrictedCallRule({ allowedFiles = [], message, objects, property, roo
|
||||
const repoPath = filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
|
||||
if (
|
||||
!filename.endsWith(".ts") ||
|
||||
!roots.some((root) => repoPath === root || repoPath.startsWith(`${root}/`)) ||
|
||||
!roots.some((root) => pathMatchesRoot(repoPath, root)) ||
|
||||
TEST_FILE_SUFFIXES.some((suffix) => filename.endsWith(suffix)) ||
|
||||
allowedFiles.includes(repoPath)
|
||||
) {
|
||||
@@ -46,6 +89,86 @@ function restrictedCallRule({ allowedFiles = [], message, objects, property, roo
|
||||
};
|
||||
}
|
||||
|
||||
// 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) => pathMatchesRoot(repoPath, root)) ||
|
||||
excludedRoots.some((root) => pathMatchesRoot(repoPath, root)) ||
|
||||
isSkippedTestPath(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",
|
||||
@@ -504,5 +627,61 @@ export default {
|
||||
"no-widen-then-assert": noWidenThenAssertRule({
|
||||
roots: ["src", "extensions", "packages", "ui/src", "test/fixtures/oxlint-boundary-guards"],
|
||||
}),
|
||||
"no-chained-type-assertions": noChainedTypeAssertionsRule({
|
||||
roots: ["src", "extensions", "packages", "ui/src", BOUNDARY_GUARD_FIXTURE_ROOT],
|
||||
// Burn-down ledger — shrink only; see PR #124060/#124073/#124079/#124082.
|
||||
excludedRoots: [
|
||||
"extensions/amazon-bedrock-mantle",
|
||||
"extensions/anthropic-vertex",
|
||||
"extensions/browser",
|
||||
"extensions/codex",
|
||||
"extensions/copilot",
|
||||
"extensions/deepinfra",
|
||||
"extensions/diagnostics-otel",
|
||||
"extensions/diagnostics-prometheus",
|
||||
"extensions/discord",
|
||||
"extensions/github-copilot",
|
||||
"extensions/google",
|
||||
"extensions/googlechat",
|
||||
"extensions/imessage",
|
||||
"extensions/line",
|
||||
"extensions/llm-task",
|
||||
"extensions/longcat",
|
||||
"extensions/matrix",
|
||||
"extensions/microsoft-foundry",
|
||||
"extensions/msteams",
|
||||
"extensions/qa-lab",
|
||||
"extensions/reef",
|
||||
"extensions/signal",
|
||||
"extensions/slack",
|
||||
"extensions/sms",
|
||||
"extensions/synology-chat",
|
||||
"extensions/telegram",
|
||||
"extensions/tlon",
|
||||
"extensions/voice-call",
|
||||
"extensions/whatsapp",
|
||||
"extensions/workboard",
|
||||
"extensions/zalo",
|
||||
"extensions/zalouser",
|
||||
"packages/ai",
|
||||
"src/runtime.ts",
|
||||
"src/acp",
|
||||
"src/agents",
|
||||
"src/channels",
|
||||
"src/commands",
|
||||
"src/config",
|
||||
"src/gateway",
|
||||
"src/infra",
|
||||
"src/media",
|
||||
"src/meeting-bot",
|
||||
"src/plugin-sdk",
|
||||
"src/plugins",
|
||||
"src/process",
|
||||
"src/proxy-capture",
|
||||
"src/shared",
|
||||
"src/trajectory",
|
||||
"ui/src",
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ export const BOUNDARY_CHECKS = (
|
||||
["lint:tmp:no-raw-channel-fetch", "pnpm", ["run", "lint:tmp:no-raw-channel-fetch"]],
|
||||
["lint:tmp:no-raw-http2-imports", "pnpm", ["run", "lint:tmp:no-raw-http2-imports"]],
|
||||
["lint:agent:ingress-owner", "pnpm", ["run", "lint:agent:ingress-owner"]],
|
||||
["lint:no-chained-type-assertions", "pnpm", ["run", "lint:no-chained-type-assertions"]],
|
||||
["lint:no-widen-then-assert", "pnpm", ["run", "lint:no-widen-then-assert"]],
|
||||
[
|
||||
"lint:plugins:no-register-http-handler",
|
||||
|
||||
Reference in New Issue
Block a user