fix(javascript): drop built-in effects for visibly replaced globals (#4565)

The semantic collectors admitted Node timers, fetch, WebSocket, JSON.parse
and global coercions from the identifier spelling alone, so a source that
replaced those globals (`setTimeout = () => null`) still produced resolved
built-in effects. Require the existing isUnmodifiedGlobalReference guard,
which the static analyzer already uses for fetch and WebSocket, before
admitting these effects.

The guard treats any visible possible write as a replacement without
evaluating order or reachability, so computed global writes and guarded
polyfills also remove the resolved effect. The workflow docs now say so.

Fixes #3367
This commit is contained in:
amyanger
2026-10-22 19:28:53 +01:00
committed by GitHub
parent de00fdc17d
commit 7436ac8e20
5 changed files with 150 additions and 6 deletions
@@ -37,6 +37,7 @@ import {
traverseDataEffects as traverseWithContext,
type DataEffectTraversalContext as TraversalContext,
} from "./javascriptSemanticDataEffectHelpers.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import { semanticPrimitiveSet } from "./javascriptSemanticPrimitives.js";
import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js";
@@ -404,7 +405,10 @@ const boundaryDetails = (
!semanticResolutionBlocked(state, node.callee, name);
if (
unbound &&
["String", "Number", "Boolean", "parseInt", "parseFloat"].includes(name)
["String", "Number", "Boolean", "parseInt", "parseFloat"].includes(
name,
) &&
isUnmodifiedGlobalReference(node.callee, state, name)
)
return { kind: "coerce", method: name, resolution: "complete" };
}
@@ -415,7 +419,8 @@ const boundaryDetails = (
method === "parse" &&
t.isIdentifier(member.object, { name: "JSON" }) &&
resolveSemanticBindingState(state, member.object, "JSON") === undefined &&
!semanticResolutionBlocked(state, member.object, "JSON")
!semanticResolutionBlocked(state, member.object, "JSON") &&
isUnmodifiedGlobalReference(node.callee, state, ["JSON", "parse"])
)
return { kind: "parse", method: "JSON.parse", resolution: "complete" };
if (method === "parse" || method === "safeParse")
@@ -434,9 +439,13 @@ const requestMethod = (
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";
if (
unbound &&
name === global &&
isUnmodifiedGlobalReference(node.callee, state, name)
)
return global;
}
const method = builtinMethod(node.callee, state, [
"http",