diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index f43a86d267..fcb3c3c547 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -447,11 +447,22 @@ 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, +scalar reads retain their capture timing. Repeated loop iterations can expose +a previous disposal and therefore retain uncertainty. Private getter fields +retain their parsed initializer references; `super` reads use the selected +receiver. Custom `instanceof` hooks expose their receiver and argument. +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. +Identifier references and closure captures share Babel syntax-role facts plus +explicit binding/write roles; noncomputed declaration keys and import/meta names +are not lexical reads. Direct generator and async calls produce iterators and +Promises; body returns do not become direct call-result flows. Their unresolved +consumption remains an explicit frontier. Replaced emitter bindings select their +initializer lifetime at each operation. A lexical `this` owner remains partial +because it does not prove one runtime instance. 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.identity_boundaries.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.identity_boundaries.test.ts new file mode 100644 index 0000000000..dbe9c536f1 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.identity_boundaries.test.ts @@ -0,0 +1,98 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +it("uses parser roles for TS, import, and meta names while retaining computed reads", () => { + const ir = analyzeJavaScriptSemantics(` + const field=1, method=2, enumKey=3, url=4, type=5, computed="slot"; + interface Shape { field: number; method(): void; [computed]: number; } + enum Choice { enumKey = field } + const meta = import.meta.url; + import data from "./data.json" with { type: "json" }; + const object = { [computed]: field }; object[computed]; + `); + expect(ir.coverage.status).not.toBe("failed"); + expect( + ir.references + .filter(({ name }) => + ["field", "method", "enumKey", "url", "type"].includes(name), + ) + .map(({ name }) => name), + ).toEqual(["field", "field"]); + expect(ir.references.filter(({ name }) => name === "computed")).toHaveLength( + 3, + ); +}); + +it("keeps destructuring writes, defaults and closure captures under one role owner", () => { + const ir = analyzeJavaScriptSemantics(` + let value=0; const source={value:1}; const fallback=2; + function change() { ({ value = fallback } = source); return value; } + `); + expect( + ir.references.filter( + ({ name, role }) => name === "value" && role === "write", + ), + ).toHaveLength(1); + expect(ir.closureCaptures).toHaveLength(4); +}); + +it.each([ + "function* wrapped(){ yield 1; return 2; }", + "async function wrapped(){ return 2; }", + "async function* wrapped(){ yield 1; return 2; }", +])( + "does not project wrapped body results into direct calls: %s", + (declaration) => { + const ir = analyzeJavaScriptSemantics( + `${declaration} const result=wrapped(); function ordinary(){return 3;} const direct=ordinary();`, + ); + const wrapped = onlyCallable(ir, "wrapped"); + const ordinary = onlyCallable(ir, "ordinary"); + expect( + ir.callReturnFlows.filter( + ({ callableId }) => callableId === wrapped.callableId, + ), + ).toEqual([]); + expect( + ir.callReturnFlows.filter( + ({ callableId }) => callableId === ordinary.callableId, + ), + ).toHaveLength(1); + expect( + ir.frontiers.some(({ reason }) => + reason.includes("body results require separate consumption"), + ), + ).toBe(true); + }, +); + +it("separates emitter binding lifetimes and retains stable emitter identity", () => { + const ir = analyzeJavaScriptSemantics(` + let bus = createEmitter(); bus.on("ready", handler); bus = createEmitter(); bus.emit("ready"); + const stable = createEmitter(); stable.on("ready", handler); stable.emit("ready"); + `); + expect(ir.eventOperations).toHaveLength(4); + expect(ir.eventOperations[0]?.emitterKey).not.toBe( + ir.eventOperations[1]?.emitterKey, + ); + expect(ir.eventOperations[2]?.emitterKey).toBe( + ir.eventOperations[3]?.emitterKey, + ); + expect(ir.eventOperations[2]?.resolution).toBe("complete"); +}); + +it("does not claim one receiver instance for unrelated this expressions", () => { + const ir = analyzeJavaScriptSemantics(` + class First { start(){this.on("ready", handler);} } + class Second { finish(){this.emit("ready");} } + `); + expect(ir.eventOperations).toHaveLength(2); + expect(ir.eventOperations[0]?.emitterKey).not.toBe( + ir.eventOperations[1]?.emitterKey, + ); + expect(ir.eventOperations.map(({ resolution }) => resolution)).toEqual([ + "partial", + "partial", + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 1d4ab9e755..8a57125cb5 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -1438,7 +1438,11 @@ function* indexPrototypeUncertaintySteps( parent.arguments.includes(node as t.Expression) ) return; - if (t.isIdentifier(node) && semanticReferenceRole(node, parent) === null) + if ( + t.isIdentifier(node) && + semanticReferenceRole(node, parent, state.parentsByNode.get(parent)) === + null + ) return; taint(owner); }, diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index e38af6bb07..e9b9c92fa2 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -190,20 +190,53 @@ const emitterIdentity = ( } => { if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); - if (binding !== undefined) + if (binding !== undefined) { + const read = semanticBindingAtRead(binding, node, state.parentsByNode); + const origins = [ + ...read.binding.initializers, + ...read.binding.referenceInitializers, + ]; + const replaced = [ + ...binding.initializers, + ...binding.referenceInitializers, + ].some(({ assignment }) => assignment); + const origin = origins.length === 1 ? origins[0] : undefined; + const complete = + !replaced || + (read.status === "ordered" && + read.hasDefiniteInitializer && + origin !== undefined); return { - key: `binding:${binding.bindingId}`, + key: !replaced + ? `binding:${binding.bindingId}` + : complete + ? `binding:${binding.bindingId}:origin:${origin?.node.start ?? -1}:${origin?.node.end ?? -1}` + : `expression:${node.start ?? -1}:${node.end ?? -1}`, bindingId: binding.bindingId, - complete: true, + complete, }; + } return { key: `global:${node.name}`, bindingId: null, complete: !semanticResolutionBlocked(state, node, node.name), }; } - if (t.isThisExpression(node)) - return { key: "this", bindingId: null, complete: true }; + if (t.isThisExpression(node)) { + let owner = state.parentsByNode.get(node); + while ( + owner !== undefined && + !t.isClass(owner) && + !(t.isFunction(owner) && !t.isArrowFunctionExpression(owner)) + ) + owner = state.parentsByNode.get(owner); + // Lexical receiver ownership does not establish one runtime instance. + return { + key: `this:${owner?.start ?? -1}:${owner?.end ?? -1}`, + bindingId: null, + complete: false, + }; + } return { key: `expression:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, bindingId: null, diff --git a/src/domain/javascript/javascriptSemanticCallableResults.ts b/src/domain/javascript/javascriptSemanticCallableResults.ts index d8cfaa4b9c..ba66a22790 100644 --- a/src/domain/javascript/javascriptSemanticCallableResults.ts +++ b/src/domain/javascript/javascriptSemanticCallableResults.ts @@ -51,3 +51,12 @@ export const semanticCallableResultExpressions = ( } return output; }; + +/** A callable's direct invocation result differs from its body result syntax. */ +export const semanticCallableResultKind = ( + callable: t.Node, +): "value" | "promise" | "iterator" | "async-iterator" => { + if (!t.isFunction(callable)) return "value"; + if (callable.generator) return callable.async ? "async-iterator" : "iterator"; + return callable.async ? "promise" : "value"; +}; diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 3abe4579a6..47544f5208 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -9,7 +9,7 @@ import type { JavaScriptSemanticClosureCapture, JavaScriptSemanticFrontier, } from "./javascriptSemanticIr.js"; -import { semanticReferenceRole } from "./javascriptSemanticProjection.js"; +import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; import { createSemanticCallableStack } from "./javascriptSemanticCallableStack.js"; import { semanticStaticPropertyKey, @@ -28,6 +28,7 @@ import { } from "./javascriptSemanticCallResolution.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; +import { semanticCallableResultKind } from "./javascriptSemanticCallableResults.js"; import { assignedSemanticResultBindings } from "./javascriptSemanticDataEffectHelpers.js"; /** Local call, flow, capture, and unsupported-frontier facts. */ @@ -293,6 +294,22 @@ const collectReturnFlows = ( for (const callableId of site.calleeCallableIds) { const callable = callableById.get(callableId); if (callable === undefined) continue; + const source = state.callableNodesById.get(callableId); + const kind = + source === undefined ? "value" : semanticCallableResultKind(source); + if (kind !== "value") { + addFrontier( + { + kind: "dynamic-call", + callableId: site.callerCallableId, + location: site.location, + reason: `Direct invocation produces ${kind}; body results require separate consumption.`, + }, + state, + output, + ); + continue; + } for (const returnSite of callable.returnSites) { output.callReturnFlows.push({ callSiteId: site.callSiteId, @@ -318,7 +335,7 @@ const collectCapture = ( owner.bodyScopeId === null ) return; - if (semanticReferenceRole(node, parent) === null) return; + if (semanticIdentifierRole(node, parent, context.ancestors) === null) return; const binding = resolveSemanticBindingState(state, node, node.name); if ( binding === undefined || diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index b7a723fea7..34b8a93b7e 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -171,16 +171,12 @@ export function* collectSemanticReferencesSteps( ): Generator { const output: JavaScriptSemanticReference[] = []; const seen = new Set(); - const patterns: t.Node[] = []; yield* traverseJavaScriptAstSteps(program, { enter: (node, parent, readAncestors) => { - if (isPatternNode(node)) patterns.push(node); if ( !t.isIdentifier(node) || parent === null || - (name !== undefined && node.name !== name) || - (isNonReferenceKey(node, parent) && - !patterns.some((pattern) => bindsIdentifier(pattern, parent, node))) + (name !== undefined && node.name !== name) ) return; const ancestors = readAncestors(); @@ -218,9 +214,6 @@ export function* collectSemanticReferencesSteps( }); } }, - exit: (node) => { - if (isPatternNode(node)) patterns.pop(); - }, }); return output; } @@ -635,6 +628,13 @@ export const semanticIdentifierRole = ( parent: t.Node, ancestors: readonly t.Node[], ): JavaScriptSemanticReference["role"] | null => { + if ( + t.isObjectProperty(parent) && + parent.key === node && + !parent.computed && + parent.value !== node + ) + return null; const patternAncestor = enclosingPatternAncestor(node, parent, ancestors); if (patternAncestor !== null) { if (isPatternOwnerDeclaration(patternAncestor)) return null; @@ -642,7 +642,7 @@ export const semanticIdentifierRole = ( } const site = semanticWrappedWriteSite(node, parent, ancestors); if (site !== null && site !== parent && !isDeleteSite(site)) return "write"; - return semanticReferenceRole(node, parent); + return semanticReferenceRole(node, parent, ancestors.at(-2)); }; /** @@ -720,9 +720,8 @@ const isPatternOwnerDeclaration = (node: t.Node): boolean => export const semanticReferenceRole = ( node: t.Identifier, parent: t.Node, + grandparent?: t.Node, ): JavaScriptSemanticReference["role"] | null => { - if (isDeclarationIdentifier(node, parent) || isNonReferenceKey(node, parent)) - return null; if ( (t.isAssignmentExpression(parent) && parent.left === node) || t.isUpdateExpression(parent) || @@ -730,59 +729,23 @@ export const semanticReferenceRole = ( parent.left === node) ) return "write"; - if (t.isExportSpecifier(parent) && parent.local === node) return "export"; - return "read"; -}; - -const isDeclarationIdentifier = ( - node: t.Identifier, - parent: t.Node, -): boolean => { - if (t.isTSImportEqualsDeclaration(parent)) return parent.id === node; - return ( + // Babel owns syntax roles. These TS declaration forms are not covered by + // the installed Babel 8 isReferenced predicate. + if ( + (t.isTSImportEqualsDeclaration(parent) && parent.id === node) || (t.isTSParameterProperty(parent) && patternContains(parent.parameter, node)) || - (t.isVariableDeclarator(parent) && patternContains(parent.id, node)) || - ((t.isFunctionDeclaration(parent) || t.isFunctionExpression(parent)) && - parent.id === node) || - (t.isFunction(parent) && - parent.params.some((value) => patternContains(value, node))) || - ((t.isClassDeclaration(parent) || t.isClassExpression(parent)) && - parent.id === node) || - (t.isImportSpecifier(parent) && - (parent.local === node || parent.imported === node)) || - (t.isImportDefaultSpecifier(parent) && parent.local === node) || - (t.isImportNamespaceSpecifier(parent) && parent.local === node) || - (t.isCatchClause(parent) && parent.param === node) - ); + ((t.isTSDeclareMethod(parent) || t.isTSMethodSignature(parent)) && + parent.key === node && + !parent.computed) + ) + return null; + if (!t.isReferenced(node, parent, grandparent)) return null; + return t.isExportSpecifier(parent) && parent.local === node + ? "export" + : "read"; }; -const isNonReferenceKey = (node: t.Identifier, parent: t.Node): boolean => - ((t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) && - parent.property === node && - !parent.computed) || - ((t.isObjectProperty(parent) || t.isObjectMethod(parent)) && - parent.key === node && - !parent.computed && - !(t.isObjectProperty(parent) && parent.shorthand)) || - isNoncomputedClassMemberKey(node, parent) || - t.isPrivateName(parent) || - (t.isExportSpecifier(parent) && parent.exported === node) || - t.isLabeledStatement(parent) || - t.isBreakStatement(parent) || - t.isContinueStatement(parent); - -/** A noncomputed class member name is a property key, never a lexical read. */ -const isNoncomputedClassMemberKey = ( - node: t.Identifier, - parent: t.Node, -): boolean => - (t.isClassMethod(parent) || - t.isClassProperty(parent) || - t.isTSDeclareMethod(parent)) && - parent.key === node && - !parent.computed; - const patternNames = (pattern: t.Node): string[] => { if (t.isTSParameterProperty(pattern)) return patternNames(pattern.parameter); if (t.isIdentifier(pattern)) return [pattern.name]; diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index c905b61f76..cd9a1717c7 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -4,7 +4,10 @@ import { semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; +import { + semanticCallableResultExpressions, + semanticCallableResultKind, +} from "./javascriptSemanticCallableResults.js"; import { javaScriptSourceInvocationContext, javaScriptGenericInvocationMethod, @@ -669,10 +672,12 @@ export const createSemanticReturnedReferences = ( // A direct generator/async call produces a fresh iterator/Promise, not // the yielded/resolved contents. Escape analysis still retains those // potential references for their eventual consumers. + const resultKind = semanticCallableResultKind(callable); if ( consumption !== "potential-reference" && - (callable.generator || - (consumption === "direct-call" && callable.async)) + (resultKind === "iterator" || + resultKind === "async-iterator" || + (consumption === "direct-call" && resultKind === "promise")) ) return []; return semanticCallableResultExpressions(callable).flatMap(