mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
f719dc46c5
commit
ddd7ac7b84
4 files changed
+73
-11
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user