From 38ecc0248e1993d25a2f7dd350024484cce797bb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:34:06 +0800 Subject: [PATCH] fix(javascript): preserve route flag values and lexical RPC names --- docs/javascript-artifact-reconstruction.md | 8 ++-- docs/javascript-route-inventory.md | 4 ++ .../javascript/javascriptRouteScan.test.ts | 32 +++++++++++++++ src/domain/javascript/javascriptRouteScan.ts | 26 ++++++++++-- .../javascript/javascriptRpcEndpoints.test.ts | 30 ++++++++++++++ .../javascript/javascriptRpcEndpoints.ts | 16 +++++--- .../javascriptRouteAndRpcIdentity.test.ts | 40 +++++++++++++++++++ 7 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 tests/acceptance/applications/javascriptRouteAndRpcIdentity.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 79d624224d..04287f59ca 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -298,9 +298,11 @@ RPC client syntax also contributes network endpoint candidates, in both initializer and no binding assignments; shadowed locals do not inherit client identity. `subscribe` additionally requires a tRPC observer argument (`onData`, `onError`, `onStarted`, `onStopped`, `onComplete`, or - `onConnectionStateChange`). `this` roots, browser globals, and any path - containing an RTK Query `endpoints` segment are never procedures; cache-key - helpers such as `queryKey` issue no request and are excluded. + `onConnectionStateChange`). `this` roots and unshadowed browser globals are + excluded. A lexical client can be named `history`, and its router segments can + use browser-global spellings. RTK Query `endpoints` chains are excluded only + when the root has no recognized tRPC client binding. Cache-key helpers such as + `queryKey` issue no request and are excluded. - `trpc:link:` records the literal `url` option of `httpLink`, `httpBatchLink`, `httpBatchStreamLink`, and `httpSubscriptionLink` when the link's callee name survives bundling, including `(0, module.httpBatchLink)` diff --git a/docs/javascript-route-inventory.md b/docs/javascript-route-inventory.md index 352eadf8e4..df9a3c6121 100644 --- a/docs/javascript-route-inventory.md +++ b/docs/javascript-route-inventory.md @@ -61,6 +61,10 @@ The shared parser handles literal router calls, nested route objects, typed `_$` trees with explicitly inferred property-name fallbacks, Next App Router page paths, and navigation targets. It follows stable lexical bindings and keeps unresolved parents, mutations, spreads, and dynamic fields explicit. +Index-route flags use the same value-at-read boundary as path fields: erased +type wrappers and initialized stable boolean aliases preserve `true`/`false`; +uninitialized or unresolved flags retain an interpretation unknown instead of +silently disappearing. A selected route-builder root owns its configuration even when mutation prevents trusting its value. Ordinary wrapper keys do not become route ancestors. Missing outer ancestors of unbound typed subtrees remain unknown rather than invented. diff --git a/src/domain/javascript/javascriptRouteScan.test.ts b/src/domain/javascript/javascriptRouteScan.test.ts index 2d17e86c7c..2c80778df7 100644 --- a/src/domain/javascript/javascriptRouteScan.test.ts +++ b/src/domain/javascript/javascriptRouteScan.test.ts @@ -30,6 +30,38 @@ const analyze = (source: string, budget?: number) => { return complete(scan.finishSteps()); }; +it.each(["true", "(true as const)", "INDEX"])( + "retains an index route with flag %s", + (flag) => { + const facts = analyze( + `const INDEX=true;createBrowserRouter([{index:${flag},Component:Home}]);`, + ); + expect(facts.candidates.map(({ path }) => path)).toEqual(["/"]); + expect(facts.unresolved).toEqual([]); + }, +); + +it.each([ + "createBrowserRouter([{index:INDEX,Component:Home}]);const INDEX=true;", + "const INDEX=runtime;createBrowserRouter([{index:INDEX,Component:Home}]);", +])("retains unknown index flags without inventing a route: %s", (source) => { + const facts = analyze(source); + expect(facts.candidates).toEqual([]); + expect(facts.unresolved).toEqual([ + expect.objectContaining({ + reason: expect.stringContaining("index-route flag"), + }), + ]); +}); + +it("keeps a known false index flag absent", () => { + const facts = analyze( + "const INDEX=false;createBrowserRouter([{index:INDEX,Component:Home}]);", + ); + expect(facts.candidates).toEqual([]); + expect(facts.unresolved).toEqual([]); +}); + it("joins router children and preserves each segment's source", () => { const facts = analyze( `createBrowserRouter([{path:'/reports',children:[{path:':id?',children:[{path:'settings'}]}]}]);`, diff --git a/src/domain/javascript/javascriptRouteScan.ts b/src/domain/javascript/javascriptRouteScan.ts index a51a7c455f..96d304a622 100644 --- a/src/domain/javascript/javascriptRouteScan.ts +++ b/src/domain/javascript/javascriptRouteScan.ts @@ -982,8 +982,16 @@ export class JavaScriptRouteScan { if (!t.isIdentifier(node)) return node; if (seen.has(node) || !this.#queue(node)) return; seen.add(node); + const binding = index.bindingForReferenceAt(node); + if (binding === undefined) return; + const read = semanticBindingAtRead( + binding, + node, + index.bindings.parentsByNode, + ); + if (!read.hasDefiniteInitializer) return; const initializer = semanticStableInitializer( - index.bindingForReferenceAt(node), + read.binding, index.bindings, ); if ( @@ -1379,7 +1387,11 @@ export class JavaScriptRouteScan { fields.unknownKeys || [...fields.dynamicNames].some( (key) => - typed || key === "path" || key === "route" || key === "children", + typed || + key === "path" || + key === "route" || + key === "children" || + key === "index", ); if (uncertain) { this.#unknown( @@ -1433,8 +1445,16 @@ export class JavaScriptRouteScan { }; emit = true; } else if (!typed) { - const value = own.get("index")?.value; + const flag = own.get("index")?.value; + const value = + flag === undefined ? undefined : this.#resolve(flag, site, index); emit = t.isBooleanLiteral(value) && value.value; + if (flag !== undefined && !t.isBooleanLiteral(value)) + this.#unknown( + mechanism, + flag, + "The index-route flag is not a known boolean at this use.", + ); } if (emit && link !== undefined) { const segments: JavaScriptRouteSegment[] = []; diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index bd27234417..cb5e97cef5 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.test.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -68,6 +68,36 @@ describe("RPC object property ownership", () => { }); describe("tRPC procedure recognition", () => { + it("preserves lexical clients and router members with browser-global spellings", () => { + expect( + rpcEndpoints(`function scope() { + const history=createTRPCClient({links:[httpBatchLink({url:'/api/trpc'})]}); + history.archive.query(); + history.history.list.query(); + history.endpoints.list.query(); + navigator.permissions.query({name:'geolocation'}); + api.endpoints.list.useQuery(); + }`), + ).toEqual([ + { + kind: "network", + value: "/api/trpc", + mechanism: "trpc:link:httpBatchLink", + }, + { kind: "network", value: "archive", mechanism: "trpc:procedure:query" }, + { + kind: "network", + value: "history.list", + mechanism: "trpc:procedure:query", + }, + { + kind: "network", + value: "endpoints.list", + mechanism: "trpc:procedure:query", + }, + ]); + }); + it("recognizes React hook procedures on a renamed proxy root", () => { expect( rpcEndpoints(` diff --git a/src/domain/javascript/javascriptRpcEndpoints.ts b/src/domain/javascript/javascriptRpcEndpoints.ts index a7af6d2461..c3097aee4e 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.ts @@ -169,8 +169,9 @@ const trpcLinkUrl = ( * Create the per-source scan. Hook and option-factory methods are distinctive. * `query`/`mutate` need a root bound to a tRPC client factory, or a tRPC source * marker with a router path of at least two segments; `subscribe` needs the - * marker and a tRPC observer argument. `this` roots, browser globals and RTK - * Query `endpoints` chains are never procedures. + * marker and a tRPC observer argument. Unshadowed browser globals and `this` + * roots are excluded; RTK Query `endpoints` exclusions apply only without a + * recognized client binding. Router segment spelling does not supply identity. */ export const createJavaScriptRpcScan = ( source: string, @@ -187,15 +188,18 @@ export const createJavaScriptRpcScan = ( ): JavaScriptRpcEndpoint | undefined => { const path = memberPath(node.callee); if (path === undefined || t.isThisExpression(path.root)) return undefined; - if (t.isIdentifier(path.root) && BROWSER_GLOBAL_NAMES.has(path.root.name)) + const bound = clientRoots.has(path.root); + if ( + t.isIdentifier(path.root) && + BROWSER_GLOBAL_NAMES.has(path.root.name) && + index.bindingForReferenceAt(path.root) === undefined + ) return undefined; const method = path.properties[path.properties.length - 1]; const segments = path.properties.slice(0, -1); const first = segments[0]; if (method === undefined || first === undefined) return undefined; - if (BROWSER_GLOBAL_NAMES.has(first) || segments.includes("endpoints")) - return undefined; - const bound = clientRoots.has(path.root); + if (!bound && segments.includes("endpoints")) return undefined; const recognized = TRPC_HOOK_METHODS.has(method) ? true : TRPC_REQUEST_METHODS.has(method) diff --git a/tests/acceptance/applications/javascriptRouteAndRpcIdentity.test.ts b/tests/acceptance/applications/javascriptRouteAndRpcIdentity.test.ts new file mode 100644 index 0000000000..61f489d797 --- /dev/null +++ b/tests/acceptance/applications/javascriptRouteAndRpcIdentity.test.ts @@ -0,0 +1,40 @@ +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)( + "preserves route flags and lexical RPC names through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-route-rpc-identity-"); + await writeFixtureFiles(root, { + "entry.ts": ` + const INDEX=true; + createBrowserRouter([{index:INDEX,Component:Home}, {path:'/wrapped',children:[{index:(true as const),Component:Home}]}]); + function clientScope() { + const history=createTRPCClient({links:[httpBatchLink({url:'/api/trpc'})]}); + history.archive.query();history.history.list.query(); + navigator.permissions.query({name:'geolocation'}); + api.endpoints.list.useQuery(); + } + `, + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + expect(result.route_inventory?.routes.map(({ path }) => path)).toEqual([ + "/", + "/wrapped", + ]); + const operations = result.graph.nodes + .filter(({ kind }) => kind === "endpoint") + .flatMap(({ observations }) => observations) + .filter( + ({ properties }) => properties.mechanism === "trpc:procedure:query", + ) + .map(({ properties }) => properties.value) + .sort(); + expect(operations).toEqual(["archive", "history.list"]); + }, +);