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) => ({