feat(lint): enforce no-chained-type-assertions (#124119)

This commit is contained in:
Peter Steinberger
2026-08-15 02:13:37 -07:00
committed by GitHub
parent 4a6f99c2fc
commit fe5fa802fa
7 changed files with 206 additions and 3 deletions
+2 -1
View File
@@ -6,6 +6,7 @@
"rules": {
"openclaw-boundaries/no-raw-window-open-call": "error",
"openclaw-boundaries/no-register-http-handler-call": "error",
"openclaw-boundaries/no-widen-then-assert": "error"
"openclaw-boundaries/no-widen-then-assert": "error",
"openclaw-boundaries/no-chained-type-assertions": "error"
}
}
+1
View File
@@ -1657,6 +1657,7 @@
"lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts",
"lint:plugins:no-register-http-handler": "node scripts/run-oxlint.mjs --openclaw-focused-config --config config/oxlint/boundary-guards.json src extensions",
"lint:plugins:plugin-sdk-subpaths-exported": "node --import tsx scripts/check-plugin-sdk-subpath-exports.mts",
"lint:no-chained-type-assertions": "node scripts/run-oxlint.mjs --openclaw-focused-config --config config/oxlint/boundary-guards.json src extensions packages ui/src",
"lint:no-widen-then-assert": "node scripts/run-oxlint.mjs --openclaw-focused-config --config config/oxlint/boundary-guards.json src extensions packages ui/src",
"lint:scripts": "pnpm lint:docker-e2e && pnpm lint:tmp:no-raw-http2-imports && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts",
"lint:swift": "./scripts/lint-swift.sh",
+181 -2
View File
@@ -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",
@@ -0,0 +1,8 @@
declare const input: unknown;
input as unknown as { readonly id: string };
input as object as Record<string, unknown> as { readonly id: string };
<{ readonly id: string }>(input as object);
input as { readonly id: string };
({ id: "fixture" }) as const as const;
@@ -18,6 +18,11 @@ const cases = [
violation: `${FIXTURES}/widen-then-assert-violation.test.ts`,
violations: 3,
},
{
rule: "openclaw-boundaries/no-chained-type-assertions",
violation: `${FIXTURES}/chained-type-assertions-violation.ts`,
violations: 3,
},
];
function runGuard(target: string) {
@@ -254,6 +254,14 @@ describe("run-additional-boundary-checks", () => {
});
});
it("keeps chained-type-assertions lint in CI boundary checks", () => {
expect(BOUNDARY_CHECKS).toContainEqual({
label: "lint:no-chained-type-assertions",
command: "pnpm",
args: ["run", "lint:no-chained-type-assertions"],
});
});
it("keeps the Telegram grammY type import guard in source boundary checks", () => {
expect(BOUNDARY_CHECKS).toContainEqual({
label: "lint:extensions:telegram-grammy-types",