From eb721ebbf11e8681ccd793d6bb8dffeabf91df33 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:02:16 +0800 Subject: [PATCH] fix(javascript): retain stored receiver capture authority --- .../electronStaticAnalysisOptions.test.ts | 74 +++++++ .../javascriptSemanticMemberMutations.ts | 204 ++++++++++++++++-- .../javascriptSemanticProjectedSources.ts | 29 ++- 3 files changed, 284 insertions(+), 23 deletions(-) diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index d7e4cd23aa..ba2c4fe1cb 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; @@ -69,6 +70,8 @@ describe("Electron constant option evidence", () => { "const { webPreferences: alias } = options; mutate(alias);", "Object.assign(options, unknown);", "const box = {}; box.options = options; box.options.webPreferences.sandbox = false;", + "const box = {}; box.options = options; const saved = box.options; box.options = {}; saved.webPreferences.sandbox = false;", + "const values = [options]; values[Symbol.iterator] = input; const box = {}; [box.options] = values; box.options.webPreferences.sandbox = false;", "options = replacement;", ])("keeps changed or escaped constant options unknown: %s", (mutation) => { const analysis = analyzeJavaScriptStaticSource(` @@ -87,6 +90,77 @@ describe("Electron constant option evidence", () => { ]); }); + it.each([ + "const box = {}; box.options = options; box.options = { webPreferences: { sandbox: false } }; box.options.webPreferences.sandbox = false;", + "let box = { options: { webPreferences: { sandbox: false } } }; const saved = box; box = {}; box.options = options; saved.options.webPreferences.sandbox = false;", + "const values = [{ webPreferences: { sandbox: false } }]; values[Symbol.iterator] = input; const box = {}; [box.options] = values; box.options.webPreferences.sandbox = false;", + ])( + "keeps independent options intact after stored receiver replacement: %s", + (mutation) => { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow } = require("electron"); + const options = { webPreferences: { sandbox: true } }; + ${mutation} + new BrowserWindow(options); + `); + expect(analysis.electron.browser_windows).toMatchObject([ + { + options_status: "object-literal", + web_preferences_status: "object-literal", + web_preferences: [ + { name: "sandbox", value: { status: "literal", value: true } }, + ], + }, + ]); + }, + ); + + it.each([ + ["inner.current = second;", [true, false], ["object-literal", "dynamic"]], + [ + "source.inner = { current: second };", + [false, true], + ["dynamic", "object-literal"], + ], + ] as const)( + "preserves shallow-copy capture depth across later writes: %s", + (replacement, runtime, statuses) => { + const body = ` + const first = { webPreferences: { sandbox: true } }; + const second = { webPreferences: { sandbox: true } }; + const inner = { current: first }; + const source = { inner }; + const box = {}; + ({...box.options} = source); + ${replacement} + box.options.inner.current.webPreferences.sandbox = false; + `; + expect( + Array.from( + runInNewContext( + `${body};[first.webPreferences.sandbox, second.webPreferences.sandbox]`, + ), + ), + ).toEqual(runtime); + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow } = require("electron"); + ${body} + new BrowserWindow(first); + new BrowserWindow(second); + `); + expect( + analysis.electron.browser_windows.map( + ({ options_status }) => options_status, + ), + ).toEqual(statuses); + const intact = + analysis.electron.browser_windows[statuses.indexOf("object-literal")]; + expect(intact?.web_preferences).toMatchObject([ + { name: "sandbox", value: { status: "literal", value: true } }, + ]); + }, + ); + it.each([ "new BrowserWindow(options); const options = { webPreferences: { sandbox: true } };", "const options = alias; const alias = { webPreferences: { sandbox: true } }; new BrowserWindow(options);", diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 0877d3e6c4..f010c52852 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -33,6 +33,13 @@ import { evaluateSemanticExpression, } from "./javascriptSemanticValues.js"; import { semanticContainer } from "./javascriptSemanticSlots.js"; +import { + semanticProjectedSourceFactsAtRead, + semanticShiftCapturedSelections, + semanticNormalizeCapturedSelections, + type SemanticCapturedSelection, +} from "./javascriptSemanticProjectedSources.js"; + import { semanticArrayPropertyReferences, semanticClassCandidates, @@ -84,6 +91,13 @@ import { } from "./javascriptAstValues.js"; type PropertyPath = JavaScriptSemanticPropertyPath; +interface MutationSelectionContext { + readonly memberReadAt?: t.Node | undefined; + readonly capturedSelections?: + | readonly SemanticCapturedSelection[] + | undefined; +} + type ValueEffect = "write" | "escape"; /** Only replacing or possibly selecting an iterator-bearing prototype slot changes iteration. */ @@ -570,7 +584,7 @@ export function* collectSemanticMemberMutationsSteps( // A capture key already ends the array; a path stays one nested element. const lifetimeKey = (captureKey: string, path: PropertyPath): string => `${captureKey.slice(0, -1)},${JSON.stringify(path)}]`; - const pendingReferences: { + const pendingReferences: (MutationSelectionContext & { readonly initializer: JavaScriptSemanticBindingState["referenceInitializers"][number]; readonly path: PropertyPath; readonly bindings: ReadonlySet; @@ -580,7 +594,7 @@ export function* collectSemanticMemberMutationsSteps( readonly widenedGetterResult?: true; readonly definiteReceiver: boolean; readonly assignedSources?: readonly SemanticIterationSource[]; - }[] = []; + })[] = []; const iterableReferences: IterableReference[] = []; const iterableReferenceKeys = new WeakMap>(); const deferIterable = (reference: IterableReference): void => { @@ -594,6 +608,14 @@ export function* collectSemanticMemberMutationsSteps( reference.mutation?.end, reference.originAt?.start, reference.originAt?.end, + reference.memberReadAt?.start, + reference.memberReadAt?.end, + reference.capturedSelections?.map(({ at, depth, ownEnumerable }) => [ + at.start, + at.end, + depth, + ownEnumerable, + ]), reference.iterationOnly, reference.widenedGetterResult === true, reference.definiteReceiver === true, @@ -637,26 +659,41 @@ export function* collectSemanticMemberMutationsSteps( widenedGetterResult = false, assignedSources?: readonly SemanticIterationSource[], definiteReceiver = true, + selection?: MutationSelectionContext, ): void => { - const pending: (ReferencedValue & { - readonly bindings: ReadonlySet; - readonly originAt: t.Node | undefined; - readonly definiteReceiver: boolean; - })[] = [ + const pending: (ReferencedValue & + MutationSelectionContext & { + readonly bindings: ReadonlySet; + readonly originAt: t.Node | undefined; + readonly definiteReceiver: boolean; + })[] = [ { node, path, bindings, originAt, definiteReceiver, + memberReadAt: selection?.memberReadAt ?? mutation ?? originAt, + capturedSelections: selection?.capturedSelections, ...(ownerInitializer === undefined ? {} : { ownerInitializer }), }, ]; // Visit shallower alias traversals first so their exact prefixes suppress // longer loop paths before those paths enumerate every assignment order. for (let cursor = 0; cursor < pending.length; cursor++) { - const current = pending[cursor]; - if (current === undefined) break; + const candidate = pending[cursor]; + if (candidate === undefined) break; + // A capture deeper than the selected path is no longer applicable. Keeping + // it would manufacture new contexts on a repeated source alias cycle. + const current = candidate.capturedSelections?.length + ? { + ...candidate, + capturedSelections: semanticNormalizeCapturedSelections( + candidate.capturedSelections, + candidate.path.length, + ), + } + : candidate; const expression = unwrapJavaScriptExpression(current.node).node; const required = semanticRequireOrigin(expression, state); if (required !== undefined) { @@ -719,11 +756,23 @@ export function* collectSemanticMemberMutationsSteps( // An alias restored from a saved reference can revisit the same binding // at an earlier capture or a shorter getter-receiver path. Only a // repeated capture and path closes a cycle. - const captureKey = captureKeyOf( - binding, - current.originAt, - current.ownerInitializer, - ); + const captureSelectionIdentity = JSON.stringify([ + current.memberReadAt?.start, + current.memberReadAt?.end, + current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [ + at.start, + at.end, + depth, + ownEnumerable, + ]), + ]); + // Ancestry closes repeated source identities before recursively selected + // copy vectors grow; independent contexts remain distinct in coverage. + const captureKey = JSON.stringify([ + captureKeyOf(binding, current.originAt, current.ownerInitializer), + current.memberReadAt?.start, + current.memberReadAt?.end, + ]); const lifetime = lifetimeKey(captureKey, current.path); if (current.bindings.has(lifetime)) continue; // A self alias such as `node = node[key]` revisits its capture with the @@ -756,6 +805,9 @@ export function* collectSemanticMemberMutationsSteps( current.originAt, current.ownerInitializer, widenedGetterResult, + undefined, + undefined, + current, ); continue; } @@ -788,6 +840,7 @@ export function* collectSemanticMemberMutationsSteps( current.originAt?.end, current.ownerInitializer?.start, current.ownerInitializer?.end, + captureSelectionIdentity, ]); const previous = selections.get(selectionIdentity); const excludedKeys = @@ -844,6 +897,10 @@ export function* collectSemanticMemberMutationsSteps( mutation, current.originAt, current.ownerInitializer, + false, + undefined, + undefined, + current, ); } // Keep an antichain of exact paths. Widening distinct member keys would @@ -860,7 +917,7 @@ export function* collectSemanticMemberMutationsSteps( current.originAt?.start ?? -1 }\u0001${current.originAt?.end ?? -1}\u0001${ current.ownerInitializer?.start ?? -1 - }\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${current.definiteReceiver}\u0001${assignedSources?.[0]?.target?.start ?? -1}`; + }\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${current.definiteReceiver}\u0001${assignedSources?.[0]?.target?.start ?? -1}\u0001${captureSelectionIdentity}`; let covering = coveringPaths.get(pathState); if (covering === undefined) { covering = new SemanticPropertyPathCoverage(); @@ -883,6 +940,7 @@ export function* collectSemanticMemberMutationsSteps( nested.add(captureKey); // Returning a literal's receiver reaches that allocation, not the // binding's older values read while evaluating its initializer. + let retainsInitializers = true; if (!primitiveWrite) { for (const origin of origins.directOrigins) { const shared = semanticMutableBuiltinOrigin(origin, path); @@ -898,6 +956,27 @@ export function* collectSemanticMemberMutationsSteps( ...origins.initializers, ...origins.referenceInitializers, ]; + const receiver = + effect === "write" && + current.path.length > 1 && + binding.memberWrites.some( + (write) => + write.assignedSources !== undefined && + write.path.length < current.path.length, + ) + ? semanticProjectedSourceFactsAtRead( + binding, + current.node, + current.path.slice(0, -1), + state, + false, + current.memberReadAt ?? current.originAt ?? current.node, + current.capturedSelections, + ) + : undefined; + // A defined owner result already includes every eligible initializer; + // replaying them separately duplicates selection/capture histories. + retainsInitializers = receiver === undefined; if (effect === "escape") binding.escapedPaths.push({ path, @@ -905,7 +984,7 @@ export function* collectSemanticMemberMutationsSteps( receiverInitializers, ...(current.originAt === undefined ? {} - : { receiverAt: current.originAt }), + : { receiverAt: current.memberReadAt ?? current.originAt }), }); else binding.memberWrites.push({ @@ -917,11 +996,34 @@ export function* collectSemanticMemberMutationsSteps( receiverInitializers, ...(current.originAt === undefined ? {} - : { receiverAt: current.originAt }), + : { receiverAt: current.memberReadAt ?? current.originAt }), ...(assignedSources === undefined ? {} : { assignedSources }), }); clearSemanticValueCaches(state); - for (const initializer of origins.initializers) + // Resolve a stored child at the receiver capture, before selecting + // the final written slot. The source owner preserves replacements + // and captures; a local container does not isolate its children. + if (receiver !== undefined) { + for (const source of receiver.sources) + pending.push({ + node: source.node, + path: [...source.path, current.path.at(-1)!], + bindings: nested, + definiteReceiver: false, + originAt: source.node, + memberReadAt: current.memberReadAt, + capturedSelections: semanticNormalizeCapturedSelections([ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + source.path.length - (current.path.length - 1), + ) ?? []), + ...(source.capturedSelections ?? []), + ]), + }); + } + for (const initializer of retainsInitializers + ? origins.initializers + : []) pending.push({ node: initializer.node, path: [...initializer.projection, ...path], @@ -932,9 +1034,16 @@ export function* collectSemanticMemberMutationsSteps( origins.referenceInitializers.length === 1, originAt: initializer.node, + memberReadAt: current.memberReadAt, + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + initializer.projection.length, + ), }); } - for (const initializer of origins.referenceInitializers) { + for (const initializer of retainsInitializers + ? origins.referenceInitializers + : []) { const copiedPath = semanticCopiedReferencePath( initializer, path, @@ -957,6 +1066,11 @@ export function* collectSemanticMemberMutationsSteps( effect, ...(widenedGetterResult ? { widenedGetterResult: true } : {}), originAt: initializer.node, + memberReadAt: current.memberReadAt, + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + copiedPath.length - path.length, + ), ...(mutation === undefined ? {} : { mutation }), }); } @@ -974,6 +1088,17 @@ export function* collectSemanticMemberMutationsSteps( bindings: current.bindings, definiteReceiver: current.definiteReceiver && key !== null, originAt: current.originAt, + memberReadAt: current.memberReadAt, + capturedSelections: semanticNormalizeCapturedSelections([ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + 1, + ) ?? []), + ...(current.originAt !== undefined && + unwrapJavaScriptExpression(current.originAt).node === expression + ? [{ at: current.originAt, depth: 1 }] + : []), + ]), }); } else { const references = @@ -985,6 +1110,13 @@ export function* collectSemanticMemberMutationsSteps( ) : expandedValues(expression, current.path, effect); for (const value of references) { + if ( + value.inherited && + current.capturedSelections?.some( + (capture) => capture.depth === 1 && capture.ownEnumerable, + ) + ) + continue; if ( value.escapes && (effect !== "escape" || @@ -999,6 +1131,9 @@ export function* collectSemanticMemberMutationsSteps( current.originAt, value.ownerInitializer, widenedGetterResult || value.widenedGetterResult === true, + undefined, + undefined, + current, ); else if (value.iterableFallbackPath !== undefined) deferIterable({ @@ -1011,6 +1146,11 @@ export function* collectSemanticMemberMutationsSteps( ...(assignedSources === undefined ? {} : { assignedSources }), effect, originAt: current.originAt, + memberReadAt: current.memberReadAt, + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + value.path.length - current.path.length, + ), ...(widenedGetterResult ? { widenedGetterResult: true } : {}), ...(mutation === undefined ? {} : { mutation }), }); @@ -1034,6 +1174,24 @@ export function* collectSemanticMemberMutationsSteps( (key) => key !== null && typeof key !== "object", ), originAt: current.originAt, + memberReadAt: current.memberReadAt, + capturedSelections: semanticNormalizeCapturedSelections([ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + value.path.length - current.path.length, + ) ?? []), + ...(value.capturedSelectionAt === undefined + ? [] + : [ + { + at: value.capturedSelectionAt, + depth: value.path.length - current.path.length + 1, + ...(value.ownEnumerable + ? { ownEnumerable: true as const } + : {}), + }, + ]), + ]), }); } } @@ -1369,6 +1527,8 @@ export function* collectSemanticMemberMutationsSteps( ? { widenedGetterResult: true } : {}), originAt: initializer.node, + memberReadAt: reference.memberReadAt, + capturedSelections: reference.capturedSelections, ...(reference.mutation === undefined ? {} : { mutation: reference.mutation }), @@ -1394,6 +1554,8 @@ export function* collectSemanticMemberMutationsSteps( ? { widenedGetterResult: true } : {}), originAt: reference.originAt, + memberReadAt: reference.memberReadAt, + capturedSelections: reference.capturedSelections, ...(reference.mutation === undefined ? {} : { mutation: reference.mutation }), @@ -1410,6 +1572,7 @@ export function* collectSemanticMemberMutationsSteps( reference.widenedGetterResult === true, reference.assignedSources, reference.definiteReceiver, + reference, ); } for (const [index, reference] of [...iterableReferences].entries()) { @@ -1438,6 +1601,7 @@ export function* collectSemanticMemberMutationsSteps( reference.widenedGetterResult === true, reference.assignedSources, reference.definiteReceiver === true, + reference, ); } // Native Map lookup methods do not expose their receiver to user code. @@ -1470,7 +1634,7 @@ interface ReferencedValue extends JavaScriptSemanticObjectReference { readonly iterableFallbackPath?: PropertyPath; } -interface IterableReference extends ReferencedValue { +interface IterableReference extends ReferencedValue, MutationSelectionContext { readonly projection: PropertyPath; readonly fallbackPath: PropertyPath; readonly bindings: ReadonlySet; diff --git a/src/domain/javascript/javascriptSemanticProjectedSources.ts b/src/domain/javascript/javascriptSemanticProjectedSources.ts index d37967005a..ca7a55bc6b 100644 --- a/src/domain/javascript/javascriptSemanticProjectedSources.ts +++ b/src/domain/javascript/javascriptSemanticProjectedSources.ts @@ -178,10 +178,33 @@ export const semanticProjectedSourceEligibilityAtRead = ( )?.sources; /** Keep imported origins subject to the same selected writes as other values. */ -export const semanticProjectedSourceFactsAtRead = ( +// Projection alternatives are introduced by callers. An ordinary property path +// remains ordinary while the owner selects stored and copied source prefixes. +export function semanticProjectedSourceFactsAtRead( + binding: JavaScriptSemanticBindingState, + reference: t.Node, + path: JavaScriptSemanticPropertyPath, + state: JavaScriptSemanticAnalysisState, + includeUnknown: boolean, + memberReadAt?: t.Node, + capturedSelections?: readonly SemanticCapturedSelection[], + retainEligibility?: boolean, +): + | { + readonly sources: readonly (Omit & { + readonly path: JavaScriptSemanticPropertyPath; + })[]; + readonly retainsOrigin: boolean; + } + | undefined; +export function semanticProjectedSourceFactsAtRead( ...arguments_: Parameters -): ReturnType => - projectedSourceFactsAtRead(...arguments_); +): ReturnType; +export function semanticProjectedSourceFactsAtRead( + ...arguments_: Parameters +): ReturnType { + return projectedSourceFactsAtRead(...arguments_); +} const projectedSourceFactsAtRead = ( binding: JavaScriptSemanticBindingState,