fix(javascript): preserve route flag values and lexical RPC names

This commit is contained in:
morluto
2026-10-24 02:34:06 +08:00
parent ee0ad17a1a
commit 38ecc0248e
7 changed files with 144 additions and 12 deletions
+5 -3
View File
@@ -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:<link>` records the literal `url` option of `httpLink`,
`httpBatchLink`, `httpBatchStreamLink`, and `httpSubscriptionLink` when the
link's callee name survives bundling, including `(0, module.httpBatchLink)`
+4
View File
@@ -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.
@@ -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'}]}]}]);`,
+23 -3
View File
@@ -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[] = [];
@@ -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(`
@@ -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)
@@ -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"]);
},
);