From 7c8eb63fccecb9d8e46c652b7745067a4ad87ea7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:28:05 +0800 Subject: [PATCH] fix(javascript): retain package entrypoint provenance --- docs/javascript-artifact-reconstruction.md | 9 ++ ...ScriptArtifactAnalysis.packageMain.test.ts | 65 ++++++++++- .../javascript/JavaScriptArtifactAnalysis.ts | 26 ++++- .../JavaScriptArtifactAnalysisTypes.ts | 18 ++- .../JavaScriptArtifactGraphStructure.ts | 26 +++-- .../javascriptModuleRelationships.test.ts | 2 +- ...avascriptPackageRendererProvenance.test.ts | 105 ++++++++++++++++++ ...riptArtifactGraphObservationLookup.test.ts | 3 +- 8 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 9e10c06036..f166814287 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -72,6 +72,15 @@ code-point order. Dependency manifests remain package observations but do not declare Electron main or renderer entries. If only dependency manifests are present, the inventory artifact remains the graph root. +Package observations keep the legacy `renderer` property as a derived entrypoint +candidate: the first nonempty string from `renderer`, `browser`, then `module`. +It does not assert that the source declares a literal `renderer` field. +`renderer_declarations` records the actual present fields: strings, including +empty strings, remain exact; non-string values use `{ "status": "non-string" }` +without copying nested browser mappings. An empty record means no such fields +were present; `null` means package metadata was invalid or unavailable. Renderer +role mechanisms and package load edges name the field that supplied the candidate. + ## Select sources before analysis For a question about known entry sources, supply exact artifact-relative file diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts index 2d97028027..972a4ea767 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts @@ -2,23 +2,26 @@ import { expect, it } from "vitest"; import { analyzeArtifactFiles } from "../../../tests/fixtures/javascriptArtifactAnalysis.js"; -const packageObservation = async (text: string) => { +const packageObservation = async (text: string | null) => { const { analysis } = await analyzeArtifactFiles({ files: [ { path: "app/package.json", container_sha256: "a".repeat(64), sha256: "b".repeat(64), - bytes: Buffer.byteLength(text), + bytes: Buffer.byteLength(text ?? ""), inventory_artifact_id: "package-main-fixture", kind: "package-json", unpacked: false, - text: { included: true, value: text }, + text: + text === null + ? { included: false, reason: "invalid-utf8" } + : { included: true, value: text }, }, ], unavailable_relevant_files: [], containers: [], - text_bytes_read: Buffer.byteLength(text), + text_bytes_read: Buffer.byteLength(text ?? ""), invalid_utf8_files: 0, }); return analysis.packages[0]; @@ -36,7 +39,7 @@ it("retains declared package metadata and normal defaults", async () => { main: null, main_status: "default", main_entrypoint: "", - renderer: null, + renderer_declarations: {}, }); expect(declared).toMatchObject({ status: "included", @@ -45,10 +48,60 @@ it("retains declared package metadata and normal defaults", async () => { main: null, main_status: "invalid", main_entrypoint: "", - renderer: "source-browser.cjs", + renderer_declarations: { browser: "source-browser.cjs" }, + renderer_entrypoint: { field: "browser", path: "source-browser.cjs" }, }); }); +it.each([ + { + fields: { + renderer: "renderer.js", + browser: "browser.js", + module: "module.js", + }, + field: "renderer", + path: "renderer.js", + }, + { + fields: { renderer: false, browser: "browser.js", module: "module.js" }, + field: "browser", + path: "browser.js", + }, + { + fields: { renderer: "", browser: {}, module: "module.js" }, + field: "module", + path: "module.js", + }, +])( + "keeps simultaneous renderer declarations separate from selected $field provenance", + async ({ fields, field, path }) => { + const observation = await packageObservation(JSON.stringify(fields)); + expect(observation).toMatchObject({ renderer_entrypoint: { field, path } }); + for (const [key, value] of Object.entries(fields)) + expect(observation).toMatchObject({ + renderer_declarations: { + [key]: typeof value === "string" ? value : { status: "non-string" }, + }, + }); + }, +); + +it.each([ + ["{}", {}], + ["{", null], + ["null", null], + [null, null], +] as const)( + "keeps absent/invalid/unavailable renderer origins distinct for %s", + async (text, declarations) => { + expect(await packageObservation(text)).toMatchObject({ + renderer_declarations: declarations, + renderer_entrypoint: null, + }); + }, +); + it.each([null, false, 123, {}, []].map((main) => ({ main })))( "retains non-string main $main evidence separately from Node index fallback", async ({ main }) => { diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 21ae157f6e..648fa64565 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -379,6 +379,23 @@ const parsePackage = ( if (document.packageFields === null) return invalidPackage(file, "package.json root is not an object."); const value = document.packageFields; + const rendererDeclarations: NonNullable< + JavaScriptPackageObservation["renderer_declarations"] + > = {}; + let rendererEntrypoint: JavaScriptPackageObservation["renderer_entrypoint"] = + null; + for (const field of ["renderer", "browser", "module"] as const) { + if (!Object.hasOwn(value, field)) continue; + const declaration = value[field]; + rendererDeclarations[field] = + typeof declaration === "string" ? declaration : { status: "non-string" }; + if ( + rendererEntrypoint === null && + typeof declaration === "string" && + declaration !== "" + ) + rendererEntrypoint = { field, path: declaration }; + } return { path: file.path, sha256: file.sha256, @@ -393,10 +410,8 @@ const parsePackage = ( ? "declared" : "invalid", main_entrypoint: typeof value.main === "string" ? value.main : "", - renderer: - optionalString(value.renderer) ?? - optionalString(value.browser) ?? - optionalString(value.module), + renderer_declarations: rendererDeclarations, + renderer_entrypoint: rendererEntrypoint, limitation: null, }; }; @@ -421,7 +436,8 @@ const unavailablePackage = ( main: null, main_status: "unavailable", main_entrypoint: null, - renderer: null, + renderer_declarations: null, + renderer_entrypoint: null, limitation, }); diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index 144e4af58f..cd19abb8bf 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -20,6 +20,14 @@ interface JavaScriptStructuredObservation { readonly sha256: string; } +/** Present source declarations only; non-strings stay bounded and retain source ownership. */ +export type JavaScriptPackageRendererDeclarations = Partial< + Record< + "renderer" | "browser" | "module", + string | { readonly status: "non-string" } + > +>; + interface JavaScriptPackageValues { readonly name: string | null; readonly version: string | null; @@ -27,7 +35,12 @@ interface JavaScriptPackageValues { readonly main_status: "default" | "declared" | "invalid"; /** Node launch declaration; an empty string requests the package index fallback. */ readonly main_entrypoint: string; - readonly renderer: string | null; + readonly renderer_declarations: JavaScriptPackageRendererDeclarations; + /** Selected static renderer candidate, retaining the declaration that supplied it. */ + readonly renderer_entrypoint: { + readonly field: "renderer" | "browser" | "module"; + readonly path: string; + } | null; } /** Parsed package metadata used only for static entrypoint discovery. */ @@ -44,7 +57,8 @@ export type JavaScriptPackageObservation = JavaScriptStructuredObservation & readonly main: null; readonly main_status: "unavailable"; readonly main_entrypoint: null; - readonly renderer: null; + readonly renderer_declarations: null; + readonly renderer_entrypoint: null; readonly limitation: string; } ); diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 086f0531d2..c3ff99f226 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -53,6 +53,7 @@ interface PackageRoleInput { readonly packageFile: JavaScriptArtifactFile; readonly kind: "electron-main" | "electron-renderer"; readonly declaredPath: string | null; + readonly field: "main" | "renderer" | "browser" | "module"; } /** Create the exact root artifact node from the inventory manifest. */ @@ -552,7 +553,9 @@ export const addJavaScriptPackageNodes = ( version: packageValue.version, main: packageValue.main, main_status: packageValue.main_status, - renderer: packageValue.renderer, + // Legacy derived candidate; source fields live in renderer_declarations. + renderer: packageValue.renderer_entrypoint?.path ?? null, + renderer_declarations: packageValue.renderer_declarations, parse_status: packageValue.status, }, evidence: artifactObservationEvidence({ @@ -612,13 +615,16 @@ export const addJavaScriptPackageNodes = ( packageFile: file, kind: "electron-main", declaredPath: packageValue.main, + field: "main", }); - addPackageRole(context, { - packageNode: node, - packageFile: file, - kind: "electron-renderer", - declaredPath: packageValue.renderer, - }); + if (packageValue.renderer_entrypoint !== null) + addPackageRole(context, { + packageNode: node, + packageFile: file, + kind: "electron-renderer", + declaredPath: packageValue.renderer_entrypoint.path, + field: packageValue.renderer_entrypoint.field, + }); } return roots; }; @@ -755,16 +761,14 @@ const addPackageRole = ( kind: input.kind, anchor: input.packageFile, resolution, - mechanism: - input.kind === "electron-main" - ? "package.json:main" - : "package.json:renderer", + mechanism: `package.json:${input.field}`, }); context.accumulator.addEdge({ source_node_id: input.packageNode.node_id, target_node_id: role.node_id, relation: "loads", properties: { + source_field: input.field, declared_path: input.declaredPath, resolution_context: resolution.resolution_context, resolved_path: resolution.resolved_path, diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts index cd103df8a3..1a276c8598 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts @@ -81,7 +81,7 @@ it.each(["browser", "module", "renderer", "main"] as const)( kind === "electron-renderer" && observations.some( ({ properties }) => - properties.mechanism === "package.json:renderer" && + properties.mechanism === `package.json:${field}` && properties.resolved_path === "lib/window.cjs", ), ), diff --git a/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts new file mode 100644 index 0000000000..467de327ca --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts @@ -0,0 +1,105 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; + +const packageGraph = async (metadata: Record) => { + const root = await createTestTempDirectory( + "rea-package-renderer-provenance-", + ); + await Promise.all([ + writeFile(join(root, "package.json"), JSON.stringify(metadata)), + writeFile(join(root, "entry.js"), "export const value = 42;"), + ]); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + return javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ).graph; +}; + +it.each(["renderer", "browser", "module"] as const)( + "attributes the package renderer candidate to its actual %s field", + async (field) => { + const graph = await packageGraph({ [field]: "entry.js" }); + const role = graph.nodes.find(({ kind }) => kind === "electron-renderer"); + expect(role?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + mechanism: `package.json:${field}`, + resolved_path: "entry.js", + }), + }), + ); + const metadata = graph.nodes.find(({ kind }) => kind === "package"); + expect(metadata?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + renderer: "entry.js", + renderer_declarations: { [field]: "entry.js" }, + }), + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + source_node_id: metadata?.node_id, + target_node_id: role?.node_id, + relation: "loads", + properties: expect.objectContaining({ + source_field: field, + declared_path: "entry.js", + }), + }), + ); + expect(metadata?.observations[0]?.properties.renderer_declarations).toEqual( + { [field]: "entry.js" }, + ); + }, +); + +it("keeps present browser mappings distinct from absent renderer metadata when module supplies the candidate", async () => { + const graph = await packageGraph({ + browser: { "./server.js": "./client.js" }, + module: "entry.js", + }); + const metadata = graph.nodes.find(({ kind }) => kind === "package"); + expect(metadata?.observations[0]?.properties).toMatchObject({ + renderer: "entry.js", + renderer_declarations: { + browser: { status: "non-string" }, + module: "entry.js", + }, + }); + expect( + graph.nodes.find(({ kind }) => kind === "electron-renderer") + ?.observations[0]?.properties.mechanism, + ).toBe("package.json:module"); +}); + +it("retains simultaneous string declarations while keeping renderer priority", async () => { + const graph = await packageGraph({ + renderer: "entry.js", + browser: "browser.js", + module: "module.js", + }); + const metadata = graph.nodes.find(({ kind }) => kind === "package"); + expect(metadata?.observations[0]?.properties).toMatchObject({ + renderer: "entry.js", + renderer_declarations: { + renderer: "entry.js", + browser: "browser.js", + module: "module.js", + }, + }); + expect( + graph.nodes.find(({ kind }) => kind === "electron-renderer") + ?.observations[0]?.properties.mechanism, + ).toBe("package.json:renderer"); +}); diff --git a/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts b/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts index 6cbf2c6ea9..58d16d2454 100644 --- a/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts @@ -67,7 +67,8 @@ it("uses the first structured observation when paths repeat", async () => { name: null, version: null, main: null, - renderer: null, + renderer_declarations: null, + renderer_entrypoint: null, limitation: "later duplicate", } satisfies JavaScriptPackageObservation, ],