From fe5fa802fa27e89d1df95b83ccb79d07acbbf070 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 15 Aug 2026 02:13:37 -0700 Subject: [PATCH] feat(lint): enforce no-chained-type-assertions (#124119) --- config/oxlint/boundary-guards.json | 3 +- package.json | 1 + scripts/oxlint-boundary-guards.mjs | 183 +++++++++++++++++- scripts/run-additional-boundary-checks.mts | 1 + .../chained-type-assertions-violation.ts | 8 + test/scripts/oxlint-boundary-guards.test.ts | 5 + .../run-additional-boundary-checks.test.ts | 8 + 7 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/oxlint-boundary-guards/chained-type-assertions-violation.ts diff --git a/config/oxlint/boundary-guards.json b/config/oxlint/boundary-guards.json index 336ea8232e75..a354c51a5030 100644 --- a/config/oxlint/boundary-guards.json +++ b/config/oxlint/boundary-guards.json @@ -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" } } diff --git a/package.json b/package.json index 79686a271dfd..fd5228d9bb99 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/oxlint-boundary-guards.mjs b/scripts/oxlint-boundary-guards.mjs index 28f9f0213d5c..dfbcc3957519 100644 --- a/scripts/oxlint-boundary-guards.mjs +++ b/scripts/oxlint-boundary-guards.mjs @@ -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", + ], + }), }, }; diff --git a/scripts/run-additional-boundary-checks.mts b/scripts/run-additional-boundary-checks.mts index fe84ae1227ef..46172397f458 100644 --- a/scripts/run-additional-boundary-checks.mts +++ b/scripts/run-additional-boundary-checks.mts @@ -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", diff --git a/test/fixtures/oxlint-boundary-guards/chained-type-assertions-violation.ts b/test/fixtures/oxlint-boundary-guards/chained-type-assertions-violation.ts new file mode 100644 index 000000000000..42abc01cc44e --- /dev/null +++ b/test/fixtures/oxlint-boundary-guards/chained-type-assertions-violation.ts @@ -0,0 +1,8 @@ +declare const input: unknown; + +input as unknown as { readonly id: string }; +input as object as Record as { readonly id: string }; +<{ readonly id: string }>(input as object); + +input as { readonly id: string }; +({ id: "fixture" }) as const as const; diff --git a/test/scripts/oxlint-boundary-guards.test.ts b/test/scripts/oxlint-boundary-guards.test.ts index 625191ae5aae..ee5d9400d58c 100644 --- a/test/scripts/oxlint-boundary-guards.test.ts +++ b/test/scripts/oxlint-boundary-guards.test.ts @@ -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) { diff --git a/test/scripts/run-additional-boundary-checks.test.ts b/test/scripts/run-additional-boundary-checks.test.ts index 641430ecaf84..f310d43adfc9 100644 --- a/test/scripts/run-additional-boundary-checks.test.ts +++ b/test/scripts/run-additional-boundary-checks.test.ts @@ -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",