queries.ts 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751
  1. /**
  2. * Database Queries
  3. *
  4. * Prepared statements for CRUD operations on the knowledge graph.
  5. */
  6. import { SqliteDatabase, SqliteStatement } from './sqlite-adapter';
  7. import {
  8. Node,
  9. Edge,
  10. FileRecord,
  11. UnresolvedReference,
  12. NodeKind,
  13. EdgeKind,
  14. Language,
  15. GraphStats,
  16. SearchOptions,
  17. SearchResult,
  18. } from '../types';
  19. import { safeJsonParse } from '../utils';
  20. import { kindBonus, nameMatchBonus, scorePathRelevance } from '../search/query-utils';
  21. import { parseQuery, boundedEditDistance } from '../search/query-parser';
  22. import { isGeneratedFile } from '../extraction/generated-detection';
  23. /**
  24. * Path-only heuristic for files that should not be candidates for
  25. * "dominant file" detection: test/spec files and tool-generated files.
  26. * Generated files (`*.pb.go`, `*.pulsar.go`, mock outputs, …) often
  27. * have huge in-file edge counts that dwarf the real source — etcd's
  28. * `rpc.pb.go` has 4× the in-file edges of `server.go`.
  29. */
  30. function isLowValueFile(filePath: string): boolean {
  31. const lp = filePath.toLowerCase();
  32. return (
  33. /(?:^|\/)(tests?|__tests?__|spec)\//.test(lp) ||
  34. /_test\.go$/.test(lp) ||
  35. /(?:^|\/)test_[^/]+\.py$/.test(lp) ||
  36. /_test\.py$/.test(lp) ||
  37. /_spec\.rb$/.test(lp) ||
  38. /_test\.rb$/.test(lp) ||
  39. /\.(test|spec)\.[jt]sx?$/.test(lp) ||
  40. /(test|spec|tests)\.(java|kt|scala)$/.test(lp) ||
  41. /(tests?|spec)\.cs$/.test(lp) ||
  42. /tests?\.swift$/.test(lp) ||
  43. /_test\.dart$/.test(lp) ||
  44. isGeneratedFile(filePath)
  45. );
  46. }
  47. const SQLITE_PARAM_CHUNK_SIZE = 500;
  48. /**
  49. * Database row types (snake_case from SQLite)
  50. */
  51. interface NodeRow {
  52. id: string;
  53. kind: string;
  54. name: string;
  55. qualified_name: string;
  56. file_path: string;
  57. language: string;
  58. start_line: number;
  59. end_line: number;
  60. start_column: number;
  61. end_column: number;
  62. docstring: string | null;
  63. signature: string | null;
  64. visibility: string | null;
  65. is_exported: number;
  66. is_async: number;
  67. is_static: number;
  68. is_abstract: number;
  69. decorators: string | null;
  70. type_parameters: string | null;
  71. updated_at: number;
  72. }
  73. interface EdgeRow {
  74. id: number;
  75. source: string;
  76. target: string;
  77. kind: string;
  78. metadata: string | null;
  79. line: number | null;
  80. col: number | null;
  81. provenance: string | null;
  82. }
  83. interface FileRow {
  84. path: string;
  85. content_hash: string;
  86. language: string;
  87. size: number;
  88. modified_at: number;
  89. indexed_at: number;
  90. node_count: number;
  91. errors: string | null;
  92. }
  93. interface UnresolvedRefRow {
  94. id: number;
  95. from_node_id: string;
  96. reference_name: string;
  97. reference_kind: string;
  98. line: number;
  99. col: number;
  100. candidates: string | null;
  101. file_path: string;
  102. language: string;
  103. }
  104. /**
  105. * Convert database row to Node object
  106. */
  107. function rowToNode(row: NodeRow): Node {
  108. return {
  109. id: row.id,
  110. kind: row.kind as NodeKind,
  111. name: row.name,
  112. qualifiedName: row.qualified_name,
  113. filePath: row.file_path,
  114. language: row.language as Language,
  115. startLine: row.start_line,
  116. endLine: row.end_line,
  117. startColumn: row.start_column,
  118. endColumn: row.end_column,
  119. docstring: row.docstring ?? undefined,
  120. signature: row.signature ?? undefined,
  121. visibility: row.visibility as Node['visibility'],
  122. isExported: row.is_exported === 1,
  123. isAsync: row.is_async === 1,
  124. isStatic: row.is_static === 1,
  125. isAbstract: row.is_abstract === 1,
  126. decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined,
  127. typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined,
  128. updatedAt: row.updated_at,
  129. };
  130. }
  131. /**
  132. * Convert database row to Edge object
  133. */
  134. function rowToEdge(row: EdgeRow): Edge {
  135. return {
  136. source: row.source,
  137. target: row.target,
  138. kind: row.kind as EdgeKind,
  139. metadata: row.metadata ? safeJsonParse(row.metadata, undefined) : undefined,
  140. line: row.line ?? undefined,
  141. column: row.col ?? undefined,
  142. provenance: row.provenance as Edge['provenance'],
  143. };
  144. }
  145. /**
  146. * Convert database row to FileRecord object
  147. */
  148. function rowToFileRecord(row: FileRow): FileRecord {
  149. return {
  150. path: row.path,
  151. contentHash: row.content_hash,
  152. language: row.language as Language,
  153. size: row.size,
  154. modifiedAt: row.modified_at,
  155. indexedAt: row.indexed_at,
  156. nodeCount: row.node_count,
  157. errors: row.errors ? safeJsonParse(row.errors, undefined) : undefined,
  158. };
  159. }
  160. /**
  161. * Query builder for the knowledge graph database
  162. */
  163. export class QueryBuilder {
  164. private db: SqliteDatabase;
  165. // Node cache for frequently accessed nodes (LRU-style, max 1000 entries)
  166. private nodeCache: Map<string, Node> = new Map();
  167. private readonly maxCacheSize = 1000;
  168. // Prepared statements (lazily initialized)
  169. private stmts: {
  170. insertNode?: SqliteStatement;
  171. updateNode?: SqliteStatement;
  172. deleteNode?: SqliteStatement;
  173. deleteNodesByFile?: SqliteStatement;
  174. getNodeById?: SqliteStatement;
  175. getNodesByFile?: SqliteStatement;
  176. getNodesByKind?: SqliteStatement;
  177. insertEdge?: SqliteStatement;
  178. upsertFile?: SqliteStatement;
  179. deleteEdgesBySource?: SqliteStatement;
  180. deleteEdgesByTarget?: SqliteStatement;
  181. getEdgesBySource?: SqliteStatement;
  182. getEdgesByTarget?: SqliteStatement;
  183. insertFile?: SqliteStatement;
  184. updateFile?: SqliteStatement;
  185. deleteFile?: SqliteStatement;
  186. getFileByPath?: SqliteStatement;
  187. getAllFiles?: SqliteStatement;
  188. insertUnresolved?: SqliteStatement;
  189. deleteUnresolvedByNode?: SqliteStatement;
  190. getUnresolvedByName?: SqliteStatement;
  191. getNodesByName?: SqliteStatement;
  192. getNodesByQualifiedNameExact?: SqliteStatement;
  193. getNodesByLowerName?: SqliteStatement;
  194. getUnresolvedCount?: SqliteStatement;
  195. getUnresolvedBatch?: SqliteStatement;
  196. getAllFilePaths?: SqliteStatement;
  197. getAllNodeNames?: SqliteStatement;
  198. getDominantFile?: SqliteStatement;
  199. getTopRouteFile?: SqliteStatement;
  200. getRoutingManifest?: SqliteStatement;
  201. } = {};
  202. constructor(db: SqliteDatabase) {
  203. this.db = db;
  204. }
  205. // ===========================================================================
  206. // Node Operations
  207. // ===========================================================================
  208. /**
  209. * Insert a new node
  210. */
  211. insertNode(node: Node): void {
  212. if (!this.stmts.insertNode) {
  213. this.stmts.insertNode = this.db.prepare(`
  214. INSERT OR REPLACE INTO nodes (
  215. id, kind, name, qualified_name, file_path, language,
  216. start_line, end_line, start_column, end_column,
  217. docstring, signature, visibility,
  218. is_exported, is_async, is_static, is_abstract,
  219. decorators, type_parameters, updated_at
  220. ) VALUES (
  221. @id, @kind, @name, @qualifiedName, @filePath, @language,
  222. @startLine, @endLine, @startColumn, @endColumn,
  223. @docstring, @signature, @visibility,
  224. @isExported, @isAsync, @isStatic, @isAbstract,
  225. @decorators, @typeParameters, @updatedAt
  226. )
  227. `);
  228. }
  229. // Validate required fields to prevent SQLite bind errors
  230. if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) {
  231. console.error('[CodeGraph] Skipping node with missing required fields:', {
  232. id: node.id,
  233. kind: node.kind,
  234. name: node.name,
  235. filePath: node.filePath,
  236. language: node.language,
  237. });
  238. return;
  239. }
  240. // INSERT OR REPLACE may overwrite a node we have cached. Drop the
  241. // stale entry so the next getNodeById sees the new row, not the old
  242. // one (matches the cache-invalidation pattern used by updateNode and
  243. // deleteNode below).
  244. this.nodeCache.delete(node.id);
  245. this.stmts.insertNode.run({
  246. id: node.id,
  247. kind: node.kind,
  248. name: node.name,
  249. qualifiedName: node.qualifiedName ?? node.name,
  250. filePath: node.filePath,
  251. language: node.language,
  252. startLine: node.startLine ?? 0,
  253. endLine: node.endLine ?? 0,
  254. startColumn: node.startColumn ?? 0,
  255. endColumn: node.endColumn ?? 0,
  256. docstring: node.docstring ?? null,
  257. signature: node.signature ?? null,
  258. visibility: node.visibility ?? null,
  259. isExported: node.isExported ? 1 : 0,
  260. isAsync: node.isAsync ? 1 : 0,
  261. isStatic: node.isStatic ? 1 : 0,
  262. isAbstract: node.isAbstract ? 1 : 0,
  263. decorators: node.decorators ? JSON.stringify(node.decorators) : null,
  264. typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
  265. updatedAt: node.updatedAt ?? Date.now(),
  266. });
  267. }
  268. /**
  269. * Insert multiple nodes in a transaction
  270. */
  271. insertNodes(nodes: Node[]): void {
  272. this.db.transaction(() => {
  273. for (const node of nodes) {
  274. this.insertNode(node);
  275. }
  276. })();
  277. }
  278. /**
  279. * Update an existing node
  280. */
  281. updateNode(node: Node): void {
  282. if (!this.stmts.updateNode) {
  283. this.stmts.updateNode = this.db.prepare(`
  284. UPDATE nodes SET
  285. kind = @kind,
  286. name = @name,
  287. qualified_name = @qualifiedName,
  288. file_path = @filePath,
  289. language = @language,
  290. start_line = @startLine,
  291. end_line = @endLine,
  292. start_column = @startColumn,
  293. end_column = @endColumn,
  294. docstring = @docstring,
  295. signature = @signature,
  296. visibility = @visibility,
  297. is_exported = @isExported,
  298. is_async = @isAsync,
  299. is_static = @isStatic,
  300. is_abstract = @isAbstract,
  301. decorators = @decorators,
  302. type_parameters = @typeParameters,
  303. updated_at = @updatedAt
  304. WHERE id = @id
  305. `);
  306. }
  307. // Invalidate cache before update
  308. this.nodeCache.delete(node.id);
  309. // Validate required fields
  310. if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) {
  311. console.error('[CodeGraph] Skipping node update with missing required fields:', node.id);
  312. return;
  313. }
  314. this.stmts.updateNode.run({
  315. id: node.id,
  316. kind: node.kind,
  317. name: node.name,
  318. qualifiedName: node.qualifiedName ?? node.name,
  319. filePath: node.filePath,
  320. language: node.language,
  321. startLine: node.startLine ?? 0,
  322. endLine: node.endLine ?? 0,
  323. startColumn: node.startColumn ?? 0,
  324. endColumn: node.endColumn ?? 0,
  325. docstring: node.docstring ?? null,
  326. signature: node.signature ?? null,
  327. visibility: node.visibility ?? null,
  328. isExported: node.isExported ? 1 : 0,
  329. isAsync: node.isAsync ? 1 : 0,
  330. isStatic: node.isStatic ? 1 : 0,
  331. isAbstract: node.isAbstract ? 1 : 0,
  332. decorators: node.decorators ? JSON.stringify(node.decorators) : null,
  333. typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
  334. updatedAt: node.updatedAt ?? Date.now(),
  335. });
  336. }
  337. /**
  338. * Delete a node by ID
  339. */
  340. deleteNode(id: string): void {
  341. if (!this.stmts.deleteNode) {
  342. this.stmts.deleteNode = this.db.prepare('DELETE FROM nodes WHERE id = ?');
  343. }
  344. // Invalidate cache
  345. this.nodeCache.delete(id);
  346. this.stmts.deleteNode.run(id);
  347. }
  348. /**
  349. * Delete all nodes for a file
  350. */
  351. deleteNodesByFile(filePath: string): void {
  352. if (!this.stmts.deleteNodesByFile) {
  353. this.stmts.deleteNodesByFile = this.db.prepare('DELETE FROM nodes WHERE file_path = ?');
  354. }
  355. // Invalidate cache for nodes in this file
  356. for (const [id, node] of this.nodeCache) {
  357. if (node.filePath === filePath) {
  358. this.nodeCache.delete(id);
  359. }
  360. }
  361. this.stmts.deleteNodesByFile.run(filePath);
  362. }
  363. /**
  364. * Get a node by ID
  365. */
  366. getNodeById(id: string): Node | null {
  367. // Check cache first
  368. if (this.nodeCache.has(id)) {
  369. const cached = this.nodeCache.get(id)!;
  370. // Move to end to implement LRU (delete and re-add)
  371. this.nodeCache.delete(id);
  372. this.nodeCache.set(id, cached);
  373. return cached;
  374. }
  375. if (!this.stmts.getNodeById) {
  376. this.stmts.getNodeById = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
  377. }
  378. const row = this.stmts.getNodeById.get(id) as NodeRow | undefined;
  379. if (!row) {
  380. return null;
  381. }
  382. const node = rowToNode(row);
  383. this.cacheNode(node);
  384. return node;
  385. }
  386. /**
  387. * Batch lookup: fetch many nodes by ID in a single SQL round-trip.
  388. *
  389. * Replaces the N+1 pattern in graph traversal where every edge would
  390. * trigger its own `getNodeById` call. For a function with 50 callers
  391. * this collapses 50 point reads into one IN-list query (~10-50x
  392. * faster end-to-end).
  393. *
  394. * Returns a Map keyed by id so callers can preserve their own ordering
  395. * (typically the order edges were returned from the graph). Missing IDs
  396. * are simply absent from the map.
  397. *
  398. * Cache-aware: ids already in the LRU cache are served from memory and
  399. * the SQL query only touches the misses.
  400. */
  401. getNodesByIds(ids: readonly string[]): Map<string, Node> {
  402. const out = new Map<string, Node>();
  403. if (ids.length === 0) return out;
  404. // Serve cache hits first; build the miss list for SQL.
  405. const misses: string[] = [];
  406. for (const id of ids) {
  407. const cached = this.nodeCache.get(id);
  408. if (cached !== undefined) {
  409. // LRU touch
  410. this.nodeCache.delete(id);
  411. this.nodeCache.set(id, cached);
  412. out.set(id, cached);
  413. } else {
  414. misses.push(id);
  415. }
  416. }
  417. if (misses.length === 0) return out;
  418. // Chunk under SQLite's parameter limit (default 999, raised to 32766
  419. // in better-sqlite3 builds — chunk at 500 for safety across both
  420. // backends and to keep the query plan simple).
  421. for (let i = 0; i < misses.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  422. const chunk = misses.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  423. const placeholders = chunk.map(() => '?').join(',');
  424. const rows = this.db
  425. .prepare(`SELECT * FROM nodes WHERE id IN (${placeholders})`)
  426. .all(...chunk) as NodeRow[];
  427. for (const row of rows) {
  428. const node = rowToNode(row);
  429. out.set(node.id, node);
  430. this.cacheNode(node);
  431. }
  432. }
  433. return out;
  434. }
  435. private getExistingNodeIds(ids: readonly string[]): Set<string> {
  436. const out = new Set<string>();
  437. if (ids.length === 0) return out;
  438. const uniqueIds = [...new Set(ids)];
  439. for (let i = 0; i < uniqueIds.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  440. const chunk = uniqueIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  441. const placeholders = chunk.map(() => '?').join(',');
  442. const rows = this.db
  443. .prepare(`SELECT id FROM nodes WHERE id IN (${placeholders})`)
  444. .all(...chunk) as { id: string }[];
  445. for (const row of rows) {
  446. out.add(row.id);
  447. }
  448. }
  449. return out;
  450. }
  451. /**
  452. * Add a node to the cache, evicting oldest if needed
  453. */
  454. private cacheNode(node: Node): void {
  455. if (this.nodeCache.size >= this.maxCacheSize) {
  456. // Evict oldest (first) entry
  457. const firstKey = this.nodeCache.keys().next().value;
  458. if (firstKey) {
  459. this.nodeCache.delete(firstKey);
  460. }
  461. }
  462. this.nodeCache.set(node.id, node);
  463. }
  464. /**
  465. * Clear the node cache
  466. */
  467. clearCache(): void {
  468. this.nodeCache.clear();
  469. }
  470. /**
  471. * Get all nodes in a file
  472. */
  473. getNodesByFile(filePath: string): Node[] {
  474. if (!this.stmts.getNodesByFile) {
  475. this.stmts.getNodesByFile = this.db.prepare(
  476. 'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line'
  477. );
  478. }
  479. const rows = this.stmts.getNodesByFile.all(filePath) as NodeRow[];
  480. return rows.map(rowToNode);
  481. }
  482. /**
  483. * Find the file that holds the densest concentration of the project's
  484. * internal call graph — the "core" file. Used by context-builder to
  485. * boost ranking of symbols in that file's directory (so e.g. sinatra
  486. * queries surface `lib/sinatra/base.rb`'s `route!` instead of
  487. * `sinatra-contrib/lib/sinatra/multi_route.rb`'s `route` extension).
  488. *
  489. * Returns null if no file has a meaningful concentration (e.g. spread
  490. * evenly across many files, or empty index).
  491. *
  492. * "Internal" = source and target are in the same file. Cross-file
  493. * edges aren't useful here — they don't tell us which file is the
  494. * functional center.
  495. *
  496. * Excludes test/spec files from candidacy via path-pattern. The agent's
  497. * typical question is "how does X work", not "how is X tested", so
  498. * boosting a test file's directory would be a misfire.
  499. */
  500. getDominantFile(): { filePath: string; edgeCount: number; nextEdgeCount: number } | null {
  501. if (!this.stmts.getDominantFile) {
  502. // Pull top 20 candidates; we then filter out test/generated files
  503. // in code (regex-grade matching that SQL LIKE can't express). The
  504. // generated-file filter is critical — without it, etcd's
  505. // `api/etcdserverpb/rpc.pb.go` (1916 in-file edges, generated
  506. // protobuf stub) outranks the real `server/etcdserver/server.go`
  507. // (470 edges) by 4×, and the boost would push the agent toward
  508. // generated code.
  509. this.stmts.getDominantFile = this.db.prepare(`
  510. SELECT n.file_path AS file_path, COUNT(*) AS edge_count
  511. FROM edges e
  512. JOIN nodes n ON e.source = n.id
  513. JOIN nodes m ON e.target = m.id
  514. WHERE n.file_path = m.file_path
  515. GROUP BY n.file_path
  516. ORDER BY edge_count DESC
  517. LIMIT 20
  518. `);
  519. }
  520. const rows = this.stmts.getDominantFile.all() as Array<{ file_path: string; edge_count: number }>;
  521. const filtered = rows.filter(r => !isLowValueFile(r.file_path));
  522. if (filtered.length === 0 || filtered[0]!.edge_count < 20) return null;
  523. return {
  524. filePath: filtered[0]!.file_path,
  525. edgeCount: filtered[0]!.edge_count,
  526. nextEdgeCount: filtered[1]?.edge_count ?? 0,
  527. };
  528. }
  529. /**
  530. * Find the file that holds the densest concentration of the project's
  531. * `route` nodes (framework-emitted: Express/Gin/Flask/Rails/Drupal/etc.).
  532. * Used by handleContext on small repos to inline the project's routing
  533. * config when the agent's query is about request flow — eliminating the
  534. * "Glob + Read routes.rb" pattern that beats codegraph on tiny realworld
  535. * template repos.
  536. *
  537. * Excludes test/generated files from candidacy. Returns null if there
  538. * are fewer than 3 non-test routes total, or if no file holds at least
  539. * 30% of them (diffuse routing → no single answer file).
  540. */
  541. getTopRouteFile(): { filePath: string; routeCount: number; totalRoutes: number } | null {
  542. if (!this.stmts.getTopRouteFile) {
  543. this.stmts.getTopRouteFile = this.db.prepare(`
  544. SELECT file_path, COUNT(*) AS cnt
  545. FROM nodes
  546. WHERE kind = 'route'
  547. GROUP BY file_path
  548. ORDER BY cnt DESC
  549. LIMIT 20
  550. `);
  551. }
  552. const rows = this.stmts.getTopRouteFile.all() as Array<{ file_path: string; cnt: number }>;
  553. const filtered = rows.filter(r => !isLowValueFile(r.file_path));
  554. if (filtered.length === 0) return null;
  555. const totalRoutes = filtered.reduce((sum, r) => sum + r.cnt, 0);
  556. const top = filtered[0]!;
  557. if (totalRoutes < 3 || top.cnt < 3) return null;
  558. if (top.cnt / totalRoutes < 0.30) return null;
  559. return { filePath: top.file_path, routeCount: top.cnt, totalRoutes };
  560. }
  561. /**
  562. * Build a URL → handler manifest from the index. Each route node's
  563. * `references` edge points at the function/method that handles the
  564. * request. We join them in one pass; the agent gets the canonical
  565. * routing answer ("POST /users/login → AuthController#login") without
  566. * having to parse the framework's route DSL itself.
  567. *
  568. * Also returns the file with the most handler endpoints — used as the
  569. * "top handler file" to inline source for, so the agent has both the
  570. * mapping AND the handler implementations.
  571. */
  572. getRoutingManifest(limit: number = 40): {
  573. entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>;
  574. topHandlerFile: string | null;
  575. topHandlerFileCount: number;
  576. totalRoutes: number;
  577. } | null {
  578. if (!this.stmts.getRoutingManifest) {
  579. // Edge kind varies across framework resolvers: Spring/Rails/
  580. // Laravel/Drupal emit `references`, Express emits `calls`. Accept
  581. // both — the semantic is the same (route → its handler).
  582. this.stmts.getRoutingManifest = this.db.prepare(`
  583. SELECT
  584. r.name AS url,
  585. h.name AS handler,
  586. h.file_path AS handler_file,
  587. h.start_line AS handler_line,
  588. h.kind AS handler_kind
  589. FROM nodes r
  590. JOIN edges e ON e.source = r.id
  591. JOIN nodes h ON e.target = h.id
  592. WHERE r.kind = 'route'
  593. AND e.kind IN ('references', 'calls')
  594. AND h.kind IN ('function', 'method', 'class')
  595. ORDER BY r.file_path, r.start_line
  596. LIMIT ?
  597. `);
  598. }
  599. const rows = this.stmts.getRoutingManifest.all(limit) as Array<{
  600. url: string; handler: string; handler_file: string; handler_line: number; handler_kind: string;
  601. }>;
  602. // Drop test/generated handlers — same hygiene as elsewhere.
  603. const filtered = rows.filter(r => !isLowValueFile(r.handler_file));
  604. if (filtered.length < 3) return null;
  605. // Identify the file holding the most handlers (the "primary handler file").
  606. const fileCounts = new Map<string, number>();
  607. for (const r of filtered) {
  608. fileCounts.set(r.handler_file, (fileCounts.get(r.handler_file) ?? 0) + 1);
  609. }
  610. let topHandlerFile: string | null = null;
  611. let topHandlerFileCount = 0;
  612. for (const [file, count] of fileCounts) {
  613. if (count > topHandlerFileCount) {
  614. topHandlerFile = file;
  615. topHandlerFileCount = count;
  616. }
  617. }
  618. return {
  619. entries: filtered.map(r => ({
  620. url: r.url,
  621. handler: r.handler,
  622. handlerFile: r.handler_file,
  623. handlerLine: r.handler_line,
  624. handlerKind: r.handler_kind,
  625. })),
  626. topHandlerFile,
  627. topHandlerFileCount,
  628. totalRoutes: filtered.length,
  629. };
  630. }
  631. /**
  632. * Get all nodes of a specific kind
  633. */
  634. getNodesByKind(kind: NodeKind): Node[] {
  635. if (!this.stmts.getNodesByKind) {
  636. this.stmts.getNodesByKind = this.db.prepare('SELECT * FROM nodes WHERE kind = ?');
  637. }
  638. const rows = this.stmts.getNodesByKind.all(kind) as NodeRow[];
  639. return rows.map(rowToNode);
  640. }
  641. /**
  642. * Stream every node of a kind one at a time (lazy) instead of materializing
  643. * them all like {@link getNodesByKind}. For unbounded kinds (`function`,
  644. * `method`) on a symbol-dense project the full array is gigabytes; the
  645. * dynamic-edge synthesizers only scan-and-filter, so they iterate to keep
  646. * memory O(1) in the node count rather than O(nodes) (#610).
  647. */
  648. *iterateNodesByKind(kind: NodeKind): IterableIterator<Node> {
  649. // Fresh statement per call (not a cached one): an iterator holds an open
  650. // cursor, so a shared statement would conflict across overlapping scans.
  651. const stmt = this.db.prepare('SELECT * FROM nodes WHERE kind = ?');
  652. for (const row of stmt.iterate(kind)) {
  653. yield rowToNode(row as NodeRow);
  654. }
  655. }
  656. /**
  657. * Get all nodes in the database
  658. */
  659. getAllNodes(): Node[] {
  660. const rows = this.db.prepare('SELECT * FROM nodes').all() as NodeRow[];
  661. return rows.map(rowToNode);
  662. }
  663. /**
  664. * Get nodes by exact name match (uses idx_nodes_name index)
  665. */
  666. getNodesByName(name: string): Node[] {
  667. if (!this.stmts.getNodesByName) {
  668. this.stmts.getNodesByName = this.db.prepare('SELECT * FROM nodes WHERE name = ?');
  669. }
  670. const rows = this.stmts.getNodesByName.all(name) as NodeRow[];
  671. return rows.map(rowToNode);
  672. }
  673. /**
  674. * Get nodes by exact qualified name match (uses idx_nodes_qualified_name index)
  675. */
  676. getNodesByQualifiedNameExact(qualifiedName: string): Node[] {
  677. if (!this.stmts.getNodesByQualifiedNameExact) {
  678. this.stmts.getNodesByQualifiedNameExact = this.db.prepare(
  679. 'SELECT * FROM nodes WHERE qualified_name = ?'
  680. );
  681. }
  682. const rows = this.stmts.getNodesByQualifiedNameExact.all(qualifiedName) as NodeRow[];
  683. return rows.map(rowToNode);
  684. }
  685. /**
  686. * Get nodes by lowercase name match (uses idx_nodes_lower_name expression index)
  687. */
  688. getNodesByLowerName(lowerName: string): Node[] {
  689. if (!this.stmts.getNodesByLowerName) {
  690. this.stmts.getNodesByLowerName = this.db.prepare(
  691. 'SELECT * FROM nodes WHERE lower(name) = ?'
  692. );
  693. }
  694. const rows = this.stmts.getNodesByLowerName.all(lowerName) as NodeRow[];
  695. return rows.map(rowToNode);
  696. }
  697. /**
  698. * Search nodes by name using FTS with fallback to LIKE for better matching
  699. *
  700. * Search strategy:
  701. * 1. Try FTS5 prefix match (query*) for word-start matching
  702. * 2. If no results, try LIKE for substring matching (e.g., "signIn" finds "signInWithGoogle")
  703. * 3. Score results based on match quality
  704. */
  705. searchNodes(query: string, options: SearchOptions = {}): SearchResult[] {
  706. const { limit = 100, offset = 0 } = options;
  707. // Parse field-qualified bits out of the raw query (kind:, lang:,
  708. // path:, name:). Anything not recognised stays in `text` and goes
  709. // to FTS unchanged. Filters compose with the SearchOptions arg —
  710. // both are applied (intersection-style).
  711. const parsed = parseQuery(query);
  712. const mergedKinds =
  713. parsed.kinds.length > 0
  714. ? Array.from(new Set([...(options.kinds ?? []), ...parsed.kinds]))
  715. : options.kinds;
  716. const mergedLanguages =
  717. parsed.languages.length > 0
  718. ? Array.from(new Set([...(options.languages ?? []), ...parsed.languages]))
  719. : options.languages;
  720. const pathFilters = parsed.pathFilters;
  721. const nameFilters = parsed.nameFilters;
  722. // The text portion drives FTS/LIKE; if all the user typed was
  723. // filters (`kind:function`), we still need *some* candidate set,
  724. // so synthesise an empty-text path that returns everything matching
  725. // the filters.
  726. const text = parsed.text;
  727. const kinds = mergedKinds;
  728. const languages = mergedLanguages;
  729. // First try FTS5 with prefix matching
  730. let results = text
  731. ? this.searchNodesFTS(text, { kinds, languages, limit, offset })
  732. // Over-fetch by 5× when running filter-only (no text). The
  733. // post-scoring path: + name: filters can be very selective, so
  734. // a smaller multiplier risks returning fewer than `limit`
  735. // results despite the DB having plenty of matches.
  736. : this.searchAllByFilters({ kinds, languages, limit: limit * 5 });
  737. // If no FTS results, try LIKE-based substring search
  738. if (results.length === 0 && text.length >= 2) {
  739. results = this.searchNodesLike(text, { kinds, languages, limit, offset });
  740. }
  741. // Final fuzzy fallback: scan all known names and keep those within
  742. // a tight Levenshtein distance. Only fires when both FTS and LIKE
  743. // returned nothing AND there's a text portion long enough to be
  744. // worth fuzzing (1-char queries would match too much).
  745. if (results.length === 0 && text.length >= 3) {
  746. results = this.searchNodesFuzzy(text, { kinds, languages, limit });
  747. }
  748. // Supplement: ensure exact name matches are always candidates.
  749. // BM25 can bury short exact-match names (e.g. "getBean") under hundreds of
  750. // compound names (e.g. "getBeanDescriptor") in large codebases,
  751. // pushing them past the FTS fetch limit before post-hoc scoring can help.
  752. // Use the max BM25 score as the base so the nameMatchBonus (exact=30 vs
  753. // prefix=20) actually differentiates them after rescoring.
  754. if (results.length > 0 && query) {
  755. const existingIds = new Set(results.map(r => r.node.id));
  756. const maxFtsScore = Math.max(...results.map(r => r.score));
  757. const terms = query.split(/\s+/).filter(t => t.length >= 2);
  758. for (const term of terms) {
  759. let sql = 'SELECT * FROM nodes WHERE name = ? COLLATE NOCASE';
  760. const params: (string | number)[] = [term];
  761. if (kinds && kinds.length > 0) {
  762. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  763. params.push(...kinds);
  764. }
  765. if (languages && languages.length > 0) {
  766. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  767. params.push(...languages);
  768. }
  769. sql += ' LIMIT 20';
  770. const rows = this.db.prepare(sql).all(...params) as NodeRow[];
  771. for (const row of rows) {
  772. if (!existingIds.has(row.id)) {
  773. results.push({ node: rowToNode(row), score: maxFtsScore });
  774. existingIds.add(row.id);
  775. }
  776. }
  777. }
  778. }
  779. // Apply multi-signal scoring
  780. if (results.length > 0 && (text || query)) {
  781. const scoringQuery = text || query;
  782. results = results.map(r => ({
  783. ...r,
  784. score: r.score
  785. + kindBonus(r.node.kind)
  786. + scorePathRelevance(r.node.filePath, scoringQuery)
  787. + nameMatchBonus(r.node.name, scoringQuery),
  788. }));
  789. results.sort((a, b) => b.score - a.score);
  790. // Trim to requested limit after rescoring
  791. if (results.length > limit) {
  792. results = results.slice(0, limit);
  793. }
  794. }
  795. // Apply path: + name: filters AFTER scoring. Scoring already uses
  796. // path/name as a soft signal; the explicit filters here are a hard
  797. // gate. Done last so the FTS limit fetched plenty of candidates to
  798. // narrow from.
  799. if (pathFilters.length > 0) {
  800. const lowered = pathFilters.map((p) => p.toLowerCase());
  801. results = results.filter((r) => {
  802. const fp = r.node.filePath.toLowerCase();
  803. return lowered.some((p) => fp.includes(p));
  804. });
  805. }
  806. if (nameFilters.length > 0) {
  807. const lowered = nameFilters.map((n) => n.toLowerCase());
  808. results = results.filter((r) => {
  809. const nm = r.node.name.toLowerCase();
  810. return lowered.some((n) => nm.includes(n));
  811. });
  812. }
  813. return results;
  814. }
  815. /**
  816. * Match-everything path used when the user supplied only field
  817. * filters (`kind:function lang:typescript`) with no text. Returns
  818. * candidates ordered by name; the caller's filter pass narrows to
  819. * what was asked for.
  820. */
  821. private searchAllByFilters(options: {
  822. kinds?: NodeKind[];
  823. languages?: Language[];
  824. limit: number;
  825. }): SearchResult[] {
  826. const { kinds, languages, limit } = options;
  827. let sql = 'SELECT * FROM nodes WHERE 1=1';
  828. const params: (string | number)[] = [];
  829. if (kinds && kinds.length > 0) {
  830. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  831. params.push(...kinds);
  832. }
  833. if (languages && languages.length > 0) {
  834. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  835. params.push(...languages);
  836. }
  837. sql += ' ORDER BY name LIMIT ?';
  838. params.push(limit);
  839. const rows = this.db.prepare(sql).all(...params) as NodeRow[];
  840. return rows.map((row) => ({ node: rowToNode(row), score: 1 }));
  841. }
  842. /**
  843. * Fuzzy fallback: when zero FTS/LIKE hits, try an edit-distance
  844. * sweep over the distinct symbol-name set. Caps `maxDist` at 2 so
  845. * `getUssr` finds `getUser` but `process` doesn't match `prosody`.
  846. * Bounded edit distance keeps each comparison cheap; the per-query
  847. * scan is O(distinct-name-count) which is far smaller than total
  848. * node count on any real codebase.
  849. */
  850. private searchNodesFuzzy(
  851. text: string,
  852. options: { kinds?: NodeKind[]; languages?: Language[]; limit: number }
  853. ): SearchResult[] {
  854. const { kinds, languages, limit } = options;
  855. const lowered = text.toLowerCase();
  856. const maxDist = lowered.length <= 4 ? 1 : 2;
  857. // Pull the distinct name list once. The set is cached on QueryBuilder
  858. // by getAllNodeNames(); even on a 200k-node project the distinct
  859. // name set is typically O(10k) because most names repeat. The
  860. // candidate-cap below bounds memory regardless.
  861. const allNames = this.getAllNodeNames();
  862. const candidates: Array<{ name: string; dist: number }> = [];
  863. for (const name of allNames) {
  864. const dist = boundedEditDistance(name.toLowerCase(), lowered, maxDist);
  865. if (dist <= maxDist) candidates.push({ name, dist });
  866. }
  867. candidates.sort((a, b) => a.dist - b.dist);
  868. // Cap the per-name follow-up queries. Each survivor triggers a
  869. // separate `SELECT * FROM nodes WHERE name = ?`; without this cap
  870. // a project with many similar names (`getUser1`, `getUser2`...)
  871. // could fan out far beyond `limit` queries before the inner-loop
  872. // limit kicks in.
  873. const FUZZY_FOLLOWUP_CAP = Math.max(limit * 2, 50);
  874. const cappedCandidates = candidates.slice(0, FUZZY_FOLLOWUP_CAP);
  875. const results: SearchResult[] = [];
  876. const seen = new Set<string>();
  877. for (const c of cappedCandidates) {
  878. if (results.length >= limit) break;
  879. let sql = 'SELECT * FROM nodes WHERE name = ?';
  880. const params: (string | number)[] = [c.name];
  881. if (kinds && kinds.length > 0) {
  882. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  883. params.push(...kinds);
  884. }
  885. if (languages && languages.length > 0) {
  886. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  887. params.push(...languages);
  888. }
  889. sql += ' LIMIT 5';
  890. const rows = this.db.prepare(sql).all(...params) as NodeRow[];
  891. for (const row of rows) {
  892. if (seen.has(row.id)) continue;
  893. seen.add(row.id);
  894. // Lower the score for each edit step away from the query so
  895. // exact-match fallbacks (dist 0) outrank dist-2 typos.
  896. results.push({ node: rowToNode(row), score: 1 / (1 + c.dist) });
  897. if (results.length >= limit) break;
  898. }
  899. }
  900. return results;
  901. }
  902. /**
  903. * FTS5 search with prefix matching
  904. */
  905. private searchNodesFTS(query: string, options: SearchOptions): SearchResult[] {
  906. const { kinds, languages, limit = 100, offset = 0 } = options;
  907. // Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate")
  908. // Escape special FTS5 characters and add prefix wildcard.
  909. //
  910. // `::` is a qualifier separator in Rust/C++/Ruby, not a token char,
  911. // so treat it as whitespace before the strip step. Otherwise queries
  912. // like `stage_apply::run` collapse to `stage_applyrun` (the colons
  913. // are stripped without splitting) and find nothing. See #173.
  914. const ftsQuery = query
  915. .replace(/::/g, ' ') // Rust/C++/Ruby qualifier separator
  916. .replace(/['"*():^]/g, '') // Remove FTS5 special chars
  917. .split(/\s+/)
  918. .filter(term => term.length > 0)
  919. // Strip FTS5 boolean operators to prevent query manipulation
  920. .filter(term => !/^(AND|OR|NOT|NEAR)$/i.test(term))
  921. .map(term => `"${term}"*`) // Prefix match each term
  922. .join(' OR ');
  923. if (!ftsQuery) {
  924. return [];
  925. }
  926. // BM25 column weights: id=0, name=20, qualified_name=5, docstring=1, signature=2
  927. // Heavy name weight ensures exact/prefix name matches rank above incidental
  928. // mentions in long docstrings or qualified names of nested symbols.
  929. // Fetch 5x requested limit so post-hoc rescoring (kindBonus, pathRelevance,
  930. // nameMatchBonus) can promote results that BM25 alone undervalues.
  931. const ftsLimit = Math.max(limit * 5, 100);
  932. let sql = `
  933. SELECT nodes.*, bm25(nodes_fts, 0, 20, 5, 1, 2) as score
  934. FROM nodes_fts
  935. JOIN nodes ON nodes_fts.id = nodes.id
  936. WHERE nodes_fts MATCH ?
  937. `;
  938. const params: (string | number)[] = [ftsQuery];
  939. if (kinds && kinds.length > 0) {
  940. sql += ` AND nodes.kind IN (${kinds.map(() => '?').join(',')})`;
  941. params.push(...kinds);
  942. }
  943. if (languages && languages.length > 0) {
  944. sql += ` AND nodes.language IN (${languages.map(() => '?').join(',')})`;
  945. params.push(...languages);
  946. }
  947. sql += ' ORDER BY score LIMIT ? OFFSET ?';
  948. params.push(ftsLimit, offset);
  949. try {
  950. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  951. return rows.map((row) => ({
  952. node: rowToNode(row),
  953. score: Math.abs(row.score), // bm25 returns negative scores
  954. }));
  955. } catch {
  956. // FTS query failed, return empty
  957. return [];
  958. }
  959. }
  960. /**
  961. * LIKE-based substring search for cases where FTS doesn't match
  962. * Useful for camelCase matching (e.g., "signIn" finds "signInWithGoogle")
  963. */
  964. private searchNodesLike(query: string, options: SearchOptions): SearchResult[] {
  965. const { kinds, languages, limit = 100, offset = 0 } = options;
  966. let sql = `
  967. SELECT nodes.*,
  968. CASE
  969. WHEN name = ? THEN 1.0
  970. WHEN name LIKE ? THEN 0.9
  971. WHEN name LIKE ? THEN 0.8
  972. WHEN qualified_name LIKE ? THEN 0.7
  973. ELSE 0.5
  974. END as score
  975. FROM nodes
  976. WHERE (
  977. name LIKE ? OR
  978. qualified_name LIKE ? OR
  979. name LIKE ?
  980. )
  981. `;
  982. // Pattern variants for better matching
  983. const exactMatch = query;
  984. const startsWith = `${query}%`;
  985. const contains = `%${query}%`;
  986. const params: (string | number)[] = [
  987. exactMatch, // Exact match score
  988. startsWith, // Starts with score
  989. contains, // Contains score
  990. contains, // Qualified name score
  991. contains, // WHERE: name contains
  992. contains, // WHERE: qualified_name contains
  993. startsWith, // WHERE: name starts with
  994. ];
  995. if (kinds && kinds.length > 0) {
  996. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  997. params.push(...kinds);
  998. }
  999. if (languages && languages.length > 0) {
  1000. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  1001. params.push(...languages);
  1002. }
  1003. sql += ' ORDER BY score DESC, length(name) ASC LIMIT ? OFFSET ?';
  1004. params.push(limit, offset);
  1005. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  1006. return rows.map((row) => ({
  1007. node: rowToNode(row),
  1008. score: row.score,
  1009. }));
  1010. }
  1011. /**
  1012. * Find nodes by exact name match
  1013. *
  1014. * Used for hybrid search - looks up symbols by exact name or case-insensitive match.
  1015. * Returns high-confidence matches for known symbol names extracted from query.
  1016. *
  1017. * @param names - Array of symbol names to look up
  1018. * @param options - Search options (kinds, languages, limit)
  1019. * @returns SearchResult array with exact matches scored at 1.0
  1020. */
  1021. findNodesByExactName(names: string[], options: SearchOptions = {}): SearchResult[] {
  1022. if (names.length === 0) return [];
  1023. const { kinds, languages, limit = 50 } = options;
  1024. // Two-pass approach to handle common names (e.g., "run" has 40+ matches):
  1025. // Pass 1: Find which files contain distinctive (rare) symbols from the query.
  1026. // Pass 2: Query each name, boosting results that co-locate with distinctive symbols.
  1027. // Pass 1: Find files containing each queried name, identify distinctive names
  1028. const nameToFiles = new Map<string, Set<string>>();
  1029. for (const name of names) {
  1030. let sql = 'SELECT DISTINCT file_path FROM nodes WHERE name COLLATE NOCASE = ?';
  1031. const params: (string | number)[] = [name];
  1032. if (kinds && kinds.length > 0) {
  1033. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1034. params.push(...kinds);
  1035. }
  1036. sql += ' LIMIT 100';
  1037. const rows = this.db.prepare(sql).all(...params) as { file_path: string }[];
  1038. nameToFiles.set(name.toLowerCase(), new Set(rows.map(r => r.file_path)));
  1039. }
  1040. // Distinctive names are those with fewer than 10 file matches (e.g., "scrapeLoop" = 1 file)
  1041. const distinctiveFiles = new Set<string>();
  1042. for (const [, files] of nameToFiles) {
  1043. if (files.size > 0 && files.size < 10) {
  1044. for (const f of files) distinctiveFiles.add(f);
  1045. }
  1046. }
  1047. // Pass 2: Query each name with per-name limit, scoring by co-location
  1048. const perNameLimit = Math.max(8, Math.ceil(limit / names.length));
  1049. const allResults: SearchResult[] = [];
  1050. const seenIds = new Set<string>();
  1051. for (const name of names) {
  1052. let sql = `
  1053. SELECT nodes.*, 1.0 as score
  1054. FROM nodes
  1055. WHERE name COLLATE NOCASE = ?
  1056. `;
  1057. const params: (string | number)[] = [name];
  1058. if (kinds && kinds.length > 0) {
  1059. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1060. params.push(...kinds);
  1061. }
  1062. if (languages && languages.length > 0) {
  1063. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  1064. params.push(...languages);
  1065. }
  1066. // Fetch enough to find co-located results among common names
  1067. sql += ' LIMIT ?';
  1068. params.push(Math.max(perNameLimit * 3, 50));
  1069. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  1070. const nameResults: SearchResult[] = [];
  1071. for (const row of rows) {
  1072. const node = rowToNode(row);
  1073. if (seenIds.has(node.id)) continue;
  1074. // Boost results in files that also contain distinctive symbols
  1075. const coLocationBoost = distinctiveFiles.has(node.filePath) ? 20 : 0;
  1076. nameResults.push({ node, score: row.score + coLocationBoost });
  1077. }
  1078. // Sort by score (co-located first), take per-name limit
  1079. nameResults.sort((a, b) => b.score - a.score);
  1080. for (const r of nameResults.slice(0, perNameLimit)) {
  1081. seenIds.add(r.node.id);
  1082. allResults.push(r);
  1083. }
  1084. }
  1085. // Sort all results by score so co-located results bubble up
  1086. allResults.sort((a, b) => b.score - a.score);
  1087. return allResults.slice(0, limit);
  1088. }
  1089. /**
  1090. * Find nodes whose name contains a substring (LIKE-based).
  1091. * Useful for CamelCase-part matching where FTS fails because
  1092. * e.g. "TransportSearchAction" is one FTS token, not matchable by "Search"*.
  1093. *
  1094. * Results are ordered by name length (shorter = more likely to be the core type).
  1095. */
  1096. findNodesByNameSubstring(
  1097. substring: string,
  1098. options: SearchOptions & { excludePrefix?: boolean } = {}
  1099. ): SearchResult[] {
  1100. const { kinds, languages, limit = 30, excludePrefix } = options;
  1101. let sql = `
  1102. SELECT nodes.*, 1.0 as score
  1103. FROM nodes
  1104. WHERE name LIKE ?
  1105. `;
  1106. const params: (string | number)[] = [`%${substring}%`];
  1107. // Exclude prefix matches (handled by FTS-based prefix search in Step 2b)
  1108. if (excludePrefix) {
  1109. sql += ` AND name NOT LIKE ?`;
  1110. params.push(`${substring}%`);
  1111. }
  1112. if (kinds && kinds.length > 0) {
  1113. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1114. params.push(...kinds);
  1115. }
  1116. if (languages && languages.length > 0) {
  1117. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  1118. params.push(...languages);
  1119. }
  1120. sql += ' ORDER BY length(name) ASC LIMIT ?';
  1121. params.push(limit);
  1122. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  1123. return rows.map((row) => ({
  1124. node: rowToNode(row),
  1125. score: row.score,
  1126. }));
  1127. }
  1128. // ===========================================================================
  1129. // Edge Operations
  1130. // ===========================================================================
  1131. /**
  1132. * Insert a new edge
  1133. */
  1134. insertEdge(edge: Edge): void {
  1135. if (!this.stmts.insertEdge) {
  1136. this.stmts.insertEdge = this.db.prepare(`
  1137. INSERT OR IGNORE INTO edges (source, target, kind, metadata, line, col, provenance)
  1138. VALUES (@source, @target, @kind, @metadata, @line, @col, @provenance)
  1139. `);
  1140. }
  1141. this.stmts.insertEdge.run({
  1142. source: edge.source,
  1143. target: edge.target,
  1144. kind: edge.kind,
  1145. metadata: edge.metadata ? JSON.stringify(edge.metadata) : null,
  1146. line: edge.line ?? null,
  1147. col: edge.column ?? null,
  1148. provenance: edge.provenance ?? null,
  1149. });
  1150. }
  1151. /**
  1152. * Insert multiple edges in a transaction
  1153. */
  1154. insertEdges(edges: Edge[]): void {
  1155. if (edges.length === 0) return;
  1156. this.db.transaction(() => {
  1157. const endpointIds = new Set<string>();
  1158. for (const edge of edges) {
  1159. endpointIds.add(edge.source);
  1160. endpointIds.add(edge.target);
  1161. }
  1162. const existingNodeIds = this.getExistingNodeIds([...endpointIds]);
  1163. for (const edge of edges) {
  1164. if (!existingNodeIds.has(edge.source) || !existingNodeIds.has(edge.target)) {
  1165. continue;
  1166. }
  1167. this.insertEdge(edge);
  1168. }
  1169. })();
  1170. }
  1171. /**
  1172. * Delete all edges from a source node
  1173. */
  1174. deleteEdgesBySource(sourceId: string): void {
  1175. if (!this.stmts.deleteEdgesBySource) {
  1176. this.stmts.deleteEdgesBySource = this.db.prepare('DELETE FROM edges WHERE source = ?');
  1177. }
  1178. this.stmts.deleteEdgesBySource.run(sourceId);
  1179. }
  1180. /**
  1181. * Get outgoing edges from a node
  1182. */
  1183. getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string): Edge[] {
  1184. if ((kinds && kinds.length > 0) || provenance) {
  1185. let sql = 'SELECT * FROM edges WHERE source = ?';
  1186. const params: (string | number)[] = [sourceId];
  1187. if (kinds && kinds.length > 0) {
  1188. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1189. params.push(...kinds);
  1190. }
  1191. if (provenance) {
  1192. sql += ' AND provenance = ?';
  1193. params.push(provenance);
  1194. }
  1195. const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
  1196. return rows.map(rowToEdge);
  1197. }
  1198. if (!this.stmts.getEdgesBySource) {
  1199. this.stmts.getEdgesBySource = this.db.prepare('SELECT * FROM edges WHERE source = ?');
  1200. }
  1201. const rows = this.stmts.getEdgesBySource.all(sourceId) as EdgeRow[];
  1202. return rows.map(rowToEdge);
  1203. }
  1204. /**
  1205. * Get incoming edges to a node
  1206. */
  1207. getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] {
  1208. if (kinds && kinds.length > 0) {
  1209. const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`;
  1210. const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[];
  1211. return rows.map(rowToEdge);
  1212. }
  1213. if (!this.stmts.getEdgesByTarget) {
  1214. this.stmts.getEdgesByTarget = this.db.prepare('SELECT * FROM edges WHERE target = ?');
  1215. }
  1216. const rows = this.stmts.getEdgesByTarget.all(targetId) as EdgeRow[];
  1217. return rows.map(rowToEdge);
  1218. }
  1219. /**
  1220. * Find all edges where both source and target are in the given node set.
  1221. * Useful for recovering inter-node connectivity after BFS.
  1222. */
  1223. findEdgesBetweenNodes(nodeIds: string[], kinds?: EdgeKind[]): Edge[] {
  1224. if (nodeIds.length === 0) return [];
  1225. const idsJson = JSON.stringify(nodeIds);
  1226. let sql = `SELECT * FROM edges WHERE source IN (SELECT value FROM json_each(?)) AND target IN (SELECT value FROM json_each(?))`;
  1227. const params: string[] = [idsJson, idsJson];
  1228. if (kinds && kinds.length > 0) {
  1229. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1230. params.push(...kinds);
  1231. }
  1232. const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
  1233. return rows.map(rowToEdge);
  1234. }
  1235. // ===========================================================================
  1236. // File Operations
  1237. // ===========================================================================
  1238. /**
  1239. * Insert or update a file record
  1240. */
  1241. upsertFile(file: FileRecord): void {
  1242. if (!this.stmts.upsertFile) {
  1243. this.stmts.upsertFile = this.db.prepare(`
  1244. INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors)
  1245. VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @errors)
  1246. ON CONFLICT(path) DO UPDATE SET
  1247. content_hash = @contentHash,
  1248. language = @language,
  1249. size = @size,
  1250. modified_at = @modifiedAt,
  1251. indexed_at = @indexedAt,
  1252. node_count = @nodeCount,
  1253. errors = @errors
  1254. `);
  1255. }
  1256. this.stmts.upsertFile.run({
  1257. path: file.path,
  1258. contentHash: file.contentHash,
  1259. language: file.language,
  1260. size: file.size,
  1261. modifiedAt: file.modifiedAt,
  1262. indexedAt: file.indexedAt,
  1263. nodeCount: file.nodeCount,
  1264. errors: file.errors ? JSON.stringify(file.errors) : null,
  1265. });
  1266. }
  1267. /**
  1268. * Delete a file record and its nodes
  1269. */
  1270. deleteFile(filePath: string): void {
  1271. this.db.transaction(() => {
  1272. this.deleteNodesByFile(filePath);
  1273. if (!this.stmts.deleteFile) {
  1274. this.stmts.deleteFile = this.db.prepare('DELETE FROM files WHERE path = ?');
  1275. }
  1276. this.stmts.deleteFile.run(filePath);
  1277. })();
  1278. }
  1279. /**
  1280. * Get a file record by path
  1281. */
  1282. getFileByPath(filePath: string): FileRecord | null {
  1283. if (!this.stmts.getFileByPath) {
  1284. this.stmts.getFileByPath = this.db.prepare('SELECT * FROM files WHERE path = ?');
  1285. }
  1286. const row = this.stmts.getFileByPath.get(filePath) as FileRow | undefined;
  1287. return row ? rowToFileRecord(row) : null;
  1288. }
  1289. /**
  1290. * Get all tracked files
  1291. */
  1292. getAllFiles(): FileRecord[] {
  1293. if (!this.stmts.getAllFiles) {
  1294. this.stmts.getAllFiles = this.db.prepare('SELECT * FROM files ORDER BY path');
  1295. }
  1296. const rows = this.stmts.getAllFiles.all() as FileRow[];
  1297. return rows.map(rowToFileRecord);
  1298. }
  1299. /**
  1300. * Get files that need re-indexing (hash changed)
  1301. */
  1302. getStaleFiles(currentHashes: Map<string, string>): FileRecord[] {
  1303. const files = this.getAllFiles();
  1304. return files.filter((f) => {
  1305. const currentHash = currentHashes.get(f.path);
  1306. return currentHash && currentHash !== f.contentHash;
  1307. });
  1308. }
  1309. // ===========================================================================
  1310. // Unresolved References
  1311. // ===========================================================================
  1312. /**
  1313. * Insert an unresolved reference
  1314. */
  1315. insertUnresolvedRef(ref: UnresolvedReference): void {
  1316. if (!this.stmts.insertUnresolved) {
  1317. this.stmts.insertUnresolved = this.db.prepare(`
  1318. INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language)
  1319. VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates, @filePath, @language)
  1320. `);
  1321. }
  1322. this.stmts.insertUnresolved.run({
  1323. fromNodeId: ref.fromNodeId,
  1324. referenceName: ref.referenceName,
  1325. referenceKind: ref.referenceKind,
  1326. line: ref.line,
  1327. col: ref.column,
  1328. candidates: ref.candidates ? JSON.stringify(ref.candidates) : null,
  1329. filePath: ref.filePath ?? '',
  1330. language: ref.language ?? 'unknown',
  1331. });
  1332. }
  1333. /**
  1334. * Insert multiple unresolved references in a transaction
  1335. */
  1336. insertUnresolvedRefsBatch(refs: UnresolvedReference[]): void {
  1337. if (refs.length === 0) return;
  1338. const insert = this.db.transaction(() => {
  1339. for (const ref of refs) {
  1340. this.insertUnresolvedRef(ref);
  1341. }
  1342. });
  1343. insert();
  1344. }
  1345. /**
  1346. * Delete unresolved references from a node
  1347. */
  1348. deleteUnresolvedByNode(nodeId: string): void {
  1349. if (!this.stmts.deleteUnresolvedByNode) {
  1350. this.stmts.deleteUnresolvedByNode = this.db.prepare(
  1351. 'DELETE FROM unresolved_refs WHERE from_node_id = ?'
  1352. );
  1353. }
  1354. this.stmts.deleteUnresolvedByNode.run(nodeId);
  1355. }
  1356. /**
  1357. * Get unresolved references by name (for resolution)
  1358. */
  1359. getUnresolvedByName(name: string): UnresolvedReference[] {
  1360. if (!this.stmts.getUnresolvedByName) {
  1361. this.stmts.getUnresolvedByName = this.db.prepare(
  1362. 'SELECT * FROM unresolved_refs WHERE reference_name = ?'
  1363. );
  1364. }
  1365. const rows = this.stmts.getUnresolvedByName.all(name) as UnresolvedRefRow[];
  1366. return rows.map((row) => ({
  1367. fromNodeId: row.from_node_id,
  1368. referenceName: row.reference_name,
  1369. referenceKind: row.reference_kind as EdgeKind,
  1370. line: row.line,
  1371. column: row.col,
  1372. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  1373. filePath: row.file_path,
  1374. language: row.language as Language,
  1375. }));
  1376. }
  1377. /**
  1378. * Get all unresolved references
  1379. */
  1380. getUnresolvedReferences(): UnresolvedReference[] {
  1381. const rows = this.db.prepare('SELECT * FROM unresolved_refs').all() as UnresolvedRefRow[];
  1382. return rows.map((row) => ({
  1383. fromNodeId: row.from_node_id,
  1384. referenceName: row.reference_name,
  1385. referenceKind: row.reference_kind as EdgeKind,
  1386. line: row.line,
  1387. column: row.col,
  1388. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  1389. filePath: row.file_path,
  1390. language: row.language as Language,
  1391. }));
  1392. }
  1393. /**
  1394. * Get the count of unresolved references without loading them into memory
  1395. */
  1396. getUnresolvedReferencesCount(): number {
  1397. if (!this.stmts.getUnresolvedCount) {
  1398. this.stmts.getUnresolvedCount = this.db.prepare(
  1399. 'SELECT COUNT(*) as count FROM unresolved_refs'
  1400. );
  1401. }
  1402. const row = this.stmts.getUnresolvedCount.get() as { count: number };
  1403. return row.count;
  1404. }
  1405. /**
  1406. * Get a batch of unresolved references using LIMIT/OFFSET pagination.
  1407. * Used to process references in bounded memory chunks.
  1408. */
  1409. getUnresolvedReferencesBatch(offset: number, limit: number): UnresolvedReference[] {
  1410. if (!this.stmts.getUnresolvedBatch) {
  1411. this.stmts.getUnresolvedBatch = this.db.prepare(
  1412. 'SELECT * FROM unresolved_refs LIMIT ? OFFSET ?'
  1413. );
  1414. }
  1415. const rows = this.stmts.getUnresolvedBatch.all(limit, offset) as UnresolvedRefRow[];
  1416. return rows.map((row) => ({
  1417. fromNodeId: row.from_node_id,
  1418. referenceName: row.reference_name,
  1419. referenceKind: row.reference_kind as EdgeKind,
  1420. line: row.line,
  1421. column: row.col,
  1422. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  1423. filePath: row.file_path,
  1424. language: row.language as Language,
  1425. }));
  1426. }
  1427. /**
  1428. * Get all tracked file paths (lightweight — no full FileRecord objects)
  1429. */
  1430. getAllFilePaths(): string[] {
  1431. if (!this.stmts.getAllFilePaths) {
  1432. this.stmts.getAllFilePaths = this.db.prepare('SELECT path FROM files ORDER BY path');
  1433. }
  1434. const rows = this.stmts.getAllFilePaths.all() as Array<{ path: string }>;
  1435. return rows.map((r) => r.path);
  1436. }
  1437. /**
  1438. * Get all distinct node names (lightweight — just name strings for pre-filtering)
  1439. */
  1440. getAllNodeNames(): string[] {
  1441. if (!this.stmts.getAllNodeNames) {
  1442. this.stmts.getAllNodeNames = this.db.prepare('SELECT DISTINCT name FROM nodes');
  1443. }
  1444. const rows = this.stmts.getAllNodeNames.all() as Array<{ name: string }>;
  1445. return rows.map((r) => r.name);
  1446. }
  1447. /**
  1448. * Get unresolved references scoped to specific file paths.
  1449. * Uses the idx_unresolved_file_path index for efficient lookup.
  1450. */
  1451. getUnresolvedReferencesByFiles(filePaths: string[]): UnresolvedReference[] {
  1452. if (filePaths.length === 0) return [];
  1453. const placeholders = filePaths.map(() => '?').join(',');
  1454. const rows = this.db
  1455. .prepare(`SELECT * FROM unresolved_refs WHERE file_path IN (${placeholders})`)
  1456. .all(...filePaths) as UnresolvedRefRow[];
  1457. return rows.map((row) => ({
  1458. fromNodeId: row.from_node_id,
  1459. referenceName: row.reference_name,
  1460. referenceKind: row.reference_kind as EdgeKind,
  1461. line: row.line,
  1462. column: row.col,
  1463. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  1464. filePath: row.file_path,
  1465. language: row.language as Language,
  1466. }));
  1467. }
  1468. /**
  1469. * Delete all unresolved references (after resolution)
  1470. */
  1471. clearUnresolvedReferences(): void {
  1472. this.db.exec('DELETE FROM unresolved_refs');
  1473. }
  1474. /**
  1475. * Delete resolved references by their IDs
  1476. */
  1477. deleteResolvedReferences(fromNodeIds: string[]): void {
  1478. if (fromNodeIds.length === 0) return;
  1479. const placeholders = fromNodeIds.map(() => '?').join(',');
  1480. this.db.prepare(`DELETE FROM unresolved_refs WHERE from_node_id IN (${placeholders})`).run(...fromNodeIds);
  1481. }
  1482. /**
  1483. * Delete specific resolved references by (fromNodeId, referenceName, referenceKind) tuples.
  1484. * More precise than deleteResolvedReferences — only removes refs that were actually resolved.
  1485. */
  1486. deleteSpecificResolvedReferences(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): void {
  1487. if (refs.length === 0) return;
  1488. const stmt = this.db.prepare(
  1489. 'DELETE FROM unresolved_refs WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?'
  1490. );
  1491. const deleteMany = this.db.transaction((items: typeof refs) => {
  1492. for (const ref of items) {
  1493. stmt.run(ref.fromNodeId, ref.referenceName, ref.referenceKind);
  1494. }
  1495. });
  1496. deleteMany(refs);
  1497. }
  1498. // ===========================================================================
  1499. // Statistics
  1500. // ===========================================================================
  1501. /**
  1502. * Lightweight (nodes, edges) count snapshot. Used around an index/sync
  1503. * run to compute true additions across extraction + resolution +
  1504. * synthesis — the per-phase counter in the orchestrator only sees
  1505. * extraction's contribution, which is why the CLI summary under-reported
  1506. * the edge count (resolution + synthesizer edges were invisible).
  1507. */
  1508. getNodeAndEdgeCount(): { nodes: number; edges: number } {
  1509. return this.db
  1510. .prepare('SELECT (SELECT COUNT(*) FROM nodes) AS nodes, (SELECT COUNT(*) FROM edges) AS edges')
  1511. .get() as { nodes: number; edges: number };
  1512. }
  1513. /**
  1514. * Get graph statistics
  1515. */
  1516. getStats(): GraphStats {
  1517. // Single query for all three aggregate counts
  1518. const counts = this.db.prepare(`
  1519. SELECT
  1520. (SELECT COUNT(*) FROM nodes) AS node_count,
  1521. (SELECT COUNT(*) FROM edges) AS edge_count,
  1522. (SELECT COUNT(*) FROM files) AS file_count
  1523. `).get() as { node_count: number; edge_count: number; file_count: number };
  1524. const nodesByKind = {} as Record<NodeKind, number>;
  1525. const nodeKindRows = this.db
  1526. .prepare('SELECT kind, COUNT(*) as count FROM nodes GROUP BY kind')
  1527. .all() as Array<{ kind: string; count: number }>;
  1528. for (const row of nodeKindRows) {
  1529. nodesByKind[row.kind as NodeKind] = row.count;
  1530. }
  1531. const edgesByKind = {} as Record<EdgeKind, number>;
  1532. const edgeKindRows = this.db
  1533. .prepare('SELECT kind, COUNT(*) as count FROM edges GROUP BY kind')
  1534. .all() as Array<{ kind: string; count: number }>;
  1535. for (const row of edgeKindRows) {
  1536. edgesByKind[row.kind as EdgeKind] = row.count;
  1537. }
  1538. const filesByLanguage = {} as Record<Language, number>;
  1539. const languageRows = this.db
  1540. .prepare('SELECT language, COUNT(*) as count FROM files GROUP BY language')
  1541. .all() as Array<{ language: string; count: number }>;
  1542. for (const row of languageRows) {
  1543. filesByLanguage[row.language as Language] = row.count;
  1544. }
  1545. return {
  1546. nodeCount: counts.node_count,
  1547. edgeCount: counts.edge_count,
  1548. fileCount: counts.file_count,
  1549. nodesByKind,
  1550. edgesByKind,
  1551. filesByLanguage,
  1552. dbSizeBytes: 0, // Set by caller using DatabaseConnection.getSize()
  1553. lastUpdated: Date.now(),
  1554. };
  1555. }
  1556. // ===========================================================================
  1557. // Project Metadata
  1558. // ===========================================================================
  1559. /**
  1560. * Get a metadata value by key
  1561. */
  1562. getMetadata(key: string): string | null {
  1563. const row = this.db.prepare('SELECT value FROM project_metadata WHERE key = ?').get(key) as { value: string } | undefined;
  1564. return row?.value ?? null;
  1565. }
  1566. /**
  1567. * Set a metadata key-value pair (upsert)
  1568. */
  1569. setMetadata(key: string, value: string): void {
  1570. this.db.prepare(
  1571. 'INSERT INTO project_metadata (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at'
  1572. ).run(key, value, Date.now());
  1573. }
  1574. /**
  1575. * Get all metadata as a key-value record
  1576. */
  1577. getAllMetadata(): Record<string, string> {
  1578. const rows = this.db.prepare('SELECT key, value FROM project_metadata').all() as { key: string; value: string }[];
  1579. const result: Record<string, string> = {};
  1580. for (const row of rows) {
  1581. result[row.key] = row.value;
  1582. }
  1583. return result;
  1584. }
  1585. /**
  1586. * Clear all data from the database
  1587. */
  1588. clear(): void {
  1589. this.nodeCache.clear();
  1590. this.db.transaction(() => {
  1591. this.db.exec('DELETE FROM unresolved_refs');
  1592. this.db.exec('DELETE FROM edges');
  1593. this.db.exec('DELETE FROM nodes');
  1594. this.db.exec('DELETE FROM files');
  1595. })();
  1596. }
  1597. }