From 74050978be400bd336582aa18ce1dcac73039c6f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:43:12 +0800 Subject: [PATCH] fix(javascript): keep dependency manifests out of application roots Select the shallowest inventoried nondependency manifest and retain dependency package evidence without assigning Electron entry roles. Keep the artifact root when no application manifest is observed. Adapted from PR #1392, with compiled CLI and stdio MCP regressions for root selection and dependency-only inputs. Co-authored-by: Marnix Pluim Co-authored-by: Claude Opus 5.5 --- docs/javascript-artifact-reconstruction.md | 6 + .../JavaScriptArtifactGraphStructure.ts | 23 +++- .../applicationManifestRoots.test.ts | 120 ++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 tests/acceptance/applications/applicationManifestRoots.test.ts 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); + }, +);