fix(javascript): bind Next page admission to source context

This commit is contained in:
morluto committed 2026-10-24 02:50:17 +08:00
1 parent c20b707680
commit 5ee5e49cb4
13 files changed
+532 -53

No files matched your search

@@ -42,6 +42,7 @@ import {
type JavaScriptArtifactReconstructionInput,
} from "./JavaScriptArtifactReconstructionInput.js";
import { scanCanonicalArtifactInventoryInScope } from "../../artifacts/inventory/scanCanonical.js";
import { createJavaScriptNextAppContext } from "./JavaScriptNextAppContext.js";
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import type { ProgressReporter } from "../ProgressReporter.js";
@@ -285,6 +286,16 @@ export const reconstructJavaScriptArtifact = async (
const semanticGraph =
await completeJavaScriptAnalysisSteps(semanticGraphSteps);
await checkpointCompletedAnalysis();
const directoryPaths = new Set(
snapshot.occurrences
.filter(({ entry_kind }) => entry_kind === "directory")
.map(({ logical_path }) => logical_path),
);
const nextAppContext = createJavaScriptNextAppContext(
files.files,
analysis.jsonDocuments,
directoryPaths,
);
const completed = {
route_inventory: await completeJavaScriptAnalysisSteps(
buildJavaScriptRouteInventorySteps([
@@ -292,6 +303,7 @@ export const reconstructJavaScriptArtifact = async (
.filter(({ file }) => file.kind === "javascript")
.map(({ file, javascript, analysis_failure }) => ({
key: file.path,
next_app_context: nextAppContext(file),
path: file.path,
url: null,
sha256: file.sha256,
@@ -318,6 +330,7 @@ export const reconstructJavaScriptArtifact = async (
.filter((file) => file.kind === "javascript")
.map((file) => ({
key: file.path,
next_app_context: nextAppContext(file),
path: file.path,
url: null,
sha256: file.sha256,
@@ -339,14 +352,7 @@ export const reconstructJavaScriptArtifact = async (
: { source_selection: files.source_selection }),
graph,
semantic_graph: semanticGraph,
electron_summary: summarizeElectronBoundaries(
analysis,
new Set(
snapshot.occurrences
.filter(({ entry_kind }) => entry_kind === "directory")
.map(({ logical_path }) => logical_path),
),
),
electron_summary: summarizeElectronBoundaries(analysis, directoryPaths),
statistics: {
relevant_files:
files.files.length + files.unavailable_relevant_files.length,
@@ -15,6 +15,7 @@ interface JavaScriptJsonDocumentFactBase {
export type JavaScriptJsonDocumentFacts =
| (JavaScriptJsonDocumentFactBase & {
readonly status: "included";
readonly nextDependency: "declared" | "unproven";
readonly packageFields: Readonly<
Partial<Record<(typeof PACKAGE_PROPERTIES)[number], unknown>>
> | null;
@@ -127,6 +128,7 @@ const parseDocument = (
return Object.freeze({
source: snapshot.source,
status: "included",
nextDependency: "unproven",
packageFields: null,
topLevelKeys: Object.freeze([]),
});
@@ -145,9 +147,24 @@ const parseDocument = (
packageFields[key] = Reflect.get(record, key);
Object.freeze(packageFields);
}
const nextDeclared =
snapshot.kind === "package-json" &&
["dependencies", "devDependencies", "peerDependencies"].some((key) => {
const dependencies: unknown = record[key];
if (
typeof dependencies !== "object" ||
dependencies === null ||
Array.isArray(dependencies) ||
!Object.hasOwn(dependencies, "next")
)
return false;
const version: unknown = Reflect.get(dependencies, "next");
return typeof version === "string" && version.length > 0;
});
return Object.freeze({
source: snapshot.source,
status: "included",
nextDependency: nextDeclared ? "declared" : "unproven",
packageFields,
topLevelKeys,
});
@@ -0,0 +1,93 @@
import { expect, it } from "vitest";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js";
import { createJavaScriptNextAppContext } from "./JavaScriptNextAppContext.js";
const file = (path: string, text = ""): JavaScriptArtifactFile => ({
path,
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: Buffer.byteLength(text),
inventory_artifact_id: `art_${"b".repeat(64)}`,
kind: path.endsWith("package.json") ? "package-json" : "javascript",
unpacked: false,
text: { included: true, value: text },
});
it("uses the nearest declared Next package and root directory precedence", () => {
const files = [
file("package.json", '{"dependencies":{"next":"16.4.2"}}'),
file("app/page.js"),
file("src/app/control/page.js"),
file("pages/app/page.js"),
file("components/app/page.js"),
file("node_modules/ordinary/package.json", '{"name":"ordinary"}'),
file("node_modules/ordinary/app/page.js"),
];
const context = createJavaScriptNextAppContext(
files,
new JavaScriptJsonDocuments(),
new Set([
"app",
"src/app",
"pages",
"components/app",
"node_modules/ordinary/app",
]),
);
expect(context(files[1]!)).toMatchObject({
kind: "app-directory",
directory: "app",
package: { path: "package.json" },
});
for (const source of files.slice(2, 5))
expect(context(source).kind).toBe("outside-app-directory");
expect(context(files[6]!)).toMatchObject({
kind: "unknown",
package: { path: "node_modules/ordinary/package.json" },
});
});
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(
[manifest, chunk],
new JavaScriptJsonDocuments(),
new Set(),
);
expect(context(chunk)).toMatchObject({
kind: "compiled-directory",
directory: ".next/static/chunks/app",
});
expect(
createJavaScriptNextAppContext(
[chunk],
new JavaScriptJsonDocuments(),
new Set(),
)(chunk),
).toMatchObject({ kind: "unknown" });
});
it("supports src/app only when app is not inventoried and retains missing framework identity", () => {
const manifest = file(
"package.json",
'{"devDependencies":{"next":"16.4.2"}}',
);
const page = file("src/app/control/page.js");
expect(
createJavaScriptNextAppContext(
[manifest, page],
new JavaScriptJsonDocuments(),
new Set(["src/app"]),
)(page),
).toMatchObject({ kind: "app-directory", directory: "src/app" });
expect(
createJavaScriptNextAppContext(
[page],
new JavaScriptJsonDocuments(),
new Set(["src/app"]),
)(page),
).toMatchObject({ kind: "unknown", package: null });
});
@@ -0,0 +1,74 @@
import { posix } from "node:path";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import type { NextAppSourceContext } from "../../domain/javascript/javascriptRoutePaths.js";
import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js";
/** Package declarations and inventoried directories own source-router applicability. */
export const createJavaScriptNextAppContext = (
files: readonly JavaScriptArtifactFile[],
documents: JavaScriptJsonDocuments,
directories: ReadonlySet<string>,
): ((
file: Pick<JavaScriptArtifactFile, "path" | "container_sha256">,
) => NextAppSourceContext) => {
const packages = new Map(
files
.filter(({ kind }) => kind === "package-json")
.map((file) => [posix.dirname(file.path), file]),
);
const roots = new Map<JavaScriptArtifactFile, string | null>();
return (file) => {
for (
let directory = posix.dirname(file.path);
;
directory = posix.dirname(directory)
) {
const manifest = packages.get(directory);
if (
manifest !== undefined &&
manifest.container_sha256 === file.container_sha256
) {
const identity = {
path: manifest.path,
sha256: manifest.sha256,
container_sha256: manifest.container_sha256,
};
const facts = documents.read(manifest);
if (facts.status !== "included" || facts.nextDependency !== "declared")
return {
kind: "unknown",
package: identity,
reason:
"The owning package does not establish a Next dependency for source routing.",
};
const prefix = directory === "." ? "" : `${directory}/`;
const compiled = `${prefix}.next/static/chunks/app`;
if (file.path.startsWith(`${compiled}/`))
return {
kind: "compiled-directory",
directory: compiled,
package: identity,
};
let root = roots.get(manifest);
if (root === undefined) {
root =
["app", "src/app"]
.map((candidate) => `${prefix}${candidate}`)
.find((candidate) => directories.has(candidate)) ?? null;
roots.set(manifest, root);
}
return root !== null && file.path.startsWith(`${root}/`)
? { kind: "app-directory", directory: root, package: identity }
: { kind: "outside-app-directory", package: identity };
}
if (directory === "." || directory === "/") break;
}
return {
kind: "unknown",
package: null,
reason:
"No owning package declaration establishes a Next App Router source root.",
};
};
};