fix(javascript): distinguish Map lookups from network endpoints

Resolve unchanged Map operations through shared binding, capture, mutation,
and ambient escape facts. Reuse that proof in application and captured
bundle analysis; unresolved get/delete receivers remain inferred candidates.

Preserve repeated native operations without manufacturing receiver escapes.
Retain uncertainty for real escapes, opaque eval, and changed constructor
adder hooks. Converge stored aliases at the producing boundary.

Adapt the JavaScript changes from #4863, excluding its Linux resource fix.
Add captured-bundle parity, repeated-operation and constructor-hook coverage.

Fixes #4853
Co-authored-by: abdooo2235 <abdalrhmantheiv15@gmail.com>
This commit is contained in:
morluto committed 2026-10-18 08:25:06 +08:00
1 parent e6c487799b
commit fbfdb6abe9
16 files changed
+1021 -73

No files matched your search

@@ -71,6 +71,14 @@ export function* collectJavaScriptIdentityWritesSteps(
staticString: (node: t.Node | undefined) => string | undefined,
freshObject: (node: t.Node) => boolean,
retain: (node: t.Node, path: readonly (string | null)[]) => void,
observeInvocation?: (
node:
| t.CallExpression
| t.OptionalCallExpression
| t.NewExpression
| t.TaggedTemplateExpression,
invocation: JavaScriptIntrinsicInvocation | undefined,
) => void,
): Generator<void> {
const objectMutators = new Set([
"defineProperty",
@@ -87,8 +95,19 @@ export function* collectJavaScriptIdentityWritesSteps(
const keyedMutators = new Set(["set", "deleteProperty", "defineProperty"]);
yield* traverseJavaScriptAstSteps(program, {
enter: (node) => {
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const invocation = intrinsicInvocation(node);
if (
t.isCallExpression(node) ||
t.isOptionalCallExpression(node) ||
t.isNewExpression(node) ||
t.isTaggedTemplateExpression(node)
) {
const invocation =
t.isNewExpression(node) || t.isTaggedTemplateExpression(node)
? undefined
: intrinsicInvocation(node);
observeInvocation?.(node, invocation);
if (t.isNewExpression(node) || t.isTaggedTemplateExpression(node))
return;
if (invocation?.kind === "opaque") {
for (const scope of origin(node, "opaque-call", node))
retain(node, scope);
@@ -16,7 +16,12 @@ import {
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js";
import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import {
semanticIterationSources,
type SemanticIterationSource,
} from "./javascriptSemanticIterationSources.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"];
@@ -42,17 +47,42 @@ type AmbientPath = readonly (string | null)[];
/** Undefined means a cyclic or conflicting projection can select any depth. */
type AmbientProjection = AmbientPath | undefined;
const joinAmbientProjection = (
left: AmbientProjection,
right: AmbientProjection,
): AmbientProjection =>
left === undefined || right === undefined || left.length !== right.length
? undefined
: left.map((key, index) => (key === right[index] ? key : null));
interface AmbientReferenceFacts {
readonly stores: WeakMap<
t.Node,
Map<SemanticIterationSource, AmbientProjection>
>;
readonly prototypeTarget: (node: t.Node) => t.Node | undefined;
readonly freshObject: (node: t.Node) => boolean;
}
/**
* Follow local object identities without equating shallow copies with their
* sources. Write lookup retains every visible initializer: reassigning an alias
* later cannot erase a possible earlier mutation. Intrinsic reads stay strict.
* sources. Writes select aliases at their capture; uncertain execution order
* retains possible initializers. Intrinsic reads stay strict.
*/
const createAmbientPathResolver = (
export const createAmbientPathResolver = (
state: JavaScriptSemanticAnalysisState,
possibleWrites: boolean,
facts?: AmbientReferenceFacts,
) => {
const cached = new WeakMap<t.Node, readonly AmbientPath[]>();
return (node: t.Node, capture?: t.Node): readonly AmbientPath[] => {
const returnedReferences = createSemanticReturnedReferences(state);
return (
node: t.Node,
capture?: t.Node,
escapedContents = false,
projection: AmbientProjection = [],
localIdentity?: (node: t.Node, path: AmbientProjection) => void,
): readonly AmbientPath[] => {
const prior = possibleWrites ? undefined : cached.get(node);
if (prior !== undefined) return prior;
const result = new Map<string, AmbientPath>();
@@ -62,7 +92,7 @@ const createAmbientPathResolver = (
node: t.Node;
suffix: AmbientProjection;
readsAtSelf?: true;
}[] = [{ node, suffix: [] }];
}[] = [{ node, suffix: projection }];
// Join projections at their producing expression, rather than enumerating
// alias histories. Each key can only widen to a wildcard, and conflicting
// depths widen to unknown, so diamonds and cycles have finite state.
@@ -71,6 +101,15 @@ const createAmbientPathResolver = (
let canonical = canonicalAmbientPath(path);
for (;;) {
result.set(JSON.stringify(canonical), canonical);
if (
possibleWrites &&
canonical[0] === "global" &&
canonical[2] === "prototype" &&
canonical[3] === "constructor"
) {
canonical = ["global", canonical[1] ?? null, ...canonical.slice(4)];
continue;
}
// An unknown key may select a global self reference as well as a
// distinct property. Retain both possible object identities for writes.
if (
@@ -114,6 +153,64 @@ const createAmbientPathResolver = (
suffix: path,
...(current.readsAtSelf && { readsAtSelf: true }),
});
const continueReturned = (callee: t.Node, path: AmbientProjection) => {
for (const source of returnedReferences(callee))
continueWith(
source.node,
path === undefined
? undefined
: [
...source.projection.map((key) =>
key === null || typeof key === "object"
? null
: String(key),
),
...path,
],
);
};
if (!t.isIdentifier(root) && facts?.freshObject(root)) {
localIdentity?.(root, suffix);
for (const [source, path] of facts?.stores.get(root) ?? []) {
const selected =
suffix === undefined ||
path === undefined ||
path.every(
(key, index) =>
index >= suffix.length ||
key === null ||
suffix[index] === null ||
key === suffix[index],
);
if (
!selected ||
(suffix !== undefined &&
path !== undefined &&
suffix.length < path.length &&
!escapedContents)
)
continue;
const remaining =
suffix === undefined || path === undefined
? undefined
: suffix.slice(path.length);
pending.push({
node: source.node,
readsAtSelf: true,
suffix:
remaining === undefined
? undefined
: [
...source.projection.map((key) =>
key === null || typeof key === "object"
? null
: String(key),
),
...remaining,
],
});
}
}
if (
t.isMetaProperty(root) &&
root.meta.name === "import" &&
@@ -144,8 +241,8 @@ const createAmbientPathResolver = (
const initializers = possibleWrites
? readAt === undefined
? binding.initializers
: semanticMutationInitializers(binding, readAt, state.parentsByNode)
.initializers
: semanticBindingAtRead(binding, readAt, state.parentsByNode)
.binding.initializers
: stable === undefined
? []
: [stable];
@@ -165,6 +262,47 @@ const createAmbientPathResolver = (
});
continue;
}
if (
possibleWrites &&
t.isNewExpression(root) &&
(suffix?.[0] === "__proto__" || suffix?.[0] === "constructor")
) {
continueWith(
root.callee,
suffix[0] === "__proto__"
? ["prototype", ...suffix.slice(1)]
: suffix.slice(1),
);
continue;
}
const prototypeTarget = possibleWrites
? facts?.prototypeTarget(root)
: undefined;
if (prototypeTarget !== undefined) {
continueWith(
prototypeTarget,
suffix === undefined ? undefined : ["__proto__", ...suffix],
);
continue;
}
if (
possibleWrites &&
(t.isCallExpression(root) ||
t.isOptionalCallExpression(root) ||
t.isNewExpression(root) ||
t.isTaggedTemplateExpression(root) ||
(escapedContents && (t.isFunction(root) || t.isClass(root))))
) {
continueReturned(
t.isFunction(root) || t.isClass(root)
? root
: t.isTaggedTemplateExpression(root)
? root.tag
: root.callee,
suffix,
);
continue;
}
if (possibleWrites && t.isConditionalExpression(root)) {
continueWith(root.consequent);
continueWith(root.alternate);
@@ -175,15 +313,51 @@ const createAmbientPathResolver = (
continueWith(root.right);
continue;
}
if (possibleWrites && t.isSequenceExpression(root)) {
const value = root.expressions.at(-1);
if (value !== undefined) continueWith(value);
continue;
}
if (
possibleWrites &&
t.isAssignmentExpression(root) &&
root.operator === "="
) {
continueWith(root.right);
continue;
}
const key = suffix?.[0];
// Container escapes reuse the finite worklist, including alias cycles.
if (escapedContents && suffix?.length === 0) {
if (t.isObjectExpression(root)) {
for (const property of root.properties)
if (t.isSpreadElement(property))
continueWith(property.argument, []);
else if (
t.isObjectProperty(property) &&
t.isExpression(property.value)
)
continueWith(property.value, []);
else if (t.isObjectMethod(property) && property.kind === "get")
continueReturned(property, []);
continue;
}
if (t.isArrayExpression(root)) {
for (const element of root.elements)
if (t.isSpreadElement(element)) continueWith(element.argument, []);
else if (t.isExpression(element)) continueWith(element, []);
continue;
}
}
if (suffix !== undefined && key === undefined) continue;
if (t.isObjectExpression(root)) {
if (possibleWrites) {
for (const property of root.properties) {
if (t.isSpreadElement(property)) continueWith(property.argument);
else if (
t.isObjectProperty(property) &&
t.isExpression(property.value)
(t.isObjectProperty(property) &&
t.isExpression(property.value)) ||
(t.isObjectMethod(property) && property.kind === "get")
) {
const ownKey = semanticStaticPropertyKey(
property.key,
@@ -195,7 +369,10 @@ const createAmbientPathResolver = (
ownKey === null ||
ownKey === key
)
continueWith(property.value, suffix?.slice(1));
if (t.isObjectMethod(property))
continueReturned(property, suffix?.slice(1));
else if (t.isExpression(property.value))
continueWith(property.value, suffix?.slice(1));
}
}
} else if (
@@ -255,9 +432,24 @@ const createAmbientPathResolver = (
export function* indexJavaScriptAmbientWritesSteps(
program: t.Program,
state: JavaScriptSemanticAnalysisState,
observeOpaqueInvocation?: (node: t.Node) => void,
): Generator<void> {
const ambientReads = createAmbientPathResolver(state, false);
const ambientWrites = createAmbientPathResolver(state, true);
const browserReceiverMethods = new Set<string>();
const storedSites = new WeakSet<t.Node>();
const storedReferences: SemanticIterationSource[] = [];
const writes: { node: t.Node; path: AmbientPath }[] = [];
const targets: { node: t.Node; capture: t.Node }[] = [];
const escapes: {
node: t.Node;
target: t.Node;
browserReceiver: boolean;
projection?: AmbientProjection;
}[] = [];
const stores = new WeakMap<
t.Node,
Map<SemanticIterationSource, AmbientProjection>
>();
const interop = createSemanticModuleInterop(state, {
intrinsicPath: (node) => {
const [path] = ambientReads(node);
@@ -266,6 +458,26 @@ export function* indexJavaScriptAmbientWritesSteps(
: undefined;
},
});
const ambientWrites = createAmbientPathResolver(state, true, {
stores,
freshObject: interop.isFreshObject,
prototypeTarget: (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return undefined;
const invocation = interop.intrinsicInvocation(node);
if (
invocation?.kind !== "known" ||
invocation.path.length !== 3 ||
!["Object", "Reflect"].includes(invocation.path[1] ?? "") ||
invocation.path[2] !== "getPrototypeOf"
)
return undefined;
const target = invocation.arguments?.[0];
return t.isNode(target) && !t.isSpreadElement(target)
? target
: undefined;
},
});
yield* collectJavaScriptIdentityWritesSteps(
program,
(root, kind, writeSite) => {
@@ -276,16 +488,153 @@ export function* indexJavaScriptAmbientWritesSteps(
isUnshadowedGlobal(root, state, root.name)
? [["global", root.name]]
: [];
return ambientWrites(root, writeSite);
if (!storedSites.has(writeSite)) {
storedSites.add(writeSite);
for (const source of semanticIterationSources(
writeSite,
state.parentsByNode.get(writeSite) ?? null,
)) {
if (source.kind === "reference-store") storedReferences.push(source);
}
}
// Resolve identities after the traversal has collected every local store.
const id = targets.push({ node: root, capture: writeSite }) - 1;
return [["pending-target", String(id)]];
},
interop.intrinsicInvocation,
interop.staticString,
interop.isFreshObject,
(node, path) => {
state.ambientWrites.retain(path);
state.ambientWriteEffects.push({ path, node });
writes.push({ node, path });
},
(node, invocation) => {
observeOpaqueInvocation?.(node);
// Escaping objects can change contents without replacing their identity.
const retainEscape = (target: t.Node, browserReceiver = false) => {
escapes.push({ target, node, browserReceiver });
};
for (const argument of t.isTaggedTemplateExpression(node)
? node.quasi.expressions
: node.arguments)
if (t.isNode(argument))
retainEscape(
t.isSpreadElement(argument) ? argument.argument : argument,
);
const callee = unwrapJavaScriptExpression(
t.isTaggedTemplateExpression(node) ? node.tag : node.callee,
).node;
const browserMethod =
invocation?.kind === "known" &&
invocation.path.length === 2 &&
["open", "fetch"].includes(invocation.path[1] ?? "")
? invocation.path[1]
: undefined;
if (
!t.isNewExpression(node) &&
(t.isMemberExpression(callee) ||
t.isOptionalMemberExpression(callee)) &&
!(
invocation?.kind === "known" &&
["Object", "Reflect"].includes(invocation.path[1] ?? "")
)
) {
if (browserMethod !== undefined)
browserReceiverMethods.add(browserMethod);
retainEscape(callee.object, browserMethod !== undefined);
}
},
);
const pendingStores = new Set(storedReferences);
const dependents = new WeakMap<t.Node, Set<SemanticIterationSource>>();
while (pendingStores.size > 0) {
const source = pendingStores.values().next().value;
if (source === undefined) break;
pendingStores.delete(source);
if (source.target === undefined) continue;
const identities = new Map<t.Node, AmbientProjection>();
ambientWrites(source.target, source.mutation, false, [], (node, path) => {
const users = dependents.get(node) ?? new Set<SemanticIterationSource>();
users.add(source);
dependents.set(node, users);
identities.set(
node,
identities.has(node)
? joinAmbientProjection(identities.get(node), path)
: path,
);
});
// Publish only completed queries; each source/root projection can only widen.
for (const [node, path] of identities) {
const entries =
stores.get(node) ??
new Map<SemanticIterationSource, AmbientProjection>();
const previous = entries.get(source);
const joined = entries.has(source)
? joinAmbientProjection(previous, path)
: path;
if (
entries.has(source) &&
(previous === undefined ||
(joined !== undefined &&
previous.every((key, index) => key === joined[index])))
)
continue;
entries.set(source, joined);
stores.set(node, entries);
for (const dependent of dependents.get(node) ?? [])
pendingStores.add(dependent);
}
yield;
}
for (const source of storedReferences) {
if (source.target === undefined) continue;
let local = false;
const ambient = ambientWrites(
source.target.object,
source.mutation,
false,
[],
() => {
local = true;
},
);
if (!local || ambient.length > 0)
escapes.push({
node: source.mutation,
target: source.node,
browserReceiver: false,
projection: source.projection.map((key) =>
key === null || typeof key === "object" ? null : String(key),
),
});
}
for (const { node, path } of writes) {
const target =
path[0] === "pending-target" ? targets[Number(path[1])] : undefined;
const paths =
target === undefined
? [path]
: ambientWrites(target.node, target.capture).map((identity) => [
...identity,
...path.slice(2),
]);
for (const resolved of paths) {
state.ambientWrites.retain(resolved);
state.ambientWriteEffects.push({ path: resolved, node });
}
}
for (const { node, target, browserReceiver, projection } of escapes)
for (const path of ambientWrites(target, node, true, projection))
// Only proven browser APIs keep their global receiver from user code.
if (!(browserReceiver && path.length === 1 && path[0] === "global"))
state.ambientEscapes.retain([...path, null]);
// A function may call a browser method replaced later in source order.
if (
[...browserReceiverMethods].some((method) =>
state.ambientWrites.covers(["global", method]),
)
)
state.ambientEscapes.retain(["global", null]);
}
/**
@@ -1,4 +1,5 @@
import {
createSemanticOpaqueGlobalObserver,
indexSemanticOpaqueEffects,
semanticOpaqueBindingEffects,
semanticBindingCanBeRebound,
@@ -169,7 +170,13 @@ function* indexSemanticAmbientWritesSteps(
state: JavaScriptSemanticAnalysisState,
): Generator<void, void> {
if (indexedAmbientWriteStates.has(state)) return;
yield* indexJavaScriptAmbientWritesSteps(state.program, state);
yield* indexJavaScriptAmbientWritesSteps(
state.program,
state,
createSemanticOpaqueGlobalObserver(state),
);
if (state.opaqueEffects.globalSites.length > 0)
clearSemanticValueCaches(state);
indexedAmbientWriteStates.add(state);
}
@@ -179,7 +186,12 @@ function* indexSemanticMemberMutationFactsSteps(
): Generator<void, void> {
if (indexedMemberMutationStates.has(state)) return;
yield* indexSemanticAmbientWritesSteps(state);
yield* collectSemanticMemberMutationsSteps(state.program, state);
yield* collectSemanticMemberMutationsSteps(
state.program,
state,
(site, reference, path) =>
referenceEffectsMayHaveChanged(state, site, reference, path),
);
indexedMemberMutationStates.add(state);
}
@@ -237,12 +249,12 @@ const visitSemanticReferenceEffectsAtSite = (
if (!retain("escape", node, [])) return metadata;
for (const node of state.bindingWrites.get(binding.bindingId) ?? [])
if (!retain("binding-write", node)) return metadata;
if (indexedMemberMutationStates.has(state)) {
for (const effect of binding.memberWrites)
if (!retain("member-write", effect.node, effect.path)) return metadata;
for (const effect of binding.escapedPaths)
if (!retain("escape", effect.node, effect.path)) return metadata;
}
// The mutation producer also queries its growing facts during convergence.
// Completion stays explicit in metadata; callers cannot infer readiness.
for (const effect of binding.memberWrites)
if (!retain("member-write", effect.node, effect.path)) return metadata;
for (const effect of binding.escapedPaths)
if (!retain("escape", effect.node, effect.path)) return metadata;
} else if (lookup.kind === "ambient" || lookup.kind === "uncertain-ambient") {
for (const node of state.ambientBindingWrites.get(root.name) ?? [])
if (!retain("ambient-write", node)) return metadata;
@@ -250,6 +262,27 @@ const visitSemanticReferenceEffectsAtSite = (
return metadata;
};
/** Query current producer facts; public consumers additionally require completion. */
const referenceEffectsMayHaveChanged = (
state: JavaScriptSemanticAnalysisState,
site: t.Node,
referenceNode: t.Node,
propertyPath?: JavaScriptSemanticPropertyPath,
): boolean => {
let changed = false;
const metadata = visitSemanticReferenceEffectsAtSite(
state,
site,
referenceNode,
(effect) => {
changed = effect.timing !== "after";
return !changed;
},
propertyPath,
);
return metadata.resolution !== "resolved" || changed;
};
/** Preserve the complete diagnostic contract for consumers needing every effect. */
const semanticReferenceEffectsAtSite = (
state: JavaScriptSemanticAnalysisState,
@@ -477,18 +510,12 @@ export function* indexJavaScriptBindingsWithReceiversSteps(
},
referenceMayHaveChangedAt(site, referenceNode, propertyPath) {
if (!indexedMemberMutationStates.has(state)) return true;
let changed = false;
const metadata = visitSemanticReferenceEffectsAtSite(
return referenceEffectsMayHaveChanged(
state,
site,
referenceNode,
(effect) => {
changed = effect.timing !== "after";
return !changed;
},
propertyPath,
);
return metadata.resolution !== "resolved" || changed;
},
*referencesSteps() {
references ??= yield* collectSemanticReferencesSteps(program, state);
@@ -919,10 +946,12 @@ const createState = (
return {
program,
opaqueEffects: {
mayReferenceGlobalEval: false,
sites: new WeakSet(),
sloppySites: new WeakSet(),
byScope: new Map(),
children: new Map(),
globalSites: [],
},
staticImportResolutionMode,
valueResourceLimits: new Set(),
@@ -945,10 +974,19 @@ const createState = (
requiredModuleEffects: new Map(),
ambientBindingWrites: new Map(),
ambientWrites: new SemanticPropertyPathCoverage(),
ambientEscapes: new SemanticPropertyPathCoverage(),
ambientWriteEffects: [],
};
};
const globalEvalRoots = new Set([
"eval",
"globalThis",
"global",
"window",
"self",
]);
function* collectDefinitionsSteps(
program: t.Program,
state: JavaScriptSemanticAnalysisState,
@@ -962,7 +1000,10 @@ function* collectDefinitionsSteps(
readonly scope: JavaScriptSemanticScopeState;
readonly conditionallySkipped: boolean;
}[] = [];
const opaqueCandidates: { node: t.CallExpression; scopeId: string }[] = [];
const opaqueCandidates: {
node: t.CallExpression;
scopeId: string;
}[] = [];
let needsPrototypeFacts = false;
const functionBodies: {
readonly body: t.BlockStatement;
@@ -1040,6 +1081,10 @@ function* collectDefinitionsSteps(
state,
});
bindInnerDeclaration(node, parent, scope, state);
// Every supported global eval origin starts with one of these decoded
// identifiers, including computed properties and destructured aliases.
if (t.isIdentifier(node) && globalEvalRoots.has(node.name))
state.opaqueEffects.mayReferenceGlobalEval = true;
if (
t.isCallExpression(node) &&
t.isIdentifier(unwrapJavaScriptExpression(node.callee).node, {
@@ -1096,7 +1141,7 @@ function* collectDefinitionsSteps(
copyParameterToBody({ identifier, body, scope, state });
}
indexSemanticOpaqueEffects(opaqueCandidates, state);
if (opaqueCandidates.length > 0) clearSemanticValueCaches(state);
if (state.opaqueEffects.byScope.size > 0) clearSemanticValueCaches(state);
}
type PrototypeOwner = "Object" | "Array" | "String";
@@ -20,6 +20,7 @@ interface IterationSourceValue {
export interface SemanticIterationSource extends IterationSourceValue {
readonly kind: "iteration" | "reference-store";
readonly mutation: t.Node;
readonly target?: t.MemberExpression;
readonly fallbackSources?: readonly IterationSourceValue[];
readonly requiredSources?: readonly IterationSourceValue[];
}
@@ -40,6 +41,7 @@ const add = (
mutation: t.Node,
guards: SourceGuards,
kind: SemanticIterationSource["kind"] = "iteration",
target?: t.MemberExpression,
): void => {
sources.push({
kind,
@@ -47,6 +49,7 @@ const add = (
projection: source.projection,
mutation,
...guards,
...(target === undefined ? {} : { target }),
});
};
const project = (
@@ -95,6 +98,7 @@ const visit = (
mutation,
guards,
"reference-store",
pattern,
);
return;
}
@@ -0,0 +1,74 @@
import * as t from "@babel/types";
import {
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import {
resolveSemanticBindingState,
semanticStableInitializer,
type JavaScriptBindingIndex,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
/** Prove an unchanged Map operation; undefined requests shared member-mutation facts. */
export const isIntrinsicMapLookup = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
bindings: JavaScriptSemanticAnalysisState,
index?: Pick<JavaScriptBindingIndex, "referenceMayHaveChangedAt">,
): boolean | undefined => {
const callee = unwrapJavaScriptExpression(node.callee).node;
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return false;
const method = semanticStaticPropertyName(callee.property, callee.computed);
if (method !== "get" && method !== "delete") return false;
const prototypeMemberMayHaveChanged = (member: string): boolean =>
["Map", "Object"].some((owner) => {
const path = ["global", owner, "prototype", member];
return (
bindings.ambientWrites.covers(path) ||
bindings.ambientEscapes.covers(path)
);
});
if (prototypeMemberMayHaveChanged(method)) return false;
let receiver = unwrapJavaScriptExpression(callee.object).node;
const seen = new Set<string>();
while (t.isIdentifier(receiver)) {
if (
index !== undefined &&
(index.referenceMayHaveChangedAt(node, receiver, [method]) ||
index.referenceMayHaveChangedAt(node, receiver, ["__proto__"]))
)
return false;
const binding = resolveSemanticBindingState(
bindings,
receiver,
receiver.name,
);
if (binding === undefined || seen.has(binding.bindingId)) return false;
seen.add(binding.bindingId);
const initializer = semanticStableInitializer(binding, bindings);
if (
initializer === undefined ||
initializer.projection.length !== 0 ||
!semanticBindingAtRead(binding, receiver, bindings.parentsByNode)
.hasDefiniteInitializer
)
return false;
receiver = unwrapJavaScriptExpression(initializer.node).node;
}
if (
!t.isNewExpression(receiver) ||
!isUnmodifiedGlobalReference(receiver.callee, bindings, "Map")
)
return false;
// Iterable construction reads and invokes the instance's set hook. A changed
// adder can install its own get/delete before the constructed value is read.
if (receiver.arguments.length > 0 && prototypeMemberMayHaveChanged("set"))
return false;
if (semanticOpaqueGlobalMayHaveChanged(callee, bindings)) return false;
return index === undefined && seen.size > 0 ? undefined : true;
};
@@ -1,4 +1,5 @@
import * as t from "@babel/types";
import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
import {
semanticGlobalProcessOrigin,
@@ -11,6 +12,7 @@ import {
import {
resolveSemanticBindingState,
type JavaScriptBindingIndex,
type JavaScriptSemanticBindingState,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
@@ -132,7 +134,11 @@ const localSetterReceiver = (
export function* collectSemanticMemberMutationsSteps(
program: t.Program,
state: JavaScriptSemanticAnalysisState,
referenceMayHaveChangedAt: JavaScriptBindingIndex["referenceMayHaveChangedAt"],
): Generator<void, void> {
const deferredMapReceivers = new Set<
t.CallExpression | t.OptionalCallExpression
>();
const parents = state.parentsByNode;
const observablePaths = observableMutationPaths(state);
const returnedReferences = createSemanticReturnedReferences(state);
@@ -771,7 +777,11 @@ export function* collectSemanticMemberMutationsSteps(
});
else markEscaped(argument, node);
// Constructing a member does not pass its container as `this`.
if (!t.isNewExpression(node)) markReceiver(node.callee, node);
if (!t.isNewExpression(node)) {
const mapLookup = isIntrinsicMapLookup(node, state);
if (mapLookup === undefined) deferredMapReceivers.add(node);
else if (!mapLookup) markReceiver(node.callee, node);
}
} else if (t.isTaggedTemplateExpression(node)) {
markReceiver(node.tag, node);
for (const expression of node.quasi.expressions)
@@ -781,7 +791,11 @@ export function* collectSemanticMemberMutationsSteps(
});
// A later effect can make a previously defined destructuring source uncertain.
// Reconsider defaults and iterable origins until their effects are stable.
while (pendingReferences.length > 0 || iterableReferences.length > 0) {
while (
pendingReferences.length > 0 ||
iterableReferences.length > 0 ||
deferredMapReceivers.size > 0
) {
const effectsBefore = recordedEffectCount;
const iterablesBefore = iterableReferences.length;
const iterationBefore: boolean = arrayIterationUnknown;
@@ -870,6 +884,20 @@ export function* collectSemanticMemberMutationsSteps(
reference.widenedGetterResult === true,
);
}
// Native Map lookup methods do not expose their receiver to user code.
// Settle genuine effects first, then replay every unproven receiver escape.
// Keep proven sites until convergence: later propagated effects can revoke them.
let receiverIndex = 0;
for (const node of deferredMapReceivers) {
if (receiverIndex++ % 64 === 0) yield;
if (
isIntrinsicMapLookup(node, state, { referenceMayHaveChangedAt }) ===
true
)
continue;
deferredMapReceivers.delete(node);
markReceiver(node.callee, node);
}
if (
recordedEffectCount === effectsBefore &&
iterableReferences.length === iterablesBefore &&
@@ -1,5 +1,7 @@
import * as t from "@babel/types";
import { unwrapJavaScriptExpression } from "./javascriptAstValues.js";
import { createAmbientPathResolver } from "./javascriptSemanticAmbientWrites.js";
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
import {
semanticEffectTiming,
semanticBindingEffectFollowsCapture,
@@ -13,14 +15,20 @@ import {
/** One source owns sites and the minimal scope tree reaching them; O(AST) storage. */
export interface JavaScriptOpaqueScopeEffects {
/** Cheap source evidence collected with declarations, before alias queries. */
mayReferenceGlobalEval: boolean;
readonly sites: WeakSet<t.Node>;
readonly sloppySites: WeakSet<t.Node>;
readonly byScope: Map<string, t.CallExpression[]>;
readonly children: Map<string, Set<string>>;
readonly globalSites: (t.CallExpression | t.OptionalCallExpression)[];
}
export const indexSemanticOpaqueEffects = (
candidates: readonly { node: t.CallExpression; scopeId: string }[],
candidates: readonly {
node: t.CallExpression;
scopeId: string;
}[],
state: JavaScriptSemanticAnalysisState,
): void => {
for (const { node, scopeId } of candidates) {
@@ -44,6 +52,44 @@ export const indexSemanticOpaqueEffects = (
}
};
/** The existing ambient invocation walk owns global effects; ordinary sources
* neither retain a second call list nor create another alias resolver. */
export const createSemanticOpaqueGlobalObserver = (
state: JavaScriptSemanticAnalysisState,
): ((node: t.Node) => void) | undefined => {
if (!state.opaqueEffects.mayReferenceGlobalEval) return undefined;
const ambientPaths = createAmbientPathResolver(state, true);
let invocationSite: t.Node | undefined;
const interop = createSemanticModuleInterop(state, {
intrinsicPath: (node) => {
const paths = ambientPaths(node, invocationSite);
const path = paths.find((path) => path[1] === "eval") ?? paths[0];
if (path?.[0] !== "global" || path.some((key) => key === null))
return undefined;
// Keep the global callable owner for generic call/apply/bind resolution.
return path[1] === "eval"
? ["intrinsic", ...(path as readonly string[])]
: ["intrinsic", ...(path.slice(1) as readonly string[])];
},
});
return (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
invocationSite = node;
const invocation = interop.intrinsicInvocation(node);
if (invocation?.path.join(".") === "intrinsic.global.eval")
state.opaqueEffects.globalSites.push(node);
};
};
/** Indirect eval can change globals, but cannot rebind its caller's locals. */
export const semanticOpaqueGlobalMayHaveChanged = (
read: t.Node,
state: JavaScriptSemanticAnalysisState,
): boolean =>
state.opaqueEffects.globalSites.some(
(effect) => semanticEffectTiming(read, effect, state) !== "after",
);
/** Rebinding requires lexical visibility. Object references can remain reachable
* through aliases even where the original name is shadowed or out of block scope.
* Without a disjointness proof, consider their enclosing activation and closures.
@@ -146,6 +146,8 @@ export interface JavaScriptSemanticAnalysisState {
readonly ambientBindingWrites: Map<string, t.Node[]>;
/** Source-local writes, tagged by global, import.meta or CJS module identity. */
readonly ambientWrites: SemanticPropertyPathCoverage;
/** Ambient object identities passed to calls whose effects are not evaluated. */
readonly ambientEscapes: SemanticPropertyPathCoverage;
/** Source sites distinguish replacement before an ambient identity capture from later writes. */
readonly ambientWriteEffects: JavaScriptSemanticMemberEffect[];
}
@@ -0,0 +1,197 @@
import { expect, it } from "vitest";
import * as t from "@babel/types";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { inspectCall } from "./javascriptStaticAnalysisCalls.js";
import { createJavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisState.js";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
it("does not classify a local Map lookup as a network endpoint", () => {
const analysis = analyzeJavaScriptStaticSource(`
const records = new Map([["/accounts", { name: "Alice" }]]);
console.log(records.get("/accounts"));
fetch("/network");
`);
expect(analysis.endpoints).toMatchObject([
{ kind: "network", value: "/network", mechanism: "call:fetch" },
]);
expect(analysis.endpoints[0]?.interpretation).toBeUndefined();
});
it.each([
'const records = new Map(); const alias = records; alias.get("/accounts"); records.delete("/accounts"); alias.get("/accounts")',
'new Map().get("/accounts")',
'new Map().delete("https://example.test/accounts")',
'const records = new Map(); const alias = records; alias.get("/accounts")',
'const records = new globalThis.Map(); records?.["get"]("/accounts")',
'function inspect() { const records = new Map(); return records.get("/accounts"); }',
'const inspect = () => { const records = new Map(); const alias = records; return alias.delete("/accounts"); }',
'const browser = window; browser.open("about:blank"); new Map().get("/accounts")',
'const box = {}; box.value = { name: "Alice" }; inspect(box); new Map().get("/accounts")',
'patch(() => ({ name: "Alice" })); new Map().get("/accounts")',
'const box = {}; box.prototype = Map.prototype; new Map().get("/accounts")',
'const box = {}; const alias = box; alias.prototype = Map.prototype; inspect({}); new Map().get("/accounts")',
'const box = {}; box.make = () => Map.prototype; new Map().get("/accounts")',
'let box = {}; const first = box; box = {}; box.p = Map.prototype; patch(first); new Map().get("/accounts")',
'const box = {}; box.a = {}; box.a.p = Map.prototype; box.safe = {}; patch(box.safe); new Map().get("/accounts")',
'const records = new Map(); records.get = fetch; new Map().get("/accounts")',
'const holder = { get prototype() { return Map.prototype; } }; new Map().get("/accounts")',
'const holder = { get selected() { return {}; }, get other() { return Map.prototype; } }; holder.selected.get = fetch; new Map().get("/accounts")',
'function Map() {} new Map().constructor.prototype.get = fetch; globalThis.Map.prototype; new globalThis.Map().get("/accounts")',
'const factory = { make() { return {}; } }; factory.make = () => ({}); patch(factory.make()); new Map().get("/accounts")',
])("omits a proven Map operation: %s", (source) => {
expect(analyzeJavaScriptStaticSource(source).endpoints).toEqual([]);
});
it.each([
'client.get("/accounts")',
'Object.getPrototypeOf = () => Map.prototype; Object.getPrototypeOf({}).get = fetch; new Map().get("/accounts")',
'Reflect.getPrototypeOf = () => Map.prototype; Reflect.getPrototypeOf({}).get = fetch; new Map().get("/accounts")',
'const records = new Map(); records.get = request; records.get("/accounts")',
'let records = new Map(); records = client; records.get("/accounts")',
'function inspect(Map) { new Map().get("/accounts"); }',
'Map.prototype.get = request; const records = new Map(); records.get("/accounts")',
'const records = new Map(); records.__proto__ = { get: fetch }; records.get("/accounts")',
'const records = new Map(); const alias = records; alias.__proto__ = { get: fetch }; records.get("/accounts")',
'const records = new Map(); records["__proto__"] = { delete: fetch }; records.delete("/accounts")',
'function inspect() { records.get("/accounts"); const records = new Map(); }',
'const alias = records; const records = new Map(); alias.get("/accounts")',
'function inspect() { const alias = records; const records = new Map(); alias.get("/accounts"); }',
'function inspect() { const records = new Map(); records.get = fetch; records.get("/accounts"); }',
'patch(Map.prototype); const records = new Map(); records.get("/accounts")',
'const prototype = Map.prototype; patch(prototype); new Map().get("/accounts")',
'patch(Map); new Map().get("/accounts")',
'new Patch(Map.prototype); new Map().get("/accounts")',
'Map.prototype.patch = function () { this.get = fetch; }; Map.prototype.patch(); new Map().get("/accounts")',
'Map.patch(); new Map().get("/accounts")',
'globalThis.patch = function () { this.Map.prototype.get = fetch; }; globalThis.patch(); new Map().get("/accounts")',
'window.open = function () { this.Map.prototype.get = fetch; }; window.open(); new Map().get("/accounts")',
'function invoke() { window.open(); } window.open = patch; invoke(); new Map().get("/accounts")',
'patch`prototype ${Map.prototype}`; new Map().get("/accounts")',
'const box = { prototype: Map.prototype }; patch(box); new Map().get("/accounts")',
'patch((() => Map.prototype)()); new Map().get("/accounts")',
'const prototype = (() => Map.prototype)(); prototype.get = fetch; new Map().get("/accounts")',
'const box = {}; box.prototype = Map.prototype; box.prototype.get = fetch; new Map().get("/accounts")',
'const box = {}; box.p = (anything(), Map.prototype); patch(box); new Map().get("/accounts")',
'window.prototype = Map.prototype; new Map().get("/accounts")',
'const box = new ExternalBox(); box.prototype = Map.prototype; new Map().get("/accounts")',
'function expose(box) { box.prototype = Map.prototype; } new Map().get("/accounts")',
'const factory = { make() { return Map.prototype; } }; factory.make().get = fetch; new Map().get("/accounts")',
'new Map().__proto__.get = fetch; new Map().get("/accounts")',
'new Map().constructor.prototype.get = fetch; new Map().get("/accounts")',
'Object.getPrototypeOf(new Map()).constructor.prototype.get = fetch; new Map().get("/accounts")',
'const holder = { get prototype() { return Map.prototype; } }; holder.prototype.get = fetch; new Map().get("/accounts")',
'const holder = { get ["prototype"]() { return { nested: Map.prototype }; } }; holder.prototype.nested.get = fetch; new Map().get("/accounts")',
'const holder = { get prototype() { return Map.prototype; } }; patch(holder); new Map().get("/accounts")',
'function store() { box.child.prototype = Map.prototype; } const box = {}; const inner = {}; box.child = inner; store(); patch(inner); new Map().get("/accounts")',
'function store() { box.child.next.prototype = Map.prototype; } const box = {}; const middle = {}; const inner = {}; middle.next = inner; box.child = middle; store(); patch(inner); new Map().get("/accounts")',
'const records = new Map(); Object.getPrototypeOf(records).get = fetch; new Map().get("/accounts")',
'const getPrototype = Reflect.getPrototypeOf; getPrototype(new Map()).get = fetch; new Map().get("/accounts")',
'patch(() => Map.prototype); new Map().get("/accounts")',
'const factory = { make() { return {}; } }; factory.make = () => Map.prototype; patch(factory.make()); new Map().get("/accounts")',
'function make() { return { prototype: Map.prototype }; } patch(make()); new Map().get("/accounts")',
'const factory = { prototype() { return Map.prototype; } }; patch(factory.prototype()); new Map().get("/accounts")',
'patch([Map.prototype]); new Map().get("/accounts")',
'Object.setPrototypeOf(Map.prototype, custom); new Map().get("/accounts")',
])("keeps unresolved lookup semantics as a candidate: %s", (source) => {
expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([
{ kind: "network", value: "/accounts", interpretation: "candidate" },
]);
});
it.each([
"records.delete = fetch;",
"patch(records);",
"Map.prototype.delete = fetch;",
"const alias = records; alias.__proto__ = { delete: fetch };",
])("retains genuine changes between Map operations: %s", (mutation) => {
const analysis = analyzeJavaScriptStaticSource(
`const records = new Map(); records.get('/before'); ${mutation} records.delete('/after');`,
);
expect(analysis.endpoints).toContainEqual(
expect.objectContaining({ value: "/after", interpretation: "candidate" }),
);
});
it.each(["get", "delete"])(
"does not assume an unchanged constructor adder for Map.%s",
(method) => {
const analysis = analyzeJavaScriptStaticSource(`
Map.prototype.set = function() { this.${method} = fetch; return this; };
new Map([['x', 1]]).${method}('/accounts');
`);
expect(analysis.endpoints).toContainEqual(
expect.objectContaining({
value: "/accounts",
interpretation: "candidate",
}),
);
},
);
it.each([
"box.prototype = Map.prototype;",
'const alias = box; const prototype = Map.prototype; alias["prototype"] = prototype;',
"({ value: box.prototype } = { value: Map.prototype });",
"({ value: box.prototype = Map.prototype } = {});",
"for (box.prototype of [Map.prototype]) {}",
])(
"retains prototype identities stored after container initialization: %s",
(store) => {
const source = `const box = {}; ${store}
function patch(b) { b.prototype.get = fetch; }
patch(box); new Map().get("/accounts");`;
expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([
{ kind: "network", value: "/accounts", interpretation: "candidate" },
]);
},
);
it.each([
['new Headers().get("content-type")', 0],
['new URLSearchParams().delete("q")', 0],
['new Map().get("/accounts")', 0],
['new Map().delete("/accounts")', 0],
['client.get("/accounts")', 0],
['const client = createClient(); client.get("/accounts")', 0],
['const records = new Map(); records.get("/accounts")', 1],
] as const)(
"requests mutation facts only for unresolved local receivers: %s",
(source, pending) => {
const file = parseJavaScriptSource(source);
if (file === null) throw new Error("Expected parsed source");
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(file.program),
);
completeSemanticSteps(index.indexAmbientWritesSteps());
const accumulator = createJavaScriptAnalysisAccumulator(source.length);
const context = {
source,
accumulator,
vitePreloadDependencyTable: null,
openReceiverFacts: index.receivers.openReceivers,
rpcScan: createJavaScriptRpcScan(
source,
index.receivers.rpcClientRoots,
index,
),
};
traverseJavaScriptAst(file.program, {
enter(node) {
if (
t.isCallExpression(node) ||
t.isOptionalCallExpression(node) ||
t.isNewExpression(node)
)
inspectCall(source, node, context, index.bindings);
},
});
expect(accumulator.pendingNetworkCalls).toHaveLength(pending);
},
);
@@ -0,0 +1,70 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
it.each([
'eval("Map.prototype.get = fetch"); new Map().get("/accounts");',
'eval("Map.prototype.delete = fetch"); new Map().delete("/accounts");',
'"use strict"; eval("Map.prototype.get = fetch"); new Map().get("/accounts");',
'function run() { eval("Map.prototype.get = fetch"); return new Map().get("/accounts"); }',
'function patch() { eval("Map.prototype.get = fetch"); } patch(); new Map().get("/accounts");',
'for (const item of [1, 2]) { new Map().get("/accounts"); eval("Map.prototype.get = fetch"); }',
'const run = eval; run("Map.prototype.get = fetch"); new Map().get("/accounts");',
'let run; (run = eval)("Map.prototype.get = fetch"); new Map().get("/accounts");',
'let run; (run = globalThis.eval)("Map.prototype.get = fetch"); new Map().get("/accounts");',
'(0, eval)("Map.prototype.get = fetch"); new Map().get("/accounts");',
'globalThis.eval("Map.prototype.get = fetch"); new Map().get("/accounts");',
'eval?.("Map.prototype.get = fetch"); new Map().get("/accounts");',
'let run = eval; run("Map.prototype.get = fetch"); run = () => {}; new Map().get("/accounts");',
'let run = () => {}; run = eval; run("Map.prototype.get = fetch"); new Map().get("/accounts");',
'const { eval: run } = globalThis; run("Map.prototype.get = fetch"); new Map().get("/accounts");',
'eval.call(null, "Map.prototype.get = fetch"); new Map().get("/accounts");',
'const run = eval.bind(null); run("Map.prototype.get = fetch"); new Map().get("/accounts");',
'globalThis["ev" + "al"]("Map.prototype.get = fetch"); new Map().get("/accounts");',
'const { ["eval"]: run } = globalThis; run("Map.prototype.get = fetch"); new Map().get("/accounts");',
'const root = globalThis; const run = root["eval"]; run("Map.prototype.get = fetch"); new Map().get("/accounts");',
'function invoke() { run("Map.prototype.get = fetch"); } const run = eval; invoke(); new Map().get("/accounts");',
'function getRun() { return eval; } getRun()("Map.prototype.get = fetch"); new Map().get("/accounts");',
'Reflect.apply(eval, null, ["Map.prototype.get = fetch"]); new Map().get("/accounts");',
])(
"keeps a Map lookup after possible opaque mutation as a candidate: %s",
(source) => {
expect(analyzeJavaScriptStaticSource(source).endpoints).toMatchObject([
{ kind: "network", value: "/accounts", interpretation: "candidate" },
]);
},
);
it.each([
'new Map().get("/accounts"); eval("Map.prototype.get = fetch");',
'(new Map().get("/accounts"), eval("Map.prototype.get = fetch"));',
'function run(eval) { eval("Map.prototype.get = fetch"); return new Map().get("/accounts"); }',
'const eval = () => {}; eval("Map.prototype.get = fetch"); new Map().get("/accounts");',
'for (const item of []) { eval("Map.prototype.get = fetch"); } new Map().get("/accounts");',
'const run = eval; new Map().get("/accounts"); run("Map.prototype.get = fetch");',
'function inspect(eval) { const run = eval; run("Map.prototype.get = fetch"); return new Map().get("/accounts"); }',
'let run = eval; run = () => {}; run("Map.prototype.get = fetch"); new Map().get("/accounts");',
'eval.bind(null); new Map().get("/accounts");',
])(
"preserves a Map proof when eval cannot precede the lookup: %s",
(source) => {
expect(analyzeJavaScriptStaticSource(source).endpoints).toEqual([]);
},
);
it("does not grant indirect eval access to the caller's lexical bindings", () => {
const analysis = analyzeJavaScriptSemantics(`
export function inspect() {
let value = 1;
const run = eval;
run("value = 2");
return value;
}
`);
expect(onlyCallable(analysis, "inspect").returnSites[0]?.value).toEqual({
status: "literal",
value: 1,
});
});
@@ -63,9 +63,9 @@ it.each(expressionWrappers)(
]),
).toEqual([
["setting", "candidate"],
["/api/header-shaped", null],
["https://api.example.test/config-shaped", null],
["/api/items", null],
["/api/header-shaped", "candidate"],
["https://api.example.test/config-shaped", "candidate"],
["/api/items", "candidate"],
["changed-setting", "candidate"],
["conditional-setting", "candidate"],
]);
@@ -197,7 +197,7 @@ it("retains endpoint observations for exact template and TypeScript member keys"
]),
)
.toEqual([
["network", "/api/items", "call:client.get", null],
["network", "/api/items", "call:client.get", "candidate"],
["network", "/api/telemetry", "call:navigator.sendBeacon", null],
["network", "setting", "call:client.get", "candidate"],
]);
@@ -1,4 +1,5 @@
import * as t from "@babel/types";
import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { htmlUrlText, stripQueryAndFragment } from "../artifactPathSyntax.js";
@@ -472,6 +473,33 @@ const inspectEndpointCall = (
// A local or shadowed function with a familiar network name is not a known
// browser request API. Do not let the literal fallback turn it into one.
if (isNetworkRequestSpelling(name)) return;
if (first !== undefined && /(?:^|\.)(?:get|delete)$/u.test(name)) {
// Apply existing literal exclusions before requesting whole-source alias
// mutation facts. Inline receivers and unbound names need no such index.
if (
endpointArgumentHelper(
name,
node.arguments,
node.callee,
context.openReceiverFacts?.get(node.start ?? -1),
) === undefined
)
return;
const mapLookup = isIntrinsicMapLookup(node, bindings);
if (mapLookup === true) return;
if (mapLookup === undefined) {
context.accumulator.pendingNetworkCalls.push({ node, name });
return;
}
}
inspectLiteralEndpointCall(node, name, context);
};
const inspectLiteralEndpointCall = (
node: JavaScriptCallLike,
name: string,
context: FindingContext,
): void => {
const receiverFact = context.openReceiverFacts?.get(node.start ?? -1);
const endpoint = endpointArgumentHelper(
name,
@@ -531,6 +559,11 @@ export const finishPartialNetworkEndpoints = (
index: JavaScriptBindingIndex,
): void => {
for (const { node, name } of context.accumulator.pendingNetworkCalls) {
if (!isKnownNetworkRequest(node, bindings)) {
if (!isIntrinsicMapLookup(node, bindings, index))
inspectLiteralEndpointCall(node, name, context);
continue;
}
const target = argumentNode(node.arguments[0]);
if (target === undefined) continue;
const resolved = resolvedRequestTarget(target, node, bindings, index);
@@ -655,7 +655,10 @@ export const endpointArgument = (
)
return undefined;
return {
kind: isRequestUrlReference(value) ? "endpoint" : "candidate",
kind:
method !== "get" && method !== "delete" && isRequestUrlReference(value)
? "endpoint"
: "candidate",
value,
};
}
@@ -42,7 +42,7 @@ export interface JavaScriptAnalysisAccumulator {
electronCallPathLimitReached?: boolean;
readonly references: LocatedJavaScriptFinding<JavaScriptStaticReference>[];
readonly endpoints: LocatedJavaScriptFinding<JavaScriptStaticEndpoint>[];
/** Dynamic targets held until the shared mutation index is requested on demand. */
/** Target and receiver identities awaiting the shared mutation index. */
readonly pendingNetworkCalls: {
readonly node:
| t.CallExpression
+74 -24
View File
@@ -64,6 +64,55 @@ it.each([
},
);
it.each([
[
"const records = new Map(); const alias = records; alias.get('/accounts');",
false,
],
["new Map().delete('/accounts');", false],
[
"const records = new Map(); records.get('/accounts'); records.delete('/accounts');",
false,
],
[
"const records = new Map(); records.get = fetch; records.get('/accounts');",
true,
],
[
"Object.getPrototypeOf = () => Map.prototype; Object.getPrototypeOf({}).get = fetch; new Map().get('/accounts');",
true,
],
["client.get('/accounts');", true],
["eval('Map.prototype.get = fetch'); new Map().get('/accounts');", true],
] as const)(
"shares Map receiver certainty with application analysis: %s",
(source, candidate) => {
const input = source + " fetch('/network');";
const result = analyzeCapturedWebBundle(inspection(input));
expect(result.observations.endpoints.map(({ value }) => value)).toEqual([
"/network",
]);
expect(
result.inferences
.filter(({ kind }) => kind === "endpoint_candidate")
.map(({ value }) => value),
).toEqual(candidate ? ["/accounts"] : []);
const staticResult = analyzeJavaScriptStaticSource(input);
expect(
staticResult.endpoints
.map(({ value, interpretation }) => [value, interpretation ?? null])
.sort(),
).toEqual(
candidate
? [
["/accounts", "candidate"],
["/network", null],
]
: [["/network", null]],
);
},
);
describe("web bundle analyzer", () => {
it("extracts graph, route, endpoint, vendor, and WebMCP evidence", () => {
const source = `
@@ -385,20 +434,20 @@ describe("web bundle API classification parity", () => {
);
expect(
result.observations.endpoints.map(({ value }) => value).sort(),
).toEqual([
"../search",
"/search",
"/users",
"/v1/headers",
"https://api.example.test/headers",
"https://api.example.test/users",
]);
).toEqual(["/search", "/v1/headers"]);
expect(
result.inferences
.filter(({ kind }) => kind === "endpoint_candidate")
.map(({ value }) => value)
.sort(),
).toEqual(["cache-key", "users"]);
).toEqual([
"../search",
"/users",
"cache-key",
"https://api.example.test/headers",
"https://api.example.test/users",
"users",
]);
});
it("keeps undici header-store reads out of endpoint observations", () => {
@@ -410,15 +459,12 @@ describe("web bundle API classification parity", () => {
api.get("/api/users");
`),
);
expect(result.observations.endpoints.map(({ value }) => value)).toEqual([
"/header-list",
"/api/users",
]);
expect(result.observations.endpoints).toEqual([]);
expect(
result.inferences
.filter(({ kind }) => kind === "endpoint_candidate")
.map(({ value }) => value),
).toEqual([]);
).toEqual(["/header-list", "/api/users"]);
});
});
@@ -495,13 +541,17 @@ describe("web bundle explicit request reference forms", () => {
fs.open(path, "r");
`;
const expected = [
...references,
"?view=1",
"#view=1",
"#literal=1",
"https:api.example.test/xhr",
].sort();
const expectedCandidates = [...ambiguous, "POST", "X"].sort();
const expectedCandidates = [
...references,
...ambiguous,
"POST",
"X",
].sort();
const web = analyzeCapturedWebBundle(inspection(source));
expect(web.observations.endpoints.map(({ value }) => value).sort()).toEqual(
expected,
@@ -545,11 +595,12 @@ describe("web bundle endpoint candidate classification", () => {
const result = analyzeCapturedWebBundle(inspection(source));
expect(
result.observations.endpoints.map(({ value }) => value).sort(),
).toEqual(["/api/users", "login", "ping"]);
).toEqual(["login", "ping"]);
const candidates = result.inferences.filter(
({ kind }) => kind === "endpoint_candidate",
);
expect(candidates.map(({ value }) => value).sort()).toEqual([
"/api/users",
"ADDRESS_ADDED",
"POST",
"X",
@@ -684,15 +735,13 @@ describe("web bundle configuration and navigation parity", () => {
"users",
];
const web = analyzeCapturedWebBundle(inspection(source));
expect(web.observations.endpoints.map(({ value }) => value)).toEqual([
"/v1/settings",
]);
expect(web.observations.endpoints).toEqual([]);
expect(
web.inferences
.filter(({ kind }) => kind === "endpoint_candidate")
.map(({ value }) => value)
.sort(),
).toEqual(expected.slice(1));
).toEqual(expected);
expect(
analyzeJavaScriptStaticSource(source)
.endpoints.map(({ value }) => value)
@@ -735,8 +784,8 @@ describe("web bundle configuration and navigation parity", () => {
};
};
const proven = {
endpoints: ["/v1/state"],
candidates: ["token", "users"],
endpoints: [],
candidates: ["/v1/state", "token", "users"],
};
expect(analyze(`const vscode = require("vscode");${reads}`)).toEqual({
web: proven,
@@ -747,8 +796,9 @@ describe("web bundle configuration and navigation parity", () => {
application: proven,
});
const unproven = {
endpoints: ["/v1/state"],
endpoints: [],
candidates: [
"/v1/state",
"closedRepositories",
"lastVersion",
"onboarded",
+34 -6
View File
@@ -8,6 +8,7 @@ import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascript/javascriptSemanticAnalysis.js";
import { completeSemanticSteps } from "./javascript/javascriptSemanticTraversal.js";
import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js";
import { isIntrinsicMapLookup } from "./javascript/javascriptSemanticMapOperations.js";
import {
semanticStaticPropertyKey,
semanticStaticPropertyName,
@@ -69,8 +70,21 @@ export const analyzeScript = (
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(file.program),
);
completeSemanticSteps(index.indexAmbientWritesSteps());
const { receivers } = index;
const openReceiverFacts = receivers.openReceivers;
let mutationFactsIndexed = false;
const isMapLookup = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
): boolean => {
const proof = isIntrinsicMapLookup(node, index.bindings);
if (proof !== undefined) return proof;
if (!mutationFactsIndexed) {
completeSemanticSteps(index.indexMemberMutationFactsSteps());
mutationFactsIndexed = true;
}
return isIntrinsicMapLookup(node, index.bindings, index) === true;
};
const rpc = createJavaScriptRpcScan(
script.source.artifact.text,
receivers.rpcClientRoots,
@@ -79,7 +93,7 @@ export const analyzeScript = (
traverseJavaScriptAst(file, {
enter: (node) => {
accumulator.visitedNodes += 1;
inspectNode(script, node, accumulator, openReceiverFacts);
inspectNode(script, node, accumulator, openReceiverFacts, isMapLookup);
const endpoint = rpc.inspect(node);
if (endpoint !== undefined)
addFinding({
@@ -109,6 +123,9 @@ const inspectNode = (
node: t.Node,
accumulator: AnalysisAccumulator,
openReceiverFacts: ReadonlyMap<number, JavaScriptOpenReceiverFact>,
isMapLookup: (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
) => boolean,
): void => {
if (
(t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) &&
@@ -146,7 +163,7 @@ const inspectNode = (
t.isOptionalCallExpression(node) ||
t.isNewExpression(node)
)
inspectCall(script, node, accumulator, openReceiverFacts);
inspectCall(script, node, accumulator, openReceiverFacts, isMapLookup);
};
const inspectCall = (
@@ -154,6 +171,9 @@ const inspectCall = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
accumulator: AnalysisAccumulator,
openReceiverFacts: ReadonlyMap<number, JavaScriptOpenReceiverFact>,
isMapLookup: (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
) => boolean,
): void => {
const name = calleeName(node.callee);
const first = stringValue(node.arguments[0]);
@@ -198,13 +218,21 @@ const inspectCall = (
node.callee,
openReceiverFacts.get(node.start ?? -1),
);
if (endpoint?.kind === "candidate")
addEndpointCandidate(script, node, name, endpoint.value, accumulator);
else if (endpoint !== undefined)
const networkEndpoint =
endpoint !== undefined && !isMapLookup(node) ? endpoint : undefined;
if (networkEndpoint?.kind === "candidate")
addEndpointCandidate(
script,
node,
name,
networkEndpoint.value,
accumulator,
);
else if (networkEndpoint !== undefined)
addFinding({
collection: accumulator.endpoints,
script,
rawValue: endpoint.value,
rawValue: networkEndpoint.value,
mechanism: `call:${name}`,
node,
accumulator,