From 6c414cc8308df93d47bbdc367710f27b27eff3ed Mon Sep 17 00:00:00 2001 From: Daniil Date: Sun, 27 Sep 2026 15:27:31 +0300 Subject: [PATCH] fix(ui): link every route to its handler, however many files they span (#1975) (#1993) buildRoutes looked up node ids only for the first 60 distinct handler files, one getNodesInFile query each, so every later row went out with handlerId null and the viewer called the handler "not in the index". Load the nodes of every handler file in one chunked query instead (getNodesInFiles), which also drops the per-file query the cap existed to bound. Co-authored-by: danusha2345 Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/ui-server-api.test.ts | 34 +++++++++++++++++++++++++++++++++ src/db/queries.ts | 18 +++++++++++++++++ src/index.ts | 7 +++++++ src/ui-server/api/routes.ts | 23 +++++++++------------- 5 files changed, 69 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7e5b027..c0d66e98a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -274,6 +274,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- In `codegraph ui`, routes whose handlers live in more than 60 different files are all linked to their handler, instead of the later ones showing "not in the index". (#1975) - Kotlin functions and methods now carry their signature — `(params): ReturnType` — in `codegraph_explore`, `node` and the viewer, instead of no signature at all. Re-index Kotlin projects after upgrading. (#1495) - TypeScript/JavaScript value aliases — `export const alias = fn`, `export { fn as alias }`, object-literal `api = { run: fn }`, and same-file `const local = fn` — now forward calls edges to the aliased function, so callers and impact on the implementation include consumers that call through the alias instead of stopping at the binding. Genuine wrappers (`() => fn()`) are unchanged. Re-index after upgrading. Thanks @valkyriweb. (#1482, #1485) - `codegraph affected` now finds Go, Python and JVM test files that previously went unreported, while preserving custom `--filter` behavior (thanks @danusha2345; #1507, #1688). diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts index 512887266..e2d3646c1 100644 --- a/__tests__/ui-server-api.test.ts +++ b/__tests__/ui-server-api.test.ts @@ -20,6 +20,7 @@ import * as os from 'os'; import * as path from 'path'; import CodeGraph from '../src/index'; import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server'; +import { buildRoutes } from '../src/ui-server/api/routes'; interface Response { status: number; @@ -906,6 +907,39 @@ describe('GET /api/routes', () => { } }); + it('links every route to its handler, however many files the handlers live in (#1975)', async () => { + // 70 handlers, one file each: the lookup used to stop at the 60th file and + // report the rest as "not in the index". + const root = path.join(tempDir, 'many-handlers'); + fs.mkdirSync(path.join(root, 'src', 'handlers'), { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ dependencies: { express: '^4.0.0' } })); + const imports: string[] = []; + const mounts: string[] = []; + for (let n = 1; n <= 70; n++) { + fs.writeFileSync( + path.join(root, 'src', 'handlers', `h${n}.ts`), + `export function h${n}(req: any, res: any): void { res.json(${n}); }\n` + ); + imports.push(`import { h${n} } from './handlers/h${n}';`); + mounts.push(`app.get('/r${n}', h${n});`); + } + fs.writeFileSync( + path.join(root, 'src', 'app.ts'), + `import express from 'express';\n${imports.join('\n')}\nconst app = express();\n${mounts.join('\n')}\nexport default app;\n` + ); + const cg = CodeGraph.initSync(root, { config: { include: ['src/**/*.ts'], exclude: [] } }); + try { + await cg.indexAll(); + cg.resolveReferences(); + const body = buildRoutes(cg, new URLSearchParams('limit=200')); + expect(body.entries).toHaveLength(70); + const unlinked = body.entries.filter((e) => !e.handlerId).map((e) => e.url); + expect(unlinked).toEqual([]); + } finally { + cg.close(); + } + }, 120_000); + describe('a project that IS routed', () => { let routedApi: GraphApi; let routedServer: UiServerHandle; diff --git a/src/db/queries.ts b/src/db/queries.ts index 1bce611a1..b2452176b 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -959,6 +959,24 @@ export class QueryBuilder { return rows.map(rowToNode); } + /** + * Get all nodes in several files at once — one chunked `IN` query rather than + * one {@link getNodesByFile} per file (#1975). + */ + getNodesByFiles(filePaths: readonly string[]): Node[] { + const unique = [...new Set(filePaths)]; + const out: Node[] = []; + for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare(`SELECT * FROM nodes WHERE file_path IN (${placeholders})`) + .all(...chunk) as NodeRow[]; + for (const row of rows) out.push(rowToNode(row)); + } + return out; + } + /** * Find the file that holds the densest concentration of the project's * internal call graph — the "core" file. Used by context-builder to diff --git a/src/index.ts b/src/index.ts index 7b8ef2ca7..b27035748 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1585,6 +1585,13 @@ export class CodeGraph { return this.queries.getNodesByFile(filePath); } + /** + * Get all nodes in several files, in one batched query. + */ + getNodesInFiles(filePaths: readonly string[]): Node[] { + return this.queries.getNodesByFiles(filePaths); + } + /** * Get all nodes of a specific kind */ diff --git a/src/ui-server/api/routes.ts b/src/ui-server/api/routes.ts index a62451769..44429b6b5 100644 --- a/src/ui-server/api/routes.ts +++ b/src/ui-server/api/routes.ts @@ -79,9 +79,6 @@ export function splitRouteName(url: string): { method: string | null; path: stri return { method: head.toUpperCase(), path: url.slice(space + 1).trimStart() }; } -/** Distinct handler files we will resolve node ids for. */ -const MAX_HANDLER_FILES = 60; - /** * The engine needs three surviving rows to call a project routed, and applies * `limit` before that test — so anything below three is a question that cannot @@ -111,18 +108,16 @@ export function buildRoutes(cg: CodeGraph, query: URLSearchParams): WireRoutes { const truncated = manifest.entries.length > limit; const rows = manifest.entries.slice(0, limit); - // One `getNodesInFile` per distinct handler file — typically one or two, and - // capped so a project that scatters handlers across hundreds of files cannot - // turn one request into hundreds of queries. - const handlerFiles = [...new Set(rows.map((e) => e.handlerFile))].slice(0, MAX_HANDLER_FILES); + // Every row's handler file in one batched query, so a project that scatters + // handlers across hundreds of files still costs one query per chunk, and no + // row past a cap is reported as "not in the index" (#1975). + const handlerFiles = [...new Set(rows.map((e) => e.handlerFile))]; const byFileLineName = new Map(); - for (const file of handlerFiles) { - for (const node of cg.getNodesInFile(file)) { - // Keyed on what the manifest actually knows: file, line and name. Two - // symbols can share a line (a decorator and its method); the name breaks - // the tie, and a miss simply leaves that entry unlinked. - byFileLineName.set(`${node.filePath} ${node.startLine} ${node.name}`, node.id); - } + for (const node of cg.getNodesInFiles(handlerFiles)) { + // Keyed on what the manifest actually knows: file, line and name. Two + // symbols can share a line (a decorator and its method); the name breaks + // the tie, and a miss simply leaves that entry unlinked. + byFileLineName.set(`${node.filePath} ${node.startLine} ${node.name}`, node.id); } const entries: WireRoute[] = rows.map((entry) => ({