From 633e4ff43435480ededb5566754e16fb13b4582f Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 16 Oct 2026 01:04:24 +0800 Subject: [PATCH] fix(javascript): retain static paths through TypeScript wrappers (#3998) Normalize erased TypeScript wrappers through existing expression/string owners for static paths, call targets, collection/configuration receivers and global aliases. Preserve binding/mutation guards and explicit unknown targets, with CLI and application-boundary regressions. Original contribution by @ShuaixinHuang. Co-authored-by: ShuaixinHuang <1836656514@qq.com> --- .../javascript/javascriptSemanticAnalysis.ts | 37 ++-- ...scriptStaticAnalysisCalleeBoundary.test.ts | 167 ++++++++++++++++ .../javascriptStaticAnalysisHelpers.ts | 41 ++-- .../javascriptStaticPathResolution.test.ts | 120 +++++++++++- .../javascriptEndpointCandidateCli.test.ts | 48 ++++- .../javascriptApplicationTypeScript.test.ts | 180 ++++++++++++++++++ 6 files changed, 547 insertions(+), 46 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index a44914ab03..3749f2f732 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -300,25 +300,21 @@ function* classifyDefinitionReceiversSteps( if (client) roots.add(root); }; const classifyOpenReceiver = (node: t.Node): void => { - if ( - (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) || - (!t.isMemberExpression(node.callee) && - !t.isOptionalMemberExpression(node.callee)) - ) + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; + const callee = unwrapJavaScriptExpression(node.callee).node; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return; - const method = semanticStaticPropertyKey( - node.callee.property, - node.callee.computed, - ); + const object = unwrapJavaScriptExpression(callee.object).node; + const method = semanticStaticPropertyKey(callee.property, callee.computed); if (method === "get") { - if (isConfigurationReceiver(state, node.callee.object)) + if (isConfigurationReceiver(state, object)) facts.set(node.start ?? -1, "configuration"); - else if (isExtensionStateMember(node.callee.object)) + else if (isExtensionStateMember(object)) extensionStateReads.push(node.start ?? -1); return; } - if (method !== "open" || !t.isIdentifier(node.callee.object)) return; - const receiver = node.callee.object.name; + if (method !== "open" || !t.isIdentifier(object)) return; + const receiver = object.name; const binding = resolveSemanticBindingState(state, node, receiver); if (binding === undefined) { if (semanticResolutionBlocked(state, node, receiver)) return; @@ -338,7 +334,9 @@ function* classifyDefinitionReceiversSteps( return; } if (binding.mutable || binding.initializers.length !== 1) return; - const initializer = binding.initializers[0]?.node; + const initializerSource = binding.initializers[0]?.node; + if (initializerSource === undefined) return; + const initializer = unwrapJavaScriptExpression(initializerSource).node; if (!t.isIdentifier(initializer)) return; const initializerBinding = resolveSemanticBindingState( state, @@ -357,7 +355,9 @@ function* classifyDefinitionReceiversSteps( initializerBinding.initializers.length !== 1 ) return; - const nested = initializerBinding.initializers[0]?.node; + const nestedSource = initializerBinding.initializers[0]?.node; + if (nestedSource === undefined) return; + const nested = unwrapJavaScriptExpression(nestedSource).node; if (t.isIdentifier(nested)) { const nestedFact = resolveGlobalAliasFact( state, @@ -420,16 +420,17 @@ const isConfigurationAccessorCall = ( state: JavaScriptSemanticAnalysisState, node: t.Node, ): boolean => { + node = unwrapJavaScriptExpression(node).node; if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return false; - const callee = node.callee; + const callee = unwrapJavaScriptExpression(node.callee).node; if ( (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) || semanticStaticPropertyKey(callee.property, callee.computed) !== "getConfiguration" ) return false; - const host = callee.object; + const host = unwrapJavaScriptExpression(callee.object).node; const matchesSpelling = t.isIdentifier(host) ? host.name === "workspace" : (t.isMemberExpression(host) || t.isOptionalMemberExpression(host)) && @@ -438,7 +439,7 @@ const isConfigurationAccessorCall = ( const root = t.isIdentifier(host) ? host : t.isMemberExpression(host) || t.isOptionalMemberExpression(host) - ? host.object + ? unwrapJavaScriptExpression(host.object).node : undefined; if (!t.isIdentifier(root)) return false; const binding = resolveSemanticBindingState(state, root, root.name); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index ae396b39c6..0e98a90c51 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -2,6 +2,173 @@ import { describe, expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +const expressionWrappers = [ + ["plain", (value: string) => value], + ["asserted", (value: string) => `(${value} as any)`], + ["non-null", (value: string) => `(${value}!)`], + ["satisfies", (value: string) => `(${value} satisfies any)`], +] as const; + +it("shares erased call-target names with endpoint consumers without inventing dynamic keys", () => { + const analysis = analyzeJavaScriptStaticSource(` + (client.get as typeof client.get)("/api/items"); + (navigator as typeof navigator).sendBeacon("/api/telemetry", payload); + (client[method] as typeof client.get)("/api/unknown"); + ((choose ? client.get : client.post) as typeof client.get)("/api/conditional"); + `); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.endpoints.map(({ value, mechanism }) => [value, mechanism]), + ).toEqual([ + ["/api/items", "call:client.get"], + ["/api/telemetry", "call:navigator.sendBeacon"], + ]); +}); + +it.each(expressionWrappers)( + "excludes %s collection and configuration reads while retaining request candidates", + (_label, wrap) => { + const call = (receiver: string, method: string, value: string) => + `${wrap(`${wrap(receiver)}.${method}`)}(${JSON.stringify(value)});`; + const workspace = `${wrap("vscode")}.workspace`; + const configuration = `${wrap(`${wrap(workspace)}.getConfiguration`)}("editor")`; + const analysis = analyzeJavaScriptStaticSource(` + import * as vscode from "vscode"; + const settings = ${wrap(configuration)}; + let changed = ${wrap(configuration)}; + changed = client; + const uncertain = flag ? ${wrap(configuration)} : client; + ${call(`new ${wrap("Headers")}()`, "get", "content-type")} + ${call(`new ${wrap("URLSearchParams")}()`, "delete", "q")} + ${call(`new ${wrap("Map")}()`, "get", "setting")} + ${call(`${wrap("response")}.headers`, "get", "authorization")} + ${call(`${wrap("url")}.searchParams`, "delete", "token")} + ${call(configuration, "get", "tabSize")} + ${call("settings", "get", "wordWrap")} + ${call(`${wrap("context")}.globalState`, "get", "lastSession")} + ${call(`${wrap("context")}.workspaceState`, "get", "lastFile")} + ${call("client", "get", "setting")} + ${call(`new ${wrap("Headers")}()`, "get", "/api/header-shaped")} + ${call("settings", "get", "https://api.example.test/config-shaped")} + ${call("client", "get", "/api/items")} + ${call("changed", "get", "changed-setting")} + ${call("uncertain", "get", "conditional-setting")} + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.parse_status).toBe("complete"); + expect( + analysis.endpoints.map(({ value, interpretation }) => [ + value, + interpretation ?? null, + ]), + ).toEqual([ + ["setting", "candidate"], + ["/api/header-shaped", null], + ["https://api.example.test/config-shaped", null], + ["/api/items", null], + ["changed-setting", "candidate"], + ["conditional-setting", "candidate"], + ]); + }, +); + +it("retains uncertainty and module evidence for wrapped get receivers", () => { + const analysis = analyzeJavaScriptStaticSource(` + const vscode = otherLibrary; + const settings = vscode.workspace.getConfiguration("editor"); + (settings.get as any)("unproven-config"); + (context.globalState.get as any)("unproven-state"); + ((choose ? new Headers() : client).get as any)("uncertain-receiver"); + (client[method] as any)("unknown-method"); + ((choose ? client.get : client.post) as any)("unknown-target"); + `); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.endpoints.map(({ value, interpretation }) => [ + value, + interpretation, + ]), + ).toEqual([ + ["unproven-config", "candidate"], + ["unproven-state", "candidate"], + ["uncertain-receiver", "candidate"], + ]); +}); + +it("retains lexical browser-open facts through erased call and receiver wrappers", () => { + const analysis = analyzeJavaScriptStaticSource(` + ((window as any).open as any)(method, "browser-context"); + function local(window) { + ((window as any).open as any)(method, "local-request"); + } + `); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.endpoints.map(({ value, interpretation }) => [ + value, + interpretation, + ]), + ).toEqual([["local-request", "candidate"]]); +}); + +it.each(expressionWrappers)( + "retains global and local identity through %s alias initializers", + (_label, wrap) => { + const analysis = analyzeJavaScriptStaticSource(` + const browser = ${wrap("window")}; + (browser.open as any)(method, "browser-context"); + const database = ${wrap("indexedDB")}; + (database.open as any)("fixture-db", "2"); + const cache = ${wrap("caches")}; + (cache.open as any)("fixture-cache"); + { + const indexedDB = ${wrap("caches")}; + const nested = ${wrap("indexedDB")}; + (nested.open as any)("nested-cache"); + } + { + const caches = ${wrap("indexedDB")}; + const nested = ${wrap("caches")}; + (nested.open as any)("nested-db", "2"); + } + function local(window, indexedDB, caches) { + const browser = ${wrap("window")}; + (browser.open as any)(method, "local-window"); + const database = ${wrap("indexedDB")}; + (database.open as any)("GET", "/local-db"); + const cache = ${wrap("caches")}; + (cache.open as any)("GET", "/local-cache"); + } + let replaced = ${wrap("window")}; + replaced = client; + (replaced.open as any)(method, "changed-window"); + const uncertain = ${wrap("flag ? window : client")}; + (uncertain.open as any)(method, "uncertain-window"); + `); + expect(analysis.parse_error_count).toBe(0); + expect + .soft( + analysis.endpoints.map(({ value, interpretation }) => [ + value, + interpretation ?? null, + ]), + ) + .toEqual([ + ["local-window", "candidate"], + ["/local-db", null], + ["/local-cache", null], + ["changed-window", "candidate"], + ["uncertain-window", "candidate"], + ]); + expect(analysis.storage.map(({ kind, name }) => [kind, name])).toEqual([ + ["indexed-db", "fixture-db"], + ["cache-storage", "fixture-cache"], + ["cache-storage", "nested-cache"], + ["indexed-db", "nested-db"], + ]); + }, +); + it("retains endpoint observations for exact template and TypeScript member keys", () => { for (const [get, beacon] of [ ['"get"', '"sendBeacon"'], diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index fcb835ec45..72a3d40796 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -180,9 +180,9 @@ export const staticPathResolutionContext = ( /** Resolve a path composed only from inert literal syntax. */ export const staticPath = (node: t.Node): string | undefined => { - if (t.isStringLiteral(node)) return node.value; - if (t.isTemplateLiteral(node) && node.expressions.length === 0) - return stringValue(node); + node = unwrapJavaScriptExpression(node).node; + const value = stringValue(node); + if (value !== undefined) return value; if (t.isBinaryExpression(node, { operator: "+" })) return staticConcatenation(node); if (t.isCallExpression(node) || t.isNewExpression(node)) @@ -200,7 +200,7 @@ const staticConcatenation = (node: t.BinaryExpression): string | undefined => { let current: t.Node = node; while (t.isBinaryExpression(current, { operator: "+" })) { operands.push(current.right); - current = current.left; + current = unwrapJavaScriptExpression(current.left).node; } const anchored = isDirectoryPath(current); if (!anchored) operands.push(current); @@ -249,8 +249,9 @@ const staticFileUrlPath = ( node: t.CallExpression | t.NewExpression, ): string | undefined => { if (node.arguments.length !== 1) return undefined; - const argument = argumentNode(node.arguments[0]); - if (argument === undefined) return undefined; + const rawArgument = argumentNode(node.arguments[0]); + if (rawArgument === undefined) return undefined; + const argument = unwrapJavaScriptExpression(rawArgument).node; if (isSourceFileUrl(argument)) return sourceRelativeFileUrlPath(argument); const value = staticPath(argument); const rawPath = value?.split(/[?#]/u, 1)[0] ?? ""; @@ -277,8 +278,9 @@ const staticFileUrlPath = ( }; /** Project a known source-relative URL through the actual file URL decoder. */ -const sourceRelativeFileUrlPath = (node: t.Node): string | undefined => { - if (!isSourceFileUrl(node)) return undefined; +const sourceRelativeFileUrlPath = ( + node: t.CallExpression | t.NewExpression, +): string | undefined => { const value = stringValue(node.arguments[0]); if ( value === undefined || @@ -347,7 +349,9 @@ const staticResolvedPath = ( }; const isFilesystemPathExpression = (node: t.Node): boolean => { - if (t.isStringLiteral(node)) return node.value.startsWith("/"); + node = unwrapJavaScriptExpression(node).node; + const value = stringValue(node); + if (value !== undefined) return value.startsWith("/"); if (isFileIdentity(node) || isDirectoryIdentity(node)) return true; if (t.isBinaryExpression(node, { operator: "+" })) return ( @@ -377,6 +381,7 @@ const isFilesystemPathExpression = (node: t.Node): boolean => { }; const isDirectoryIdentity = (node: t.Node | undefined): boolean => { + if (node !== undefined) node = unwrapJavaScriptExpression(node).node; if (isDirectoryPath(node)) return true; if (!t.isCallExpression(node) && !t.isNewExpression(node)) return false; const name = calleeName(node.callee); @@ -388,6 +393,7 @@ const isDirectoryIdentity = (node: t.Node | undefined): boolean => { /** `__dirname` or the dirname of the source file, as a string. */ const isDirectoryPath = (node: t.Node | undefined): boolean => { + if (node !== undefined) node = unwrapJavaScriptExpression(node).node; if (t.isIdentifier(node, { name: "__dirname" })) return true; if (!t.isCallExpression(node)) return false; const name = calleeName(node.callee); @@ -413,6 +419,7 @@ export const isImportMetaUrl = (node: t.Node | undefined): boolean => { }; const isFileIdentity = (node: t.Node | undefined): boolean => { + if (node !== undefined) node = unwrapJavaScriptExpression(node).node; if (t.isIdentifier(node, { name: "__filename" }) || isImportMetaUrl(node)) return true; if (!t.isCallExpression(node)) return false; @@ -560,14 +567,17 @@ const KEYED_COLLECTION_CONSTRUCTORS = new Set([ * binding, so callers keep URL and path literals as requests. */ const isKeyedCollectionReceiver = (callee: t.Node): boolean => { + callee = unwrapJavaScriptExpression(callee).node; if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return false; - const receiver = callee.object; - if (t.isNewExpression(receiver)) + const receiver = unwrapJavaScriptExpression(callee.object).node; + if (t.isNewExpression(receiver)) { + const constructor = unwrapJavaScriptExpression(receiver.callee).node; return ( - t.isIdentifier(receiver.callee) && - KEYED_COLLECTION_CONSTRUCTORS.has(receiver.callee.name) + t.isIdentifier(constructor) && + KEYED_COLLECTION_CONSTRUCTORS.has(constructor.name) ); + } if ( !t.isMemberExpression(receiver) && !t.isOptionalMemberExpression(receiver) @@ -669,14 +679,15 @@ export const storageKind = ( return undefined; }; -/** Produce a dotted callee name from member syntax. */ +/** Produce a dotted callee name through type-erased expression wrappers. */ export const calleeName = (node: t.Node): string => { + node = unwrapJavaScriptExpression(node).node; const segments: string[] = []; while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { // Dynamic keys retain their syntax identity instead of inventing a name. segments.push(memberPropertyName(node)); if (!t.isNode(node.object)) break; - node = node.object; + node = unwrapJavaScriptExpression(node.object).node; } const base = t.isIdentifier(node) ? node.name diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 8d1a58ff12..83d6daba8f 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -5,6 +5,7 @@ import { parseExpression } from "@babel/parser"; import { describe, expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { parserPluginsForPath } from "./javascriptSourceParser.js"; import { staticPath, staticPathResolutionContext, @@ -46,8 +47,13 @@ const anchoredExpressions = [ ] as const; const unknownExpressions = [ + "runtimePath as string", + "path.join(__dirname as string, runtimeFile as string) as string", 'path[method](__dirname, "preload.js")', 'path[`join${suffix}`](__dirname, "preload.js")', + '(path[method] as typeof path.join)(__dirname, "preload.js")', + '((useJoin ? path.join : path.resolve) as typeof path.join)(__dirname, "preload.js")', + 'fileURLToPath(new (runtimeURL as typeof URL)("./preload.js", import.meta.url))', "path.resolve()", "path.posix.resolve()", 'path.resolve("sub", "../preload.js")', @@ -69,7 +75,101 @@ const unknownExpressions = [ 'path.dirname(__filename) + "preload.js"', ]; +const pathValueExpressions = [ + ['"./preload.js" as const', "./preload.js", "module-specifier"], + [ + 'path.join(__dirname, "preload.js") as string', + "preload.js", + "filesystem-expression", + ], + [ + 'path.join(__dirname as string, "preload.js" as const)', + "preload.js", + "filesystem-expression", + ], + [ + 'path.resolve(__dirname as string, "preload.js" as const)', + "preload.js", + "filesystem-expression", + ], + [ + 'fileURLToPath(new URL("./preload.js", import.meta.url as string) as URL) as string', + "./preload.js", + "filesystem-expression", + ], + [ + 'path.join(path.dirname(__filename as string), "preload.js")', + "preload.js", + "filesystem-expression", + ], + [ + 'path.join(path.dirname(__filename) as string, "preload.js")', + "preload.js", + "filesystem-expression", + ], + [ + '((__dirname + "/lib") as string) + "/preload.js"', + "lib/preload.js", + "filesystem-expression", + ], + ['"/app/preload.js"', "/app/preload.js", "filesystem-expression"], + ["`/app/preload.js`", "/app/preload.js", "filesystem-expression"], + [ + 'path.join(`/app`, "preload.js")', + "/app/preload.js", + "filesystem-expression", + ], + ["`./preload.js`", "./preload.js", "module-specifier"], +] as const; + +const pathCallTargetExpressions = [ + ['(path.join as typeof path.join)(__dirname, "preload.js")', "preload.js"], + ['path.join!(__dirname, "preload.js")', "preload.js"], + [ + '(path.resolve satisfies typeof path.resolve)(__dirname, "preload.js")', + "preload.js", + ], + [ + '((path as typeof path).posix as typeof path.posix).resolve("/app", "preload.js")', + "/app/preload.js", + ], + [ + '(fileURLToPath as typeof fileURLToPath)(new URL("./preload.js", import.meta.url))', + "./preload.js", + ], + [ + 'fileURLToPath(new (URL as typeof URL)("./preload.js", import.meta.url))', + "./preload.js", + ], + [ + 'fileURLToPath(new (url.URL as typeof URL)("./preload.js", import.meta.url))', + "./preload.js", + ], + [ + 'path.join((path.dirname as typeof path.dirname)(__filename), "preload.js")', + "preload.js", + ], + [ + 'path.join(dirname((fileURLToPath as typeof fileURLToPath)(import.meta.url)), "preload.js")', + "preload.js", + ], +] as const; + describe("inert JavaScript path resolution", () => { + it.each(pathCallTargetExpressions)( + "preserves exact path values and context through call target %s", + (expression, path) => { + assertPathProjection(expression, path, "filesystem-expression"); + }, + ); + + it.each(pathValueExpressions)( + "preserves exact path values and context through %s", + (expression, path, context) => { + assertPathProjection(expression, path, context); + }, + ); + it.each(absoluteParts)( "matches the POSIX resolve oracle for %j", (...parts) => { @@ -141,9 +241,7 @@ describe("inert JavaScript path resolution", () => { }); it.each(unknownExpressions)("keeps unsupported %s unknown", (expression) => { - expect( - staticPath(parseExpression(expression, { sourceType: "module" })), - ).toBeUndefined(); + expect(staticPath(parsePathExpression(expression))).toBeUndefined(); const { analysis, windowExpression, utilityExpression } = analyzePathConsumers(expression); expect(analysis.parse_status).toBe("partial"); @@ -210,9 +308,7 @@ describe("inert JavaScript path resolution", () => { 'path.join("/app", runtimeFile)', "path.join(...parts)", ])("preserves unresolved join syntax in %s", (expression) => - expect( - staticPath(parseExpression(expression, { sourceType: "module" })), - ).toBeUndefined(), + expect(staticPath(parsePathExpression(expression))).toBeUndefined(), ); }); @@ -237,11 +333,15 @@ it.each([ 'fileURLToPath("file:///app/preload.js", options)', "fileURLToPath(selectedUrl)", ])("leaves platform-dependent or invalid %s unknown", (expression) => - expect( - staticPath(parseExpression(expression, { sourceType: "module" })), - ).toBeUndefined(), + expect(staticPath(parsePathExpression(expression))).toBeUndefined(), ); +const parsePathExpression = (expression: string) => + parseExpression(expression, { + sourceType: "module", + plugins: parserPluginsForPath("preload.ts"), + }); + const analyzePathConsumers = (expression: string) => { const property = `preload: ${expression}`; const genericPrefix = "const options = { "; @@ -265,7 +365,7 @@ const assertPathProjection = ( path: string, context: JavaScriptStaticPathContext, ) => { - const node = parseExpression(expression, { sourceType: "module" }); + const node = parsePathExpression(expression); expect(staticPath(node)).toBe(path); expect(staticPathResolutionContext(node)).toBe(context); const { diff --git a/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts b/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts index e741971adc..38027906e8 100644 --- a/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts +++ b/tests/acceptance/applications/javascriptEndpointCandidateCli.test.ts @@ -13,8 +13,25 @@ const execute = promisify(execFile); it("reports a candidate endpoint through the worker boundary in the public CLI", async () => { const root = await createTestTempDirectory("rea-endpoint-candidate-cli-"); - // A method-like call whose first argument is neither a URL nor a path. - await writeFile(join(root, "main.js"), 'client.post("session");\n'); + // Retain ambiguous requests while excluding type-erased collection/config reads. + await writeFile( + join(root, "main.ts"), + ` + import * as vscode from "vscode"; + client.post("session"); + (client.get as any)("/api/items"); + (new Headers().get as any)("content-type"); + (new URLSearchParams() as any).delete("q"); + ((vscode.workspace.getConfiguration("editor") as any).get as any)("tabSize"); + (context.globalState.get as any)("lastSession"); + const browser = window as Window; + (browser.open as any)(method, "browser-context"); + const database = indexedDB as IDBFactory; + (database.open as any)("fixture-db", "2"); + const cache = caches as CacheStorage; + (cache.open as any)("fixture-cache"); + `, + ); const { stdout } = await execute( process.execPath, ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], @@ -23,7 +40,32 @@ it("reports a candidate endpoint through the worker boundary in the public CLI", const result = javascriptApplicationAnalysisResultSchema.parse( parseEvidence(JSON.parse(stdout)).normalized_result, ); - const endpoint = result.graph.nodes.find(({ kind }) => kind === "endpoint"); + expect + .soft( + result.graph.nodes + .filter(({ kind }) => kind === "endpoint") + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.value), + ) + .sort(), + ) + .toEqual(["/api/items", "session"]); + expect( + result.graph.nodes + .filter(({ kind }) => kind === "storage") + .flatMap(({ observations }) => + observations.map( + ({ properties }) => + `${String(properties.storage_kind)}:${String(properties.name)}`, + ), + ) + .sort(), + ).toEqual(["cache-storage:fixture-cache", "indexed-db:fixture-db"]); + const endpoint = result.graph.nodes.find( + ({ kind, observations }) => + kind === "endpoint" && + observations.some(({ properties }) => properties.value === "session"), + ); expect(endpoint?.observations[0]).toMatchObject({ properties: { endpoint_kind: "network", diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTypeScript.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTypeScript.test.ts index 09cd97fe77..27916abb33 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTypeScript.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTypeScript.test.ts @@ -125,6 +125,186 @@ it("resolves erased wrappers through the composed application graph", async () = expect(parseJavaScriptSource(source, "app.mts")?.errors).toEqual([]); }); +it.each([ + { + name: "path-value wrappers", + plain: ['"./preload.js"', '"./worker.js"', '"kept"'], + wrapped: [ + '("./preload.js" as const)', + '("./worker.js" as const)', + '("kept" as const)', + ], + preloadPath: "./preload.js", + workerPath: "./worker.js", + context: "module-specifier", + }, + { + name: "path-call target wrappers", + plain: [ + 'path.join(__dirname, "preload.js")', + 'fileURLToPath(new URL("./worker.js", import.meta.url))', + '"kept"', + ], + wrapped: [ + '(path.join as typeof path.join)(__dirname, "preload.js")', + '(fileURLToPath as typeof fileURLToPath)(new (URL as typeof URL)("./worker.js", import.meta.url))', + '("kept" as const)', + ], + preloadPath: "preload.js", + workerPath: "./worker.js", + context: "filesystem-expression", + }, +])( + "retains Electron entrypoint facts through erased TypeScript $name", + async ({ + plain: plainExpressions, + wrapped: wrappedExpressions, + preloadPath, + workerPath, + context, + }) => { + const applications: ApplicationAnalysis[] = []; + for (const [preload, worker, serviceName] of [ + plainExpressions, + wrappedExpressions, + ]) { + applications.push( + await analyzeFiles( + new Map([ + ["package.json", '{"main":"input.ts","type":"commonjs"}'], + [ + "input.ts", + `const { BrowserWindow, utilityProcess } = require("electron"); + const path = require("node:path"); + const { fileURLToPath } = require("node:url"); + new BrowserWindow({ webPreferences: { preload: ${preload} } }); + utilityProcess.fork(${worker}, [], { serviceName: ${serviceName} });`, + ], + ["preload.js", "void 'preload';\n"], + ["worker.js", "void 'worker';\n"], + ]), + ), + ); + } + + const entrypointFacts = (analysis: ApplicationAnalysis) => { + const nodes = new Map( + analysis.graph.nodes.map((node) => [node.node_id, node]), + ); + const windows = analysis.graph.nodes.filter( + ({ kind }) => kind === "browser-window", + ); + const utilities = analysis.graph.nodes.filter( + ({ kind }) => kind === "electron-utility", + ); + const windowIds = new Set(windows.map(({ node_id }) => node_id)); + const preloadLoads = analysis.graph.edges.filter( + ({ source_node_id, relation }) => + windowIds.has(source_node_id) && relation === "loads", + ); + const entrypointIds = new Set([ + ...preloadLoads.map(({ target_node_id }) => target_node_id), + ...utilities.map(({ node_id }) => node_id), + ]); + // Several provenance edges can identify the same entrypoint asset. + const assetMappings = new Map( + analysis.graph.edges + .filter( + ({ source_node_id, relation }) => + entrypointIds.has(source_node_id) && relation === "maps_to", + ) + .map(({ source_node_id, target_node_id, properties }) => { + const identity = [ + nodes.get(source_node_id)?.kind, + properties.resolved_path, + properties.resolution_status, + nodes + .get(target_node_id) + ?.observations.find( + ({ properties: target }) => typeof target.path === "string", + )?.properties.path, + ]; + return [ + canonicalJson(identity, "Electron entrypoint asset identity"), + identity, + ] as const; + }), + ); + return { + summary: analysis.summary, + windows: windows.flatMap(({ observations }) => + observations.map(({ properties }) => properties), + ), + utilities: utilities.flatMap(({ observations }) => + observations + .filter( + ({ evidence }) => + evidence.extractor.operation === "observe-utility-process-fork", + ) + .map(({ properties }) => properties), + ), + preloadLoads: preloadLoads.map(({ properties }) => properties), + assetMappings: [...assetMappings] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([, identity]) => identity), + }; + }; + + const [plain, wrapped] = applications.map(entrypointFacts); + expect(plain).toMatchObject({ + summary: { + browser_windows: 1, + explicit_web_preferences: 1, + preload_entrypoints: 1, + utility_processes: 1, + resolved_utility_entrypoints: 1, + }, + windows: [ + { + source_path: "input.ts", + options_status: "object-literal", + web_preferences_status: "object-literal", + web_preferences: [ + { + name: "preload", + value: { + status: "literal", + value: preloadPath, + expression: null, + }, + }, + ], + preload_path: preloadPath, + preload_resolution_context: context, + absence_means_default: false, + }, + ], + utilities: [ + { + module_path: workerPath, + module_resolution_context: context, + module_expression: null, + service_name: "kept", + }, + ], + preloadLoads: [ + { + declared_path: preloadPath, + resolution_context: context, + resolved_path: "preload.js", + resolution_status: "resolved", + limitations: [], + }, + ], + assetMappings: [ + ["electron-preload", "preload.js", "resolved", "preload.js"], + ["electron-utility", "worker.js", "resolved", "worker.js"], + ], + }); + expect(wrapped).toEqual(plain); + }, +); + it("applies TypeScript .mts ambiguity rules while retaining unambiguous generics", () => { expect( parseJavaScriptSource(