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", + ); + }); +});