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:
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 = (
|
||||
|
||||
Reference in new issue
Block a user