diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 8dd1b603ff..328a255e4f 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -510,8 +510,13 @@ 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. Provenance-only expression-depth omissions enter the same typed source coverage -as value-evaluation bounds and remain visible in retained application Evidence. +are not lexical reads. TypeScript type positions, including `typeof` type queries +and computed keys in erased signatures, do not evaluate JavaScript bindings or +create runtime calls or captures. Decorators on declared class fields can still +run when the class is defined, and a decorated declared or abstract field can +evaluate its computed key. Provenance-only expression-depth omissions enter +the same typed source coverage as value-evaluation bounds and remain visible in +retained application Evidence. Immediate synchronous function results also share the loader reference owner. An expression arrow returning the retained CommonJS `require` can preserve that loader identity. Possible loader results with opaque alternatives or block diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts index 774c302c76..d2f10ef81f 100644 --- a/src/domain/javascript/electronBundledRequireIdentity.test.ts +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -431,3 +431,69 @@ describe("Webpack externals through the loader's completion paths", () => { ]); }); }); + +describe("Webpack identity through erased TypeScript syntax", () => { + it.each([ + [ + "the module table", + bootstrap( + EXTERNAL, + CONSUMER, + "interface RuntimeShape { m(): void } n(100);", + ), + ], + [ + "the fresh module object", + bootstrap(EXTERNAL, CONSUMER).replace( + "return m[t](o,o.exports,n)", + "interface ModuleShape { o(): void } return m[t](o,o.exports,n)", + ), + ], + [ + "the module table through a type query", + bootstrap(EXTERNAL, CONSUMER, "type RuntimeShape = typeof m; n(100);"), + ], + [ + "the fresh module object through a type query", + bootstrap(EXTERNAL, CONSUMER).replace( + "return m[t](o,o.exports,n)", + "type ModuleShape = typeof o; return m[t](o,o.exports,n)", + ), + ], + [ + "the loader beside an uncalled installer", + bootstrap( + EXTERNAL, + CONSUMER, + "n.m=m;n.i=e=>{for(var k in e)n.m[k]=e[k]};interface RuntimeShape { n(): void } n(100);", + ), + ], + [ + "the loader through a type query beside an uncalled installer", + bootstrap( + EXTERNAL, + CONSUMER, + "n.m=m;n.i=e=>{for(var k in e)n.m[k]=e[k]};type RuntimeShape = typeof n; n(100);", + ), + ], + ])("does not let erased TypeScript syntax escape %s", (_name, source) => { + const electron = electronOf(source); + expect(electron.browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + ]); + expect(electron.ipc).toMatchObject([ + { channel: "get-connections", side: "main" }, + ]); + }); + + it.each([ + "const shape = { [m]: true }; n(100);", + "const kind = typeof m; n(100);", + "m.extra; n(100);", + ])( + "keeps an actual table reference outside the loader unproven: %s", + (entry) => { + expectUnresolved(bootstrap(EXTERNAL, CONSUMER, entry)); + }, + ); +}); diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 2c619d6705..c5a6d3de25 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -734,6 +734,29 @@ it("resolves an arrow handler in place when its inherited this is module scope", ]), ).toEqual([["ch", "wrapper-substitution"]]); }); +it.each(["interface Shape { marker(): void }", "type Shape = typeof marker;"])( + "does not give a handler invocation identity through erased syntax: %s", + (declaration) => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const CH = "ch"; + function register(marker) { + const handler = () => { ${declaration} }; + ipcRenderer.on(CH, handler); + } + contextBridge.exposeInMainWorld("api", { on: () => register("unused") }); + `); + expect( + result.electron.ipc + .filter(({ mode }) => mode === "listen") + .map(({ channel, resolution_basis }) => [ + channel, + resolution_basis ?? "direct", + ]), + ).toEqual([["ch", "wrapper-substitution"]]); + expect(result.parse_status).toBe("complete"); + }, +); it("resolves a handler a module-level factory call created once in place", () => { const result = analyzeJavaScriptStaticSource(` const { ipcMain } = require("electron"); diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index a039e1844c..b0718328af 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -14,6 +14,7 @@ import { semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; +import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; import type { JavaScriptElectronIdentity } from "./javascriptSemanticAnalysis.js"; import type { JavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisState.js"; import type { @@ -35,7 +36,7 @@ import { import { argumentNode } from "./javascriptStaticAnalysisHelpers.js"; import { childNodes, - traverseJavaScriptAst, + traverseJavaScriptValueAst, } from "./javascriptSemanticTraversal.js"; import { range, calleeName } from "./javascriptStaticAnalysisHelpers.js"; import { ipcDescriptor } from "./electronStaticAnalysisIpc.js"; @@ -440,28 +441,33 @@ export const collectElectronWrappedCallPaths = ( if (activatedScopes === 0) return { captures: false, traceDependent }; const scopes = new Set(enclosing.slice(0, activatedScopes)); let captures = false; - t.traverseFast(fn.node, (node) => { - if (captures) return; - if ( - t.isThisExpression(node) || - t.isSuper(node) || - (t.isMetaProperty(node) && node.meta.name === "new") - ) { + traverseJavaScriptValueAst(fn.node, state.erasedSyntaxNodes, { + enter: (node, parent, readAncestors) => { + if (captures) return; + if ( + t.isThisExpression(node) || + t.isSuper(node) || + (t.isMetaProperty(node) && node.meta.name === "new") + ) { + captures = + contextOwnerActivated && inheritsFunctionContext(node, fn.node); + return; + } + if (!t.isIdentifier(node)) return; + if ( + parent !== null && + semanticIdentifierRole(node, parent, readAncestors) === null + ) + return; + const binding = resolveSemanticBindingState(state, node, node.name); captures = - contextOwnerActivated && inheritsFunctionContext(node, fn.node); - return; - } - if (!t.isIdentifier(node)) return; - const parent = state.parentsByNode.get(node); - if (parent !== undefined && !t.isReferenced(node, parent)) return; - const binding = resolveSemanticBindingState(state, node, node.name); - captures = - binding === undefined - ? (node.name === "arguments" && - contextOwnerActivated && - inheritsFunctionContext(node, fn.node)) || - semanticResolutionBlocked(state, node, node.name) - : scopes.has(binding.scopeId); + binding === undefined + ? (node.name === "arguments" && + contextOwnerActivated && + inheritsFunctionContext(node, fn.node)) || + semanticResolutionBlocked(state, node, node.name) + : scopes.has(binding.scopeId); + }, }); return { captures, traceDependent }; }; @@ -1136,6 +1142,7 @@ export const collectElectronWrappedCallPaths = ( env: SymbolicEnvironment, context: PathContext, ): void { + if (state.erasedSyntaxNodes.has(node)) return; if (--remainingVisits < 0 || t.isFunction(node) || t.isClass(node)) return; if (t.isBlockStatement(node)) { let next = context; @@ -1187,7 +1194,7 @@ export const collectElectronWrappedCallPaths = ( call(node, env, context); for (const child of childNodes(node)) walk(child, env, context); } - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, { enter: (node) => { if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; diff --git a/src/domain/javascript/javascriptBootstrapIntegrity.ts b/src/domain/javascript/javascriptBootstrapIntegrity.ts index d60117081e..7870abe4c3 100644 --- a/src/domain/javascript/javascriptBootstrapIntegrity.ts +++ b/src/domain/javascript/javascriptBootstrapIntegrity.ts @@ -2,12 +2,13 @@ import * as t from "@babel/types"; import type { javascriptBootstrapAt } from "./javascriptBootstrap.js"; import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; import { resolveSemanticBindingState, semanticStableInitializer, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; type Bootstrap = NonNullable>; @@ -89,7 +90,7 @@ const loaderShape = ( const calls: t.CallExpression[] = []; const modules = new Set(); const returns: (t.Expression | null | undefined)[] = []; - traverseJavaScriptAst(loader.body, { + traverseJavaScriptValueAst(loader.body, state.erasedSyntaxNodes, { skip: (node) => t.isFunction(node), enter: (node) => { if (t.isReturnStatement(node)) returns.push(node.argument); @@ -284,11 +285,12 @@ const moduleObjectContained = ( state: JavaScriptSemanticAnalysisState, ): boolean => { let contained = true; - traverseJavaScriptAst(loader.body, { + traverseJavaScriptValueAst(loader.body, state.erasedSyntaxNodes, { enter: (node, parent, readAncestors) => { if (!contained || !t.isIdentifier(node) || parent === null) return; const ancestors = readAncestors(); - if (!t.isReferenced(node, parent, ancestors.at(-2))) return; + if (semanticIdentifierRole(node, parent, () => ancestors) === null) + return; if (bindingOf(state, node) !== moduleId) return; contained = !ancestors.some((ancestor) => t.isFunction(ancestor)) && @@ -328,7 +330,7 @@ const withCacheReads = ( if (shape.cacheId === undefined) return shape; const sites = new Set(shape.cacheSites); let contained = true; - traverseJavaScriptAst(loader.body, { + traverseJavaScriptValueAst(loader.body, state.erasedSyntaxNodes, { skip: (node) => t.isFunction(node), enter: (node, parent) => { if ( @@ -445,13 +447,14 @@ const referencesAreContained = ( state, ); let contained = true; - traverseJavaScriptAst(call, { + traverseJavaScriptValueAst(call, state.erasedSyntaxNodes, { skip: () => !contained, enter: (node, parent, readAncestors) => { if (!t.isIdentifier(node) || !names.has(node.name) || parent === null) return; const ancestors = readAncestors(); - if (!t.isReferenced(node, parent, ancestors.at(-2))) return; + if (semanticIdentifierRole(node, parent, () => ancestors) === null) + return; const id = bindingOf(state, node); const role = id === undefined ? undefined : tracked.get(id); if (role === undefined) return; @@ -486,7 +489,7 @@ const unreadRuntimeDefinitions = ( const definitions = new Map(); const readKeys = new Set(); let confined = true; - traverseJavaScriptAst(call, { + traverseJavaScriptValueAst(call, state.erasedSyntaxNodes, { enter: (node, parent, readAncestors) => { // A helper invoked as `loader.helper()` can obtain the namespace through // its receiver instead of a tracked loader identifier. @@ -494,7 +497,7 @@ const unreadRuntimeDefinitions = ( if (!t.isIdentifier(node) || parent === null) return; const ancestors = readAncestors(); if ( - !t.isReferenced(node, parent, ancestors.at(-2)) || + semanticIdentifierRole(node, parent, () => ancestors) === null || !loaderIds.has(bindingOf(state, node) ?? "") ) return; diff --git a/src/domain/javascript/javascriptBundledRequireOrigin.ts b/src/domain/javascript/javascriptBundledRequireOrigin.ts index 27f29aef4c..76fd433f23 100644 --- a/src/domain/javascript/javascriptBundledRequireOrigin.ts +++ b/src/domain/javascript/javascriptBundledRequireOrigin.ts @@ -27,7 +27,7 @@ import { } from "./javascriptStaticAnalysisHelpers.js"; import { completeSemanticSteps, - traverseJavaScriptAst, + traverseJavaScriptValueAst, } from "./javascriptSemanticTraversal.js"; /** Loader bindings and literal externals proven for one standalone bootstrap. */ @@ -188,6 +188,7 @@ const canonicalWebpackInteropBindings = ( completeSemanticSteps( collectJavaScriptIdentityWritesSteps( state.program, + state.erasedSyntaxNodes, (root, kind) => { return kind === "member" && loaderMayOwnReference(root) ? [["loader"]] @@ -266,7 +267,7 @@ const isInstalledBeforeEntryDispatch = ( for (let index = 0; index < body.body.length; index += 1) { const topLevel = body.body[index]; if (topLevel === undefined) continue; - traverseJavaScriptAst(topLevel, { + traverseJavaScriptValueAst(topLevel, state.erasedSyntaxNodes, { enter: (node, _parent, ancestors) => { if ( entryIndex < 0 && diff --git a/src/domain/javascript/javascriptCommonJsExportReceivers.ts b/src/domain/javascript/javascriptCommonJsExportReceivers.ts index 7815935e48..b3dca48d6d 100644 --- a/src/domain/javascript/javascriptCommonJsExportReceivers.ts +++ b/src/domain/javascript/javascriptCommonJsExportReceivers.ts @@ -12,7 +12,7 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; -import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAstSteps } from "./javascriptSemanticTraversal.js"; import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; @@ -441,154 +441,158 @@ export function* indexCommonJsExportReceiversSteps( if (exportsWrite) exportsIdentity = selected; }; - yield* traverseJavaScriptAstSteps(state.program, { - enter: (node, parent) => { - const outer = execution.at(-1) ?? "ordered"; - const mode: Execution = - outer === "deferred" || t.isFunction(node) || t.isClass(node) - ? "deferred" - : outer === "conditional" || - // Mark the whole operation: a later tree-visited RHS must not - // restore certainty after an earlier tree-visited pattern write. - hasDestructuringEvaluationOrder(node) || - t.isIfStatement(node) || - t.isSwitchStatement(node) || - t.isLoop(node) || - t.isTryStatement(node) || - t.isObjectPattern(node) || - t.isArrayPattern(node) || - (t.isLogicalExpression(parent) && parent.right === node) || - (t.isAssignmentPattern(parent) && parent.right === node) || - (t.isAssignmentExpression(parent) && - ["&&=", "||=", "??="].includes(parent.operator) && - parent.right === node) || - t.isOptionalCallExpression(parent) || - (t.isOptionalMemberExpression(parent) && - parent.property === node) || - (t.isConditionalExpression(parent) && parent.test !== node) - ? "conditional" - : "ordered"; - execution.push(mode); - if ( - t.isAssignmentExpression(node) && - commonJsExportName(node.left, state) !== undefined && - !isCommonJsModuleExports(node.left, state) - ) { - const receiver = - t.isMemberExpression(node.left) || - t.isOptionalMemberExpression(node.left) - ? node.left.object - : null; - captures.set( - node, - mode !== "ordered" - ? null - : receiver !== null && - isUnshadowedGlobal(receiver, state, "exports") - ? exportsIdentity - : publicIdentity, - ); - } - const descriptor = commonJsDefinedExport(node, state); - if (descriptor !== undefined) { - const receiver = isUnshadowedGlobal( - descriptor.receiver, - state, - "exports", - ) - ? exportsIdentity - : publicIdentity; - const names = describedFields.get(receiver) ?? new Set(); - names.add(descriptor.exportedName); - describedFields.set(receiver, names); - } - const defined = descriptor ?? commonJsAssignedExports(node, state); - if (defined !== undefined && t.isCallExpression(node)) - captures.set( - node, - mode !== "ordered" - ? null - : isUnshadowedGlobal(defined.receiver, state, "exports") - ? exportsIdentity - : publicIdentity, - ); - }, - exit: (node) => { - const mode = execution.pop() ?? "conditional"; - if (publicCallWrites.has(node)) { - if (mode === "deferred") deferredReplacement = true; - else publicIdentity = null; - } - if (t.isAssignmentExpression(node)) { - const left = unwrapJavaScriptExpression(node.left).node; - const identity = - node.operator === "=" && - (t.isIdentifier(left) || - t.isMemberExpression(left) || - t.isOptionalMemberExpression(left)) - ? valueOf(node.right) - : null; - for (const target of semanticWriteTargets(node.left)) - write(target, identity, mode); - values.set(node, identity); - } else if (t.isVariableDeclarator(node) && node.init != null) { - for (const target of semanticWriteTargets(node.id)) - write( - target, - t.isIdentifier(node.id) ? valueOf(node.init) : null, - mode, + yield* traverseJavaScriptValueAstSteps( + state.program, + state.erasedSyntaxNodes, + { + enter: (node, parent) => { + const outer = execution.at(-1) ?? "ordered"; + const mode: Execution = + outer === "deferred" || t.isFunction(node) || t.isClass(node) + ? "deferred" + : outer === "conditional" || + // Mark the whole operation: a later tree-visited RHS must not + // restore certainty after an earlier tree-visited pattern write. + hasDestructuringEvaluationOrder(node) || + t.isIfStatement(node) || + t.isSwitchStatement(node) || + t.isLoop(node) || + t.isTryStatement(node) || + t.isObjectPattern(node) || + t.isArrayPattern(node) || + (t.isLogicalExpression(parent) && parent.right === node) || + (t.isAssignmentPattern(parent) && parent.right === node) || + (t.isAssignmentExpression(parent) && + ["&&=", "||=", "??="].includes(parent.operator) && + parent.right === node) || + t.isOptionalCallExpression(parent) || + (t.isOptionalMemberExpression(parent) && + parent.property === node) || + (t.isConditionalExpression(parent) && parent.test !== node) + ? "conditional" + : "ordered"; + execution.push(mode); + if ( + t.isAssignmentExpression(node) && + commonJsExportName(node.left, state) !== undefined && + !isCommonJsModuleExports(node.left, state) + ) { + const receiver = + t.isMemberExpression(node.left) || + t.isOptionalMemberExpression(node.left) + ? node.left.object + : null; + captures.set( + node, + mode !== "ordered" + ? null + : receiver !== null && + isUnshadowedGlobal(receiver, state, "exports") + ? exportsIdentity + : publicIdentity, ); - } else if (t.isForOfStatement(node) || t.isForInStatement(node)) { - if (!t.isVariableDeclaration(node.left)) - for (const target of semanticWriteTargets(node.left)) - write(target, null, mode); - } else if ( - t.isUpdateExpression(node) || - t.isUnaryExpression(node, { operator: "delete" }) - ) { - if (t.isUnaryExpression(node, { operator: "delete" })) { - const name = commonJsExportName(node.argument, state); - if ( - name !== undefined && - !isCommonJsModuleExports(node.argument, state) && - (t.isMemberExpression(node.argument) || - t.isOptionalMemberExpression(node.argument)) - ) { - const receiver = isUnshadowedGlobal( - node.argument.object, - state, - "exports", - ) - ? exportsIdentity - : publicIdentity; - const fields = deletions.get(receiver) ?? new Map(); - fields.set(name, { - node, - mode: name === "*" && mode === "ordered" ? "conditional" : mode, - }); - deletions.set(receiver, fields); - } } - write(node.argument, null, mode); - } else if (isUnshadowedGlobal(node, state, "exports")) { - values.set(node, exportsIdentity); - } else if (isCommonJsModuleExports(node, state)) { - values.set(node, publicIdentity); - } else if ( - t.isObjectExpression(node) || - t.isArrayExpression(node) || - t.isFunctionExpression(node) || - t.isArrowFunctionExpression(node) || - t.isClassExpression(node) - ) { - values.set(node, node); - fresh.add(node); - } else if (t.isSequenceExpression(node)) { - const last = node.expressions.at(-1); - values.set(node, last === undefined ? null : valueOf(last)); - } + const descriptor = commonJsDefinedExport(node, state); + if (descriptor !== undefined) { + const receiver = isUnshadowedGlobal( + descriptor.receiver, + state, + "exports", + ) + ? exportsIdentity + : publicIdentity; + const names = describedFields.get(receiver) ?? new Set(); + names.add(descriptor.exportedName); + describedFields.set(receiver, names); + } + const defined = descriptor ?? commonJsAssignedExports(node, state); + if (defined !== undefined && t.isCallExpression(node)) + captures.set( + node, + mode !== "ordered" + ? null + : isUnshadowedGlobal(defined.receiver, state, "exports") + ? exportsIdentity + : publicIdentity, + ); + }, + exit: (node) => { + const mode = execution.pop() ?? "conditional"; + if (publicCallWrites.has(node)) { + if (mode === "deferred") deferredReplacement = true; + else publicIdentity = null; + } + if (t.isAssignmentExpression(node)) { + const left = unwrapJavaScriptExpression(node.left).node; + const identity = + node.operator === "=" && + (t.isIdentifier(left) || + t.isMemberExpression(left) || + t.isOptionalMemberExpression(left)) + ? valueOf(node.right) + : null; + for (const target of semanticWriteTargets(node.left)) + write(target, identity, mode); + values.set(node, identity); + } else if (t.isVariableDeclarator(node) && node.init != null) { + for (const target of semanticWriteTargets(node.id)) + write( + target, + t.isIdentifier(node.id) ? valueOf(node.init) : null, + mode, + ); + } else if (t.isForOfStatement(node) || t.isForInStatement(node)) { + if (!t.isVariableDeclaration(node.left)) + for (const target of semanticWriteTargets(node.left)) + write(target, null, mode); + } else if ( + t.isUpdateExpression(node) || + t.isUnaryExpression(node, { operator: "delete" }) + ) { + if (t.isUnaryExpression(node, { operator: "delete" })) { + const name = commonJsExportName(node.argument, state); + if ( + name !== undefined && + !isCommonJsModuleExports(node.argument, state) && + (t.isMemberExpression(node.argument) || + t.isOptionalMemberExpression(node.argument)) + ) { + const receiver = isUnshadowedGlobal( + node.argument.object, + state, + "exports", + ) + ? exportsIdentity + : publicIdentity; + const fields = deletions.get(receiver) ?? new Map(); + fields.set(name, { + node, + mode: name === "*" && mode === "ordered" ? "conditional" : mode, + }); + deletions.set(receiver, fields); + } + } + write(node.argument, null, mode); + } else if (isUnshadowedGlobal(node, state, "exports")) { + values.set(node, exportsIdentity); + } else if (isCommonJsModuleExports(node, state)) { + values.set(node, publicIdentity); + } else if ( + t.isObjectExpression(node) || + t.isArrayExpression(node) || + t.isFunctionExpression(node) || + t.isArrowFunctionExpression(node) || + t.isClassExpression(node) + ) { + values.set(node, node); + fresh.add(node); + } else if (t.isSequenceExpression(node)) { + const last = node.expressions.at(-1); + values.set(node, last === undefined ? null : valueOf(last)); + } + }, }, - }); + ); const effects = new Map(); for (const [node, receiver] of captures) { const status: ReceiverStatus = diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 4201e131b0..63f8374464 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -6,7 +6,7 @@ import { semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAstSteps } from "./javascriptSemanticTraversal.js"; /** Match Electron's supported module entry points. */ export const ELECTRON_MODULE = @@ -60,6 +60,7 @@ export interface JavaScriptIntrinsicInvocation { */ export function* collectJavaScriptIdentityWritesSteps( program: t.Program, + erasedSyntaxNodes: WeakSet, origin: ( root: t.Node, kind: "binding" | "member" | "opaque-call", @@ -93,7 +94,7 @@ export function* collectJavaScriptIdentityWritesSteps( "setPrototypeOf", ]); const keyedMutators = new Set(["set", "deleteProperty", "defineProperty"]); - yield* traverseJavaScriptAstSteps(program, { + yield* traverseJavaScriptValueAstSteps(program, erasedSyntaxNodes, { enter: (node) => { if ( t.isCallExpression(node) || diff --git a/src/domain/javascript/javascriptModuleDeclaration.ts b/src/domain/javascript/javascriptModuleDeclaration.ts index 2e29ba9324..b071139030 100644 --- a/src/domain/javascript/javascriptModuleDeclaration.ts +++ b/src/domain/javascript/javascriptModuleDeclaration.ts @@ -27,10 +27,8 @@ export const isTypeOnlyExportDeclaration = ( ): boolean => node.exportKind === "type" || (t.isExportNamedDeclaration(node) && - node.declaration !== null && - (t.isTSInterfaceDeclaration(node.declaration) || - t.isTSTypeAliasDeclaration(node.declaration) || - t.isTSDeclareFunction(node.declaration))); + node.declaration != null && + isErasedJavaScriptDeclaration(node.declaration)); /** * Runtime external-module target for a value import-equals declaration; diff --git a/src/domain/javascript/javascriptRouteScan.ts b/src/domain/javascript/javascriptRouteScan.ts index 6e9937a29e..32a4193597 100644 --- a/src/domain/javascript/javascriptRouteScan.ts +++ b/src/domain/javascript/javascriptRouteScan.ts @@ -17,7 +17,7 @@ import { STATIC_ROUTE_CALL_NAMES, stringValue, } from "./javascriptStaticAnalysisHelpers.js"; -import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAstSteps } from "./javascriptSemanticTraversal.js"; import { semanticStableInitializer, type JavaScriptBindingIndex, @@ -312,27 +312,33 @@ export class JavaScriptRouteScan { return; if (this.#queue(node)) requests.push({ node, site }); }; - yield* traverseJavaScriptAstSteps(root, { - skip: () => this.#result.capacity !== null, - enter: (node) => { - if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { - const parts = calleeName(node.callee).split("."); - if (navigationRecognition(parts.at(-1)!, parts.at(-2) ?? "") !== true) - return; - const first = node.arguments[0]; - if ( - first !== undefined && - !t.isSpreadElement(first) && - !t.isArgumentPlaceholder(first) + yield* traverseJavaScriptValueAstSteps( + root, + this.index.bindings.erasedSyntaxNodes, + { + skip: () => this.#result.capacity !== null, + enter: (node) => { + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { + const parts = calleeName(node.callee).split("."); + if ( + navigationRecognition(parts.at(-1)!, parts.at(-2) ?? "") !== true + ) + return; + const first = node.arguments[0]; + if ( + first !== undefined && + !t.isSpreadElement(first) && + !t.isArgumentPlaceholder(first) + ) + remember(first, node); + } else if ( + t.isObjectProperty(node) && + semanticStaticPropertyName(node.key, node.computed) === "href" ) - remember(first, node); - } else if ( - t.isObjectProperty(node) && - semanticStaticPropertyName(node.key, node.computed) === "href" - ) - remember(node.value, node); + remember(node.value, node); + }, }, - }); + ); if (requests.length === 0 || this.#result.capacity !== null) return; yield* this.index.indexMemberMutationFactsSteps(); for (const { node, site } of requests) { @@ -384,87 +390,93 @@ export class JavaScriptRouteScan { structured: true, reported: true, }; - yield* traverseJavaScriptAstSteps(value, { - skip: (node) => - this.#result.capacity !== null || - isRouteValueBoundary(node, true) || - seen.has(node), - enter: (node, parent) => { - if (node !== value) { - if (!this.#queue(node)) return; - seen.add(node); - } - this.#dynamicValues.set(node, owner); - let overwritten = false; - if ( - t.isObjectProperty(node) && - !isObjectPrototypeSetter(node) && - t.isObjectExpression(parent) - ) { - const key = semanticStaticPropertyKey(node.key, node.computed); - if (key !== null) { - const fields = this.#fields(parent); - const effective = fields.values.get(key); - // Unknown spreads/keys may preserve earlier data. Only a known - // final slot or method proves that this reference was discarded. - overwritten = - fields.dynamicNames.has(key) || - (effective !== undefined && effective !== node); + yield* traverseJavaScriptValueAstSteps( + value, + this.index.bindings.erasedSyntaxNodes, + { + skip: (node) => + this.#result.capacity !== null || + isRouteValueBoundary(node, true) || + seen.has(node), + enter: (node, parent) => { + if (node !== value) { + if (!this.#queue(node)) return; + seen.add(node); } - } - const nonValue = - overwritten || - t.isTSType(node) || - !isNavigationDataEdge(node, parent) || - (parent !== null && nonValueReferences.has(parent)); - if (nonValue) nonValueReferences.add(node); - if ( - !nonValue && - (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) - ) { - for (const selected of this.#memberValues(node)) admit(selected); - } - if ( - nonValue || - !t.isIdentifier(node) || - (parent !== null && !t.isReferenced(node, parent)) - ) - return; - const declared = this.index.bindingForReferenceAt(node); - if (declared === undefined) return; - for (const sources of [ - declared.initializers, - declared.referenceInitializers, - ]) - for (const source of sources) if (!this.#queue(source.node)) return; - // Different reads may select different values; unresolved order - // retains its possible sources instead of becoming absence. - const binding = semanticBindingAtRead( - declared, - node, - this.index.bindings.parentsByNode, - ).binding; - for (const initializers of [ - binding.initializers, - binding.referenceInitializers, - ]) - for (const initializer of initializers) { - if ( - semanticReferenceSourcesInactive( - initializer, - this.index.bindings, + this.#dynamicValues.set(node, owner); + let overwritten = false; + if ( + t.isObjectProperty(node) && + !isObjectPrototypeSetter(node) && + t.isObjectExpression(parent) + ) { + const key = semanticStaticPropertyKey(node.key, node.computed); + if (key !== null) { + const fields = this.#fields(parent); + const effective = fields.values.get(key); + // Unknown spreads/keys may preserve earlier data. Only a known + // final slot or method proves that this reference was discarded. + overwritten = + fields.dynamicNames.has(key) || + (effective !== undefined && effective !== node); + } + } + const nonValue = + overwritten || + t.isTSType(node) || + !isNavigationDataEdge(node, parent) || + (parent !== null && nonValueReferences.has(parent)); + if (nonValue) nonValueReferences.add(node); + if ( + !nonValue && + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + ) { + for (const selected of this.#memberValues(node)) admit(selected); + } + if ( + nonValue || + !t.isIdentifier(node) || + (parent !== null && !t.isReferenced(node, parent)) + ) + return; + const declared = this.index.bindingForReferenceAt(node); + if (declared === undefined) return; + for (const sources of [ + declared.initializers, + declared.referenceInitializers, + ]) + for (const source of sources) + if (!this.#queue(source.node)) return; + // Different reads may select different values; unresolved order + // retains its possible sources instead of becoming absence. + const binding = semanticBindingAtRead( + declared, + node, + this.index.bindings.parentsByNode, + ).binding; + for (const initializers of [ + binding.initializers, + binding.referenceInitializers, + ]) + for (const initializer of initializers) { + if ( + semanticReferenceSourcesInactive( + initializer, + this.index.bindings, + ) ) - ) - continue; - for (const selected of this.#initializerValues(initializer)) - admit(selected); - } + continue; + for (const selected of this.#initializerValues(initializer)) + admit(selected); + } + }, }, - }); + ); } } inspect(node: t.Node, parent: t.Node | null): void { + if (this.index.bindings.erasedSyntaxNodes.has(node)) return; const expressionParent = parent === null ? null : (this.#expressionParents.get(parent) ?? parent); if ( diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 5142c97879..fbf1efd884 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -600,6 +600,7 @@ export function* indexJavaScriptAmbientWritesSteps( }); yield* collectJavaScriptIdentityWritesSteps( program, + state.erasedSyntaxNodes, (root, kind, writeSite) => { if (kind === "opaque-call") return []; observeWriteSite?.(writeSite); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.identity_boundaries.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.identity_boundaries.test.ts index dbe9c536f1..eb42369ebc 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.identity_boundaries.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.identity_boundaries.test.ts @@ -20,10 +20,29 @@ it("uses parser roles for TS, import, and meta names while retaining computed re .map(({ name }) => name), ).toEqual(["field", "field"]); expect(ir.references.filter(({ name }) => name === "computed")).toHaveLength( - 3, + 2, ); }); +it("distinguishes erased type queries from evaluated typeof expressions", () => { + const ir = analyzeJavaScriptSemantics(` + const marker = 1; + type Shape = typeof marker; + function describe() { type Local = typeof marker; } + const kind = typeof marker; + `); + expect( + ir.references + .filter(({ name }) => name === "marker") + .map(({ role, resolution, location }) => [ + role, + resolution, + location.start.line, + ]), + ).toEqual([["read", "resolved", 5]]); + expect(ir.closureCaptures).toEqual([]); +}); + 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; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index 97a4a7acaf..f79d102ae8 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -926,3 +926,32 @@ describe("results of methods on an escaped receiver", () => { }); }, 30000); }); + +it.each([ + ["named", "@dec abstract slot: { mode: string };"], + ["computed literal", '@dec abstract ["slot"]: { mode: string };'], +])( + "retains the possible inherited alias through a decorated abstract %s field", + (_name, field) => { + // An emit that erases the field lets this write reach Base.slot. The + // analysis must still invalidate shared even if another emit shadows it. + const setup = ` + function dec(...args: unknown[]) {} + const shared = { mode: "initial" }; + class Base { slot = shared; } + abstract class Derived extends Base { ${field} } + const Constructor = Derived as unknown as new () => Base; + `; + expect(resultValue(`${setup} return shared.mode;`)).toEqual({ + status: "literal", + value: "initial", + }); + expect( + resultValue(` + ${setup} + new Constructor().slot.mode = "updated"; + return shared.mode; + `), + ).toMatchObject({ status: "unknown" }); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts index 7e5cd13177..ad050270b8 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts @@ -325,12 +325,8 @@ describe("JavaScript semantic analysis: class member keys", () => { marker(value?: number) {} } `); - // Only the computed abstract key evaluates the outer binding. - expect( - ir.references - .filter(({ name }) => name === "marker") - .map(({ role, location }) => `${role}@${location.start.line}`), - ).toEqual(["read@6"]); + // Abstract signatures, including computed keys, are erased. + expect(ir.references.filter(({ name }) => name === "marker")).toEqual([]); }); it("does not capture an outer binding through a noncomputed method name", () => { diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 1fbce52664..56f72c8122 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -58,7 +58,9 @@ import { } from "./javascriptSemanticState.js"; import { completeSemanticSteps, + isErasedJavaScriptValueChild, traverseJavaScriptAstSteps, + traverseJavaScriptValueAstSteps, } from "./javascriptSemanticTraversal.js"; import { semanticRequireOrigin, @@ -690,7 +692,7 @@ function* classifyDefinitionReceiversSteps( ); if (aliasFact !== undefined) facts.set(node.start ?? -1, aliasFact); }; - yield* traverseJavaScriptAstSteps(program, { + yield* traverseJavaScriptValueAstSteps(program, state.erasedSyntaxNodes, { enter: (node) => { classifyRpcRoot(node); classifyOpenReceiver(node); @@ -897,6 +899,7 @@ export function* indexJavaScriptElectronIdentitySteps( }; yield* collectJavaScriptIdentityWritesSteps( program, + state.erasedSyntaxNodes, (node, kind) => { if (kind === "opaque-call") return [interop.opaqueMutationPath(node)]; const { root, members } = electronMemberPath(node); @@ -922,7 +925,7 @@ export function* indexJavaScriptElectronIdentitySteps( const intactExport = (path: readonly (string | null)[]): boolean => ELECTRON_MODULE.test(path[0] ?? "") && !writes.covers(["electron", ...path.slice(1)]); - yield* traverseJavaScriptAstSteps(program, { + yield* traverseJavaScriptValueAstSteps(program, state.erasedSyntaxNodes, { enter: (node) => { if ( !t.isCallExpression(node) && @@ -1052,6 +1055,7 @@ const createState = ( getterResourceLimits: new Set(), returnedReferenceWidenings: new Set(), parentsByNode: new WeakMap(), + erasedSyntaxNodes: new WeakSet(), scopes: [root], scopesById: new Map([[root.scopeId, root]]), scopeByNode: new WeakMap([[program, root]]), @@ -1124,6 +1128,13 @@ function* collectDefinitionsSteps( yield* traverseJavaScriptAstSteps(program, { enter: (node, parent, readAncestors) => { if (parent !== null) state.parentsByNode.set(node, parent); + if ( + (parent !== null && state.erasedSyntaxNodes.has(parent)) || + isErasedJavaScriptValueChild(node, parent) + ) { + state.erasedSyntaxNodes.add(node); + return; + } if ( t.isWithStatement(node) || t.isForInStatement(node) || @@ -1438,7 +1449,7 @@ function* indexPrototypeUncertaintySteps( "defineProperties", "setPrototypeOf", ]); - yield* traverseJavaScriptAstSteps(program, { + yield* traverseJavaScriptValueAstSteps(program, state.erasedSyntaxNodes, { enter: (node, parent) => { const write = semanticWriteSiteTarget(node); if (write !== null) { diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 9bdff8ac9e..591a0f4e36 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -15,7 +15,7 @@ import { type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { @@ -100,7 +100,7 @@ export const collectJavaScriptSemanticAsyncEffects = ( const callableIds = new Set(callables.map(({ callableId }) => callableId)); const callableStack = createSemanticCallableIdStack(callableIds); const ancestors: t.Node[] = []; - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, { enter: (node, parent) => { callableStack.enter(node, parent); if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 1879fa47b6..ef85b6a1b3 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -26,7 +26,7 @@ import { parameterBindings, resolveLocalCallables, } from "./javascriptSemanticCallResolution.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { semanticCallableResultKind } from "./javascriptSemanticCallableResults.js"; import { assignedSemanticResultBindings } from "./javascriptSemanticDataEffectHelpers.js"; @@ -93,7 +93,7 @@ export const collectJavaScriptSemanticCalls = ( const callableStack = createSemanticCallableStack((callableId) => callableById.get(callableId), ); - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, { enter: (node, parent) => { callableStack.enter(node, parent); const owner = enclosingFunction(callableStack.callables); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index b2ad8bbe92..7d80c29297 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -25,7 +25,7 @@ import { type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { @@ -94,7 +94,7 @@ const collectSpawns = ( ); const callableStack = createSemanticCallableIdStack(admittedCallables); const ancestors: t.Node[] = []; - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, { enter: (node, parent) => { callableStack.enter(node, parent); if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { @@ -179,7 +179,7 @@ const collectInteractions = ( spawnByNode, spawnsByBinding, }; - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, { enter: (node, parent) => { callableStack.enter(node, parent); if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index ccfd619b64..d349e7e491 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -31,7 +31,7 @@ import { type JavaScriptSemanticMemberEffect, type JavaScriptSemanticRequiredModuleEffect, } from "./javascriptSemanticState.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { readExactJavaScriptLiteral, semanticMemberChain, @@ -901,7 +901,7 @@ export const traverseDataEffects = ( context.callableStack, ); context.ancestors.length = 0; - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, context.state.erasedSyntaxNodes, { enter: (node, parent) => { callableStack.enter(node, parent); visit(node, parent); diff --git a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts index 4b121309c1..b523f7d10d 100644 --- a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts @@ -16,7 +16,7 @@ import { collectJavaScriptSemanticResources } from "./javascriptSemanticResource import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; -import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAstSteps } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; @@ -94,6 +94,7 @@ export function* collectJavaScriptDerivedSemanticsSteps( yield; const assignmentFlows = yield* collectAssignmentFlowsSteps( program, + state, callables, ); const functionFingerprints = @@ -126,13 +127,14 @@ export function* collectJavaScriptDerivedSemanticsSteps( /** Keep assignment syntax in the IR so graph consumers never reparse source. */ function* collectAssignmentFlowsSteps( program: t.Program, + state: JavaScriptSemanticAnalysisState, callables: readonly JavaScriptSemanticCallable[], ): Generator { const flows: JavaScriptSemanticIr["assignmentFlows"][number][] = []; const stack = createSemanticCallableIdStack( new Set(callables.map((item) => item.callableId)), ); - yield* traverseJavaScriptAstSteps(program, { + yield* traverseJavaScriptValueAstSteps(program, state.erasedSyntaxNodes, { enter: (node, parent) => { stack.enter(node, parent); if (!t.isAssignmentExpression(node)) return; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 531da49942..e0a2b3ffad 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -80,7 +80,7 @@ import { } from "./javascriptSemanticPropertyPaths.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { - traverseJavaScriptAstSteps, + traverseJavaScriptValueAstSteps, childNodes, } from "./javascriptSemanticTraversal.js"; import { @@ -1482,7 +1482,7 @@ export function* collectSemanticMemberMutationsSteps( ); }; const writeOnlyMembers = new WeakSet(); - yield* traverseJavaScriptAstSteps(program, { + yield* traverseJavaScriptValueAstSteps(program, state.erasedSyntaxNodes, { enter: (node, parent, readAncestors) => { const writeTarget = semanticWriteSiteTarget(node); if (writeTarget !== null && !semanticWriteSiteReadsFirst(node)) diff --git a/src/domain/javascript/javascriptSemanticObjects.ts b/src/domain/javascript/javascriptSemanticObjects.ts index bce7e997fa..da3d9527b3 100644 --- a/src/domain/javascript/javascriptSemanticObjects.ts +++ b/src/domain/javascript/javascriptSemanticObjects.ts @@ -16,7 +16,7 @@ import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; /** Recover bounded static property reads, writes, spreads, and destructuring. */ @@ -33,7 +33,7 @@ export const collectJavaScriptSemanticObjects = ( output, writeTargets: new Map(), }; - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, { enter: (node, parent) => { callableStack.enter(node, parent); const ownerCallableId = callableStack.callables.at(-1) ?? null; diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 0a27bacf24..e5e3217fe6 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -16,7 +16,7 @@ import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { completeSemanticSteps, - traverseJavaScriptAstSteps, + traverseJavaScriptValueAstSteps, } from "./javascriptSemanticTraversal.js"; import { evaluateSemanticBinding, @@ -32,6 +32,7 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { + isErasedJavaScriptDeclaration, isTypeOnlyExportDeclaration, isTypeOnlyExportSpecifier, isTypeOnlyImportDeclaration, @@ -97,6 +98,16 @@ export const collectSemanticModuleLink = ( CommonJsExportReceiverEffect >, ): void => { + // Import/export declarations also describe erased type links. Their nested + // expressions do not establish runtime require or CommonJS export links. + if ( + state.erasedSyntaxNodes.has(node) && + !t.isTSImportEqualsDeclaration(node) && + !t.isImportDeclaration(node) && + !t.isExportNamedDeclaration(node) && + !t.isExportAllDeclaration(node) + ) + return; if (t.isTSImportEqualsDeclaration(node)) collectImportEquals(node, state); else if (t.isImportDeclaration(node)) collectImports(node, state); else if (t.isExportAllDeclaration(node)) @@ -117,7 +128,9 @@ export const collectSemanticModuleLink = ( addModuleLink( state, { - kind: "export", + kind: isErasedJavaScriptDeclaration(node.declaration) + ? "type-import" + : "export", specifier: null, importedName: null, localName: defaultExportLocalName(node.declaration), @@ -179,7 +192,7 @@ export function* collectSemanticReferencesSteps( ): Generator { const output: JavaScriptSemanticReference[] = []; const seen = new Set(); - yield* traverseJavaScriptAstSteps(program, { + yield* traverseJavaScriptValueAstSteps(program, state.erasedSyntaxNodes, { enter: (node, parent, readAncestors) => { if ( !t.isIdentifier(node) || diff --git a/src/domain/javascript/javascriptSemanticPromises.ts b/src/domain/javascript/javascriptSemanticPromises.ts index 476d1c1feb..250285e458 100644 --- a/src/domain/javascript/javascriptSemanticPromises.ts +++ b/src/domain/javascript/javascriptSemanticPromises.ts @@ -16,7 +16,7 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; @@ -111,7 +111,7 @@ const collectCandidates = ( ); const callableStack = createSemanticCallableIdStack(admittedCallables); const ancestors: t.Node[] = []; - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, { enter: (node, parent) => { callableStack.enter(node, parent); const details = promiseCandidateDetails(node, state); diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index b75b1fa6a6..e87a91178d 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -7,7 +7,7 @@ import { type SemanticCapturedSelection, } from "./javascriptSemanticProjectedSources.js"; import * as t from "@babel/types"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js"; import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; @@ -1404,7 +1404,7 @@ const writesModuleRequire = ( // Only a retained module identity can revoke the host delegate. Escaping // an unrelated local table is not evidence that it became the host module. isCommonJsModuleObject(node, state, false); - traverseJavaScriptAst(state.program, { + traverseJavaScriptValueAst(state.program, state.erasedSyntaxNodes, { enter: (node) => { const target = t.isAssignmentExpression(node) ? node.left diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index b9eaf40491..1c85fcb456 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -135,6 +135,8 @@ export interface JavaScriptSemanticAnalysisState { /** Callee sites whose possible local returns needed a broader projection. */ readonly returnedReferenceWidenings: Set; readonly parentsByNode: WeakMap; + /** TypeScript syntax omitted from JavaScript value evaluation. */ + readonly erasedSyntaxNodes: WeakSet; readonly scopes: JavaScriptSemanticScopeState[]; readonly scopesById: Map; /** Scope boundaries and descendant scopes resolved on demand. */ diff --git a/src/domain/javascript/javascriptSemanticTraversal.ts b/src/domain/javascript/javascriptSemanticTraversal.ts index 581d7d4645..32fba040ec 100644 --- a/src/domain/javascript/javascriptSemanticTraversal.ts +++ b/src/domain/javascript/javascriptSemanticTraversal.ts @@ -1,4 +1,49 @@ import * as t from "@babel/types"; +import { + isErasedJavaScriptDeclaration, + isTypeOnlyExportDeclaration, + isTypeOnlyExportSpecifier, + isTypeOnlyImportDeclaration, + isTypeOnlyImportSpecifier, +} from "./javascriptModuleDeclaration.js"; + +/** Whether this child and its descendants contribute no JavaScript value. */ +export const isErasedJavaScriptValueChild = ( + node: t.Node, + parent: t.Node | null, +): boolean => { + if ( + isErasedJavaScriptDeclaration(node) || + t.isTSType(node) || + t.isTSTypeElement(node) || + t.isTSDeclareMethod(node) || + t.isTSTypeAnnotation(node) || + t.isTSTypeParameterDeclaration(node) || + t.isTSTypeParameterInstantiation(node) || + t.isTSClassImplements(node) || + t.isTSInterfaceHeritage(node) || + t.isTSInterfaceBody(node) || + (t.isImportDeclaration(node) && isTypeOnlyImportDeclaration(node)) || + ((t.isExportNamedDeclaration(node) || t.isExportAllDeclaration(node)) && + isTypeOnlyExportDeclaration(node)) || + (t.isImportSpecifier(node) && isTypeOnlyImportSpecifier(node)) || + (t.isExportSpecifier(node) && isTypeOnlyExportSpecifier(node)) || + (t.isTSImportEqualsDeclaration(node) && node.importKind === "type") + ) + return true; + // Undecorated declared or abstract fields erase their keys and annotations. + // Decorators and a decorated field's computed key may execute at definition. + return ( + t.isClassProperty(parent) && + (parent.declare === true || parent.abstract === true) && + !t.isDecorator(node) && + !( + node === parent.key && + parent.computed === true && + (parent.decorators?.length ?? 0) > 0 + ) + ); +}; /** * Visit Babel AST nodes in deterministic source-tree order, excluding skipped subtrees. @@ -15,6 +60,30 @@ export const traverseJavaScriptAst = ( visitor: JavaScriptAstVisitor, ): void => completeSemanticSteps(traverseJavaScriptAstSteps(root, visitor)); +/** Visit only syntax retained as JavaScript values after TypeScript erasure. */ +export const traverseJavaScriptValueAst = ( + root: t.Node, + erasedSyntaxNodes: WeakSet, + visitor: JavaScriptAstVisitor, +): void => + completeSemanticSteps( + traverseJavaScriptValueAstSteps(root, erasedSyntaxNodes, visitor), + ); + +/** Cooperative value-only view of the same deterministic AST traversal. */ +export function* traverseJavaScriptValueAstSteps( + root: t.Node, + erasedSyntaxNodes: WeakSet, + visitor: JavaScriptAstVisitor, +): Generator { + if (erasedSyntaxNodes.has(root)) return; + yield* traverseJavaScriptAstSteps(root, { + ...visitor, + skip: (node) => + erasedSyntaxNodes.has(node) || visitor.skip?.(node) === true, + }); +} + /** Callbacks for one deterministic AST traversal. */ export interface JavaScriptAstVisitor { /** Omit a child and its subtree without entering or exiting it. */ diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 5fbf0ac127..530c00605c 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -113,7 +113,10 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( isElectronRendererLoad(callee, site, findings, index), source, accumulator, - vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program), + vitePreloadDependencyTable: indexVitePreloadDependencyTable( + file.program, + bindings.erasedSyntaxNodes, + ), openReceiverFacts: receivers.openReceivers, electronBindings: electronIdentity.bindings, electronExportIntact: electronIdentity.intactExport, @@ -167,7 +170,8 @@ function* traverseStaticSourceSteps( enter: (node, parent) => { findings.accumulator.visitedNodes += 1; routeScan.inspect(node, parent); - inspectNode(node, findings, bindings); + if (!bindings.erasedSyntaxNodes.has(node)) + inspectNode(node, findings, bindings); return undefined; }, }); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 4a148f0d34..ea46a0f474 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -1,6 +1,7 @@ import * as t from "@babel/types"; import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; +import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; import { htmlUrlText, stripQueryAndFragment } from "../artifactPathSyntax.js"; import { sanitizeEndpointCandidate } from "../browserObservation.js"; @@ -397,9 +398,10 @@ type ViteMapDepsFunction = /** Index the canonical Vite helper's cache initializer from parsed syntax. */ export const indexVitePreloadDependencyTable = ( program: t.Program, + erasedSyntaxNodes: WeakSet, ): readonly (string | undefined)[] | null => { const helpers: ViteMapDepsFunction[] = []; - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, erasedSyntaxNodes, { enter: (node) => { if (t.isFunctionDeclaration(node) && node.id?.name === "__vite__mapDeps") helpers.push(node); @@ -422,7 +424,7 @@ export const indexVitePreloadDependencyTable = ( : mapParameter; if (!t.isIdentifier(mapIdentifier)) return []; const tables: t.ArrayExpression[] = []; - traverseJavaScriptAst(helper, { + traverseJavaScriptValueAst(helper, erasedSyntaxNodes, { skip: (node) => t.isFunction(node), enter: (node) => { if ( @@ -454,17 +456,13 @@ export const indexVitePreloadDependencyTable = ( : stringValue(element), ); let escapedOrMutated = false; - traverseJavaScriptAst(program, { + traverseJavaScriptValueAst(program, erasedSyntaxNodes, { skip: (node) => node === helper, - enter: (node, parent) => { - if (!t.isIdentifier(node, { name: "__vite__mapDeps" })) return; + enter: (node, parent, readAncestors) => { if ( - (parent === helper && - t.isFunctionDeclaration(parent) && - parent.id === node) || - (t.isVariableDeclarator(parent) && - parent.init === helper && - parent.id === node) + !t.isIdentifier(node, { name: "__vite__mapDeps" }) || + parent === null || + semanticIdentifierRole(node, parent, readAncestors) === null ) return; if ( diff --git a/src/domain/javascript/javascriptTypeErasure.test.ts b/src/domain/javascript/javascriptTypeErasure.test.ts new file mode 100644 index 0000000000..7adadd7f57 --- /dev/null +++ b/src/domain/javascript/javascriptTypeErasure.test.ts @@ -0,0 +1,64 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it("keeps declared exports as source type links without runtime values", () => { + const ir = analyzeJavaScriptSemantics(` + export declare const token: number; + export declare class Shape {} + export default interface DefaultShape {} + `); + expect(ir.coverage.status).not.toBe("failed"); + expect(ir.moduleLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "type-import", + localName: "token", + exportedName: "token", + }), + expect.objectContaining({ + kind: "type-import", + localName: "Shape", + exportedName: "Shape", + }), + expect.objectContaining({ + kind: "type-import", + exportedName: "default", + }), + ]), + ); + expect(ir.moduleLinks.filter(({ kind }) => kind === "export")).toEqual([]); +}); + +it("retains computed keys and decorators only when declared or abstract fields are decorated", () => { + const ir = analyzeJavaScriptSemantics(` + const key = Symbol(); + function dec(...args: unknown[]) {} + class Declared { + declare [key]: string; + } + abstract class AbstractShape { + abstract [key]: string; + } + class DecoratedDeclared { + @dec declare [key]: string; + } + abstract class DecoratedAbstract { + @dec abstract [key]: string; + } + `); + expect(ir.coverage.status).not.toBe("failed"); + expect(ir.references.filter(({ name }) => name === "key")).toHaveLength(2); + expect(ir.references.filter(({ name }) => name === "dec")).toHaveLength(2); +}); + +it("does not promote a type literal to a route candidate beside a runtime route", () => { + const analysis = analyzeJavaScriptStaticSource(` + createBrowserRouter([{ path: "/admin", Component: Admin }]); + type Ghost = "/admin/ghost"; + `); + expect(analysis.routes?.candidates.map(({ path }) => path)).toEqual([ + "/admin", + ]); +}); diff --git a/src/domain/javascript/javascriptVitePreloadDependencies.test.ts b/src/domain/javascript/javascriptVitePreloadDependencies.test.ts index f22e81b052..681af59318 100644 --- a/src/domain/javascript/javascriptVitePreloadDependencies.test.ts +++ b/src/domain/javascript/javascriptVitePreloadDependencies.test.ts @@ -84,6 +84,29 @@ describe("Vite preload dependency recovery", () => { ).toEqual(["./original.js"]); }); + it.each([ + ["type DependencyHelper = typeof __vite__mapDeps;", "./original.js"], + ["const labels = { __vite__mapDeps() {} };", "./original.js"], + ["const labels = { [__vite__mapDeps]: true };", null], + ["const dependencyTable = __vite__mapDeps.f;", null], + ])( + "distinguishes an erased type query from a runtime helper escape: %s", + (read, expected) => { + const analysis = analyzeJavaScriptStaticSource(` + const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies = + (map.f || (map.f = ["./original.js"]))) => + indexes.map((index) => dependencies[index]); + ${read} + __vite__mapDeps([0]); + `); + expect( + analysis.references + .filter(({ kind }) => kind === "dynamic-import") + .map(({ specifier }) => specifier), + ).toEqual([expected]); + }, + ); + it("keeps table slots from a spread onward unknown", () => { const analysis = analyzeJavaScriptStaticSource(` const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies = diff --git a/tests/boundary/cli/javascriptTypeErasure.test.ts b/tests/boundary/cli/javascriptTypeErasure.test.ts new file mode 100644 index 0000000000..7a56b4e54f --- /dev/null +++ b/tests/boundary/cli/javascriptTypeErasure.test.ts @@ -0,0 +1,77 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +for (const surface of ["CLI", "stdio MCP"] as const) + cliTest( + `excludes erased TypeScript reads and handler captures through ${surface}`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-typescript-erasure-"); + await writeFixtureFiles(root, { + "main.cts": [ + 'import type { Shape } from "./shape";', + 'const { contextBridge, ipcRenderer } = require("electron");', + "const marker = 1;", + "type Description = typeof marker;", + "function describe() { type Local = typeof marker; }", + "const kind = typeof marker;", + 'const CHANNEL = "ready";', + "function register(value: Shape) {", + " const handler = () => {", + " type Local = typeof value;", + " interface Method { value(): void; }", + " };", + " ipcRenderer.on(CHANNEL, handler);", + "}", + 'contextBridge.exposeInMainWorld("api", { on: () => register("unused") });', + ].join("\n"), + "shape.d.ts": "export type Shape = string;", + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics).toMatchObject({ + parsed_javascript_files: 2, + parse_failures: 0, + }); + const graph = result.semantic_graph; + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const markerReads = graph.relations.filter( + (relation) => + relation.relation === "reads" && + nodes.get(relation.target_node_id)?.label === "marker", + ); + expect(markerReads).toHaveLength(1); + expect(markerReads[0]?.evidence.location).toMatchObject({ + available: true, + value: { kind: "source-range", source: "main.cts", start: { line: 6 } }, + }); + expect( + graph.relations.filter( + (relation) => + relation.relation === "captures" && + nodes.get(relation.source_node_id)?.label === "value", + ), + ).toEqual([]); + const channels = result.graph.nodes + .filter((node) => node.kind === "ipc-channel") + .flatMap((node) => node.observations); + expect(channels).toHaveLength(1); + expect(channels[0]?.properties).toMatchObject({ + channel: "ready", + resolution: "literal", + }); + expect( + result.graph.nodes.flatMap((node) => node.observations), + ).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + semantic_role: "source-module", + path: "main.cts", + type_relationships: 1, + }), + }), + ); + }, + );