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:
1 parent
de00fdc17d
commit
7436ac8e20
5 files changed
+150
-6
No files matched your search
@@ -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: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user