fix(javascript): prove namespace interop before attributing Electron APIs (#1857)

* fix(javascript): prove Electron module namespace conversion

* refactor(javascript): share AST child field metadata

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>

* fix(javascript): retain destructured intrinsic writes

Share one pattern-target walker across lexical definitions, member mutation
tracking and Electron identity writes. Keep overwritten globals unresolved
without treating property keys or shadowed locals as global replacements.

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>

* fix(javascript): preserve intrinsic projections and fresh target identity

Resolve known mutators through static binding projections and prove stable
identifier chains to fresh literals before widening unknown mutation targets.
Keep reassigned, uninitialized and member-projected targets conservative.
Cover both review regressions through source tests and inert CLI/MCP fixtures.

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>

* fix(javascript): retain fresh function and class target identity

Recognize stable local function, undecorated class and RegExp targets before
widening intrinsic mutations. Keep decorated classes opaque because their
binding value can be replaced, and preserve class-initialization writes.
Cover fresh targets and replacement decorators in inert CLI/MCP fixtures.

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>

* fix(javascript): preserve intrinsic alias capture timing

Validate each alias read after initialization and retain the original function capture across copies. Share declaration initializer facts with RPC and Electron analysis, including class declarations used for fresh-target identity. Accept single-statement control blocks while keeping lexical roles and added operations checked. Cover the capture regressions through inert source and public CLI/MCP workflows.

* fix(javascript): respect hoisted fresh function declarations

Function declarations provide fresh targets before their textual position. Retain initialization-order checks for variable and class values, including aliases. Cover the reviewed regression with source controls and the public CLI/MCP fixture.

* fix(javascript): preserve proven interop result projections

* 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>

* fix(javascript): retain bound intrinsic mutation effects

* fix(javascript): preserve bound receivers for intrinsic invocation

* fix(javascript): prove generic binding and invocation effects

* fix(javascript): retain Reflect set receiver effects

---------

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>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 01:06:33 +08:00
1 parent 1769417001
commit 9b3e59804d
15 files changed
+2043 -221

No files matched your search

@@ -18,6 +18,7 @@ import {
import {
semanticObjectPatternKeys,
semanticStaticPropertyKey,
semanticWriteTargets,
} from "./javascriptAstValues.js";
import type {
JavaScriptSemanticAnalysisState,
@@ -31,16 +32,14 @@ import {
resolveSemanticBindingFromScope,
semanticBindingId,
semanticScopeId,
semanticStableInitializer,
semanticVariableScope,
} from "./javascriptSemanticState.js";
import {
completeSemanticSteps,
traverseJavaScriptAstSteps,
} from "./javascriptSemanticTraversal.js";
import {
semanticNamespaceInteropOperand,
semanticRequireOrigin,
} from "./javascriptSemanticRequireOrigin.js";
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
import {
collectSemanticReturns,
@@ -49,7 +48,7 @@ import {
import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDerivedAnalysis.js";
import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js";
import {
collectElectronMemberWritesSteps,
collectJavaScriptIdentityWritesSteps,
electronExportPath,
electronMemberPath,
ELECTRON_MODULE,
@@ -64,6 +63,8 @@ import {
} from "./javascriptSourceParser.js";
import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js";
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js";
interface BindPatternInput {
readonly pattern: t.Node;
@@ -205,13 +206,10 @@ export function* classifyParsedJavaScriptRpcClientRootsSteps(
if (binding === undefined) return;
let client = clients.get(binding.bindingId);
if (client === undefined) {
const [initializer] = binding.initializers;
const initializer = semanticStableInitializer(binding, state);
client =
initializer !== undefined &&
initializer.projection.length === 0 &&
binding.initializers.length === 1 &&
!binding.definitions.some(({ kind }) => kind === "assignment") &&
!state.conditionalInitializers.has(initializer.node) &&
isJavaScriptRpcClientFactory(initializer.node);
clients.set(binding.bindingId, client);
}
@@ -374,6 +372,9 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
const facts = new Map<number, readonly string[]>();
const state = createState(file.program);
yield* collectDefinitionsSteps(file.program, state);
const interop = createSemanticModuleInterop(state);
const writes = new SemanticPropertyPathCoverage();
let collectedWrites = false;
const exportsByBinding = new Map<string, readonly string[] | null>();
const rootExport = (root: t.Node): readonly string[] | undefined => {
if (t.isIdentifier(root)) {
@@ -382,23 +383,45 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
if (binding === undefined) return undefined;
let exported = exportsByBinding.get(binding.bindingId);
if (exported === undefined) {
exported = electronExport(binding, state);
exported = electronExport(binding, state, interop);
exportsByBinding.set(binding.bindingId, exported);
}
return exported ?? undefined;
return collectedWrites && interop.invalidated(binding)
? undefined
: (exported ?? undefined);
}
const origin = semanticRequireOrigin(
semanticNamespaceInteropOperand(root, state) ?? root,
state,
)?.origin;
const wrapped = interop.callOrigin(root);
const origin =
wrapped === undefined
? semanticRequireOrigin(root, state)?.origin
: collectedWrites && interop.invalidatedCall(root)
? undefined
: wrapped.origin;
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
? electronExportPath(origin.importedPath)
: undefined;
};
const writes = yield* collectElectronMemberWritesSteps(
yield* collectJavaScriptIdentityWritesSteps(
file.program,
rootExport,
(root, kind) => {
if (kind === "opaque-call") return interop.opaqueMutationPath(root);
const exported = kind === "binding" ? undefined : rootExport(root);
return exported === undefined
? (interop.intrinsicPath(root) ??
(kind === "member"
? interop.boundIntrinsic(root)?.path
: undefined))
: ["electron", ...exported];
},
interop.intrinsicInvocation,
interop.staticString,
interop.isFreshObject,
(node, path) => {
if (path[0] === "electron") writes.retain(path);
else interop.retainWrite(node, path);
},
);
collectedWrites = true;
yield* traverseJavaScriptAstSteps(file.program, {
enter: (node) => {
if (
@@ -409,7 +432,10 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
return;
const { root, members } = electronMemberPath(node.callee);
const exported = rootExport(root);
if (exported !== undefined && !writes.covers([...exported, ...members]))
if (
exported !== undefined &&
!writes.covers(["electron", ...exported, ...members])
)
facts.set(root.start ?? -1, exported);
},
});
@@ -421,19 +447,16 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
const electronExport = (
binding: JavaScriptSemanticBindingState,
state: JavaScriptSemanticAnalysisState,
interop: ReturnType<typeof createSemanticModuleInterop>,
): readonly string[] | null => {
const initializers = namespacePreservingInitializers(binding, state);
if (initializers === undefined) return null;
const directOrigin = bindingOrigin(binding, initializers, state);
const [initializer] = initializers;
if (
directOrigin === undefined &&
(initializer === undefined ||
initializers.length !== 1 ||
initializers.length !== binding.initializers.length ||
state.conditionalInitializers.has(initializer.node))
)
return null;
const reassigned = binding.definitions.some(
({ kind }) => kind === "assignment",
);
const converted = interop.origin(binding);
if (reassigned && converted === undefined) return null;
const directOrigin = converted?.origin ?? bindingOrigin(binding, state);
const initializer = semanticStableInitializer(binding, state);
if (directOrigin === undefined && initializer === undefined) return null;
const provenance =
directOrigin === undefined
? evaluateSemanticProvenance(binding, state)
@@ -448,66 +471,17 @@ const electronExport = (
: null;
};
/**
* Return the initializers that can change which module a binding names.
* Bundlers emit `name = __toESM(name)` after a CommonJS require; rewrapping
* the binding's own value keeps every member, so it is not a reassignment.
*/
const namespacePreservingInitializers = (
binding: JavaScriptSemanticBindingState,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticBindingState["initializers"] | undefined => {
const initializers = binding.initializers.filter(
({ node }) => !rewrapsOwnNamespace(binding, node, state),
);
const rewraps = binding.initializers.length - initializers.length;
const assignments = binding.definitions.filter(
({ kind }) => kind === "assignment",
).length;
return assignments === rewraps ? initializers : undefined;
};
const rewrapsOwnNamespace = (
binding: JavaScriptSemanticBindingState,
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): boolean => {
const assignment = state.parentsByNode.get(node);
const operand = semanticNamespaceInteropOperand(node, state);
return (
t.isAssignmentExpression(assignment) &&
assignment.operator === "=" &&
assignment.right === node &&
t.isIdentifier(assignment.left, { name: binding.name }) &&
t.isIdentifier(operand, { name: binding.name }) &&
!semanticResolutionBlocked(state, operand, operand.name) &&
resolveSemanticBindingState(state, operand, operand.name) === binding
);
};
const bindingOrigin = (
binding: JavaScriptSemanticBindingState,
initializers: JavaScriptSemanticBindingState["initializers"],
state: JavaScriptSemanticAnalysisState,
): JavaScriptModuleOrigin | undefined => {
if (binding.directOrigins.length > 0)
return binding.directOrigins.length === 1
? binding.directOrigins[0]
: undefined;
const [initializer] = initializers;
// A var/let declaration permits writes without proving one occurred.
// Require one unconditional initializer and no actual binding writes.
if (
initializer === undefined ||
initializers.length !== 1 ||
state.conditionalInitializers.has(initializer.node)
)
return undefined;
const origin = semanticRequireOrigin(
semanticNamespaceInteropOperand(initializer.node, state) ??
initializer.node,
state,
)?.origin;
const initializer = semanticStableInitializer(binding, state);
if (initializer === undefined) return undefined;
const origin = semanticRequireOrigin(initializer.node, state)?.origin;
const projection = initializer.projection;
return origin === undefined ||
!projection.every((key): key is string => typeof key === "string")
@@ -650,10 +624,11 @@ function* collectDefinitionsSteps(
for (const [index, { body, scope, parameters }] of functionBodies.entries()) {
if (index % 64 === 0) yield;
for (const parameter of parameters)
for (const identifier of assignedPatternIdentifiers(
for (const identifier of semanticWriteTargets(
t.isTSParameterProperty(parameter) ? parameter.parameter : parameter,
))
copyParameterToBody({ identifier, body, scope, state });
if (t.isIdentifier(identifier))
copyParameterToBody({ identifier, body, scope, state });
}
}
@@ -801,16 +776,18 @@ const bindAssignment = (
state,
);
else
for (const identifier of assignedPatternIdentifiers(node.left))
addAssignment(identifier, node, scope, state);
for (const identifier of semanticWriteTargets(node.left))
if (t.isIdentifier(identifier))
addAssignment(identifier, node, scope, state);
} else if (t.isUpdateExpression(node) && t.isIdentifier(node.argument))
addAssignment(node.argument, node, scope, state);
else if (t.isForOfStatement(node) || t.isForInStatement(node))
for (const pattern of t.isVariableDeclaration(node.left)
? node.left.declarations.map(({ id }) => id)
: [node.left]) {
for (const identifier of assignedPatternIdentifiers(pattern))
addAssignment(identifier, node, scope, state);
for (const identifier of semanticWriteTargets(pattern))
if (t.isIdentifier(identifier))
addAssignment(identifier, node, scope, state);
if (t.isForOfStatement(node))
// Keep the loop value unknown, but preserve references yielded into
// each bound slot so writes/escapes can reach their iterable origins.
@@ -825,30 +802,6 @@ const bindAssignment = (
}
};
const assignedPatternIdentifiers = (
pattern: t.Node,
): readonly t.Identifier[] => {
const identifiers: t.Identifier[] = [];
const pending = [pattern];
while (pending.length > 0) {
const node = pending.pop();
if (node === undefined) continue;
if (t.isIdentifier(node)) identifiers.push(node);
else if (t.isRestElement(node)) pending.push(node.argument);
else if (t.isAssignmentPattern(node)) pending.push(node.left);
else if (t.isArrayPattern(node)) {
for (const element of node.elements)
if (element !== null) pending.push(element);
} else if (t.isObjectPattern(node)) {
for (const property of node.properties)
pending.push(
t.isRestElement(property) ? property.argument : property.value,
);
}
}
return identifiers;
};
const nestedScope = (
node: t.Node,
parent: t.Node | null,