fix(javascript): bound precise alias projection histories before expansion

This commit is contained in:
morluto committed 2026-10-24 08:22:14 +08:00
1 parent eb721ebbf1
commit 4394e8b592
4 files changed
+363 -29

No files matched your search

@@ -0,0 +1,173 @@
import { expect, it } from "vitest";
import {
analyzeJavaScriptSemantics,
indexJavaScriptBindingsWithReceiversSteps,
} from "./javascriptSemanticAnalysis.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import {
joinSemanticPropertyPaths,
semanticPropertyPathKeyMatches,
} from "./javascriptSemanticPropertyPaths.js";
const resultValue = (body: string) =>
onlyCallable(
analyzeJavaScriptSemantics(`export function result() { ${body} }`),
"result",
).returnSites[0]?.value;
it("retains common copy exclusions and the union of array prefixes", () => {
const path = joinSemanticPropertyPaths(
[{ excludedKeys: ["1", "2"], startIndex: 1 }],
[{ excludedKeys: ["2", "3"], startIndex: 2 }],
);
const key = path?.[0];
if (key === undefined) throw new Error("Expected joined projection");
expect(
["0", "1", "2", "3", "4", "named"].filter((name) =>
semanticPropertyPathKeyMatches(key, name),
),
).toEqual(["1", "3", "4"]);
const withScalar = joinSemanticPropertyPaths(path ?? [], [0]);
const scalarKey = withScalar?.[0];
if (scalarKey === undefined) throw new Error("Expected joined scalar");
expect(
["0", "1", "2", "3", "named"].filter((name) =>
semanticPropertyPathKeyMatches(scalarKey, name),
),
).toEqual(["0", "1", "3"]);
const exact = [1] as const;
expect(joinSemanticPropertyPaths(exact, ["1"])).toBe(exact);
});
const memberAliasSource = (count: number) => `
import { Worker } from "node:worker_threads";
const alias0 = globalThis;
${Array.from(
{ length: count },
(_, index) =>
`const alias${index + 1} = flag${index} ? alias${index}.globalThis : alias${index}.window;`,
).join("\n")}
alias${count}.URL = replacement;
new Worker(new URL("./worker.js", import.meta.url));
`;
it("joins overflowing member-alias histories into disclosed possible effects", () => {
const source = memberAliasSource(16);
const ir = analyzeJavaScriptSemantics(source);
expect(ir.coverage).toMatchObject({
status: "partial",
omittedCount: null,
resourceLimits: ["alias-selections"],
});
expect(ir.limitations).toContain(
semanticResourceLimitReason("alias-selections"),
);
const facts = analyzeJavaScriptStaticSource(source);
expect(facts.limitations).toContain(
semanticResourceLimitReason("alias-selections"),
);
expect(
facts.references.filter(({ kind }) => kind === "worker"),
).toMatchObject([{ specifier: null }]);
});
it("never records a joined selector as a definite receiver replacement", () => {
const parsed = parseJavaScriptSource(memberAliasSource(16));
if (parsed === null) throw new Error("Expected parsed source");
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
);
completeSemanticSteps(index.indexMemberMutationFactsSteps());
const joined = [...index.bindings.bindingsById.values()]
.flatMap(({ memberWrites }) => memberWrites)
.filter(({ path }) => path.includes(null));
expect(joined.length).toBeGreaterThan(0);
expect(
joined.every(({ receiverIdentity }) => receiverIdentity === "possible"),
).toBe(true);
});
it("keeps small selector alternatives exact and preserves an untouched third sibling", () => {
const ir = analyzeJavaScriptSemantics(`
export function result() {
const source = { left: { value: 1 }, right: { value: 3 }, third: { value: 2 } };
const selected = flag ? source.left : source.right;
selected.value = replacement;
return source.third.value;
}
`);
expect(onlyCallable(ir, "result").returnSites[0]?.value).toEqual({
status: "literal",
value: 2,
});
expect(ir.coverage.resourceLimits ?? []).not.toContain("alias-selections");
});
it.each([
"const left={value:1}; const right={value:2}; const window={left,right}; const selected=flag?window.left:window.right; selected.value=9; return left.value;",
"const left={value:1}; const right={value:2}; const globalThis={window:left,globalThis:right}; const selected=flag?globalThis.window:globalThis.globalThis; selected.value=9; return left.value;",
"const local={value:1}; const selected=flag?local:external; selected.value=9; return local.value;",
])("retains local and unresolved mutation targets: %s", (body) => {
expect(resultValue(body)?.status).toBe("unknown");
});
it.each(["first", "second"])(
"retains both captured targets across replacement: %s",
(name) => {
expect(
resultValue(`
const first = { value: 1 };
const second = { value: 2 };
let current = first;
const before = current;
current = second;
const after = current;
const selected = flag ? before : after;
selected.value = 9;
return ${name}.value;
`)?.status,
).toBe("unknown");
},
);
it.each([
"const first={value:1}; const second={value:2}; const captured=first; let selected=first; selected=second; const alias=flag?captured:captured; alias.value=9; return second.value;",
"const first={value:2}; const second={value:1}; let selected=first; selected=second; const captured=selected; const alias=flag?captured:captured; alias.value=9; return first.value;",
"const source={left:{value:1},right:{value:2}}; source.left.value=9; return source.right.value;",
])("preserves captured ownership and unjoined siblings: %s", (body) => {
expect(resultValue(body)).toEqual({ status: "literal", value: 2 });
});
it("preserves exclusions shared by shallow-copy alternatives", () => {
expect(
resultValue(`
const excluded = { value: 2 };
const first = { value: 1 };
const second = { value: 3 };
const source = { excluded, first, second };
const { excluded: leftExcluded, first: leftFirst, ...left } = source;
const { excluded: rightExcluded, second: rightSecond, ...right } = source;
const selected = flag ? left : right;
mutate(selected);
return excluded.value;
`),
).toEqual({ status: "literal", value: 2 });
});
it("keeps a preceding global self-property replacement unresolved", () => {
const facts = analyzeJavaScriptStaticSource(`
import { Worker } from "node:worker_threads";
globalThis.window = replacement;
const selected = flag ? globalThis.window : globalThis;
selected.URL = replacement;
new Worker(new URL("./worker.js", import.meta.url));
`);
expect(
facts.references.filter(({ kind }) => kind === "worker"),
).toMatchObject([{ specifier: null }]);
});
@@ -21,6 +21,7 @@ import {
semanticIterationSources,
type SemanticIterationSource,
} from "./javascriptSemanticIterationSources.js";
import { joinSemanticPropertyPaths } from "./javascriptSemanticPropertyPaths.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
import {
semanticCopiedReferencePath,
@@ -189,10 +190,8 @@ export const createAmbientPathResolver = (
continue;
}
if (suffix !== undefined && previous.length === suffix.length) {
const joined: AmbientPath = previous.map((key, index) =>
key === suffix?.[index] ? key : null,
);
if (joined.every((key, index) => key === previous[index])) {
const joined = joinSemanticPropertyPaths(previous, suffix);
if (joined === previous) {
resolvedIdentity = true;
continue;
}
@@ -73,6 +73,7 @@ import {
} from "./javascriptSemanticReferenceSources.js";
import {
semanticArrayIndex,
joinSemanticPropertyPaths,
SemanticPropertyPathCoverage,
semanticPropertyPathKeyMatches,
type JavaScriptSemanticPropertyPath,
@@ -661,31 +662,46 @@ export function* collectSemanticMemberMutationsSteps(
definiteReceiver = true,
selection?: MutationSelectionContext,
): void => {
const pending: (ReferencedValue &
type JoinedProjection = {
readonly path: PropertyPath;
readonly definiteReceiver: boolean;
scheduled: boolean;
};
type PendingValue = ReferencedValue &
MutationSelectionContext & {
readonly bindings: ReadonlySet<string>;
readonly originAt: t.Node | undefined;
readonly definiteReceiver: boolean;
})[] = [
{
node,
path,
bindings,
originAt,
definiteReceiver,
memberReadAt: selection?.memberReadAt ?? mutation ?? originAt,
capturedSelections: selection?.capturedSelections,
...(ownerInitializer === undefined ? {} : { ownerInitializer }),
},
];
// Visit shallower alias traversals first so their exact prefixes suppress
// longer loop paths before those paths enumerate every assignment order.
for (let cursor = 0; cursor < pending.length; cursor++) {
const candidate = pending[cursor];
if (candidate === undefined) break;
// A capture deeper than the selected path is no longer applicable. Keeping
// it would manufacture new contexts on a repeated source alias cycle.
const current = candidate.capturedSelections?.length
readonly exactKey?: string;
readonly joined?: {
readonly key: string;
readonly value: JoinedProjection;
};
};
const pending: PendingValue[] = [];
// The effect, mutation and assigned sources belong to this invocation.
// Alias histories do not select a value: the binding/capture/projection do.
// Keep small alternatives exact. Bound additional states across this whole
// query before scheduling/projecting or copying capture-history sets, then
// visit the complete same-depth may-projection closure with monotone joins.
const joinedProjections = new Map<string, JoinedProjection>();
const exactProjections = new Map<string, Set<string>>();
let additionalAlternatives = 0;
let projectionsWidened = false;
const nodeIdentities = new WeakMap<t.Node, number>();
let nextNodeIdentity = 0;
const nodeIdentity = (node: t.Node | undefined): number | null => {
if (node === undefined) return null;
const prior = nodeIdentities.get(node);
if (prior !== undefined) return prior;
const identity = nextNodeIdentity++;
nodeIdentities.set(node, identity);
return identity;
};
const enqueue = (candidate: PendingValue): void => {
// A capture deeper than this projection is no longer applicable. Keep
// the canonical context before indexing or scheduling alias histories.
const value = candidate.capturedSelections?.length
? {
...candidate,
capturedSelections: semanticNormalizeCapturedSelections(
@@ -694,6 +710,101 @@ export function* collectSemanticMemberMutationsSteps(
),
}
: candidate;
const expression = unwrapJavaScriptExpression(value.node).node;
const binding = t.isIdentifier(expression)
? resolveBinding(expression, expression.name)
: undefined;
if (binding === undefined) {
pending.push(value);
return;
}
const context = JSON.stringify([
binding.bindingId,
nodeIdentity(value.originAt),
nodeIdentity(value.ownerInitializer),
nodeIdentity(value.memberReadAt),
value.definiteReceiver,
value.path.length,
value.widenedGetterResult === true,
]);
const captures = value.capturedSelections?.map(
({ at, depth, ownEnumerable }) => [
nodeIdentity(at),
depth,
ownEnumerable === true,
],
);
// Capture vectors are additional precise states, but distinct captured
// reads keep separate may-projection joins after the budget is reached.
const key = JSON.stringify([context, captures]);
if (!projectionsWidened) {
const exact = exactProjections.get(context) ?? new Set<string>();
const selection = JSON.stringify([captures, value.path]);
if (exact.has(selection)) return;
if (
exact.size > 0 &&
++additionalAlternatives > SEMANTIC_ALIAS_SELECTION_LIMIT
) {
projectionsWidened = true;
state.valueResourceLimits.add("alias-selections");
exactProjections.clear();
} else {
exact.add(selection);
exactProjections.set(context, exact);
}
}
const prior = joinedProjections.get(key);
const path =
prior === undefined
? value.path
: joinSemanticPropertyPaths(prior.path, value.path);
if (path === undefined) throw new Error("Projection depth key mismatch");
const definiteReceiver =
value.definiteReceiver &&
(prior?.definiteReceiver ?? true) &&
path.every((key) => key !== null && typeof key !== "object");
const joined =
prior !== undefined &&
path === prior.path &&
definiteReceiver === prior.definiteReceiver
? prior
: { path, definiteReceiver, scheduled: false };
joinedProjections.set(key, joined);
if (!projectionsWidened) {
pending.push({ ...value, exactKey: key });
return;
}
if (joined.scheduled) return;
joined.scheduled = true;
pending.push({
...value,
path,
definiteReceiver,
joined: { key, value: joined },
});
};
enqueue({
node,
path,
bindings,
originAt,
definiteReceiver,
memberReadAt: selection?.memberReadAt ?? mutation ?? originAt,
capturedSelections: selection?.capturedSelections,
...(ownerInitializer === undefined ? {} : { ownerInitializer }),
});
// Visit shallower alias traversals first so their exact prefixes suppress
// longer loop paths before those paths enumerate every assignment order.
for (let cursor = 0; cursor < pending.length; cursor++) {
const current = pending[cursor];
if (current === undefined) break;
if (
(current.exactKey !== undefined &&
joinedProjections.get(current.exactKey)?.scheduled === true) ||
(current.joined !== undefined &&
joinedProjections.get(current.joined.key) !== current.joined.value)
)
continue;
const expression = unwrapJavaScriptExpression(current.node).node;
const required = semanticRequireOrigin(expression, state);
if (required !== undefined) {
@@ -1005,7 +1116,7 @@ export function* collectSemanticMemberMutationsSteps(
// and captures; a local container does not isolate its children.
if (receiver !== undefined) {
for (const source of receiver.sources)
pending.push({
enqueue({
node: source.node,
path: [...source.path, current.path.at(-1)!],
bindings: nested,
@@ -1024,7 +1135,7 @@ export function* collectSemanticMemberMutationsSteps(
for (const initializer of retainsInitializers
? origins.initializers
: [])
pending.push({
enqueue({
node: initializer.node,
path: [...initializer.projection, ...path],
bindings: nested,
@@ -1082,7 +1193,7 @@ export function* collectSemanticMemberMutationsSteps(
expression.property,
expression.computed,
);
pending.push({
enqueue({
node: expression.object,
path: [key, ...current.path],
bindings: current.bindings,
@@ -1155,7 +1266,7 @@ export function* collectSemanticMemberMutationsSteps(
...(mutation === undefined ? {} : { mutation }),
});
else
pending.push({
enqueue({
...value,
bindings: current.bindings,
definiteReceiver:
@@ -12,6 +12,57 @@ export type JavaScriptSemanticPropertyPath = readonly (
| JavaScriptSemanticPropertySelection
)[];
/** Join equal-depth may-effect projections without claiming an exact selector. */
export function joinSemanticPropertyPaths(
previous: readonly (string | null)[],
incoming: readonly (string | null)[],
): readonly (string | null)[] | undefined;
export function joinSemanticPropertyPaths(
previous: JavaScriptSemanticPropertyPath,
incoming: JavaScriptSemanticPropertyPath,
): JavaScriptSemanticPropertyPath | undefined;
export function joinSemanticPropertyPaths(
previous: JavaScriptSemanticPropertyPath,
incoming: JavaScriptSemanticPropertyPath,
): JavaScriptSemanticPropertyPath | undefined {
if (previous.length !== incoming.length) return undefined;
const joined = previous.map((key, index) =>
joinPropertySelections(key, incoming[index]!),
);
return joined.every((key, index) => key === previous[index])
? previous
: joined;
}
const joinPropertySelections = (
previous: JavaScriptSemanticPropertyPath[number],
incoming: JavaScriptSemanticPropertyPath[number],
): JavaScriptSemanticPropertyPath[number] => {
if (keyCovers(previous, incoming)) return previous;
if (keyCovers(incoming, previous)) return incoming;
if (previous === null || incoming === null) return null;
if (typeof previous !== "object" && typeof incoming !== "object") return null;
if (typeof previous === "object" && typeof incoming === "object")
return {
excludedKeys: previous.excludedKeys.filter((key) =>
incoming.excludedKeys.includes(key),
),
...(previous.startIndex === undefined || incoming.startIndex === undefined
? {}
: { startIndex: Math.min(previous.startIndex, incoming.startIndex) }),
};
const selected = typeof previous === "object" ? previous : incoming;
const scalar = typeof previous === "object" ? incoming : previous;
if (typeof selected !== "object" || typeof scalar === "object") return null;
const index = semanticArrayIndex(scalar);
return {
excludedKeys: selected.excludedKeys.filter((key) => key !== String(scalar)),
...(selected.startIndex === undefined || index === null
? {}
: { startIndex: Math.min(selected.startIndex, index) }),
};
};
/** Read only canonical JavaScript array indices, excluding named properties. */
export const semanticArrayIndex = (key: string | number): number | null => {
const index = Number(key);