From 787726817c0f6bf2df64703b647cb7a71e83c5ee Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:47:38 +0800 Subject: [PATCH] fix(javascript): retain private receiver and iterative disposal effects --- ...anticAnalysis.language_invocations.test.ts | 84 +++++++++++++++++++ .../javascriptSemanticGetterResults.ts | 26 ++++-- .../javascriptSemanticLanguageInvocations.ts | 14 +++- .../javascriptSemanticMemberMutations.ts | 61 +++++++++----- .../javascriptSemanticMutationInitializers.ts | 20 ++++- 5 files changed, 169 insertions(+), 36 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts index 68e11b5a10..ef03a001eb 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts @@ -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"); +}); diff --git a/src/domain/javascript/javascriptSemanticGetterResults.ts b/src/domain/javascript/javascriptSemanticGetterResults.ts index 1ed8772eb7..d1c01e813e 100644 --- a/src/domain/javascript/javascriptSemanticGetterResults.ts +++ b/src/domain/javascript/javascriptSemanticGetterResults.ts @@ -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); }; diff --git a/src/domain/javascript/javascriptSemanticLanguageInvocations.ts b/src/domain/javascript/javascriptSemanticLanguageInvocations.ts index d9fe9d7b53..419fe8f352 100644 --- a/src/domain/javascript/javascriptSemanticLanguageInvocations.ts +++ b/src/domain/javascript/javascriptSemanticLanguageInvocations.ts @@ -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)) diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 274a7707e6..7086dcb6d5 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -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(); - 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 ( diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 1c74f5797b..190b055b4d 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -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; };