fix(viewer): the routes list's limit counts routes, not one route's calls

The routing manifest has a row per (route, symbol it reaches), and the
routes list behind Entry points and the Steps picker folds those rows to
one per route. To have rows to fold, it fetched limit * 4 + 1 of them,
so an inline handler, which has a row per call in its body, could use
up the window by itself, and the routes after it were never fetched. On
an Express app with one 24-call inline handler before two routes,
limit=3 listed only GET /dense.

getRoutingManifest takes a perRoute flag that applies its limit to
routes, in the same file and line order, and returns every row of those
routes. Without the flag the query and its result are unchanged.

Added on top of the contributor's change: the window is still
over-fetched, four routes per route shown, because some routes are
dropped only after their rows come back (a test or generated handler,
an inline route written in a test). A window of exactly limit + 1
routes could hold only those. On hono, whose routes are nearly all
registered in test files, Entry points then listed 0 routes instead of
main's 28, and the routes list 21 instead of 32. With the over-fetch,
every route main lists is still listed at each limit tried (3, 12, 60,
200 and 300), and hono's Entry points list 32. truncated also keeps
main's check that the window came back full.

Validation: the new limit=3 test fails on main (only GET /dense) and
passes here. A project whose test-file routes sort first fails with the
PR as first written (it reads as unrouted) and passes here and on main.
Routes listed on Wiki.js (132 routes) and realworld-express are
identical to main, and on hono they differ only by the additions above.
The ui suites and the other route consumers: 34 files, 723 passed, 0
failed, 3 skipped (environment-gated). The changelog line is in
docs/viewer-launch-changelog.md, since the viewer is not released yet.

