From 89006609f1c09f452391a6b77cf2a64368a0b920 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:00:41 +0800 Subject: [PATCH] fix(javascript): separate erased types from runtime semantics Record erasure during definition indexing and reuse it across runtime references, captures, effects, module links, routes, Electron and Vite. Preserve source type links and emitted decorator and computed-key effects; keep actual runtime reads and escapes conservative. Validation: 1,647 focused tests and 9 documentation tests passed, with build, static and documentation gates. The inherited reference-lifetimes stress file exhausts a 512 MiB worker on both unmodified 18451e479 and this change; that file remains unverified. TypeScript 7.0.2 emit was inspected for ES2022 and ES2018 without executing emitted code. --- docs/javascript-application-workflows.md | 9 +- .../electronBundledRequireIdentity.test.ts | 66 ++++ .../electronWrappedCallPaths.test.ts | 23 ++ .../javascript/electronWrappedCallPaths.ts | 53 ++-- .../javascriptBootstrapIntegrity.ts | 21 +- .../javascriptBundledRequireOrigin.ts | 5 +- .../javascriptCommonJsExportReceivers.ts | 296 +++++++++--------- .../javascriptElectronMemberWrites.ts | 5 +- .../javascript/javascriptModuleDeclaration.ts | 6 +- src/domain/javascript/javascriptRouteScan.ts | 200 ++++++------ .../javascriptSemanticAmbientWrites.ts | 1 + ...manticAnalysis.identity_boundaries.test.ts | 21 +- ...tSemanticAnalysis.member_mutations.test.ts | 29 ++ ...vascriptSemanticAnalysis.structure.test.ts | 8 +- .../javascript/javascriptSemanticAnalysis.ts | 17 +- .../javascriptSemanticAsyncEffects.ts | 4 +- .../javascript/javascriptSemanticCalls.ts | 4 +- .../javascriptSemanticChildProcesses.ts | 6 +- .../javascriptSemanticDataEffectHelpers.ts | 4 +- .../javascriptSemanticDerivedAnalysis.ts | 6 +- .../javascriptSemanticMemberMutations.ts | 4 +- .../javascript/javascriptSemanticObjects.ts | 4 +- .../javascriptSemanticProjection.ts | 19 +- .../javascript/javascriptSemanticPromises.ts | 4 +- .../javascriptSemanticRequireOrigin.ts | 4 +- .../javascript/javascriptSemanticState.ts | 2 + .../javascript/javascriptSemanticTraversal.ts | 69 ++++ .../javascript/javascriptStaticAnalysis.ts | 8 +- .../javascriptStaticAnalysisCalls.ts | 22 +- .../javascript/javascriptTypeErasure.test.ts | 64 ++++ .../javascriptVitePreloadDependencies.test.ts | 23 ++ .../cli/javascriptTypeErasure.test.ts | 77 +++++ 32 files changed, 756 insertions(+), 328 deletions(-) create mode 100644 src/domain/javascript/javascriptTypeErasure.test.ts create mode 100644 tests/boundary/cli/javascriptTypeErasure.test.ts 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, + }), + }), + ); + }, + );