From 5ee5e49cb4356c10ba284089b8be9b15f588f622 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:48:30 +0800 Subject: [PATCH] fix(javascript): bind Next page admission to source context --- docs/javascript-route-inventory.md | 22 +++- .../JavaScriptArtifactReconstruction.ts | 22 ++-- .../javascript/JavaScriptJsonDocuments.ts | 17 +++ .../JavaScriptNextAppContext.test.ts | 93 ++++++++++++++++ .../javascript/JavaScriptNextAppContext.ts | 74 ++++++++++++ .../javascriptRouteInventory.test.ts | 40 +++++++ .../javascript/javascriptRouteInventory.ts | 27 ++++- src/domain/javascript/javascriptRoutePaths.ts | 105 ++++++++++++++++-- .../javascript/javascriptRouteScan.test.ts | 34 +++++- src/domain/javascript/javascriptRouteScan.ts | 76 ++++++++----- .../javascriptNextRouteContext.test.ts | 70 ++++++++++++ ...javascriptNextRouteSegmentIdentity.test.ts | 1 + tests/acceptance/javascriptRoutesCli.test.ts | 4 + 13 files changed, 532 insertions(+), 53 deletions(-) create mode 100644 src/application/javascript/JavaScriptNextAppContext.test.ts create mode 100644 src/application/javascript/JavaScriptNextAppContext.ts create mode 100644 tests/acceptance/applications/javascriptNextRouteContext.test.ts diff --git a/docs/javascript-route-inventory.md b/docs/javascript-route-inventory.md index df9a3c6121..f3873eaa63 100644 --- a/docs/javascript-route-inventory.md +++ b/docs/javascript-route-inventory.md @@ -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/` diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index ad2076c950..fe726070e8 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -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, diff --git a/src/application/javascript/JavaScriptJsonDocuments.ts b/src/application/javascript/JavaScriptJsonDocuments.ts index b58d5474bc..3700b29004 100644 --- a/src/application/javascript/JavaScriptJsonDocuments.ts +++ b/src/application/javascript/JavaScriptJsonDocuments.ts @@ -15,6 +15,7 @@ interface JavaScriptJsonDocumentFactBase { export type JavaScriptJsonDocumentFacts = | (JavaScriptJsonDocumentFactBase & { readonly status: "included"; + readonly nextDependency: "declared" | "unproven"; readonly packageFields: Readonly< Partial> > | 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, }); diff --git a/src/application/javascript/JavaScriptNextAppContext.test.ts b/src/application/javascript/JavaScriptNextAppContext.test.ts new file mode 100644 index 0000000000..53e55ae476 --- /dev/null +++ b/src/application/javascript/JavaScriptNextAppContext.test.ts @@ -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 }); +}); diff --git a/src/application/javascript/JavaScriptNextAppContext.ts b/src/application/javascript/JavaScriptNextAppContext.ts new file mode 100644 index 0000000000..5f9fc75151 --- /dev/null +++ b/src/application/javascript/JavaScriptNextAppContext.ts @@ -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, +): (( + file: Pick, +) => NextAppSourceContext) => { + const packages = new Map( + files + .filter(({ kind }) => kind === "package-json") + .map((file) => [posix.dirname(file.path), file]), + ); + const roots = new Map(); + 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.", + }; + }; +}; diff --git a/src/domain/javascript/javascriptRouteInventory.test.ts b/src/domain/javascript/javascriptRouteInventory.test.ts index ec638b24cd..a3b528a894 100644 --- a/src/domain/javascript/javascriptRouteInventory.test.ts +++ b/src/domain/javascript/javascriptRouteInventory.test.ts @@ -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');`), diff --git a/src/domain/javascript/javascriptRouteInventory.ts b/src/domain/javascript/javascriptRouteInventory.ts index 3d515193a0..f57abd8b63 100644 --- a/src/domain/javascript/javascriptRouteInventory.ts +++ b/src/domain/javascript/javascriptRouteInventory.ts @@ -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") { diff --git a/src/domain/javascript/javascriptRoutePaths.ts b/src/domain/javascript/javascriptRoutePaths.ts index 9d42639103..f65cd31f22 100644 --- a/src/domain/javascript/javascriptRoutePaths.ts +++ b/src/domain/javascript/javascriptRoutePaths.ts @@ -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; +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 diff --git a/src/domain/javascript/javascriptRouteScan.test.ts b/src/domain/javascript/javascriptRouteScan.test.ts index 2c80778df7..7719076c94 100644 --- a/src/domain/javascript/javascriptRouteScan.test.ts +++ b/src/domain/javascript/javascriptRouteScan.test.ts @@ -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(); }, ); diff --git a/src/domain/javascript/javascriptRouteScan.ts b/src/domain/javascript/javascriptRouteScan.ts index 96d304a622..9610dea308 100644 --- a/src/domain/javascript/javascriptRouteScan.ts +++ b/src/domain/javascript/javascriptRouteScan.ts @@ -250,6 +250,8 @@ export class JavaScriptRouteScan { }; readonly #rejected = new WeakSet(); readonly #seen = new Set(); + readonly #nextPaths: { value: string; node: t.Node; computed: boolean }[] = + []; readonly #literalPaths: { value: string; node: t.Node }[] = []; readonly #propertyValues = new WeakSet(); readonly #dynamicValues = new WeakMap(); @@ -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 { 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 || diff --git a/tests/acceptance/applications/javascriptNextRouteContext.test.ts b/tests/acceptance/applications/javascriptNextRouteContext.test.ts new file mode 100644 index 0000000000..667dcef244 --- /dev/null +++ b/tests/acceptance/applications/javascriptNextRouteContext.test.ts @@ -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", + }), + ]), + ); + }, +); diff --git a/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts b/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts index 0f72a05412..e3587c4a38 100644 --- a/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts +++ b/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts @@ -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"), diff --git a/tests/acceptance/javascriptRoutesCli.test.ts b/tests/acceptance/javascriptRoutesCli.test.ts index 18107c225c..2a2bc8d3ec 100644 --- a/tests/acceptance/javascriptRoutesCli.test.ts +++ b/tests/acceptance/javascriptRoutesCli.test.ts @@ -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);