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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-15 04:51:27 +00:00
1 parent 788964d52c
commit 3be45116ab
4 files changed
+221 -63

No files matched your search

@@ -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<string> }
>();
const exactAmbientWritePaths = (
state: JavaScriptSemanticAnalysisState,
): ReadonlySet<string> => {
const effects = state.ambientWriteEffects;
const cached = exactAmbientWrites.get(effects);
if (cached?.length === effects.length) return cached.paths;
const paths = new Set<string>();
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. */
@@ -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"],
});
});
@@ -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<TimerCandidate, "method" | "identity"> | 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,
@@ -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 = (