diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 328a255e4f..4c746f5d3b 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -448,8 +448,10 @@ uncertainty rather than pretending to execute later writes. References handed back by local functions, methods, constructors, async functions, and generators also retain escape uncertainty, including when the call is read-only; this does not establish that a mutation occurred. Aliases -and shared children in spread and rest copies retain that uncertainty; copied -primitive slots and unrelated containing properties remain known. Object rest +and shared children in spread and rest copies retain that uncertainty. Exposing +the copy alone does not invalidate copied primitive slots or unrelated containing +properties. Dynamic child writes such as `copy[key].value = 2` remain conservative +about which original slot was selected. Object rest excludes consumed keys, array rest excludes the consumed prefix for known arrays, and later explicit data properties and methods replace earlier object spread references. A known array prefix does not expose values from a following @@ -466,6 +468,11 @@ Receiver projections follow known data fields initialized from `this` or earlier self-referencing fields; static fields retain their declaring class identity. An alias cycle that repeatedly selects deeper children retains escape uncertainty at its recovered roots rather than claiming a fixed number of loop iterations. +Equivalent member reads in a repeating loop share unknown ordering instead of +creating a distinct capture for every branch history. Straight-line captures and +once-only loop inputs retain their own selection sites. When unobserved suffixes +are widened, their exact observed prefixes stay separate so unrelated siblings +remain known. Writes through an object getter's returned receiver retain that uncertainty when the object is an array element, nested inside mixed object/array diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 096ca03bad..ce4b4fd6e9 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -61,6 +61,7 @@ import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedRe import { semanticMutationInitializers, semanticBindingAtRead, + semanticCaptureOrderingIdentity, semanticScopeExitEffect, } from "./javascriptSemanticMutationInitializers.js"; import { semanticCoercionOperands } from "./javascriptSemanticLanguageInvocations.js"; @@ -551,6 +552,26 @@ export function* collectSemanticMemberMutationsSteps( resolvedBindings.set(node, resolved ?? null); return resolved; }; + 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 captureIdentity = ({ at, ownEnumerable }: SemanticCapturedSelection) => + ownEnumerable ? at : semanticCaptureOrderingIdentity(at, parents); + const capturedSelectionKey = ( + captures: readonly SemanticCapturedSelection[] | undefined, + ) => + captures?.map((capture) => [ + nodeIdentity(captureIdentity(capture)), + capture.depth, + capture.ownEnumerable === true, + ]); // One capture tuple recurs across every alias it reaches. Re-encoding it // would rebuild the same long key and force a fresh string hash per lookup. const captureKeys = new WeakMap< @@ -611,12 +632,7 @@ export function* collectSemanticMemberMutationsSteps( reference.originAt?.end, reference.memberReadAt?.start, reference.memberReadAt?.end, - reference.capturedSelections?.map(({ at, depth, ownEnumerable }) => [ - at.start, - at.end, - depth, - ownEnumerable, - ]), + capturedSelectionKey(reference.capturedSelections), reference.iterationOnly, reference.widenedGetterResult === true, reference.definiteReceiver === true, @@ -688,16 +704,6 @@ export function* collectSemanticMemberMutationsSteps( 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. @@ -718,6 +724,20 @@ export function* collectSemanticMemberMutationsSteps( pending.push(value); return; } + const observablePath = observablePaths.normalize( + binding.bindingId, + value.path, + effect, + ); + // Join unobserved suffixes within their exact prefix. Joining distinct + // prefixes first could invent a wildcard reaching unrelated siblings. + // Keep the raw path for capture, cycle and stored-source selection. + const unobservedPrefix = + observablePath === null + ? value.path + : observablePath !== value.path + ? observablePath.slice(0, -1) + : undefined; const context = JSON.stringify([ binding.bindingId, nodeIdentity(value.originAt), @@ -726,16 +746,11 @@ export function* collectSemanticMemberMutationsSteps( value.definiteReceiver, value.path.length, value.widenedGetterResult === true, + unobservedPrefix, ]); - 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 captures = capturedSelectionKey(value.capturedSelections); + // Distinct selection times remain distinct; syntactic histories with + // the same unknown ordering in one repeating loop share a context. const key = JSON.stringify([context, captures]); if (!projectionsWidened) { const exact = exactProjections.get(context) ?? new Set(); @@ -870,12 +885,7 @@ export function* collectSemanticMemberMutationsSteps( const captureSelectionIdentity = JSON.stringify([ current.memberReadAt?.start, current.memberReadAt?.end, - current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [ - at.start, - at.end, - depth, - ownEnumerable, - ]), + capturedSelectionKey(current.capturedSelections), ]); // Ancestry closes repeated source identities before recursively selected // copy vectors grow; independent contexts remain distinct in coverage. @@ -980,8 +990,7 @@ export function* collectSemanticMemberMutationsSteps( const capture = captures[captureIndex]; if (capture === undefined) return undefined; return JSON.stringify([ - capture.at.start, - capture.at.end, + nodeIdentity(captureIdentity(capture)), capture.depth === index + 1 && capture.ownEnumerable === true, ]); }) @@ -2227,9 +2236,10 @@ const observableMutationPaths = ( ): PropertyPath | null => { let observer = bindingNodes.get(bindingId); if (observer === undefined) return path; - if (path.some((key) => key !== null && typeof key === "object")) - return path; - if (effect === "escape") { + if ( + effect === "escape" && + !path.some((key) => key !== null && typeof key === "object") + ) { observer = project(observer, path); settle(); const origins = observer.origins; @@ -2244,6 +2254,9 @@ const observableMutationPaths = ( for (let offset = 0; offset < path.length; offset++) { const key = path[offset]; if (key === undefined) break; + // Copied-slot masks remain symbolic. An unobserved exact prefix before + // the mask still ends a growing path; do not skip that prefix entirely. + if (key !== null && typeof key === "object") break; observer = project(observer, [key]); settle(); if ((observer.origins?.size ?? 0) === 0 && offset + 1 < path.length) diff --git a/src/domain/javascript/javascriptSemanticMutationCycles.test.ts b/src/domain/javascript/javascriptSemanticMutationCycles.test.ts new file mode 100644 index 0000000000..c79c6dd30e --- /dev/null +++ b/src/domain/javascript/javascriptSemanticMutationCycles.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +const returnedValue = (body: string, expression: string) => + onlyCallable( + analyzeJavaScriptSemantics( + `export function result() { ${body} return ${expression}; }`, + ), + "result", + ).returnSites[0]?.value; + +describe("member-path cycles and captured aliases", () => { + const branches = Array.from( + { length: 16 }, + (_, index) => `if (flag === ${index}) node = node.m${index};`, + ).join(" else "); + + it.each(["copy[key].value = 2;", "mutate(copy[key]);"])( + "retains shared-child effects through rest copies: %s", + (effect) => { + const body = ` + const seed = { mode: "initial", m15: { m15: { child: { value: 1 } } } }; + let node = seed.m15; + while (node) { + ${branches} + const { ignore, ...copy } = node; + ${effect} + } + `; + expect(returnedValue(body, "seed.m15.m15.child.value")?.status).toBe( + "unknown", + ); + expect(returnedValue(body, "seed.mode")).toEqual({ + status: "literal", + value: "initial", + }); + }, + ); + + it.each([ + ["while", (body: string) => `while (node) { ${body} }`], + ["do/while", (body: string) => `do { ${body} } while (node);`], + ["for", (body: string) => `for (; node; ) { ${body} }`], + ["for/in", (body: string) => `for (const key in { a: 1 }) { ${body} }`], + ["for/of", (body: string) => `for (const item of [1]) { ${body} }`], + ] as const)( + "keeps a late child effect within its siblings in %s", + (_kind, loop) => { + const body = ` + const seed = { mode: "initial", m15: { value: 1, keep: 3 } }; + let node = seed; + ${loop(`${branches} node.value = 2;`)} + `; + expect(returnedValue(body, "seed.m15.value")?.status).toBe("unknown"); + expect(returnedValue(body, "seed.mode")).toEqual({ + status: "literal", + value: "initial", + }); + expect(returnedValue(body, "seed.m15.keep")).toEqual({ + status: "literal", + value: 3, + }); + }, + ); + + it("keeps an acyclic chain of reassignments within its selected child", () => { + const body = ` + const seed = { branch: { child: { value: 1 }, keep: 3 }, keep: 4 }; + let node = seed; + node = node.branch; + node = node.child; + mutate(node); + `; + expect(returnedValue(body, "seed.branch.child.value")?.status).toBe( + "unknown", + ); + expect(returnedValue(body, "seed.branch.keep")).toEqual({ + status: "literal", + value: 3, + }); + expect(returnedValue(body, "seed.keep")).toEqual({ + status: "literal", + value: 4, + }); + }); + + it("selects saved aliases at their capture, before or after a rebind", () => { + const body = ` + const seed = { branch: { child: { value: 1 }, keep: 3 } }; + let node = seed.branch; + const before = node; + node = node.child; + const after = node; + `; + expect( + returnedValue(`${body} mutate(after);`, "seed.branch.child.value") + ?.status, + ).toBe("unknown"); + expect(returnedValue(`${body} mutate(after);`, "seed.branch.keep")).toEqual( + { + status: "literal", + value: 3, + }, + ); + expect( + returnedValue(`${body} mutate(before);`, "seed.branch.keep")?.status, + ).toBe("unknown"); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index d4f5c16dc9..0286b6f677 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -26,7 +26,7 @@ interface StatementPosition { interface BindingReadPosition { readonly position: StatementPosition | null; - readonly repeating: boolean; + readonly repeatingLoop: t.Loop | undefined; } interface ExpressionSiblings { @@ -545,7 +545,7 @@ export const semanticDefiniteEffectPrecedesReference = ( ): boolean => { const before = directMutationPosition(effect, parents); const after = bindingReadPosition(reference, parents); - if (before === null || after.position === null || after.repeating) + if (before === null || after.position === null || after.repeatingLoop) return false; if (positionPrecedes(before, after.position)) return true; return ( @@ -701,7 +701,7 @@ const bindingReadPosition = ( path.push(current); const parent = parents.get(current); if (t.isLoop(parent)) { - result = { position: null, repeating: true }; + result = { position: null, repeatingLoop: parent }; break; } if ( @@ -709,7 +709,7 @@ const bindingReadPosition = ( (t.isFunction(parent) && !semanticComputedMethodKey(parent, current)) || delayedInstanceFieldValue(parent, current) ) { - result = { position: null, repeating: false }; + result = { position: null, repeatingLoop: undefined }; break; } current = parent; @@ -736,6 +736,33 @@ const bindingReadPosition = ( return result; }; +/** Repeated member reads share unknown ordering, while retaining their source sites. */ +export const semanticCaptureOrderingIdentity = ( + capture: t.Node, + parents: WeakMap, +): t.Node => { + // Calls can have a distinct invocation effect at their own selection site. + if (!t.isMemberExpression(capture) && !t.isOptionalMemberExpression(capture)) + return capture; + const loop = bindingReadPosition(capture, parents).repeatingLoop; + if (loop === undefined) return capture; + let child: t.Node = capture; + for ( + let parent = parents.get(child); + parent !== undefined && parent !== loop; + parent = parents.get(child) + ) + child = parent; + // These inputs run once, before the repeating portion of the loop. + if ( + (t.isForStatement(loop) && child === loop.init) || + ((t.isForInStatement(loop) || t.isForOfStatement(loop)) && + child === loop.right) + ) + return capture; + return loop; +}; + /** Select a local alias at its read, before any later straight-line reassignment. */ type SemanticBindingRead = { readonly status: "ordered" | "unresolved"; @@ -811,7 +838,7 @@ export const semanticBindingAtRead = ( ? null : initializerPosition(reference, parents, true) : initializerPosition(initializer, parents); - const { position, repeating } = bindingReadPosition(read, parents); + const { position, repeatingLoop } = bindingReadPosition(read, parents); // Coverage and latest-value selection are independent. A preceding write // can cover entry even when another write's order remains unknown. const hasDefiniteInitializer = @@ -832,7 +859,7 @@ export const semanticBindingAtRead = ( ); }); if ( - !repeating && + !repeatingLoop && origin !== undefined && binding.initializers.length + binding.referenceInitializers.length === 1 && sourcePosition !== null && @@ -845,7 +872,7 @@ export const semanticBindingAtRead = ( hasDefiniteInitializer, }; const uncertainOrder = - repeating || + repeatingLoop || sourcePosition === null || position === null || sourcePosition.body !== position.body; diff --git a/tests/acceptance/applications/javascriptCapturedParameters.test.ts b/tests/acceptance/applications/javascriptCapturedParameters.test.ts index a12fd38bdd..164048e35c 100644 --- a/tests/acceptance/applications/javascriptCapturedParameters.test.ts +++ b/tests/acceptance/applications/javascriptCapturedParameters.test.ts @@ -22,6 +22,54 @@ export function destructured({x}) { const saved=x; x=2; return {value:saved}; } export function local() { let value="before"; value="after"; return {value}; } `; +cliTest( + "retains cyclic member effects and sibling values through CLI and stdio MCP", + async ({ cli }) => { + const branches = Array.from( + { length: 16 }, + (_, index) => `if (flag === ${index}) node = node.m${index};`, + ).join(" else "); + await verifyJavaScriptReturnShapes( + cli, + `export function result() { + const seed = { mode: "initial", m15: { value: 1, keep: 3 } }; + let node = seed; + while (node) { ${branches} node.value = 2; } + return { changed: seed.m15.value, mode: seed.mode, kept: seed.m15.keep }; + } + export function copied() { + const seed = { mode: "initial", keep: 3, m15: { m15: { child: { value: 1 } } } }; + let node = seed.m15; + while (node) { + ${branches} + const { ignore, ...copy } = node; + mutate(copy[key]); + } + return { changed: seed.m15.m15.child.value, mode: seed.mode, kept: seed.keep }; + }`, + (fields) => { + for (const name of ["result", "copied"]) + expect(fields(name)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "/changed", state: "unknown" }), + expect.objectContaining({ + path: "/mode", + state: "literal", + value: "initial", + }), + expect.objectContaining({ + path: "/kept", + state: "literal", + value: 3, + }), + ]), + ); + }, + ); + }, + 120_000, +); + cliTest( "preserves incoming values and proven assignments through CLI and stdio MCP", async ({ cli }) => {