mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(javascript): bind Next page admission to source context
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user