mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(javascript): share accessor selection and invocation effects
This commit is contained in:
@@ -413,12 +413,22 @@ containers, or assigned to a previously declared identifier. Saving the getter
|
||||
result in an alias does not make its old field values immutable. Known array
|
||||
indices include holes; an element after a spread has only a minimum possible
|
||||
index. For these returned-reference writes, unrelated properties and known
|
||||
array prefixes remain precise. Getter invocation has separate effects: a
|
||||
property read can invoke a getter that mutates its receiver without any write
|
||||
through the returned value. Current analysis can miss these invocation effects
|
||||
and retain a stale receiver-field literal
|
||||
([#6669](https://github.com/morluto/rea/issues/6669)); absence of a later write
|
||||
does not establish getter purity.
|
||||
array prefixes remain precise. Accessor applicability uses the shared object
|
||||
reference owner for literal definition order, own-property shadowing, known
|
||||
array elements and explicit literal prototypes. Getter invocation effects
|
||||
project write and call operands through the same local alias and `this` owner
|
||||
used by returned references. Reads of another selected getter continue that
|
||||
effect walk. A receiver reached by these effects becomes uncertain on an
|
||||
ordinary or optional read, compound assignment or update. A simple assignment
|
||||
or deletion does not invoke the getter. Selected setters invalidate
|
||||
the receiver on assignment, including through known array and prototype paths.
|
||||
Inert getters and ordinary data slots retain their supported precision.
|
||||
|
||||
These are conservative source-derived effects, not execution results. The
|
||||
analysis does not establish general accessor purity, model every class or
|
||||
reflectively installed descriptor, or cover getter reads introduced by object
|
||||
spread/destructuring and all implicit coercion or disposal invocations. Absence of a later explicit write does not establish getter purity;
|
||||
unsupported invocation forms can still retain stale receiver-field facts.
|
||||
Nested callable returns are not assigned to their parent callable. Projected
|
||||
graph observations carry source ranges but never source text.
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
|
||||
|
||||
const returned = (body: string) =>
|
||||
onlyCallable(
|
||||
analyzeJavaScriptSemantics(`export function observe() { ${body} }`),
|
||||
"observe",
|
||||
).returnSites[0]?.value;
|
||||
|
||||
it.each([
|
||||
["ordinary read", "box.trigger;"],
|
||||
["optional read", "box?.trigger;"],
|
||||
["compound assignment", "box.trigger += 1;"],
|
||||
["update", "box.trigger++;"],
|
||||
])("invalidates a receiver after a mutating getter's %s", (_name, read) => {
|
||||
expect(
|
||||
returned(`
|
||||
const box = { value: 1, get trigger() { this.value = 2; return 0; } };
|
||||
${read}
|
||||
return box.value;
|
||||
`),
|
||||
).toMatchObject({ status: "unknown" });
|
||||
});
|
||||
|
||||
it("follows inherited getter selection while preserving own-property shadowing", () => {
|
||||
const prototype = "{ get trigger() { this.value = 2; return 0; } }";
|
||||
expect(
|
||||
returned(`
|
||||
const prototype = ${prototype};
|
||||
const box = { __proto__: prototype, value: 1 };
|
||||
box.trigger;
|
||||
return box.value;
|
||||
`),
|
||||
).toMatchObject({ status: "unknown" });
|
||||
expect(
|
||||
returned(`
|
||||
const box = { __proto__: ${prototype}, value: 1, trigger: 0 };
|
||||
box.trigger;
|
||||
return box.value;
|
||||
`),
|
||||
).toEqual({ status: "literal", value: 1 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["simple store", "box.trigger = 2;"],
|
||||
["destructuring store", "({ trigger: box.trigger } = { trigger: 2 });"],
|
||||
["delete", "delete box.trigger;"],
|
||||
["later data property", "box.trigger;", ", trigger: 0"],
|
||||
])("does not invoke a getter for %s", (_name, operation, later = "") => {
|
||||
expect(
|
||||
returned(`
|
||||
const box = { value: 1, get trigger() { this.value = 2; return 0; } ${later} };
|
||||
${operation}
|
||||
return box.value;
|
||||
`),
|
||||
).toEqual({ status: "literal", value: 1 });
|
||||
});
|
||||
|
||||
it("preserves an inert getter and a primitive sibling outside the receiver", () => {
|
||||
expect(
|
||||
returned(`
|
||||
const box = { value: 1, get trigger() { return this.value; } };
|
||||
box.trigger;
|
||||
return box.value;
|
||||
`),
|
||||
).toEqual({ status: "literal", value: 1 });
|
||||
expect(
|
||||
returned(`
|
||||
const outer = { kept: "stable", rows: [{ value: 1, get trigger() { this.value = 2; } }] };
|
||||
outer.rows[0].trigger;
|
||||
return outer.kept;
|
||||
`),
|
||||
).toEqual({ status: "literal", value: "stable" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"array element",
|
||||
"const rows = [{ value: 1, set trigger(value) { this.value = value; } }];",
|
||||
"rows[0]",
|
||||
],
|
||||
[
|
||||
"inherited setter",
|
||||
"const box = { __proto__: { set trigger(value) { this.value = value; } }, value: 1 };",
|
||||
"box",
|
||||
],
|
||||
[
|
||||
"aliased nested receiver",
|
||||
"const box = { value: 1, set trigger(value) { this.value = value; } }; const outer = { box };",
|
||||
"outer.box",
|
||||
],
|
||||
])(
|
||||
"invalidates the selected %s receiver on assignment",
|
||||
(_name, setup, receiver) => {
|
||||
expect(
|
||||
returned(`${setup} ${receiver}.trigger = 2; return ${receiver}.value;`),
|
||||
).toMatchObject({ status: "unknown" });
|
||||
},
|
||||
);
|
||||
|
||||
it("does not inherit a setter through an own data property or copied descriptor", () => {
|
||||
for (const box of [
|
||||
"{ __proto__: { set trigger(value) { this.value = value; } }, value: 1, trigger: 0 }",
|
||||
"{ ...{ set trigger(value) { this.value = value; } }, value: 1 }",
|
||||
]) {
|
||||
expect(
|
||||
returned(`const box = ${box}; box.trigger = 2; return box.value;`),
|
||||
).toEqual({ status: "literal", value: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it("follows getter-to-getter invocation effects with the selected receiver", () => {
|
||||
expect(
|
||||
returned(`
|
||||
const box = {
|
||||
value: 1,
|
||||
get trigger() { return this.trigger2; },
|
||||
get trigger2() { this.value = 2; return 0; },
|
||||
};
|
||||
box.trigger;
|
||||
return box.value;
|
||||
`),
|
||||
).toMatchObject({ status: "unknown" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"const other = {}; other.x = 2; return this.value;",
|
||||
"const unused = () => { this.value = 2; }; return 0;",
|
||||
"const outer = this; const unused = () => outer.value++; return 0;",
|
||||
])(
|
||||
"keeps the receiver exact when getter effects do not reach it: %s",
|
||||
(body) => {
|
||||
expect(
|
||||
returned(`
|
||||
const box = { value: 1, get trigger() { ${body} } };
|
||||
box.trigger;
|
||||
return box.value;
|
||||
`),
|
||||
).toEqual({ status: "literal", value: 1 });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"(() => { this.value = 2; })(); return 0;",
|
||||
"const change = () => { this.value = 2; }; change(); return 0;",
|
||||
"const outer = this; function change() { outer.value = 2; } change(); return 0;",
|
||||
"const a = { box: this }; a.box.value = 2; return 0;",
|
||||
"const outer = this; function Change() { outer.value = 2; } new Change(); return 0;",
|
||||
"const tag = () => { this.value = 2; }; tag``; return 0;",
|
||||
])(
|
||||
"retains effects through invoked local callables and containers: %s",
|
||||
(body) => {
|
||||
expect(
|
||||
returned(`
|
||||
const box = { value: 1, get trigger() { ${body} } };
|
||||
box.trigger;
|
||||
return box.value;
|
||||
`),
|
||||
).toMatchObject({ status: "unknown" });
|
||||
},
|
||||
);
|
||||
|
||||
it("retains references returned from tagged invocation through the ordinary callee owner", () => {
|
||||
expect(
|
||||
returned(`
|
||||
const shared = { value: 1 };
|
||||
const tag = () => shared;
|
||||
tag\`\`;
|
||||
return shared.value;
|
||||
`),
|
||||
).toMatchObject({ status: "unknown" });
|
||||
expect(
|
||||
returned(`
|
||||
const shared = { value: 1 };
|
||||
const tag = () => 0;
|
||||
tag\`\`;
|
||||
return shared.value;
|
||||
`),
|
||||
).toEqual({ status: "literal", value: 1 });
|
||||
});
|
||||
@@ -9,12 +9,17 @@ import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceS
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReferences.js";
|
||||
import {
|
||||
semanticObjectPropertyReferences,
|
||||
semanticArrayPropertyReferences,
|
||||
type JavaScriptSemanticObjectReference as Reference,
|
||||
} from "./javascriptSemanticObjectReferences.js";
|
||||
import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js";
|
||||
import type {
|
||||
GetterQueryJob,
|
||||
import {
|
||||
SemanticGetterQuery,
|
||||
type GetterQueryJob,
|
||||
} from "./javascriptSemanticGetterQueries.js";
|
||||
|
||||
type ClassNode = t.ClassDeclaration | t.ClassExpression;
|
||||
@@ -55,6 +60,10 @@ export interface GetterResultResolvers {
|
||||
isStatic: boolean,
|
||||
) => boolean;
|
||||
readonly bases: (node: t.Node) => readonly ClassNode[];
|
||||
readonly containerReferences?: (
|
||||
node: t.ObjectExpression | t.ArrayExpression,
|
||||
path: PropertyPath,
|
||||
) => readonly Reference[];
|
||||
}
|
||||
|
||||
/** Branch syntax contributes alternatives without evaluating application code. */
|
||||
@@ -83,10 +92,11 @@ class GetterResultWalker {
|
||||
readonly job: GetterQueryJob,
|
||||
readonly query: SemanticGetterQuery,
|
||||
readonly resolvers: GetterResultResolvers,
|
||||
roots: readonly {
|
||||
readonly node: t.Node | null;
|
||||
}[] = semanticCallableResultExpressions(job.invocation.getter),
|
||||
) {
|
||||
for (const { node } of semanticCallableResultExpressions(
|
||||
job.invocation.getter,
|
||||
))
|
||||
for (const { node } of roots)
|
||||
if (node !== null) this.#enqueue(() => ({ node, path: job.path }));
|
||||
this.#scope = query.state.scopeByNode.get(job.invocation.getter)?.scopeId;
|
||||
}
|
||||
@@ -364,7 +374,21 @@ class GetterResultWalker {
|
||||
for (const node of branches)
|
||||
this.#enqueue(() => ({ ...current, node }));
|
||||
else if (current.privateRead !== undefined) this.#privateRead(current);
|
||||
else this.#retain(current, { node: expression, path: current.path });
|
||||
else if (
|
||||
current.path.length > 0 &&
|
||||
(t.isObjectExpression(expression) || t.isArrayExpression(expression)) &&
|
||||
this.resolvers.containerReferences !== undefined
|
||||
) {
|
||||
for (const reference of this.resolvers.containerReferences(
|
||||
expression,
|
||||
current.path,
|
||||
))
|
||||
this.#enqueue(() => ({
|
||||
...current,
|
||||
node: reference.node,
|
||||
path: reference.path,
|
||||
}));
|
||||
} else this.#retain(current, { node: expression, path: current.path });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,3 +398,39 @@ export const expandSemanticGetterResult = (
|
||||
query: SemanticGetterQuery,
|
||||
resolvers: GetterResultResolvers,
|
||||
): void => new GetterResultWalker(job, query, resolvers).run();
|
||||
|
||||
/** Project an effect operand through the same local aliases and `this` owner as getter results. */
|
||||
export const semanticGetterReceiverPaths = (
|
||||
getter: t.ObjectMethod,
|
||||
operand: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly PropertyPath[] => {
|
||||
const owner = state.parentsByNode.get(getter);
|
||||
if (!t.isObjectExpression(owner)) return [];
|
||||
const query = new SemanticGetterQuery(state);
|
||||
const references = query.run(
|
||||
() => {
|
||||
query.schedule({ getter, owner }, []);
|
||||
return [];
|
||||
},
|
||||
(job) =>
|
||||
new GetterResultWalker(
|
||||
job,
|
||||
query,
|
||||
{
|
||||
objectReferences: (_receiver, path) => [{ node: getter, path }],
|
||||
classReferences: () => [],
|
||||
privateReceiver: () => false,
|
||||
bases: () => [],
|
||||
containerReferences: (node, path) =>
|
||||
t.isObjectExpression(node)
|
||||
? semanticObjectPropertyReferences(node, path, state)
|
||||
: semanticArrayPropertyReferences(node, path),
|
||||
},
|
||||
[{ node: operand }],
|
||||
).run(),
|
||||
);
|
||||
return references
|
||||
.filter(({ node }) => node === getter)
|
||||
.map(({ path }) => path);
|
||||
};
|
||||
|
||||
@@ -33,15 +33,21 @@ import {
|
||||
semanticClassCandidates,
|
||||
semanticClassPropertyReferences,
|
||||
semanticObjectPropertyReferences,
|
||||
semanticObjectAccessorReferences,
|
||||
type JavaScriptSemanticObjectReference,
|
||||
} from "./javascriptSemanticObjectReferences.js";
|
||||
import {
|
||||
semanticIterationSources,
|
||||
type SemanticIterationSource,
|
||||
} from "./javascriptSemanticIterationSources.js";
|
||||
import { semanticGetterReceiverPaths } from "./javascriptSemanticGetterResults.js";
|
||||
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
|
||||
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
|
||||
import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js";
|
||||
import {
|
||||
semanticMutationInitializers,
|
||||
semanticBindingAtRead,
|
||||
} from "./javascriptSemanticMutationInitializers.js";
|
||||
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
|
||||
import {
|
||||
semanticCopiedReferencePath,
|
||||
semanticKnownArrayIteration,
|
||||
@@ -55,10 +61,14 @@ import {
|
||||
type JavaScriptSemanticPropertyPath,
|
||||
} from "./javascriptSemanticPropertyPaths.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
traverseJavaScriptAstSteps,
|
||||
childNodes,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
semanticWriteSiteTarget,
|
||||
semanticWriteSiteReadsFirst,
|
||||
semanticWriteTargets,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
@@ -85,79 +95,169 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => {
|
||||
|
||||
/** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */
|
||||
|
||||
/** Setter keys of one object literal; a computed setter key may match any key. */
|
||||
interface SetterKeys {
|
||||
readonly names: ReadonlySet<string>;
|
||||
readonly computed: boolean;
|
||||
}
|
||||
|
||||
// Writes to one literal repeat; scan its properties once, including the
|
||||
// common result that it defines no setter.
|
||||
const setterKeysByObject = new WeakMap<t.ObjectExpression, SetterKeys | null>();
|
||||
|
||||
const setterKeys = (object: t.ObjectExpression): SetterKeys | null => {
|
||||
const cached = setterKeysByObject.get(object);
|
||||
if (cached !== undefined) return cached;
|
||||
const names = new Set<string>();
|
||||
let computed = false;
|
||||
// Follow definition order: a later data property or method with the same
|
||||
// static name replaces the accessor, so its setter no longer runs. Spreads
|
||||
// and unknown computed keys may or may not replace it and keep it.
|
||||
for (const property of object.properties) {
|
||||
if (t.isSpreadElement(property)) continue;
|
||||
const name = semanticStaticPropertyKey(property.key, property.computed);
|
||||
if (t.isObjectMethod(property) && property.kind === "set") {
|
||||
if (name === null) computed = true;
|
||||
else names.add(name);
|
||||
/** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */
|
||||
const localAccessorMethods = (
|
||||
root: t.Node,
|
||||
path: PropertyPath,
|
||||
accessor: "get" | "set",
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
keyMayExist: (
|
||||
key: PropertyPath[number] | undefined,
|
||||
kind: "get" | "set",
|
||||
) => boolean,
|
||||
): readonly t.ObjectMethod[] | undefined => {
|
||||
if (!keyMayExist(path.at(-1), accessor)) return [];
|
||||
const methods = new Set<t.ObjectMethod>();
|
||||
const pending = [{ node: root, path }];
|
||||
const examined = new WeakMap<t.Node, Set<string>>();
|
||||
let selections = 0;
|
||||
for (let cursor = 0; cursor < pending.length; cursor++) {
|
||||
const current = pending[cursor]!;
|
||||
const node = unwrapJavaScriptExpression(current.node).node;
|
||||
const key = JSON.stringify(current.path);
|
||||
const paths = examined.get(node) ?? new Set<string>();
|
||||
if (paths.has(key)) continue;
|
||||
paths.add(key);
|
||||
examined.set(node, paths);
|
||||
if (++selections > SEMANTIC_ALIAS_SELECTION_LIMIT) {
|
||||
state.valueResourceLimits.add("alias-selections");
|
||||
return undefined;
|
||||
}
|
||||
if (t.isObjectMethod(node) && current.path.length === 0) {
|
||||
methods.add(node);
|
||||
continue;
|
||||
}
|
||||
if (t.isIdentifier(node)) {
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
if (binding === undefined) continue;
|
||||
const selected = semanticBindingAtRead(
|
||||
binding,
|
||||
node,
|
||||
state.parentsByNode,
|
||||
).binding;
|
||||
for (const source of [
|
||||
...selected.initializers,
|
||||
...selected.referenceInitializers,
|
||||
])
|
||||
pending.push({
|
||||
node: source.node,
|
||||
path: [...source.projection, ...current.path],
|
||||
});
|
||||
} else if (
|
||||
name !== null &&
|
||||
!(t.isObjectMethod(property) && property.kind === "get")
|
||||
)
|
||||
names.delete(name);
|
||||
t.isMemberExpression(node) ||
|
||||
t.isOptionalMemberExpression(node)
|
||||
) {
|
||||
pending.push({
|
||||
node: node.object,
|
||||
path: [
|
||||
semanticStaticPropertyKey(node.property, node.computed),
|
||||
...current.path,
|
||||
],
|
||||
});
|
||||
} else if (
|
||||
current.path.length > 0 &&
|
||||
(t.isObjectExpression(node) || t.isArrayExpression(node))
|
||||
) {
|
||||
const [selected, ...remaining] = current.path;
|
||||
const references = t.isArrayExpression(node)
|
||||
? semanticArrayPropertyReferences(node, [selected!])
|
||||
: remaining.length === 0
|
||||
? semanticObjectAccessorReferences(node, selected!, state, accessor)
|
||||
: semanticObjectPropertyReferences(node, [selected!], state);
|
||||
for (const source of references)
|
||||
pending.push({
|
||||
node: source.node,
|
||||
path: [...source.path, ...remaining],
|
||||
});
|
||||
} else {
|
||||
for (const source of branchReferenceValues(node, current.path))
|
||||
pending.push(source);
|
||||
}
|
||||
}
|
||||
const keys = names.size === 0 && !computed ? null : { names, computed };
|
||||
setterKeysByObject.set(object, keys);
|
||||
return keys;
|
||||
return [...methods];
|
||||
};
|
||||
|
||||
/**
|
||||
* The receiver path whose own object-literal setter an assignment to `path`
|
||||
* runs, following nested literal members from the binding's initializer.
|
||||
*/
|
||||
const localSetterReceiver = (
|
||||
initializers: readonly {
|
||||
readonly node: t.Node;
|
||||
readonly projection: readonly unknown[];
|
||||
}[],
|
||||
path: readonly unknown[],
|
||||
): readonly string[] | undefined => {
|
||||
const key = path.at(-1);
|
||||
if (typeof key !== "string") return undefined;
|
||||
const receiver = path.slice(0, -1);
|
||||
if (!receiver.every((segment) => typeof segment === "string"))
|
||||
return undefined;
|
||||
for (const { node, projection } of initializers) {
|
||||
if (projection.length !== 0) continue;
|
||||
let object: t.Node | undefined = unwrapJavaScriptExpression(node).node;
|
||||
for (const segment of receiver) {
|
||||
if (!t.isObjectExpression(object)) break;
|
||||
const member: t.ObjectExpression["properties"][number] | undefined =
|
||||
object.properties.findLast(
|
||||
(property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) ===
|
||||
segment,
|
||||
);
|
||||
object = t.isObjectProperty(member)
|
||||
? unwrapJavaScriptExpression(member.value).node
|
||||
: undefined;
|
||||
type AccessorEffectOperand =
|
||||
| { readonly kind: "escape"; readonly node: t.Node }
|
||||
| {
|
||||
readonly kind: "read";
|
||||
readonly node: t.Node;
|
||||
readonly key: PropertyPath[number];
|
||||
}
|
||||
if (!t.isObjectExpression(object)) continue;
|
||||
const keys = setterKeys(object);
|
||||
if (keys !== null && (keys.computed || keys.names.has(key)))
|
||||
return receiver as readonly string[];
|
||||
| {
|
||||
readonly kind: "invoke";
|
||||
readonly node: t.Node;
|
||||
readonly invocation?: t.CallExpression | t.OptionalCallExpression;
|
||||
};
|
||||
|
||||
const accessorOperands = new WeakMap<
|
||||
t.Function,
|
||||
readonly AccessorEffectOperand[]
|
||||
>();
|
||||
|
||||
/** Collect invocation operands once; receiver interpretation belongs to the getter owner. */
|
||||
const accessorEffectOperands = (
|
||||
method: t.Function,
|
||||
): readonly AccessorEffectOperand[] => {
|
||||
const cached = accessorOperands.get(method);
|
||||
if (cached !== undefined) return cached;
|
||||
const operands: AccessorEffectOperand[] = [];
|
||||
const pending: t.Node[] = [method.body];
|
||||
const writeOnly = new WeakSet<t.Node>();
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop()!;
|
||||
// Defining a nested callable does not invoke it.
|
||||
if (t.isFunction(node) || t.isClass(node)) continue;
|
||||
const write = semanticWriteSiteTarget(node);
|
||||
if (write !== null) {
|
||||
for (const target of semanticWriteTargets(write)) {
|
||||
if (!semanticWriteSiteReadsFirst(node)) writeOnly.add(target);
|
||||
if (!t.isIdentifier(target))
|
||||
operands.push({ kind: "escape", node: target.object });
|
||||
}
|
||||
}
|
||||
if (
|
||||
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) &&
|
||||
!writeOnly.has(node)
|
||||
)
|
||||
operands.push({
|
||||
kind: "read",
|
||||
node: node.object,
|
||||
key: semanticStaticPropertyKey(node.property, node.computed),
|
||||
});
|
||||
if (
|
||||
t.isCallExpression(node) ||
|
||||
t.isOptionalCallExpression(node) ||
|
||||
t.isNewExpression(node)
|
||||
) {
|
||||
const callee = unwrapJavaScriptExpression(node.callee).node;
|
||||
operands.push({
|
||||
kind: "invoke",
|
||||
node: node.callee,
|
||||
...(t.isNewExpression(node) ? {} : { invocation: node }),
|
||||
});
|
||||
if (
|
||||
!t.isNewExpression(node) &&
|
||||
(t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee))
|
||||
)
|
||||
operands.push({ kind: "escape", node: callee.object });
|
||||
for (const argument of node.arguments)
|
||||
operands.push({
|
||||
kind: "escape",
|
||||
node: t.isSpreadElement(argument) ? argument.argument : argument,
|
||||
});
|
||||
} else if (t.isTaggedTemplateExpression(node)) {
|
||||
operands.push({ kind: "invoke", node: node.tag });
|
||||
const tag = unwrapJavaScriptExpression(node.tag).node;
|
||||
if (t.isMemberExpression(tag) || t.isOptionalMemberExpression(tag))
|
||||
operands.push({ kind: "escape", node: tag.object });
|
||||
for (const expression of node.quasi.expressions)
|
||||
operands.push({ kind: "escape", node: expression });
|
||||
}
|
||||
pending.push(...childNodes(node).reverse());
|
||||
}
|
||||
return undefined;
|
||||
accessorOperands.set(method, operands);
|
||||
return operands;
|
||||
};
|
||||
|
||||
export function* collectSemanticMemberMutationsSteps(
|
||||
@@ -168,9 +268,97 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
const deferredMapReceivers = new Set<
|
||||
t.CallExpression | t.OptionalCallExpression
|
||||
>();
|
||||
// The binding pass already retained callable nodes. Ordinary data-only code
|
||||
// must not pay an alias walk for every member read or assignment.
|
||||
const accessorKeys = {
|
||||
get: new Set<string | null>(),
|
||||
set: new Set<string | null>(),
|
||||
};
|
||||
for (const node of state.callableNodesById.values())
|
||||
if (t.isObjectMethod(node) && (node.kind === "get" || node.kind === "set"))
|
||||
accessorKeys[node.kind].add(
|
||||
semanticStaticPropertyKey(node.key, node.computed),
|
||||
);
|
||||
const accessorKeyMayExist = (
|
||||
key: PropertyPath[number] | undefined,
|
||||
kind: "get" | "set",
|
||||
): boolean => {
|
||||
const keys = accessorKeys[kind];
|
||||
return (
|
||||
keys.has(null) ||
|
||||
(key === null || typeof key === "object"
|
||||
? keys.size > 0
|
||||
: keys.has(String(key)))
|
||||
);
|
||||
};
|
||||
const returnedReferences = createSemanticReturnedReferences(state);
|
||||
const getterEffectPaths = (
|
||||
root: t.Node,
|
||||
path: PropertyPath,
|
||||
): readonly PropertyPath[] => {
|
||||
if (!accessorKeyMayExist(path.at(-1), "get")) return [];
|
||||
const pending = [path];
|
||||
const examined = new Set<string>();
|
||||
const effects: PropertyPath[] = [];
|
||||
for (let cursor = 0; cursor < pending.length; cursor++) {
|
||||
const selected = pending[cursor]!;
|
||||
const identity = JSON.stringify(selected);
|
||||
if (examined.has(identity)) continue;
|
||||
examined.add(identity);
|
||||
if (examined.size > SEMANTIC_ALIAS_SELECTION_LIMIT) {
|
||||
state.valueResourceLimits.add("alias-selections");
|
||||
return [[]];
|
||||
}
|
||||
const methods = localAccessorMethods(
|
||||
root,
|
||||
selected,
|
||||
"get",
|
||||
state,
|
||||
accessorKeyMayExist,
|
||||
);
|
||||
const receiver = selected.slice(0, -1);
|
||||
if (methods === undefined) {
|
||||
effects.push(receiver);
|
||||
continue;
|
||||
}
|
||||
for (const method of methods) {
|
||||
const callables: t.Function[] = [method];
|
||||
const visitedCallables = new Set<t.Function>();
|
||||
for (let index = 0; index < callables.length; index++) {
|
||||
const callable = callables[index]!;
|
||||
if (visitedCallables.has(callable)) continue;
|
||||
visitedCallables.add(callable);
|
||||
for (const operand of accessorEffectOperands(callable)) {
|
||||
if (operand.kind === "invoke") {
|
||||
for (const target of returnedReferences(
|
||||
operand.node,
|
||||
operand.invocation,
|
||||
).callables)
|
||||
if (!target.generator) callables.push(target);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
operand.kind === "read" &&
|
||||
!accessorKeyMayExist(operand.key, "get")
|
||||
)
|
||||
continue;
|
||||
for (const suffix of semanticGetterReceiverPaths(
|
||||
method,
|
||||
operand.node,
|
||||
state,
|
||||
)) {
|
||||
const target = [...receiver, ...suffix];
|
||||
if (operand.kind === "escape") effects.push(target);
|
||||
else pending.push([...target, operand.key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return effects;
|
||||
};
|
||||
const parents = state.parentsByNode;
|
||||
const observablePaths = observableMutationPaths(state);
|
||||
const returnedReferences = createSemanticReturnedReferences(state);
|
||||
const recordedRequiredEffects = new Set<string>();
|
||||
let recordedEffectCount = 0;
|
||||
const recordRequiredEffect = (
|
||||
@@ -513,15 +701,27 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
current.originAt,
|
||||
parents,
|
||||
);
|
||||
// A setter on the binding's own literal runs on assignment and may
|
||||
// A selected setter runs on assignment and may
|
||||
// replace any member of its receiver, so the written key is unknown.
|
||||
// Deleting the accessor never calls it.
|
||||
if (
|
||||
effect === "write" &&
|
||||
!t.isUnaryExpression(mutation, { operator: "delete" })
|
||||
) {
|
||||
const receiver = localSetterReceiver(origins.initializers, path);
|
||||
if (receiver !== undefined) path = [...receiver, null];
|
||||
const selectedPath = path;
|
||||
if (
|
||||
origins.initializers.some(
|
||||
(source) =>
|
||||
(localAccessorMethods(
|
||||
source.node,
|
||||
[...source.projection, ...selectedPath],
|
||||
"set",
|
||||
state,
|
||||
accessorKeyMayExist,
|
||||
)?.length ?? 1) > 0,
|
||||
)
|
||||
)
|
||||
path = [...selectedPath.slice(0, -1), null];
|
||||
}
|
||||
// Keep an antichain of exact paths. Widening distinct member keys would
|
||||
// also invalidate unrelated siblings that never reach this effect.
|
||||
@@ -888,8 +1088,13 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
sourcesByTarget.get(target) ?? [],
|
||||
);
|
||||
};
|
||||
const writeOnlyMembers = new WeakSet<t.Node>();
|
||||
yield* traverseJavaScriptAstSteps(program, {
|
||||
enter: (node, parent) => {
|
||||
const writeTarget = semanticWriteSiteTarget(node);
|
||||
if (writeTarget !== null && !semanticWriteSiteReadsFirst(node))
|
||||
for (const target of semanticWriteTargets(writeTarget))
|
||||
writeOnlyMembers.add(target);
|
||||
for (const source of semanticIterationSources(node, parent))
|
||||
deferIterable({
|
||||
...source,
|
||||
@@ -900,7 +1105,14 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
originAt: source.mutation,
|
||||
iterationOnly: source.kind === "iteration",
|
||||
});
|
||||
const writeTarget = semanticWriteSiteTarget(node);
|
||||
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
||||
// The write owner classifies direct and destructuring stores once.
|
||||
if (!writeOnlyMembers.has(node))
|
||||
for (const path of getterEffectPaths(node.object, [
|
||||
semanticStaticPropertyKey(node.property, node.computed),
|
||||
]))
|
||||
markEscaped(node.object, node, path);
|
||||
}
|
||||
if (writeTarget !== null) markTarget(writeTarget, node);
|
||||
else if (
|
||||
t.isCallExpression(node) ||
|
||||
@@ -929,6 +1141,8 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
else if (!mapLookup) markReceiver(node.callee, node);
|
||||
}
|
||||
} else if (t.isTaggedTemplateExpression(node)) {
|
||||
for (const source of returnedReferences(node.tag).references)
|
||||
markEscaped(source.node, node, source.projection);
|
||||
markReceiver(node.tag, node);
|
||||
for (const expression of node.quasi.expressions)
|
||||
markEscaped(expression, node);
|
||||
|
||||
@@ -611,6 +611,20 @@ export const semanticObjectPropertyReferences = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Select accessor descriptors without invoking them or following their return values. */
|
||||
export const semanticObjectAccessorReferences = (
|
||||
node: t.ObjectExpression,
|
||||
key: PropertyPath[number],
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
accessor: "get" | "set",
|
||||
): readonly JavaScriptSemanticObjectReference[] =>
|
||||
new ObjectReferenceWalk(
|
||||
node,
|
||||
[key],
|
||||
new SemanticGetterQuery(state),
|
||||
accessor,
|
||||
).run();
|
||||
|
||||
class ObjectReferenceWalk {
|
||||
readonly #references: JavaScriptSemanticObjectReference[] = [];
|
||||
readonly #overwritten = new Set<string>();
|
||||
@@ -625,6 +639,7 @@ class ObjectReferenceWalk {
|
||||
readonly node: t.ObjectExpression,
|
||||
path: PropertyPath,
|
||||
readonly query: SemanticGetterQuery,
|
||||
readonly accessor?: "get" | "set",
|
||||
) {
|
||||
[this.#key, ...this.#remaining] = path;
|
||||
}
|
||||
@@ -648,12 +663,14 @@ class ObjectReferenceWalk {
|
||||
const selected = this.#key ?? null;
|
||||
if (typeof selected !== "object" && this.#overwritten.has(String(selected)))
|
||||
return;
|
||||
this.#references.push({
|
||||
node: property.argument,
|
||||
path: [this.#spreadKey(), ...this.#remaining],
|
||||
capturedSelectionAt: property.argument,
|
||||
ownEnumerable: true,
|
||||
});
|
||||
// Spread creates data properties; the source descriptor is not installed.
|
||||
if (this.accessor === undefined)
|
||||
this.#references.push({
|
||||
node: property.argument,
|
||||
path: [this.#spreadKey(), ...this.#remaining],
|
||||
capturedSelectionAt: property.argument,
|
||||
ownEnumerable: true,
|
||||
});
|
||||
if (
|
||||
selected !== null &&
|
||||
typeof selected !== "object" &&
|
||||
@@ -703,9 +720,11 @@ class ObjectReferenceWalk {
|
||||
name === null &&
|
||||
this.#key != null &&
|
||||
typeof this.#key !== "object" &&
|
||||
(t.isObjectMethod(property, { kind: "get" })
|
||||
(t.isObjectMethod(property) && property.kind === (this.accessor ?? "get")
|
||||
? this.#data.has(String(this.#key)) ||
|
||||
this.#getters.has(String(this.#key))
|
||||
(this.accessor === "set" ? this.#setters : this.#getters).has(
|
||||
String(this.#key),
|
||||
)
|
||||
: this.#overwritten.has(String(this.#key)))
|
||||
)
|
||||
return;
|
||||
@@ -714,6 +733,11 @@ class ObjectReferenceWalk {
|
||||
!semanticPropertyPathKeyMatches(this.#key ?? null, name)
|
||||
)
|
||||
return;
|
||||
if (this.accessor !== undefined) {
|
||||
if (t.isObjectMethod(property) && property.kind === this.accessor)
|
||||
this.#references.push({ node: property, path: [] });
|
||||
return;
|
||||
}
|
||||
if (t.isObjectMethod(property)) {
|
||||
if (property.kind === "get")
|
||||
this.query.schedule(
|
||||
|
||||
@@ -64,6 +64,8 @@ interface Candidate {
|
||||
|
||||
export interface SemanticReturnedReferenceResult {
|
||||
readonly references: readonly SemanticReturnedReference[];
|
||||
/** Source-local callable targets resolved by the same binding/projection owner. */
|
||||
readonly callables: ReadonlySet<t.Function>;
|
||||
/** A visible invocation-method mutation prevents a source-local result proof. */
|
||||
readonly unresolvedInvocation?: boolean;
|
||||
/** Both states close the supported source-local may-target graph. Widened joins projections without dropping represented targets; it does not claim runtime completeness. */
|
||||
@@ -144,9 +146,10 @@ export const createSemanticReturnedReferences = (
|
||||
let contextsWidened = false;
|
||||
const result: {
|
||||
references: readonly SemanticReturnedReference[];
|
||||
callables: ReadonlySet<t.Function>;
|
||||
precision: SemanticReturnedReferenceResult["precision"];
|
||||
unresolvedInvocation?: boolean;
|
||||
} = { references: [], precision: "exact" };
|
||||
} = { references: [], callables: functions, precision: "exact" };
|
||||
// The invocation owner retains the actual callable receiver. A bare alias
|
||||
// of fn.call has lost that receiver and cannot execute fn by itself.
|
||||
const nativeArrayIteration = (array: t.ArrayExpression) =>
|
||||
|
||||
@@ -86,6 +86,41 @@ describe("JavaScript export return-shape comparison", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves accessor-effect uncertainty through analyzed Evidence and export comparison", async () => {
|
||||
const [left, right] = await analyzeSources({
|
||||
left: `export default function observe() {
|
||||
const box = { value: 1, get trigger() { this.value = 2; return 0; } };
|
||||
box.trigger;
|
||||
return { type: "event", value: box.value, stable: "kept" };
|
||||
}`,
|
||||
right: `export default function observe() {
|
||||
return { type: "event", value: 2, stable: "kept" };
|
||||
}`,
|
||||
});
|
||||
const execution = compareJavaScriptExportShapesEvidence({
|
||||
left: left.evidence,
|
||||
right: right.evidence,
|
||||
left_module_path: "parser.mjs",
|
||||
left_export_name: "default",
|
||||
right_module_path: "parser.mjs",
|
||||
right_export_name: "default",
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
expect(execution.value.result.changes).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "unknown",
|
||||
path: "/value",
|
||||
left: expect.objectContaining({ availability: "unknown" }),
|
||||
right: { availability: "literal", value: 2 },
|
||||
}),
|
||||
]);
|
||||
expect(execution.value.sources).toContain(left.evidence);
|
||||
expect(execution.value.result.coverage.status).toBe("partial");
|
||||
expect(inventoryProperties(execution.value.result, "left", 0)).toEqual(
|
||||
expect.arrayContaining(["/stable", "/value", "/type"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps additions unknown when spread coverage is incomplete", async () => {
|
||||
const [left, right] = await analyzeSources({
|
||||
left: `
|
||||
|
||||
Reference in New Issue
Block a user