diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 2a2ebef9d5..77a448b4a3 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -272,6 +272,147 @@ describe("unavailable inventoried native add-ons", () => { }); }); +describe("unavailable package metadata", () => { + it("blocks directory fallback until same-container manifests are readable", () => { + const manifest = { + path: "app/relative/package.json", + container_sha256: file("app/main.cjs", "root").container_sha256, + sha256: null, + bytes: 28, + inventory_artifact_id: null, + inventory_occurrence_id: "missing-relative-manifest", + kind: "package-json" as const, + hash_status: "unavailable" as const, + limitations: ["unpacked manifest companion is missing"], + }; + const files = fileMap([ + file("app/main.cjs", "root"), + file("app/main.mjs", "root"), + file("app/relative/index.js", "root"), + ]); + const requestedPaths: string[] = []; + const lookup = { + get: (path: string) => { + requestedPaths.push(path); + return path === manifest.path ? manifest : undefined; + }, + hasDirectory: () => false, + }; + const relativeRequire = resolve({ + declaredPath: "./relative", + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files, + unanalyzedFiles: lookup, + }); + expect(requestedPaths).toContain(manifest.path); + expect(relativeRequire).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + limitations: ["unpacked manifest companion is missing"], + }); + expect( + resolve({ + declaredPath: "./relative", + sourcePath: "app/main.mjs", + context: "module-specifier", + moduleKind: "import", + files, + unanalyzedFiles: lookup, + }), + ).toMatchObject({ resolution_status: "not-found", resolved_path: null }); + + const restoredFiles = fileMap([ + ...files.values(), + file("app/relative/package.json", "root", '{"main":"public.js"}'), + file("app/relative/public.js", "root"), + ]); + expect( + resolve({ + declaredPath: "./relative", + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files: restoredFiles, + unanalyzedFiles: lookup, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/relative/public.js", + }); + }); + + it("keeps bare package exports unknown while its manifest is unavailable", () => { + const containerSha256 = file("app/main.mjs", "root").container_sha256; + const manifest = { + path: "app/node_modules/fixture/package.json", + container_sha256: containerSha256, + sha256: null, + bytes: 47, + inventory_artifact_id: null, + kind: "package-json" as const, + hash_status: "unavailable" as const, + limitations: ["unpacked exports manifest is missing"], + }; + const files = fileMap([ + file("app/main.cjs", "root"), + file("app/main.mjs", "root"), + file("app/node_modules/fixture/index.js", "root"), + file("app/node_modules/fixture/import.js", "root"), + file("app/node_modules/fixture/require.js", "root"), + ]); + const lookup = { + get: (path: string) => (path === manifest.path ? manifest : undefined), + hasDirectory: () => false, + }; + for (const [sourcePath, moduleKind] of [ + ["app/main.cjs", "require"], + ["app/main.mjs", "import"], + ] as const) + expect( + resolve({ + declaredPath: "fixture", + sourcePath, + context: "module-specifier", + moduleKind, + files, + unanalyzedFiles: lookup, + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + limitations: ["unpacked exports manifest is missing"], + }); + + const restoredFiles = fileMap([ + ...files.values(), + file( + manifest.path, + "root", + '{"exports":{"import":"./import.js","require":"./require.js"}}', + ), + ]); + for (const [sourcePath, moduleKind, target] of [ + ["app/main.cjs", "require", "app/node_modules/fixture/require.js"], + ["app/main.mjs", "import", "app/node_modules/fixture/import.js"], + ] as const) + expect( + resolve({ + declaredPath: "fixture", + sourcePath, + context: "module-specifier", + moduleKind, + files: restoredFiles, + unanalyzedFiles: lookup, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: target, + }); + }); +}); + describe("bare package root file lookup", () => { it("uses loader-specific bare package roots when only a sibling .js file exists", () => { const absentDirectory = fileMap([ diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index b056ce959f..7a3e4c89e0 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -1144,10 +1144,21 @@ const readPackageMetadata = ( const path = posix.join(directory, "package.json"); const file = input.files.get(path); const source = input.files.get(input.sourcePath); - if ( - file === undefined || - (source !== undefined && file.container_sha256 !== source.container_sha256) - ) + if (file === undefined) { + const unavailable = input.unanalyzedFiles?.get(path); + if ( + source !== undefined && + unavailable?.container_sha256 === source.container_sha256 + ) + return { + kind: "unavailable", + limitation: + unavailable.limitations?.[0] ?? + `Directory package metadata ${path} was inventoried but its bytes are unavailable.`, + }; + return { kind: "absent" }; + } + if (source !== undefined && file.container_sha256 !== source.container_sha256) return { kind: "absent" }; if (!file.text.included) return { diff --git a/tests/boundary/cli/javascriptSelfImportRelationships.test.ts b/tests/boundary/cli/javascriptSelfImportRelationships.test.ts index 27c4a9ed4d..97d542fdd8 100644 --- a/tests/boundary/cli/javascriptSelfImportRelationships.test.ts +++ b/tests/boundary/cli/javascriptSelfImportRelationships.test.ts @@ -1,4 +1,5 @@ -import { rm } from "node:fs/promises"; +import { createPackageWithOptions } from "@electron/asar"; +import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; @@ -15,6 +16,92 @@ import { writeFixtureFiles, } from "../../support/javascriptApplicationFixture.js"; +cliTest( + "keeps missing same-container package manifests unavailable until restored", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-missing-package-manifests-", + ); + const sourceRoot = join(root, "source"); + const archive = join(root, "app.asar"); + const relativeManifest = '{"main":"public.js"}'; + const exportsManifest = + '{"exports":{"import":"./import.js","require":"./require.js"}}'; + await writeFixtureFiles(sourceRoot, { + "main.cjs": 'require("./relative");\nrequire("fixture");\n', + "main.mjs": 'import "fixture";\n', + "relative/package.json": relativeManifest, + "relative/index.js": "export const fallback = true;\n", + "relative/public.js": "export const publicEntry = true;\n", + "node_modules/fixture/package.json": exportsManifest, + "node_modules/fixture/index.js": "export const fallback = true;\n", + "node_modules/fixture/import.js": "export const imported = true;\n", + "node_modules/fixture/require.js": "module.exports = true;\n", + }); + await createPackageWithOptions(sourceRoot, archive, { + unpack: "package.json", + }); + const unpacked = `${archive}.unpacked`; + const relativeManifestPath = join(unpacked, "relative/package.json"); + const exportsManifestPath = join( + unpacked, + "node_modules/fixture/package.json", + ); + await Promise.all([rm(relativeManifestPath), rm(exportsManifestPath)]); + + const analyze = async () => { + const output = await cli.run({ + arguments: ["analyze-javascript-application", archive, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + return javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + }; + const unavailable = await analyze(); + const cjs = findSourceModule(unavailable.graph, "main.cjs"); + const mjs = findSourceModule(unavailable.graph, "main.mjs"); + for (const [source, specifier] of [ + [cjs, "./relative"], + [cjs, "fixture"], + [mjs, "fixture"], + ] as const) + expect( + unavailable.graph.edges.find( + (edge) => + edge.source_node_id === source?.node_id && + edge.properties.specifier === specifier, + )?.properties, + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + }); + expect(unavailable.graph.coverage.status).toBe("partial"); + + await Promise.all([ + writeFile(relativeManifestPath, relativeManifest), + writeFile(exportsManifestPath, exportsManifest), + ]); + const restored = await analyze(); + for (const [sourcePath, specifier, target] of [ + ["main.cjs", "./relative", "relative/public.js"], + ["main.cjs", "fixture", "node_modules/fixture/require.js"], + ["main.mjs", "fixture", "node_modules/fixture/import.js"], + ] as const) + expect( + restored.graph.edges.find( + (edge) => + edge.source_node_id === + findSourceModule(restored.graph, sourcePath)?.node_id && + edge.properties.specifier === specifier, + )?.properties, + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: target, + }); + }, +); + cliTest( "resolves ESM specifiers containing whitespace using URL text semantics", async ({ cli }) => {