fix(javascript): share accessor selection and invocation effects

This commit is contained in:
morluto committed 2026-10-24 01:47:31 +08:00
1 parent 48f700ee35
commit 6e324b2244
7 files changed
+623 -96

No files matched your search

+16 -6
View File
@@ -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: `