fix(javascript): retain stable declarations and bound intrinsic writes

Keep repeated variable declarations with one unconditional initializer while
rejecting assignments, conditional initialization and multiple values.
Normalize known intrinsic calls, aliases and bound argument prefixes at the
semantic owner, retaining opaque targets without tainting unused bindings or
fresh local objects. Cover both regressions through source and CLI/MCP tests.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
N0zoM1z0
2026-10-11 16:25:15 +00:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Kaoru0822
parent 3d1d5b56b2
commit 2a9fdde5db
10 changed files with 239 additions and 37 deletions
+6 -1
View File
@@ -343,7 +343,12 @@ writes inside directly invoked local functions, are not evaluated. Other
reassignments, dynamic selections, and visibly overwritten namespace members
are unresolved.
Known Object/Reflect mutators remain recognizable through literal destructuring
of intrinsic objects. Stable identifier chains to fresh object, array, function,
of intrinsic objects and stable bound-function aliases. Calls retain bound
argument prefixes through `call`, `apply`, and `Reflect.apply`; unexamined
targets remain unresolved. Creating an unused binding does not itself mutate
an intrinsic. Variable redeclarations retain a single unconditional initializer
when they introduce no assignment or second initializer.
Stable identifier chains to fresh object, array, function,
undecorated class or RegExp syntax keep those targets separate from intrinsic
objects when initialized before use; opaque, decorated, reassigned or
not-yet-initialized targets remain unresolved. Writes during class initialization
@@ -505,3 +505,45 @@ describe("Electron module interop capture and scope", () => {
expect(analysis.electron.browser_windows).toHaveLength(1);
});
});
describe("Electron module interop bound mutators", () => {
it.each([
'const change = Object.defineProperty.bind(null); change(Object,"create",{value:customCreate});',
'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});',
'const first = Object.defineProperty.bind(null,Object); const change = first; change("create",{value:customCreate});',
'const change = Object.defineProperty.bind(null,Object); change.call(null,"create",{value:customCreate});',
'const change = Object.defineProperty.bind(null,Object); change.apply(null,["create",{value:customCreate}]);',
'const change = Object.defineProperty.bind(null,Object); Reflect.apply(change,null,["create",{value:customCreate}]);',
'Object.defineProperty.bind(null,Object,"create")({value:customCreate});',
'const change = Object.defineProperty.bind(null,...args); change(Object,"create",{value:customCreate});',
'const metadata = {}; Object.defineProperty.call(...args,metadata,"x",{value:1});',
'const metadata = {}; Object.defineProperty.apply(...args,[metadata,"x",{value:1}]);',
'const change = Reflect.set.bind(null,Object,"create"); change(customCreate);',
'const change = Object.defineProperty.call; change(null,Object,"create",{value:customCreate});',
'const {apply:change} = Object.defineProperty; change(null,[Object,"create",{value:customCreate}]);',
'const change = Reflect.apply.call; change(null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);',
])("rejects intrinsic writes through a bound mutator: %s", (write) => {
expect(
analyzeJavaScriptStaticSource(
`${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`,
).electron.browser_windows,
).toEqual([]);
});
it.each([
'const change = Object.defineProperty.bind(null); change(metadata,"x",{value:1});',
'const change = Object.defineProperty.bind(null,metadata,"x"); change({value:1});',
'const first = Object.defineProperty.bind(null,metadata); const change = first.bind(null,"x"); change({value:1});',
'const change = Object.defineProperty.bind(null,Object,"create");',
'Object.defineProperty.call(null,metadata,"x",{value:1});',
'Object.defineProperty.apply(null,[metadata,"x",{value:1}]);',
'const change = Object.defineProperty.call; change(null,metadata,"x",{value:1});',
'const {apply:change} = Object.defineProperty; change(null,[metadata,"x",{value:1}]);',
])("keeps a fresh bound target or unused binding separate: %s", (write) => {
expect(
analyzeJavaScriptStaticSource(
`const metadata = {}; ${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`,
).electron.browser_windows,
).toHaveLength(1);
});
});
@@ -143,4 +143,30 @@ describe("Electron API identity", () => {
analysis.electron.browser_windows.map(({ preload_path }) => preload_path),
).toEqual(["./const.js", "./let.js", "./destructured.js"]);
});
it("preserves one initializer across variable redeclarations without accepting writes", () => {
const analysis = analyzeJavaScriptStaticSource(`
var electron;
var electron = require("electron");
var electron;
new electron.BrowserWindow({webPreferences:{preload:"./redeclared.js"}});
var replaced;
var replaced = require("electron");
replaced = fake;
new replaced.BrowserWindow({webPreferences:{preload:"./replaced.js"}});
var initializedTwice = require("electron");
var initializedTwice = fake;
new initializedTwice.BrowserWindow({});
var conditional;
if (flag) { var conditional = require("electron"); }
new conditional.BrowserWindow({});
`);
expect(
analysis.electron.browser_windows.map(({ preload_path }) => preload_path),
).toEqual(["./redeclared.js"]);
});
});
@@ -42,6 +42,14 @@ export const electronMemberPath = (
return { root, members: members.toReversed() };
};
/** A known intrinsic callable and its examined positional arguments. */
export interface JavaScriptIntrinsicInvocation {
readonly path: readonly string[];
readonly arguments:
| readonly t.CallExpression["arguments"][number][]
| undefined;
}
/** Index member and binding writes through caller-proven lexical identities. */
export function* collectJavaScriptIdentityWritesSteps(
program: t.Program,
@@ -49,7 +57,9 @@ export function* collectJavaScriptIdentityWritesSteps(
root: t.Node,
kind: "binding" | "member" | "opaque-call",
) => readonly string[] | undefined,
intrinsic: (node: t.Node) => readonly string[] | undefined,
intrinsicInvocation: (
node: t.CallExpression | t.OptionalCallExpression,
) => JavaScriptIntrinsicInvocation | undefined,
freshObject: (node: t.Node) => boolean,
retain: (node: t.Node, path: readonly (string | null)[]) => void,
): Generator<void> {
@@ -69,39 +79,16 @@ export function* collectJavaScriptIdentityWritesSteps(
yield* traverseJavaScriptAstSteps(program, {
enter: (node) => {
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
let callee = intrinsic(node.callee);
let arguments_ = node.arguments;
let applied: t.Node | undefined;
let usesApply = false;
if (callee?.join(".") === "intrinsic.Reflect.apply") {
usesApply = true;
const callable = arguments_[0];
callee = t.isNode(callable) ? intrinsic(callable) : undefined;
applied = arguments_[2];
}
if (callee?.at(-1) === "call") {
callee = callee.slice(0, -1);
arguments_ = arguments_.slice(1);
} else if (callee?.at(-1) === "apply") {
usesApply = true;
callee = callee.slice(0, -1);
applied = arguments_[1];
}
if (usesApply)
arguments_ =
t.isArrayExpression(applied) &&
applied.elements.every((element) => t.isExpression(element))
? applied.elements.filter((element): element is t.Expression =>
t.isExpression(element),
)
: [];
const invocation = intrinsicInvocation(node);
const callee = invocation?.path;
const arguments_ = invocation?.arguments;
const method = callee?.at(-1);
const owner = callee?.length === 3 ? callee[1] : undefined;
if (
(owner === "Object" && objectMutators.has(method ?? "")) ||
(owner === "Reflect" && reflectMutators.has(method ?? ""))
) {
const target = arguments_[0];
const target = arguments_?.[0];
// A known mutator with an opaque argument list can overwrite any
// captured intrinsic. Do not turn an unexamined target into proof.
if (target === undefined || t.isSpreadElement(target)) {
@@ -113,7 +100,7 @@ export function* collectJavaScriptIdentityWritesSteps(
const exported = origin(root, "member");
if (exported !== undefined) {
const key = keyedMutators.has(method ?? "")
? [stringValue(arguments_[1]) ?? null]
? [stringValue(arguments_?.[1]) ?? null]
: [];
retain(node, [...exported, ...members, ...key]);
} else if (!freshObject(target)) {
@@ -0,0 +1,103 @@
import * as t from "@babel/types";
import type { JavaScriptIntrinsicInvocation } from "./javascriptElectronMemberWrites.js";
import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
resolveSemanticBindingState,
semanticStableInitializer,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
const arrayArguments = (array: t.Node | undefined) =>
t.isArrayExpression(array) &&
array.elements.every((element) => t.isExpression(element))
? array.elements.filter((element): element is t.Expression =>
t.isExpression(element),
)
: undefined;
const invocationArguments = (
method: "call" | "apply",
arguments_: JavaScriptIntrinsicInvocation["arguments"],
) =>
t.isSpreadElement(arguments_?.[0])
? undefined
: method === "apply"
? arrayArguments(arguments_?.[1])
: arguments_?.slice(1);
/**
* Normalize known invocation syntax once, retaining bound argument prefixes.
* Binding a function alone is not a write; only its eventual call is examined.
*/
export const resolveJavaScriptIntrinsicInvocation = (
node: t.CallExpression | t.OptionalCallExpression,
state: JavaScriptSemanticAnalysisState,
intrinsicPath: (node: t.Node) => readonly string[] | undefined,
): JavaScriptIntrinsicInvocation | undefined => {
const visited = new Set<string>();
let callee: t.Node = node.callee;
let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments;
for (;;) {
callee = unwrapJavaScriptExpression(callee).node;
let path = intrinsicPath(callee);
// Static projections can capture call/apply themselves as aliases.
while (path !== undefined && path.length > 3) {
const method = path.at(-1);
if (method !== "call" && method !== "apply") break;
arguments_ = invocationArguments(method, arguments_);
path = path.slice(0, -1);
}
if (path?.join(".") === "intrinsic.Reflect.apply") {
const callable = arguments_?.[0];
if (!t.isExpression(callable)) return undefined;
callee = callable;
arguments_ = arrayArguments(arguments_?.[2]);
continue;
}
if (path !== undefined) return { path, arguments: arguments_ };
if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) {
const method = semanticStaticPropertyKey(
callee.property,
callee.computed,
);
if (method === "call" || method === "apply") {
callee = callee.object;
arguments_ = invocationArguments(method, arguments_);
continue;
}
}
if (t.isCallExpression(callee) || t.isOptionalCallExpression(callee)) {
const binder: t.Node = unwrapJavaScriptExpression(callee.callee).node;
if (
(!t.isMemberExpression(binder) &&
!t.isOptionalMemberExpression(binder)) ||
semanticStaticPropertyKey(binder.property, binder.computed) !== "bind"
)
return undefined;
const prefix = callee.arguments.slice(1);
arguments_ = t.isSpreadElement(callee.arguments[0])
? undefined
: prefix.length === 0
? arguments_
: [...prefix, ...(arguments_ ?? [])];
callee = binder.object;
continue;
}
if (!t.isIdentifier(callee)) return undefined;
const binding = resolveSemanticBindingState(state, callee, callee.name);
const initializer = semanticStableInitializer(binding, state);
if (
binding === undefined ||
initializer === undefined ||
initializer.projection.length !== 0 ||
visited.has(binding.bindingId)
)
return undefined;
visited.add(binding.bindingId);
callee = initializer.node;
}
};
@@ -16,6 +16,7 @@ import {
javaScriptAstChildKeys,
} from "./javascriptSemanticTraversal.js";
import { electronMemberPath } from "./javascriptElectronMemberWrites.js";
import { resolveJavaScriptIntrinsicInvocation } from "./javascriptIntrinsicInvocation.js";
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js";
import {
readExactJavaScriptLiteral,
@@ -441,6 +442,8 @@ export const createSemanticModuleInterop = (
origin,
callOrigin,
intrinsicPath,
intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) =>
resolveJavaScriptIntrinsicInvocation(node, state, intrinsicPath),
isFreshObject,
opaqueMutationPath,
retainWrite: (node: t.Node, path: readonly (string | null)[]) => {
@@ -195,6 +195,32 @@ describe("tRPC binding identity and bundled syntax", () => {
]);
});
it("retains one client initializer across variable redeclarations", () => {
expect(
rpcEndpoints(`
var c;
var c = createTRPCClient({});
var c;
c.users.query();
var replaced;
var replaced = createTRPCClient({});
replaced = pool;
replaced.posts.query();
var initializedTwice = createTRPCClient({});
var initializedTwice = pool;
initializedTwice.comments.query();
var conditional;
if (flag) { var conditional = createTRPCClient({}); }
conditional.groups.query();
`),
).toEqual([
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
]);
});
it("recognizes surviving factory names inside bundled call wrappers", () => {
expect(
rpcEndpoints(`
@@ -410,7 +410,7 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
? interop.intrinsicPath(root)
: ["electron", ...exported];
},
interop.intrinsicPath,
interop.intrinsicInvocation,
interop.isFreshObject,
(node, path) => {
if (path[0] === "electron") writes.retain(path);
@@ -77,6 +77,7 @@ export interface JavaScriptSemanticAnalysisState {
/**
* Return one unconditional source initializer for a declared lexical binding.
* Repeated variable declarations without another initializer do not write it.
* This establishes a stable source fact only; it does not prove immutability or
* that the declaration has executed at a particular program point.
*/
@@ -87,10 +88,10 @@ export const semanticStableInitializer = (
const [initializer] = binding?.initializers ?? [];
const [definition] = binding?.definitions ?? [];
return binding !== undefined &&
binding.definitions.length === 1 &&
(definition?.kind === "variable" ||
definition?.kind === "function" ||
definition?.kind === "class") &&
((definition?.kind === "variable" &&
binding.definitions.every(({ kind }) => kind === "variable")) ||
(binding.definitions.length === 1 &&
(definition?.kind === "function" || definition?.kind === "class"))) &&
binding.initializers.length === 1 &&
initializer !== undefined &&
!state.conditionalInitializers.has(initializer.node)
@@ -32,6 +32,10 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
"decorated.cjs",
'@(() => Object) class metadata {}; Object.defineProperty(metadata,"create",{value:customCreate});',
],
[
"bound.cjs",
'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});',
],
] as const)
await writeFile(
join(root, name),
@@ -44,6 +48,8 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
);
const source = `const metadata = {};
Object.defineProperty(metadata,"x",{value:1});
const decorateMetadata = Object.defineProperty.bind(null,metadata,"bound");
decorateMetadata({value:1});
const localFunction = () => {};
Object.defineProperty(localFunction,"x",{value:1});
const localClass = class {};
@@ -59,7 +65,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
electron = __toESM(electron, 1);
new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}});
new electron.BrowserWindow({webPreferences:{preload:runtimePath}});
const direct = require("electron");
var direct;
var direct = require("electron");
var direct;
new direct.BrowserWindow({});
const fresh = __toESM(require("electron"), 1);
new fresh.BrowserWindow({});
@@ -77,6 +85,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
require("./poisoned.cjs");
require("./projected.cjs");
require("./decorated.cjs");
require("./bound.cjs");
throw new Error("must not execute main");
`;
await writeFile(join(root, "main.cjs"), source);
@@ -103,7 +112,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
preload_entrypoints: 1,
});
expect(result.statistics.parse_failures).toBe(0);
expect(result.statistics.parsed_javascript_files).toBe(5);
expect(result.statistics.parsed_javascript_files).toBe(6);
const windows = result.graph.nodes.filter(
({ kind }) => kind === "browser-window",
);