From 7436ac8e20710f4b092a137aaa6b4b0626ed5067 Mon Sep 17 00:00:00 2001 From: amyanger <57688398+amyanger@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:28:53 +0100 Subject: [PATCH] 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 --- docs/javascript-application-workflows.md | 5 ++ ...emanticAnalysis.runtime_boundaries.test.ts | 58 +++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 4 +- .../javascriptSemanticDataEffects.ts | 19 +++-- ...javascriptReplacedGlobalEffectsCli.test.ts | 70 +++++++++++++++++++ 5 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 tests/acceptance/applications/javascriptReplacedGlobalEffectsCli.test.ts diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 50ebc287b8..e502cdf5a1 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -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 diff --git a/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts index 4ee17e8b25..5321edcdef 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.runtime_boundaries.test.ts @@ -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" }, + ]); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 84e72939ec..e38af6bb07 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -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; } diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index 69deae89cc..732b309fce 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -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", diff --git a/tests/acceptance/applications/javascriptReplacedGlobalEffectsCli.test.ts b/tests/acceptance/applications/javascriptReplacedGlobalEffectsCli.test.ts new file mode 100644 index 0000000000..5e27fc743f --- /dev/null +++ b/tests/acceptance/applications/javascriptReplacedGlobalEffectsCli.test.ts @@ -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: [], + }); + }, +);