fix(javascript): bound member-mutation paths through reassignment loops

Fixes #1401. Refs #1065. Preserve exact member facts and equal-depth write effects while bounding the observed reassignment-loop expansion.

Validated with the JavaScript semantic/application suite, focused alias regressions, full static checks, real compiled CLI and SDK MCP Notion workflows, and all 20 checks on the final PR head.

Co-Authored-By: Marnix Pluim <286664873+marnixpluim@users.noreply.github.com>
Co-Authored-By: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 12:03:13 +08:00
1 parent 98022a8352
commit 28595276fa
4 files changed
+524 -14

No files matched your search

@@ -18,6 +18,7 @@ import { semanticIterationSources } from "./javascriptSemanticIterationSources.j
import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js";
import {
semanticArrayIndex,
SemanticPropertyPathCoverage,
semanticPropertyPathKeyMatches,
type JavaScriptSemanticPropertyPath,
} from "./javascriptSemanticPropertyPaths.js";
@@ -42,8 +43,10 @@ export const collectSemanticMemberMutations = (
if (parent !== null) parents.set(node, parent);
},
});
const normalizePath = observableMutationPaths(state);
const recordedEffects = new Set<string>();
const selections = new Map<string, readonly string[]>();
const coveringPaths = new Map<string, SemanticPropertyPathCoverage>();
let arrayIterationUnknown = false;
const pendingReferences: {
readonly initializer: JavaScriptSemanticBindingState["referenceInitializers"][number];
@@ -93,8 +96,10 @@ export const collectSemanticMemberMutations = (
originAt: t.Node | undefined = mutation,
): void => {
const pending = [{ node, path, bindings, originAt }];
while (pending.length > 0) {
const current = pending.pop();
// 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;
const expression = unwrapJavaScriptExpression(current.node).node;
if (t.isIdentifier(expression)) {
@@ -126,10 +131,13 @@ export const collectSemanticMemberMutations = (
if (current.bindings.has(lifetime)) continue;
// Union compatible copied-slot selections at each binding. Carrying every
// combination of exclusions through branch diamonds is exponential.
const selectorIndex = current.path.findIndex(
// Preserve paths that can change observed slots or Array prototype
// effects; canonicalize suffixes that cannot distinguish lattice facts.
let path = normalizePath(binding.bindingId, current.path, effect);
if (path === null) continue;
const selectorIndex = path.findIndex(
(key) => key !== null && typeof key === "object",
);
let path = current.path;
const selector = path[selectorIndex];
if (selector !== null && typeof selector === "object") {
const selectionIdentity = JSON.stringify([
@@ -157,6 +165,25 @@ export const collectSemanticMemberMutations = (
index === selectorIndex ? { ...selector, excludedKeys } : key,
);
}
// Keep an antichain of exact paths. Widening distinct member keys would
// also invalidate unrelated siblings that never reach this effect.
const pathState = JSON.stringify([
binding.bindingId,
effect,
// Short writes stop at owned literal slots, whereas longer writes
// reach shared children. Only equal-depth writes cover one another.
effect === "write" ? path.length : null,
mutation?.start,
mutation?.end,
current.originAt?.start,
current.originAt?.end,
]);
let covering = coveringPaths.get(pathState);
if (covering === undefined) {
covering = new SemanticPropertyPathCoverage();
coveringPaths.set(pathState, covering);
}
if (!covering.retain(path)) continue;
const identity = JSON.stringify([
binding.bindingId,
effect,
@@ -544,6 +571,241 @@ const referencedValues = (
return [];
};
type MutationObservableOrigin =
| t.ObjectExpression
| t.ArrayExpression
| "ambient"
| "array"
| "array-prototype"
| "opaque";
interface MutationObserverNode {
readonly origins: Set<MutationObservableOrigin>;
readonly sources: Set<MutationObserverNode>;
readonly dependents: Set<MutationObserverNode>;
readonly projections: Map<string, MutationObserverNode>;
readonly read?: {
readonly source: MutationObserverNode;
readonly key: PropertyPath[number];
};
}
const observableMutationPaths = (
state: JavaScriptSemanticAnalysisState,
): ((
bindingId: string,
path: PropertyPath,
effect: ValueEffect,
) => PropertyPath | null) => {
const bindingNodes = new Map<string, MutationObserverNode>();
const expressions = new WeakMap<t.Node, MutationObserverNode>();
const pendingExpressions: {
readonly expression: t.Node;
readonly observer: MutationObserverNode;
}[] = [];
const pending: MutationObserverNode[] = [];
const queued = new Set<MutationObserverNode>();
const schedule = (node: MutationObserverNode): void => {
if (queued.has(node)) return;
queued.add(node);
pending.push(node);
};
const create = (
read?: MutationObserverNode["read"],
): MutationObserverNode => ({
origins: new Set(),
sources: new Set(),
dependents: new Set(),
projections: new Map(),
...(read === undefined ? {} : { read }),
});
const link = (
source: MutationObserverNode,
target: MutationObserverNode,
): void => {
if (target.sources.has(source)) return;
target.sources.add(source);
source.dependents.add(target);
schedule(target);
};
const expressionNode = (node: t.Node): MutationObserverNode => {
const expression = unwrapJavaScriptExpression(node).node;
const existing = expressions.get(expression);
if (existing !== undefined) return existing;
const observer = create();
expressions.set(expression, observer);
pendingExpressions.push({ expression, observer });
return observer;
};
const project = (
source: MutationObserverNode,
path: PropertyPath,
): MutationObserverNode => {
let observer = source;
for (const key of path) {
const identity = JSON.stringify(
key === null || typeof key === "object" ? key : String(key),
);
let selected = observer.projections.get(identity);
if (selected === undefined) {
selected = create({ source: observer, key });
observer.projections.set(identity, selected);
observer.dependents.add(selected);
schedule(selected);
}
observer = selected;
}
return observer;
};
for (const binding of state.bindingsById.values())
bindingNodes.set(binding.bindingId, create());
for (const binding of state.bindingsById.values()) {
const observer = bindingNodes.get(binding.bindingId);
if (observer === undefined) continue;
for (const initializer of binding.initializers)
link(
project(expressionNode(initializer.node), initializer.projection),
observer,
);
// Preserve exact paths where copies, defaults or iteration need their
// reference-specific projections; an opaque origin prevents pruning.
if (binding.referenceInitializers.length > 0)
observer.origins.add("opaque");
for (const initializer of binding.referenceInitializers) {
for (const source of [
initializer,
...(initializer.requiredSources ?? []),
...(initializer.fallbackSources ?? []),
])
link(expressionNode(source.node), observer);
}
}
const settle = (): void => {
while (pendingExpressions.length > 0 || pending.length > 0) {
while (pendingExpressions.length > 0) {
const selected = pendingExpressions.pop();
if (selected === undefined) break;
const { expression, observer } = selected;
if (t.isObjectExpression(expression) || t.isArrayExpression(expression))
observer.origins.add(expression);
else if (t.isIdentifier(expression)) {
const binding = resolveSemanticBindingState(
state,
expression,
expression.name,
);
if (binding !== undefined) {
const origin = bindingNodes.get(binding.bindingId);
if (origin !== undefined) link(origin, observer);
} else if (expression.name === "Array") observer.origins.add("array");
else if (
["globalThis", "global", "window", "self"].includes(expression.name)
)
observer.origins.add("ambient");
} else if (
t.isMemberExpression(expression) ||
t.isOptionalMemberExpression(expression)
)
link(
project(expressionNode(expression.object), [
semanticStaticPropertyKey(
expression.property,
expression.computed,
),
]),
observer,
);
else
for (const reference of referencedValues(expression, [], "escape"))
link(
project(expressionNode(reference.node), reference.path),
observer,
);
schedule(observer);
}
const observer = pending.pop();
if (observer === undefined) continue;
queued.delete(observer);
let changed = false;
const add = (origin: MutationObservableOrigin): void => {
if (observer.origins.has(origin)) return;
observer.origins.add(origin);
changed = true;
};
for (const source of observer.sources)
for (const origin of source.origins) add(origin);
if (observer.read !== undefined) {
const { source, key } = observer.read;
if (key === null || typeof key === "object") {
// Dynamic keys and copied-slot masks retain their original paths.
// Enumerating their exclusion combinations would recreate the growth
// that the mutation collector's selection union already prevents.
if (source.origins.size > 0) add("opaque");
} else
for (const origin of source.origins) {
if (typeof origin === "string") {
if (origin === "array-prototype" || origin === "opaque")
add(origin);
else if (
origin === "ambient" &&
semanticPropertyPathKeyMatches(key, "Array")
)
add("array");
else if (
origin === "array" &&
semanticPropertyPathKeyMatches(key, "prototype")
)
add("array-prototype");
} else {
for (const reference of referencedValues(
origin,
[key],
"escape",
)) {
link(
project(expressionNode(reference.node), reference.path),
observer,
);
if (reference.iterableFallbackPath !== undefined)
link(
project(
expressionNode(reference.node),
reference.iterableFallbackPath,
),
observer,
);
}
}
}
}
if (changed)
for (const dependent of observer.dependents) schedule(dependent);
}
};
settle();
return (bindingId, path, effect) => {
let observer = bindingNodes.get(bindingId);
if (observer === undefined) return path;
if (path.some((key) => key !== null && typeof key === "object"))
return path;
if (effect === "escape") {
observer = project(observer, path);
settle();
return observer.origins.size === 0 ? null : path;
}
if (observer.origins.size === 0) return path.length === 0 ? path : [null];
for (let offset = 0; offset < path.length; offset++) {
const key = path[offset];
if (key === undefined) break;
observer = project(observer, [key]);
settle();
if (observer.origins.size === 0 && offset + 1 < path.length)
return [...path.slice(0, offset + 1), null];
}
return path;
};
};
const objectReferencedValues = (
node: t.ObjectExpression,
path: PropertyPath,