mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 14:56:32 +00:00
fix(javascript): bound precise alias projection histories before expansion
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user