From ddd7ac7b846b48191ead1f7dcd0e3bd735468fa8 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:11:41 +0800 Subject: [PATCH] fix(javascript): preserve Next filesystem route spelling Apply Next-specific uppercase %5F normalization while retaining ordinary percent sequences, encoded separators, and literal percent characters as distinct source segments. Fixes #6641 --- docs/javascript-route-inventory.md | 11 ++++ src/domain/javascript/javascriptRoutePaths.ts | 16 +++--- .../javascript/javascriptRouteScan.test.ts | 7 ++- ...javascriptNextRouteSegmentIdentity.test.ts | 50 +++++++++++++++++++ 4 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts diff --git a/docs/javascript-route-inventory.md b/docs/javascript-route-inventory.md index 6fa4d99d70..352eadf8e4 100644 --- a/docs/javascript-route-inventory.md +++ b/docs/javascript-route-inventory.md @@ -78,6 +78,17 @@ can differ from the routing base. Every affected occurrence keeps is explicitly a path-name heuristic; `page` uses page-file or structured/typed configuration support. Name-only `route`/`addRoute` calls remain `unknown` because they can also register server APIs. +Next page-source segments keep their filename spelling when converted to route +patterns. Next's [page-path conversion](https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/shared/lib/page-path/absolute-path-to-page.ts#L21-L39) +removes page extensions and the `index` tail without URI-decoding segments, so +ordinary percent sequences are not decoded: `%61` and `a` remain separate +candidates and a literal `%` is retained. Its [App pathname normalizer](https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/normalizers/built/app/app-pathname-normalizer.ts#L5-L18) +specifically applies the [underscore normalizer](https://github.com/vercel/next.js/blob/v16.4.2/packages/next/src/server/normalizers/underscore-normalizer.ts#L2-L9), +which maps `%5F` to `_`; private-folder checks apply to the source spelling +before that escape is mapped. When classifying a chunk URL, REA reads its +pathname, then applies these same Next-specific rules. This is static source-path +interpretation, not browser request-path decoding or reachability. + Known capture omissions remain inline in `summary.reported_capture_omissions` and queryable as `unknowns` of kind `capture-omission`. Each keeps a stable gap ID, its script-record/script-source unit, and the reported count (including `null` diff --git a/src/domain/javascript/javascriptRoutePaths.ts b/src/domain/javascript/javascriptRoutePaths.ts index 1a10b2c30a..9d42639103 100644 --- a/src/domain/javascript/javascriptRoutePaths.ts +++ b/src/domain/javascript/javascriptRoutePaths.ts @@ -40,18 +40,14 @@ export const classifyNextAppRoutePath = ( if (!match) return; if (match[2] !== undefined && !/(?:^|\/)static\/chunks\/app\//u.test(value)) return; - const encodedSegments = (match[1] ?? "").split("/"); + const sourceSegments = (match[1] ?? "").split("/"); // A private folder excludes the whole page, including intercepted ancestors. - // Test before decoding: %5F names are public URLs. - if (encodedSegments.some((segment) => segment.startsWith("_"))) return; + // Keep filesystem segment spelling: only Next's explicit %5F escape is + // normalized below; generic URI decoding merges distinct source filenames. + if (sourceSegments.some((segment) => segment.startsWith("_"))) return; const segments: string[] = []; - for (const encoded of encodedSegments) { - let segment: string; - try { - segment = decodeURIComponent(encoded); - } catch { - return; - } + for (const sourceSegment of sourceSegments) { + const segment = sourceSegment.replace(/%5F/gu, "_"); if (/^\([^)]*\)$/u.test(segment) || segment.startsWith("@")) continue; // Intercepted routes need a parent routing context that a filename does not establish. if (segment.startsWith("(")) diff --git a/src/domain/javascript/javascriptRouteScan.test.ts b/src/domain/javascript/javascriptRouteScan.test.ts index 9dcbb6752c..2d17e86c7c 100644 --- a/src/domain/javascript/javascriptRouteScan.test.ts +++ b/src/domain/javascript/javascriptRouteScan.test.ts @@ -244,7 +244,12 @@ it.each([ ["app/blog/_draft/page.js", undefined], ["app/(group)/_private/nested/page.tsx", undefined], ["app/%5Fcomponents/page.tsx", "/_components"], - ["app/blog/%5fdraft/page.js", "/blog/_draft"], + ["app/blog/%5fdraft/page.js", "/blog/%5fdraft"], + ["app/%61/page.tsx", "/%61"], + ["https://example.test/app/%61/page.tsx", "/%61"], + ["app/a/page.tsx", "/a"], + ["app/100%/page.tsx", "/100%"], + ["app/team%2Fadmin/page.tsx", "/team%2Fadmin"], ["static/chunks/app/(account)/reports/[id]/page-deadbeef.js", "/reports/:id"], ["app/docs/[...slug]/page.js", "/docs/:slug+"], ["app/(account)/reports/[id]/page.tsx", "/reports/:id"], diff --git a/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts b/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts new file mode 100644 index 0000000000..0f72a05412 --- /dev/null +++ b/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts @@ -0,0 +1,50 @@ +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest( + "preserves literal percent bytes in Next route source segments", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-next-route-segment-"); + const page = (name: string) => + `export default function Page${name}(){return null;}`; + await writeFixtureFiles(root, { + "app/%61/page.js": page("EncodedA"), + "app/a/page.js": page("PlainA"), + "app/100%/page.js": page("Percent"), + "app/team%2Fadmin/page.js": page("EncodedSlash"), + "app/%5Fpublic/page.js": page("EscapedUnderscore"), + "app/blog/%5fdraft/page.js": page("LowercaseEscape"), + }); + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + }); + expect(response.exitCode, response.stdout + response.stderr).toBe(0); + const analysis = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + expect( + analysis.route_inventory?.routes + .map(({ path, source_count }) => ({ path, source_count })) + .sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0, + ), + ).toEqual([ + { path: "/%61", source_count: 1 }, + { path: "/100%", source_count: 1 }, + { path: "/_public", source_count: 1 }, + { path: "/a", source_count: 1 }, + { path: "/blog/%5fdraft", source_count: 1 }, + { path: "/team%2Fadmin", source_count: 1 }, + ]); + expect(analysis.route_inventory?.coverage).toMatchObject({ + source_parsing: "complete", + candidate_enumeration: "complete", + application_routes: "unknown", + }); + }, +);