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

This commit is contained in:
morluto
2026-10-24 02:48:30 +08:00
parent c20b707680
commit 5ee5e49cb4
13 changed files with 532 additions and 53 deletions
+21 -1
View File
@@ -48,7 +48,8 @@ at the 1 MiB serialized view budget. Oversized indivisible facts return a
resource error carrying the retained parent Evidence ID.
Pattern IDs bind the exact path. Source IDs bind the source key, path, original
URL, content digest and container digest. Occurrences identify a detector mechanism at a source location; one location
URL, content digest, container digest, and any retained Next source applicability
context (including the owning package digest). Occurrences identify a detector mechanism at a source location; one location
may support multiple mechanisms. `source_count` counts distinct sources. Lookup IDs use 128 bits of SHA-256;
full content and inventory digests remain SHA-256. Field selection always keeps
row identity and necessary provenance. The page's deduplicated `sources` table
@@ -73,6 +74,25 @@ supported by structural or navigation candidates across the supplied sources.
This limits framework filesystem noise, while leaving unsupported candidates
unknown. The parser shares the existing AST traversal and lexical index.
Next page conversion receives an explicit source context. For local source files,
the shared JSON owner first establishes a declared `next` dependency in the
nearest owning package. Inventoried directories then select that package's `app`
before `src/app`, following Next's [root selection](https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/lib/find-pages-dir.ts).
Only files beneath the selected directory supply source-page support. Default
`.next/static/chunks/app` output receives a separate compiled-directory context
from the same package owner; custom build directories remain unmodeled. Pages Router
files, unrelated components, and nested packages do not inherit that root merely
because their paths contain `app`. Missing package/framework evidence remains an
interpretation unknown for page-shaped source paths. Each page-shaped source retains its
`next_app_context` and package identity so this derivation remains inspectable.
Captured script URLs use the `/_next/static/chunks/app/` asset boundary, independently
of local package evidence. Source literals use recognized Next runtime context;
imports, exports, and direct call arguments do not themselves declare pages.
The parser completes its runtime-marker scan before interpreting retained module
literals, so marker placement does not change the result. A matching filename
suffix alone cannot establish App Router ownership.
[Next dynamic segments](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes)
map to `:id`, `:slug+`, or `:slug*`; groups and parallel slots do not contribute
URL segments. Intercepted routes remain unresolved. A prefix before `/_next/`
@@ -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.",
};
};
};
@@ -20,12 +20,52 @@ const source = (
status: "complete",
reason: null,
facts: analyzeJavaScriptStaticSource(text).routes!,
...(url === null && key.startsWith("app/")
? {
next_app_context: {
kind: "app-directory" as const,
directory: "app",
package: {
path: "package.json",
sha256: "b".repeat(64),
container_sha256: "c".repeat(64),
},
},
}
: {}),
});
const build = (inputs: JavaScriptRouteInventoryInput[], bytes?: number) =>
javaScriptRouteInventorySchema.parse(
completeSemanticSteps(buildJavaScriptRouteInventorySteps(inputs, bytes)),
);
it("does not promote page-shaped paths without source-router applicability", () => {
const unknown = source("app/page.js", "");
delete (unknown as { next_app_context?: unknown }).next_app_context;
const result = build([unknown]);
expect(result.routes).toEqual([]);
expect(result.unknowns).toMatchObject([
{ kind: "interpretation", reason: expect.stringContaining("not measured") },
]);
});
it("binds source identity to the package evidence establishing router applicability", () => {
const first = source("app/page.js", "");
const context = first.next_app_context;
if (context?.kind !== "app-directory")
throw new Error("Expected declared fixture context");
const second = {
...first,
next_app_context: {
...context,
package: { ...context.package, sha256: "d".repeat(64) },
},
};
expect(build([first]).sources[0]!.id).not.toBe(
build([second]).sources[0]!.id,
);
});
it("coalesces exact patterns while retaining independent occurrences and stable identities", () => {
const inputs = [
source("one.js", `router.push('/reports');`),
@@ -16,6 +16,8 @@ import {
import {
classifyNextAppRoutePath,
nextScriptBasePath,
nextAppSourceContextSchema,
type NextAppSourceContext,
} from "./javascriptRoutePaths.js";
const sourceStatus = z.enum(["complete", "partial", "unavailable", "unknown"]);
@@ -30,6 +32,7 @@ const sourceShape = {
status: sourceStatus,
reported_route_capacity: javaScriptRouteSourceFactsSchema.shape.capacity,
reason: z.string().nullable(),
next_app_context: nextAppSourceContextSchema.exactOptional(),
base_path: z
.strictObject({
value: z.string(),
@@ -134,13 +137,16 @@ export const javaScriptRouteInventorySchema = inventoryShape.superRefine(
source.url,
source.sha256,
source.container_sha256,
...(source.next_app_context === undefined
? []
: [source.next_app_context]),
])
)
context.addIssue({
code: "custom",
path: ["sources"],
message:
"Route source identity must be unique and bind its source/content fields",
"Route source identity must be unique and bind source/content/applicability fields",
});
sources.add(source.id);
}
@@ -268,6 +274,7 @@ export type JavaScriptRouteInventorySource = z.infer<
typeof javaScriptRouteInventorySourceSchema
>;
export interface JavaScriptRouteInventoryInput {
readonly next_app_context?: NextAppSourceContext;
readonly key: string;
readonly path: string | null;
readonly url: string | null;
@@ -429,12 +436,28 @@ export function* buildJavaScriptRouteInventorySteps(
const manifestIds: string[] = [];
for (const input of inputs) {
yield;
const metadata = classifyNextAppRoutePath(
input.url ?? input.path ?? "",
input.url !== null
? { kind: "script-url" }
: {
kind: "source-path",
source: input.next_app_context ?? {
kind: "unknown",
package: null,
reason: "Next App Router source applicability was not measured.",
},
},
);
const nextContext =
metadata === undefined ? undefined : input.next_app_context;
const id = lookupId("jss", [
input.key,
input.path,
input.url,
input.sha256,
input.container_sha256,
...(nextContext === undefined ? [] : [nextContext]),
]);
manifestIds.push(id);
const prefix =
@@ -447,6 +470,7 @@ export function* buildJavaScriptRouteInventorySteps(
sha256: input.sha256,
container_sha256: input.container_sha256,
status: input.status,
...(nextContext === undefined ? {} : { next_app_context: nextContext }),
reported_route_capacity: input.facts?.capacity ?? null,
reason: input.reason,
base_path: prefix
@@ -455,7 +479,6 @@ export function* buildJavaScriptRouteInventorySteps(
};
if (!reserve(source)) continue;
sources.push(source);
const metadata = classifyNextAppRoutePath(input.url ?? input.path ?? "");
const metadataPath = metadata?.kind === "known" ? metadata.path : undefined;
contexts.push({ input, source, metadataPath });
if (metadata?.kind === "unknown") {
+94 -11
View File
@@ -1,3 +1,42 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
const packageIdentitySchema = z.strictObject({
path: z.string().min(1),
sha256: digestSchema,
container_sha256: digestSchema,
});
export const nextAppSourceContextSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("app-directory"),
directory: z.string().min(1),
package: packageIdentitySchema,
}),
z.strictObject({
kind: z.literal("compiled-directory"),
directory: z.string().min(1),
package: packageIdentitySchema,
}),
z.strictObject({
kind: z.literal("outside-app-directory"),
package: packageIdentitySchema,
}),
z.strictObject({
kind: z.literal("unknown"),
reason: z.string(),
package: packageIdentitySchema.nullable(),
}),
]);
export type NextAppSourceContext = z.infer<typeof nextAppSourceContextSchema>;
export type NextAppRouteContext =
| { readonly kind: "source-path"; readonly source: NextAppSourceContext }
| { readonly kind: "script-url" }
| {
readonly kind: "source-literal";
readonly runtime: "next" | "unknown";
}
| { readonly kind: "source-reference" };
/** URL route syntax, independent of filesystem path normalization. */
export const routePath = (
value: string,
@@ -19,27 +58,71 @@ export type NextAppRoutePath =
/** Classify a page filename without treating missing routing context as absence. */
export const classifyNextAppRoutePath = (
raw: string,
context: NextAppRouteContext,
): NextAppRoutePath | undefined => {
if (raw.includes("\\")) return;
if (raw.includes("\\") || context.kind === "source-reference") return;
let value = raw;
if (raw.includes("://")) {
if (
context.kind === "script-url" ||
(context.kind === "source-literal" && raw.includes("://"))
) {
try {
value = new URL(raw).pathname;
} catch {
return;
}
}
// A deployment prefix may itself contain an `app` segment. Only the chunk
// path following Next's asset boundary can describe an App Router module.
const assetRoot = value.indexOf("/_next/");
if (assetRoot >= 0) value = value.slice(assetRoot + "/_next/".length);
const match =
/(?:^|\/)app\/(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)(?:[?#].*)?$/u.exec(
value,
// Ownership selects the routing root before page-segment conversion.
let relative: string | undefined;
let chunk = false;
if (context.kind === "source-path") {
if (context.source.kind === "outside-app-directory") return;
if (
context.source.kind === "app-directory" ||
context.source.kind === "compiled-directory"
) {
const prefix = `${context.source.directory}/`;
if (!value.startsWith(prefix)) return;
relative = value.slice(prefix.length);
chunk = context.source.kind === "compiled-directory";
}
} else if (context.kind === "script-url" || context.runtime === "next") {
const boundary = "/_next/static/chunks/app/";
const assetRoot = value.indexOf(boundary);
const localChunk = ["static/chunks/app/", ".next/static/chunks/app/"].find(
(prefix) => value.startsWith(prefix),
);
if (!match) return;
if (match[2] !== undefined && !/(?:^|\/)static\/chunks\/app\//u.test(value))
if (assetRoot >= 0) {
relative = value.slice(assetRoot + boundary.length);
chunk = true;
} else if (context.kind === "source-literal" && localChunk !== undefined) {
relative = value.slice(localChunk.length);
chunk = true;
} else if (context.kind === "source-literal") {
const prefix = value.startsWith("src/app/") ? "src/app/" : "app/";
if (value.startsWith(prefix)) relative = value.slice(prefix.length);
}
}
if (relative === undefined) {
if (
/(?:^|\/)app\/(?:.*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.test(
value,
)
)
return {
kind: "unknown",
reason:
context.kind === "source-path" && context.source.kind === "unknown"
? context.source.reason
: "The page-shaped path has no established Next App Router root or runtime context.",
};
return;
}
const match =
/^(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)(?:[?#].*)?$/u.exec(
relative,
);
if (match === null || (match[2] !== undefined && !chunk)) return;
const sourceSegments = (match[1] ?? "").split("/");
// A private folder excludes the whole page, including intercepted ancestors.
// Keep filesystem segment spelling: only Next's explicit %5F escape is
@@ -62,6 +62,20 @@ it("keeps a known false index flag absent", () => {
expect(facts.unresolved).toEqual([]);
});
it("keeps module references out of Next page declarations and retains unknown runtime applicability", () => {
const facts = analyze(
`import Page from './components/app/string-only/page.js';const ordinary=require('./components/app/module-only/page.js');const page='app/unproven/page.js';`,
);
expect(facts.candidates).toEqual([]);
expect(facts.unresolved).toMatchObject([
{ mechanism: "next-app-chunk", reason: expect.stringContaining("context") },
]);
const after = analyze(
`const page='app/reports/page.js';globalThis.TURBOPACK=[];`,
);
expect(after.candidates.map(({ path }) => path)).toEqual(["/reports"]);
});
it("joins router children and preserves each segment's source", () => {
const facts = analyze(
`createBrowserRouter([{path:'/reports',children:[{path:':id?',children:[{path:'settings'}]}]}]);`,
@@ -278,7 +292,7 @@ it.each([
["app/%5Fcomponents/page.tsx", "/_components"],
["app/blog/%5fdraft/page.js", "/blog/%5fdraft"],
["app/%61/page.tsx", "/%61"],
["https://example.test/app/%61/page.tsx", "/%61"],
["https://example.test/app/%61/page.tsx", undefined],
["app/a/page.tsx", "/a"],
["app/100%/page.tsx", "/100%"],
["app/team%2Fadmin/page.tsx", "/team%2Fadmin"],
@@ -292,7 +306,10 @@ it.each([
] as const)(
"interprets Next App Router path %s without claiming reachability",
(value, expected) => {
const route = classifyNextAppRoutePath(value);
const route = classifyNextAppRoutePath(value, {
kind: "source-literal",
runtime: "next",
});
expect(route?.kind === "known" ? route.path : undefined).toBe(expected);
},
);
@@ -689,7 +706,7 @@ it("keeps callable bodies independent of navigation data ownership", () => {
});
it.each([
"const modulePath='app/feed/(.)photo/page.tsx';",
"globalThis.TURBOPACK=[];const modulePath='app/feed/(.)photo/page.tsx';",
"globalThis.TURBOPACK=[];const modulePath='/app/feed/(.)photo/page.tsx';",
] as const)("retains intercepted module paths as unknown: %s", (source) => {
const facts = analyze(source);
@@ -697,7 +714,9 @@ it.each([
expect(facts.unresolved).toEqual([
expect.objectContaining({
mechanism: "next-app-chunk",
reason: expect.stringContaining("intercepted"),
reason: expect.stringContaining(
source.includes("='/app/") ? "context" : "intercepted",
),
}),
]);
});
@@ -705,7 +724,12 @@ it.each([
it.each(["app/(.)photo/_private/page.tsx", "app/_private/(.)photo/page.tsx"])(
"keeps private pages excluded before interpreting %s",
(value) => {
expect(classifyNextAppRoutePath(value)).toBeUndefined();
expect(
classifyNextAppRoutePath(value, {
kind: "source-literal",
runtime: "next",
}),
).toBeUndefined();
},
);
+50 -26
View File
@@ -250,6 +250,8 @@ export class JavaScriptRouteScan {
};
readonly #rejected = new WeakSet<t.Node>();
readonly #seen = new Set<string>();
readonly #nextPaths: { value: string; node: t.Node; computed: boolean }[] =
[];
readonly #literalPaths: { value: string; node: t.Node }[] = [];
readonly #propertyValues = new WeakSet<t.Node>();
readonly #dynamicValues = new WeakMap<t.Node, DynamicRouteValueOwner>();
@@ -533,33 +535,31 @@ export class JavaScriptRouteScan {
}
const value = stringValue(node);
if (value === undefined) return;
const next = classifyNextAppRoutePath(value);
if (
(t.isBinaryExpression(expressionParent) &&
expressionParent.operator === "+") ||
(t.isTemplateLiteral(expressionParent) &&
expressionParent.expressions.length > 0)
) {
if (next !== undefined)
this.#unknown(
"next-app-chunk",
expressionParent!,
"The module filename is part of a computed expression; a complete page path remains unknown.",
);
return;
}
if (next?.kind === "unknown") {
this.#unknown("next-app-chunk", node, next.reason);
return;
}
if (next?.kind === "known")
this.#emit(
next.path,
"next-app-chunk",
[{ value, location: range(node), origin: "next-app-path" }],
const reference =
expressionParent !== null &&
(t.isImportDeclaration(expressionParent) ||
t.isExportNamedDeclaration(expressionParent) ||
t.isExportAllDeclaration(expressionParent) ||
t.isImportExpression(expressionParent) ||
t.isCallExpression(expressionParent) ||
t.isOptionalCallExpression(expressionParent) ||
t.isNewExpression(expressionParent));
const next = classifyNextAppRoutePath(
value,
reference
? { kind: "source-reference" }
: { kind: "source-literal", runtime: "unknown" },
);
if (next !== undefined && this.#queue(node, value))
this.#nextPaths.push({
value,
node,
false,
);
computed:
(t.isBinaryExpression(expressionParent) &&
expressionParent.operator === "+") ||
(t.isTemplateLiteral(expressionParent) &&
expressionParent.expressions.length > 0),
});
if (
!this.#propertyValues.has(node) &&
routePath(value) !== undefined &&
@@ -703,6 +703,30 @@ export class JavaScriptRouteScan {
*finishSteps(): Generator<void, JavaScriptRouteSourceFacts> {
const index = this.index;
if (this.#result.capacity !== null) return this.#result;
for (const { value, node, computed } of this.#nextPaths) {
yield;
if (this.#result.capacity !== null) return this.#result;
const next = classifyNextAppRoutePath(value, {
kind: "source-literal",
runtime: this.#nextRuntime ? "next" : "unknown",
});
if (computed) {
this.#unknown(
"next-app-chunk",
node,
"The module filename is part of a computed expression; a complete page path remains unknown.",
);
} else if (next?.kind === "unknown")
this.#unknown("next-app-chunk", node, next.reason);
else if (next?.kind === "known")
this.#emit(
next.path,
"next-app-chunk",
[{ value, location: range(node), origin: "next-app-path" }],
node,
false,
);
}
if (
this.#calls.length > 0 ||
this.#automaticRoots.size > 0 ||
@@ -0,0 +1,70 @@
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"binds Next page candidates to their source root through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-next-route-context-");
const page = "export default function Page(){return null;}";
await writeFixtureFiles(root, {
"package.json": JSON.stringify({ dependencies: { next: "16.4.2" } }),
"app/page.js": page,
"pages/app/page.js": page,
"pages/app/settings/page.js": page,
"components/app/not-a-route/page.js": page,
"src/app/shadowed/page.js": page,
"node_modules/ordinary/package.json": JSON.stringify({
name: "ordinary",
}),
"node_modules/ordinary/app/dependency-only/page.js": page,
"entry.mjs":
"import Page from './components/app/not-a-route/page.js';export default Page;",
"workspace/package.json": JSON.stringify({
devDependencies: { next: "16.4.2" },
}),
"workspace/src/app/control/page.js": page,
"plain/package.json": JSON.stringify({ main: "main.cjs" }),
"plain/main.cjs":
"module.exports=require('./components/app/module-only/page.js');",
"plain/components/app/module-only/page.js":
"module.exports={kind:'ordinary-module'};",
});
const result = await analyzeJavaScriptDirectory(surface, cli, root);
expect(result.statistics.parse_failures).toBe(0);
const inventory = result.route_inventory;
expect(
inventory?.routes.map(({ path, source_count, occurrence_count }) => ({
path,
source_count,
occurrence_count,
})),
).toEqual([
{ path: "/", source_count: 1, occurrence_count: 1 },
{ path: "/control", source_count: 1, occurrence_count: 1 },
]);
expect(
inventory?.sources.find(({ key }) => key === "app/page.js")
?.next_app_context,
).toMatchObject({
kind: "app-directory",
directory: "app",
package: { path: "package.json" },
});
const unproven = inventory?.sources.find(
({ key }) => key === "plain/components/app/module-only/page.js",
);
expect(unproven?.next_app_context).toMatchObject({ kind: "unknown" });
expect(inventory?.unknowns).toEqual(
expect.arrayContaining([
expect.objectContaining({
source_id: unproven?.id,
kind: "interpretation",
}),
]),
);
},
);
@@ -13,6 +13,7 @@ cliTest(
const page = (name: string) =>
`export default function Page${name}(){return null;}`;
await writeFixtureFiles(root, {
"package.json": JSON.stringify({ dependencies: { next: "16.4.2" } }),
"app/%61/page.js": page("EncodedA"),
"app/a/page.js": page("PlainA"),
"app/100%/page.js": page("Percent"),
@@ -32,6 +32,10 @@ const fixture = async () => {
join(path, "routes.js"),
`globalThis.TURBOPACK=[];globalThis.TURBOPACK.push([1,function module(){const routes=defineRoutes({reports:{_$:'reports',settings:{_$:'settings'},history:{}}});createBrowserRouter([{path:'/account',children:[{path:':id'}]}]);router.push('/reports/' + selectedId);router.push({pathname:'/reports/' + selectedId});router.navigate(['/reports/',selectedId]);const mixed={children:[{path:'/reports/stale-ordinary',element:View},{_$:'/reports/stale-typed'}]};mixed.children[1]._$='/reports/changed';createBrowserRouter(mixed);router.push({pathname:'/reports/static-object',query:{href:'/query-data',path:'/download/file',component:'preview'}});router.addRoute('admin',{path:'settings',component:View});const aliasQuery={path:'/download/alias',component:'preview',href:'/alias-query-data',module:'app/fake/page.tsx'};const target={pathname:'/reports/aliased',query:aliasQuery};router.push(target);const independent={path:'/checks/independent',component:View};router.push({pathname:'/reports/aliased',query:independent ? {ok:1}:{ok:2}});createBrowserRouter([{path:'/reports/stale-computed',[computedKey]:'/unrelated',children:[{path:'settings'}]}]);const registry={route:{path:'/checks/projected',component:View},query:{path:'/download/projected-query',component:Preview}};const {query:payload}=registry;router.push({pathname:'/reports/aliased',query:payload});const pattern='/checks/optional/:id?';createBrowserRouter([{path:pattern}]);const restRegistry={query:{path:'/download/rest-query',component:Preview},route:{path:'/checks/rest',component:View}};const {route,...restPayload}=restRegistry;router.push({pathname:'/reports/aliased',query:restPayload});const fallback={path:'/checks/inactive-default',component:View};const {query:actualQuery=fallback}={query:{ok:1}};router.push({pathname:'/reports/aliased',query:actualQuery});const cache=[];cache.push('/checks/array-data');text.replace('/checks/string-data','');const inactiveBranch={path:'/checks/inactive-branch',component:View};router.push({pathname:'/reports/aliased',query:true?{}:inactiveBranch});const overwritten={path:'/checks/overwritten',component:View};router.push({pathname:'/reports/aliased',query:{item:overwritten,item:0}});const memberRegistry={query:{path:'/download/member-query',component:Preview},route:{path:'/checks/member',component:View}};router.push({pathname:'/reports/aliased',query:memberRegistry.query});const protoPayload={path:'/download/proto-query',component:Preview};router.push({pathname:'/reports/aliased',query:{['__proto__']:protoPayload,__proto__:null}});const replacedRegistry={query:independent};replacedRegistry.query={ok:1};router.push({pathname:'/reports/aliased',query:replacedRegistry.query});let replacedDirect=independent;replacedDirect={ok:1};router.push({pathname:'/reports/aliased',query:replacedDirect});const uncertainPayload={path:'/download/uncertain-query',component:Preview};const uncertainRegistry={query:uncertainPayload};const otherRegistry={query:{ok:1}};const uncertainAlias=flag?uncertainRegistry:otherRegistry;uncertainAlias.query={ok:1};router.push({pathname:'/reports/aliased',query:uncertainRegistry.query});const possiblePayload={path:'/download/possible-query',component:Preview};const possibleRegistry={query:possiblePayload,...unknown};router.push({pathname:'/reports/aliased',query:possibleRegistry.query});const cyclicRegistry={get a(){return this.a.b}};router.push({pathname:'/reports/aliased',query:cyclicRegistry.a});const finiteRegistry={get a(){return this},get b(){return this.a.a},path:'/download/finite-query',component:Preview};router.push({pathname:'/reports/aliased',query:finiteRegistry.a.b});const configurationRegistry={children:[{path:'/checks/config-sibling',component:View}],routes:[]};createBrowserRouter(configurationRegistry.routes);const helper='app/reports/helper/page-helpers.js';const extra='/reports/help';}]);`,
);
await writeFile(
join(path, "package.json"),
JSON.stringify({ dependencies: { next: "16.4.2" } }),
);
const intercepted = join(path, "app/feed/(.)photo");
await mkdir(intercepted, { recursive: true });
await writeFile(join(intercepted, "page.tsx"), interceptedSource);