From 7aa943c14bea11f4ab0c8ca8cfe60f6bb462ebdd Mon Sep 17 00:00:00 2001 From: JG Tromp Date: Wed, 28 Oct 2026 12:46:45 +0100 Subject: [PATCH] fix(javascript): resolve Vite build-output preload dependencies (#7745) * fix(javascript): resolve Vite build-output preload dependencies With an absolute base, Vite writes __vite__mapDeps entries relative to the build output directory (for example assets/chunk.js), and its preload helper prefixes the configured base at runtime. REA emitted these entries as dynamic-import module specifiers, so a scheme-less path without a leading dot resolved as a bare package and every entry was reported as an unresolved "external" module, even when the build output directory was selected. The static analyzer now marks such entries with a vite-output: specifier, as Webpack chunk loads are marked with chunk:. Entries starting with ./, ../ or /, and scheme-qualified entries, keep their previous handling. The graph resolves a marked entry as a URL path under each ancestor directory of the importing file; exactly one ancestor holding the file establishes the build output directory. Otherwise the entry is not-found (or unavailable when several ancestors match) with a limitation that names the build output directory. On a synthetic 7-file Vite build, the 12 preload entries that were "external" now resolve when the build output directory is selected, and are not-found with that guidance when only assets/ is selected. * fix(javascript): keep unanalyzed Vite output candidates resolveViteOutputDependency considered only resolved ancestors. A file that exists under an ancestor but was not analyzed (for example dist/assets/lazy.css for the entry assets/lazy.css) resolves there as unavailable, and that result was replaced by the root's not-found with a limitation saying that no ancestor holds the file. Keep resolved and unavailable ancestor candidates together. One candidate is returned with its own path and limitations; several are reported as unavailable with every candidate path and limitation. Only when no ancestor can hold the file does the root's not-found result stand. --- docs/javascript-artifact-reconstruction.md | 7 + .../JavaScriptArtifactGraphFindings.ts | 150 ++++++++++++++---- .../javascriptStaticAnalysisCalls.ts | 25 ++- .../javascriptStaticAnalysisTypes.ts | 8 + .../javascriptVitePreloadDependencies.test.ts | 21 +++ .../viteOutputDependencies.test.ts | 99 ++++++++++++ 6 files changed, 275 insertions(+), 35 deletions(-) create mode 100644 tests/boundary/filesystem/javascript-applications/viteOutputDependencies.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 6bda719d9c..2ac0072053 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -364,6 +364,13 @@ Selected bounded text is then parsed as inert data to recover: relative to the calling module; string worker and service-worker paths use the document base URL or Node's working directory, which static analysis does not establish, so they keep their expression and stay unresolved); +- Vite preload dependency tables (`__vite__mapDeps`): `./` entries resolve + relative to the importing chunk; base-relative entries such as + `assets/chunk.js` carry a `vite-output:` specifier and resolve under the one + ancestor directory of the importer that holds them, the build output + directory. A file found there but not analyzed, such as a stylesheet, stays + `unavailable` with its path. Selecting only the assets directory leaves them + `not-found`; - route, network endpoint, storage, and vendor-marker observations; - local source-map declarations and original source names and content digests; diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 1250fb3319..8bef71eb85 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -3,7 +3,10 @@ import { posix } from "node:path"; import { compactStringIdentityKey } from "../../domain/compactStringIdentity.js"; import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; -import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; +import { + VITE_OUTPUT_DEPENDENCY_PREFIX, + type JavaScriptStaticAnalysis, +} from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { addStaticInferenceEdge, @@ -665,9 +668,13 @@ const resolveReference = ( posix.join(posix.dirname(sourcePath), reference.resolution_base_path), ) : sourcePath; - const chunk = isWorker - ? null - : resolveBundlerChunkReference(context, sourcePath, reference); + const viteDependency = specifier.startsWith(VITE_OUTPUT_DEPENDENCY_PREFIX) + ? specifier.slice(VITE_OUTPUT_DEPENDENCY_PREFIX.length) + : undefined; + const chunk = + isWorker || viteDependency !== undefined + ? null + : resolveBundlerChunkReference(context, sourcePath, reference); if (chunk !== null) return chunk; const module = isWorker || reference.module_runtime === undefined @@ -687,35 +694,38 @@ const resolveReference = ( }; } // Worker scripts are URLs relative to the script, not module specifiers. - const resolution = isWorker - ? resolveArtifactPathByContext({ - declaredPath: specifier, - sourcePath, - context: "url-reference", - files: context.filesByPath, - jsonDocuments: context.analysis.jsonDocuments, - directoryPaths: context.directoryPaths, - unanalyzedFiles: context.unanalyzedFiles, - }) - : resolveArtifactPathByContext({ - declaredPath: specifier, - sourcePath: resolutionSourcePath, - context: "module-specifier", - files: context.filesByPath, - jsonDocuments: context.analysis.jsonDocuments, - directoryPaths: context.directoryPaths, - unanalyzedFiles: context.unanalyzedFiles, - ...("require" === reference.kind || - "static-import" === reference.kind || - "dynamic-import" === reference.kind - ? { - moduleKind: moduleResolutionModeForJavaScriptReference( - resolutionSourcePath, - reference.kind, - ), - } - : {}), - }); + const resolution = + viteDependency !== undefined + ? resolveViteOutputDependency(context, sourcePath, viteDependency) + : isWorker + ? resolveArtifactPathByContext({ + declaredPath: specifier, + sourcePath, + context: "url-reference", + files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, + directoryPaths: context.directoryPaths, + unanalyzedFiles: context.unanalyzedFiles, + }) + : resolveArtifactPathByContext({ + declaredPath: specifier, + sourcePath: resolutionSourcePath, + context: "module-specifier", + files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, + directoryPaths: context.directoryPaths, + unanalyzedFiles: context.unanalyzedFiles, + ...("require" === reference.kind || + "static-import" === reference.kind || + "dynamic-import" === reference.kind + ? { + moduleKind: moduleResolutionModeForJavaScriptReference( + resolutionSourcePath, + reference.kind, + ), + } + : {}), + }); const path = resolution.resolved_path; const targetPath = path ?? resolution.candidate_path; const targetKind = @@ -773,6 +783,80 @@ const resolveReference = ( }; }; +/** + * Vite's preload helper prefixes the configured base to a path under the + * build output directory, which is an ancestor of the importing chunk. Only + * one ancestor holding the file establishes that directory. + */ +const resolveViteOutputDependency = ( + context: JavaScriptArtifactGraphContext, + sourcePath: string, + dependency: string, +): ArtifactPathResolution => { + const resolutions = ancestorDirectories(sourcePath).map((directory) => + resolveArtifactPathByContext({ + declaredPath: dependency, + sourcePath: posix.join(directory, posix.basename(sourcePath)), + context: "url-reference", + files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, + directoryPaths: context.directoryPaths, + unanalyzedFiles: context.unanalyzedFiles, + }), + ); + // An ancestor whose candidate exists but was not analyzed, or whose absence + // is unknown, may still be the build output directory. Keep it. + const candidates = resolutions.filter( + ({ resolution_status: status }) => + status === "resolved" || status === "unavailable", + ); + const [single] = candidates; + if (candidates.length === 1 && single !== undefined) return single; + const root = resolutions.at(-1); + if (root === undefined) + throw new TypeError("A source path has at least the root directory"); + if (candidates.length > 1) { + const { candidate_path: _rootCandidate, ...rootFacts } = root; + const paths = candidates.map( + (candidate) => candidate.resolved_path ?? candidate.candidate_path, + ); + return { + ...rootFacts, + resolved_path: null, + resolution_status: "unavailable", + limitations: [ + ...new Set([ + ...candidates.flatMap(({ limitations }) => limitations), + `Several ancestors of the importing file may hold this Vite build-output path (${paths.join(", ")}); the build output directory is not established.`, + ]), + ], + }; + } + return { + ...root, + resolved_path: null, + resolution_status: + root.resolution_status === "resolved" + ? "not-found" + : root.resolution_status, + limitations: [ + ...root.limitations, + "A Vite preload dependency is relative to the build output directory, and no ancestor of the importing file holds it. Select the build output directory, which holds index.html and the assets directory.", + ], + }; +}; + +/** The source's directory and each ancestor up to the selected root. */ +const ancestorDirectories = (sourcePath: string): string[] => { + const directories: string[] = []; + for (let directory = posix.dirname(sourcePath); ;) { + directories.push(directory === "." ? "" : directory); + if (directory === "." || directory === "/" || directory === "") break; + directory = posix.dirname(directory); + } + return directories; +}; + const resolveBundlerChunkReference = ( context: JavaScriptArtifactGraphContext, sourcePath: string, diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index b7dbccda5d..e71986ad51 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -7,7 +7,11 @@ import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; -import { htmlUrlText, stripQueryAndFragment } from "../artifactPathSyntax.js"; +import { + hasScheme, + htmlUrlText, + stripQueryAndFragment, +} from "../artifactPathSyntax.js"; import { sanitizeEndpointCandidate } from "../browserObservation.js"; import { addFindingOnce, @@ -36,6 +40,7 @@ import type { JavaScriptFindingContext as FindingContext, JavaScriptReferenceInput as ReferenceInput, } from "./javascriptStaticAnalysisState.js"; +import { VITE_OUTPUT_DEPENDENCY_PREFIX } from "./javascriptStaticAnalysisTypes.js"; import { resolveSemanticBindingState, semanticStableInitializer, @@ -392,10 +397,26 @@ const vitePreloadDependencySpecifiers = ( return indexes.map((element) => { if (element === null || !t.isNumericLiteral(element)) return undefined; const index = element.value; - return Number.isSafeInteger(index) && index >= 0 ? table[index] : undefined; + return Number.isSafeInteger(index) && index >= 0 + ? viteDependencySpecifier(table[index]) + : undefined; }); }; +// A relative base writes importer-relative `./` paths, which remain module +// URLs. Without a scheme or leading dot or slash, the entry names a file under +// the build output directory. +const viteDependencySpecifier = ( + dependency: string | undefined, +): string | undefined => + dependency === undefined || + dependency === "" || + dependency.startsWith(".") || + dependency.startsWith("/") || + hasScheme(dependency) + ? dependency + : `${VITE_OUTPUT_DEPENDENCY_PREFIX}${dependency}`; + const isExplicitDefaultArgument = ( argument: t.CallExpression["arguments"][number], bindings: JavaScriptSemanticAnalysisState, diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 994ab20a55..0ad2c5eba5 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -5,6 +5,14 @@ import type { JavaScriptStaticCoverage } from "./javascriptStaticCoverage.js"; /** Token semantics shared by bundle producers and worker admission. */ export const JAVASCRIPT_AST_FINGERPRINT_ALGORITHM = "babel-ast-v3"; +/** + * Marks a Vite preload dependency written relative to the build output + * directory (Vite's absolute-base form, such as `assets/chunk.js`). The + * runtime helper prefixes the configured base, so the path is not a module + * specifier and not relative to the importing chunk. + */ +export const VITE_OUTPUT_DEPENDENCY_PREFIX = "vite-output:"; + /** Static syntax context retained for later artifact path resolution. */ export type JavaScriptStaticPathContext = | "module-specifier" diff --git a/src/domain/javascript/javascriptVitePreloadDependencies.test.ts b/src/domain/javascript/javascriptVitePreloadDependencies.test.ts index 681af59318..27554181c6 100644 --- a/src/domain/javascript/javascriptVitePreloadDependencies.test.ts +++ b/src/domain/javascript/javascriptVitePreloadDependencies.test.ts @@ -121,4 +121,25 @@ describe("Vite preload dependency recovery", () => { .map(({ specifier }) => specifier), ).toEqual(["./first.js", null]); }); + + it("marks build-output paths apart from importer-relative module URLs", () => { + const analysis = analyzeJavaScriptStaticSource(` + const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies = + (map.f || (map.f = ["assets/lazy.js", "./near.js", "https://cdn.example/x.js", "/abs.js"]))) => + indexes.map((index) => dependencies[index]); + __vite__mapDeps([0, 1, 2, 3]); + `); + + expect( + analysis.references + .filter(({ kind }) => kind === "dynamic-import") + .map(({ specifier }) => specifier) + .sort(), + ).toEqual([ + "./near.js", + "/abs.js", + "https://cdn.example/x.js", + "vite-output:assets/lazy.js", + ]); + }); }); diff --git a/tests/boundary/filesystem/javascript-applications/viteOutputDependencies.test.ts b/tests/boundary/filesystem/javascript-applications/viteOutputDependencies.test.ts new file mode 100644 index 0000000000..dafdf609be --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/viteOutputDependencies.test.ts @@ -0,0 +1,99 @@ +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { + reconstructJavaScriptFixture, + writeFixtureFiles, +} from "../../../support/javascriptApplicationFixture.js"; + +// Vite's absolute-base build output: the helper table names files under the +// output directory, and the real import is relative to the importing chunk. +const viteBuild = async (): Promise => { + const root = await createTestTempDirectory("rea-vite-output-"); + await writeFixtureFiles(root, { + "index.html": + '', + "assets/index.js": ` + const __vite__mapDeps = (i, m = __vite__mapDeps, d = + (m.f || (m.f = ["assets/lazy.js", "assets/missing.js"]))) => i.map((i) => d[i]); + const preload = (load, deps) => load(); + export const route = () => + preload(() => import("./lazy.js"), __vite__mapDeps([0, 1])); + `, + "assets/lazy.js": "export const lazy = true;", + }); + return root; +}; + +const preloadEdges = ( + graph: Awaited>["graph"], +) => + graph.edges + .filter( + ({ relation, properties }) => + relation === "imports" && + typeof properties.specifier === "string" && + properties.specifier.startsWith("vite-output:"), + ) + .map(({ properties }) => ({ + specifier: properties.specifier, + resolution_status: properties.resolution_status, + resolved_path: properties.resolved_path, + })); + +describe("Vite build-output preload dependencies", () => { + it("resolves a dependency under the selected build output directory", async () => { + const result = await reconstructJavaScriptFixture(await viteBuild()); + + expect(preloadEdges(result.graph)).toContainEqual({ + specifier: "vite-output:assets/lazy.js", + resolution_status: "resolved", + resolved_path: "assets/lazy.js", + }); + expect(JSON.stringify(result.graph)).toContain( + "no ancestor of the importing file holds it", + ); + }); + + it("does not report a dependency as an external package when only the assets directory is selected", async () => { + const root = await viteBuild(); + const result = await reconstructJavaScriptFixture(join(root, "assets")); + const serialized = JSON.stringify(result.graph); + + expect(serialized).not.toContain('"resolution_status":"external"'); + expect(serialized).toContain( + "A Vite preload dependency is relative to the build output directory", + ); + }); + + it("keeps an inventoried but unanalyzed dependency under a nested build output directory", async () => { + const root = await createTestTempDirectory("rea-vite-output-css-"); + await writeFixtureFiles(root, { + "dist/index.html": + '', + "dist/assets/index.js": ` + const __vite__mapDeps = (i, m = __vite__mapDeps, d = + (m.f || (m.f = ["assets/lazy.css"]))) => i.map((i) => d[i]); + export const route = () => __vite__mapDeps([0]); + `, + "dist/assets/lazy.css": ".lazy { color: green; }", + }); + const result = await reconstructJavaScriptFixture(root); + const edge = result.graph.edges.find( + ({ properties }) => + properties.specifier === "vite-output:assets/lazy.css", + ); + + expect(edge?.properties).toMatchObject({ + resolution_status: "unavailable", + candidate_path: "dist/assets/lazy.css", + }); + const serialized = JSON.stringify(edge); + expect(serialized).toContain("dist/assets/lazy.css"); + expect(serialized).not.toContain( + "no ancestor of the importing file holds it", + ); + }); +});