mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(javascript): bound loop capture and rest-copy expansion
This commit is contained in:
@@ -448,8 +448,10 @@ uncertainty rather than pretending to execute later writes. References
|
||||
handed back by local functions, methods, constructors, async functions, and
|
||||
generators also retain escape uncertainty, including when the call is read-only;
|
||||
this does not establish that a mutation occurred. Aliases
|
||||
and shared children in spread and rest copies retain that uncertainty; copied
|
||||
primitive slots and unrelated containing properties remain known. Object rest
|
||||
and shared children in spread and rest copies retain that uncertainty. Exposing
|
||||
the copy alone does not invalidate copied primitive slots or unrelated containing
|
||||
properties. Dynamic child writes such as `copy[key].value = 2` remain conservative
|
||||
about which original slot was selected. Object rest
|
||||
excludes consumed keys, array rest excludes the consumed prefix for known
|
||||
arrays, and later explicit data properties and methods replace earlier object
|
||||
spread references. A known array prefix does not expose values from a following
|
||||
@@ -466,6 +468,11 @@ Receiver projections follow known data fields initialized from `this` or earlier
|
||||
self-referencing fields; static fields retain their declaring class identity.
|
||||
An alias cycle that repeatedly selects deeper children retains escape uncertainty
|
||||
at its recovered roots rather than claiming a fixed number of loop iterations.
|
||||
Equivalent member reads in a repeating loop share unknown ordering instead of
|
||||
creating a distinct capture for every branch history. Straight-line captures and
|
||||
once-only loop inputs retain their own selection sites. When unobserved suffixes
|
||||
are widened, their exact observed prefixes stay separate so unrelated siblings
|
||||
remain known.
|
||||
|
||||
Writes through an object getter's returned receiver retain that uncertainty
|
||||
when the object is an array element, nested inside mixed object/array
|
||||
|
||||
@@ -61,6 +61,7 @@ import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedRe
|
||||
import {
|
||||
semanticMutationInitializers,
|
||||
semanticBindingAtRead,
|
||||
semanticCaptureOrderingIdentity,
|
||||
semanticScopeExitEffect,
|
||||
} from "./javascriptSemanticMutationInitializers.js";
|
||||
import { semanticCoercionOperands } from "./javascriptSemanticLanguageInvocations.js";
|
||||
@@ -551,6 +552,26 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
resolvedBindings.set(node, resolved ?? null);
|
||||
return resolved;
|
||||
};
|
||||
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 captureIdentity = ({ at, ownEnumerable }: SemanticCapturedSelection) =>
|
||||
ownEnumerable ? at : semanticCaptureOrderingIdentity(at, parents);
|
||||
const capturedSelectionKey = (
|
||||
captures: readonly SemanticCapturedSelection[] | undefined,
|
||||
) =>
|
||||
captures?.map((capture) => [
|
||||
nodeIdentity(captureIdentity(capture)),
|
||||
capture.depth,
|
||||
capture.ownEnumerable === true,
|
||||
]);
|
||||
// One capture tuple recurs across every alias it reaches. Re-encoding it
|
||||
// would rebuild the same long key and force a fresh string hash per lookup.
|
||||
const captureKeys = new WeakMap<
|
||||
@@ -611,12 +632,7 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
reference.originAt?.end,
|
||||
reference.memberReadAt?.start,
|
||||
reference.memberReadAt?.end,
|
||||
reference.capturedSelections?.map(({ at, depth, ownEnumerable }) => [
|
||||
at.start,
|
||||
at.end,
|
||||
depth,
|
||||
ownEnumerable,
|
||||
]),
|
||||
capturedSelectionKey(reference.capturedSelections),
|
||||
reference.iterationOnly,
|
||||
reference.widenedGetterResult === true,
|
||||
reference.definiteReceiver === true,
|
||||
@@ -688,16 +704,6 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
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.
|
||||
@@ -718,6 +724,20 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
pending.push(value);
|
||||
return;
|
||||
}
|
||||
const observablePath = observablePaths.normalize(
|
||||
binding.bindingId,
|
||||
value.path,
|
||||
effect,
|
||||
);
|
||||
// Join unobserved suffixes within their exact prefix. Joining distinct
|
||||
// prefixes first could invent a wildcard reaching unrelated siblings.
|
||||
// Keep the raw path for capture, cycle and stored-source selection.
|
||||
const unobservedPrefix =
|
||||
observablePath === null
|
||||
? value.path
|
||||
: observablePath !== value.path
|
||||
? observablePath.slice(0, -1)
|
||||
: undefined;
|
||||
const context = JSON.stringify([
|
||||
binding.bindingId,
|
||||
nodeIdentity(value.originAt),
|
||||
@@ -726,16 +746,11 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
value.definiteReceiver,
|
||||
value.path.length,
|
||||
value.widenedGetterResult === true,
|
||||
unobservedPrefix,
|
||||
]);
|
||||
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 captures = capturedSelectionKey(value.capturedSelections);
|
||||
// Distinct selection times remain distinct; syntactic histories with
|
||||
// the same unknown ordering in one repeating loop share a context.
|
||||
const key = JSON.stringify([context, captures]);
|
||||
if (!projectionsWidened) {
|
||||
const exact = exactProjections.get(context) ?? new Set<string>();
|
||||
@@ -870,12 +885,7 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
const captureSelectionIdentity = JSON.stringify([
|
||||
current.memberReadAt?.start,
|
||||
current.memberReadAt?.end,
|
||||
current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [
|
||||
at.start,
|
||||
at.end,
|
||||
depth,
|
||||
ownEnumerable,
|
||||
]),
|
||||
capturedSelectionKey(current.capturedSelections),
|
||||
]);
|
||||
// Ancestry closes repeated source identities before recursively selected
|
||||
// copy vectors grow; independent contexts remain distinct in coverage.
|
||||
@@ -980,8 +990,7 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
const capture = captures[captureIndex];
|
||||
if (capture === undefined) return undefined;
|
||||
return JSON.stringify([
|
||||
capture.at.start,
|
||||
capture.at.end,
|
||||
nodeIdentity(captureIdentity(capture)),
|
||||
capture.depth === index + 1 && capture.ownEnumerable === true,
|
||||
]);
|
||||
})
|
||||
@@ -2227,9 +2236,10 @@ const observableMutationPaths = (
|
||||
): PropertyPath | null => {
|
||||
let observer = bindingNodes.get(bindingId);
|
||||
if (observer === undefined) return path;
|
||||
if (path.some((key) => key !== null && typeof key === "object"))
|
||||
return path;
|
||||
if (effect === "escape") {
|
||||
if (
|
||||
effect === "escape" &&
|
||||
!path.some((key) => key !== null && typeof key === "object")
|
||||
) {
|
||||
observer = project(observer, path);
|
||||
settle();
|
||||
const origins = observer.origins;
|
||||
@@ -2244,6 +2254,9 @@ const observableMutationPaths = (
|
||||
for (let offset = 0; offset < path.length; offset++) {
|
||||
const key = path[offset];
|
||||
if (key === undefined) break;
|
||||
// Copied-slot masks remain symbolic. An unobserved exact prefix before
|
||||
// the mask still ends a growing path; do not skip that prefix entirely.
|
||||
if (key !== null && typeof key === "object") break;
|
||||
observer = project(observer, [key]);
|
||||
settle();
|
||||
if ((observer.origins?.size ?? 0) === 0 && offset + 1 < path.length)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
|
||||
|
||||
const returnedValue = (body: string, expression: string) =>
|
||||
onlyCallable(
|
||||
analyzeJavaScriptSemantics(
|
||||
`export function result() { ${body} return ${expression}; }`,
|
||||
),
|
||||
"result",
|
||||
).returnSites[0]?.value;
|
||||
|
||||
describe("member-path cycles and captured aliases", () => {
|
||||
const branches = Array.from(
|
||||
{ length: 16 },
|
||||
(_, index) => `if (flag === ${index}) node = node.m${index};`,
|
||||
).join(" else ");
|
||||
|
||||
it.each(["copy[key].value = 2;", "mutate(copy[key]);"])(
|
||||
"retains shared-child effects through rest copies: %s",
|
||||
(effect) => {
|
||||
const body = `
|
||||
const seed = { mode: "initial", m15: { m15: { child: { value: 1 } } } };
|
||||
let node = seed.m15;
|
||||
while (node) {
|
||||
${branches}
|
||||
const { ignore, ...copy } = node;
|
||||
${effect}
|
||||
}
|
||||
`;
|
||||
expect(returnedValue(body, "seed.m15.m15.child.value")?.status).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(returnedValue(body, "seed.mode")).toEqual({
|
||||
status: "literal",
|
||||
value: "initial",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["while", (body: string) => `while (node) { ${body} }`],
|
||||
["do/while", (body: string) => `do { ${body} } while (node);`],
|
||||
["for", (body: string) => `for (; node; ) { ${body} }`],
|
||||
["for/in", (body: string) => `for (const key in { a: 1 }) { ${body} }`],
|
||||
["for/of", (body: string) => `for (const item of [1]) { ${body} }`],
|
||||
] as const)(
|
||||
"keeps a late child effect within its siblings in %s",
|
||||
(_kind, loop) => {
|
||||
const body = `
|
||||
const seed = { mode: "initial", m15: { value: 1, keep: 3 } };
|
||||
let node = seed;
|
||||
${loop(`${branches} node.value = 2;`)}
|
||||
`;
|
||||
expect(returnedValue(body, "seed.m15.value")?.status).toBe("unknown");
|
||||
expect(returnedValue(body, "seed.mode")).toEqual({
|
||||
status: "literal",
|
||||
value: "initial",
|
||||
});
|
||||
expect(returnedValue(body, "seed.m15.keep")).toEqual({
|
||||
status: "literal",
|
||||
value: 3,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an acyclic chain of reassignments within its selected child", () => {
|
||||
const body = `
|
||||
const seed = { branch: { child: { value: 1 }, keep: 3 }, keep: 4 };
|
||||
let node = seed;
|
||||
node = node.branch;
|
||||
node = node.child;
|
||||
mutate(node);
|
||||
`;
|
||||
expect(returnedValue(body, "seed.branch.child.value")?.status).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(returnedValue(body, "seed.branch.keep")).toEqual({
|
||||
status: "literal",
|
||||
value: 3,
|
||||
});
|
||||
expect(returnedValue(body, "seed.keep")).toEqual({
|
||||
status: "literal",
|
||||
value: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("selects saved aliases at their capture, before or after a rebind", () => {
|
||||
const body = `
|
||||
const seed = { branch: { child: { value: 1 }, keep: 3 } };
|
||||
let node = seed.branch;
|
||||
const before = node;
|
||||
node = node.child;
|
||||
const after = node;
|
||||
`;
|
||||
expect(
|
||||
returnedValue(`${body} mutate(after);`, "seed.branch.child.value")
|
||||
?.status,
|
||||
).toBe("unknown");
|
||||
expect(returnedValue(`${body} mutate(after);`, "seed.branch.keep")).toEqual(
|
||||
{
|
||||
status: "literal",
|
||||
value: 3,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
returnedValue(`${body} mutate(before);`, "seed.branch.keep")?.status,
|
||||
).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ interface StatementPosition {
|
||||
|
||||
interface BindingReadPosition {
|
||||
readonly position: StatementPosition | null;
|
||||
readonly repeating: boolean;
|
||||
readonly repeatingLoop: t.Loop | undefined;
|
||||
}
|
||||
|
||||
interface ExpressionSiblings {
|
||||
@@ -545,7 +545,7 @@ export const semanticDefiniteEffectPrecedesReference = (
|
||||
): boolean => {
|
||||
const before = directMutationPosition(effect, parents);
|
||||
const after = bindingReadPosition(reference, parents);
|
||||
if (before === null || after.position === null || after.repeating)
|
||||
if (before === null || after.position === null || after.repeatingLoop)
|
||||
return false;
|
||||
if (positionPrecedes(before, after.position)) return true;
|
||||
return (
|
||||
@@ -701,7 +701,7 @@ const bindingReadPosition = (
|
||||
path.push(current);
|
||||
const parent = parents.get(current);
|
||||
if (t.isLoop(parent)) {
|
||||
result = { position: null, repeating: true };
|
||||
result = { position: null, repeatingLoop: parent };
|
||||
break;
|
||||
}
|
||||
if (
|
||||
@@ -709,7 +709,7 @@ const bindingReadPosition = (
|
||||
(t.isFunction(parent) && !semanticComputedMethodKey(parent, current)) ||
|
||||
delayedInstanceFieldValue(parent, current)
|
||||
) {
|
||||
result = { position: null, repeating: false };
|
||||
result = { position: null, repeatingLoop: undefined };
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
@@ -736,6 +736,33 @@ const bindingReadPosition = (
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Repeated member reads share unknown ordering, while retaining their source sites. */
|
||||
export const semanticCaptureOrderingIdentity = (
|
||||
capture: t.Node,
|
||||
parents: WeakMap<t.Node, t.Node>,
|
||||
): t.Node => {
|
||||
// Calls can have a distinct invocation effect at their own selection site.
|
||||
if (!t.isMemberExpression(capture) && !t.isOptionalMemberExpression(capture))
|
||||
return capture;
|
||||
const loop = bindingReadPosition(capture, parents).repeatingLoop;
|
||||
if (loop === undefined) return capture;
|
||||
let child: t.Node = capture;
|
||||
for (
|
||||
let parent = parents.get(child);
|
||||
parent !== undefined && parent !== loop;
|
||||
parent = parents.get(child)
|
||||
)
|
||||
child = parent;
|
||||
// These inputs run once, before the repeating portion of the loop.
|
||||
if (
|
||||
(t.isForStatement(loop) && child === loop.init) ||
|
||||
((t.isForInStatement(loop) || t.isForOfStatement(loop)) &&
|
||||
child === loop.right)
|
||||
)
|
||||
return capture;
|
||||
return loop;
|
||||
};
|
||||
|
||||
/** Select a local alias at its read, before any later straight-line reassignment. */
|
||||
type SemanticBindingRead = {
|
||||
readonly status: "ordered" | "unresolved";
|
||||
@@ -811,7 +838,7 @@ export const semanticBindingAtRead = (
|
||||
? null
|
||||
: initializerPosition(reference, parents, true)
|
||||
: initializerPosition(initializer, parents);
|
||||
const { position, repeating } = bindingReadPosition(read, parents);
|
||||
const { position, repeatingLoop } = bindingReadPosition(read, parents);
|
||||
// Coverage and latest-value selection are independent. A preceding write
|
||||
// can cover entry even when another write's order remains unknown.
|
||||
const hasDefiniteInitializer =
|
||||
@@ -832,7 +859,7 @@ export const semanticBindingAtRead = (
|
||||
);
|
||||
});
|
||||
if (
|
||||
!repeating &&
|
||||
!repeatingLoop &&
|
||||
origin !== undefined &&
|
||||
binding.initializers.length + binding.referenceInitializers.length === 1 &&
|
||||
sourcePosition !== null &&
|
||||
@@ -845,7 +872,7 @@ export const semanticBindingAtRead = (
|
||||
hasDefiniteInitializer,
|
||||
};
|
||||
const uncertainOrder =
|
||||
repeating ||
|
||||
repeatingLoop ||
|
||||
sourcePosition === null ||
|
||||
position === null ||
|
||||
sourcePosition.body !== position.body;
|
||||
|
||||
@@ -22,6 +22,54 @@ export function destructured({x}) { const saved=x; x=2; return {value:saved}; }
|
||||
export function local() { let value="before"; value="after"; return {value}; }
|
||||
`;
|
||||
|
||||
cliTest(
|
||||
"retains cyclic member effects and sibling values through CLI and stdio MCP",
|
||||
async ({ cli }) => {
|
||||
const branches = Array.from(
|
||||
{ length: 16 },
|
||||
(_, index) => `if (flag === ${index}) node = node.m${index};`,
|
||||
).join(" else ");
|
||||
await verifyJavaScriptReturnShapes(
|
||||
cli,
|
||||
`export function result() {
|
||||
const seed = { mode: "initial", m15: { value: 1, keep: 3 } };
|
||||
let node = seed;
|
||||
while (node) { ${branches} node.value = 2; }
|
||||
return { changed: seed.m15.value, mode: seed.mode, kept: seed.m15.keep };
|
||||
}
|
||||
export function copied() {
|
||||
const seed = { mode: "initial", keep: 3, m15: { m15: { child: { value: 1 } } } };
|
||||
let node = seed.m15;
|
||||
while (node) {
|
||||
${branches}
|
||||
const { ignore, ...copy } = node;
|
||||
mutate(copy[key]);
|
||||
}
|
||||
return { changed: seed.m15.m15.child.value, mode: seed.mode, kept: seed.keep };
|
||||
}`,
|
||||
(fields) => {
|
||||
for (const name of ["result", "copied"])
|
||||
expect(fields(name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "/changed", state: "unknown" }),
|
||||
expect.objectContaining({
|
||||
path: "/mode",
|
||||
state: "literal",
|
||||
value: "initial",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "/kept",
|
||||
state: "literal",
|
||||
value: 3,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"preserves incoming values and proven assignments through CLI and stdio MCP",
|
||||
async ({ cli }) => {
|
||||
|
||||
Reference in New Issue
Block a user