fix(javascript): retain private receiver and iterative disposal effects

This commit is contained in:
morluto
2026-10-24 02:47:38 +08:00
parent 0211b65a81
commit 787726817c
5 changed files with 169 additions and 36 deletions
@@ -147,3 +147,87 @@ it("retains shared child effects from selected setters", () => {
`)?.status,
).toBe("unknown");
});
it("exposes custom instanceof receivers but keeps object equality noncoercing", () => {
expect(
returned(`
const state = { value: 1, [Symbol.hasInstance]() { this.value = 2; return false; } };
const result = {} instanceof state;
return state.value;
`)?.status,
).toBe("unknown");
expect(
returned(`
const state = { value: 1 };
const target = { [Symbol.hasInstance](value) { value.value = 2; return false; } };
const result = state instanceof target;
return state.value;
`)?.status,
).toBe("unknown");
expect(
returned(`
const state = { value: 1, [Symbol.toPrimitive]() { this.value = 2; return 0; } };
const equal = state == state; const unequal = state != {};
return state.value;
`),
).toEqual({ status: "literal", value: 1 });
});
it("exposes class private storage reached by an invoked getter", () => {
expect(
returned(`
const shared = { value: 1 };
class Box { #state = shared; get trigger() { this.#state.value = 2; return 0; } }
new Box().trigger;
return shared.value;
`)?.status,
).toBe("unknown");
});
it("retains disposal of per-iteration resources through existing loop origins", () => {
expect(
returned(`
const state = { value: 1, [Symbol.dispose]() { this.value = 2; } };
for (using owned of [state]) {}
return state.value;
`)?.status,
).toBe("unknown");
});
it("does not expose a returned resource as an unchanged object snapshot", () => {
const value = returned(`
const state = { value: 1, [Symbol.dispose]() { this.value = 2; } };
using owned = state;
return state;
`);
expect(
value?.status === "object"
? value.properties.find(({ name }) => name === "value")?.value.status
: value?.status,
).toBe("unknown");
});
it("retains earlier iteration disposal uncertainty for reused outer resources", () => {
expect(
returned(`
const state = { value: 1, [Symbol.dispose]() { this.value = 2; } };
for (const index of [0, 1]) {
using owned = state;
const snapshot = state.value;
if (index === 1) return snapshot;
}
`)?.status,
).toBe("unknown");
});
it("follows superclass getter effects through the selected receiver", () => {
expect(
returned(`
const shared = { value: 1 };
class Base { get trigger2() { this.state.value = 2; return 0; } }
class Box extends Base { state = shared; get trigger() { return super.trigger2; } }
new Box().trigger;
return shared.value;
`)?.status,
).toBe("unknown");
});
@@ -233,7 +233,7 @@ class GetterResultWalker {
}));
}
#ownsThis(expression: t.ThisExpression): boolean {
#ownsThis(expression: t.ThisExpression | t.Super): boolean {
let owner = this.query.state.parentsByNode.get(expression);
while (
owner !== undefined &&
@@ -269,7 +269,10 @@ class GetterResultWalker {
});
}
#thisValue(current: GetterWork, expression: t.ThisExpression): void {
#thisValue(
current: GetterWork,
expression: t.ThisExpression | t.Super,
): void {
if (!this.#ownsThis(expression)) return;
const { getter, owner, receiver = owner } = this.job.invocation;
if (current.privateRead !== undefined && t.isClass(owner)) {
@@ -362,7 +365,8 @@ class GetterResultWalker {
#visit(current: GetterWork): void {
const expression = unwrapJavaScriptExpression(current.node).node;
if (t.isIdentifier(expression) && this.#alias(current, expression)) return;
if (t.isThisExpression(expression)) this.#thisValue(current, expression);
if (t.isThisExpression(expression) || t.isSuper(expression))
this.#thisValue(current, expression);
else if (
t.isMemberExpression(expression) ||
t.isOptionalMemberExpression(expression)
@@ -400,17 +404,18 @@ export const expandSemanticGetterResult = (
): 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 = (
export const semanticGetterReceiverReferences = (
getter: t.ObjectMethod | t.ClassMethod,
operand: t.Node,
state: JavaScriptSemanticAnalysisState,
): readonly PropertyPath[] => {
): readonly Reference[] => {
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);
let privateReceiver = false;
const references = query.run(
() => {
query.schedule({ getter, owner }, []);
@@ -423,7 +428,12 @@ export const semanticGetterReceiverPaths = (
{
objectReferences: (_receiver, path) => [{ node: getter, path }],
classReferences: (_lookup, path) => [{ node: getter, path }],
privateReceiver: () => false,
privateReceiver: () => {
// Keep private-field initializer identity from the existing
// getter owner; private slots are not public property paths.
privateReceiver = true;
return true;
},
bases: () => [],
containerReferences: (node, path) =>
t.isObjectExpression(node)
@@ -433,7 +443,5 @@ export const semanticGetterReceiverPaths = (
[{ node: operand }],
).run(),
);
return references
.filter(({ node }) => node === getter)
.map(({ path }) => path);
return references.filter(({ node }) => node === getter || privateReceiver);
};
@@ -1,9 +1,19 @@
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[] => {
export const semanticCoercionOperands = (
node: t.Node,
isObject: (operand: t.Node) => boolean = () => false,
): readonly t.Node[] => {
if (t.isBinaryExpression(node)) {
if (["===", "!==", "instanceof"].includes(node.operator)) return [];
if (["===", "!=="].includes(node.operator)) return [];
if (node.operator === "instanceof") return [node.left, node.right];
if (
["==", "!="].includes(node.operator) &&
isObject(node.left) &&
isObject(node.right)
)
return [];
return node.operator === "in" ? [node.left] : [node.left, node.right];
}
if (t.isUnaryExpression(node) && ["+", "-", "~"].includes(node.operator))
@@ -47,7 +47,7 @@ import {
type SemanticIterationSource,
type SemanticPropertyReadSource,
} from "./javascriptSemanticIterationSources.js";
import { semanticGetterReceiverPaths } from "./javascriptSemanticGetterResults.js";
import { semanticGetterReceiverReferences } from "./javascriptSemanticGetterResults.js";
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
import {
@@ -370,26 +370,35 @@ export function* collectSemanticMemberMutationsSteps(
);
};
const returnedReferences = createSemanticReturnedReferences(state);
const getterEffectPaths = (
const getterEffects = (
root: t.Node,
path: PropertyPath,
lookup: "prototype" | "own" = "prototype",
): readonly PropertyPath[] => {
): readonly JavaScriptSemanticObjectReference[] => {
if (!accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ path, lookup }];
const pending = [{ node: root, path, lookup }];
const examined = new Set<string>();
const effects: PropertyPath[] = [];
const effects: JavaScriptSemanticObjectReference[] = [];
for (let cursor = 0; cursor < pending.length; cursor++) {
const { path: selected, lookup: selectedLookup } = pending[cursor]!;
const identity = JSON.stringify([selected, selectedLookup]);
const {
node: selectedRoot,
path: selected,
lookup: selectedLookup,
} = pending[cursor]!;
const identity = JSON.stringify([
selectedRoot.start,
selectedRoot.end,
selected,
selectedLookup,
]);
if (examined.has(identity)) continue;
examined.add(identity);
if (examined.size > SEMANTIC_ALIAS_SELECTION_LIMIT) {
state.valueResourceLimits.add("alias-selections");
return [[]];
return [{ node: root, path: [] }];
}
const methods = localAccessorMethods(
root,
selectedRoot,
selected,
"get",
state,
@@ -398,7 +407,7 @@ export function* collectSemanticMemberMutationsSteps(
);
const receiver = selected.slice(0, -1);
if (methods === undefined) {
effects.push(receiver);
effects.push({ node: selectedRoot, path: receiver });
continue;
}
for (const method of methods) {
@@ -424,15 +433,23 @@ export function* collectSemanticMemberMutationsSteps(
semanticReferenceSourcesInactive(operand.guards, state)))
)
continue;
for (const suffix of semanticGetterReceiverPaths(
for (const reference of semanticGetterReceiverReferences(
method,
operand.node,
state,
)) {
const target = [...receiver, ...suffix];
if (operand.kind === "escape") effects.push(target);
const receiverReference = reference.node === method;
const targetRoot = receiverReference
? selectedRoot
: reference.node;
const target = receiverReference
? [...receiver, ...reference.path]
: reference.path;
if (operand.kind === "escape")
effects.push({ node: targetRoot, path: target });
else
pending.push({
node: targetRoot,
path: [...target, ...operand.path],
lookup: operand.lookup,
});
@@ -1211,14 +1228,16 @@ export function* collectSemanticMemberMutationsSteps(
originAt: source.mutation,
iterationOnly: source.kind === "iteration",
});
for (const operand of semanticCoercionOperands(node))
for (const operand of semanticCoercionOperands(node, (operand) => {
const value = evaluateSemanticExpression(operand, state, node);
return value.status === "object" || value.status === "array";
}))
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")
) {
@@ -1240,27 +1259,27 @@ export function* collectSemanticMemberMutationsSteps(
"escape",
exit,
node.id,
node.init,
node.init ?? undefined,
);
else markEscaped(node.init, exit);
else if (node.init != null) markEscaped(node.init, exit);
}
}
for (const source of semanticPropertyReadSources(node, parent)) {
if (semanticReferenceSourcesInactive(source, state)) continue;
for (const path of getterEffectPaths(
for (const effect of getterEffects(
source.node,
source.projection,
source.lookup,
))
markEscaped(source.node, source.mutation, path);
markEscaped(effect.node, source.mutation, effect.path);
}
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, [
for (const effect of getterEffects(node.object, [
semanticStaticPropertyKey(node.property, node.computed),
]))
markEscaped(node.object, node, path);
markEscaped(effect.node, node, effect.path);
}
if (writeTarget !== null) markTarget(writeTarget, node);
else if (
@@ -164,8 +164,10 @@ 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.end !== undefined) {
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);
@@ -183,8 +185,18 @@ const withinScopeExit = (
node !== undefined;
node = parents.get(node)
) {
if (node === scope) return true;
if (t.isFunction(node)) return false;
if (node === scope) {
for (
let owner = parents.get(scope);
owner !== undefined;
owner = parents.get(owner)
) {
if (t.isLoop(owner)) return false;
if (t.isFunction(owner)) break;
}
return !t.isLoop(scope);
}
if (t.isFunction(node) || t.isLoop(node)) return false;
}
return false;
};