From 3be45116ab262dd2edde504975ef4fb48107f2ef Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:35:38 +0000 Subject: [PATCH] fix(javascript): stop classifying replaced globals as built-in effects Timer, request, coercion and JSON.parse effects recognized unbound globals without consulting visible source-local writes, so `setTimeout = f` or `self.fetch = f` still produced Node timer and fetch operations. The ambient write owner now classifies a global reference as unmodified, possibly replaced (a write under an unknown key, such as `globalThis[k] = v`), or replaced (an exact visible write). Replaced globals no longer produce built-in effects; possibly replaced ones keep the effect with partial resolution instead of dropping it. The member-specific `isUnmodifiedGlobalMember` helper is superseded and removed. Fixes #3367 Co-Authored-By: Claude Opus 5.5 --- .../javascriptSemanticAmbientWrites.ts | 107 +++++++++++++++--- ...tSemanticAnalysis.replaced_globals.test.ts | 57 ++++++++++ .../javascriptSemanticAsyncEffects.ts | 56 ++++++--- .../javascriptSemanticDataEffects.ts | 64 ++++++----- 4 files changed, 221 insertions(+), 63 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.replaced_globals.test.ts diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 3b52f2ccc..11543c492 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -14,6 +14,7 @@ import { resolveSemanticBindingState, semanticStableInitializer, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticMemberEffect, } from "./javascriptSemanticState.js"; import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js"; import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; @@ -289,29 +290,99 @@ export function* indexJavaScriptAmbientWritesSteps( } /** - * Recognize an ambient name or direct global-object property without a visible - * source-local overwrite. This is a syntax-local identity fact, not proof that - * the runtime's globals have never been patched by other code. + * How a global reference relates to visible source-local writes. A write under + * an unknown key may or may not select the name, so it leaves the built-in + * possible rather than replaced. */ +type SemanticGlobalReferenceState = + | "not-global" + | "unmodified" + | "possibly-replaced" + | "replaced"; + +/** Exact written paths, indexed once per collected ambient write list. */ +const exactAmbientWrites = new WeakMap< + readonly JavaScriptSemanticMemberEffect[], + { readonly length: number; readonly paths: ReadonlySet } +>(); + +const exactAmbientWritePaths = ( + state: JavaScriptSemanticAnalysisState, +): ReadonlySet => { + const effects = state.ambientWriteEffects; + const cached = exactAmbientWrites.get(effects); + if (cached?.length === effects.length) return cached.paths; + const paths = new Set(); + for (const { path } of effects) + if (path.every((key) => typeof key === "string" || typeof key === "number")) + paths.add(JSON.stringify(path.map(String))); + exactAmbientWrites.set(effects, { length: effects.length, paths }); + return paths; +}; + +/** Coverage is the prefix index; only an all-exact written prefix replaces. */ +const ambientPathWrite = ( + state: JavaScriptSemanticAnalysisState, + path: readonly string[], +): "none" | "possible" | "exact" => { + if (!state.ambientWrites.covers(path)) return "none"; + const exact = exactAmbientWritePaths(state); + for (let length = path.length; length > 0; length -= 1) + if (exact.has(JSON.stringify(path.slice(0, length)))) return "exact"; + return "possible"; +}; + +/** + * Classify an ambient name, direct global-object property, or member path of + * one, such as `JSON.parse`, against visible source-local overwrites. This is a + * syntax-local identity fact, not proof that the runtime's globals have never + * been patched by other code. + */ +const semanticGlobalReferenceState = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + name: string, + members: readonly string[] = [], +): SemanticGlobalReferenceState => { + const node = unwrapJavaScriptExpression(callee).node; + const paths = [["global", name, ...members]]; + if (!isUnshadowedGlobal(node, state, name)) { + if ( + !t.isMemberExpression(node) || + semanticStaticPropertyKey(node.property, node.computed) !== name + ) + return "not-global"; + const root = unwrapJavaScriptExpression(node.object).node; + const namespace = globalObjectName(root, state); + if (namespace === undefined) return "not-global"; + paths.push(["global", namespace]); + } + const writes = paths.map((path) => ambientPathWrite(state, path)); + if (writes.includes("exact")) return "replaced"; + return writes.includes("possible") ? "possibly-replaced" : "unmodified"; +}; + +/** Recognize a global reference without any visible source-local overwrite. */ export const isUnmodifiedGlobalReference = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, name: string, -): boolean => { - const node = unwrapJavaScriptExpression(callee).node; - if (state.ambientWrites.covers(["global", name])) return false; - if (isUnshadowedGlobal(node, state, name)) return true; - if ( - !t.isMemberExpression(node) || - semanticStaticPropertyKey(node.property, node.computed) !== name - ) - return false; - const root = unwrapJavaScriptExpression(node.object).node; - const namespace = globalObjectName(root, state); - return ( - namespace !== undefined && - !state.ambientWrites.covers(["global", namespace]) - ); +): boolean => + semanticGlobalReferenceState(callee, state, name) === "unmodified"; + +/** + * Resolve a built-in effect's identity: complete when unmodified, partial when + * an unknown-key write may have replaced it, and null when not the built-in. + */ +export const semanticBuiltinGlobalResolution = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + name: string, + members: readonly string[] = [], +): "complete" | "partial" | null => { + const reference = semanticGlobalReferenceState(callee, state, name, members); + if (reference === "unmodified") return "complete"; + return reference === "possibly-replaced" ? "partial" : null; }; /** A module URL base is known only while its property has no visible write. */ diff --git a/src/domain/javascript/javascriptSemanticAnalysis.replaced_globals.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.replaced_globals.test.ts new file mode 100644 index 000000000..fbb080000 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.replaced_globals.test.ts @@ -0,0 +1,57 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const effects = (source: string) => { + const ir = analyzeJavaScriptSemantics(source); + return { + timers: ir.timerOperations.map( + ({ method, resolution }) => `${method}:${resolution}`, + ), + requests: ir.requestOperations + .filter(({ kind }) => kind === "request") + .map(({ method, resolution }) => `${method}:${resolution}`), + parses: ir.boundaryOperations + .filter(({ kind }) => kind === "parse") + .map(({ method, resolution }) => `${method}:${resolution}`), + }; +}; + +const calls = ` + function work() {} + setTimeout(work, 100); + fetch("https://example.invalid"); + JSON.parse("null"); +`; + +it("does not classify calls to replaced globals as built-in effects", () => { + expect( + effects(` + setTimeout = () => null; + fetch = () => null; + JSON.parse = () => null; + ${calls} + `), + ).toEqual({ timers: [], requests: [], parses: ["parse:partial"] }); +}); + +it("keeps built-in effects partial after a write under an unknown key", () => { + expect( + effects(` + globalThis[Symbol.for("registry")] = new Map(); + ${calls} + `), + ).toEqual({ + timers: ["setTimeout:partial"], + requests: ["fetch:partial"], + parses: ["JSON.parse:partial"], + }); +}); + +it("keeps built-in effects for unmodified globals", () => { + expect(effects(calls)).toEqual({ + timers: ["setTimeout:complete"], + requests: ["fetch:complete"], + parses: ["JSON.parse:complete"], + }); +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index a34bfb750..e819ae128 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -1,5 +1,6 @@ import * as t from "@babel/types"; +import { semanticBuiltinGlobalResolution } from "./javascriptSemanticAmbientWrites.js"; import type { JavaScriptSemanticCallable, JavaScriptSemanticEventOperation, @@ -68,6 +69,8 @@ interface TimerCandidate { readonly node: t.CallExpression | t.OptionalCallExpression; readonly method: JavaScriptSemanticTimerOperation["method"]; readonly kind: JavaScriptSemanticTimerOperation["kind"]; + /** Partial when an unknown-key global write may have replaced the timer. */ + readonly identity: "complete" | "partial"; readonly ownerCallableId: string | null; readonly handleBindingId: string | null; readonly projectedResultBindings: readonly { @@ -209,8 +212,9 @@ const emitterIdentity = ( const collectTimer = (input: TimerCollectionInput): void => { const { node, ownerCallableId, state, output, ancestors } = input; - const method = timerMethod(node.callee, state); - if (method === null) return; + const timer = timerMethod(node.callee, state); + if (timer === null) return; + const { method, identity } = timer; const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) ? "schedule" : "cancel"; @@ -223,6 +227,7 @@ const collectTimer = (input: TimerCollectionInput): void => { node, method, kind, + identity, ownerCallableId, handleBindingId: kind === "schedule" @@ -275,38 +280,53 @@ const resolveTimers = ( ? timerId(linked[0]) : null, delayMilliseconds: timerDelay(candidate), - resolution: - candidate.kind === "schedule" - ? "complete" - : linked.length === 1 && - !linked[0]?.projectedResultBindings.some( - ({ bindingId }) => bindingId === candidate.handleBindingId, - ) - ? "complete" - : linked.length > 1 - ? "partial" - : "unresolved", + resolution: timerResolution(candidate, linked), }; }); }; +const timerResolution = ( + candidate: TimerCandidate, + linked: readonly TimerCandidate[], +): JavaScriptSemanticTimerOperation["resolution"] => { + const resolution = + candidate.kind === "schedule" + ? "complete" + : linked.length === 1 && + !linked[0]?.projectedResultBindings.some( + ({ bindingId }) => bindingId === candidate.handleBindingId, + ) + ? "complete" + : linked.length > 1 + ? "partial" + : "unresolved"; + return resolution === "complete" && candidate.identity === "partial" + ? "partial" + : resolution; +}; + const timerMethod = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, -): JavaScriptSemanticTimerOperation["method"] | null => { +): Pick | null => { if (t.isIdentifier(callee)) { const binding = resolveSemanticBindingState(state, callee, callee.name); if (binding === undefined) { const method = admittedTimerMethod(callee.name); if (method === null) return null; - return semanticResolutionBlocked(state, callee, callee.name) - ? null - : method; + // A visibly replaced global no longer schedules a Node timer. + const identity = semanticBuiltinGlobalResolution( + callee, + state, + callee.name, + ); + return identity === null ? null : { method, identity }; } } - return admittedTimerMethod( + const method = admittedTimerMethod( builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "", ); + return method === null ? null : { method, identity: "complete" }; }; const admittedTimerMethod = ( value: string, diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index 6d1de8325..7eb1a33d8 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -1,5 +1,6 @@ import * as t from "@babel/types"; +import { semanticBuiltinGlobalResolution } from "./javascriptSemanticAmbientWrites.js"; import type { JavaScriptSemanticBoundaryOperation, JavaScriptSemanticCallable, @@ -14,7 +15,6 @@ import { } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, - semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { @@ -202,8 +202,9 @@ const collectRequests = ( !t.isNewExpression(node) ) return; - const method = requestMethod(node, context.state); - if (method === null) return; + const request = requestMethod(node, context.state); + if (request === null) return; + const { method, identity } = request; const endpoint = requestEndpoint(node); output.push({ node, @@ -224,7 +225,8 @@ const collectRequests = ( linkedRequestIds: [], endpoint, fields: requestFields(node, method, context.state), - resolution: endpoint === null ? "partial" : "complete", + resolution: + endpoint === null || identity === "partial" ? "partial" : "complete", }, }); }); @@ -340,25 +342,27 @@ const boundaryDetails = ( } | null => { if (t.isIdentifier(node.callee)) { const name = node.callee.name; - const unbound = - resolveSemanticBindingState(state, node.callee, name) === undefined && - !semanticResolutionBlocked(state, node.callee, name); - if ( - unbound && - ["String", "Number", "Boolean", "parseInt", "parseFloat"].includes(name) - ) - return { kind: "coerce", method: name, resolution: "complete" }; + const resolution = [ + "String", + "Number", + "Boolean", + "parseInt", + "parseFloat", + ].includes(name) + ? semanticBuiltinGlobalResolution(node.callee, state, name) + : null; + if (resolution !== null) + return { kind: "coerce", method: name, resolution }; } const member = memberCallee(node); if (member === null) return null; const method = semanticStaticPropertyName(member.property, member.computed); - if ( - method === "parse" && - t.isIdentifier(member.object, { name: "JSON" }) && - resolveSemanticBindingState(state, member.object, "JSON") === undefined && - !semanticResolutionBlocked(state, member.object, "JSON") - ) - return { kind: "parse", method: "JSON.parse", resolution: "complete" }; + const jsonParse = + method === "parse" && t.isIdentifier(member.object, { name: "JSON" }) + ? semanticBuiltinGlobalResolution(member.object, state, "JSON", ["parse"]) + : null; + if (jsonParse !== null) + return { kind: "parse", method: "JSON.parse", resolution: jsonParse }; if (method === "parse" || method === "safeParse") return { kind: "parse", method, resolution: "partial" }; if (method === "validate" || method === "assert" || method === "is") @@ -369,15 +373,19 @@ const boundaryDetails = ( const requestMethod = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, state: JavaScriptSemanticAnalysisState, -): JavaScriptSemanticRequestOperation["method"] | null => { +): { + readonly method: JavaScriptSemanticRequestOperation["method"]; + readonly identity: "complete" | "partial"; +} | null => { if (t.isIdentifier(node.callee)) { const name = node.callee.name; - const unbound = - resolveSemanticBindingState(state, node.callee, name) === undefined && - !semanticResolutionBlocked(state, node.callee, name); - if (!t.isNewExpression(node) && unbound && name === "fetch") return "fetch"; - if (t.isNewExpression(node) && unbound && name === "WebSocket") - return "WebSocket"; + const global = t.isNewExpression(node) ? "WebSocket" : "fetch"; + // A visibly replaced global is no longer the built-in request API. + const identity = + name === global + ? semanticBuiltinGlobalResolution(node.callee, state, name) + : null; + if (identity !== null) return { method: global, identity }; } const method = builtinMethod(node.callee, state, [ "http", @@ -385,7 +393,9 @@ const requestMethod = ( "node:http", "node:https", ]); - return method === "request" || method === "get" ? method : null; + return method === "request" || method === "get" + ? { method, identity: "complete" } + : null; }; const requestFields = (