From ed24604ee10362b063feb8610cee1f2049df65c8 Mon Sep 17 00:00:00 2001 From: LouisChen <65096591@qq.com> Date: Wed, 14 Oct 2026 03:12:04 +0800 Subject: [PATCH] fix(javascript): preserve empty ESM import names in provenance (#2797) Co-authored-by: LouisChen <3361008+chqcjq@users.noreply.github.com> --- ...vascriptSemanticAnalysis.structure.test.ts | 39 +++++++++++ .../javascript/javascriptSemanticAnalysis.ts | 3 +- .../applications/emptyImportNameCli.test.ts | 70 +++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 tests/acceptance/applications/emptyImportNameCli.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts index 2312eff5d..16b8c5091 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts @@ -10,6 +10,45 @@ import { origin, } from "./javascriptSemanticAnalysis.fixture.js"; +describe("JavaScript semantic analysis: ESM import origins", () => { + it.each(["", "a", "0"])( + "preserves exact ESM import name %j in binding origins", + (name) => { + const ir = analyzeJavaScriptSemantics( + "import { " + + JSON.stringify(name) + + ' as selected } from "./module.mjs"; const alias = selected; export { alias };', + ); + for (const binding of ["selected", "alias"]) + expect(origin(topLevelBinding(ir, binding))).toEqual({ + specifier: "./module.mjs", + importedPath: [name], + }); + expect(ir.moduleLinks).toContainEqual( + expect.objectContaining({ + kind: "import", + importedName: name, + localName: "selected", + }), + ); + }, + ); + + it("keeps default and namespace import origins distinct from an empty named import", () => { + const ir = analyzeJavaScriptSemantics( + 'import value from "./module.mjs"; import * as namespace from "./module.mjs";', + ); + expect(origin(topLevelBinding(ir, "value"))).toEqual({ + specifier: "./module.mjs", + importedPath: ["default"], + }); + expect(origin(topLevelBinding(ir, "namespace"))).toEqual({ + specifier: "./module.mjs", + importedPath: [], + }); + }); +}); + describe("JavaScript semantic analysis: structure 1", () => { it("resolves imports, require destructuring, aliases, assignments, and shadowing", () => { const ir = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 119fbdd45..7e20c0e13 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -57,7 +57,6 @@ import { ELECTRON_MODULE, } from "./javascriptElectronMemberWrites.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; -import { propertyName } from "./javascriptAstValues.js"; import { semanticCoverage } from "./javascriptSemanticCoverage.js"; import { semanticResourceLimitsIn } from "./javascriptSemanticResourceLimits.js"; import { @@ -1109,7 +1108,7 @@ const bindImports = ( ? ["default"] : t.isImportNamespaceSpecifier(specifier) ? [] - : [propertyName(specifier.imported) || "[dynamic]"]; + : [semanticStaticPropertyKey(specifier.imported, false) ?? "[dynamic]"]; addBinding({ state, scope, diff --git a/tests/acceptance/applications/emptyImportNameCli.test.ts b/tests/acceptance/applications/emptyImportNameCli.test.ts new file mode 100644 index 000000000..1805d8d6c --- /dev/null +++ b/tests/acceptance/applications/emptyImportNameCli.test.ts @@ -0,0 +1,70 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves empty ESM import names on local re-export origins through %s", + async (surface, { cli, processes }) => { + const root = await createTestTempDirectory("rea-empty-import-name-"); + const modulePath = join(root, "module.mjs"); + const appPath = join(root, "app.mjs"); + await writeFile( + modulePath, + 'const value = 1; export { value as "", value as "a", value as "0" };', + ); + await writeFile( + appPath, + [ + 'import { "" as empty, "a" as letter, "0" as zero } from "./module.mjs";', + "export { empty, letter, zero };", + 'export { "" as direct } from "./module.mjs";', + ].join("\n"), + ); + for (const path of [modulePath, appPath]) { + const checked = await processes.run(process.execPath, ["--check", path]); + expect(checked.exitCode, checked.stderr).toBe(0); + } + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + const imports = result.graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && properties.specifier === "./module.mjs", + ); + for (const [name, local] of [ + ["", "empty"], + ["a", "letter"], + ["0", "zero"], + ]) { + for (const kind of ["import", "export"]) { + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + module_link_kind: kind, + ...(kind === "export" + ? { exported_name: local } + : { local_name: local }), + imported_name: name, + imported_path: [name], + resolved_path: "module.mjs", + resolution_status: "resolved", + }), + }), + ); + } + } + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + exported_name: "direct", + imported_name: "", + imported_path: [""], + resolution_status: "resolved", + }), + }), + ); + }, +);