From 0d321ccae08fcfef073df9c5f6aaf10bf122ab0f Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Sun, 12 Apr 2026 03:00:35 -0700 Subject: [PATCH] lint: add rule banning foreign imports in itremote/remotely closures remote-tools-imports/no-foreign-imports-in-remote-closure flags any identifier inside an itremote()/remotely() closure that is bound to an import from somewhere other than spec/lib/remote-tools. Those bindings get rewritten to __vite_ssr_import_N__ by vite's SSR transform and fail when the closure is stringified and eval'd in a remote context. Skips type-only imports and type-annotation positions. Currently flags 50 sites across 5 files (node, webview, chromium, fuses, api-native-image). --- .oxlintrc.json | 7 +- script/lint-plugins/remote-tools-imports.mjs | 154 +++++++++++++++++++ spec/lib/remote-tools.ts | 8 +- 3 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 script/lint-plugins/remote-tools-imports.mjs diff --git a/.oxlintrc.json b/.oxlintrc.json index 2cfc85aaa3..d128bb74df 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -11,6 +11,10 @@ { "name": "no-only-tests", "specifier": "./script/lint-plugins/no-only-tests.mjs" + }, + { + "name": "remote-tools-imports", + "specifier": "./script/lint-plugins/remote-tools-imports.mjs" } ], "categories": { @@ -315,7 +319,8 @@ { "files": ["spec/**/*.ts", "spec/**/*.js", "spec/**/*.mjs"], "rules": { - "no-only-tests/no-only-tests": "error" + "no-only-tests/no-only-tests": "error", + "remote-tools-imports/no-foreign-imports-in-remote-closure": "error" } }, { diff --git a/script/lint-plugins/remote-tools-imports.mjs b/script/lint-plugins/remote-tools-imports.mjs new file mode 100644 index 0000000000..48f698fddd --- /dev/null +++ b/script/lint-plugins/remote-tools-imports.mjs @@ -0,0 +1,154 @@ +// Flags imports (other than from spec/lib/remote-tools) that are referenced +// inside itremote()/remotely() closures. vite's SSR transform rewrites import +// bindings to __vite_ssr_import_N__, which breaks when the closure is +// stringified and eval'd in a renderer/child process. Importing from +// remote-tools instead lets runRemote()'s __rt shim resolve them. + +const REMOTE_TOOLS_RE = /[./]lib\/remote-tools(\.ts)?$/; + +function isRemoteCall(callee) { + // itremote(name, fn), itremote.only(name, fn), itremote.skip(name, fn) + if (callee.type === 'Identifier' && callee.name === 'itremote') return { fnArg: 1 }; + if ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'itremote' && + callee.property.type === 'Identifier' && + (callee.property.name === 'only' || callee.property.name === 'skip') + ) { + return { fnArg: 1 }; + } + // .remotely(fn, ...args) + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + callee.property.name === 'remotely' + ) { + return { fnArg: 0 }; + } + return null; +} + +function collectDeclared(node, declared) { + if (!node) return; + if (node.type === 'Identifier') { + declared.add(node.name); + } else if (node.type === 'ObjectPattern') { + for (const p of node.properties) collectDeclared(p.value ?? p.argument, declared); + } else if (node.type === 'ArrayPattern') { + for (const e of node.elements) collectDeclared(e, declared); + } else if (node.type === 'RestElement') { + collectDeclared(node.argument, declared); + } else if (node.type === 'AssignmentPattern') { + collectDeclared(node.left, declared); + } +} + +function walkClosure(node, onIdentifierRef, declared) { + if (!node || typeof node !== 'object') return; + switch (node.type) { + case 'Identifier': + if (!declared.has(node.name)) onIdentifierRef(node); + return; + case 'MemberExpression': + walkClosure(node.object, onIdentifierRef, declared); + if (node.computed) walkClosure(node.property, onIdentifierRef, declared); + return; + case 'Property': + if (node.computed) walkClosure(node.key, onIdentifierRef, declared); + walkClosure(node.value, onIdentifierRef, declared); + return; + case 'VariableDeclarator': + collectDeclared(node.id, declared); + walkClosure(node.init, onIdentifierRef, declared); + return; + case 'FunctionDeclaration': + case 'FunctionExpression': + case 'ArrowFunctionExpression': { + const inner = new Set(declared); + if (node.id) inner.add(node.id.name); + for (const p of node.params) collectDeclared(p, inner); + walkClosure(node.body, onIdentifierRef, inner); + return; + } + case 'CatchClause': { + const inner = new Set(declared); + collectDeclared(node.param, inner); + walkClosure(node.body, onIdentifierRef, inner); + return; + } + } + for (const key of Object.keys(node)) { + if ( + key === 'type' || + key === 'loc' || + key === 'range' || + key === 'start' || + key === 'end' || + key === 'parent' || + key === 'typeAnnotation' || + key === 'typeParameters' || + key === 'returnType' + ) { + continue; + } + const child = node[key]; + if (Array.isArray(child)) { + for (const c of child) walkClosure(c, onIdentifierRef, declared); + } else if (child && typeof child === 'object' && typeof child.type === 'string') { + walkClosure(child, onIdentifierRef, declared); + } + } +} + +export default { + meta: { name: 'remote-tools-imports' }, + rules: { + 'no-foreign-imports-in-remote-closure': { + meta: { type: 'problem' }, + create(context) { + // Map of import-binding name -> { source, node } for everything NOT + // from remote-tools. + const foreignImports = new Map(); + + return { + ImportDeclaration(node) { + const source = node.source.value; + if (REMOTE_TOOLS_RE.test(source)) return; + if (node.importKind === 'type') return; + for (const spec of node.specifiers) { + if (spec.importKind === 'type') continue; + foreignImports.set(spec.local.name, { source, node: spec.local }); + } + }, + CallExpression(node) { + const match = isRemoteCall(node.callee); + if (!match) return; + const fnArg = node.arguments[match.fnArg]; + if (!fnArg || (fnArg.type !== 'FunctionExpression' && fnArg.type !== 'ArrowFunctionExpression')) { + return; + } + const reported = new Set(); + walkClosure( + fnArg, + (id) => { + const hit = foreignImports.get(id.name); + if (hit && !reported.has(id.name)) { + reported.add(id.name); + context.report({ + node: id, + message: + `'${id.name}' is imported from '${hit.source}' but used inside a ` + + `stringified remote closure. Import it from './lib/remote-tools' ` + + `instead (add it there if missing).` + }); + } + }, + new Set() + ); + } + }; + } + } + } +}; diff --git a/spec/lib/remote-tools.ts b/spec/lib/remote-tools.ts index 6364d50ff6..cdcc20e5ae 100644 --- a/spec/lib/remote-tools.ts +++ b/spec/lib/remote-tools.ts @@ -13,8 +13,9 @@ export * as util from 'node:util'; export * as os from 'node:os'; export * as cp from 'node:child_process'; export { once } from 'node:events'; -export { setTimeout as delay } from 'node:timers/promises'; +export { setTimeout } from 'node:timers/promises'; export { expect } from 'chai'; +export { BrowserWindow, nativeImage, webContents } from 'electron/main'; // Renderer-side mirror of the exports above. Keep the keys in sync. export const REMOTE_TOOLS_SHIM = `{ @@ -25,8 +26,11 @@ export const REMOTE_TOOLS_SHIM = `{ os: require('node:os'), cp: require('node:child_process'), once: require('node:events').once, - delay: require('node:timers/promises').setTimeout, + setTimeout: require('node:timers/promises').setTimeout, expect: require('chai').expect, + BrowserWindow: require('electron').BrowserWindow, + nativeImage: require('electron').nativeImage, + webContents: require('electron').webContents, }`; const SSR_IMPORT_RE = /__vite_ssr_import_\d+__/g;