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
This commit is contained in:
RRFZ authored and GitHub committed 2026-10-29 13:43:55 +08:00
1 parent 4fbce5c122
commit fca331804a
4 files changed
+70 -6

No files matched your search

@@ -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: {
@@ -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,
@@ -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"
@@ -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) => {