diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 4a4c622f5b..8dd1b603ff 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -427,8 +427,11 @@ value. Earlier `break`/`continue` paths can leave a var initializer unexecuted. Legacy non-strict block-function copies are indexed on their outer variable owner, while strict/module and lexical-shadowing boundaries keep their values. A catch-shadowed var declaration keeps declaration ownership separate from its -initializer's lexical assignment. Ordinary functions own an implicit arguments -object; arrows retain lexical arguments lookup. Promise source identity uses +initializer's lexical assignment, including object and array destructuring slots. +Projected initializer facts retain their source and alias identity on the lexical +recipient, so a later mutation through that alias prevents reporting the original +object field as an unchanged constant. Ordinary functions own an implicit +arguments object; arrows retain lexical arguments lookup. Promise source identity uses the same read-position selection and requires an unmodified intrinsic factory. Rebound values are evaluated at their read position: saving an incoming parameter diff --git a/src/domain/javascript/javascriptSemanticAnalysis.initialization_boundaries.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.initialization_boundaries.test.ts index 216ef23e90..748a83477a 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.initialization_boundaries.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.initialization_boundaries.test.ts @@ -123,6 +123,38 @@ it("keeps ordinary arguments local while arrows retain the outer binding", () => ).toBe(false); }); +it.each([ + ["object", "{ handler }", "{ handler: state }"], + ["array", "[handler]", "[state]"], +])( + "retains mutations through catch-shadowed %s var aliases", + (_shape, pattern, value) => { + for (const shadowed of [true, false]) { + const ir = analyze(` + exports.read = function read() { + const state = { value: 1 }; + try { throw 0; } catch (${shadowed ? "handler" : "error"}) { + var ${pattern} = ${value}; + handler.value = 2; + } + return state.value; + }; + `); + expect(ir.coverage.status).toBe("complete"); + expect(onlyCallable(ir, "read").returnSites[0]?.value).toEqual({ + status: "unknown", + reason: "Own property value is unknown-coverage.", + }); + if (shadowed) + expect( + ir.bindings.find( + ({ name, kind }) => name === "handler" && kind === "variable", + )?.value.status, + ).toBe("unknown"); + } + }, +); + it("does not resolve Promise sources through a future initializer", () => { const early = analyze( ` diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 5c6d86bf87..51c6b3fe78 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -1156,11 +1156,7 @@ function* collectDefinitionsSteps( parentScope.kind === "block" ) legacyBlockFunctions.push({ node, scope: parentScope }); - if ( - t.isVariableDeclarator(node) && - node.init != null && - t.isIdentifier(node.id) - ) { + if (t.isVariableDeclarator(node) && node.init != null) { const owner = semanticVariableScope(parentScope, parent, state); if (owner !== parentScope) displacedVarInitializers.push({ node, scope: parentScope, owner }); @@ -1233,30 +1229,68 @@ function* collectDefinitionsSteps( // A var declaration is instantiated in its variable environment, while its // initializer assigns through the lexical environment at the source site. for (const { node, scope, owner } of displacedVarInitializers) { - if (!t.isIdentifier(node.id) || node.init == null) continue; - const selected = resolveSemanticBindingFromScope( - scope, - node.id.name, - state, - ); - const declared = owner.bindings.get(node.id.name); - if ( - selected === undefined || - selected === declared || - declared === undefined - ) - continue; - for (let index = declared.initializers.length - 1; index >= 0; index--) - if (declared.initializers[index]?.node === node.init) - declared.initializers.splice(index, 1); - addAssignment( - node.id, - node.init, - scope, - state, - state.conditionallySkippedExpressions.has(node.init), - node, - ); + for (const identifier of semanticWriteTargets(node.id)) { + if (!t.isIdentifier(identifier)) continue; + const selected = resolveSemanticBindingFromScope( + scope, + identifier.name, + state, + ); + const declared = owner.bindings.get(identifier.name); + if ( + selected === undefined || + selected === declared || + declared === undefined + ) + continue; + // Move the facts already parsed for this slot, including destructuring + // projections, defaults and rest copies, without reinterpreting its RHS. + const belongsToDeclaration = (value: t.Node): boolean => { + let candidate: t.Node | undefined = value; + while (candidate !== undefined && candidate !== node) + candidate = state.parentsByNode.get(candidate); + return candidate === node; + }; + for (let index = 0; index < declared.initializers.length; index++) { + const initializer = declared.initializers[index]; + if ( + initializer !== undefined && + belongsToDeclaration(initializer.node) + ) { + declared.initializers.splice(index, 1); + index--; + selected.initializers.push(initializer); + } + } + for ( + let index = 0; + index < declared.referenceInitializers.length; + index++ + ) { + const initializer = declared.referenceInitializers[index]; + if ( + initializer !== undefined && + belongsToDeclaration(initializer.node) + ) { + declared.referenceInitializers.splice(index, 1); + index--; + selected.referenceInitializers.push({ + ...initializer, + assignment: true, + }); + } + } + addAssignment( + identifier, + null, + scope, + state, + node.init !== null && + node.init !== undefined && + state.conditionallySkippedExpressions.has(node.init), + node, + ); + } } for (const { node, ancestors } of thrownValues) { let child: t.Node = node; diff --git a/tests/acceptance/applications/catchInitializerOwnership.test.ts b/tests/acceptance/applications/catchInitializerOwnership.test.ts new file mode 100644 index 0000000000..24cc0e3d79 --- /dev/null +++ b/tests/acceptance/applications/catchInitializerOwnership.test.ts @@ -0,0 +1,68 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves catch-shadowed initializer mutations in exported Evidence through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory( + "rea-catch-initializer-ownership-", + ); + await writeFile( + join(root, "app.cjs"), + `throw new Error("This analysis fixture must not execute"); +exports.shadowed = function shadowed() { + const state = { value: 1 }; + try { throw 0; } catch (handler) { + var { handler } = { handler: state }; + handler.value = 2; + } + return state.value; +}; +exports.unshadowed = function unshadowed() { + const state = { value: 1 }; + try { throw 0; } catch (error) { + var { handler } = { handler: state }; + handler.value = 2; + } + return state.value; +}; +exports.readOnly = function readOnly() { + const state = { value: 1 }; + try { throw 0; } catch (handler) { + var { handler } = { handler: state }; + const saved = handler.value; + } + return state.value; +}; +`, + ); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + for (const [name, status] of [ + ["shadowed", "unknown"], + ["unshadowed", "unknown"], + ["readOnly", "literal"], + ] as const) { + const observation = graph.nodes + .flatMap(({ observations }) => observations) + .find( + ({ properties }) => + properties.semantic_role === "export-return-shapes" && + properties.exported_name === name, + ); + expect(observation, name).toBeDefined(); + expect(observation?.properties.static_return_shapes).toMatchObject([ + { + value_status: status, + fields: [ + { path: "", state: status, value: status === "unknown" ? null : 1 }, + ], + }, + ]); + } + }, +);