Original PR #2370 by @danusha2345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-10-23 16:56:54 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 2def0a6d6d
commit 33dc2ae72b
5 changed files with 104 additions and 15 deletions
+66
View File
@@ -25,6 +25,16 @@ beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-repeated-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', private: true, dependencies: { express: '^4.0.0', astro: '^4.0.0' } }),
'dense/app.js': `const express = require('express'); const app = express();
${Array.from({ length: 24 }, (_, i) => `function task${i}() { return ${i}; }`).join('\n')}
function second(req, res) { res.end(); }
function third(req, res) { res.end(); }
app.get('/dense', (req, res) => {
${Array.from({ length: 24 }, (_, i) => `task${i}();`).join('\n')}
});
app.get('/second', second);
app.get('/third', third);
`,
'server/app.js': `const express = require('express');
const app = express();
@@ -70,6 +80,62 @@ afterAll(() => {
});
describe('the routes list', () => {
it('applies its limit to routes, not to the calls of one inline handler', () => {
const payload = buildRoutes(cg, new URLSearchParams('limit=3'));
expect(payload.entries.map((e) => e.url)).toEqual(['GET /dense', 'GET /second', 'GET /third']);
expect(payload.shown).toBe(3);
expect(payload.truncated).toBe(true);
});
it('still calls a project routed when its one inline route has three calls', async () => {
const singleRoot = path.join(root, 'one-route');
fs.mkdirSync(singleRoot, { recursive: true });
fs.writeFileSync(path.join(singleRoot, 'package.json'), JSON.stringify({ dependencies: { express: '^4.0.0' } }));
fs.writeFileSync(path.join(singleRoot, 'app.js'), `const app = require('express')();
function first() {} function second() {} function third() {}
app.get('/one', () => { first(); second(); third(); });
`);
const single = await CodeGraph.init(singleRoot, { index: true });
try {
const payload = buildRoutes(single, new URLSearchParams('limit=3'));
expect(payload.routed).toBe(true);
expect(payload.entries.map((e) => e.url)).toEqual(['GET /one']);
} finally {
single.close();
}
});
it('fills the page past routes it drops after reading them, like a test file\'s', async () => {
const testsFirst = path.join(root, 'tests-first');
const put = (rel: string, text: string) => {
fs.mkdirSync(path.dirname(path.join(testsFirst, rel)), { recursive: true });
fs.writeFileSync(path.join(testsFirst, rel), text);
};
put('package.json', JSON.stringify({ dependencies: { express: '^4.0.0' } }));
// Sorts before src/, and every handler is test code, so these rows are
// dropped only once they are read: they must not use up the window.
put('__tests__/routes.test.js', `const app = require('express')();
${Array.from({ length: 6 }, (_, i) => `function t${i}(req, res) { res.end(); }\napp.get('/t${i}', t${i});`).join('\n')}
`);
put('src/app.js', `const app = require('express')();
function a(req, res) { res.end(); }
function b(req, res) { res.end(); }
function c(req, res) { res.end(); }
app.get('/a', a);
app.get('/b', b);
app.get('/c', c);
`);
const project = await CodeGraph.init(testsFirst, { index: true });
try {
const payload = buildRoutes(project, new URLSearchParams('limit=3'));
expect(payload.routed).toBe(true);
expect(payload.entries.map((e) => e.url)).toEqual(['GET /a', 'GET /b', 'GET /c']);
expect(payload.truncated).toBe(false);
} finally {
project.close();
}
});
it('has one row per route', () => {
const { entries } = buildRoutes(cg, new URLSearchParams());
const ids = entries.map((e) => e.routeId);
+2
View File
@@ -127,6 +127,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel
- **Routes served by a component are listed.** Entry points and the Steps picker listed a route only when a function, method or class served it. A Vue Router, Nuxt, Svelte or Astro screen is served by a component, so those apps' routes weren't listed at all. They are now, each with the component that serves it.
- **One busy inline handler no longer hides the other routes.** Entry points and the Steps picker filled their list by the symbols each route reaches rather than by routes, so an inline handler with dozens of calls could use up the list by itself, and the routes after it went unlisted. They now count routes. Thanks @danusha2345. (#2370)
- **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up.
- **A screen that talks to native code keeps its own navigations.** In a React Native or Expo app, a `router.push` written inside a listener for a native event was credited to whichever screen had *started* that round trip, not to the screen the push is written on. In one app that moved seven transitions off the capture screen and onto the review screen it opens — leaving the review screen looking as though nothing in the app could reach it, stranded in the "no transition reaches this" band at the bottom of the Screens tab, and printing Swift conditions like `Thread.isMainThread` on a JavaScript navigation. A navigation now belongs to the screen whose file it is written in; an event arriving from native code, from an HTTP call or off a queue is no longer read backwards as if it were a caller.
+21 -7
View File
@@ -377,6 +377,7 @@ export class QueryBuilder {
getTopRouteFile?: SqliteStatement;
unseenName?: SqliteStatement;
getRoutingManifest?: SqliteStatement;
getRoutingManifestPerRoute?: SqliteStatement;
insertNameSegment?: SqliteStatement;
} = {};
@@ -1332,8 +1333,12 @@ export class QueryBuilder {
* Also returns the file with the most handler endpoints — used as the
* "top handler file" to inline source for, so the agent has both the
* mapping AND the handler implementations.
*
* `limit` caps rows, and a route has a row per symbol it reaches. With
* `perRoute` it caps routes instead and returns every row of those routes,
* so one inline handler's many calls can't take other routes' places.
*/
getRoutingManifest(limit: number = 40): {
getRoutingManifest(limit: number = 40, perRoute: boolean = false): {
entries: Array<{
url: string;
handler: string;
@@ -1349,12 +1354,15 @@ export class QueryBuilder {
topHandlerFileCount: number;
totalRoutes: number;
} | null {
if (!this.stmts.getRoutingManifest) {
const key = perRoute ? 'getRoutingManifestPerRoute' : 'getRoutingManifest';
if (!this.stmts[key]) {
// Edge kind varies across framework resolvers: Spring/Rails/
// Laravel/Drupal emit `references`, Express emits `calls`. Accept
// both — the semantic is the same (route → its handler). A screen in
// a Vue / Svelte / Astro app is served by a `component`.
this.stmts.getRoutingManifest = this.db.prepare(`
const serves = `e.kind IN ('references', 'calls')
AND h.kind IN ('function', 'method', 'class', 'constant', 'variable', 'component')`;
this.stmts[key] = this.db.prepare(`
SELECT
r.name AS url,
r.id AS route_id,
@@ -1368,13 +1376,19 @@ export class QueryBuilder {
JOIN edges e ON e.source = r.id
JOIN nodes h ON e.target = h.id
WHERE r.kind = 'route'
AND e.kind IN ('references', 'calls')
AND h.kind IN ('function', 'method', 'class', 'constant', 'variable', 'component')
AND ${serves}
${perRoute ? `AND r.id IN (
SELECT r2.id FROM nodes r2
WHERE r2.kind = 'route'
AND EXISTS (SELECT 1 FROM edges e JOIN nodes h ON e.target = h.id WHERE e.source = r2.id AND ${serves})
ORDER BY r2.file_path, r2.start_line
LIMIT ?
)` : ''}
ORDER BY r.file_path, r.start_line
LIMIT ?
${perRoute ? '' : 'LIMIT ?'}
`);
}
const rows = this.stmts.getRoutingManifest.all(limit) as Array<{
const rows = this.stmts[key]!.all(limit) as Array<{
url: string; route_id: string; route_file: string; route_line: number;
handler: string; handler_file: string; handler_line: number; handler_kind: string;
}>;
+2 -2
View File
@@ -2429,7 +2429,7 @@ export class CodeGraph {
* via the `references` edge that framework resolvers emit. Returns
* null when fewer than 3 valid (non-test) routes exist.
*/
getRoutingManifest(limit?: number): {
getRoutingManifest(limit?: number, perRoute?: boolean): {
entries: Array<{
url: string;
handler: string;
@@ -2444,7 +2444,7 @@ export class CodeGraph {
topHandlerFileCount: number;
totalRoutes: number;
} | null {
return this.queries.getRoutingManifest(limit);
return this.queries.getRoutingManifest(limit, perRoute);
}
// ===========================================================================
+13 -6
View File
@@ -90,8 +90,13 @@ export function splitRouteName(url: string): { method: string | null; path: stri
*/
const MIN_LIMIT = 3;
/** Manifest rows fetched per route shown, so folding repeats still fills the page. */
const ROWS_PER_ROUTE = 4;
/**
* Routes fetched per route shown. Some routes in the window are dropped only
* after their rows come back (a test or generated handler, an inline route
* written in a test), so the window is over-fetched for them, or a project
* whose first routes are its tests' would list few or none of its own.
*/
const ROUTES_PER_ROUTE_SHOWN = 4;
type ManifestRow = NonNullable<ReturnType<CodeGraph['getRoutingManifest']>>['entries'][number];
@@ -143,9 +148,11 @@ export function buildRoutes(cg: CodeGraph, query: URLSearchParams): WireRoutes {
// symbols, or an inline handler whose every call reads as a "handler"
// (hono's `GET /stream/text` came back three times, as `streamText`,
// `writeln` and `sleep`), repeats. A route has ONE answer to "what serves
// this" — route-roots.ts's — so rows are over-fetched and folded onto it.
const fetched = limit * ROWS_PER_ROUTE + 1;
const manifest = cg.getRoutingManifest(fetched);
// this" — route-roots.ts's — so every row of a route is fetched and folded
// onto it. The window counts routes, not rows: one inline handler with
// dozens of calls must not push the routes after it off the page.
const fetched = limit * ROUTES_PER_ROUTE_SHOWN + 1;
const manifest = cg.getRoutingManifest(fetched, true);
const routeCount = cg.getStats().nodesByKind.route ?? 0;
if (!manifest) {
@@ -161,7 +168,7 @@ export function buildRoutes(cg: CodeGraph, query: URLSearchParams): WireRoutes {
}
const folded = oneRowPerRoute(cg, manifest.entries);
const truncated = folded.length > limit || manifest.entries.length >= fetched;
const truncated = folded.length > limit || new Set(manifest.entries.map((e) => e.routeId)).size >= fetched;
const rows = folded.slice(0, limit);
// Every row's handler file in one batched query, so a project that scatters