diff --git a/src/domain/javascript/javascriptMemberMutationCoverage.test.ts b/src/domain/javascript/javascriptMemberMutationCoverage.test.ts new file mode 100644 index 0000000000..2311416559 --- /dev/null +++ b/src/domain/javascript/javascriptMemberMutationCoverage.test.ts @@ -0,0 +1,173 @@ +import { expect, it } from "vitest"; +import { + analyzeJavaScriptSemantics, + indexJavaScriptBindingsWithReceiversSteps, +} from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; +import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; + +import { + joinSemanticPropertyPaths, + semanticPropertyPathKeyMatches, +} from "./javascriptSemanticPropertyPaths.js"; + +const resultValue = (body: string) => + onlyCallable( + analyzeJavaScriptSemantics(`export function result() { ${body} }`), + "result", + ).returnSites[0]?.value; + +it("retains common copy exclusions and the union of array prefixes", () => { + const path = joinSemanticPropertyPaths( + [{ excludedKeys: ["1", "2"], startIndex: 1 }], + [{ excludedKeys: ["2", "3"], startIndex: 2 }], + ); + const key = path?.[0]; + if (key === undefined) throw new Error("Expected joined projection"); + expect( + ["0", "1", "2", "3", "4", "named"].filter((name) => + semanticPropertyPathKeyMatches(key, name), + ), + ).toEqual(["1", "3", "4"]); + const withScalar = joinSemanticPropertyPaths(path ?? [], [0]); + const scalarKey = withScalar?.[0]; + if (scalarKey === undefined) throw new Error("Expected joined scalar"); + expect( + ["0", "1", "2", "3", "named"].filter((name) => + semanticPropertyPathKeyMatches(scalarKey, name), + ), + ).toEqual(["0", "1", "3"]); + const exact = [1] as const; + expect(joinSemanticPropertyPaths(exact, ["1"])).toBe(exact); +}); + +const memberAliasSource = (count: number) => ` + import { Worker } from "node:worker_threads"; + const alias0 = globalThis; + ${Array.from( + { length: count }, + (_, index) => + `const alias${index + 1} = flag${index} ? alias${index}.globalThis : alias${index}.window;`, + ).join("\n")} + alias${count}.URL = replacement; + new Worker(new URL("./worker.js", import.meta.url)); +`; + +it("joins overflowing member-alias histories into disclosed possible effects", () => { + const source = memberAliasSource(16); + const ir = analyzeJavaScriptSemantics(source); + expect(ir.coverage).toMatchObject({ + status: "partial", + omittedCount: null, + resourceLimits: ["alias-selections"], + }); + expect(ir.limitations).toContain( + semanticResourceLimitReason("alias-selections"), + ); + const facts = analyzeJavaScriptStaticSource(source); + expect(facts.limitations).toContain( + semanticResourceLimitReason("alias-selections"), + ); + expect( + facts.references.filter(({ kind }) => kind === "worker"), + ).toMatchObject([{ specifier: null }]); +}); + +it("never records a joined selector as a definite receiver replacement", () => { + const parsed = parseJavaScriptSource(memberAliasSource(16)); + if (parsed === null) throw new Error("Expected parsed source"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const joined = [...index.bindings.bindingsById.values()] + .flatMap(({ memberWrites }) => memberWrites) + .filter(({ path }) => path.includes(null)); + expect(joined.length).toBeGreaterThan(0); + expect( + joined.every(({ receiverIdentity }) => receiverIdentity === "possible"), + ).toBe(true); +}); + +it("keeps small selector alternatives exact and preserves an untouched third sibling", () => { + const ir = analyzeJavaScriptSemantics(` + export function result() { + const source = { left: { value: 1 }, right: { value: 3 }, third: { value: 2 } }; + const selected = flag ? source.left : source.right; + selected.value = replacement; + return source.third.value; + } + `); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toEqual({ + status: "literal", + value: 2, + }); + expect(ir.coverage.resourceLimits ?? []).not.toContain("alias-selections"); +}); + +it.each([ + "const left={value:1}; const right={value:2}; const window={left,right}; const selected=flag?window.left:window.right; selected.value=9; return left.value;", + "const left={value:1}; const right={value:2}; const globalThis={window:left,globalThis:right}; const selected=flag?globalThis.window:globalThis.globalThis; selected.value=9; return left.value;", + "const local={value:1}; const selected=flag?local:external; selected.value=9; return local.value;", +])("retains local and unresolved mutation targets: %s", (body) => { + expect(resultValue(body)?.status).toBe("unknown"); +}); + +it.each(["first", "second"])( + "retains both captured targets across replacement: %s", + (name) => { + expect( + resultValue(` + const first = { value: 1 }; + const second = { value: 2 }; + let current = first; + const before = current; + current = second; + const after = current; + const selected = flag ? before : after; + selected.value = 9; + return ${name}.value; + `)?.status, + ).toBe("unknown"); + }, +); + +it.each([ + "const first={value:1}; const second={value:2}; const captured=first; let selected=first; selected=second; const alias=flag?captured:captured; alias.value=9; return second.value;", + "const first={value:2}; const second={value:1}; let selected=first; selected=second; const captured=selected; const alias=flag?captured:captured; alias.value=9; return first.value;", + "const source={left:{value:1},right:{value:2}}; source.left.value=9; return source.right.value;", +])("preserves captured ownership and unjoined siblings: %s", (body) => { + expect(resultValue(body)).toEqual({ status: "literal", value: 2 }); +}); + +it("preserves exclusions shared by shallow-copy alternatives", () => { + expect( + resultValue(` + const excluded = { value: 2 }; + const first = { value: 1 }; + const second = { value: 3 }; + const source = { excluded, first, second }; + const { excluded: leftExcluded, first: leftFirst, ...left } = source; + const { excluded: rightExcluded, second: rightSecond, ...right } = source; + const selected = flag ? left : right; + mutate(selected); + return excluded.value; + `), + ).toEqual({ status: "literal", value: 2 }); +}); + +it("keeps a preceding global self-property replacement unresolved", () => { + const facts = analyzeJavaScriptStaticSource(` + import { Worker } from "node:worker_threads"; + globalThis.window = replacement; + const selected = flag ? globalThis.window : globalThis; + selected.URL = replacement; + new Worker(new URL("./worker.js", import.meta.url)); + `); + expect( + facts.references.filter(({ kind }) => kind === "worker"), + ).toMatchObject([{ specifier: null }]); +}); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 1ccce96da6..9853f2bea1 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -21,6 +21,7 @@ import { semanticIterationSources, type SemanticIterationSource, } from "./javascriptSemanticIterationSources.js"; +import { joinSemanticPropertyPaths } from "./javascriptSemanticPropertyPaths.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; import { semanticCopiedReferencePath, @@ -189,10 +190,8 @@ export const createAmbientPathResolver = ( continue; } if (suffix !== undefined && previous.length === suffix.length) { - const joined: AmbientPath = previous.map((key, index) => - key === suffix?.[index] ? key : null, - ); - if (joined.every((key, index) => key === previous[index])) { + const joined = joinSemanticPropertyPaths(previous, suffix); + if (joined === previous) { resolvedIdentity = true; continue; } diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index f010c52852..cc4b186a1a 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -73,6 +73,7 @@ import { } from "./javascriptSemanticReferenceSources.js"; import { semanticArrayIndex, + joinSemanticPropertyPaths, SemanticPropertyPathCoverage, semanticPropertyPathKeyMatches, type JavaScriptSemanticPropertyPath, @@ -661,31 +662,46 @@ export function* collectSemanticMemberMutationsSteps( definiteReceiver = true, selection?: MutationSelectionContext, ): void => { - const pending: (ReferencedValue & + type JoinedProjection = { + readonly path: PropertyPath; + readonly definiteReceiver: boolean; + scheduled: boolean; + }; + type PendingValue = 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 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 + readonly exactKey?: string; + readonly joined?: { + readonly key: string; + readonly value: JoinedProjection; + }; + }; + const pending: PendingValue[] = []; + // The effect, mutation and assigned sources belong to this invocation. + // Alias histories do not select a value: the binding/capture/projection do. + // Keep small alternatives exact. Bound additional states across this whole + // query before scheduling/projecting or copying capture-history sets, then + // visit the complete same-depth may-projection closure with monotone joins. + const joinedProjections = new Map(); + const exactProjections = new Map>(); + let additionalAlternatives = 0; + let projectionsWidened = false; + const nodeIdentities = new WeakMap(); + let nextNodeIdentity = 0; + const nodeIdentity = (node: t.Node | undefined): number | null => { + if (node === undefined) return null; + const prior = nodeIdentities.get(node); + if (prior !== undefined) return prior; + const identity = nextNodeIdentity++; + nodeIdentities.set(node, identity); + return identity; + }; + const enqueue = (candidate: PendingValue): void => { + // A capture deeper than this projection is no longer applicable. Keep + // the canonical context before indexing or scheduling alias histories. + const value = candidate.capturedSelections?.length ? { ...candidate, capturedSelections: semanticNormalizeCapturedSelections( @@ -694,6 +710,101 @@ export function* collectSemanticMemberMutationsSteps( ), } : candidate; + const expression = unwrapJavaScriptExpression(value.node).node; + const binding = t.isIdentifier(expression) + ? resolveBinding(expression, expression.name) + : undefined; + if (binding === undefined) { + pending.push(value); + return; + } + const context = JSON.stringify([ + binding.bindingId, + nodeIdentity(value.originAt), + nodeIdentity(value.ownerInitializer), + nodeIdentity(value.memberReadAt), + value.definiteReceiver, + value.path.length, + value.widenedGetterResult === true, + ]); + const captures = value.capturedSelections?.map( + ({ at, depth, ownEnumerable }) => [ + nodeIdentity(at), + depth, + ownEnumerable === true, + ], + ); + // Capture vectors are additional precise states, but distinct captured + // reads keep separate may-projection joins after the budget is reached. + const key = JSON.stringify([context, captures]); + if (!projectionsWidened) { + const exact = exactProjections.get(context) ?? new Set(); + const selection = JSON.stringify([captures, value.path]); + if (exact.has(selection)) return; + if ( + exact.size > 0 && + ++additionalAlternatives > SEMANTIC_ALIAS_SELECTION_LIMIT + ) { + projectionsWidened = true; + state.valueResourceLimits.add("alias-selections"); + exactProjections.clear(); + } else { + exact.add(selection); + exactProjections.set(context, exact); + } + } + const prior = joinedProjections.get(key); + const path = + prior === undefined + ? value.path + : joinSemanticPropertyPaths(prior.path, value.path); + if (path === undefined) throw new Error("Projection depth key mismatch"); + const definiteReceiver = + value.definiteReceiver && + (prior?.definiteReceiver ?? true) && + path.every((key) => key !== null && typeof key !== "object"); + const joined = + prior !== undefined && + path === prior.path && + definiteReceiver === prior.definiteReceiver + ? prior + : { path, definiteReceiver, scheduled: false }; + joinedProjections.set(key, joined); + if (!projectionsWidened) { + pending.push({ ...value, exactKey: key }); + return; + } + if (joined.scheduled) return; + joined.scheduled = true; + pending.push({ + ...value, + path, + definiteReceiver, + joined: { key, value: joined }, + }); + }; + enqueue({ + 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; + if ( + (current.exactKey !== undefined && + joinedProjections.get(current.exactKey)?.scheduled === true) || + (current.joined !== undefined && + joinedProjections.get(current.joined.key) !== current.joined.value) + ) + continue; const expression = unwrapJavaScriptExpression(current.node).node; const required = semanticRequireOrigin(expression, state); if (required !== undefined) { @@ -1005,7 +1116,7 @@ export function* collectSemanticMemberMutationsSteps( // and captures; a local container does not isolate its children. if (receiver !== undefined) { for (const source of receiver.sources) - pending.push({ + enqueue({ node: source.node, path: [...source.path, current.path.at(-1)!], bindings: nested, @@ -1024,7 +1135,7 @@ export function* collectSemanticMemberMutationsSteps( for (const initializer of retainsInitializers ? origins.initializers : []) - pending.push({ + enqueue({ node: initializer.node, path: [...initializer.projection, ...path], bindings: nested, @@ -1082,7 +1193,7 @@ export function* collectSemanticMemberMutationsSteps( expression.property, expression.computed, ); - pending.push({ + enqueue({ node: expression.object, path: [key, ...current.path], bindings: current.bindings, @@ -1155,7 +1266,7 @@ export function* collectSemanticMemberMutationsSteps( ...(mutation === undefined ? {} : { mutation }), }); else - pending.push({ + enqueue({ ...value, bindings: current.bindings, definiteReceiver: diff --git a/src/domain/javascript/javascriptSemanticPropertyPaths.ts b/src/domain/javascript/javascriptSemanticPropertyPaths.ts index 6d1fe61486..1018bbed84 100644 --- a/src/domain/javascript/javascriptSemanticPropertyPaths.ts +++ b/src/domain/javascript/javascriptSemanticPropertyPaths.ts @@ -12,6 +12,57 @@ export type JavaScriptSemanticPropertyPath = readonly ( | JavaScriptSemanticPropertySelection )[]; +/** Join equal-depth may-effect projections without claiming an exact selector. */ +export function joinSemanticPropertyPaths( + previous: readonly (string | null)[], + incoming: readonly (string | null)[], +): readonly (string | null)[] | undefined; +export function joinSemanticPropertyPaths( + previous: JavaScriptSemanticPropertyPath, + incoming: JavaScriptSemanticPropertyPath, +): JavaScriptSemanticPropertyPath | undefined; +export function joinSemanticPropertyPaths( + previous: JavaScriptSemanticPropertyPath, + incoming: JavaScriptSemanticPropertyPath, +): JavaScriptSemanticPropertyPath | undefined { + if (previous.length !== incoming.length) return undefined; + const joined = previous.map((key, index) => + joinPropertySelections(key, incoming[index]!), + ); + return joined.every((key, index) => key === previous[index]) + ? previous + : joined; +} + +const joinPropertySelections = ( + previous: JavaScriptSemanticPropertyPath[number], + incoming: JavaScriptSemanticPropertyPath[number], +): JavaScriptSemanticPropertyPath[number] => { + if (keyCovers(previous, incoming)) return previous; + if (keyCovers(incoming, previous)) return incoming; + if (previous === null || incoming === null) return null; + if (typeof previous !== "object" && typeof incoming !== "object") return null; + if (typeof previous === "object" && typeof incoming === "object") + return { + excludedKeys: previous.excludedKeys.filter((key) => + incoming.excludedKeys.includes(key), + ), + ...(previous.startIndex === undefined || incoming.startIndex === undefined + ? {} + : { startIndex: Math.min(previous.startIndex, incoming.startIndex) }), + }; + const selected = typeof previous === "object" ? previous : incoming; + const scalar = typeof previous === "object" ? incoming : previous; + if (typeof selected !== "object" || typeof scalar === "object") return null; + const index = semanticArrayIndex(scalar); + return { + excludedKeys: selected.excludedKeys.filter((key) => key !== String(scalar)), + ...(selected.startIndex === undefined || index === null + ? {} + : { startIndex: Math.min(selected.startIndex, index) }), + }; +}; + /** Read only canonical JavaScript array indices, excluding named properties. */ export const semanticArrayIndex = (key: string | number): number | null => { const index = Number(key);