From fca331804af9dd4fadcec8335e06fc432c26efde Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:43:55 -0400 Subject: [PATCH] fix(javascript): select JSON and addon loaders by exact suffix (#7821) Node's CommonJS loader matches registered extensions case-sensitively, so require("./data.JSON") and require("./addon.NODE") run as JavaScript. Derive target_json_status and the native loads relation from the exact loader suffix instead of the inventory's case-folded file kind. Fixes #7623 --- .../JavaScriptArtifactGraphFindings.ts | 4 +- .../JavaScriptModuleRelationships.ts | 12 +++-- .../javascriptModuleResolutionMode.ts | 6 +++ .../javascriptRequireLiteralPaths.test.ts | 54 +++++++++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 74aca4d02a..9e0b3c7036 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -36,6 +36,7 @@ import { import { moduleResolutionModeForJavaScriptReference, isJsonModuleTarget, + isNativeAddonModuleTarget, } from "../../domain/javascript/javascriptModuleResolutionMode.js"; type StaticReference = JavaScriptStaticAnalysis["references"][number]; @@ -309,7 +310,8 @@ const addReference = ( coverage: input.coverage, relation: input.value.operation === "load" && - resolved?.file?.kind === "native-addon" + resolved?.path != null && + isNativeAddonModuleTarget(resolved.path, resolved.file?.kind) ? "loads" : "imports", properties: { diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index 05a4e786f6..a59c8c048d 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -18,7 +18,10 @@ import type { JavaScriptModuleOrigin, JavaScriptSemanticModuleLink, } from "../../domain/javascript/javascriptSemanticIr.js"; -import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js"; +import { + isJsonModuleTarget, + moduleResolutionModeForJavaScriptReference, +} from "../../domain/javascript/javascriptModuleResolutionMode.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { addAstContainsEdge, @@ -327,10 +330,9 @@ const resolveModuleTarget = ( node, path, file, - jsonStatus: - file.kind === "json" || file.kind === "package-json" - ? input.context.analysis.jsonDocuments.read(file).status - : null, + jsonStatus: isJsonModuleTarget(path, file.kind) + ? input.context.analysis.jsonDocuments.read(file).status + : null, status: "resolved", limitations: resolution.limitations, resolution, diff --git a/src/domain/javascript/javascriptModuleResolutionMode.ts b/src/domain/javascript/javascriptModuleResolutionMode.ts index 129c572271..66d130c758 100644 --- a/src/domain/javascript/javascriptModuleResolutionMode.ts +++ b/src/domain/javascript/javascriptModuleResolutionMode.ts @@ -8,6 +8,12 @@ export const isJsonModuleTarget = ( ): boolean => path.endsWith(".json") && (kind === "json" || kind === "package-json"); +/** Native-addon dispatch likewise needs the exact `.node` loader suffix. */ +export const isNativeAddonModuleTarget = ( + path: string, + kind: JavaScriptArtifactFileKind | undefined, +): boolean => path.endsWith(".node") && kind === "native-addon"; + /** Loader evidence retained for a source-level module reference. */ export type JavaScriptModuleResolutionMode = | "import" diff --git a/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts b/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts index ab47a41818..38dd1c5396 100644 --- a/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts +++ b/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts @@ -27,6 +27,32 @@ const analyze = async (root: string) => { }; }; +const analyzeLoaderSuffixCase = async () => { + const root = await createTestTempDirectory("rea-require-suffix-case-"); + const javascript = 'module.exports = { loadedBy: "javascript" };'; + // Case-insensitive hosts cannot hold both spellings in one directory. + await mkdir(join(root, "lower")); + await Promise.all([ + writeFile( + join(root, "main.cjs"), + ["./data.JSON", "./addon.NODE", "./lower/data.json", "./lower/addon.node"] + .map((specifier) => `require(${JSON.stringify(specifier)});`) + .join("\n"), + ), + writeFile(join(root, "data.JSON"), javascript), + writeFile(join(root, "addon.NODE"), javascript), + writeFile(join(root, "lower", "data.json"), '{"loadedBy":"json"}'), + writeFile(join(root, "lower", "addon.node"), Buffer.from([0, 1, 2, 3])), + ]); + // Node matches registered loader extensions case-sensitively. + const nodeRequire = createRequire(join(root, "main.cjs")); + expect(nodeRequire("./data.JSON")).toEqual({ loadedBy: "javascript" }); + expect(nodeRequire("./addon.NODE")).toEqual({ loadedBy: "javascript" }); + const { graph } = await analyze(root); + return (specifier: string) => + graph.edges.filter((edge) => edge.properties.specifier === specifier); +}; + describe("literal CommonJS paths in application graphs", () => { it("agrees with Node for both require projections while preserving ESM suffixes", async () => { const root = await createTestTempDirectory("rea-literal-require-"); @@ -77,6 +103,34 @@ describe("literal CommonJS paths in application graphs", () => { expect(graph.coverage.status).toBe("complete"); }); + it("attaches JSON loader status only to Node's exact .json suffix", async () => { + const edges = await analyzeLoaderSuffixCase(); + const relationship = (specifier: string) => + edges(specifier).find( + (edge) => edge.properties.module_link_kind === "require", + )?.properties; + expect(relationship("./data.JSON")).toMatchObject({ + resolved_path: "data.JSON", + target_file_kind: "json", + target_json_status: null, + }); + expect(relationship("./lower/data.json")).toMatchObject({ + resolved_path: "lower/data.json", + target_file_kind: "json", + target_json_status: "included", + }); + }); + + it("emits a native loads edge only for Node's exact .node suffix", async () => { + const edges = await analyzeLoaderSuffixCase(); + const relations = (specifier: string) => + edges(specifier) + .filter((edge) => edge.properties.kind === "require") + .map((edge) => edge.relation); + expect(relations("./addon.NODE")).toEqual(["imports"]); + expect(relations("./lower/addon.node")).toEqual(["loads"]); + }); + it.each(["literal", "stripped", "both"] as const)( "keeps native graph/count projections aligned with %s files", async (layout) => {