improve(ci): replace boundary guards with focused Oxlint rules (#95368)

* perf(ci): move boundary guards to oxlint

Co-authored-by: Sash Zats <sash@zats.io>

* fix(ci): avoid dead editor URL export

Co-authored-by: Sash Zats <sash@zats.io>

* fix(ci): declare path-loaded boundary guard roots

Co-authored-by: Sash Zats <sash@zats.io>

* fix(ci): model Oxlint plugin export in Knip

Co-authored-by: Sash Zats <sash@zats.io>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Sash Zats
2026-07-17 11:37:35 +01:00
committed by GitHub
co-authored by Peter Steinberger
parent 9769642e7f
commit ff95d0c269
18 changed files with 204 additions and 210 deletions
+2
View File
@@ -62,6 +62,8 @@ const ROOT_TEST_ENTRY_GLOBS = [
"test/helpers/config/bundled-channel-config-runtime.ts!",
// The topology analyzer owns these as an intentionally self-contained graph.
"test/fixtures/ts-topology/basic/**/*.{js,mjs,cjs,ts,mts,cts}!",
// The focused Oxlint test invokes these deliberate violations by path.
"test/fixtures/oxlint-boundary-guards/*.ts!",
] as const;
const workspaces = Object.fromEntries(
+2
View File
@@ -63,6 +63,8 @@ const repositoryScriptEntries = [
"scripts/mcp-code-mode-gateway-e2e.ts!",
"scripts/openclaw-release-clawhub-plan.ts!",
"scripts/openclaw-release-clawhub-runtime-state.ts!",
// Oxlint loads this JS plugin by path from config/oxlint/boundary-guards.json.
"scripts/oxlint-boundary-guards.mjs!",
"scripts/plugin-prerelease-liveish-matrix.mjs!",
"scripts/pr-gates-lock.mjs!",
"scripts/pr-lib/process-group-runner.mjs!",
+2
View File
@@ -52,6 +52,8 @@ const config = {
"enumMembers",
"namespaceMembers",
],
// Oxlint consumes this required default export through a JSON config path.
"scripts/oxlint-boundary-guards.mjs": ["exports"],
"scripts/repro/code-mode-namespace-live.ts": [
"exports",
"nsExports",
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"plugins": [],
"jsPlugins": ["../../scripts/oxlint-boundary-guards.mjs"],
"categories": { "correctness": "off" },
"rules": {
"openclaw-boundaries/no-raw-window-open-call": "error",
"openclaw-boundaries/no-register-http-handler-call": "error"
}
}
+2 -2
View File
@@ -1704,7 +1704,7 @@
"lint:plugins:no-extension-src-imports": "node --import tsx scripts/check-no-extension-src-imports.ts",
"lint:plugins:no-extension-test-core-imports": "node --import tsx scripts/check-no-extension-test-core-imports.ts",
"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/check-no-register-http-handler.mjs",
"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 scripts/check-plugin-sdk-subpath-exports.mjs",
"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",
@@ -1719,7 +1719,7 @@
"lint:tmp:sqlite-transaction-boundary": "node scripts/check-sqlite-transaction-boundary.mjs",
"lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs",
"lint:ui:i18n": "pnpm ui:i18n:verify",
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
"lint:ui:no-raw-window-open": "node scripts/run-oxlint.mjs --openclaw-focused-config --config config/oxlint/boundary-guards.json ui/src",
"lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs",
"lint:web-search-provider-boundaries": "node scripts/check-web-search-provider-boundaries.mjs",
"lint:webhook:no-low-level-body-read": "node scripts/check-webhook-auth-body-order.mjs",
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env node
// Ensures UI code opens external URLs through the safe helper.
import { promises as fs } from "node:fs";
import path from "node:path";
import ts from "typescript";
import {
collectTypeScriptFiles,
resolveRepoRoot,
runAsScript,
toLine,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const uiSourceDir = path.join(repoRoot, "ui", "src", "ui");
const allowedCallsites = new Set([path.join(uiSourceDir, "open-external-url.ts")]);
function isRawWindowOpenCall(expression) {
const propertyAccess = unwrapExpression(expression);
if (!ts.isPropertyAccessExpression(propertyAccess) || propertyAccess.name.text !== "open") {
return false;
}
const receiver = unwrapExpression(propertyAccess.expression);
return (
ts.isIdentifier(receiver) && (receiver.text === "window" || receiver.text === "globalThis")
);
}
/**
* Finds raw `window.open(...)` or `globalThis.open(...)` call lines.
*/
export function findRawWindowOpenLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const lines = [];
const visit = (node) => {
if (ts.isCallExpression(node) && isRawWindowOpenCall(node.expression)) {
lines.push(toLine(sourceFile, node.expression));
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return lines;
}
/**
* Runs the raw window.open guard.
*/
export async function main() {
const files = await collectTypeScriptFiles(uiSourceDir, {
extraTestSuffixes: [".browser.test.ts", ".node.test.ts"],
ignoreMissing: true,
});
const violations = [];
for (const filePath of files) {
if (allowedCallsites.has(filePath)) {
continue;
}
const content = await fs.readFile(filePath, "utf8");
for (const line of findRawWindowOpenLines(content, filePath)) {
const relPath = path.relative(repoRoot, filePath);
violations.push(`${relPath}:${line}`);
}
}
if (violations.length === 0) {
return;
}
console.error("Found raw window.open usage outside safe helper:");
for (const violation of violations) {
console.error(`- ${violation}`);
}
console.error("Use openExternalUrlSafe(...) from ui/src/lib/open-external-url.ts instead.");
process.exit(1);
}
runAsScript(import.meta.url, main);
@@ -1,43 +0,0 @@
#!/usr/bin/env node
// Blocks deprecated plugin `registerHttpHandler` callsites.
import ts from "typescript";
import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
import {
collectCallExpressionLines,
runAsScript,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
const sourceRoots = ["src", "extensions"];
function isDeprecatedRegisterHttpHandlerCall(expression) {
const callee = unwrapExpression(expression);
return ts.isPropertyAccessExpression(callee) && callee.name.text === "registerHttpHandler";
}
/**
* Finds deprecated `registerHttpHandler(...)` call lines.
*/
function findDeprecatedRegisterHttpHandlerLines(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
return collectCallExpressionLines(ts, sourceFile, (node) =>
isDeprecatedRegisterHttpHandlerCall(node.expression) ? node.expression : null,
);
}
/**
* Runs the deprecated HTTP handler API guard.
*/
export async function main() {
await runCallsiteGuard({
importMetaUrl: import.meta.url,
sourceRoots,
findCallLines: findDeprecatedRegisterHttpHandlerLines,
header: "Found deprecated plugin API call registerHttpHandler(...):",
footer:
"Use registerHttpRoute({ path, auth, match, handler }) and registerPluginHttpRoute for dynamic webhook paths.",
});
}
runAsScript(import.meta.url, main);
+66
View File
@@ -0,0 +1,66 @@
const EXPRESSION_WRAPPER_RE =
/^(?:ChainExpression|ParenthesizedExpression|TSAsExpression|TSNonNullExpression|TSTypeAssertion)$/;
const TEST_FILE_SUFFIXES = [".test.ts", ".test-utils.ts", ".test-harness.ts", ".e2e-harness.ts"];
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) => repoPath === root || repoPath.startsWith(`${root}/`)) ||
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 });
},
};
},
};
}
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.",
}),
},
};
+11 -5
View File
@@ -39,6 +39,7 @@ const OXLINT_VALUE_FLAGS = new Set([
"--tsconfig",
"--warn",
]);
const OPENCLAW_FOCUSED_CONFIG_FLAG = "--openclaw-focused-config";
/**
* Returns whether oxlint args need package-boundary declaration artifacts first.
@@ -206,10 +207,14 @@ async function prepareExtensionPackageBoundaryArtifacts(env) {
* Applies wrapper policy and runs oxlint with the final argument list.
*/
export async function main(argv = process.argv.slice(2), runtimeEnv = process.env) {
const { args: policyArgs, env } = applyLocalOxlintPolicy(
argv,
resolveLocalHeavyCheckEnv(runtimeEnv),
);
const focusedConfig = argv.includes(OPENCLAW_FOCUSED_CONFIG_FLAG);
const oxlintArgs = argv.filter((arg) => arg !== OPENCLAW_FOCUSED_CONFIG_FLAG);
const localEnv = resolveLocalHeavyCheckEnv(runtimeEnv);
// Focused configs are syntax-only guards; keep wrapper process handling
// without the broad type-aware policy or package artifact preparation.
const { args: policyArgs, env } = focusedConfig
? { args: oxlintArgs, env: localEnv }
: applyLocalOxlintPolicy(oxlintArgs, localEnv);
const sparseTargets = filterSparseMissingOxlintTargets(policyArgs);
const finalArgs = sparseTargets.args;
if (sparseTargets.skippedTargets.length > 0) {
@@ -229,7 +234,7 @@ export async function main(argv = process.argv.slice(2), runtimeEnv = process.en
}
const releaseLock =
env.OPENCLAW_OXLINT_SKIP_LOCK === "1"
env.OPENCLAW_OXLINT_SKIP_LOCK === "1" || focusedConfig
? () => {}
: shouldAcquireLocalHeavyCheckLockForOxlint(finalArgs, {
cwd: process.cwd(),
@@ -244,6 +249,7 @@ export async function main(argv = process.argv.slice(2), runtimeEnv = process.en
try {
if (
!focusedConfig &&
env.OPENCLAW_OXLINT_SKIP_PREPARE !== "1" &&
shouldPrepareExtensionPackageBoundaryArtifacts(finalArgs)
) {
@@ -0,0 +1,12 @@
window.open("https://example.com");
globalThis.open("https://example.com");
(window as Window).open("https://example.com");
(window.open as typeof window.open)("https://example.com");
window?.open?.("https://example.com");
const open = window.open;
const { open: openWindow } = window;
window["open"]("https://example.com");
app.window.open("https://example.com");
(window satisfies Window).open("https://example.com");
// window.open("https://example.com");
const text = "window.open('https://example.com')";
@@ -0,0 +1,7 @@
plugin.registerHttpHandler(() => {});
(plugin.registerHttpHandler as (handler: () => void) => void)(() => {});
plugin?.registerHttpHandler?.(() => {});
const register = plugin.registerHttpHandler;
const { registerHttpHandler } = plugin;
plugin["registerHttpHandler"](() => {});
(plugin.registerHttpHandler satisfies (handler: () => void) => void)(() => {});
@@ -1,40 +0,0 @@
// Check No Raw Window Open tests cover check no raw window open script behavior.
import { describe, expect, it } from "vitest";
import { findRawWindowOpenLines } from "../../scripts/check-no-raw-window-open.mjs";
describe("check-no-raw-window-open", () => {
it("finds direct window.open calls", () => {
const source = `
function openDocs() {
window.open("https://docs.openclaw.ai");
}
`;
expect(findRawWindowOpenLines(source)).toEqual([3]);
});
it("finds globalThis.open calls", () => {
const source = `
function openDocs() {
globalThis.open("https://docs.openclaw.ai");
}
`;
expect(findRawWindowOpenLines(source)).toEqual([3]);
});
it("ignores mentions in strings and comments", () => {
const source = `
// window.open("https://example.com")
const text = "window.open('https://example.com')";
`;
expect(findRawWindowOpenLines(source)).toStrictEqual([]);
});
it("handles parenthesized and asserted window references", () => {
const source = `
const openRef = (window as Window).open;
openRef("https://example.com");
(window as Window).open("https://example.com");
`;
expect(findRawWindowOpenLines(source)).toEqual([4]);
});
});
@@ -0,0 +1,41 @@
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
const FIXTURES = "test/fixtures/oxlint-boundary-guards";
const cases = [
{
rule: "openclaw-boundaries/no-register-http-handler-call",
violation: `${FIXTURES}/register-http-handler-violation.ts`,
violations: 3,
},
{
rule: "openclaw-boundaries/no-raw-window-open-call",
violation: `${FIXTURES}/raw-window-open-violation.ts`,
violations: 5,
},
];
function runGuard(target: string) {
return spawnSync(
process.execPath,
[
"scripts/run-oxlint.mjs",
"--openclaw-focused-config",
"--config",
"config/oxlint/boundary-guards.json",
target,
],
{ encoding: "utf8" },
);
}
describe("oxlint boundary guards", () => {
it.each(cases)("matches legacy call-only semantics for $rule", (testCase) => {
const violation = runGuard(testCase.violation);
const output = `${violation.stdout}${violation.stderr}`;
expect(violation.status).toBe(1);
expect(output.split(`${testCase.rule.replace("/", "(")})`)).toHaveLength(
testCase.violations + 1,
);
});
});
+4 -3
View File
@@ -9,8 +9,9 @@ import {
} from "../app-navigation.ts";
import { pathForRoute } from "../app-route-paths.ts";
import { normalizeAgentLabel } from "../lib/agents/display.ts";
import { editorOpenUrl } from "../lib/editor-links.ts";
import { openEditor } from "../lib/editor-links.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { openExternalUrlSafe } from "../lib/open-external-url.ts";
import { searchForSession } from "../lib/sessions/index.ts";
import {
canArchiveSessionRow,
@@ -435,10 +436,10 @@ export abstract class AppSidebarMenusElement extends AppSidebarSessionGroupsElem
this.selectSession(session.key);
break;
case "open-pr":
window.open(action.url, "_blank", "noopener");
openExternalUrlSafe(action.url);
break;
case "open-in":
window.open(editorOpenUrl(action.editor, action.path));
openEditor(action.editor, action.path);
break;
case "toggle-pin":
void this.patchSession(session, { pinned: !session.pinned });
+6 -1
View File
@@ -11,7 +11,7 @@ export const EDITOR_LABELS: Record<EditorId, string> = {
zed: "Zed",
};
export function editorOpenUrl(editor: EditorId, absPath: string, line?: number | null): string {
function editorOpenUrl(editor: EditorId, absPath: string, line?: number | null): string {
const normalizedPath = absPath.replaceAll("\\", "/");
const urlPath = normalizedPath.startsWith("/") ? normalizedPath : `/${normalizedPath}`;
const encodedPath = urlPath
@@ -22,3 +22,8 @@ export function editorOpenUrl(editor: EditorId, absPath: string, line?: number |
.join("/");
return `${editor}://file${encodedPath}${line ? `:${line}` : ""}`;
}
export function openEditor(editor: EditorId, path: string, line?: number | null) {
// Typed editor IDs plus encoded paths make this custom-scheme handoff safe.
return window.open(editorOpenUrl(editor, path, line));
}
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
import { editorOpenUrl } from "../../../lib/editor-links.ts";
import { openEditor } from "../../../lib/editor-links.ts";
import { hasUniformLineEndings } from "./chat-sidebar.ts";
describe("hasUniformLineEndings", () => {
@@ -19,35 +19,42 @@ describe("hasUniformLineEndings", () => {
});
});
describe("editorOpenUrl", () => {
it("creates a custom editor URL for a plain path", () => {
expect(editorOpenUrl("cursor", "/workspace/src/foo.ts")).toBe(
describe("openEditor", () => {
it.each([
[
"plain path",
"cursor",
"/workspace/src/foo.ts",
undefined,
"cursor://file/workspace/src/foo.ts",
);
});
it("encodes spaces in paths", () => {
expect(editorOpenUrl("vscode", "/workspace/My File.ts")).toBe(
],
[
"spaces",
"vscode",
"/workspace/My File.ts",
undefined,
"vscode://file/workspace/My%20File.ts",
);
});
it("appends a target line", () => {
expect(editorOpenUrl("zed", "/workspace/src/foo.ts", 42)).toBe(
"zed://file/workspace/src/foo.ts:42",
);
});
it("normalizes Windows paths", () => {
expect(editorOpenUrl("vscode", "C:\\workspace\\src\\foo.ts", 42)).toBe(
],
["target line", "zed", "/workspace/src/foo.ts", 42, "zed://file/workspace/src/foo.ts:42"],
[
"Windows path",
"vscode",
"C:\\workspace\\src\\foo.ts",
42,
"vscode://file/C:/workspace/src/foo.ts:42",
);
});
it("encodes URL-significant path characters", () => {
expect(editorOpenUrl("windsurf", "/workspace/#notes?.md")).toBe(
],
[
"URL-significant characters",
"windsurf",
"/workspace/#notes?.md",
undefined,
"windsurf://file/workspace/%23notes%3F.md",
);
],
] as const)("opens the encoded custom URL for %s", (_name, editor, path, line, expected) => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
openEditor(editor, path, line);
expect(open).toHaveBeenCalledWith(expected);
open.mockRestore();
});
});
+2 -3
View File
@@ -18,7 +18,7 @@ import {
type EmbedSandboxMode,
} from "../../../lib/chat/tool-display.ts";
import { copyToClipboard } from "../../../lib/clipboard.ts";
import { type EditorId, editorOpenUrl } from "../../../lib/editor-links.ts";
import { type EditorId, openEditor } from "../../../lib/editor-links.ts";
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
import "./session-diff-panel.ts";
import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts";
@@ -937,8 +937,7 @@ class ChatDetailPanel extends OpenClawLightDomElement {
return;
}
this.fileEditorMenuOpen = false;
// A custom-scheme window hands off to the OS without navigating this page.
window.open(editorOpenUrl(editor, absPath, content.line));
openEditor(editor, absPath, content.line);
};
private readonly copyFileContents = () => {
+4 -4
View File
@@ -17,8 +17,9 @@ import type { SessionMenuAction, SessionMenuWork } from "../../components/sessio
import { isStoppableCloudWorkerPlacement } from "../../components/session-row-badges.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { editorOpenUrl } from "../../lib/editor-links.ts";
import { openEditor } from "../../lib/editor-links.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { openExternalUrlSafe } from "../../lib/open-external-url.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
import type { SessionsGroupBy } from "../../lib/sessions/grouping.ts";
import {
@@ -1096,11 +1097,10 @@ class SessionsPage extends OpenClawLightDomElement {
context.navigate("chat", { search: searchForSession(row.key), hash: "" });
break;
case "open-pr":
window.open(action.url, "_blank", "noopener");
openExternalUrlSafe(action.url);
break;
case "open-in":
// A custom-scheme window hands off to the OS without navigating this page.
window.open(editorOpenUrl(action.editor, action.path));
openEditor(action.editor, action.path);
break;
case "toggle-pin":
void this.patchSession(row.key, { pinned: row.pinned !== true });