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).
This commit is contained in:
Samuel Attard
2026-04-12 03:00:35 -07:00
parent 36e5f6a2a5
commit 0d321ccae0
3 changed files with 166 additions and 3 deletions

View File

@@ -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"
}
},
{

View File

@@ -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 };
}
// <anything>.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()
);
}
};
}
}
}
};

View File

@@ -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;