fix(javascript): preserve incoming callback target uncertainty (#4243)

Preserve incoming callback uncertainty and resolve proven preceding writes.
Share read-position and initializer identity facts with returned-reference
analysis; cache completed projected work with actual cycle dependencies.

Fixes #3880
This commit is contained in:
Ysopwnable
2026-10-16 10:09:22 +08:00
committed by GitHub
parent 1bd5c5c87e
commit 42fc6c53ae
8 changed files with 822 additions and 118 deletions
@@ -6,14 +6,17 @@ import type {
} from "./javascriptSemanticIr.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import {
unwrapJavaScriptExpression,
runtimeFunctionParameters,
} from "./javascriptAstValues.js";
import {
resolveSemanticBindingState,
semanticStableInitializer,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
type JavaScriptSemanticInitializer,
} from "./javascriptSemanticState.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
@@ -22,13 +25,15 @@ export interface LocalCallableResolution {
readonly callableIds: readonly string[];
readonly complete: boolean;
readonly reason: string;
/** A cycle cut depends on the active alias path and cannot be memoized. */
readonly cycleDependent?: true;
}
interface CallResolutionContext {
readonly state: JavaScriptSemanticAnalysisState;
readonly callableById: ReadonlyMap<string, JavaScriptSemanticCallable>;
readonly seenBindings: ReadonlySet<string>;
readonly bindingCache: Map<string, LocalCallableResolution>;
readonly seenInitializers: ReadonlySet<JavaScriptSemanticInitializer>;
readonly bindingCache: WeakMap<t.Identifier, LocalCallableResolution>;
}
/** Inputs needed to resolve one local call target. */
@@ -36,8 +41,8 @@ export interface ResolveLocalCallablesInput {
readonly node: t.Node;
readonly state: JavaScriptSemanticAnalysisState;
readonly callableById: ReadonlyMap<string, JavaScriptSemanticCallable>;
readonly seenBindings: ReadonlySet<string>;
readonly bindingCache: Map<string, LocalCallableResolution>;
readonly seenInitializers: ReadonlySet<JavaScriptSemanticInitializer>;
readonly bindingCache: WeakMap<t.Identifier, LocalCallableResolution>;
}
/** Resolve local callable candidates without following dynamic properties. */
@@ -47,7 +52,7 @@ export const resolveLocalCallables = (
resolveCallables(input.node, {
state: input.state,
callableById: input.callableById,
seenBindings: input.seenBindings,
seenInitializers: input.seenInitializers,
bindingCache: input.bindingCache,
});
@@ -84,14 +89,39 @@ const resolveIdentifier = (
node: t.Identifier,
context: CallResolutionContext,
): LocalCallableResolution => {
const cached = context.bindingCache.get(node);
if (cached !== undefined) return cached;
const binding = resolveSemanticBindingState(context.state, node, node.name);
return binding === undefined
? {
callableIds: [],
complete: false,
reason: `Unresolved call target ${node.name}.`,
}
: resolveBindingCallables(binding, context);
if (binding === undefined)
return {
callableIds: [],
complete: false,
reason: `Unresolved call target ${node.name}.`,
};
const read = semanticBindingAtRead(
binding,
node,
context.state.parentsByNode,
);
const candidates = resolveBindingCallables(read.binding, context);
// Knowing every local candidate does not prove that any write replaced the
// incoming value, or that a write in another activation precedes this read.
const covered =
read.hasDefiniteInitializer ||
semanticStableInitializer(binding, context.state) !== undefined;
const resolution =
candidates.complete && !covered
? {
...candidates,
complete: false,
reason: `Call target ${binding.name} may retain an incoming value or an unordered assignment.`,
}
: candidates;
// Key by the immutable read, not binding identity: before/after snapshots
// differ. Cache cycle-independent partial results too, keeping alias
// diamonds linear even when their shared leaf has an unknown incoming value.
if (!resolution.cycleDependent) context.bindingCache.set(node, resolution);
return resolution;
};
const resolveAlternatives = (
@@ -108,6 +138,9 @@ const resolveAlternatives = (
),
complete: candidates.every(({ complete }) => complete),
reason: "Conditional or logical call target has multiple candidates.",
...(candidates.some((candidate) => candidate.cycleDependent)
? { cycleDependent: true as const }
: {}),
};
};
@@ -115,28 +148,31 @@ const resolveBindingCallables = (
binding: JavaScriptSemanticBindingState,
context: CallResolutionContext,
): LocalCallableResolution => {
const cached = context.bindingCache.get(binding.bindingId);
if (cached !== undefined) return cached;
if (context.seenBindings.has(binding.bindingId))
return {
callableIds: [],
complete: false,
reason: `Call target alias cycle at ${binding.name}.`,
};
if (binding.initializers.length === 0)
return missingBindingInitializer(binding);
const candidateContext = {
...context,
seenBindings: new Set([...context.seenBindings, binding.bindingId]),
};
const candidates = binding.initializers.map(({ node, projection }) =>
projection.length === 0
? resolveCallables(node, candidateContext)
: {
const candidates = binding.initializers.map(
(initializer): LocalCallableResolution => {
if (context.seenInitializers.has(initializer))
return {
callableIds: [],
complete: false,
reason: `Projected call target ${binding.name} is unresolved.`,
},
cycleDependent: true,
reason: `Call target initializer cycle at ${binding.name}.`,
};
return initializer.projection.length === 0
? resolveCallables(initializer.node, {
...context,
seenInitializers: new Set([
...context.seenInitializers,
initializer,
]),
})
: {
callableIds: [],
complete: false,
reason: `Projected call target ${binding.name} is unresolved.`,
};
},
);
const callableIds = uniqueCallableIds(
candidates.flatMap((candidate) => candidate.callableIds),
@@ -148,9 +184,10 @@ const resolveBindingCallables = (
callableIds.length === 1 && candidates.every(({ complete }) => complete)
? "Unique local callable target."
: `Call target ${binding.name} has ambiguous or unsupported assignments.`,
...(candidates.some((candidate) => candidate.cycleDependent)
? { cycleDependent: true as const }
: {}),
};
if (resolution.complete)
context.bindingCache.set(binding.bindingId, resolution);
return resolution;
};