diff --git a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts index 01a9566de..0a339f2ca 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts @@ -104,6 +104,98 @@ describe("reference lifetimes across calls and shallow copies", () => { ); }); +describe("capture ordering within declarations and expressions", () => { + it.each([ + "const ignored = mutate(source);", + "void mutate(source);", + "consume(mutate(source));", + ])("preserves a primitive before a later nested call: %s", (escape) => { + expect( + resultValue( + `const source={value:1}; const snapshot=source.value; ${escape} return snapshot;`, + ), + ).toEqual({ status: "literal", value: 1 }); + expect( + resultValue( + `const source={value:1}; ${escape} const snapshot=source.value; return snapshot;`, + )?.status, + ).toBe("unknown"); + }); + + it("preserves a primitive before an awaited escape", () => { + const value = onlyCallable( + analyzeJavaScriptSemantics(`export async function result() { + const source={value:1}; const snapshot=source.value; + await mutate(source); return snapshot; + }`), + "result", + ).returnSites[0]?.value; + expect(value).toEqual({ status: "literal", value: 1 }); + }); + + it.each([ + "({value:1} as {value:number})", + "({value:1} satisfies {value:number})", + "({value:1})!", + ])("classifies transparent initializer wrappers: %s", (source) => { + expect( + resultValue( + `const source=${source}; const snapshot=source.value; mutate(source); return snapshot;`, + ), + ).toEqual({ status: "literal", value: 1 }); + expect( + resultValue( + `const source=${source}; mutate(source); const snapshot=source.value; return snapshot;`, + )?.status, + ).toBe("unknown"); + }); + + it.each(["(source as {value:number})", "(source satisfies {value:number})"])( + "captures through a wrapped alias: %s", + (alias) => { + expect( + resultValue( + `const source={value:1}; const alias=${alias}; const snapshot=alias.value; mutate(alias); return snapshot;`, + ), + ).toEqual({ status: "literal", value: 1 }); + }, + ); + + it.each([ + ["{child:{}}", "{child:copy=fallback}"], + ["[{}]", "[copy=fallback]"], + ])("guards fallback selection before escape: %s", (source, pattern) => { + expect( + resultValue( + `const fallback={value:1}; const source=${source}; const ${pattern}=source; mutate(copy); return fallback.value;`, + ), + ).toEqual({ status: "literal", value: 1 }); + expect( + resultValue( + `const fallback={value:1}; const source=${source}; mutate(source); const ${pattern}=source; mutate(copy); return fallback.value;`, + )?.status, + ).toBe("unknown"); + }); + + it.each([ + "const source={value:1}, snapshot=source.value; mutate(source);", + "const source={value:1}, snapshot=source.value, ignored=mutate(source);", + ])("preserves sequential declarator captures: %s", (body) => { + expect(resultValue(`${body} return snapshot;`)).toEqual({ + status: "literal", + value: 1, + }); + }); + + it("retains an escape in an earlier declarator", () => { + expect( + resultValue( + "const source={value:1}, ignored=mutate(source), snapshot=source.value; return snapshot;", + )?.status, + ).toBe("unknown"); + }); +}); + describe("primitive snapshots across calls", () => { it.each([ "const [copy] = source;", diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 526e937ad..ae887b9c7 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -439,10 +439,13 @@ const referenceSourcesInactive = ( state: JavaScriptSemanticAnalysisState, ): boolean => reference.requiredSources?.some( - (source) => selectedReferenceSlot(source, state)?.presence === "absent", + (source) => + selectedReferenceSlot(source, state, source.node)?.presence === "absent", ) === true || reference.fallbackSources?.some((source) => { - const slot = selectedReferenceSlot(source, state); + // Selection precedes effects through the chosen reference. A later escape + // cannot make a fallback that was excluded at destructuring reachable. + const slot = selectedReferenceSlot(source, state, source.node); return ( slot?.presence === "present" && (slot.value.status === "literal" || @@ -558,7 +561,7 @@ const objectReferencedValues = ( path: [ typeof selected === "object" ? { - ...(selected ?? {}), + ...selected, excludedKeys: [ ...new Set([ ...(selected?.excludedKeys ?? []), @@ -693,6 +696,7 @@ const copiedReferencePath = ( const selectedReferenceSlot = ( source: { readonly node: t.Node; readonly projection: PropertyPath }, state: JavaScriptSemanticAnalysisState, + capturePoint?: t.Node, ) => { const path: string[] = []; for (const key of source.projection) { @@ -700,7 +704,7 @@ const selectedReferenceSlot = ( path.push(String(key)); } return semanticSlotAtPath( - evaluateSemanticExpression(source.node, state), + evaluateSemanticExpression(source.node, state, capturePoint), path, ); }; diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index d80d6b9ad..970f6b1f2 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -1,23 +1,47 @@ import * as t from "@babel/types"; -import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; import type { JavaScriptSemanticBindingState } from "./javascriptSemanticState.js"; interface StatementPosition { readonly body: readonly t.Statement[]; readonly index: number; + readonly declaratorIndex: number; } const statementPosition = ( statement: t.Statement, parents: WeakMap, + declarator?: t.VariableDeclarator, ): StatementPosition | null => { const parent = parents.get(statement); if (!t.isProgram(parent) && !t.isBlockStatement(parent)) return null; const index = parent.body.indexOf(statement); - return index < 0 ? null : { body: parent.body, index }; + return index < 0 + ? null + : { + body: parent.body, + index, + declaratorIndex: + t.isVariableDeclaration(statement) && declarator !== undefined + ? statement.declarations.indexOf(declarator) + : -1, + }; }; +const positionPrecedes = ( + left: StatementPosition, + right: StatementPosition, +): boolean => + left.body === right.body && + (left.index < right.index || + (left.index === right.index && + left.declaratorIndex >= 0 && + left.declaratorIndex < right.declaratorIndex)); + const directInitializerPosition = ( node: t.Node, parents: WeakMap, @@ -26,7 +50,7 @@ const directInitializerPosition = ( if (t.isVariableDeclarator(parent) && parent.init === node) { const declaration = parents.get(parent); return t.isVariableDeclaration(declaration) - ? statementPosition(declaration, parents) + ? statementPosition(declaration, parents, parent) : null; } if ( @@ -51,7 +75,7 @@ const directMutationPosition = ( if (t.isVariableDeclarator(node)) { const declaration = parents.get(node); return t.isVariableDeclaration(declaration) - ? statementPosition(declaration, parents) + ? statementPosition(declaration, parents, node) : null; } if (t.isSpreadElement(node)) { @@ -70,10 +94,19 @@ const directMutationPosition = ( !t.isTaggedTemplateExpression(node) ) return null; - const statement = parents.get(node); - return t.isExpressionStatement(statement) && statement.expression === node - ? statementPosition(statement, parents) - : null; + let current = parents.get(node); + while (current !== undefined) { + if (t.isFunction(current)) return null; + if (t.isStatement(current)) return statementPosition(current, parents); + if (t.isVariableDeclarator(current)) { + const declaration = parents.get(current); + return t.isVariableDeclaration(declaration) + ? statementPosition(declaration, parents, current) + : null; + } + current = parents.get(current); + } + return null; }; const referenceInitializerPosition = ( @@ -91,7 +124,7 @@ const referenceInitializerPosition = ( if (t.isVariableDeclarator(parent)) { const declaration = parents.get(parent); return t.isVariableDeclaration(declaration) - ? statementPosition(declaration, parents) + ? statementPosition(declaration, parents, parent) : null; } current = parent; @@ -117,7 +150,11 @@ export const semanticMutationInitializers = ( position: initializer.entryBody === undefined ? directInitializerPosition(initializer.node, parents) - : { body: initializer.entryBody.body, index: -1 }, + : { + body: initializer.entryBody.body, + index: -1, + declaratorIndex: -1, + }, })), ...binding.referenceInitializers.map((initializer) => ({ initializer, @@ -127,19 +164,25 @@ export const semanticMutationInitializers = ( if ( origins.some( ({ position }) => - position === null || - position.body !== mutationPosition.body || - position.index >= mutationPosition.index, + position === null || !positionPrecedes(position, mutationPosition), ) ) return binding; - const latestIndex = origins.reduce( - (latest, { position }) => Math.max(latest, position?.index ?? -1), - -1, + const latest = origins.reduce( + (latest, { position }) => + position !== null && + (latest === null || positionPrecedes(latest, position)) + ? position + : latest, + null, ); const retained = new Set( origins - .filter(({ position }) => position?.index === latestIndex) + .filter( + ({ position }) => + position?.index === latest?.index && + position?.declaratorIndex === latest?.declaratorIndex, + ) .map(({ initializer }) => initializer), ); return { @@ -188,8 +231,7 @@ export const semanticBindingPrecedesCapture = ( return ( sourcePosition !== null && capturePosition !== null && - sourcePosition.body === capturePosition.body && - sourcePosition.index < capturePosition.index + positionPrecedes(sourcePosition, capturePosition) ); }; @@ -209,12 +251,13 @@ export const semanticEscapeFollowsCapture = ( return false; // 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; if ( - !t.isObjectExpression(initializer.node) && - !t.isArrayExpression(initializer.node) && - !t.isIdentifier(initializer.node) && - !t.isMemberExpression(initializer.node) && - !t.isOptionalMemberExpression(initializer.node) + !t.isObjectExpression(origin) && + !t.isArrayExpression(origin) && + !t.isIdentifier(origin) && + !t.isMemberExpression(origin) && + !t.isOptionalMemberExpression(origin) ) return false; const capturePosition = semanticCapturePosition(capture, parents); @@ -222,7 +265,6 @@ export const semanticEscapeFollowsCapture = ( return ( capturePosition !== null && escapePosition !== null && - capturePosition.body === escapePosition.body && - capturePosition.index < escapePosition.index + positionPrecedes(capturePosition, escapePosition) ); }; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 51dd8da71..2f5362670 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -114,17 +114,25 @@ export const evaluateSemanticBinding = ( primitiveBindingValues: primitiveBindingValuesFor(state), }); -/** Evaluate one arbitrary inert expression in the established lexical state. */ +/** Evaluate an inert expression, optionally at an earlier reference capture. */ export const evaluateSemanticExpression = ( node: t.Node, state: JavaScriptSemanticAnalysisState, -): JavaScriptSemanticValue => - evaluateExpression(node, { + capturePoint?: t.Node, +): JavaScriptSemanticValue => { + const context: EvaluationContext = { state, bindings: new Set(), expressionDepth: 0, primitiveBindingValues: primitiveBindingValuesFor(state), - }); + }; + return evaluateExpression( + node, + capturePoint === undefined + ? context + : (captureContextAt(capturePoint, context) ?? context), + ); +}; /** Follow module provenance through destructuring, members, and aliases. */ export const evaluateSemanticProvenance = ( @@ -733,11 +741,17 @@ const isPrimitive = (value: JavaScriptSemanticValue): boolean => const primitiveCaptureContext = ( node: t.Node, context: EvaluationContext, +): EvaluationContext | undefined => { + if (t.isObjectExpression(node) || t.isArrayExpression(node)) return undefined; + return captureContextAt(node, context); +}; + +const captureContextAt = ( + node: t.Node, + context: EvaluationContext, ): EvaluationContext | undefined => { if ( context.capturePoint !== undefined || - t.isObjectExpression(node) || - t.isArrayExpression(node) || semanticCapturePosition(node, context.state.parentsByNode) === null ) return undefined; diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 9abbd4f0d..a16378317 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -541,6 +541,9 @@ describe("application workflow CLI and MCP capture lifetimes", () => { "destructured", "objectSnapshot", "arraySnapshot", + "unusedFallback", + "nestedSnapshot", + "declaratorSnapshot", ]; const sources = [ `export default function make() { @@ -559,11 +562,22 @@ describe("application workflow CLI and MCP capture lifetimes", () => { const objectSnapshot = {value: source.value}; const arraySnapshot = [source.value]; mutate(source); + const fallback = {value: "TOKEN"}; + const container = {child: {}}; + const {child: selected = fallback} = container; + mutate(selected); + const nestedSource = {value: "TOKEN"}; + const nestedSnapshot = nestedSource.value; + const ignored = mutate(nestedSource); + const declaratorSource = {value: "TOKEN"}, + declaratorSnapshot = declaratorSource.value, + declaratorIgnored = mutate(declaratorSource); return { kind: "capture-boundary", rebound: original.value, rest: child.value, snapshot, destructured, objectSnapshot: objectSnapshot.value, arraySnapshot: arraySnapshot[0], + unusedFallback: fallback.value, nestedSnapshot, declaratorSnapshot, }; }`, `export default function make() { return ${JSON.stringify({ @@ -597,7 +611,7 @@ describe("application workflow CLI and MCP capture lifetimes", () => { await writeFile(inputPath, JSON.stringify(input)); const expected = { normalized_result: { - summary: { added: 0, removed: 0, changed: 6, unknown: 0 }, + summary: { added: 0, removed: 0, changed: fields.length, unknown: 0 }, coverage: { status: "complete-within-inputs" }, changes: expect.arrayContaining( fields.map((field) =>