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 authored and GitHub committed 2026-10-23 02:28:53 +08:00
1 parent de00fdc17d
commit 7436ac8e20
5 files changed
+150 -6

No files matched your search

+5
View File
@@ -183,6 +183,11 @@ registrations/removals and dispatch, Node timers and cancellation handles, async
`node:child_process` creation and ownership, configuration sources and direct
defaults, request construction and response consumers, parse/coercion/
validation boundaries, and built-in resource acquisition/release.
Global timer, `fetch`, `WebSocket`, `JSON.parse`, and coercion calls receive
these built-in effects only when the file has no visible possible write to that
global, including a computed write through a global object. The check does not
evaluate order or reachability, so a later or guarded polyfill write also
removes the resolved effect.
Local callback candidates do not establish a complete target set when a
conditional write may leave a parameter, catch binding, or uninitialized
@@ -218,3 +218,61 @@ describe("JavaScript semantic analysis: event dispatch data", () => {
]);
});
});
describe("JavaScript semantic analysis: replaced built-in globals", () => {
it.each([
["setTimeout", "setTimeout = local; setTimeout(work, 1);", "timer"],
[
"globalThis.setTimeout",
"globalThis.setTimeout = local; setTimeout(work, 1);",
"timer",
],
["fetch", "fetch = local; fetch('https://example.test');", "request"],
[
"WebSocket",
"WebSocket = local; new WebSocket('wss://example.test');",
"request",
],
// A replaced JSON.parse keeps only the generic, partial `.parse` candidate.
["JSON.parse", "JSON.parse = local; JSON.parse('null');", "boundary"],
["JSON", "JSON = { parse: local }; JSON.parse('null');", "boundary"],
["String", "String = local; String(value);", "boundary"],
] as const)(
"does not admit built-in effects after a visible %s replacement",
(_name, source, family) => {
const ir = analyzeJavaScriptSemantics(source);
const operations = {
timer: ir.timerOperations,
request: ir.requestOperations,
boundary: ir.boundaryOperations,
};
expect(
operations[family].filter(
({ resolution }) => resolution === "complete",
),
).toEqual([]);
},
);
it("keeps built-in effects for unmodified globals beside unrelated writes", () => {
const ir = analyzeJavaScriptSemantics(`
JSON.stringify = local; other.setTimeout = local;
setTimeout(work, 1);
fetch('https://example.test');
new WebSocket('wss://example.test');
JSON.parse('null');
String(value);
`);
expect(ir.timerOperations).toMatchObject([
{ method: "setTimeout", resolution: "complete" },
]);
expect(ir.requestOperations.map(({ method }) => method)).toEqual([
"fetch",
"WebSocket",
]);
expect(ir.boundaryOperations).toMatchObject([
{ method: "JSON.parse", resolution: "complete" },
{ method: "String", resolution: "complete" },
]);
});
});
@@ -16,6 +16,7 @@ import {
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import {
builtinDataEffectMethod,
@@ -333,7 +334,8 @@ const timerMethod = (
if (binding === undefined) {
const method = admittedTimerMethod(callee.name);
if (method === null) return null;
return semanticResolutionBlocked(state, callee, callee.name)
return semanticResolutionBlocked(state, callee, callee.name) ||
!isUnmodifiedGlobalReference(callee, state, callee.name)
? null
: method;
}
@@ -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",
@@ -0,0 +1,70 @@
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
const EFFECT_RELATIONS = [
"schedules-timer",
"constructs-request",
"parses",
"coerces",
];
const SOURCE = `function work() {}
setTimeout(work, 100);
fetch('https://example.invalid');
new WebSocket('wss://example.invalid');
JSON.parse('null');
String(work);
`;
const REPLACEMENTS = `setTimeout = () => null;
fetch = () => null;
WebSocket = class {};
JSON.parse = () => null;
String = () => '';
`;
cliTest.for(["CLI", "stdio MCP"] as const)(
"does not resolve built-in effects after visible global replacements through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-replaced-globals-");
await writeFixtureFiles(root, {
"replaced/main.mjs": REPLACEMENTS + SOURCE,
"control/main.mjs": SOURCE,
});
const effects = async (directory: string) => {
const graph = (
await analyzeJavaScriptDirectory(surface, cli, `${root}/${directory}`)
).semantic_graph;
if (graph === null) throw new Error("Expected a semantic graph");
return {
relations: graph.relations
.filter(({ relation }) => EFFECT_RELATIONS.includes(relation))
.map(({ relation, resolution }) => `${relation}:${resolution}`)
.sort(),
boundaryUnknowns: graph.unknowns
.filter(({ family }) => family === "boundary")
.map(({ detail }) => detail),
};
};
// The replaced JSON.parse remains only the generic `.parse` candidate.
await expect(effects("replaced")).resolves.toEqual({
relations: ["parses:candidate"],
boundaryUnknowns: ["Static parse boundary kind is a candidate."],
});
await expect(effects("control")).resolves.toEqual({
relations: [
"coerces:resolved",
"constructs-request:resolved",
"constructs-request:resolved",
"parses:resolved",
"schedules-timer:resolved",
],
boundaryUnknowns: [],
});
},
);