From c6ac60acfe61edbd41ef35a9e13758e144b48fd7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:14:34 +0800 Subject: [PATCH] fix(javascript): preserve capture and property-key effect ownership --- docs/javascript-application-workflows.md | 10 ++++- ...anticAnalysis.language_invocations.test.ts | 27 ++++++++++++ .../javascriptSemanticMemberMutations.ts | 14 ++++--- .../javascriptSemanticMutationInitializers.ts | 8 ++++ .../javascriptSemanticObjectReferences.ts | 24 +++++++++++ .../javascriptSemanticPropertyKeys.ts | 42 +++++++++++++++++++ .../javascript/javascriptSemanticValues.ts | 11 +++++ 7 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticPropertyKeys.ts diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index e0441b15ea..21bd72ca62 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -468,7 +468,9 @@ or deletion does not invoke the getter. Selected setters invalidate 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. +getters and ordinary data slots retain their supported precision. Exposing a +constructed receiver also exposes its retained shared field references, using +the same reference owner as selected getter effects. Object spreads, rest copies and destructuring obtain their property reads from the shared pattern-source owner. Copies read own enumerable properties; @@ -476,11 +478,15 @@ 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. Repeated loop iterations can expose +scalar reads retain their capture timing, including reads before a later scope's +disposal. Repeated loop iterations can expose a previous disposal and therefore retain uncertainty. Private getter fields retain their parsed initializer references; `super` reads use the selected receiver conservatively, including superclass reads whose lookup and receiver authority differ. Custom `instanceof` hooks expose their receiver and argument. +Known unmodified intrinsic Symbol keys cannot overwrite string properties or +invoke primitive conversion; their omitted symbol-slot inventory stays partial. +Shadowed or visibly replaced Symbol references retain unknown key effects. 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 diff --git a/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts index aab2be357e..2d57459de2 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts @@ -252,3 +252,30 @@ it("keeps same-receiver equality in an inert getter precise", () => { `), ).toEqual({ status: "literal", value: 1 }); }); + +it("keeps intrinsic symbol keys separate from string overwrites without claiming complete inventory", () => { + expect( + returned( + `const state={value:1,[Symbol.toPrimitive](){return 0;}};return state.value;`, + ), + ).toEqual({ status: "literal", value: 1 }); + const ir = analyzeJavaScriptSemantics( + `const state={value:1,[Symbol.toPrimitive](){return 0;}};`, + ); + expect(ir.bindings.find(({ name }) => name === "state")?.value).toMatchObject( + { status: "object", unknownProperties: true, omittedProperties: 1 }, + ); + expect( + returned( + `const Symbol={toPrimitive:"value"};const state={value:1,[Symbol.toPrimitive]:2};return state.value;`, + )?.status, + ).toBe("unknown"); +}); + +it("exposes shared fields when a constructed receiver is passed to an opaque call", () => { + expect( + returned( + `const shared={value:1};class Box{state=shared;} const box=new Box(); unknown(box);return shared.value;`, + )?.status, + ).toBe("unknown"); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index d4c7befa80..6f49f60b7b 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -1,4 +1,5 @@ import * as t from "@babel/types"; +import { semanticKnownSymbolKey } from "./javascriptSemanticPropertyKeys.js"; import { createAmbientPathResolver, retainSemanticAmbientEffect, @@ -1231,7 +1232,7 @@ export function* collectSemanticMemberMutationsSteps( const value = evaluateSemanticExpression(operand, state, node); return value.status === "object" || value.status === "array"; })) - markEscaped(operand, node); + if (!semanticKnownSymbolKey(operand, state)) markEscaped(operand, node); if (t.isTemplateLiteral(node) && !t.isTaggedTemplateExpression(parent)) for (const expression of node.expressions) markEscaped(expression, node); @@ -1558,10 +1559,13 @@ const constructedReferenceValues = ( state: JavaScriptSemanticAnalysisState, widenedGetterResult: boolean, ): readonly ReferencedValue[] => { - // Only retry results lost a suffix that could reach any constructed field. - // Preserve that reachability through aliases and nested allocations, without - // broadening ordinary whole-receiver escapes. - const [key = widenedGetterResult ? null : undefined, ...remaining] = path; + // Exposing a constructed receiver also exposes its retained field references, + // just as exposing an object literal exposes its children. Widened results + // retain that reachability after a suffix was lost. + const [ + key = effect === "escape" || widenedGetterResult ? null : undefined, + ...remaining + ] = path; // Replacing a receiver property does not mutate the object it previously held. if (key === undefined || (effect === "write" && remaining.length === 0)) return []; diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 218b1cc849..89b4a5e352 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -1016,6 +1016,12 @@ export const semanticCapturePosition = ( semanticStaticPropertyKey(parent.key, parent.computed) !== null ) current = parent; + else if ( + t.isSpreadElement(parent) && + parent.argument === current && + t.isObjectExpression(parents.get(parent)) + ) + current = parent; else if (t.isObjectExpression(parent) || t.isArrayExpression(parent)) current = parent; else return null; @@ -1069,6 +1075,8 @@ export const semanticEscapeFollowsCapture = ( !semanticBindingPrecedesCapture(binding, capture, parents) ) return false; + if (scopeExitOwners.has(escape)) + return semanticEffectFollowsReference(capture, escape, parents); // A literal allocates here. Aliases are evaluated at the same capture point, // so an outer/persistent origin still receives all of its own escape effects. const origin = unwrapJavaScriptExpression(initializer.node).node; diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 191a7bb682..a400e8f3dd 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -1,4 +1,5 @@ import * as t from "@babel/types"; +import { semanticKnownSymbolKey } from "./javascriptSemanticPropertyKeys.js"; import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, @@ -120,6 +121,12 @@ const instanceFieldReferences = ( : current.klass.body.body.slice(0, before); for (const member of [...members].reverse()) { if (!isRuntimeJavaScriptClassProperty(member) || member.static) continue; + if ( + propertyName !== null && + member.computed && + semanticKnownSymbolKey(member.key, state) + ) + continue; const key = semanticStaticPropertyKey(member.key, member.computed); if (key !== null && shadowed.has(key)) continue; if (key !== null) shadowed.add(key); @@ -155,6 +162,7 @@ const classPropertyDescriptors = ( klass: t.ClassDeclaration | t.ClassExpression, propertyName: string | null, kind: ClassPropertyLookup["kind"], + state: JavaScriptSemanticAnalysisState, initializedBefore?: t.ClassProperty, accessor: "get" | "set" = "get", ) => { @@ -171,6 +179,13 @@ const classPropertyDescriptors = ( ? -1 : klass.body.body.indexOf(initializedBefore); klass.body.body.forEach((member, index) => { + if ( + (t.isClassMethod(member) || t.isClassProperty(member)) && + propertyName !== null && + member.computed && + semanticKnownSymbolKey(member.key, state) + ) + return; if ( before >= 0 && index >= before && @@ -334,6 +349,7 @@ class ClassReferenceWalk { klass, this.#name, this.lookup.kind, + this.state, this.lookup.initializedBefore, this.context.accessor, ); @@ -795,6 +811,14 @@ class ObjectReferenceWalk { this.#spread(property); return; } + if ( + property.computed && + this.#key !== undefined && + this.#key !== null && + typeof this.#key !== "object" && + semanticKnownSymbolKey(property.key, this.query.state) + ) + return; const name = semanticStaticPropertyKey(property.key, property.computed); if (this.#prototype(property, name)) return; const kind = diff --git a/src/domain/javascript/javascriptSemanticPropertyKeys.ts b/src/domain/javascript/javascriptSemanticPropertyKeys.ts new file mode 100644 index 0000000000..37751fa386 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticPropertyKeys.ts @@ -0,0 +1,42 @@ +import * as t from "@babel/types"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; + +// These intrinsic properties hold symbols, so ToPropertyKey cannot produce a +// string slot. Visible replacement of Symbol/the selected property voids proof. +const wellKnownSymbols = new Set([ + "asyncIterator", + "hasInstance", + "isConcatSpreadable", + "iterator", + "match", + "matchAll", + "replace", + "search", + "species", + "split", + "toPrimitive", + "toStringTag", + "unscopables", + "dispose", + "asyncDispose", +]); + +/** A proved symbol key cannot shadow a selected string property. */ +export const semanticKnownSymbolKey = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const key = unwrapJavaScriptExpression(node).node; + if (!t.isMemberExpression(key)) return false; + const name = semanticStaticPropertyKey(key.property, key.computed); + return ( + name !== null && + wellKnownSymbols.has(name) && + isUnmodifiedGlobalReference(key, state, ["Symbol", name]) + ); +}; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 5ebde887a2..68aeb9e679 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -4,6 +4,7 @@ import { semanticOpaqueBindingReadMayHaveChanged, } from "./javascriptSemanticOpaqueEffects.js"; import * as t from "@babel/types"; +import { semanticKnownSymbolKey } from "./javascriptSemanticPropertyKeys.js"; import { logicalRightOperand } from "./javascriptSymbolicControlFlow.js"; import { @@ -864,6 +865,16 @@ const evaluateObject = ( } continue; } + if ( + property.computed && + semanticKnownSymbolKey(property.key, context.state) + ) { + // The string-slot lattice omits symbol names, but they cannot overwrite + // established string fields. Keep the omitted inventory explicit. + unknownProperties = true; + if (omittedProperties !== null) omittedProperties += 1; + continue; + } const name = semanticStaticPropertyKey(property.key, property.computed); if (name === null) { unknownProperties = true;