From 75daf0c7df108f04ab41f34aec1a31d4a7ceb322 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:30:47 +0800 Subject: [PATCH] fix(javascript): preserve loader classification and exact source targets --- docs/javascript-artifact-reconstruction.md | 9 ++ .../JavaScriptArtifactPathResolution.ts | 18 ++- ...avascriptModuleResolutionSemantics.test.ts | 123 ++++++++++++++++++ .../javascriptModuleSpecifierPaths.test.ts | 1 - 4 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 04287f59ca..c36a3b7225 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -844,3 +844,12 @@ truncated under the `html_inline_script_locations_per_file` limit, and the scope's evidence counts the omitted locations. The graph names the file and up to 10 starting lines in its limitations, and coverage is partial. Import maps, speculation rules, data blocks, and blank bodies are not JavaScript and are not reported. + +For static source relationships, an explicitly requested declaration filename +(`.d.ts`, `.d.mts`, or `.d.cts`) retains its inventoried identity through both +`import` and `require`. A declaration found only as an emitted-extension fallback +remains unavailable as a runtime implementation. Package URL targets decode +filename escapes once, including dots within filenames; forbidden whole segments +and encoded separators remain rejected. ESM local-path classification uses the +authored request before URL whitespace preprocessing, so leading whitespace +cannot turn a bare request into a relative file dependency. diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index d46af98586..52b4446521 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -648,8 +648,8 @@ const contextualCandidate = ( "URL and Node builtin schemes are outside static artifact module resolution.", ]); if ( - !isRelativeJavaScriptModuleSpecifier(declared) && - !declared.startsWith("/") + !isRelativeJavaScriptModuleSpecifier(input.declaredPath) && + !input.declaredPath.startsWith("/") ) return bareModuleCandidate(input); if (input.moduleKind !== "require") { @@ -1088,7 +1088,7 @@ const resolveLegacyEsmSubpath = ( subpath: string, ): CandidateResolution => { const subpathPath = stripQueryAndFragment(htmlUrlText(subpath)); - const declarationRejection = rejectDeclaration(subpathPath); + const declarationRejection = rejectDeclaration(subpathPath, true); if (declarationRejection !== null) return declarationRejection; let depth = directory === "." ? 0 : directory.split("/").length; for (const segment of subpathPath.split("/")) { @@ -1115,7 +1115,7 @@ const resolveLegacyEsmSubpath = ( const encodedPath = url.pathname.slice( PACKAGE_ARTIFACT_ROOT_URL.pathname.length, ); - const rejected = rejectDeclaration(encodedPath); + const rejected = rejectDeclaration(encodedPath, true); if (rejected !== null) return rejected; const decoded = decodeModuleUrlPath(encodedPath); if (typeof decoded !== "string") return decoded; @@ -1201,7 +1201,7 @@ const resolveExportTarget = ( PACKAGE_ARTIFACT_ROOT_URL.pathname.length, ); // Package targets are URLs for both loaders, so their percent bytes encode. - const rejected = rejectDeclaration(encodedPath, false); + const rejected = rejectDeclaration(encodedPath, true); if (rejected !== null) return withTargetSource(rejected); const decoded = decodeModuleUrlPath(encodedPath); if (typeof decoded !== "string") return withTargetSource(decoded); @@ -1483,7 +1483,15 @@ const resolveCandidate = ( emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [], ): CandidateResolution => { if (!commonJsDirectoryRequest(input)) { + // An explicitly selected source path owns its identity independently of + // whether its declaration bytes can implement a runtime module. + const exactSource = + input.context === "module-specifier" && + input.runtimePathKind === undefined + ? resolveRuntimeSourceCandidates(input, candidate, []) + : null; const file = + exactSource ?? resolveFileCandidates( input, fileCandidates(candidate, input.moduleKind), diff --git a/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts b/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts new file mode 100644 index 0000000000..4e53ac774f --- /dev/null +++ b/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts @@ -0,0 +1,123 @@ +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { + analyzeJavaScriptDirectory, + runNodeJsonControl, +} from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.each(["CLI", "stdio MCP"] as const)( + "preserves module classification and explicit source identities through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-module-owner-semantics-"); + const valid = { + "legacy/x%2Ey.js": "node_modules/legacy/x.y.js", + "mapped/encoded": "node_modules/mapped/x.y.js", + "mapped/double": "node_modules/mapped/x%2Ey.js", + "#encoded": "local.file.mjs", + "./dep.mjs ": "dep.mjs", + "./de\np.mjs": "dep.mjs", + }; + const invalid = [" ./dep.mjs", "\n./dep.mjs", "\t./dep.mjs", ".\n/dep.mjs"]; + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ + type: "module", + imports: { "#encoded": "./local%2Efile.mjs" }, + }), + "main.mjs": [ + ...Object.keys(valid), + ...invalid, + "mapped/missing", + "mapped/invalid", + "./dep.d.ts", + "./cfg.d.mts", + "./legacy.d.cts", + ] + .map((specifier) => `import ${JSON.stringify(specifier)};`) + .join("\n"), + "main.cjs": [ + "./dep.d.ts", + "./cfg.d.mts", + "./legacy.d.cts", + "mapped/encoded", + "mapped/double", + ] + .map((specifier) => `require(${JSON.stringify(specifier)});`) + .join("\n"), + "fallback.cts": 'require("./only.js");', + "dep.d.ts": "export declare const value: number;", + "cfg.d.mts": "export declare const value: number;", + "legacy.d.cts": "export declare const value: number;", + "only.d.ts": "export declare const value: number;", + "dep.mjs": 'throw new Error("must never load");', + "local.file.mjs": 'throw new Error("must never load");', + "node_modules/legacy/package.json": '{"type":"module"}', + "node_modules/legacy/x.y.js": 'throw new Error("must never load");', + "node_modules/mapped/package.json": JSON.stringify({ + type: "module", + exports: { + "./encoded": "./x%2Ey.js", + "./double": "./x%252Ey.js", + "./missing": ["./absent%2Efile.js", "./x.y.js"], + "./invalid": "./%2e%2e/escape.js", + }, + }), + "node_modules/mapped/x.y.js": 'throw new Error("must never load");', + "node_modules/mapped/x%2Ey.js": 'throw new Error("must never load");', + "oracle.mjs": `import {fileURLToPath} from "node:url"; import {relative} from "node:path"; const valid = ${JSON.stringify(Object.keys(valid))}; const invalid = ${JSON.stringify(invalid)}; console.log(JSON.stringify({ valid: valid.map(request => relative(process.cwd(), fileURLToPath(import.meta.resolve(request)))), invalid: invalid.map(request => { try { import.meta.resolve(request); return false; } catch { return true; } }) }));`, + }); + expect(await runNodeJsonControl(root, join(root, "oracle.mjs"))).toEqual({ + valid: Object.values(valid), + invalid: invalid.map(() => true), + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + const edges = (specifier: string) => + result.graph.edges.filter( + (edge) => + edge.relation === "imports" && + edge.properties.specifier === specifier, + ); + for (const [specifier, target] of Object.entries(valid)) { + const matches = edges(specifier); + expect(matches.length, specifier).toBeGreaterThan(0); + for (const edge of matches) + expect(edge.properties).toMatchObject({ + resolution_status: "resolved", + resolved_path: target, + }); + } + for (const specifier of invalid) { + const matches = edges(specifier); + expect(matches.length, specifier).toBeGreaterThan(0); + for (const edge of matches) + expect(edge.properties.resolved_path).toBeNull(); + } + for (const suffix of ["dep.d.ts", "cfg.d.mts", "legacy.d.cts"]) { + const matches = edges(`./${suffix}`); + expect(matches.length, suffix).toBeGreaterThan(0); + for (const edge of matches) + expect(edge.properties).toMatchObject({ + resolution_status: "resolved", + resolved_path: suffix, + }); + } + for (const [specifier, status] of [ + ["./only.js", "unavailable"], + ["mapped/missing", "not-found"], + ["mapped/invalid", "rejected"], + ] as const) { + const matches = edges(specifier); + expect(matches.length, specifier).toBeGreaterThan(0); + for (const edge of matches) + expect(edge.properties).toMatchObject({ + resolution_status: status, + resolved_path: null, + }); + } + }, +); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts index c19baab615..8b0a811191 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts @@ -342,7 +342,6 @@ describe("package exports URL path rejections", () => { "./actual%00file.cjs", "./actual%2Ffile.cjs", "./actual%5Cfile.cjs", - "./actual%2Efile.cjs", "./../../../../escape.cjs", ].map((declaredPath) => ({ moduleKind, declaredPath })), ),