diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 02373d6257..da5dca64bd 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -60,6 +60,12 @@ manifest and graph commitments, JavaScript Application Graph, static Electron summary, reconstruction statistics, and explicit limitations. It does not require a live Hopper, Ghidra, browser, or Electron process. +The application graph selects the shallowest `package.json` outside +`node_modules` as its package root, breaking equal-depth ties by Unicode +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. + ## ASAR integrity ASAR inventory checks Electron integrity metadata for embedded archive entries diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 112a5f8b62..971c569613 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -26,6 +26,7 @@ import { staticInferenceEvidence, } from "./JavaScriptArtifactGraphEvidence.js"; import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; +import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; interface PackageRoleInput { readonly packageNode: ApplicationNode; @@ -281,6 +282,7 @@ export const addJavaScriptPackageNodes = ( context: JavaScriptArtifactGraphContext, ): ApplicationNode[] => { const roots: ApplicationNode[] = []; + const rootPath = applicationManifestPath(context); for (const packageValue of context.analysis.packages) { const file = context.filesByPath.get(packageValue.path); if (file === undefined) continue; @@ -309,7 +311,7 @@ export const addJavaScriptPackageNodes = ( }, ], }); - if (roots.length === 0) { + if (file.path === rootPath) { roots.push(node); context.accumulator.addEdge({ source_node_id: node.node_id, @@ -330,6 +332,7 @@ export const addJavaScriptPackageNodes = ( file, operation: "inventory-package", }); + if (dependencyManifest(file.path)) continue; addPackageRole(context, { packageNode: node, packageFile: file, @@ -346,6 +349,24 @@ export const addJavaScriptPackageNodes = ( return roots; }; +/** Dependency-only artifacts retain their inventory root. */ +const applicationManifestPath = ( + context: JavaScriptArtifactGraphContext, +): string | undefined => + context.analysis.packages + .map(({ path }) => path) + .filter( + (path) => context.filesByPath.has(path) && !dependencyManifest(path), + ) + .sort( + (left, right) => + left.split("/").length - right.split("/").length || + compareUnicodeCodePoints(left, right), + )[0]; + +const dependencyManifest = (path: string): boolean => + path.split("/").slice(0, -1).includes("node_modules"); + const createFileTarget = ( context: JavaScriptArtifactGraphContext, file: JavaScriptArtifactFile, diff --git a/tests/acceptance/applications/applicationManifestRoots.test.ts b/tests/acceptance/applications/applicationManifestRoots.test.ts new file mode 100644 index 0000000000..8e3dcab102 --- /dev/null +++ b/tests/acceptance/applications/applicationManifestRoots.test.ts @@ -0,0 +1,120 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps dependency manifests out of application roots and Electron entries through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-application-manifest-"); + const dependency = join(root, "node_modules", "dep"); + const nested = join(root, "a"); + await Promise.all([mkdir(dependency, { recursive: true }), mkdir(nested)]); + await Promise.all([ + writeFile( + join(nested, "package.json"), + JSON.stringify({ name: "nested" }), + ), + writeFile( + join(root, "package.json"), + JSON.stringify({ name: "app", main: "main.js" }), + ), + writeFile( + join(root, "main.js"), + "throw new Error('source must remain inert');", + ), + writeFile( + join(dependency, "package.json"), + JSON.stringify({ + name: "dep", + main: "index.js", + browser: "browser.js", + }), + ), + writeFile(join(dependency, "index.js"), "module.exports = {};"), + writeFile(join(dependency, "browser.js"), "export const browser = true;"), + ]); + + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(response.exitCode).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const { graph } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(document).normalized_result; + expect( + graph.nodes + .filter(({ node_id }) => graph.root_node_ids.includes(node_id)) + .flatMap(({ observations }) => observations.map(({ label }) => label)), + ).toEqual(["app"]); + expect( + graph.nodes + .filter(({ kind }) => kind === "electron-main") + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.declared_path), + ), + ).toEqual(["main.js"]); + expect( + graph.nodes.filter(({ kind }) => kind === "electron-renderer"), + ).toEqual([]); + expect( + graph.nodes + .filter(({ kind }) => kind === "package") + .flatMap(({ observations }) => observations.map(({ label }) => label)) + .sort(), + ).toEqual(["app", "dep", "nested"]); + }, +); + +cliTest( + "keeps the artifact root when only dependency manifests are present", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-dependency-only-"); + const dependency = join(root, "node_modules", "dep"); + await mkdir(dependency, { recursive: true }); + await writeFile( + join(dependency, "package.json"), + JSON.stringify({ name: "dep", main: "index.js" }), + ); + await writeFile(join(dependency, "index.js"), "module.exports = {};"); + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + const { graph } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(response.json).normalized_result; + expect( + graph.nodes + .filter(({ node_id }) => graph.root_node_ids.includes(node_id)) + .map(({ kind }) => kind), + ).toEqual(["artifact"]); + expect( + graph.nodes + .filter(({ kind }) => kind === "package") + .flatMap(({ observations }) => observations.map(({ label }) => label)), + ).toEqual(["dep"]); + expect( + graph.nodes.some( + ({ kind }) => kind === "electron-main" || kind === "electron-renderer", + ), + ).toBe(false); + }, +);