fix(javascript): retain package entrypoint provenance

This commit is contained in:
morluto
2026-10-27 06:28:05 +08:00
parent c27560e434
commit 7c8eb63fcc
8 changed files with 228 additions and 26 deletions
@@ -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
@@ -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 }) => {
@@ -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,
});
@@ -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;
}
);
@@ -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,
@@ -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",
),
),
@@ -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<string, unknown>) => {
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");
});
@@ -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,
],