diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 7a09e4c6d8..f43a86d267 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -23,6 +23,11 @@ artifact digest and structural graph ID. Semantic tracing requires the semantic relation graph and reports when it is unavailable rather than treating missing data as an empty graph. +Worker progress reports the source path and last observed parse, static, +semantic, projection or transfer stage. These notifications do not measure +stage CPU/time or establish the root cause of a slow or incomplete analysis. +Semantic projection limits bound retained output, not upstream analysis work. + Explicit TypeScript `import type` and `export type` declarations remain source facts and do not create runtime import or export relationships. Type export names are reported separately from value export names. Inline `type` specifiers can @@ -431,14 +436,22 @@ used by returned references. Reads of another selected getter continue that effect walk. A receiver reached by these effects becomes uncertain on an ordinary or optional read, compound assignment or update. A simple assignment or deletion does not invoke the getter. Selected setters invalidate -the receiver on assignment, including through known array and prototype paths. -Inert getters and ordinary data slots retain their supported precision. +the receiver and its shared children on assignment, including through known +array and prototype paths. Supported class descriptors use the same inherited +field and own-property shadowing rules as class reference selection. Inert +getters and ordinary data slots retain their supported precision. -These are conservative source-derived effects, not execution results. The -analysis does not establish general accessor purity, model every class or -reflectively installed descriptor, or cover getter reads introduced by object -spread/destructuring and all implicit coercion or disposal invocations. Absence of a later explicit write does not establish getter purity; -unsupported invocation forms can still retain stale receiver-field facts. +Object spreads, rest copies and destructuring obtain their property reads from +the shared pattern-source owner. Copies read own enumerable properties; +ordinary destructuring can invoke an inherited getter. Operations requiring +primitive conversion conservatively expose their conversion operands, including +computed keys, arithmetic and untagged template interpolation. `using` and +`await using` expose the retained resource at its lexical scope exit; earlier +scalar reads retain their capture timing. These are source-derived effects, +not execution results or proofs of accessor purity. Reflectively installed +descriptors remain opaque; their setup calls retain ordinary argument/receiver +escape uncertainty. External prototype changes and unsupported runtime protocols +remain unknown rather than supplying exact post-invocation values. Nested callable returns are not assigned to their parent callable. Projected graph observations carry source ranges but never source text. diff --git a/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts new file mode 100644 index 0000000000..68e11b5a10 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.language_invocations.test.ts @@ -0,0 +1,149 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +const returned = (body: string) => + onlyCallable( + analyzeJavaScriptSemantics(`export function observe() { ${body} }`), + "observe", + ).returnSites[0]?.value; + +it.each([ + "const copy = { ...state };", + "const { trigger } = state;", + "let trigger; ({ trigger } = state);", + "const { ...copy } = state;", + "const outer = { state }; const { state: { trigger } } = outer;", +])( + "indexes implicit getter invocation through the shared pattern/copy owner: %s", + (read) => { + expect( + returned(` + const state = { value: 1, get trigger() { this.value = 2; return 0; } }; + ${read} + return state.value; + `)?.status, + ).toBe("unknown"); + }, +); + +it("keeps copied own data properties separate from inherited getters and excluded rest keys", () => { + expect( + returned(` + const state = { __proto__: { get trigger() { this.value = 2; return 0; } }, value: 1 }; + const copy = { ...state }; + return state.value; + `), + ).toEqual({ status: "literal", value: 1 }); + expect( + returned(` + const state = { value: 1, get trigger() { return 0; } }; + const { trigger, ...copy } = state; + return state.value; + `), + ).toEqual({ status: "literal", value: 1 }); +}); + +it.each([ + "const key = { [state]: 0 };", + "const result = state + '';", + "const result = +state;", + "const result = `${state}`;", +])( + "retains receiver uncertainty after implicit primitive conversion: %s", + (operation) => { + expect( + returned(` + const state = { [Symbol.toPrimitive]() { this.value = 2; return 'key'; }, value: 1 }; + ${operation} + return state.value; + `)?.status, + ).toBe("unknown"); + }, +); + +it("keeps noncoercing operations and primitive operands precise", () => { + expect( + returned(` + const state = { [Symbol.toPrimitive]() { this.value = 2; return 'key'; }, value: 1 }; + const same = state === state; + const truthy = !state; + const primitive = 2 + 3; + return state.value; + `), + ).toEqual({ status: "literal", value: 1 }); +}); + +it.each(["using", "await using"])( + "retains %s disposal effects at lexical scope exit", + (kind) => { + const source = `${kind === "await using" ? "async " : ""}function observe() { + const state = { [Symbol.dispose]() { this.value = 2; }, value: 1 }; + { ${kind} owned = state; } + return state.value; + }`; + const ir = analyzeJavaScriptSemantics(source); + expect(onlyCallable(ir, "observe").returnSites[0]?.value.status).toBe( + "unknown", + ); + }, +); + +it("preserves scalar snapshots selected before disposal", () => { + expect( + returned(` + const state = { [Symbol.dispose]() { this.value = 2; }, value: 1 }; + const snapshot = state.value; + { using owned = state; } + return snapshot; + `), + ).toEqual({ status: "literal", value: 1 }); + expect( + returned(` + const state = { [Symbol.dispose]() { this.value = 2; }, value: 1 }; + using owned = state; + return state.value; + `), + ).toEqual({ status: "literal", value: 1 }); +}); + +it("keeps the disposer receiver captured before an outer binding replacement", () => { + expect( + returned(` + let state = { [Symbol.dispose]() { this.value = 2; }, value: 1 }; + const original = state; + { using owned = state; state = { value: 3 }; } + return original.value; + `)?.status, + ).toBe("unknown"); + expect( + returned(` + const state = { [Symbol.dispose]() { this.value = 2; }, value: 1 }; + { using nothing = null; } + return state.value; + `), + ).toEqual({ status: "literal", value: 1 }); +}); + +it("projects supported class getter effects through inherited receiver fields", () => { + expect( + returned(` + const shared = { value: 1 }; + class Base { get trigger() { this.state.value = 2; return 0; } } + class Box extends Base { state = shared; } + new Box().trigger; + return shared.value; + `)?.status, + ).toBe("unknown"); +}); + +it("retains shared child effects from selected setters", () => { + expect( + returned(` + const shared = { value: 1 }; + const box = { shared, set trigger(value) { this.shared.value = value; } }; + box.trigger = 2; + return shared.value; + `)?.status, + ).toBe("unknown"); +}); diff --git a/src/domain/javascript/javascriptSemanticGetterResults.ts b/src/domain/javascript/javascriptSemanticGetterResults.ts index c0de099456..1ed8772eb7 100644 --- a/src/domain/javascript/javascriptSemanticGetterResults.ts +++ b/src/domain/javascript/javascriptSemanticGetterResults.ts @@ -401,12 +401,15 @@ export const expandSemanticGetterResult = ( /** Project an effect operand through the same local aliases and `this` owner as getter results. */ export const semanticGetterReceiverPaths = ( - getter: t.ObjectMethod, + getter: t.ObjectMethod | t.ClassMethod, operand: t.Node, state: JavaScriptSemanticAnalysisState, ): readonly PropertyPath[] => { - const owner = state.parentsByNode.get(getter); - if (!t.isObjectExpression(owner)) return []; + const parent = state.parentsByNode.get(getter); + const owner = t.isClassBody(parent) + ? state.parentsByNode.get(parent) + : parent; + if (!t.isObjectExpression(owner) && !t.isClass(owner)) return []; const query = new SemanticGetterQuery(state); const references = query.run( () => { @@ -419,7 +422,7 @@ export const semanticGetterReceiverPaths = ( query, { objectReferences: (_receiver, path) => [{ node: getter, path }], - classReferences: () => [], + classReferences: (_lookup, path) => [{ node: getter, path }], privateReceiver: () => false, bases: () => [], containerReferences: (node, path) => diff --git a/src/domain/javascript/javascriptSemanticIterationSources.ts b/src/domain/javascript/javascriptSemanticIterationSources.ts index debe56235e..a863e3d55e 100644 --- a/src/domain/javascript/javascriptSemanticIterationSources.ts +++ b/src/domain/javascript/javascriptSemanticIterationSources.ts @@ -29,6 +29,13 @@ export interface SemanticIterationSource extends IterationSourceValue { readonly requiredSources?: readonly IterationSourceValue[]; } +/** A property value obtained by destructuring or an own-enumerable copy. */ +export interface SemanticPropertyReadSource + extends IterationSourceValue, SourceGuards { + readonly mutation: t.Node; + readonly lookup: "prototype" | "own"; +} + interface PatternSource extends IterationSourceValue { // A rest binding is a fresh array whose children retain their source indices. readonly restStartIndex?: number; @@ -112,6 +119,7 @@ const project = ( interface PatternCollection { readonly sources: SemanticIterationSource[]; readonly mutation: t.Node; + readonly propertyReads: SemanticPropertyReadSource[]; } const visit = ( @@ -191,11 +199,7 @@ const visit = ( if (t.isObjectPattern(pattern)) { for (const property of pattern.properties) { if (t.isRestElement(property)) { - // A rest copy owns its scalar slots; only selected child references - // escape when that fresh copy is stored through a member target. - visit( - context, - property.argument, + const rest = source === undefined ? undefined : { @@ -208,20 +212,31 @@ const visit = ( ].sort(compareUnicodeCodePoints), }, ], - }, - guards, - ); + }; + if (rest !== undefined) + context.propertyReads.push({ + ...rest, + ...guards, + mutation, + lookup: "own", + }); + // A rest copy owns its scalar slots; only selected child references + // escape when that fresh copy is stored through a member target. + visit(context, property.argument, rest, guards); continue; } - visit( - context, - property.value, - project( - source, - semanticStaticPropertyKey(property.key, property.computed), - ), - guards, + const selected = project( + source, + semanticStaticPropertyKey(property.key, property.computed), ); + if (selected !== undefined) + context.propertyReads.push({ + ...selected, + ...guards, + mutation, + lookup: "prototype", + }); + visit(context, property.value, selected, guards); } return; } @@ -258,13 +273,40 @@ const visit = ( visit(context, pattern.argument, undefined, guards); }; -/** Describe iterator receivers and property reference stores without evaluation. */ -export const semanticIterationSources = ( +interface SemanticSourceFacts { + readonly iterations: readonly SemanticIterationSource[]; + readonly propertyReads: readonly SemanticPropertyReadSource[]; +} +const emptySourceFacts: SemanticSourceFacts = { + iterations: [], + propertyReads: [], +}; +const sourceFacts = new WeakMap< + t.Node, + { readonly parent: t.Node | null; readonly facts: SemanticSourceFacts } +>(); + +/** One owner for pattern projections, guards, iteration and copy-read sources. */ +const semanticSourceFacts = ( node: t.Node, parent: t.Node | null, -): readonly SemanticIterationSource[] => { +): SemanticSourceFacts => { + if ( + !( + t.isVariableDeclarator(node) || + t.isAssignmentExpression(node) || + t.isForOfStatement(node) || + t.isSpreadElement(node) || + t.isYieldExpression(node) || + t.isFunction(node) + ) + ) + return emptySourceFacts; + const cached = sourceFacts.get(node); + if (cached?.parent === parent) return cached.facts; const sources: SemanticIterationSource[] = []; - const context = { sources, mutation: node }; + const propertyReads: SemanticPropertyReadSource[] = []; + const context = { sources, propertyReads, mutation: node }; if (t.isVariableDeclarator(node) && node.init != null) visit(context, node.id, { node: node.init, projection: [] }); else if ( @@ -279,13 +321,38 @@ export const semanticIterationSources = ( for (const declaration of node.left.declarations) visit(context, declaration.id, source); } else visit(context, node.left, source); - } else if (t.isSpreadElement(node) && t.isArrayExpression(parent)) - add(sources, { node: node.argument, projection: [] }, node, {}); - else if (t.isYieldExpression(node) && node.delegate && node.argument != null) + } else if (t.isSpreadElement(node)) { + if (t.isArrayExpression(parent)) + add(sources, { node: node.argument, projection: [] }, node, {}); + else if (t.isObjectExpression(parent)) + propertyReads.push({ + node: node.argument, + projection: [{ excludedKeys: [] }], + mutation: node, + lookup: "own", + }); + } else if ( + t.isYieldExpression(node) && + node.delegate && + node.argument != null + ) add(sources, { node: node.argument, projection: [] }, node, {}); else if (t.isFunction(node)) for (const parameter of runtimeFunctionParameters(node)) visit(context, parameter, undefined); - - return sources; + const facts = { iterations: sources, propertyReads }; + sourceFacts.set(node, { parent, facts }); + return facts; }; + +export const semanticIterationSources = ( + node: t.Node, + parent: t.Node | null, +): readonly SemanticIterationSource[] => + semanticSourceFacts(node, parent).iterations; + +export const semanticPropertyReadSources = ( + node: t.Node, + parent: t.Node | null, +): readonly SemanticPropertyReadSource[] => + semanticSourceFacts(node, parent).propertyReads; diff --git a/src/domain/javascript/javascriptSemanticLanguageInvocations.ts b/src/domain/javascript/javascriptSemanticLanguageInvocations.ts new file mode 100644 index 0000000000..d9fe9d7b53 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticLanguageInvocations.ts @@ -0,0 +1,33 @@ +import * as t from "@babel/types"; + +/** Operands exposed to user-defined primitive conversion by language operations. */ +export const semanticCoercionOperands = (node: t.Node): readonly t.Node[] => { + if (t.isBinaryExpression(node)) { + if (["===", "!==", "instanceof"].includes(node.operator)) return []; + return node.operator === "in" ? [node.left] : [node.left, node.right]; + } + if (t.isUnaryExpression(node) && ["+", "-", "~"].includes(node.operator)) + return [node.argument]; + if (t.isUpdateExpression(node)) return [node.argument]; + if ( + t.isAssignmentExpression(node) && + !["=", "&&=", "||=", "??="].includes(node.operator) + ) + return [node.left, node.right]; + if ( + (t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) || + t.isObjectProperty(node) || + t.isObjectMethod(node) || + t.isClassProperty(node) || + t.isClassMethod(node) || + t.isClassAccessorProperty(node)) && + node.computed + ) + return [ + t.isMemberExpression(node) || t.isOptionalMemberExpression(node) + ? node.property + : node.key, + ]; + return []; +}; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 066fcd64c4..507255a788 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -32,13 +32,16 @@ import { semanticArrayPropertyReferences, semanticClassCandidates, semanticClassPropertyReferences, + semanticClassAccessorReferences, semanticObjectPropertyReferences, semanticObjectAccessorReferences, type JavaScriptSemanticObjectReference, } from "./javascriptSemanticObjectReferences.js"; import { semanticIterationSources, + semanticPropertyReadSources, type SemanticIterationSource, + type SemanticPropertyReadSource, } from "./javascriptSemanticIterationSources.js"; import { semanticGetterReceiverPaths } from "./javascriptSemanticGetterResults.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; @@ -46,7 +49,9 @@ import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedRe import { semanticMutationInitializers, semanticBindingAtRead, + semanticScopeExitEffect, } from "./javascriptSemanticMutationInitializers.js"; +import { semanticCoercionOperands } from "./javascriptSemanticLanguageInvocations.js"; import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; import { semanticCopiedReferencePath, @@ -105,9 +110,10 @@ const localAccessorMethods = ( key: PropertyPath[number] | undefined, kind: "get" | "set", ) => boolean, -): readonly t.ObjectMethod[] | undefined => { + lookup: "prototype" | "own" = "prototype", +): readonly (t.ObjectMethod | t.ClassMethod)[] | undefined => { if (!keyMayExist(path.at(-1), accessor)) return []; - const methods = new Set(); + const methods = new Set(); const pending = [{ node: root, path }]; const examined = new WeakMap>(); let selections = 0; @@ -123,7 +129,10 @@ const localAccessorMethods = ( state.valueResourceLimits.add("alias-selections"); return undefined; } - if (t.isObjectMethod(node) && current.path.length === 0) { + if ( + (t.isObjectMethod(node) || t.isClassMethod(node)) && + current.path.length === 0 + ) { methods.add(node); continue; } @@ -162,13 +171,49 @@ const localAccessorMethods = ( const references = t.isArrayExpression(node) ? semanticArrayPropertyReferences(node, [selected!]) : remaining.length === 0 - ? semanticObjectAccessorReferences(node, selected!, state, accessor) + ? semanticObjectAccessorReferences( + node, + selected!, + state, + accessor, + lookup, + ) : semanticObjectPropertyReferences(node, [selected!], state); for (const source of references) pending.push({ node: source.node, path: [...source.path, ...remaining], }); + } else if ( + (t.isClass(node) || t.isNewExpression(node)) && + current.path.length > 0 + ) { + const [selected, ...remaining] = current.path; + const owners = t.isClass(node) + ? [node] + : semanticClassCandidates(node.callee, state); + for (const owner of owners) { + const selection = { + owner, + kind: t.isClass(node) ? ("static" as const) : ("instance" as const), + }; + const references = + remaining.length > 0 + ? semanticClassPropertyReferences(selection, [selected!], state) + : lookup === "own" + ? [] + : semanticClassAccessorReferences( + selection, + selected!, + state, + accessor, + ); + for (const source of references) + pending.push({ + node: source.node, + path: [...source.path, ...remaining], + }); + } } else { for (const source of branchReferenceValues(node, current.path)) pending.push(source); @@ -182,7 +227,9 @@ type AccessorEffectOperand = | { readonly kind: "read"; readonly node: t.Node; - readonly key: PropertyPath[number]; + readonly path: PropertyPath; + readonly lookup: "prototype" | "own"; + readonly guards?: SemanticPropertyReadSource; } | { readonly kind: "invoke"; @@ -202,10 +249,12 @@ const accessorEffectOperands = ( const cached = accessorOperands.get(method); if (cached !== undefined) return cached; const operands: AccessorEffectOperand[] = []; - const pending: t.Node[] = [method.body]; + const pending: { node: t.Node; parent: t.Node | null }[] = [ + { node: method.body, parent: method }, + ]; const writeOnly = new WeakSet(); while (pending.length > 0) { - const node = pending.pop()!; + const { node, parent } = pending.pop()!; // Defining a nested callable does not invoke it. if (t.isFunction(node) || t.isClass(node)) continue; const write = semanticWriteSiteTarget(node); @@ -223,7 +272,8 @@ const accessorEffectOperands = ( operands.push({ kind: "read", node: node.object, - key: semanticStaticPropertyKey(node.property, node.computed), + path: [semanticStaticPropertyKey(node.property, node.computed)], + lookup: "prototype", }); if ( t.isCallExpression(node) || @@ -254,7 +304,28 @@ const accessorEffectOperands = ( for (const expression of node.quasi.expressions) operands.push({ kind: "escape", node: expression }); } - pending.push(...childNodes(node).reverse()); + for (const operand of semanticCoercionOperands(node)) + operands.push({ kind: "escape", node: operand }); + if (t.isTemplateLiteral(node) && !t.isTaggedTemplateExpression(parent)) + for (const expression of node.expressions) + operands.push({ kind: "escape", node: expression }); + for (const source of semanticPropertyReadSources(node, parent)) + operands.push({ + kind: "read", + node: source.node, + path: source.projection, + lookup: source.lookup, + guards: source, + }); + if ( + t.isVariableDeclarator(node) && + node.init != null && + t.isVariableDeclaration(parent) && + (parent.kind === "using" || parent.kind === "await using") + ) + operands.push({ kind: "escape", node: node.init }); + for (const child of childNodes(node).reverse()) + pending.push({ node: child, parent: node }); } accessorOperands.set(method, operands); return operands; @@ -275,7 +346,10 @@ export function* collectSemanticMemberMutationsSteps( set: new Set(), }; for (const node of state.callableNodesById.values()) - if (t.isObjectMethod(node) && (node.kind === "get" || node.kind === "set")) + if ( + (t.isObjectMethod(node) || t.isClassMethod(node)) && + (node.kind === "get" || node.kind === "set") + ) accessorKeys[node.kind].add( semanticStaticPropertyKey(node.key, node.computed), ); @@ -295,14 +369,15 @@ export function* collectSemanticMemberMutationsSteps( const getterEffectPaths = ( root: t.Node, path: PropertyPath, + lookup: "prototype" | "own" = "prototype", ): readonly PropertyPath[] => { if (!accessorKeyMayExist(path.at(-1), "get")) return []; - const pending = [path]; + const pending = [{ path, lookup }]; const examined = new Set(); const effects: PropertyPath[] = []; for (let cursor = 0; cursor < pending.length; cursor++) { - const selected = pending[cursor]!; - const identity = JSON.stringify(selected); + const { path: selected, lookup: selectedLookup } = pending[cursor]!; + const identity = JSON.stringify([selected, selectedLookup]); if (examined.has(identity)) continue; examined.add(identity); if (examined.size > SEMANTIC_ALIAS_SELECTION_LIMIT) { @@ -315,6 +390,7 @@ export function* collectSemanticMemberMutationsSteps( "get", state, accessorKeyMayExist, + selectedLookup, ); const receiver = selected.slice(0, -1); if (methods === undefined) { @@ -339,7 +415,9 @@ export function* collectSemanticMemberMutationsSteps( } if ( operand.kind === "read" && - !accessorKeyMayExist(operand.key, "get") + (!accessorKeyMayExist(operand.path.at(-1), "get") || + (operand.guards !== undefined && + semanticReferenceSourcesInactive(operand.guards, state))) ) continue; for (const suffix of semanticGetterReceiverPaths( @@ -349,7 +427,11 @@ export function* collectSemanticMemberMutationsSteps( )) { const target = [...receiver, ...suffix]; if (operand.kind === "escape") effects.push(target); - else pending.push([...target, operand.key]); + else + pending.push({ + path: [...target, ...operand.path], + lookup: operand.lookup, + }); } } } @@ -701,9 +783,8 @@ export function* collectSemanticMemberMutationsSteps( current.originAt, parents, ); - // A selected setter runs on assignment and may - // replace any member of its receiver, so the written key is unknown. - // Deleting the accessor never calls it. + // Invoking a setter exposes the receiver, including shared children. + // Preserve the explicit target write separately; deleting never invokes it. if ( effect === "write" && !t.isUnaryExpression(mutation, { operator: "delete" }) @@ -721,7 +802,15 @@ export function* collectSemanticMemberMutationsSteps( )?.length ?? 1) > 0, ) ) - path = [...selectedPath.slice(0, -1), null]; + markValue( + current.node, + selectedPath.slice(0, -1), + new Set(), + "escape", + mutation, + current.originAt, + current.ownerInitializer, + ); } // Keep an antichain of exact paths. Widening distinct member keys would // also invalidate unrelated siblings that never reach this effect. @@ -1090,7 +1179,7 @@ export function* collectSemanticMemberMutationsSteps( }; const writeOnlyMembers = new WeakSet(); yield* traverseJavaScriptAstSteps(program, { - enter: (node, parent) => { + enter: (node, parent, readAncestors) => { const writeTarget = semanticWriteSiteTarget(node); if (writeTarget !== null && !semanticWriteSiteReadsFirst(node)) for (const target of semanticWriteTargets(writeTarget)) @@ -1105,6 +1194,49 @@ export function* collectSemanticMemberMutationsSteps( originAt: source.mutation, iterationOnly: source.kind === "iteration", }); + for (const operand of semanticCoercionOperands(node)) + markEscaped(operand, node); + if (t.isTemplateLiteral(node) && !t.isTaggedTemplateExpression(parent)) + for (const expression of node.expressions) + markEscaped(expression, node); + if ( + t.isVariableDeclarator(node) && + node.init != null && + t.isVariableDeclaration(parent) && + (parent.kind === "using" || parent.kind === "await using") + ) { + const scope = readAncestors().findLast( + (ancestor) => + t.isBlockStatement(ancestor) || + t.isProgram(ancestor) || + t.isStaticBlock(ancestor) || + t.isSwitchStatement(ancestor) || + t.isLoop(ancestor), + ); + if (scope !== undefined) { + const exit = semanticScopeExitEffect(scope); + if (t.isIdentifier(node.id)) + markValue( + node.id, + [], + new Set(), + "escape", + exit, + node.id, + node.init, + ); + else markEscaped(node.init, exit); + } + } + for (const source of semanticPropertyReadSources(node, parent)) { + if (semanticReferenceSourcesInactive(source, state)) continue; + for (const path of getterEffectPaths( + source.node, + source.projection, + source.lookup, + )) + markEscaped(source.node, source.mutation, path); + } if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { // The write owner classifies direct and destructuring stores once. if (!writeOnlyMembers.has(node)) diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index f5d5b24e0d..1c74f5797b 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -156,6 +156,39 @@ export const semanticTopLevelEffectFollowsCapture = ( return expressionEvaluationOrder(capture, effect, parents) === "before"; }; +const scopeExitMarkers = new WeakMap(); +const scopeExitOwners = new WeakMap(); + +/** A derived scope-end effect keeps disposal separate from initializer evaluation. */ +export const semanticScopeExitEffect = (scope: t.Node): t.Node => { + const cached = scopeExitMarkers.get(scope); + if (cached !== undefined) return cached; + const marker = t.emptyStatement(); + marker.start = scope.end; + marker.end = scope.end; + if (scope.loc != null) + marker.loc = { ...scope.loc, start: scope.loc.end, end: scope.loc.end }; + scopeExitMarkers.set(scope, marker); + scopeExitOwners.set(marker, scope); + return marker; +}; + +const withinScopeExit = ( + reference: t.Node, + scope: t.Node, + parents: WeakMap, +): boolean => { + for ( + let node: t.Node | undefined = reference; + node !== undefined; + node = parents.get(node) + ) { + if (node === scope) return true; + if (t.isFunction(node)) return false; + } + return false; +}; + /** Include expression order when a write operation contains the read site. */ export const semanticEffectFollowsReference = ( reference: t.Node, @@ -163,6 +196,11 @@ export const semanticEffectFollowsReference = ( parents: WeakMap, ): boolean => { if (reference === effect) return false; + const exit = effect === undefined ? undefined : scopeExitOwners.get(effect); + if (exit !== undefined) { + if (withinScopeExit(reference, exit, parents)) return true; + effect = exit; + } let current: t.Node | undefined = reference; let child: t.Node | undefined; while (current !== undefined && current !== effect) { @@ -210,6 +248,11 @@ export const semanticEffectTiming = ( state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticEffectTiming => { if (effect === undefined) return "unknown"; + const exit = scopeExitOwners.get(effect); + if (exit !== undefined) { + if (withinScopeExit(site, exit, state.parentsByNode)) return "after"; + effect = exit; + } // Invocation receives arguments after their values have been selected. if ( site === effect && diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index b6c7c08d91..191a7bb682 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -156,9 +156,10 @@ const classPropertyDescriptors = ( propertyName: string | null, kind: ClassPropertyLookup["kind"], initializedBefore?: t.ClassProperty, + accessor: "get" | "set" = "get", ) => { const isStatic = kind === "static"; - const selectedGetters: t.ClassMethod[] = []; + const selectedAccessors: t.ClassMethod[] = []; const fields: t.ClassProperty[] = []; const indexedMembers: { readonly member: t.ClassMethod | t.ClassProperty; @@ -191,19 +192,19 @@ const classPropertyDescriptors = ( propertyName === null ? [] : indexedMembers.filter(({ key }) => key === propertyName); - const uncertainGetters = indexedMembers.filter( + const uncertainAccessors = indexedMembers.filter( (entry): entry is typeof entry & { member: t.ClassMethod } => { const { member, key } = entry; return ( t.isClassMethod(member) && - member.kind === "get" && + member.kind === accessor && (propertyName === null || (member.computed && key === null)) ); }, ); const getters = matchingMembers.filter( (entry): entry is typeof entry & { member: t.ClassMethod } => - t.isClassMethod(entry.member) && entry.member.kind === "get", + t.isClassMethod(entry.member) && entry.member.kind === accessor, ); const getter = getters.at(-1); const getterIndex = getter?.index ?? -1; @@ -223,20 +224,20 @@ const classPropertyDescriptors = ( fields.push(member); } if (getters.length > 0 && !laterDataMember && getter !== undefined) - selectedGetters.push(getter.member); - for (const { member, index } of uncertainGetters) + selectedAccessors.push(getter.member); + for (const { member, index } of uncertainAccessors) if ( !matchingMembers.some( ({ member: matching, index: matchingIndex }) => (t.isClassProperty(matching) || (t.isClassMethod(matching) && - (matching.kind === "method" || matching.kind === "get"))) && + (matching.kind === "method" || matching.kind === accessor))) && matchingIndex > index, ) ) - selectedGetters.push(member); + selectedAccessors.push(member); return { - getters: selectedGetters, + accessors: selectedAccessors, fields, inherits: matchingMembers.length === 0, }; @@ -254,6 +255,18 @@ export const semanticClassPropertyReferences = ( ); }; +/** Class descriptor selection shares field shadowing and inheritance with reference lookup. */ +export const semanticClassAccessorReferences = ( + lookup: ClassPropertyLookup, + key: PropertyPath[number], + state: JavaScriptSemanticAnalysisState, + accessor: "get" | "set", +): readonly JavaScriptSemanticObjectReference[] => + new ClassReferenceWalk(lookup, [key], state, { + query: new SemanticGetterQuery(state), + accessor, + }).run(); + class ClassReferenceWalk { readonly #references: JavaScriptSemanticObjectReference[] = []; readonly #pending: (t.ClassDeclaration | t.ClassExpression)[]; @@ -269,6 +282,7 @@ class ClassReferenceWalk { readonly context: { readonly query: SemanticGetterQuery; readonly escapes?: boolean; + readonly accessor?: "get" | "set"; }, ) { const [key, ...path] = selection; @@ -287,7 +301,9 @@ class ClassReferenceWalk { this.state, this.lookup.initializedBefore, ); - for (const reference of fields.references) this.#references.push(reference); + if (this.context.accessor === undefined) + for (const reference of fields.references) + this.#references.push(reference); this.#fields = fields.definiteNames; return this.#name !== null && this.#fields.has(this.#name); } @@ -298,6 +314,10 @@ class ClassReferenceWalk { ): void { const key = semanticStaticPropertyKey(getter.key, getter.computed); if (key !== null && this.#fields.has(key)) return; + if (this.context.accessor !== undefined) { + this.#references.push({ node: getter, path: [] }); + return; + } this.context.query.schedule( { getter, @@ -315,11 +335,14 @@ class ClassReferenceWalk { this.#name, this.lookup.kind, this.lookup.initializedBefore, + this.context.accessor, ); - for (const field of descriptors.fields) + for (const field of this.context.accessor === undefined + ? descriptors.fields + : []) if (field.value != null) this.#references.push({ node: field.value, path: this.#path }); - for (const getter of this.lookup.fieldsOnly ? [] : descriptors.getters) + for (const getter of this.lookup.fieldsOnly ? [] : descriptors.accessors) this.#getter(klass, getter); // Only a definite own descriptor can suppress possible superclass results. if (descriptors.inherits && klass.superClass != null) @@ -617,12 +640,14 @@ export const semanticObjectAccessorReferences = ( key: PropertyPath[number], state: JavaScriptSemanticAnalysisState, accessor: "get" | "set", + lookup: "prototype" | "own" = "prototype", ): readonly JavaScriptSemanticObjectReference[] => new ObjectReferenceWalk( node, [key], new SemanticGetterQuery(state), accessor, + lookup, ).run(); class ObjectReferenceWalk { @@ -640,6 +665,7 @@ class ObjectReferenceWalk { path: PropertyPath, readonly query: SemanticGetterQuery, readonly accessor?: "get" | "set", + readonly accessorLookup: "prototype" | "own" = "prototype", ) { [this.#key, ...this.#remaining] = path; } @@ -694,6 +720,8 @@ class ObjectReferenceWalk { ) return false; const selected = this.#key ?? null; + if (this.accessor !== undefined && this.accessorLookup === "own") + return true; if ( t.isExpression(property.value) && (selected === null || diff --git a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts index 2ff8bb2fae..f296c263a4 100644 --- a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts +++ b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts @@ -121,6 +121,39 @@ describe("JavaScript export return-shape comparison", () => { ); }); + it("preserves independent implicit invocation unknowns through retained export Evidence", async () => { + const [left, right] = await analyzeSources({ + left: `export default function observe() { + const getter = { value: 1, get trigger() { this.value = 2; return 0; } }; + const copy = { ...getter }; + const coercion = { [Symbol.toPrimitive]() { this.value = 2; return "key"; }, value: 1 }; + const keyed = { [coercion]: 0 }; + const disposal = { [Symbol.dispose]() { this.value = 2; }, value: 1 }; + { using owned = disposal; } + return { type: "event", getter: getter.value, coercion: coercion.value, disposal: disposal.value, stable: "kept" }; + }`, + right: `export default function observe() { + return { type: "event", getter: 2, coercion: 2, disposal: 2, stable: "kept" }; + }`, + }); + const result = compare(left, right); + expect(result.changes).toHaveLength(3); + expect(result.changes).toEqual( + expect.arrayContaining( + ["/getter", "/coercion", "/disposal"].map((path) => + expect.objectContaining({ + path, + status: "unknown", + left: expect.objectContaining({ availability: "unknown" }), + right: { availability: "literal", value: 2 }, + }), + ), + ), + ); + expect(result.coverage.status).toBe("partial"); + expect(inventoryProperties(result, "left", 0)).toContain("/stable"); + }); + it("keeps additions unknown when spread coverage is incomplete", async () => { const [left, right] = await analyzeSources({ left: `