From ce89706145dd98fbaa8df8f062fdc80777136940 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Thu, 15 Oct 2026 14:59:06 +0800 Subject: [PATCH] fix(javascript): preserve exact static property keys (#3480) * fix(javascript): preserve Electron options with static empty keys * fix(javascript): retain exact computed member keys --- .../electronStaticAnalysisOptions.test.ts | 134 ++++++++++++++ .../electronStaticAnalysisValues.ts | 5 +- ...criptSemanticAnalysis.member_depth.test.ts | 8 + ...scriptStaticAnalysisCalleeBoundary.test.ts | 167 ++++++++++++++++++ .../javascriptStaticAnalysisHelpers.ts | 7 +- .../javascriptStaticPathResolution.test.ts | 8 + ...ascriptApplicationTemplateLiterals.test.ts | 2 + 7 files changed, 323 insertions(+), 8 deletions(-) diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index 418920777e..f271e6ffc5 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -112,3 +112,137 @@ describe("Electron option evidence", () => { ); }); }); + +describe("Electron empty option keys", () => { + describe.each([ + ["plain empty string", '[""]'], + ["empty template", "[``]"], + ["wrapped empty string", '["" as const]'], + ])("unrelated %s option keys", (_label, key) => { + it("retains an earlier explicit webPreferences object", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow } = require("electron"); + new BrowserWindow({ + webPreferences: { preload: "./kept.js", sandbox: true }, + ${key}: true + }); + `); + + expect(analysis.parse_error_count).toBe(0); + expect(analysis.parse_status).toBe("complete"); + expect(analysis.electron.browser_windows).toMatchObject([ + { + web_preferences_status: "object-literal", + preload_path: "./kept.js", + preload_resolution_context: "module-specifier", + web_preferences: [ + { + name: "preload", + value: { status: "literal", value: "./kept.js" }, + }, + { name: "sandbox", value: { status: "literal", value: true } }, + ], + }, + ]); + }); + + it("retains an earlier explicit preload within webPreferences", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow } = require("electron"); + new BrowserWindow({ webPreferences: { + preload: "./kept.js", sandbox: true, ${key}: false + } }); + `); + + expect(analysis.parse_error_count).toBe(0); + expect(analysis.parse_status).toBe("complete"); + expect(analysis.electron.browser_windows).toMatchObject([ + { + web_preferences_status: "object-literal", + preload_path: "./kept.js", + preload_resolution_context: "module-specifier", + web_preferences: [ + { name: "", value: { status: "literal", value: false } }, + { + name: "preload", + value: { status: "literal", value: "./kept.js" }, + }, + { name: "sandbox", value: { status: "literal", value: true } }, + ], + }, + ]); + }); + + it("retains an earlier explicit utility-process service name", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { utilityProcess } = require("electron"); + utilityProcess.fork("./worker.js", [], { + serviceName: "kept", ${key}: true + }); + `); + + expect(analysis.parse_error_count).toBe(0); + expect(analysis.parse_status).toBe("complete"); + expect(analysis.electron.utility_processes).toMatchObject([ + { module_path: "./worker.js", service_name: "kept" }, + ]); + }); + }); + + it.each([ + ["dynamic key", "[key]: null"], + ["spread", "...options"], + ])( + "keeps a preceding %s override unknown after an unrelated empty template key", + (_label, override) => { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow, utilityProcess } = require("electron"); + new BrowserWindow({ + webPreferences: { preload: "./maybe.js" }, + ${override}, [\`\`]: true + }); + new BrowserWindow({ webPreferences: { + preload: "./maybe.js", ${override}, [\`\`]: true + } }); + utilityProcess.fork("./worker.js", [], { + serviceName: "maybe", ${override}, [\`\`]: true + }); + `); + + expect(analysis.parse_error_count).toBe(0); + expect(analysis.parse_status).toBe("partial"); + expect(analysis.electron.browser_windows).toMatchObject([ + { + web_preferences_status: "dynamic", + web_preferences: [], + preload_path: null, + preload_resolution_context: null, + }, + { + web_preferences_status: "object-literal", + preload_path: null, + preload_resolution_context: null, + }, + ]); + expect(analysis.electron.browser_windows[1]?.web_preferences).toEqual( + expect.arrayContaining([ + { + name: "", + value: { status: "literal", value: true, expression: null }, + }, + { + name: "preload", + value: { status: "literal", value: "./maybe.js", expression: null }, + }, + expect.objectContaining({ + name: expect.stringMatching(/^\[(?:dynamic|spread)@/u), + value: expect.objectContaining({ status: "dynamic", value: null }), + }), + ]), + ); + expect(analysis.electron.utility_processes).toMatchObject([ + { module_path: "./worker.js", service_name: null }, + ]); + }, + ); +}); diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 90ef1f0a59..d1c1adcedc 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -65,9 +65,8 @@ export const objectProperty = ( if (!t.isObjectExpression(object)) return { status: "dynamic" }; for (const property of object.properties.toReversed()) { if (t.isSpreadElement(property)) return { status: "dynamic" }; - const key = semanticStaticPropertyName(property.key, property.computed); - if (key === "" && !t.isStringLiteral(property.key)) - return { status: "dynamic" }; + const key = semanticStaticPropertyKey(property.key, property.computed); + if (key === null) return { status: "dynamic" }; if (key !== name) continue; return t.isObjectProperty(property) ? { status: "explicit", property } diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts index 964551a7d6..234f05d262 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts @@ -134,11 +134,19 @@ it("keeps a shadowed require local through a deep member initializer", () => { it.each([ ["root.first.last()", "root.first.last"], + ["root[`first`].last()", "root.first.last"], + ['root["first" as const].last()', "root.first.last"], ['root[""].last()', "root..last"], + ["root[``].last()", "root..last"], + ['root["" as const].last()', "root..last"], ['("")[""].last()', "last"], ['("")[""]()', ""], ["root[0].last()", "root.0.last"], + ["root[0x1n].last()", "root.1.last"], + ["root[-1n].last()", "root.-1.last"], ["root[key].last()", "root.[computed@5].last"], + ["root[`first${key}`].last()", "root.[computed@5].last"], + ["root[`\\unicode`].last()", "root.[computed@5].last"], ["root?.first.last()", "root.first.last"], [`root${ordinaryMemberChain}.last()`, `root${ordinaryMemberChain}.last`], ])("preserves exact callee syntax (case %#)", (source, expected) => { diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index cb602ca0f1..ae396b39c6 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"; +it("retains endpoint observations for exact template and TypeScript member keys", () => { + for (const [get, beacon] of [ + ['"get"', '"sendBeacon"'], + ["`get`", "`sendBeacon`"], + ['"get" as const', '"sendBeacon" as const'], + ]) { + const analysis = analyzeJavaScriptStaticSource(` + import * as vscode from "vscode"; + client[${get}]("/api/items"); + navigator[${beacon}]("/api/telemetry", payload); + client[${get}]("setting"); + new Headers()[${get}]("content-type"); + new URLSearchParams()[${get}]("q"); + vscode.workspace.getConfiguration("editor")[${get}]("tabSize"); + `); + + expect.soft(analysis.parse_error_count).toBe(0); + expect.soft(analysis.parse_status).toBe("complete"); + expect + .soft( + analysis.endpoints.map(({ kind, value, mechanism, interpretation }) => [ + kind, + value, + mechanism, + interpretation ?? null, + ]), + ) + .toEqual([ + ["network", "/api/items", "call:client.get", null], + ["network", "/api/telemetry", "call:navigator.sendBeacon", null], + ["network", "setting", "call:client.get", "candidate"], + ]); + } +}); + +it("preserves preload and utility paths through exact computed join members", () => { + for (const key of ['"join"', "`join`", '"join" as const']) { + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow, utilityProcess } = require("electron"); + const path = require("node:path"); + new BrowserWindow({ webPreferences: { + preload: path[${key}](__dirname, "preload.js") + } }); + utilityProcess.fork(path[${key}](__dirname, "worker.js"), [], { + serviceName: "kept" + }); + `); + + expect.soft(analysis.parse_error_count).toBe(0); + expect.soft(analysis.parse_status).toBe("complete"); + expect + .soft( + analysis.role_paths.map( + ({ role, path, resolution_context, mechanism }) => [ + role, + path, + resolution_context, + mechanism, + ], + ), + ) + .toEqual([ + ["preload", "preload.js", "filesystem-expression", "property:preload"], + ]); + expect + .soft( + analysis.electron.browser_windows.map( + ({ location: _location, ...finding }) => finding, + ), + ) + .toEqual([ + { + options_status: "object-literal", + web_preferences_status: "object-literal", + web_preferences: [ + { + name: "preload", + value: { + status: "literal", + value: "preload.js", + expression: null, + }, + }, + ], + preload_path: "preload.js", + preload_resolution_context: "filesystem-expression", + module_key: null, + }, + ]); + expect + .soft( + analysis.electron.utility_processes.map( + ({ location: _location, ...finding }) => finding, + ), + ) + .toEqual([ + { + module_path: "worker.js", + module_resolution_context: "filesystem-expression", + module_expression: null, + service_name: "kept", + module_key: null, + }, + ]); + } +}); + +it("keeps dynamic, interpolated, and invalid template keys unresolved", () => { + for (const { key, errors } of [ + { key: "method", errors: 0 }, + { key: "`join${suffix}`", errors: 0 }, + { key: ["`", String.raw`\unicode`, "`"].join(""), errors: 4 }, + ]) { + const preload = `path[${key}](__dirname, "preload.js")`; + const worker = `path[${key}](__dirname, "worker.js")`; + const analysis = analyzeJavaScriptStaticSource(` + const { BrowserWindow, utilityProcess } = require("electron"); + const path = require("node:path"); + client[${key}]("/api/items"); + navigator[${key}]("/api/telemetry", payload); + new BrowserWindow({ webPreferences: { preload: ${preload} } }); + utilityProcess.fork(${worker}, [], { serviceName: "kept" }); + `); + + expect.soft(analysis.parse_error_count).toBe(errors); + expect.soft(analysis.parse_status).toBe("partial"); + expect.soft(analysis.endpoints).toEqual([]); + expect.soft(analysis.role_paths).toEqual([]); + expect + .soft( + analysis.electron.browser_windows.map( + ({ location: _location, ...finding }) => finding, + ), + ) + .toEqual([ + { + options_status: "object-literal", + web_preferences_status: "object-literal", + web_preferences: [ + { + name: "preload", + value: { status: "dynamic", value: null, expression: preload }, + }, + ], + preload_path: null, + preload_resolution_context: null, + module_key: null, + }, + ]); + expect + .soft( + analysis.electron.utility_processes.map( + ({ location: _location, ...finding }) => finding, + ), + ) + .toEqual([ + { + module_path: null, + module_resolution_context: null, + module_expression: worker, + service_name: "kept", + module_key: null, + }, + ]); + } +}); + describe("static callee boundary matching", () => { it("matches complete callee members without suffix collisions", () => { const analysis = analyzeJavaScriptStaticSource(` diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 3b7e79c1dc..fcb835ec45 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -693,11 +693,8 @@ export const calleeName = (node: t.Node): string => { const memberPropertyName = ( node: t.MemberExpression | t.OptionalMemberExpression, ): string => - node.computed && - !t.isStringLiteral(node.property) && - !t.isNumericLiteral(node.property) - ? `[computed@${String(node.property.start ?? -1)}]` - : semanticStaticPropertyName(node.property, node.computed); + semanticStaticPropertyKey(node.property, node.computed) ?? + (node.computed ? `[computed@${String(node.property.start ?? -1)}]` : ""); /** * Return a string literal value without evaluating an expression. A template diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 5081eb37dc..8d1a58ff12 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -23,6 +23,7 @@ const absoluteParts = [ const anchoredExpressions = [ ['path.resolve(__dirname, "preload.cjs")', "preload.cjs"], + ['path[`resolve`](__dirname, "preload.js")', "preload.js"], ['path.posix.resolve("ignored", __dirname, "preload.js")', "preload.js"], ['path.resolve("/ignored", __dirname, "preload.js")', "preload.js"], [ @@ -45,6 +46,8 @@ const anchoredExpressions = [ ] as const; const unknownExpressions = [ + 'path[method](__dirname, "preload.js")', + 'path[`join${suffix}`](__dirname, "preload.js")', "path.resolve()", "path.posix.resolve()", 'path.resolve("sub", "../preload.js")', @@ -106,6 +109,11 @@ describe("inert JavaScript path resolution", () => { "preload.js", "filesystem-expression", ], + [ + 'path[`join`](__dirname, "preload.js")', + "preload.js", + "filesystem-expression", + ], [ 'path.join(__dirname, "/preload.js")', "preload.js", diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts index 1e1f4d970b..d4774afa8c 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts @@ -54,6 +54,8 @@ const graphIdentities = async (source: string) => { // source locations and every derived identity stay comparable. it.each([ "fetch('https://api.example.com/v1/users');", + "client['get']('/api/items');", + "navigator['sendBeacon']('/api/events', payload);", "localStorage.setItem('token', 1);", "new Worker(new URL('./worker.js', import.meta.url));", "const dep = require('./dep.js'); dep();",