diff --git a/src/domain/javascript/javascriptRouteInventory.test.ts b/src/domain/javascript/javascriptRouteInventory.test.ts index b4fd09c20a..028a5282a1 100644 --- a/src/domain/javascript/javascriptRouteInventory.test.ts +++ b/src/domain/javascript/javascriptRouteInventory.test.ts @@ -39,15 +39,81 @@ const build = (inputs: JavaScriptRouteInventoryInput[], bytes?: number) => 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.each([ + ["hello world", "hello%20world"], + ["team?admin", "team%3Fadmin"], + ["team#admin", "team%23admin"], +] as const)( + "preserves Next filename pattern %s and its source identity", + (segment, encoded) => { + const url = `https://example.test/_next/static/chunks/app/${encoded}/page-abc.js`; + const result = build([ + source(`app/${segment}/page.js`, ""), + source("captured.js", "", url), + source( + "runtime.js", + `globalThis.TURBOPACK=[];const page=${JSON.stringify(url)};`, + ), + ]); + expect(result.routes).toMatchObject([ + { path: `/${segment}`, source_count: 3, occurrence_count: 3 }, + ]); + expect(result.routes).toHaveLength(1); + expect(result.occurrences.map(({ source_id }) => source_id).sort()).toEqual( + result.sources.map(({ id }) => id).sort(), + ); + expect(result.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: `app/${segment}/page.js`, url: null }), + expect.objectContaining({ path: null, url }), + ]), + ); + expect(result.unknowns).toEqual([]); + expect(result.coverage).toEqual({ + scope: "provided-code-sources", + source_parsing: "complete", + candidate_enumeration: "complete", + application_routes: "unknown", + }); + }, +); + +it.each(["%00", "%09", "%0A", "%7F"])( + "retains an interpretation gap for a Next filename containing %s", + (encoded) => { + const url = `https://example.test/_next/static/chunks/app/hello${encoded}world/page-abc.js`; + const result = build([source("captured.js", "", url)]); + expect(result.routes).toEqual([]); + expect(result.unknowns).toMatchObject([ + { + source_id: result.sources[0]!.id, + kind: "interpretation", + reason: expect.stringContaining("control character"), + }, + ]); + expect(result.sources[0]!.url).toBe(url); + expect(result.coverage.candidate_enumeration).toBe("partial"); + }, +); + +it.each(["app/page.js", "app/hello\nworld/page.js"])( + "does not promote page-shaped path %j without source-router applicability", + (path) => { + const unknown = source(path, ""); + delete (unknown as { next_app_context?: unknown }).next_app_context; + const result = build([unknown]); + expect(result.routes).toEqual([]); + expect(result.unknowns).toMatchObject([ + { + source_id: result.sources[0]!.id, + kind: "interpretation", + reason: expect.stringContaining("not measured"), + }, + ]); + expect(result.sources[0]!.path).toBe(path); + expect(result.coverage.candidate_enumeration).toBe("partial"); + }, +); it("binds source identity to the package evidence establishing router applicability", () => { const first = source("app/page.js", ""); diff --git a/src/domain/javascript/javascriptRoutePaths.ts b/src/domain/javascript/javascriptRoutePaths.ts index ecfe8fd571..5aad7623c8 100644 --- a/src/domain/javascript/javascriptRoutePaths.ts +++ b/src/domain/javascript/javascriptRoutePaths.ts @@ -105,7 +105,7 @@ export const classifyNextAppRoutePath = ( } if (relative === undefined) { if ( - /(?:^|\/)app\/(?:.*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.test( + /(?:^|\/)app\/(?:[\s\S]*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.test( value, ) ) @@ -137,7 +137,7 @@ export const classifyNextAppRoutePath = ( } } const match = - /^(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)([?#].*)?$/u.exec( + /^(?:([\s\S]*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)([?#].*)?$/u.exec( relative, ); if ( @@ -148,6 +148,11 @@ export const classifyNextAppRoutePath = ( (match[3] !== undefined && (urlPath || context.kind === "source-path")) ) return; + if (/[\u0000-\u001f\u007f]/u.test(relative)) + return { + kind: "unknown", + reason: "A Next page filename contains a control character.", + }; 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 @@ -178,8 +183,9 @@ export const classifyNextAppRoutePath = ( : segment, ); } - const path = routePath(`/${segments.filter(Boolean).join("/")}`); - return path === undefined ? undefined : { kind: "known", path }; + // These are filename-derived patterns, not navigation URLs. Preserve spaces + // and literal delimiters rather than applying URL syntax validation. + return { kind: "known", path: `/${segments.filter(Boolean).join("/")}` }; }; export const nextScriptBasePath = (source: string): string | undefined => { diff --git a/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts b/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts index e3587c4a38..a27f2c9c09 100644 --- a/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts +++ b/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts @@ -20,6 +20,7 @@ cliTest( "app/team%2Fadmin/page.js": page("EncodedSlash"), "app/%5Fpublic/page.js": page("EscapedUnderscore"), "app/blog/%5fdraft/page.js": page("LowercaseEscape"), + "app/hello world/page.js": page("Space"), }); const response = await cli.run({ arguments: ["analyze-javascript-application", root, "--format", "json"], @@ -40,6 +41,7 @@ cliTest( { path: "/_public", source_count: 1 }, { path: "/a", source_count: 1 }, { path: "/blog/%5fdraft", source_count: 1 }, + { path: "/hello world", source_count: 1 }, { path: "/team%2Fadmin", source_count: 1 }, ]); expect(analysis.route_inventory?.coverage).toMatchObject({