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
This commit is contained in:
morluto committed 2026-10-24 00:11:41 +08:00
1 parent f719dc46c5
commit ddd7ac7b84
4 files changed
+73 -11

No files matched your search

+11
View File
@@ -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`
+6 -10
View File
@@ -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("("))
@@ -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"],
@@ -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",
});
},
);