Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 733c160d4f fix(javascript): escape repeated prefixes in front of copied-slot selections
Member-mutation normalization keeps any path carrying a copied-slot selection
exact and never prunes it, but the repeated-prefix rule only escaped a
repeating prefix when its projected origins proved it unprunable. A self alias
in front of a spread selection, such as `const t = { ...e.opts }; e = e.value;
consume(t)` with a parameter `e`, therefore grew `[value, value, ..., opts,
<selection>]` without bound until the worker heap was exhausted. The owner of
both rules now reports a selection-carrying path as never pruned, so the
prefix escapes once as designed.

Obsidian's bundled pdf.js viewer (Fluent's FluentDateTime constructor)
exhausted a 6 GB heap in 36 s and now completes its semantic phase in 0.9 s.
Semantic IR is byte-identical on the 151 comparable real files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:53:59 +00:00
4 changed files with 37 additions and 52 deletions

No files matched your search

@@ -665,3 +665,24 @@ describe("references captured before an intermediate rebind", () => {
},
);
});
describe("self aliases in front of copied-slot selections", () => {
// Normalization keeps copied-slot selections exact, so a self alias in front
// of one must escape its repeated prefix instead of growing the path.
it.each([
["seed.opts.inner.token", "unknown"],
["seed.value.opts.inner.token", "unknown"],
["seed.keep", "literal"],
])(
"escapes the repeated prefix: %s",
(read, status) => {
const start = performance.now();
const value = resultValue(
`const seed = { keep: 3, opts: { inner: { token: 1 } }, value: { opts: { inner: { token: 2 } } } }; let e = flag ? seed : seed.value; const t = { ...e.opts }; e = e.value; consume(t); return ${read};`,
);
expect(performance.now() - start).toBeLessThan(2000);
expect(value?.status).toBe(status);
},
30000,
);
});
@@ -82,29 +82,6 @@ it("bounds shared partial callback alias expansion by AST size", () => {
expect(large.referenceReads).toBeLessThan(3 * small.referenceReads);
});
it("bounds reassignment meshes by write pairs when reads select every write", () => {
const measure = (writes: number) => {
const context = indexed(`function f(){} function g(){} let a = f, b = g;
function run(){ ${Array.from({ length: writes }, (_, i) => (i % 2 === 0 ? "a=b;" : "b=a;")).join("")} a(); }`);
const run = context.state.program.body.at(-1);
if (!t.isFunctionDeclaration(run)) throw new Error("Missing run");
const last = run.body.body.at(-1);
if (!t.isExpressionStatement(last) || !t.isCallExpression(last.expression))
throw new Error("Missing call");
const reads = vi.spyOn(context.bindingCache, "get");
const result = context.resolve(last.expression.callee);
expect(result).toMatchObject({ complete: false, cycleDependent: true });
expect(
result.callableIds.map((id) => context.callableById.get(id)?.name).sort(),
).toEqual(["f", "g"]);
return reads.mock.calls.length;
};
const small = measure(6);
const large = measure(10);
expect(small).toBeGreaterThan(0);
expect(large).toBeLessThan(3 * small);
});
it.each([
["if(flag){cb=fallback;cb();}", true],
["if(flag)cb=fallback;cb();", false],
@@ -33,8 +33,6 @@ interface CallResolutionContext {
readonly state: JavaScriptSemanticAnalysisState;
readonly callableById: ReadonlyMap<string, JavaScriptSemanticCallable>;
readonly seenInitializers: ReadonlySet<JavaScriptSemanticInitializer>;
/** Bindings on the active chain whose read selected every initializer. */
readonly seenBindings: ReadonlySet<JavaScriptSemanticBindingState>;
readonly bindingCache: WeakMap<t.Identifier, LocalCallableResolution>;
}
@@ -55,7 +53,6 @@ export const resolveLocalCallables = (
state: input.state,
callableById: input.callableById,
seenInitializers: input.seenInitializers,
seenBindings: new Set(),
bindingCache: input.bindingCache,
});
@@ -106,28 +103,7 @@ const resolveIdentifier = (
node,
context.state.parentsByNode,
);
// A read that selects every initializer resolves the same candidates
// wherever the chain re-enters it, and a longer chain can only cut more of
// them, so re-entry adds no target. Closing that cycle at the binding bounds
// an alias mesh by pairs of writes instead of every initializer order.
// Position-narrowed reads keep their per-initializer cycles.
const selectsAll = read.binding === binding;
if (selectsAll && context.seenBindings.has(binding))
return {
callableIds: [],
complete: false,
cycleDependent: true,
reason: `Call target initializer cycle at ${binding.name}.`,
};
const candidates = resolveBindingCallables(
read.binding,
selectsAll
? {
...context,
seenBindings: new Set(context.seenBindings).add(binding),
}
: context,
);
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 =
@@ -385,7 +385,11 @@ export function* collectSemanticMemberMutationsSteps(
}
if (
repeatedPrefix !== undefined &&
observablePaths.repeatsUnpruned(binding.bindingId, repeatedPrefix)
observablePaths.repeatsUnpruned(
binding.bindingId,
repeatedPrefix,
current.path,
)
) {
markValue(
current.node,
@@ -1066,6 +1070,10 @@ interface MutationObserverNode {
};
}
/** Copied-slot selections (from spreads and rests) stay exact path keys. */
const carriesSelection = (path: PropertyPath): boolean =>
path.some((key) => key !== null && typeof key === "object");
const observableMutationPaths = (
state: JavaScriptSemanticAnalysisState,
): {
@@ -1077,6 +1085,7 @@ const observableMutationPaths = (
readonly repeatsUnpruned: (
bindingId: string,
prefix: PropertyPath,
path: PropertyPath,
) => boolean;
} => {
const bindingNodes = new Map<string, MutationObserverNode>();
@@ -1299,9 +1308,7 @@ const observableMutationPaths = (
effect: ValueEffect,
): PropertyPath | null => {
let observer = bindingNodes.get(bindingId);
if (observer === undefined) return path;
if (path.some((key) => key !== null && typeof key === "object"))
return path;
if (observer === undefined || carriesSelection(path)) return path;
if (effect === "escape") {
observer = project(observer, path);
settle();
@@ -1323,11 +1330,15 @@ const observableMutationPaths = (
// repeating a prefix revisits an origin set, every longer repetition stays
// observable and normalization can never prune it. The answer depends only
// on the binding and prefix, so every revisit of a long chain shares it.
// Normalization keeps copied-slot selections exact, so it never prunes a
// path carrying one, however often a prefix repeats in front of it.
const repeated = new Map<string, boolean>();
const repeatsUnpruned = (
bindingId: string,
prefix: PropertyPath,
path: PropertyPath,
): boolean => {
if (carriesSelection(path)) return true;
const key = JSON.stringify([bindingId, prefix]);
const cached = repeated.get(key);
if (cached !== undefined) return cached;