diff --git a/src/application/ReferenceSourceImportEntries.test.ts b/src/application/ReferenceSourceImportEntries.test.ts index 9997c491c3..decbe02a98 100644 --- a/src/application/ReferenceSourceImportEntries.test.ts +++ b/src/application/ReferenceSourceImportEntries.test.ts @@ -155,9 +155,12 @@ it("retains Kotlin source identity and language across the inventory projection" expect(parsed.relationships).toEqual([]); }); -it("resolves runtime require package mains without an index and before typed indexes", () => { +it("resolves declared package mains and index fallback from source-owned fields", () => { const files = [ - ["main.cjs", 'require("./pkg");\nrequire("./dep");\n'], + [ + "main.cjs", + 'require("./pkg");\nrequire("./dep");\nrequire("./fallback");\n', + ], [ "main.ts", 'import dep = require("./typed");\n' + @@ -167,6 +170,9 @@ it("resolves runtime require package mains without an index and before typed ind ], ["pkg/package.json", '{"main":"actual.js"}\n'], ["pkg/actual.js", 'module.exports = "package main";\n'], + ["fallback/package.json", "{}\n"], + ["fallback/index.js", 'module.exports = "index";\n'], + ["fallback/phantom.js", 'module.exports = "host decoy";\n'], ["dep.js", 'module.exports = "file";\n'], ["dep/index.ts", 'export const value = "index";\n'], ["typed.js", 'module.exports = "runtime file";\n'], @@ -192,12 +198,32 @@ it("resolves runtime require package mains without an index and before typed ind limitations: [], }; - const parsed = parseReferenceSourceEntries( - read, - new Set(files.map(([path]) => path)), - [], - ); + const previous = Object.getOwnPropertyDescriptor(Object.prototype, "main"); + let parsed: ReturnType; + try { + Object.defineProperty(Object.prototype, "main", { + configurable: true, + writable: true, + value: "phantom.js", + }); + parsed = parseReferenceSourceEntries( + read, + new Set(files.map(([path]) => path)), + [], + ); + } finally { + if (previous === undefined) + Reflect.deleteProperty(Object.prototype, "main"); + else Object.defineProperty(Object.prototype, "main", previous); + } + expect(parsed.relationships).toContainEqual({ + from_path: "main.cjs", + to: "fallback/index.js", + kind: "requires", + resolution: "internal", + parse_state: "parsed", + }); expect(parsed.relationships).toContainEqual({ from_path: "main.cjs", to: "pkg/actual.js", diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 8b1002ebc1..d378071d79 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -136,7 +136,9 @@ const readPackageMains = ( new TextDecoder("utf-8", { fatal: true }).decode(entry.bytes), ); const main = - typeof manifest === "object" && manifest !== null + typeof manifest === "object" && + manifest !== null && + Object.hasOwn(manifest, "main") ? Reflect.get(manifest, "main") : undefined; mains.set( diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts index 01f60fa94e..5e4394941d 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts @@ -22,6 +22,62 @@ const packageObservation = (text: string) => invalid_utf8_files: 0, }).packages[0]; +it("retains only declared package metadata despite inherited host fields", () => { + const inherited = { + name: "host-name", + version: "host-version", + main: "host-entry.cjs", + renderer: "host-renderer.cjs", + browser: "host-browser.cjs", + module: "host-module.mjs", + }; + const previous = Object.fromEntries( + Object.keys(inherited).map((key) => [ + key, + Object.getOwnPropertyDescriptor(Object.prototype, key), + ]), + ); + let absent: ReturnType; + let declared: ReturnType; + try { + for (const [key, value] of Object.entries(inherited)) + Object.defineProperty(Object.prototype, key, { + configurable: true, + writable: true, + value, + }); + absent = packageObservation("{}"); + declared = packageObservation( + '{"name":"source-name","version":"1.0.0","main":null,"browser":"source-browser.cjs"}', + ); + } finally { + for (const key of Object.keys(inherited)) { + const descriptor = previous[key]; + if (descriptor === undefined) + Reflect.deleteProperty(Object.prototype, key); + else Object.defineProperty(Object.prototype, key, descriptor); + } + } + expect(absent).toMatchObject({ + status: "included", + name: null, + version: null, + main: null, + main_status: "default", + main_entrypoint: "", + renderer: null, + }); + expect(declared).toMatchObject({ + status: "included", + name: "source-name", + version: "1.0.0", + main: null, + main_status: "invalid", + main_entrypoint: "", + renderer: "source-browser.cjs", + }); +}); + it.each([null, false, 123, {}, []].map((main) => ({ main })))( "retains non-string main $main evidence separately from Node index fallback", ({ main }) => { diff --git a/src/application/javascript/JavaScriptJsonDocuments.ts b/src/application/javascript/JavaScriptJsonDocuments.ts index 9b79023b82..b58d5474bc 100644 --- a/src/application/javascript/JavaScriptJsonDocuments.ts +++ b/src/application/javascript/JavaScriptJsonDocuments.ts @@ -135,18 +135,16 @@ const parseDocument = ( const topLevelKeys = Object.freeze( Object.keys(record).sort(compareUnicodeCodePoints), ); - const packageFields = - snapshot.kind === "package-json" - ? Object.freeze( - Object.fromEntries( - PACKAGE_PROPERTIES.flatMap((key) => - Object.hasOwn(record, key) - ? [[key, Reflect.get(record, key)]] - : [], - ), - ), - ) - : null; + // Absent source fields must stay absent when consumers read named properties. + const packageFields: Partial< + Record<(typeof PACKAGE_PROPERTIES)[number], unknown> + > | null = snapshot.kind === "package-json" ? Object.create(null) : null; + if (packageFields !== null) { + for (const key of PACKAGE_PROPERTIES) + if (Object.hasOwn(record, key)) + packageFields[key] = Reflect.get(record, key); + Object.freeze(packageFields); + } return Object.freeze({ source: snapshot.source, status: "included", diff --git a/tests/boundary/javascript/packageMainHostPaths.test.ts b/tests/boundary/javascript/packageMainHostPaths.test.ts index ed65d5d859..5a56a2611f 100644 --- a/tests/boundary/javascript/packageMainHostPaths.test.ts +++ b/tests/boundary/javascript/packageMainHostPaths.test.ts @@ -7,16 +7,22 @@ import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/java import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { writeFixtureFiles } from "../../support/workspace/writeFixtureFiles.js"; -it.each(["absolute-host", "relative-package"] as const)( - "compares %s package main with the native Node resolver", +it.each([ + "absolute-host", + "relative-package", + "inherited-main", + "inherited-exports", +] as const)( + "compares %s package selection with the native Node resolver", async (kind) => { const root = await createTestTempDirectory("rea-package-main-host-"); const artifact = join(root, "artifact"); const host = join(root, "host/window.cjs"); const main = kind === "absolute-host" ? host : "entry.cjs"; + const metadata = kind.startsWith("inherited-") ? {} : { main }; const contents = { "app/main.cjs": "throw new Error('Analyzed input must never execute');", - "app/node_modules/fixture/package.json": JSON.stringify({ main }), + "app/node_modules/fixture/package.json": JSON.stringify(metadata), "app/node_modules/fixture/entry.cjs": "throw new Error('Package input must never execute');", "app/node_modules/fixture/index.js": @@ -34,11 +40,6 @@ it.each(["absolute-host", "relative-package"] as const)( ), }); const loader = createRequire(join(artifact, "app/main.cjs")); - expect(loader.resolve("fixture")).toBe( - kind === "absolute-host" - ? host - : join(artifact, "app/node_modules/fixture/entry.cjs"), - ); const files = new Map( Object.entries(contents).map(([path, value]) => [ path, @@ -54,18 +55,43 @@ it.each(["absolute-host", "relative-package"] as const)( }, ]), ); - expect( - resolveStandaloneArtifactPath({ + const property = kind === "inherited-exports" ? "exports" : "main"; + const previous = Object.getOwnPropertyDescriptor( + Object.prototype, + property, + ); + let nativePath: string; + let resolution: ReturnType; + try { + if (kind.startsWith("inherited-")) + Object.defineProperty(Object.prototype, property, { + configurable: true, + writable: true, + value: property === "main" ? host : "./entry.cjs", + }); + nativePath = loader.resolve("fixture"); + resolution = resolveStandaloneArtifactPath({ declaredPath: "fixture", sourcePath: "app/main.cjs", context: "module-specifier", moduleKind: "require", files, - }), - ).toMatchObject({ + }); + } finally { + if (previous === undefined) + Reflect.deleteProperty(Object.prototype, property); + else Object.defineProperty(Object.prototype, property, previous); + } + const target = kind.startsWith("inherited-") ? "index.js" : "entry.cjs"; + expect(nativePath).toBe( + kind === "absolute-host" + ? host + : join(artifact, "app/node_modules/fixture", target), + ); + expect(resolution).toMatchObject({ resolution_status: kind === "absolute-host" ? "unavailable" : "resolved", resolved_path: - kind === "absolute-host" ? null : "app/node_modules/fixture/entry.cjs", + kind === "absolute-host" ? null : `app/node_modules/fixture/${target}`, }); }, );