From c74059d89fdf0872b7a02fdb6c8f1f04a754a8ba Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:40:45 +0800 Subject: [PATCH] fix(javascript): preserve unknown Next package ownership --- .../JavaScriptArtifactReconstruction.ts | 3 +- .../JavaScriptNextAppContext.test.ts | 108 ++++++++++++++++-- .../javascript/JavaScriptNextAppContext.ts | 48 +++++++- .../javascriptNextRouteContext.test.ts | 79 +++++++++++++ 4 files changed, 221 insertions(+), 17 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 8e3bf7ecc7..17e03aeee2 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -305,9 +305,10 @@ export const reconstructJavaScriptArtifact = async ( .map(({ logical_path }) => logical_path), ); const nextAppContext = createJavaScriptNextAppContext( - files.files, + files, analysis.jsonDocuments, directoryPaths, + snapshot.coverage, ); const completed = { route_inventory: await completeJavaScriptAnalysisSteps( diff --git a/src/application/javascript/JavaScriptNextAppContext.test.ts b/src/application/javascript/JavaScriptNextAppContext.test.ts index 6bf719c752..c0136bf6a4 100644 --- a/src/application/javascript/JavaScriptNextAppContext.test.ts +++ b/src/application/javascript/JavaScriptNextAppContext.test.ts @@ -4,6 +4,18 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import { createJavaScriptNextAppContext } from "./JavaScriptNextAppContext.js"; +const contextFor = ( + files: readonly JavaScriptArtifactFile[], + documents: JavaScriptJsonDocuments, + directories: ReadonlySet, +) => + createJavaScriptNextAppContext( + { files, unavailable_relevant_files: [] }, + documents, + directories, + { scope: "container-entries", status: "complete" }, + ); + const file = (path: string, text = ""): JavaScriptArtifactFile => ({ path, container_sha256: "a".repeat(64), @@ -23,7 +35,7 @@ it("keeps package applicability and its root cache scoped to the source containe }; const page = file("app/page.js"); const foreignPage = { ...page, container_sha256: foreign.container_sha256 }; - const context = createJavaScriptNextAppContext( + const context = contextFor( [manifest, foreign, page, foreignPage], new JavaScriptJsonDocuments(), new Set(["app"]), @@ -49,7 +61,7 @@ it("uses the nearest declared Next package and root directory precedence", () => file("node_modules/ordinary/package.json", '{"name":"ordinary"}'), file("node_modules/ordinary/app/page.js"), ]; - const context = createJavaScriptNextAppContext( + const context = contextFor( files, new JavaScriptJsonDocuments(), new Set([ @@ -76,7 +88,7 @@ it("uses the nearest declared Next package and root directory precedence", () => it("keeps default compiled output under its declared package and unrelated chunk-like paths unknown", () => { const manifest = file("package.json", '{"dependencies":{"next":"16.4.2"}}'); const chunk = file(".next/static/chunks/app/build/page-abc.js"); - const context = createJavaScriptNextAppContext( + const context = contextFor( [manifest, chunk], new JavaScriptJsonDocuments(), new Set(), @@ -86,11 +98,7 @@ it("keeps default compiled output under its declared package and unrelated chunk directory: ".next/static/chunks/app", }); expect( - createJavaScriptNextAppContext( - [chunk], - new JavaScriptJsonDocuments(), - new Set(), - )(chunk), + contextFor([chunk], new JavaScriptJsonDocuments(), new Set())(chunk), ).toMatchObject({ kind: "unknown" }); }); @@ -101,17 +109,97 @@ it("supports src/app only when app is not inventoried and retains missing framew ); const page = file("src/app/control/page.js"); expect( - createJavaScriptNextAppContext( + contextFor( [manifest, page], new JavaScriptJsonDocuments(), new Set(["src/app"]), )(page), ).toMatchObject({ kind: "app-directory", directory: "src/app" }); expect( - createJavaScriptNextAppContext( + contextFor( [page], new JavaScriptJsonDocuments(), new Set(["src/app"]), )(page), ).toMatchObject({ kind: "unknown", package: null }); }); + +it("does not infer absent manifests or higher-priority directories from incomplete traversal", () => { + const manifest = file("package.json", '{"dependencies":{"next":"16.4.2"}}'); + const page = file("src/app/control/page.js"); + const files = { files: [manifest, page], unavailable_relevant_files: [] }; + const context = createJavaScriptNextAppContext( + files, + new JavaScriptJsonDocuments(), + new Set(["src/app"]), + { scope: "container-entries", status: "unknown" }, + ); + expect(context(page)).toMatchObject({ + kind: "unknown", + package: null, + reason: expect.stringContaining("src/app/control/package.json"), + }); + const rootPage = file("page.js"); + expect( + createJavaScriptNextAppContext( + files, + new JavaScriptJsonDocuments(), + new Set(["src/app"]), + { scope: "container-entries", status: "unknown" }, + )(rootPage), + ).toMatchObject({ + kind: "unknown", + package: { path: "package.json" }, + reason: expect.stringContaining("higher-priority app directory"), + }); +}); + +it("retains the nearest unavailable manifest's evidence instead of borrowing an ancestor's Next declaration", () => { + const parent = file("package.json", '{"dependencies":{"next":"16.4.2"}}'); + const page = file("app/widget/app/page.js"); + const unavailable = { + path: "app/widget/package.json", + container_sha256: parent.container_sha256, + sha256: null, + bytes: 42, + inventory_artifact_id: null, + inventory_occurrence_id: "unreadable-nested-package", + kind: "package-json" as const, + limitations: ["The unpacked package companion is missing."], + }; + const context = createJavaScriptNextAppContext( + { files: [parent, page], unavailable_relevant_files: [unavailable] }, + new JavaScriptJsonDocuments(), + new Set(["app", "app/widget/app"]), + { scope: "container-entries", status: "complete" }, + ); + expect(context(page)).toMatchObject({ + kind: "unknown", + package: null, + reason: expect.stringContaining(unavailable.path), + }); + const result = context(page); + if (result.kind !== "unknown") + throw new Error("Expected unknown package ownership"); + expect(result.reason).toContain(unavailable.inventory_occurrence_id); + expect(result.reason).toContain(unavailable.limitations[0]); + expect(context(file("app/page.js"))).toMatchObject({ + kind: "app-directory", + package: { path: parent.path }, + }); + const foreign = createJavaScriptNextAppContext( + { + files: [parent, page], + unavailable_relevant_files: [ + { ...unavailable, container_sha256: "c".repeat(64) }, + ], + }, + new JavaScriptJsonDocuments(), + new Set(["app", "app/widget/app"]), + { scope: "container-entries", status: "complete" }, + ); + expect(foreign(page)).toMatchObject({ + kind: "app-directory", + package: { path: parent.path }, + }); +}); diff --git a/src/application/javascript/JavaScriptNextAppContext.ts b/src/application/javascript/JavaScriptNextAppContext.ts index 99cbc383fd..70c7528361 100644 --- a/src/application/javascript/JavaScriptNextAppContext.ts +++ b/src/application/javascript/JavaScriptNextAppContext.ts @@ -1,20 +1,36 @@ import { posix } from "node:path"; -import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import type { ArtifactInventoryCoverage } from "../../domain/artifactGraph.js"; +import type { + JavaScriptArtifactFileSet, + JavaScriptArtifactFile, +} from "../../domain/javascript/javascriptArtifactFiles.js"; import type { NextAppSourceContext } from "../../domain/javascript/javascriptRoutePaths.js"; import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; /** Package declarations and inventoried directories own source-router applicability. */ export const createJavaScriptNextAppContext = ( - files: readonly JavaScriptArtifactFile[], + fileSet: Pick< + JavaScriptArtifactFileSet, + "files" | "unavailable_relevant_files" + >, documents: JavaScriptJsonDocuments, directories: ReadonlySet, + coverage: ArtifactInventoryCoverage, ): (( file: Pick, ) => NextAppSourceContext) => { const packages = new Map( - files + fileSet.files + .filter(({ kind }) => kind === "package-json") + .map((file) => [ + compositeKey([file.container_sha256, posix.dirname(file.path)]), + file, + ]), + ); + const unavailablePackages = new Map( + fileSet.unavailable_relevant_files .filter(({ kind }) => kind === "package-json") .map((file) => [ compositeKey([file.container_sha256, posix.dirname(file.path)]), @@ -28,9 +44,15 @@ export const createJavaScriptNextAppContext = ( ; directory = posix.dirname(directory) ) { - const manifest = packages.get( - compositeKey([file.container_sha256, directory]), - ); + const key = compositeKey([file.container_sha256, directory]); + const unavailable = unavailablePackages.get(key); + if (unavailable !== undefined) + return { + kind: "unknown", + package: null, + reason: `The nearest owning package manifest ${unavailable.path}${unavailable.inventory_occurrence_id === undefined ? "" : ` (inventory occurrence ${unavailable.inventory_occurrence_id})`} is unavailable: ${unavailable.limitations?.join("; ") || "Inventoried package bytes are unavailable."}`, + }; + const manifest = packages.get(key); if (manifest !== undefined) { const identity = { path: manifest.path, @@ -53,6 +75,14 @@ export const createJavaScriptNextAppContext = ( directory: compiled, package: identity, }; + // Selecting src/app requires proving that the higher-priority app is absent. + if (coverage.status !== "complete" && !directories.has(`${prefix}app`)) + return { + kind: "unknown", + package: identity, + reason: + "Incomplete artifact traversal does not establish whether the owning package has a higher-priority app directory.", + }; let root = roots.get(manifest); if (root === undefined) { root = @@ -65,6 +95,12 @@ export const createJavaScriptNextAppContext = ( ? { kind: "app-directory", directory: root, package: identity } : { kind: "outside-app-directory", package: identity }; } + if (coverage.status !== "complete") + return { + kind: "unknown", + package: null, + reason: `Incomplete artifact traversal does not establish whether ${directory === "." ? "" : `${directory}/`}package.json owns this source.`, + }; if (directory === "." || directory === "/") break; } return { diff --git a/tests/acceptance/applications/javascriptNextRouteContext.test.ts b/tests/acceptance/applications/javascriptNextRouteContext.test.ts index 667dcef244..4788845f1d 100644 --- a/tests/acceptance/applications/javascriptNextRouteContext.test.ts +++ b/tests/acceptance/applications/javascriptNextRouteContext.test.ts @@ -1,3 +1,7 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPackageWithOptions } from "@electron/asar"; + import { expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -68,3 +72,78 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ); }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps an unreadable nearest Next package unknown and recovers after restoration through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-next-unavailable-package-"); + const source = join(root, "source"); + const input = join(root, "input"); + const archive = join(input, "app.asar"); + const manifest = JSON.stringify({ dependencies: { next: "16.4.2" } }); + const nestedManifest = "app/widget/package.json"; + await writeFixtureFiles(source, { + "package.json": manifest, + "app/page.js": "export default function Page(){return null;}", + [nestedManifest]: manifest, + "app/widget/app/page.js": "export default function Page(){return null;}", + }); + await mkdir(input); + // ASAR matches unpack globs against absolute source filenames. + await createPackageWithOptions(source, archive, { + unpack: `**/${nestedManifest}`, + }); + const companion = join(`${archive}.unpacked`, nestedManifest); + await rm(companion); + const missing = await analyzeJavaScriptDirectory(surface, cli, input); + const routes = missing.route_inventory; + const parent = routes?.sources.find( + ({ key }) => key === "app.asar/app/page.js", + ); + expect(parent?.next_app_context).toMatchObject({ + kind: "app-directory", + package: { path: "app.asar/package.json" }, + }); + const nested = routes?.sources.find( + ({ key }) => key === "app.asar/app/widget/app/page.js", + ); + expect(nested?.next_app_context).toMatchObject({ + kind: "unknown", + package: null, + reason: expect.stringContaining(nestedManifest), + }); + expect(routes?.unknowns).toContainEqual( + expect.objectContaining({ + source_id: nested?.id, + kind: "interpretation", + reason: expect.stringContaining("unavailable"), + }), + ); + expect(routes?.routes.map(({ path }) => path)).toEqual(["/"]); + expect(routes?.routes[0]?.source_count).toBe(1); + + await writeFile(companion, manifest); + const restored = await analyzeJavaScriptDirectory(surface, cli, input); + const recovered = restored.route_inventory?.sources.find( + ({ key }) => key === nested?.key, + ); + expect(recovered?.next_app_context).toMatchObject({ + kind: "app-directory", + directory: "app.asar/app/widget/app", + package: { path: `app.asar/${nestedManifest}` }, + }); + expect(recovered?.id).not.toBe(nested?.id); + expect( + restored.route_inventory?.routes.map(({ path, source_count }) => ({ + path, + source_count, + })), + ).toEqual([{ path: "/", source_count: 2 }]); + expect( + restored.route_inventory?.unknowns.some( + ({ source_id, kind }) => + source_id === recovered?.id && kind === "interpretation", + ), + ).toBe(false); + }, +);