fix(javascript): retain implicit property conversion and disposal effects

This commit is contained in:
morluto
2026-10-24 02:39:53 +08:00
parent 44efd920c6
commit 905ae18451
9 changed files with 569 additions and 68 deletions
+20 -7
View File
@@ -23,6 +23,11 @@ artifact digest and structural graph ID. Semantic tracing requires the semantic
relation graph and reports when it is unavailable rather than treating missing
data as an empty graph.
Worker progress reports the source path and last observed parse, static,
semantic, projection or transfer stage. These notifications do not measure
stage CPU/time or establish the root cause of a slow or incomplete analysis.
Semantic projection limits bound retained output, not upstream analysis work.
Explicit TypeScript `import type` and `export type` declarations remain source
facts and do not create runtime import or export relationships. Type export names
are reported separately from value export names. Inline `type` specifiers can
@@ -431,14 +436,22 @@ 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.
the receiver and its shared children on assignment, including through known
array and prototype paths. Supported class descriptors use the same inherited
field and own-property shadowing rules as class reference selection. 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.
Object spreads, rest copies and destructuring obtain their property reads from
the shared pattern-source owner. Copies read own enumerable properties;
ordinary destructuring can invoke an inherited getter. Operations requiring
primitive conversion conservatively expose their conversion operands, including
computed keys, arithmetic and untagged template interpolation. `using` and
`await using` expose the retained resource at its lexical scope exit; earlier
scalar reads retain their capture timing. These are source-derived effects,
not execution results or proofs of accessor purity. Reflectively installed
descriptors remain opaque; their setup calls retain ordinary argument/receiver
escape uncertainty. External prototype changes and unsupported runtime protocols
remain unknown rather than supplying exact post-invocation values.
Nested callable returns are not assigned to their parent callable. Projected
graph observations carry source ranges but never source text.
@@ -0,0 +1,149 @@
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([
"const copy = { ...state };",
"const { trigger } = state;",
"let trigger; ({ trigger } = state);",
"const { ...copy } = state;",
"const outer = { state }; const { state: { trigger } } = outer;",
])(
"indexes implicit getter invocation through the shared pattern/copy owner: %s",
(read) => {
expect(
returned(`
const state = { value: 1, get trigger() { this.value = 2; return 0; } };
${read}
return state.value;
`)?.status,
).toBe("unknown");
},
);
it("keeps copied own data properties separate from inherited getters and excluded rest keys", () => {
expect(
returned(`
const state = { __proto__: { get trigger() { this.value = 2; return 0; } }, value: 1 };
const copy = { ...state };
return state.value;
`),
).toEqual({ status: "literal", value: 1 });
expect(
returned(`
const state = { value: 1, get trigger() { return 0; } };
const { trigger, ...copy } = state;
return state.value;
`),
).toEqual({ status: "literal", value: 1 });
});
it.each([
"const key = { [state]: 0 };",
"const result = state + '';",
"const result = +state;",
"const result = `${state}`;",
])(
"retains receiver uncertainty after implicit primitive conversion: %s",
(operation) => {
expect(
returned(`
const state = { [Symbol.toPrimitive]() { this.value = 2; return 'key'; }, value: 1 };
${operation}
return state.value;
`)?.status,
).toBe("unknown");
},
);
it("keeps noncoercing operations and primitive operands precise", () => {
expect(
returned(`
const state = { [Symbol.toPrimitive]() { this.value = 2; return 'key'; }, value: 1 };
const same = state === state;
const truthy = !state;
const primitive = 2 + 3;
return state.value;
`),
).toEqual({ status: "literal", value: 1 });
});
it.each(["using", "await using"])(
"retains %s disposal effects at lexical scope exit",
(kind) => {
const source = `${kind === "await using" ? "async " : ""}function observe() {
const state = { [Symbol.dispose]() { this.value = 2; }, value: 1 };
{ ${kind} owned = state; }
return state.value;
}`;
const ir = analyzeJavaScriptSemantics(source);
expect(onlyCallable(ir, "observe").returnSites[0]?.value.status).toBe(
"unknown",
);
},
);
it("preserves scalar snapshots selected before disposal", () => {
expect(
returned(`
const state = { [Symbol.dispose]() { this.value = 2; }, value: 1 };
const snapshot = state.value;
{ using owned = state; }
return snapshot;
`),
).toEqual({ status: "literal", value: 1 });
expect(
returned(`
const state = { [Symbol.dispose]() { this.value = 2; }, value: 1 };
using owned = state;
return state.value;
`),
).toEqual({ status: "literal", value: 1 });
});
it("keeps the disposer receiver captured before an outer binding replacement", () => {
expect(
returned(`
let state = { [Symbol.dispose]() { this.value = 2; }, value: 1 };
const original = state;
{ using owned = state; state = { value: 3 }; }
return original.value;
`)?.status,
).toBe("unknown");
expect(
returned(`
const state = { [Symbol.dispose]() { this.value = 2; }, value: 1 };
{ using nothing = null; }
return state.value;
`),
).toEqual({ status: "literal", value: 1 });
});
it("projects supported class getter effects through inherited receiver fields", () => {
expect(
returned(`
const shared = { value: 1 };
class Base { get trigger() { this.state.value = 2; return 0; } }
class Box extends Base { state = shared; }
new Box().trigger;
return shared.value;
`)?.status,
).toBe("unknown");
});
it("retains shared child effects from selected setters", () => {
expect(
returned(`
const shared = { value: 1 };
const box = { shared, set trigger(value) { this.shared.value = value; } };
box.trigger = 2;
return shared.value;
`)?.status,
).toBe("unknown");
});
@@ -401,12 +401,15 @@ export const expandSemanticGetterResult = (
/** Project an effect operand through the same local aliases and `this` owner as getter results. */
export const semanticGetterReceiverPaths = (
getter: t.ObjectMethod,
getter: t.ObjectMethod | t.ClassMethod,
operand: t.Node,
state: JavaScriptSemanticAnalysisState,
): readonly PropertyPath[] => {
const owner = state.parentsByNode.get(getter);
if (!t.isObjectExpression(owner)) return [];
const parent = state.parentsByNode.get(getter);
const owner = t.isClassBody(parent)
? state.parentsByNode.get(parent)
: parent;
if (!t.isObjectExpression(owner) && !t.isClass(owner)) return [];
const query = new SemanticGetterQuery(state);
const references = query.run(
() => {
@@ -419,7 +422,7 @@ export const semanticGetterReceiverPaths = (
query,
{
objectReferences: (_receiver, path) => [{ node: getter, path }],
classReferences: () => [],
classReferences: (_lookup, path) => [{ node: getter, path }],
privateReceiver: () => false,
bases: () => [],
containerReferences: (node, path) =>
@@ -29,6 +29,13 @@ export interface SemanticIterationSource extends IterationSourceValue {
readonly requiredSources?: readonly IterationSourceValue[];
}
/** A property value obtained by destructuring or an own-enumerable copy. */
export interface SemanticPropertyReadSource
extends IterationSourceValue, SourceGuards {
readonly mutation: t.Node;
readonly lookup: "prototype" | "own";
}
interface PatternSource extends IterationSourceValue {
// A rest binding is a fresh array whose children retain their source indices.
readonly restStartIndex?: number;
@@ -112,6 +119,7 @@ const project = (
interface PatternCollection {
readonly sources: SemanticIterationSource[];
readonly mutation: t.Node;
readonly propertyReads: SemanticPropertyReadSource[];
}
const visit = (
@@ -191,11 +199,7 @@ const visit = (
if (t.isObjectPattern(pattern)) {
for (const property of pattern.properties) {
if (t.isRestElement(property)) {
// A rest copy owns its scalar slots; only selected child references
// escape when that fresh copy is stored through a member target.
visit(
context,
property.argument,
const rest =
source === undefined
? undefined
: {
@@ -208,20 +212,31 @@ const visit = (
].sort(compareUnicodeCodePoints),
},
],
},
guards,
);
};
if (rest !== undefined)
context.propertyReads.push({
...rest,
...guards,
mutation,
lookup: "own",
});
// A rest copy owns its scalar slots; only selected child references
// escape when that fresh copy is stored through a member target.
visit(context, property.argument, rest, guards);
continue;
}
visit(
context,
property.value,
project(
source,
semanticStaticPropertyKey(property.key, property.computed),
),
guards,
const selected = project(
source,
semanticStaticPropertyKey(property.key, property.computed),
);
if (selected !== undefined)
context.propertyReads.push({
...selected,
...guards,
mutation,
lookup: "prototype",
});
visit(context, property.value, selected, guards);
}
return;
}
@@ -258,13 +273,40 @@ const visit = (
visit(context, pattern.argument, undefined, guards);
};
/** Describe iterator receivers and property reference stores without evaluation. */
export const semanticIterationSources = (
interface SemanticSourceFacts {
readonly iterations: readonly SemanticIterationSource[];
readonly propertyReads: readonly SemanticPropertyReadSource[];
}
const emptySourceFacts: SemanticSourceFacts = {
iterations: [],
propertyReads: [],
};
const sourceFacts = new WeakMap<
t.Node,
{ readonly parent: t.Node | null; readonly facts: SemanticSourceFacts }
>();
/** One owner for pattern projections, guards, iteration and copy-read sources. */
const semanticSourceFacts = (
node: t.Node,
parent: t.Node | null,
): readonly SemanticIterationSource[] => {
): SemanticSourceFacts => {
if (
!(
t.isVariableDeclarator(node) ||
t.isAssignmentExpression(node) ||
t.isForOfStatement(node) ||
t.isSpreadElement(node) ||
t.isYieldExpression(node) ||
t.isFunction(node)
)
)
return emptySourceFacts;
const cached = sourceFacts.get(node);
if (cached?.parent === parent) return cached.facts;
const sources: SemanticIterationSource[] = [];
const context = { sources, mutation: node };
const propertyReads: SemanticPropertyReadSource[] = [];
const context = { sources, propertyReads, mutation: node };
if (t.isVariableDeclarator(node) && node.init != null)
visit(context, node.id, { node: node.init, projection: [] });
else if (
@@ -279,13 +321,38 @@ export const semanticIterationSources = (
for (const declaration of node.left.declarations)
visit(context, declaration.id, source);
} else visit(context, node.left, source);
} else if (t.isSpreadElement(node) && t.isArrayExpression(parent))
add(sources, { node: node.argument, projection: [] }, node, {});
else if (t.isYieldExpression(node) && node.delegate && node.argument != null)
} else if (t.isSpreadElement(node)) {
if (t.isArrayExpression(parent))
add(sources, { node: node.argument, projection: [] }, node, {});
else if (t.isObjectExpression(parent))
propertyReads.push({
node: node.argument,
projection: [{ excludedKeys: [] }],
mutation: node,
lookup: "own",
});
} else if (
t.isYieldExpression(node) &&
node.delegate &&
node.argument != null
)
add(sources, { node: node.argument, projection: [] }, node, {});
else if (t.isFunction(node))
for (const parameter of runtimeFunctionParameters(node))
visit(context, parameter, undefined);
return sources;
const facts = { iterations: sources, propertyReads };
sourceFacts.set(node, { parent, facts });
return facts;
};
export const semanticIterationSources = (
node: t.Node,
parent: t.Node | null,
): readonly SemanticIterationSource[] =>
semanticSourceFacts(node, parent).iterations;
export const semanticPropertyReadSources = (
node: t.Node,
parent: t.Node | null,
): readonly SemanticPropertyReadSource[] =>
semanticSourceFacts(node, parent).propertyReads;
@@ -0,0 +1,33 @@
import * as t from "@babel/types";
/** Operands exposed to user-defined primitive conversion by language operations. */
export const semanticCoercionOperands = (node: t.Node): readonly t.Node[] => {
if (t.isBinaryExpression(node)) {
if (["===", "!==", "instanceof"].includes(node.operator)) return [];
return node.operator === "in" ? [node.left] : [node.left, node.right];
}
if (t.isUnaryExpression(node) && ["+", "-", "~"].includes(node.operator))
return [node.argument];
if (t.isUpdateExpression(node)) return [node.argument];
if (
t.isAssignmentExpression(node) &&
!["=", "&&=", "||=", "??="].includes(node.operator)
)
return [node.left, node.right];
if (
(t.isMemberExpression(node) ||
t.isOptionalMemberExpression(node) ||
t.isObjectProperty(node) ||
t.isObjectMethod(node) ||
t.isClassProperty(node) ||
t.isClassMethod(node) ||
t.isClassAccessorProperty(node)) &&
node.computed
)
return [
t.isMemberExpression(node) || t.isOptionalMemberExpression(node)
? node.property
: node.key,
];
return [];
};
@@ -32,13 +32,16 @@ import {
semanticArrayPropertyReferences,
semanticClassCandidates,
semanticClassPropertyReferences,
semanticClassAccessorReferences,
semanticObjectPropertyReferences,
semanticObjectAccessorReferences,
type JavaScriptSemanticObjectReference,
} from "./javascriptSemanticObjectReferences.js";
import {
semanticIterationSources,
semanticPropertyReadSources,
type SemanticIterationSource,
type SemanticPropertyReadSource,
} from "./javascriptSemanticIterationSources.js";
import { semanticGetterReceiverPaths } from "./javascriptSemanticGetterResults.js";
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
@@ -46,7 +49,9 @@ import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedRe
import {
semanticMutationInitializers,
semanticBindingAtRead,
semanticScopeExitEffect,
} from "./javascriptSemanticMutationInitializers.js";
import { semanticCoercionOperands } from "./javascriptSemanticLanguageInvocations.js";
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
import {
semanticCopiedReferencePath,
@@ -105,9 +110,10 @@ const localAccessorMethods = (
key: PropertyPath[number] | undefined,
kind: "get" | "set",
) => boolean,
): readonly t.ObjectMethod[] | undefined => {
lookup: "prototype" | "own" = "prototype",
): readonly (t.ObjectMethod | t.ClassMethod)[] | undefined => {
if (!keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.ObjectMethod>();
const methods = new Set<t.ObjectMethod | t.ClassMethod>();
const pending = [{ node: root, path }];
const examined = new WeakMap<t.Node, Set<string>>();
let selections = 0;
@@ -123,7 +129,10 @@ const localAccessorMethods = (
state.valueResourceLimits.add("alias-selections");
return undefined;
}
if (t.isObjectMethod(node) && current.path.length === 0) {
if (
(t.isObjectMethod(node) || t.isClassMethod(node)) &&
current.path.length === 0
) {
methods.add(node);
continue;
}
@@ -162,13 +171,49 @@ const localAccessorMethods = (
const references = t.isArrayExpression(node)
? semanticArrayPropertyReferences(node, [selected!])
: remaining.length === 0
? semanticObjectAccessorReferences(node, selected!, state, accessor)
? semanticObjectAccessorReferences(
node,
selected!,
state,
accessor,
lookup,
)
: semanticObjectPropertyReferences(node, [selected!], state);
for (const source of references)
pending.push({
node: source.node,
path: [...source.path, ...remaining],
});
} else if (
(t.isClass(node) || t.isNewExpression(node)) &&
current.path.length > 0
) {
const [selected, ...remaining] = current.path;
const owners = t.isClass(node)
? [node]
: semanticClassCandidates(node.callee, state);
for (const owner of owners) {
const selection = {
owner,
kind: t.isClass(node) ? ("static" as const) : ("instance" as const),
};
const references =
remaining.length > 0
? semanticClassPropertyReferences(selection, [selected!], state)
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
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);
@@ -182,7 +227,9 @@ type AccessorEffectOperand =
| {
readonly kind: "read";
readonly node: t.Node;
readonly key: PropertyPath[number];
readonly path: PropertyPath;
readonly lookup: "prototype" | "own";
readonly guards?: SemanticPropertyReadSource;
}
| {
readonly kind: "invoke";
@@ -202,10 +249,12 @@ const accessorEffectOperands = (
const cached = accessorOperands.get(method);
if (cached !== undefined) return cached;
const operands: AccessorEffectOperand[] = [];
const pending: t.Node[] = [method.body];
const pending: { node: t.Node; parent: t.Node | null }[] = [
{ node: method.body, parent: method },
];
const writeOnly = new WeakSet<t.Node>();
while (pending.length > 0) {
const node = pending.pop()!;
const { node, parent } = pending.pop()!;
// Defining a nested callable does not invoke it.
if (t.isFunction(node) || t.isClass(node)) continue;
const write = semanticWriteSiteTarget(node);
@@ -223,7 +272,8 @@ const accessorEffectOperands = (
operands.push({
kind: "read",
node: node.object,
key: semanticStaticPropertyKey(node.property, node.computed),
path: [semanticStaticPropertyKey(node.property, node.computed)],
lookup: "prototype",
});
if (
t.isCallExpression(node) ||
@@ -254,7 +304,28 @@ const accessorEffectOperands = (
for (const expression of node.quasi.expressions)
operands.push({ kind: "escape", node: expression });
}
pending.push(...childNodes(node).reverse());
for (const operand of semanticCoercionOperands(node))
operands.push({ kind: "escape", node: operand });
if (t.isTemplateLiteral(node) && !t.isTaggedTemplateExpression(parent))
for (const expression of node.expressions)
operands.push({ kind: "escape", node: expression });
for (const source of semanticPropertyReadSources(node, parent))
operands.push({
kind: "read",
node: source.node,
path: source.projection,
lookup: source.lookup,
guards: source,
});
if (
t.isVariableDeclarator(node) &&
node.init != null &&
t.isVariableDeclaration(parent) &&
(parent.kind === "using" || parent.kind === "await using")
)
operands.push({ kind: "escape", node: node.init });
for (const child of childNodes(node).reverse())
pending.push({ node: child, parent: node });
}
accessorOperands.set(method, operands);
return operands;
@@ -275,7 +346,10 @@ export function* collectSemanticMemberMutationsSteps(
set: new Set<string | null>(),
};
for (const node of state.callableNodesById.values())
if (t.isObjectMethod(node) && (node.kind === "get" || node.kind === "set"))
if (
(t.isObjectMethod(node) || t.isClassMethod(node)) &&
(node.kind === "get" || node.kind === "set")
)
accessorKeys[node.kind].add(
semanticStaticPropertyKey(node.key, node.computed),
);
@@ -295,14 +369,15 @@ export function* collectSemanticMemberMutationsSteps(
const getterEffectPaths = (
root: t.Node,
path: PropertyPath,
lookup: "prototype" | "own" = "prototype",
): readonly PropertyPath[] => {
if (!accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [path];
const pending = [{ path, lookup }];
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);
const { path: selected, lookup: selectedLookup } = pending[cursor]!;
const identity = JSON.stringify([selected, selectedLookup]);
if (examined.has(identity)) continue;
examined.add(identity);
if (examined.size > SEMANTIC_ALIAS_SELECTION_LIMIT) {
@@ -315,6 +390,7 @@ export function* collectSemanticMemberMutationsSteps(
"get",
state,
accessorKeyMayExist,
selectedLookup,
);
const receiver = selected.slice(0, -1);
if (methods === undefined) {
@@ -339,7 +415,9 @@ export function* collectSemanticMemberMutationsSteps(
}
if (
operand.kind === "read" &&
!accessorKeyMayExist(operand.key, "get")
(!accessorKeyMayExist(operand.path.at(-1), "get") ||
(operand.guards !== undefined &&
semanticReferenceSourcesInactive(operand.guards, state)))
)
continue;
for (const suffix of semanticGetterReceiverPaths(
@@ -349,7 +427,11 @@ export function* collectSemanticMemberMutationsSteps(
)) {
const target = [...receiver, ...suffix];
if (operand.kind === "escape") effects.push(target);
else pending.push([...target, operand.key]);
else
pending.push({
path: [...target, ...operand.path],
lookup: operand.lookup,
});
}
}
}
@@ -701,9 +783,8 @@ export function* collectSemanticMemberMutationsSteps(
current.originAt,
parents,
);
// 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.
// Invoking a setter exposes the receiver, including shared children.
// Preserve the explicit target write separately; deleting never invokes it.
if (
effect === "write" &&
!t.isUnaryExpression(mutation, { operator: "delete" })
@@ -721,7 +802,15 @@ export function* collectSemanticMemberMutationsSteps(
)?.length ?? 1) > 0,
)
)
path = [...selectedPath.slice(0, -1), null];
markValue(
current.node,
selectedPath.slice(0, -1),
new Set(),
"escape",
mutation,
current.originAt,
current.ownerInitializer,
);
}
// Keep an antichain of exact paths. Widening distinct member keys would
// also invalidate unrelated siblings that never reach this effect.
@@ -1090,7 +1179,7 @@ export function* collectSemanticMemberMutationsSteps(
};
const writeOnlyMembers = new WeakSet<t.Node>();
yield* traverseJavaScriptAstSteps(program, {
enter: (node, parent) => {
enter: (node, parent, readAncestors) => {
const writeTarget = semanticWriteSiteTarget(node);
if (writeTarget !== null && !semanticWriteSiteReadsFirst(node))
for (const target of semanticWriteTargets(writeTarget))
@@ -1105,6 +1194,49 @@ export function* collectSemanticMemberMutationsSteps(
originAt: source.mutation,
iterationOnly: source.kind === "iteration",
});
for (const operand of semanticCoercionOperands(node))
markEscaped(operand, node);
if (t.isTemplateLiteral(node) && !t.isTaggedTemplateExpression(parent))
for (const expression of node.expressions)
markEscaped(expression, node);
if (
t.isVariableDeclarator(node) &&
node.init != null &&
t.isVariableDeclaration(parent) &&
(parent.kind === "using" || parent.kind === "await using")
) {
const scope = readAncestors().findLast(
(ancestor) =>
t.isBlockStatement(ancestor) ||
t.isProgram(ancestor) ||
t.isStaticBlock(ancestor) ||
t.isSwitchStatement(ancestor) ||
t.isLoop(ancestor),
);
if (scope !== undefined) {
const exit = semanticScopeExitEffect(scope);
if (t.isIdentifier(node.id))
markValue(
node.id,
[],
new Set(),
"escape",
exit,
node.id,
node.init,
);
else markEscaped(node.init, exit);
}
}
for (const source of semanticPropertyReadSources(node, parent)) {
if (semanticReferenceSourcesInactive(source, state)) continue;
for (const path of getterEffectPaths(
source.node,
source.projection,
source.lookup,
))
markEscaped(source.node, source.mutation, path);
}
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
// The write owner classifies direct and destructuring stores once.
if (!writeOnlyMembers.has(node))
@@ -156,6 +156,39 @@ export const semanticTopLevelEffectFollowsCapture = (
return expressionEvaluationOrder(capture, effect, parents) === "before";
};
const scopeExitMarkers = new WeakMap<t.Node, t.EmptyStatement>();
const scopeExitOwners = new WeakMap<t.Node, t.Node>();
/** A derived scope-end effect keeps disposal separate from initializer evaluation. */
export const semanticScopeExitEffect = (scope: t.Node): t.Node => {
const cached = scopeExitMarkers.get(scope);
if (cached !== undefined) return cached;
const marker = t.emptyStatement();
marker.start = scope.end;
marker.end = scope.end;
if (scope.loc != null)
marker.loc = { ...scope.loc, start: scope.loc.end, end: scope.loc.end };
scopeExitMarkers.set(scope, marker);
scopeExitOwners.set(marker, scope);
return marker;
};
const withinScopeExit = (
reference: t.Node,
scope: t.Node,
parents: WeakMap<t.Node, t.Node>,
): boolean => {
for (
let node: t.Node | undefined = reference;
node !== undefined;
node = parents.get(node)
) {
if (node === scope) return true;
if (t.isFunction(node)) return false;
}
return false;
};
/** Include expression order when a write operation contains the read site. */
export const semanticEffectFollowsReference = (
reference: t.Node,
@@ -163,6 +196,11 @@ export const semanticEffectFollowsReference = (
parents: WeakMap<t.Node, t.Node>,
): boolean => {
if (reference === effect) return false;
const exit = effect === undefined ? undefined : scopeExitOwners.get(effect);
if (exit !== undefined) {
if (withinScopeExit(reference, exit, parents)) return true;
effect = exit;
}
let current: t.Node | undefined = reference;
let child: t.Node | undefined;
while (current !== undefined && current !== effect) {
@@ -210,6 +248,11 @@ export const semanticEffectTiming = (
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticEffectTiming => {
if (effect === undefined) return "unknown";
const exit = scopeExitOwners.get(effect);
if (exit !== undefined) {
if (withinScopeExit(site, exit, state.parentsByNode)) return "after";
effect = exit;
}
// Invocation receives arguments after their values have been selected.
if (
site === effect &&
@@ -156,9 +156,10 @@ const classPropertyDescriptors = (
propertyName: string | null,
kind: ClassPropertyLookup["kind"],
initializedBefore?: t.ClassProperty,
accessor: "get" | "set" = "get",
) => {
const isStatic = kind === "static";
const selectedGetters: t.ClassMethod[] = [];
const selectedAccessors: t.ClassMethod[] = [];
const fields: t.ClassProperty[] = [];
const indexedMembers: {
readonly member: t.ClassMethod | t.ClassProperty;
@@ -191,19 +192,19 @@ const classPropertyDescriptors = (
propertyName === null
? []
: indexedMembers.filter(({ key }) => key === propertyName);
const uncertainGetters = indexedMembers.filter(
const uncertainAccessors = indexedMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } => {
const { member, key } = entry;
return (
t.isClassMethod(member) &&
member.kind === "get" &&
member.kind === accessor &&
(propertyName === null || (member.computed && key === null))
);
},
);
const getters = matchingMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } =>
t.isClassMethod(entry.member) && entry.member.kind === "get",
t.isClassMethod(entry.member) && entry.member.kind === accessor,
);
const getter = getters.at(-1);
const getterIndex = getter?.index ?? -1;
@@ -223,20 +224,20 @@ const classPropertyDescriptors = (
fields.push(member);
}
if (getters.length > 0 && !laterDataMember && getter !== undefined)
selectedGetters.push(getter.member);
for (const { member, index } of uncertainGetters)
selectedAccessors.push(getter.member);
for (const { member, index } of uncertainAccessors)
if (
!matchingMembers.some(
({ member: matching, index: matchingIndex }) =>
(t.isClassProperty(matching) ||
(t.isClassMethod(matching) &&
(matching.kind === "method" || matching.kind === "get"))) &&
(matching.kind === "method" || matching.kind === accessor))) &&
matchingIndex > index,
)
)
selectedGetters.push(member);
selectedAccessors.push(member);
return {
getters: selectedGetters,
accessors: selectedAccessors,
fields,
inherits: matchingMembers.length === 0,
};
@@ -254,6 +255,18 @@ export const semanticClassPropertyReferences = (
);
};
/** Class descriptor selection shares field shadowing and inheritance with reference lookup. */
export const semanticClassAccessorReferences = (
lookup: ClassPropertyLookup,
key: PropertyPath[number],
state: JavaScriptSemanticAnalysisState,
accessor: "get" | "set",
): readonly JavaScriptSemanticObjectReference[] =>
new ClassReferenceWalk(lookup, [key], state, {
query: new SemanticGetterQuery(state),
accessor,
}).run();
class ClassReferenceWalk {
readonly #references: JavaScriptSemanticObjectReference[] = [];
readonly #pending: (t.ClassDeclaration | t.ClassExpression)[];
@@ -269,6 +282,7 @@ class ClassReferenceWalk {
readonly context: {
readonly query: SemanticGetterQuery;
readonly escapes?: boolean;
readonly accessor?: "get" | "set";
},
) {
const [key, ...path] = selection;
@@ -287,7 +301,9 @@ class ClassReferenceWalk {
this.state,
this.lookup.initializedBefore,
);
for (const reference of fields.references) this.#references.push(reference);
if (this.context.accessor === undefined)
for (const reference of fields.references)
this.#references.push(reference);
this.#fields = fields.definiteNames;
return this.#name !== null && this.#fields.has(this.#name);
}
@@ -298,6 +314,10 @@ class ClassReferenceWalk {
): void {
const key = semanticStaticPropertyKey(getter.key, getter.computed);
if (key !== null && this.#fields.has(key)) return;
if (this.context.accessor !== undefined) {
this.#references.push({ node: getter, path: [] });
return;
}
this.context.query.schedule(
{
getter,
@@ -315,11 +335,14 @@ class ClassReferenceWalk {
this.#name,
this.lookup.kind,
this.lookup.initializedBefore,
this.context.accessor,
);
for (const field of descriptors.fields)
for (const field of this.context.accessor === undefined
? descriptors.fields
: [])
if (field.value != null)
this.#references.push({ node: field.value, path: this.#path });
for (const getter of this.lookup.fieldsOnly ? [] : descriptors.getters)
for (const getter of this.lookup.fieldsOnly ? [] : descriptors.accessors)
this.#getter(klass, getter);
// Only a definite own descriptor can suppress possible superclass results.
if (descriptors.inherits && klass.superClass != null)
@@ -617,12 +640,14 @@ export const semanticObjectAccessorReferences = (
key: PropertyPath[number],
state: JavaScriptSemanticAnalysisState,
accessor: "get" | "set",
lookup: "prototype" | "own" = "prototype",
): readonly JavaScriptSemanticObjectReference[] =>
new ObjectReferenceWalk(
node,
[key],
new SemanticGetterQuery(state),
accessor,
lookup,
).run();
class ObjectReferenceWalk {
@@ -640,6 +665,7 @@ class ObjectReferenceWalk {
path: PropertyPath,
readonly query: SemanticGetterQuery,
readonly accessor?: "get" | "set",
readonly accessorLookup: "prototype" | "own" = "prototype",
) {
[this.#key, ...this.#remaining] = path;
}
@@ -694,6 +720,8 @@ class ObjectReferenceWalk {
)
return false;
const selected = this.#key ?? null;
if (this.accessor !== undefined && this.accessorLookup === "own")
return true;
if (
t.isExpression(property.value) &&
(selected === null ||
@@ -121,6 +121,39 @@ describe("JavaScript export return-shape comparison", () => {
);
});
it("preserves independent implicit invocation unknowns through retained export Evidence", async () => {
const [left, right] = await analyzeSources({
left: `export default function observe() {
const getter = { value: 1, get trigger() { this.value = 2; return 0; } };
const copy = { ...getter };
const coercion = { [Symbol.toPrimitive]() { this.value = 2; return "key"; }, value: 1 };
const keyed = { [coercion]: 0 };
const disposal = { [Symbol.dispose]() { this.value = 2; }, value: 1 };
{ using owned = disposal; }
return { type: "event", getter: getter.value, coercion: coercion.value, disposal: disposal.value, stable: "kept" };
}`,
right: `export default function observe() {
return { type: "event", getter: 2, coercion: 2, disposal: 2, stable: "kept" };
}`,
});
const result = compare(left, right);
expect(result.changes).toHaveLength(3);
expect(result.changes).toEqual(
expect.arrayContaining(
["/getter", "/coercion", "/disposal"].map((path) =>
expect.objectContaining({
path,
status: "unknown",
left: expect.objectContaining({ availability: "unknown" }),
right: { availability: "literal", value: 2 },
}),
),
),
);
expect(result.coverage.status).toBe("partial");
expect(inventoryProperties(result, "left", 0)).toContain("/stable");
});
it("keeps additions unknown when spread coverage is incomplete", async () => {
const [left, right] = await analyzeSources({
left: `