mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
feat(lint): enforce no-chained-type-assertions (#124119)
This commit is contained in:
committed by
GitHub
parent
4a6f99c2fc
commit
fe5fa802fa
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user