mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(javascript): retain implicit property conversion and disposal effects
This commit is contained in:
@@ -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: `
|
||||
|
||||
Reference in New Issue
Block a user