queries.ts 93 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515
  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. import { splitIdentifierSegments } from '../search/identifier-segments';
  24. /**
  25. * Path-only heuristic for files that should not be candidates for
  26. * "dominant file" detection: test/spec files and tool-generated files.
  27. * Generated files (`*.pb.go`, `*.pulsar.go`, mock outputs, …) often
  28. * have huge in-file edge counts that dwarf the real source — etcd's
  29. * `rpc.pb.go` has 4× the in-file edges of `server.go`.
  30. */
  31. function isLowValueFile(filePath: string): boolean {
  32. const lp = filePath.toLowerCase();
  33. return (
  34. /(?:^|\/)(tests?|__tests?__|spec)\//.test(lp) ||
  35. /_test\.go$/.test(lp) ||
  36. /(?:^|\/)test_[^/]+\.py$/.test(lp) ||
  37. /_test\.py$/.test(lp) ||
  38. /_spec\.rb$/.test(lp) ||
  39. /_test\.rb$/.test(lp) ||
  40. /\.(test|spec)\.[jt]sx?$/.test(lp) ||
  41. /(test|spec|tests)\.(java|kt|scala)$/.test(lp) ||
  42. /(tests?|spec)\.cs$/.test(lp) ||
  43. /tests?\.swift$/.test(lp) ||
  44. /_test\.dart$/.test(lp) ||
  45. isGeneratedFile(filePath)
  46. );
  47. }
  48. const SQLITE_PARAM_CHUNK_SIZE = 500;
  49. /**
  50. * Database row types (snake_case from SQLite)
  51. */
  52. interface NodeRow {
  53. id: string;
  54. kind: string;
  55. name: string;
  56. qualified_name: string;
  57. file_path: string;
  58. language: string;
  59. start_line: number;
  60. end_line: number;
  61. start_column: number;
  62. end_column: number;
  63. docstring: string | null;
  64. signature: string | null;
  65. visibility: string | null;
  66. is_exported: number;
  67. is_async: number;
  68. is_static: number;
  69. is_abstract: number;
  70. decorators: string | null;
  71. type_parameters: string | null;
  72. return_type: string | null;
  73. updated_at: number;
  74. }
  75. interface EdgeRow {
  76. id: number;
  77. source: string;
  78. target: string;
  79. kind: string;
  80. metadata: string | null;
  81. line: number | null;
  82. col: number | null;
  83. provenance: string | null;
  84. }
  85. interface FileRow {
  86. path: string;
  87. content_hash: string;
  88. language: string;
  89. size: number;
  90. modified_at: number;
  91. indexed_at: number;
  92. node_count: number;
  93. errors: string | null;
  94. }
  95. interface UnresolvedRefRow {
  96. id: number;
  97. from_node_id: string;
  98. reference_name: string;
  99. reference_kind: string;
  100. line: number;
  101. col: number;
  102. candidates: string | null;
  103. file_path: string;
  104. language: string;
  105. status: string;
  106. name_tail: string;
  107. }
  108. /**
  109. * Last segment of a (possibly dotted/qualified) reference name — the part a
  110. * new symbol's plain node name could match: 'util.greet' → 'greet',
  111. * 'mod::fn' → 'fn', 'greet' → 'greet'. Written to unresolved_refs.name_tail
  112. * when a ref is marked failed, so the #1240 retry lookup can match dotted
  113. * refs against newly-added node names.
  114. */
  115. function referenceNameTail(referenceName: string): string {
  116. const idx = Math.max(referenceName.lastIndexOf('.'), referenceName.lastIndexOf(':'));
  117. return idx >= 0 ? referenceName.slice(idx + 1) : referenceName;
  118. }
  119. /**
  120. * Convert database row to Node object
  121. */
  122. function rowToNode(row: NodeRow): Node {
  123. return {
  124. id: row.id,
  125. kind: row.kind as NodeKind,
  126. name: row.name,
  127. qualifiedName: row.qualified_name,
  128. filePath: row.file_path,
  129. language: row.language as Language,
  130. startLine: row.start_line,
  131. endLine: row.end_line,
  132. startColumn: row.start_column,
  133. endColumn: row.end_column,
  134. docstring: row.docstring ?? undefined,
  135. signature: row.signature ?? undefined,
  136. visibility: row.visibility as Node['visibility'],
  137. isExported: row.is_exported === 1,
  138. isAsync: row.is_async === 1,
  139. isStatic: row.is_static === 1,
  140. isAbstract: row.is_abstract === 1,
  141. decorators: row.decorators ? safeJsonParse(row.decorators, undefined) : undefined,
  142. typeParameters: row.type_parameters ? safeJsonParse(row.type_parameters, undefined) : undefined,
  143. returnType: row.return_type ?? undefined,
  144. updatedAt: row.updated_at,
  145. };
  146. }
  147. /**
  148. * Convert database row to Edge object
  149. */
  150. function rowToEdge(row: EdgeRow): Edge {
  151. return {
  152. source: row.source,
  153. target: row.target,
  154. kind: row.kind as EdgeKind,
  155. metadata: row.metadata ? safeJsonParse(row.metadata, undefined) : undefined,
  156. line: row.line ?? undefined,
  157. column: row.col ?? undefined,
  158. provenance: row.provenance as Edge['provenance'],
  159. };
  160. }
  161. /**
  162. * Convert database row to FileRecord object
  163. */
  164. function rowToFileRecord(row: FileRow): FileRecord {
  165. return {
  166. path: row.path,
  167. contentHash: row.content_hash,
  168. language: row.language as Language,
  169. size: row.size,
  170. modifiedAt: row.modified_at,
  171. indexedAt: row.indexed_at,
  172. nodeCount: row.node_count,
  173. errors: row.errors ? safeJsonParse(row.errors, undefined) : undefined,
  174. };
  175. }
  176. /**
  177. * Query builder for the knowledge graph database
  178. */
  179. export class QueryBuilder {
  180. private db: SqliteDatabase;
  181. // Project-name tokens (go.mod / package.json / repo dir), normalized. A query
  182. // word matching one is dropped from path-relevance scoring — it names the
  183. // whole project, not a symbol, so it carries no discriminative signal (#720).
  184. // Set once by the CodeGraph instance; empty by default (no down-weighting).
  185. private projectNameTokens: Set<string> = new Set();
  186. // Node cache for frequently accessed nodes (LRU-style, max 1000 entries)
  187. private nodeCache: Map<string, Node> = new Map();
  188. private readonly maxCacheSize = 1000;
  189. // Prepared statements (lazily initialized)
  190. private stmts: {
  191. insertNode?: SqliteStatement;
  192. updateNode?: SqliteStatement;
  193. deleteNode?: SqliteStatement;
  194. deleteNodesByFile?: SqliteStatement;
  195. getNodeById?: SqliteStatement;
  196. getNodesByFile?: SqliteStatement;
  197. getNodesByKind?: SqliteStatement;
  198. insertEdge?: SqliteStatement;
  199. upsertFile?: SqliteStatement;
  200. deleteEdgesBySource?: SqliteStatement;
  201. deleteEdgesByTarget?: SqliteStatement;
  202. getEdgesBySource?: SqliteStatement;
  203. getEdgesByTarget?: SqliteStatement;
  204. insertFile?: SqliteStatement;
  205. updateFile?: SqliteStatement;
  206. deleteFile?: SqliteStatement;
  207. getFileByPath?: SqliteStatement;
  208. getAllFiles?: SqliteStatement;
  209. insertUnresolved?: SqliteStatement;
  210. deleteUnresolvedByNode?: SqliteStatement;
  211. getUnresolvedByName?: SqliteStatement;
  212. getNodesByName?: SqliteStatement;
  213. getNodesByNamePrefix?: SqliteStatement;
  214. getNodesByQualifiedNameExact?: SqliteStatement;
  215. getNodesByLowerName?: SqliteStatement;
  216. getUnresolvedCount?: SqliteStatement;
  217. getUnresolvedBatch?: SqliteStatement;
  218. getUnresolvedBatchAfter?: SqliteStatement;
  219. deleteRefsByRowIdsFull?: SqliteStatement;
  220. getAllFilePaths?: SqliteStatement;
  221. getAllNodeNames?: SqliteStatement;
  222. getDominantFile?: SqliteStatement;
  223. getTopRouteFile?: SqliteStatement;
  224. getRoutingManifest?: SqliteStatement;
  225. insertNameSegment?: SqliteStatement;
  226. } = {};
  227. // Names whose segments were already written this session — skips re-splitting
  228. // and re-inserting for the same-named nodes that repeat across files ("get",
  229. // "render", …). Purely a write-path fast path; INSERT OR IGNORE is the
  230. // correctness backstop. Bounded so a pathological repo can't grow it forever.
  231. private segmentedNames: Set<string> = new Set();
  232. private static readonly MAX_SEGMENTED_NAMES = 65536;
  233. // Multi-row INSERT statements, cached per (statement kind × row count). The
  234. // bulk write path decomposes N rows into a few fixed batch sizes so each
  235. // size's statement is prepared once and reused — one .run() binds a whole
  236. // chunk instead of one row, which is where the per-call overhead lives.
  237. // Row order within and across chunks is the input order, so rowid assignment
  238. // (and therefore resolution's insertion-order disambiguation) is identical
  239. // to the one-row-per-run path.
  240. private batchStmts: Map<string, SqliteStatement> = new Map();
  241. private static readonly BATCH_SIZES: readonly number[] = [128, 32, 8, 1];
  242. /**
  243. * Run `rows` through a multi-row `INSERT` built as `head + (tuple,)*n`,
  244. * decomposed greedily into the cached batch sizes. Preserves row order.
  245. */
  246. private runBatched(kind: string, head: string, tuple: string, rows: unknown[][]): void {
  247. if (rows.length === 0) return;
  248. let i = 0;
  249. for (const size of QueryBuilder.BATCH_SIZES) {
  250. while (rows.length - i >= size) {
  251. const key = `${kind}:${size}`;
  252. let stmt = this.batchStmts.get(key);
  253. if (!stmt) {
  254. stmt = this.db.prepare(head + new Array(size).fill(tuple).join(','));
  255. this.batchStmts.set(key, stmt);
  256. }
  257. if (size === 1) {
  258. stmt.run(...rows[i]!);
  259. } else {
  260. const params: unknown[] = [];
  261. for (let r = 0; r < size; r++) {
  262. const row = rows[i + r]!;
  263. for (let c = 0; c < row.length; c++) params.push(row[c]);
  264. }
  265. stmt.run(...params);
  266. }
  267. i += size;
  268. }
  269. }
  270. }
  271. constructor(db: SqliteDatabase) {
  272. this.db = db;
  273. }
  274. /**
  275. * Swap the underlying connection in place. Used by pool workers'
  276. * connection recycling (plan §7a.6, writes-under-readers): a long-lived
  277. * read connection pins WAL checkpoint progress, and the deep WAL that
  278. * accumulates behind it taxes every main-thread B-tree page operation
  279. * (deletes measured 42.6s → 118.8s from 0 to 4 attached readers on
  280. * identical hardware). Workers therefore close and reopen their read-only
  281. * connection at the pool-idle boundary; everything above the connection —
  282. * this QueryBuilder, the resolver and its warm caches — survives, and only
  283. * connection-derived state (prepared statements) resets, re-preparing
  284. * lazily on next use.
  285. */
  286. rebind(db: SqliteDatabase): void {
  287. this.db = db;
  288. this.stmts = {};
  289. this.batchStmts.clear();
  290. }
  291. /** Set the normalized project-name tokens used to down-weight non-discriminative
  292. * query words in path scoring (#720). Called once when the project opens. */
  293. setProjectNameTokens(tokens: Set<string>): void {
  294. this.projectNameTokens = tokens;
  295. }
  296. /** The normalized project-name tokens (#720); empty if none were derived. */
  297. getProjectNameTokens(): Set<string> {
  298. return this.projectNameTokens;
  299. }
  300. // ===========================================================================
  301. // Node Operations
  302. // ===========================================================================
  303. /**
  304. * Insert a new node
  305. */
  306. insertNode(node: Node): void {
  307. if (!this.stmts.insertNode) {
  308. this.stmts.insertNode = this.db.prepare(`
  309. INSERT OR REPLACE INTO nodes (
  310. id, kind, name, qualified_name, file_path, language,
  311. start_line, end_line, start_column, end_column,
  312. docstring, signature, visibility,
  313. is_exported, is_async, is_static, is_abstract,
  314. decorators, type_parameters, return_type, updated_at
  315. ) VALUES (
  316. @id, @kind, @name, @qualifiedName, @filePath, @language,
  317. @startLine, @endLine, @startColumn, @endColumn,
  318. @docstring, @signature, @visibility,
  319. @isExported, @isAsync, @isStatic, @isAbstract,
  320. @decorators, @typeParameters, @returnType, @updatedAt
  321. )
  322. `);
  323. }
  324. // Validate required fields to prevent SQLite bind errors
  325. if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) {
  326. console.error('[CodeGraph] Skipping node with missing required fields:', {
  327. id: node.id,
  328. kind: node.kind,
  329. name: node.name,
  330. filePath: node.filePath,
  331. language: node.language,
  332. });
  333. return;
  334. }
  335. // INSERT OR REPLACE may overwrite a node we have cached. Drop the
  336. // stale entry so the next getNodeById sees the new row, not the old
  337. // one (matches the cache-invalidation pattern used by updateNode and
  338. // deleteNode below).
  339. this.nodeCache.delete(node.id);
  340. this.stmts.insertNode.run({
  341. id: node.id,
  342. kind: node.kind,
  343. name: node.name,
  344. qualifiedName: node.qualifiedName ?? node.name,
  345. filePath: node.filePath,
  346. language: node.language,
  347. startLine: node.startLine ?? 0,
  348. endLine: node.endLine ?? 0,
  349. startColumn: node.startColumn ?? 0,
  350. endColumn: node.endColumn ?? 0,
  351. docstring: node.docstring ?? null,
  352. signature: node.signature ?? null,
  353. visibility: node.visibility ?? null,
  354. isExported: node.isExported ? 1 : 0,
  355. isAsync: node.isAsync ? 1 : 0,
  356. isStatic: node.isStatic ? 1 : 0,
  357. isAbstract: node.isAbstract ? 1 : 0,
  358. decorators: node.decorators ? JSON.stringify(node.decorators) : null,
  359. typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
  360. returnType: node.returnType ?? null,
  361. updatedAt: node.updatedAt ?? Date.now(),
  362. });
  363. // Segment vocabulary rides the same write path (and transaction) so it can
  364. // never drift ahead of the nodes it describes. Deletes intentionally leave
  365. // orphans behind — vocab rows are proposals re-verified against nodes
  366. // before use, and a full index clears the table at its start. File nodes
  367. // are excluded: a file's basename duplicates the symbols inside it
  368. // (state-machine.ts / OrderStateMachine), which double-counts every
  369. // concept and defeats the singleton-vs-cluster rarity statistics. Import
  370. // nodes are excluded too (#1144): they're named after module specifiers
  371. // ("external-unindexed-pkg", "./utils/helpers"), not symbols — an
  372. // import-only name can never be surfaced (getSegmentMatches requires a
  373. // real definition), so its rows would only inflate the rarity statistics.
  374. if (this.isSegmentableKind(node.kind)) this.insertNameSegments(node.name);
  375. }
  376. /** Which node kinds contribute their name to the segment vocabulary — the
  377. * single gate shared by insertNode, updateNode, and the rebuild page query
  378. * (getDistinctNodeNames), so the write paths can't drift apart. */
  379. private isSegmentableKind(kind: string): boolean {
  380. return kind !== 'file' && kind !== 'import';
  381. }
  382. /** Write `name`'s segments into name_segment_vocab (idempotent). */
  383. private insertNameSegments(name: string): void {
  384. const rows: unknown[][] = [];
  385. this.collectNameSegmentRows(name, rows);
  386. this.runBatched(
  387. 'insertNameSegments',
  388. 'INSERT OR IGNORE INTO name_segment_vocab (segment, name) VALUES ',
  389. '(?,?)',
  390. rows
  391. );
  392. }
  393. /**
  394. * Insert multiple nodes in a transaction
  395. */
  396. insertNodes(nodes: Node[]): void {
  397. this.db.transaction(() => {
  398. // Bulk path: same semantics as insertNode() per row (validation, cache
  399. // invalidation, segment vocab), but bound as multi-row INSERTs — the
  400. // per-.run() call overhead dominates the store phase on full indexes.
  401. const rows: unknown[][] = [];
  402. const segmentRows: unknown[][] = [];
  403. for (const node of nodes) {
  404. if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) {
  405. console.error('[CodeGraph] Skipping node with missing required fields:', {
  406. id: node.id,
  407. kind: node.kind,
  408. name: node.name,
  409. filePath: node.filePath,
  410. language: node.language,
  411. });
  412. continue;
  413. }
  414. this.nodeCache.delete(node.id);
  415. rows.push([
  416. node.id,
  417. node.kind,
  418. node.name,
  419. node.qualifiedName ?? node.name,
  420. node.filePath,
  421. node.language,
  422. node.startLine ?? 0,
  423. node.endLine ?? 0,
  424. node.startColumn ?? 0,
  425. node.endColumn ?? 0,
  426. node.docstring ?? null,
  427. node.signature ?? null,
  428. node.visibility ?? null,
  429. node.isExported ? 1 : 0,
  430. node.isAsync ? 1 : 0,
  431. node.isStatic ? 1 : 0,
  432. node.isAbstract ? 1 : 0,
  433. node.decorators ? JSON.stringify(node.decorators) : null,
  434. node.typeParameters ? JSON.stringify(node.typeParameters) : null,
  435. node.returnType ?? null,
  436. node.updatedAt ?? Date.now(),
  437. ]);
  438. if (this.isSegmentableKind(node.kind)) this.collectNameSegmentRows(node.name, segmentRows);
  439. }
  440. this.runBatched(
  441. 'insertNodes',
  442. `INSERT OR REPLACE INTO nodes (
  443. id, kind, name, qualified_name, file_path, language,
  444. start_line, end_line, start_column, end_column,
  445. docstring, signature, visibility,
  446. is_exported, is_async, is_static, is_abstract,
  447. decorators, type_parameters, return_type, updated_at
  448. ) VALUES `,
  449. '(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
  450. rows
  451. );
  452. this.runBatched(
  453. 'insertNameSegments',
  454. 'INSERT OR IGNORE INTO name_segment_vocab (segment, name) VALUES ',
  455. '(?,?)',
  456. segmentRows
  457. );
  458. })();
  459. }
  460. /**
  461. * Store one file's whole extraction bundle — nodes, edges, unresolved refs,
  462. * and the file record — in a SINGLE transaction. The bulk-index path calls
  463. * this once per file instead of opening one transaction per table (#1015
  464. * file-order commit discipline is unchanged: callers still invoke it in file
  465. * order, and row order within is input order).
  466. *
  467. * Edges MUST already be endpoint-filtered by the caller (the store path
  468. * filters to the file's own inserted node ids), so the per-file existence
  469. * SELECT that insertEdges() pays is skipped here.
  470. */
  471. storeFileBundle(bundle: {
  472. nodes: Node[];
  473. edges: Edge[];
  474. refs: UnresolvedReference[];
  475. file: FileRecord;
  476. }): void {
  477. this.db.transaction(() => {
  478. this.insertNodes(bundle.nodes);
  479. if (bundle.edges.length > 0) {
  480. const rows: unknown[][] = [];
  481. for (const edge of bundle.edges) {
  482. rows.push([
  483. edge.source,
  484. edge.target,
  485. edge.kind,
  486. edge.metadata ? JSON.stringify(edge.metadata) : null,
  487. edge.line ?? null,
  488. edge.column ?? null,
  489. edge.provenance ?? null,
  490. ]);
  491. }
  492. this.runBatched(
  493. 'insertEdges',
  494. 'INSERT OR IGNORE INTO edges (source, target, kind, metadata, line, col, provenance) VALUES ',
  495. '(?,?,?,?,?,?,?)',
  496. rows
  497. );
  498. }
  499. if (bundle.refs.length > 0) this.insertUnresolvedRefsBatch(bundle.refs);
  500. this.upsertFile(bundle.file);
  501. })();
  502. }
  503. /**
  504. * Collect (segment, name) rows for a name, honouring the same session-dedupe
  505. * semantics as insertNameSegments(). Shared by the bulk write paths.
  506. */
  507. private collectNameSegmentRows(name: string, out: unknown[][]): void {
  508. if (this.segmentedNames.has(name)) return;
  509. if (this.segmentedNames.size >= QueryBuilder.MAX_SEGMENTED_NAMES) this.segmentedNames.clear();
  510. this.segmentedNames.add(name);
  511. for (const segment of splitIdentifierSegments(name)) out.push([segment, name]);
  512. }
  513. /**
  514. * Update an existing node
  515. */
  516. updateNode(node: Node): void {
  517. if (!this.stmts.updateNode) {
  518. this.stmts.updateNode = this.db.prepare(`
  519. UPDATE nodes SET
  520. kind = @kind,
  521. name = @name,
  522. qualified_name = @qualifiedName,
  523. file_path = @filePath,
  524. language = @language,
  525. start_line = @startLine,
  526. end_line = @endLine,
  527. start_column = @startColumn,
  528. end_column = @endColumn,
  529. docstring = @docstring,
  530. signature = @signature,
  531. visibility = @visibility,
  532. is_exported = @isExported,
  533. is_async = @isAsync,
  534. is_static = @isStatic,
  535. is_abstract = @isAbstract,
  536. decorators = @decorators,
  537. type_parameters = @typeParameters,
  538. return_type = @returnType,
  539. updated_at = @updatedAt
  540. WHERE id = @id
  541. `);
  542. }
  543. // Invalidate cache before update
  544. this.nodeCache.delete(node.id);
  545. // Validate required fields
  546. if (!node.id || !node.kind || !node.name || !node.filePath || !node.language) {
  547. console.error('[CodeGraph] Skipping node update with missing required fields:', node.id);
  548. return;
  549. }
  550. this.stmts.updateNode.run({
  551. id: node.id,
  552. kind: node.kind,
  553. name: node.name,
  554. qualifiedName: node.qualifiedName ?? node.name,
  555. filePath: node.filePath,
  556. language: node.language,
  557. startLine: node.startLine ?? 0,
  558. endLine: node.endLine ?? 0,
  559. startColumn: node.startColumn ?? 0,
  560. endColumn: node.endColumn ?? 0,
  561. docstring: node.docstring ?? null,
  562. signature: node.signature ?? null,
  563. visibility: node.visibility ?? null,
  564. isExported: node.isExported ? 1 : 0,
  565. isAsync: node.isAsync ? 1 : 0,
  566. isStatic: node.isStatic ? 1 : 0,
  567. isAbstract: node.isAbstract ? 1 : 0,
  568. decorators: node.decorators ? JSON.stringify(node.decorators) : null,
  569. typeParameters: node.typeParameters ? JSON.stringify(node.typeParameters) : null,
  570. returnType: node.returnType ?? null,
  571. updatedAt: node.updatedAt ?? Date.now(),
  572. });
  573. // updateNode is a second real write path to `nodes` — framework
  574. // post-extract passes rewrite names through it (NestJS route prefixing),
  575. // and a renamed node's new name must reach the segment vocabulary just
  576. // like an inserted one's (#1141). Without this the rename left the new
  577. // name permanently unsearchable: the old name's rows became honest-gate
  578. // orphans and the only backfill is gated on the vocab being EMPTY.
  579. // insertNameSegments is idempotent (in-memory set + INSERT OR IGNORE),
  580. // so no name-changed check is needed.
  581. if (this.isSegmentableKind(node.kind)) this.insertNameSegments(node.name);
  582. }
  583. /**
  584. * Delete a node by ID
  585. */
  586. deleteNode(id: string): void {
  587. if (!this.stmts.deleteNode) {
  588. this.stmts.deleteNode = this.db.prepare('DELETE FROM nodes WHERE id = ?');
  589. }
  590. // Invalidate cache
  591. this.nodeCache.delete(id);
  592. this.stmts.deleteNode.run(id);
  593. }
  594. /**
  595. * Delete all nodes for a file
  596. */
  597. deleteNodesByFile(filePath: string): void {
  598. if (!this.stmts.deleteNodesByFile) {
  599. this.stmts.deleteNodesByFile = this.db.prepare('DELETE FROM nodes WHERE file_path = ?');
  600. }
  601. // Invalidate cache for nodes in this file
  602. for (const [id, node] of this.nodeCache) {
  603. if (node.filePath === filePath) {
  604. this.nodeCache.delete(id);
  605. }
  606. }
  607. this.stmts.deleteNodesByFile.run(filePath);
  608. }
  609. // ===========================================================================
  610. // Name-segment vocabulary (prompt-hook graph-derived gate)
  611. // ===========================================================================
  612. /** Wipe the segment vocabulary. A full index calls this at its start; the
  613. * node write path repopulates it as files (re-)index, so the end state is
  614. * exactly the current names with no orphan rows. */
  615. clearNameSegmentVocab(): void {
  616. this.db.exec('DELETE FROM name_segment_vocab');
  617. this.segmentedNames.clear();
  618. }
  619. /** True when the vocab has no rows — an index built before the table existed.
  620. * `sync` uses this to heal such databases (see rebuildNameSegmentVocabFrom). */
  621. isNameSegmentVocabEmpty(): boolean {
  622. const row = this.db.prepare('SELECT 1 FROM name_segment_vocab LIMIT 1').get();
  623. return row === undefined;
  624. }
  625. /** One page of distinct segmentable node names, for batched vocab rebuilds
  626. * (file basenames and import specifiers are excluded from the vocab — see
  627. * insertNode). */
  628. getDistinctNodeNames(limit: number, offset: number): string[] {
  629. const rows = this.db
  630. .prepare("SELECT DISTINCT name FROM nodes WHERE kind NOT IN ('file', 'import') ORDER BY name LIMIT ? OFFSET ?")
  631. .all(limit, offset) as Array<{ name: string }>;
  632. return rows.map((r) => r.name);
  633. }
  634. /** Insert segments for a batch of names in one transaction (vocab heal path). */
  635. insertNameSegmentsBatch(names: string[]): void {
  636. this.db.transaction(() => {
  637. const rows: unknown[][] = [];
  638. for (const name of names) this.collectNameSegmentRows(name, rows);
  639. this.runBatched(
  640. 'insertNameSegments',
  641. 'INSERT OR IGNORE INTO name_segment_vocab (segment, name) VALUES ',
  642. '(?,?)',
  643. rows
  644. );
  645. })();
  646. }
  647. /**
  648. * Names whose segments cover at least `minWords` distinct PROMPT WORDS —
  649. * the co-occurrence probe behind the prompt hook's medium tier: the words
  650. * "state" and "machine" both being segments of `OrderStateMachine` is strong
  651. * evidence the prompt names that symbol in prose. Ordered by coverage.
  652. *
  653. * Takes (segment variant → original word) pairs and folds variants back to
  654. * their word INSIDE the SQL: a name matching both `service` and `services`
  655. * counts ONE word, not two. Counting raw variants let plural-variant pairs
  656. * of a single word tie with genuine two-word matches and — because ORDER
  657. * BY/LIMIT run here, before any JS-side re-check — crowd a real match past
  658. * the LIMIT on vocab-heavy repos (#1146).
  659. */
  660. getSegmentCoOccurrence(
  661. variants: Array<{ segment: string; word: string }>,
  662. minWords: number,
  663. limit: number,
  664. ): Array<{ name: string; matches: number }> {
  665. if (variants.length === 0) return [];
  666. const placeholders = variants.map(() => '?').join(', ');
  667. const whens = variants.map(() => 'WHEN ? THEN ?').join(' ');
  668. const rows = this.db
  669. .prepare(
  670. `SELECT name, COUNT(DISTINCT CASE segment ${whens} END) AS matches
  671. FROM name_segment_vocab
  672. WHERE segment IN (${placeholders})
  673. GROUP BY name
  674. HAVING matches >= ?
  675. ORDER BY matches DESC, length(name) ASC
  676. LIMIT ?`,
  677. )
  678. .all(
  679. ...variants.flatMap((v) => [v.segment, v.word]),
  680. ...variants.map((v) => v.segment),
  681. minWords,
  682. limit,
  683. ) as Array<{ name: string; matches: number }>;
  684. return rows;
  685. }
  686. /** How many distinct names each segment appears in — the rarity signal that
  687. * separates a discriminative word ("checkout") from a ubiquitous one ("state"). */
  688. getSegmentNameCounts(segments: string[]): Map<string, number> {
  689. if (segments.length === 0) return new Map();
  690. const placeholders = segments.map(() => '?').join(', ');
  691. const rows = this.db
  692. .prepare(
  693. `SELECT segment, COUNT(*) AS n FROM name_segment_vocab
  694. WHERE segment IN (${placeholders}) GROUP BY segment`,
  695. )
  696. .all(...segments) as Array<{ segment: string; n: number }>;
  697. return new Map(rows.map((r) => [r.segment, r.n]));
  698. }
  699. /** Names containing the given segment (rare-single-word tier). */
  700. getNamesForSegment(segment: string, limit: number): string[] {
  701. const rows = this.db
  702. .prepare('SELECT name FROM name_segment_vocab WHERE segment = ? ORDER BY length(name) ASC LIMIT ?')
  703. .all(segment, limit) as Array<{ name: string }>;
  704. return rows.map((r) => r.name);
  705. }
  706. /**
  707. * Get a node by ID
  708. */
  709. getNodeById(id: string): Node | null {
  710. // Check cache first
  711. if (this.nodeCache.has(id)) {
  712. const cached = this.nodeCache.get(id)!;
  713. // Move to end to implement LRU (delete and re-add)
  714. this.nodeCache.delete(id);
  715. this.nodeCache.set(id, cached);
  716. return cached;
  717. }
  718. if (!this.stmts.getNodeById) {
  719. this.stmts.getNodeById = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
  720. }
  721. const row = this.stmts.getNodeById.get(id) as NodeRow | undefined;
  722. if (!row) {
  723. return null;
  724. }
  725. const node = rowToNode(row);
  726. this.cacheNode(node);
  727. return node;
  728. }
  729. /**
  730. * Batch lookup: fetch many nodes by ID in a single SQL round-trip.
  731. *
  732. * Replaces the N+1 pattern in graph traversal where every edge would
  733. * trigger its own `getNodeById` call. For a function with 50 callers
  734. * this collapses 50 point reads into one IN-list query (~10-50x
  735. * faster end-to-end).
  736. *
  737. * Returns a Map keyed by id so callers can preserve their own ordering
  738. * (typically the order edges were returned from the graph). Missing IDs
  739. * are simply absent from the map.
  740. *
  741. * Cache-aware: ids already in the LRU cache are served from memory and
  742. * the SQL query only touches the misses.
  743. */
  744. getNodesByIds(ids: readonly string[]): Map<string, Node> {
  745. const out = new Map<string, Node>();
  746. if (ids.length === 0) return out;
  747. // Serve cache hits first; build the miss list for SQL.
  748. const misses: string[] = [];
  749. for (const id of ids) {
  750. const cached = this.nodeCache.get(id);
  751. if (cached !== undefined) {
  752. // LRU touch
  753. this.nodeCache.delete(id);
  754. this.nodeCache.set(id, cached);
  755. out.set(id, cached);
  756. } else {
  757. misses.push(id);
  758. }
  759. }
  760. if (misses.length === 0) return out;
  761. // Chunk under SQLite's parameter limit (default 999, raised to 32766
  762. // in better-sqlite3 builds — chunk at 500 for safety across both
  763. // backends and to keep the query plan simple).
  764. for (let i = 0; i < misses.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  765. const chunk = misses.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  766. const placeholders = chunk.map(() => '?').join(',');
  767. const rows = this.db
  768. .prepare(`SELECT * FROM nodes WHERE id IN (${placeholders})`)
  769. .all(...chunk) as NodeRow[];
  770. for (const row of rows) {
  771. const node = rowToNode(row);
  772. out.set(node.id, node);
  773. this.cacheNode(node);
  774. }
  775. }
  776. return out;
  777. }
  778. private getExistingNodeIds(ids: readonly string[]): Set<string> {
  779. const out = new Set<string>();
  780. if (ids.length === 0) return out;
  781. const uniqueIds = [...new Set(ids)];
  782. for (let i = 0; i < uniqueIds.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  783. const chunk = uniqueIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  784. const placeholders = chunk.map(() => '?').join(',');
  785. const rows = this.db
  786. .prepare(`SELECT id FROM nodes WHERE id IN (${placeholders})`)
  787. .all(...chunk) as { id: string }[];
  788. for (const row of rows) {
  789. out.add(row.id);
  790. }
  791. }
  792. return out;
  793. }
  794. /**
  795. * Add a node to the cache, evicting oldest if needed
  796. */
  797. private cacheNode(node: Node): void {
  798. if (this.nodeCache.size >= this.maxCacheSize) {
  799. // Evict oldest (first) entry
  800. const firstKey = this.nodeCache.keys().next().value;
  801. if (firstKey) {
  802. this.nodeCache.delete(firstKey);
  803. }
  804. }
  805. this.nodeCache.set(node.id, node);
  806. }
  807. /**
  808. * Clear the node cache
  809. */
  810. clearCache(): void {
  811. this.nodeCache.clear();
  812. }
  813. /**
  814. * Get all nodes in a file
  815. */
  816. getNodesByFile(filePath: string): Node[] {
  817. if (!this.stmts.getNodesByFile) {
  818. this.stmts.getNodesByFile = this.db.prepare(
  819. 'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line'
  820. );
  821. }
  822. const rows = this.stmts.getNodesByFile.all(filePath) as NodeRow[];
  823. return rows.map(rowToNode);
  824. }
  825. /**
  826. * Find the file that holds the densest concentration of the project's
  827. * internal call graph — the "core" file. Used by context-builder to
  828. * boost ranking of symbols in that file's directory (so e.g. sinatra
  829. * queries surface `lib/sinatra/base.rb`'s `route!` instead of
  830. * `sinatra-contrib/lib/sinatra/multi_route.rb`'s `route` extension).
  831. *
  832. * Returns null if no file has a meaningful concentration (e.g. spread
  833. * evenly across many files, or empty index).
  834. *
  835. * "Internal" = source and target are in the same file. Cross-file
  836. * edges aren't useful here — they don't tell us which file is the
  837. * functional center.
  838. *
  839. * Excludes test/spec files from candidacy via path-pattern. The agent's
  840. * typical question is "how does X work", not "how is X tested", so
  841. * boosting a test file's directory would be a misfire.
  842. */
  843. getDominantFile(): { filePath: string; edgeCount: number; nextEdgeCount: number } | null {
  844. if (!this.stmts.getDominantFile) {
  845. // Pull top 20 candidates; we then filter out test/generated files
  846. // in code (regex-grade matching that SQL LIKE can't express). The
  847. // generated-file filter is critical — without it, etcd's
  848. // `api/etcdserverpb/rpc.pb.go` (1916 in-file edges, generated
  849. // protobuf stub) outranks the real `server/etcdserver/server.go`
  850. // (470 edges) by 4×, and the boost would push the agent toward
  851. // generated code.
  852. this.stmts.getDominantFile = this.db.prepare(`
  853. SELECT n.file_path AS file_path, COUNT(*) AS edge_count
  854. FROM edges e
  855. JOIN nodes n ON e.source = n.id
  856. JOIN nodes m ON e.target = m.id
  857. WHERE n.file_path = m.file_path
  858. GROUP BY n.file_path
  859. ORDER BY edge_count DESC
  860. LIMIT 20
  861. `);
  862. }
  863. const rows = this.stmts.getDominantFile.all() as Array<{ file_path: string; edge_count: number }>;
  864. const filtered = rows.filter(r => !isLowValueFile(r.file_path));
  865. if (filtered.length === 0 || filtered[0]!.edge_count < 20) return null;
  866. return {
  867. filePath: filtered[0]!.file_path,
  868. edgeCount: filtered[0]!.edge_count,
  869. nextEdgeCount: filtered[1]?.edge_count ?? 0,
  870. };
  871. }
  872. /**
  873. * Find the file that holds the densest concentration of the project's
  874. * `route` nodes (framework-emitted: Express/Gin/Flask/Rails/Drupal/etc.).
  875. * Used by handleContext on small repos to inline the project's routing
  876. * config when the agent's query is about request flow — eliminating the
  877. * "Glob + Read routes.rb" pattern that beats codegraph on tiny realworld
  878. * template repos.
  879. *
  880. * Excludes test/generated files from candidacy. Returns null if there
  881. * are fewer than 3 non-test routes total, or if no file holds at least
  882. * 30% of them (diffuse routing → no single answer file).
  883. */
  884. getTopRouteFile(): { filePath: string; routeCount: number; totalRoutes: number } | null {
  885. if (!this.stmts.getTopRouteFile) {
  886. this.stmts.getTopRouteFile = this.db.prepare(`
  887. SELECT file_path, COUNT(*) AS cnt
  888. FROM nodes
  889. WHERE kind = 'route'
  890. GROUP BY file_path
  891. ORDER BY cnt DESC
  892. LIMIT 20
  893. `);
  894. }
  895. const rows = this.stmts.getTopRouteFile.all() as Array<{ file_path: string; cnt: number }>;
  896. const filtered = rows.filter(r => !isLowValueFile(r.file_path));
  897. if (filtered.length === 0) return null;
  898. const totalRoutes = filtered.reduce((sum, r) => sum + r.cnt, 0);
  899. const top = filtered[0]!;
  900. if (totalRoutes < 3 || top.cnt < 3) return null;
  901. if (top.cnt / totalRoutes < 0.30) return null;
  902. return { filePath: top.file_path, routeCount: top.cnt, totalRoutes };
  903. }
  904. /**
  905. * Build a URL → handler manifest from the index. Each route node's
  906. * `references` edge points at the function/method that handles the
  907. * request. We join them in one pass; the agent gets the canonical
  908. * routing answer ("POST /users/login → AuthController#login") without
  909. * having to parse the framework's route DSL itself.
  910. *
  911. * Also returns the file with the most handler endpoints — used as the
  912. * "top handler file" to inline source for, so the agent has both the
  913. * mapping AND the handler implementations.
  914. */
  915. getRoutingManifest(limit: number = 40): {
  916. entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>;
  917. topHandlerFile: string | null;
  918. topHandlerFileCount: number;
  919. totalRoutes: number;
  920. } | null {
  921. if (!this.stmts.getRoutingManifest) {
  922. // Edge kind varies across framework resolvers: Spring/Rails/
  923. // Laravel/Drupal emit `references`, Express emits `calls`. Accept
  924. // both — the semantic is the same (route → its handler).
  925. this.stmts.getRoutingManifest = this.db.prepare(`
  926. SELECT
  927. r.name AS url,
  928. h.name AS handler,
  929. h.file_path AS handler_file,
  930. h.start_line AS handler_line,
  931. h.kind AS handler_kind
  932. FROM nodes r
  933. JOIN edges e ON e.source = r.id
  934. JOIN nodes h ON e.target = h.id
  935. WHERE r.kind = 'route'
  936. AND e.kind IN ('references', 'calls')
  937. AND h.kind IN ('function', 'method', 'class')
  938. ORDER BY r.file_path, r.start_line
  939. LIMIT ?
  940. `);
  941. }
  942. const rows = this.stmts.getRoutingManifest.all(limit) as Array<{
  943. url: string; handler: string; handler_file: string; handler_line: number; handler_kind: string;
  944. }>;
  945. // Drop test/generated handlers — same hygiene as elsewhere.
  946. const filtered = rows.filter(r => !isLowValueFile(r.handler_file));
  947. if (filtered.length < 3) return null;
  948. // Identify the file holding the most handlers (the "primary handler file").
  949. const fileCounts = new Map<string, number>();
  950. for (const r of filtered) {
  951. fileCounts.set(r.handler_file, (fileCounts.get(r.handler_file) ?? 0) + 1);
  952. }
  953. let topHandlerFile: string | null = null;
  954. let topHandlerFileCount = 0;
  955. for (const [file, count] of fileCounts) {
  956. if (count > topHandlerFileCount) {
  957. topHandlerFile = file;
  958. topHandlerFileCount = count;
  959. }
  960. }
  961. return {
  962. entries: filtered.map(r => ({
  963. url: r.url,
  964. handler: r.handler,
  965. handlerFile: r.handler_file,
  966. handlerLine: r.handler_line,
  967. handlerKind: r.handler_kind,
  968. })),
  969. topHandlerFile,
  970. topHandlerFileCount,
  971. totalRoutes: filtered.length,
  972. };
  973. }
  974. /**
  975. * Get all nodes of a specific kind
  976. */
  977. getNodesByKind(kind: NodeKind): Node[] {
  978. if (!this.stmts.getNodesByKind) {
  979. this.stmts.getNodesByKind = this.db.prepare('SELECT * FROM nodes WHERE kind = ?');
  980. }
  981. const rows = this.stmts.getNodesByKind.all(kind) as NodeRow[];
  982. return rows.map(rowToNode);
  983. }
  984. /**
  985. * Stream every node of a kind one at a time (lazy) instead of materializing
  986. * them all like {@link getNodesByKind}. For unbounded kinds (`function`,
  987. * `method`) on a symbol-dense project the full array is gigabytes; the
  988. * dynamic-edge synthesizers only scan-and-filter, so they iterate to keep
  989. * memory O(1) in the node count rather than O(nodes) (#610).
  990. */
  991. *iterateNodesByKind(kind: NodeKind): IterableIterator<Node> {
  992. // Fresh statement per call (not a cached one): an iterator holds an open
  993. // cursor, so a shared statement would conflict across overlapping scans.
  994. const stmt = this.db.prepare('SELECT * FROM nodes WHERE kind = ?');
  995. for (const row of stmt.iterate(kind)) {
  996. yield rowToNode(row as NodeRow);
  997. }
  998. }
  999. /**
  1000. * Get all nodes in the database
  1001. */
  1002. getAllNodes(): Node[] {
  1003. const rows = this.db.prepare('SELECT * FROM nodes').all() as NodeRow[];
  1004. return rows.map(rowToNode);
  1005. }
  1006. /**
  1007. * Stream nodes of one language whose `decorators` JSON array contains
  1008. * `decorator`. The LIKE on the JSON text is a cheap index-free pre-filter
  1009. * (a decorator name can appear as a substring of another), so callers must
  1010. * still exact-check `node.decorators.includes(decorator)`. Exists so the
  1011. * kotlin expect/actual synthesizer never materializes the whole node table
  1012. * the way `getAllNodes().filter(...)` did — that array alone OOM'd Node's
  1013. * default heap on a 2M-node graph (#1212).
  1014. */
  1015. *iterateNodesByLanguageWithDecorator(language: Language, decorator: string): IterableIterator<Node> {
  1016. // Fresh statement per call — an iterator holds an open cursor (see
  1017. // iterateNodesByKind).
  1018. const stmt = this.db.prepare(
  1019. "SELECT * FROM nodes WHERE language = ? AND decorators LIKE '%' || ? || '%'"
  1020. );
  1021. for (const row of stmt.iterate(language, `"${decorator}"`)) {
  1022. yield rowToNode(row as NodeRow);
  1023. }
  1024. }
  1025. /**
  1026. * Distinct languages present in the files table. One indexed aggregate —
  1027. * lets the dynamic-edge synthesizers skip passes for languages the project
  1028. * doesn't contain at all (a Kotlin pass has no work on a pure-C repo), so
  1029. * their cost is zero rather than a full-graph scan that finds nothing (#1212).
  1030. */
  1031. getDistinctFileLanguages(): Set<string> {
  1032. const rows = this.db.prepare('SELECT DISTINCT language FROM files').all() as Array<{ language: string }>;
  1033. return new Set(rows.map((r) => r.language));
  1034. }
  1035. /**
  1036. * Get nodes by exact name match (uses idx_nodes_name index)
  1037. */
  1038. getNodesByName(name: string): Node[] {
  1039. if (!this.stmts.getNodesByName) {
  1040. this.stmts.getNodesByName = this.db.prepare('SELECT * FROM nodes WHERE name = ?');
  1041. }
  1042. const rows = this.stmts.getNodesByName.all(name) as NodeRow[];
  1043. return rows.map(rowToNode);
  1044. }
  1045. /**
  1046. * Nodes whose name starts with `prefix`, by index range scan (a LIKE would
  1047. * skip idx_nodes_name under SQLite's default case-insensitive LIKE).
  1048. */
  1049. getNodesByNamePrefix(prefix: string, limit = 20): Node[] {
  1050. if (!this.stmts.getNodesByNamePrefix) {
  1051. this.stmts.getNodesByNamePrefix = this.db.prepare(
  1052. 'SELECT * FROM nodes WHERE name >= ? AND name < ? ORDER BY name LIMIT ?'
  1053. );
  1054. }
  1055. const rows = this.stmts.getNodesByNamePrefix.all(prefix, prefix + '￿', limit) as NodeRow[];
  1056. return rows.map(rowToNode);
  1057. }
  1058. /**
  1059. * Get nodes by exact qualified name match (uses idx_nodes_qualified_name index)
  1060. */
  1061. getNodesByQualifiedNameExact(qualifiedName: string): Node[] {
  1062. if (!this.stmts.getNodesByQualifiedNameExact) {
  1063. this.stmts.getNodesByQualifiedNameExact = this.db.prepare(
  1064. 'SELECT * FROM nodes WHERE qualified_name = ?'
  1065. );
  1066. }
  1067. const rows = this.stmts.getNodesByQualifiedNameExact.all(qualifiedName) as NodeRow[];
  1068. return rows.map(rowToNode);
  1069. }
  1070. /**
  1071. * Get nodes by lowercase name match (uses idx_nodes_lower_name expression index)
  1072. */
  1073. getNodesByLowerName(lowerName: string): Node[] {
  1074. if (!this.stmts.getNodesByLowerName) {
  1075. this.stmts.getNodesByLowerName = this.db.prepare(
  1076. 'SELECT * FROM nodes WHERE lower(name) = ?'
  1077. );
  1078. }
  1079. const rows = this.stmts.getNodesByLowerName.all(lowerName) as NodeRow[];
  1080. return rows.map(rowToNode);
  1081. }
  1082. /**
  1083. * Search nodes by name using FTS with fallback to LIKE for better matching
  1084. *
  1085. * Search strategy:
  1086. * 1. Try FTS5 prefix match (query*) for word-start matching
  1087. * 2. If no results, try LIKE for substring matching (e.g., "signIn" finds "signInWithGoogle")
  1088. * 3. Score results based on match quality
  1089. */
  1090. searchNodes(query: string, options: SearchOptions = {}): SearchResult[] {
  1091. const { limit = 100, offset = 0 } = options;
  1092. // Parse field-qualified bits out of the raw query (kind:, lang:,
  1093. // path:, name:). Anything not recognised stays in `text` and goes
  1094. // to FTS unchanged. Filters compose with the SearchOptions arg —
  1095. // both are applied (intersection-style).
  1096. const parsed = parseQuery(query);
  1097. const mergedKinds =
  1098. parsed.kinds.length > 0
  1099. ? Array.from(new Set([...(options.kinds ?? []), ...parsed.kinds]))
  1100. : options.kinds;
  1101. const mergedLanguages =
  1102. parsed.languages.length > 0
  1103. ? Array.from(new Set([...(options.languages ?? []), ...parsed.languages]))
  1104. : options.languages;
  1105. const pathFilters = parsed.pathFilters;
  1106. const nameFilters = parsed.nameFilters;
  1107. // The text portion drives FTS/LIKE; if all the user typed was
  1108. // filters (`kind:function`), we still need *some* candidate set,
  1109. // so synthesise an empty-text path that returns everything matching
  1110. // the filters.
  1111. const text = parsed.text;
  1112. const kinds = mergedKinds;
  1113. const languages = mergedLanguages;
  1114. // First try FTS5 with prefix matching
  1115. let results = text
  1116. ? this.searchNodesFTS(text, { kinds, languages, limit, offset })
  1117. // Over-fetch by 5× when running filter-only (no text). The
  1118. // post-scoring path: + name: filters can be very selective, so
  1119. // a smaller multiplier risks returning fewer than `limit`
  1120. // results despite the DB having plenty of matches.
  1121. : this.searchAllByFilters({ kinds, languages, limit: limit * 5 });
  1122. // If no FTS results, try LIKE-based substring search
  1123. if (results.length === 0 && text.length >= 2) {
  1124. results = this.searchNodesLike(text, { kinds, languages, limit, offset });
  1125. }
  1126. // Final fuzzy fallback: scan all known names and keep those within
  1127. // a tight Levenshtein distance. Only fires when both FTS and LIKE
  1128. // returned nothing AND there's a text portion long enough to be
  1129. // worth fuzzing (1-char queries would match too much).
  1130. if (results.length === 0 && text.length >= 3) {
  1131. results = this.searchNodesFuzzy(text, { kinds, languages, limit });
  1132. }
  1133. // Supplement: ensure exact name matches are always candidates.
  1134. // BM25 can bury short exact-match names (e.g. "getBean") under hundreds of
  1135. // compound names (e.g. "getBeanDescriptor") in large codebases,
  1136. // pushing them past the FTS fetch limit before post-hoc scoring can help.
  1137. // Use the max BM25 score as the base so the nameMatchBonus (exact=30 vs
  1138. // prefix=20) actually differentiates them after rescoring.
  1139. if (results.length > 0 && query) {
  1140. const existingIds = new Set(results.map(r => r.node.id));
  1141. const maxFtsScore = Math.max(...results.map(r => r.score));
  1142. const terms = query.split(/\s+/).filter(t => t.length >= 2);
  1143. for (const term of terms) {
  1144. let sql = 'SELECT * FROM nodes WHERE name = ? COLLATE NOCASE';
  1145. const params: (string | number)[] = [term];
  1146. if (kinds && kinds.length > 0) {
  1147. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1148. params.push(...kinds);
  1149. }
  1150. if (languages && languages.length > 0) {
  1151. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  1152. params.push(...languages);
  1153. }
  1154. sql += ' LIMIT 20';
  1155. const rows = this.db.prepare(sql).all(...params) as NodeRow[];
  1156. for (const row of rows) {
  1157. if (!existingIds.has(row.id)) {
  1158. results.push({ node: rowToNode(row), score: maxFtsScore });
  1159. existingIds.add(row.id);
  1160. }
  1161. }
  1162. }
  1163. }
  1164. // Apply multi-signal scoring
  1165. if (results.length > 0 && (text || query)) {
  1166. const scoringQuery = text || query;
  1167. results = results.map(r => ({
  1168. ...r,
  1169. score: r.score
  1170. + kindBonus(r.node.kind)
  1171. + scorePathRelevance(r.node.filePath, scoringQuery, this.projectNameTokens)
  1172. + nameMatchBonus(r.node.name, scoringQuery),
  1173. }));
  1174. results.sort((a, b) => b.score - a.score);
  1175. // Trim to requested limit after rescoring
  1176. if (results.length > limit) {
  1177. results = results.slice(0, limit);
  1178. }
  1179. }
  1180. // Apply path: + name: filters AFTER scoring. Scoring already uses
  1181. // path/name as a soft signal; the explicit filters here are a hard
  1182. // gate. Done last so the FTS limit fetched plenty of candidates to
  1183. // narrow from.
  1184. if (pathFilters.length > 0) {
  1185. const lowered = pathFilters.map((p) => p.toLowerCase());
  1186. results = results.filter((r) => {
  1187. const fp = r.node.filePath.toLowerCase();
  1188. return lowered.some((p) => fp.includes(p));
  1189. });
  1190. }
  1191. if (nameFilters.length > 0) {
  1192. const lowered = nameFilters.map((n) => n.toLowerCase());
  1193. results = results.filter((r) => {
  1194. const nm = r.node.name.toLowerCase();
  1195. return lowered.some((n) => nm.includes(n));
  1196. });
  1197. }
  1198. return results;
  1199. }
  1200. /**
  1201. * Match-everything path used when the user supplied only field
  1202. * filters (`kind:function lang:typescript`) with no text. Returns
  1203. * candidates ordered by name; the caller's filter pass narrows to
  1204. * what was asked for.
  1205. */
  1206. private searchAllByFilters(options: {
  1207. kinds?: NodeKind[];
  1208. languages?: Language[];
  1209. limit: number;
  1210. }): SearchResult[] {
  1211. const { kinds, languages, limit } = options;
  1212. let sql = 'SELECT * FROM nodes WHERE 1=1';
  1213. const params: (string | number)[] = [];
  1214. if (kinds && kinds.length > 0) {
  1215. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1216. params.push(...kinds);
  1217. }
  1218. if (languages && languages.length > 0) {
  1219. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  1220. params.push(...languages);
  1221. }
  1222. sql += ' ORDER BY name LIMIT ?';
  1223. params.push(limit);
  1224. const rows = this.db.prepare(sql).all(...params) as NodeRow[];
  1225. return rows.map((row) => ({ node: rowToNode(row), score: 1 }));
  1226. }
  1227. /**
  1228. * Fuzzy fallback: when zero FTS/LIKE hits, try an edit-distance
  1229. * sweep over the distinct symbol-name set. Caps `maxDist` at 2 so
  1230. * `getUssr` finds `getUser` but `process` doesn't match `prosody`.
  1231. * Bounded edit distance keeps each comparison cheap; the per-query
  1232. * scan is O(distinct-name-count) which is far smaller than total
  1233. * node count on any real codebase.
  1234. */
  1235. private searchNodesFuzzy(
  1236. text: string,
  1237. options: { kinds?: NodeKind[]; languages?: Language[]; limit: number }
  1238. ): SearchResult[] {
  1239. const { kinds, languages, limit } = options;
  1240. const lowered = text.toLowerCase();
  1241. const maxDist = lowered.length <= 4 ? 1 : 2;
  1242. // Pull the distinct name list once. The set is cached on QueryBuilder
  1243. // by getAllNodeNames(); even on a 200k-node project the distinct
  1244. // name set is typically O(10k) because most names repeat. The
  1245. // candidate-cap below bounds memory regardless.
  1246. const allNames = this.getAllNodeNames();
  1247. const candidates: Array<{ name: string; dist: number }> = [];
  1248. for (const name of allNames) {
  1249. const dist = boundedEditDistance(name.toLowerCase(), lowered, maxDist);
  1250. if (dist <= maxDist) candidates.push({ name, dist });
  1251. }
  1252. candidates.sort((a, b) => a.dist - b.dist);
  1253. // Cap the per-name follow-up queries. Each survivor triggers a
  1254. // separate `SELECT * FROM nodes WHERE name = ?`; without this cap
  1255. // a project with many similar names (`getUser1`, `getUser2`...)
  1256. // could fan out far beyond `limit` queries before the inner-loop
  1257. // limit kicks in.
  1258. const FUZZY_FOLLOWUP_CAP = Math.max(limit * 2, 50);
  1259. const cappedCandidates = candidates.slice(0, FUZZY_FOLLOWUP_CAP);
  1260. const results: SearchResult[] = [];
  1261. const seen = new Set<string>();
  1262. for (const c of cappedCandidates) {
  1263. if (results.length >= limit) break;
  1264. let sql = 'SELECT * FROM nodes WHERE name = ?';
  1265. const params: (string | number)[] = [c.name];
  1266. if (kinds && kinds.length > 0) {
  1267. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1268. params.push(...kinds);
  1269. }
  1270. if (languages && languages.length > 0) {
  1271. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  1272. params.push(...languages);
  1273. }
  1274. sql += ' LIMIT 5';
  1275. const rows = this.db.prepare(sql).all(...params) as NodeRow[];
  1276. for (const row of rows) {
  1277. if (seen.has(row.id)) continue;
  1278. seen.add(row.id);
  1279. // Lower the score for each edit step away from the query so
  1280. // exact-match fallbacks (dist 0) outrank dist-2 typos.
  1281. results.push({ node: rowToNode(row), score: 1 / (1 + c.dist) });
  1282. if (results.length >= limit) break;
  1283. }
  1284. }
  1285. return results;
  1286. }
  1287. /**
  1288. * FTS5 search with prefix matching
  1289. */
  1290. private searchNodesFTS(query: string, options: SearchOptions): SearchResult[] {
  1291. const { kinds, languages, limit = 100, offset = 0 } = options;
  1292. // Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate")
  1293. // Escape special FTS5 characters and add prefix wildcard.
  1294. //
  1295. // `::` is a qualifier separator in Rust/C++/Ruby, not a token char,
  1296. // so treat it as whitespace before the strip step. Otherwise queries
  1297. // like `stage_apply::run` collapse to `stage_applyrun` (the colons
  1298. // are stripped without splitting) and find nothing. See #173.
  1299. const ftsQuery = query
  1300. .replace(/::/g, ' ') // Rust/C++/Ruby qualifier separator
  1301. .replace(/['"*():^]/g, '') // Remove FTS5 special chars
  1302. .split(/\s+/)
  1303. .filter(term => term.length > 0)
  1304. // Strip FTS5 boolean operators to prevent query manipulation
  1305. .filter(term => !/^(AND|OR|NOT|NEAR)$/i.test(term))
  1306. .map(term => `"${term}"*`) // Prefix match each term
  1307. .join(' OR ');
  1308. if (!ftsQuery) {
  1309. return [];
  1310. }
  1311. // BM25 column weights: id=0, name=20, qualified_name=5, docstring=1, signature=2
  1312. // Heavy name weight ensures exact/prefix name matches rank above incidental
  1313. // mentions in long docstrings or qualified names of nested symbols.
  1314. // Fetch 5x requested limit so post-hoc rescoring (kindBonus, pathRelevance,
  1315. // nameMatchBonus) can promote results that BM25 alone undervalues.
  1316. const ftsLimit = Math.max(limit * 5, 100);
  1317. let sql = `
  1318. SELECT nodes.*, bm25(nodes_fts, 0, 20, 5, 1, 2) as score
  1319. FROM nodes_fts
  1320. JOIN nodes ON nodes_fts.id = nodes.id
  1321. WHERE nodes_fts MATCH ?
  1322. `;
  1323. const params: (string | number)[] = [ftsQuery];
  1324. if (kinds && kinds.length > 0) {
  1325. sql += ` AND nodes.kind IN (${kinds.map(() => '?').join(',')})`;
  1326. params.push(...kinds);
  1327. }
  1328. if (languages && languages.length > 0) {
  1329. sql += ` AND nodes.language IN (${languages.map(() => '?').join(',')})`;
  1330. params.push(...languages);
  1331. }
  1332. sql += ' ORDER BY score LIMIT ? OFFSET ?';
  1333. params.push(ftsLimit, offset);
  1334. try {
  1335. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  1336. return rows.map((row) => ({
  1337. node: rowToNode(row),
  1338. score: Math.abs(row.score), // bm25 returns negative scores
  1339. }));
  1340. } catch {
  1341. // FTS query failed, return empty
  1342. return [];
  1343. }
  1344. }
  1345. /**
  1346. * LIKE-based substring search for cases where FTS doesn't match
  1347. * Useful for camelCase matching (e.g., "signIn" finds "signInWithGoogle")
  1348. */
  1349. private searchNodesLike(query: string, options: SearchOptions): SearchResult[] {
  1350. const { kinds, languages, limit = 100, offset = 0 } = options;
  1351. let sql = `
  1352. SELECT nodes.*,
  1353. CASE
  1354. WHEN name = ? THEN 1.0
  1355. WHEN name LIKE ? THEN 0.9
  1356. WHEN name LIKE ? THEN 0.8
  1357. WHEN qualified_name LIKE ? THEN 0.7
  1358. ELSE 0.5
  1359. END as score
  1360. FROM nodes
  1361. WHERE (
  1362. name LIKE ? OR
  1363. qualified_name LIKE ? OR
  1364. name LIKE ?
  1365. )
  1366. `;
  1367. // Pattern variants for better matching
  1368. const exactMatch = query;
  1369. const startsWith = `${query}%`;
  1370. const contains = `%${query}%`;
  1371. const params: (string | number)[] = [
  1372. exactMatch, // Exact match score
  1373. startsWith, // Starts with score
  1374. contains, // Contains score
  1375. contains, // Qualified name score
  1376. contains, // WHERE: name contains
  1377. contains, // WHERE: qualified_name contains
  1378. startsWith, // WHERE: name starts with
  1379. ];
  1380. if (kinds && kinds.length > 0) {
  1381. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1382. params.push(...kinds);
  1383. }
  1384. if (languages && languages.length > 0) {
  1385. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  1386. params.push(...languages);
  1387. }
  1388. sql += ' ORDER BY score DESC, length(name) ASC LIMIT ? OFFSET ?';
  1389. params.push(limit, offset);
  1390. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  1391. return rows.map((row) => ({
  1392. node: rowToNode(row),
  1393. score: row.score,
  1394. }));
  1395. }
  1396. /**
  1397. * Find nodes by exact name match
  1398. *
  1399. * Used for hybrid search - looks up symbols by exact name or case-insensitive match.
  1400. * Returns high-confidence matches for known symbol names extracted from query.
  1401. *
  1402. * @param names - Array of symbol names to look up
  1403. * @param options - Search options (kinds, languages, limit)
  1404. * @returns SearchResult array with exact matches scored at 1.0
  1405. */
  1406. findNodesByExactName(names: string[], options: SearchOptions = {}): SearchResult[] {
  1407. if (names.length === 0) return [];
  1408. const { kinds, languages, limit = 50 } = options;
  1409. // Two-pass approach to handle common names (e.g., "run" has 40+ matches):
  1410. // Pass 1: Find which files contain distinctive (rare) symbols from the query.
  1411. // Pass 2: Query each name, boosting results that co-locate with distinctive symbols.
  1412. // Pass 1: Find files containing each queried name, identify distinctive names
  1413. const nameToFiles = new Map<string, Set<string>>();
  1414. for (const name of names) {
  1415. let sql = 'SELECT DISTINCT file_path FROM nodes WHERE name COLLATE NOCASE = ?';
  1416. const params: (string | number)[] = [name];
  1417. if (kinds && kinds.length > 0) {
  1418. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1419. params.push(...kinds);
  1420. }
  1421. sql += ' LIMIT 100';
  1422. const rows = this.db.prepare(sql).all(...params) as { file_path: string }[];
  1423. nameToFiles.set(name.toLowerCase(), new Set(rows.map(r => r.file_path)));
  1424. }
  1425. // Distinctive names are those with fewer than 10 file matches (e.g., "scrapeLoop" = 1 file)
  1426. const distinctiveFiles = new Set<string>();
  1427. for (const [, files] of nameToFiles) {
  1428. if (files.size > 0 && files.size < 10) {
  1429. for (const f of files) distinctiveFiles.add(f);
  1430. }
  1431. }
  1432. // Pass 2: Query each name with per-name limit, scoring by co-location
  1433. const perNameLimit = Math.max(8, Math.ceil(limit / names.length));
  1434. const allResults: SearchResult[] = [];
  1435. const seenIds = new Set<string>();
  1436. for (const name of names) {
  1437. let sql = `
  1438. SELECT nodes.*, 1.0 as score
  1439. FROM nodes
  1440. WHERE name COLLATE NOCASE = ?
  1441. `;
  1442. const params: (string | number)[] = [name];
  1443. if (kinds && kinds.length > 0) {
  1444. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1445. params.push(...kinds);
  1446. }
  1447. if (languages && languages.length > 0) {
  1448. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  1449. params.push(...languages);
  1450. }
  1451. // Fetch enough to find co-located results among common names
  1452. sql += ' LIMIT ?';
  1453. params.push(Math.max(perNameLimit * 3, 50));
  1454. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  1455. const nameResults: SearchResult[] = [];
  1456. for (const row of rows) {
  1457. const node = rowToNode(row);
  1458. if (seenIds.has(node.id)) continue;
  1459. // Boost results in files that also contain distinctive symbols
  1460. const coLocationBoost = distinctiveFiles.has(node.filePath) ? 20 : 0;
  1461. nameResults.push({ node, score: row.score + coLocationBoost });
  1462. }
  1463. // Sort by score (co-located first), take per-name limit
  1464. nameResults.sort((a, b) => b.score - a.score);
  1465. for (const r of nameResults.slice(0, perNameLimit)) {
  1466. seenIds.add(r.node.id);
  1467. allResults.push(r);
  1468. }
  1469. }
  1470. // Sort all results by score so co-located results bubble up
  1471. allResults.sort((a, b) => b.score - a.score);
  1472. return allResults.slice(0, limit);
  1473. }
  1474. /**
  1475. * Find nodes whose name contains a substring (LIKE-based).
  1476. * Useful for CamelCase-part matching where FTS fails because
  1477. * e.g. "TransportSearchAction" is one FTS token, not matchable by "Search"*.
  1478. *
  1479. * Results are ordered by name length (shorter = more likely to be the core type).
  1480. */
  1481. findNodesByNameSubstring(
  1482. substring: string,
  1483. options: SearchOptions & { excludePrefix?: boolean } = {}
  1484. ): SearchResult[] {
  1485. const { kinds, languages, limit = 30, excludePrefix } = options;
  1486. let sql = `
  1487. SELECT nodes.*, 1.0 as score
  1488. FROM nodes
  1489. WHERE name LIKE ?
  1490. `;
  1491. const params: (string | number)[] = [`%${substring}%`];
  1492. // Exclude prefix matches (handled by FTS-based prefix search in Step 2b)
  1493. if (excludePrefix) {
  1494. sql += ` AND name NOT LIKE ?`;
  1495. params.push(`${substring}%`);
  1496. }
  1497. if (kinds && kinds.length > 0) {
  1498. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1499. params.push(...kinds);
  1500. }
  1501. if (languages && languages.length > 0) {
  1502. sql += ` AND language IN (${languages.map(() => '?').join(',')})`;
  1503. params.push(...languages);
  1504. }
  1505. sql += ' ORDER BY length(name) ASC LIMIT ?';
  1506. params.push(limit);
  1507. const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[];
  1508. return rows.map((row) => ({
  1509. node: rowToNode(row),
  1510. score: row.score,
  1511. }));
  1512. }
  1513. // ===========================================================================
  1514. // Edge Operations
  1515. // ===========================================================================
  1516. /**
  1517. * Insert a new edge
  1518. */
  1519. insertEdge(edge: Edge): void {
  1520. if (!this.stmts.insertEdge) {
  1521. this.stmts.insertEdge = this.db.prepare(`
  1522. INSERT OR IGNORE INTO edges (source, target, kind, metadata, line, col, provenance)
  1523. VALUES (@source, @target, @kind, @metadata, @line, @col, @provenance)
  1524. `);
  1525. }
  1526. this.stmts.insertEdge.run({
  1527. source: edge.source,
  1528. target: edge.target,
  1529. kind: edge.kind,
  1530. metadata: edge.metadata ? JSON.stringify(edge.metadata) : null,
  1531. line: edge.line ?? null,
  1532. col: edge.column ?? null,
  1533. provenance: edge.provenance ?? null,
  1534. });
  1535. }
  1536. /**
  1537. * Insert multiple edges in a transaction
  1538. */
  1539. insertEdges(edges: Edge[]): void {
  1540. if (edges.length === 0) return;
  1541. this.db.transaction(() => {
  1542. const endpointIds = new Set<string>();
  1543. for (const edge of edges) {
  1544. endpointIds.add(edge.source);
  1545. endpointIds.add(edge.target);
  1546. }
  1547. const existingNodeIds = this.getExistingNodeIds([...endpointIds]);
  1548. const rows: unknown[][] = [];
  1549. for (const edge of edges) {
  1550. if (!existingNodeIds.has(edge.source) || !existingNodeIds.has(edge.target)) {
  1551. continue;
  1552. }
  1553. rows.push([
  1554. edge.source,
  1555. edge.target,
  1556. edge.kind,
  1557. edge.metadata ? JSON.stringify(edge.metadata) : null,
  1558. edge.line ?? null,
  1559. edge.column ?? null,
  1560. edge.provenance ?? null,
  1561. ]);
  1562. }
  1563. this.runBatched(
  1564. 'insertEdges',
  1565. 'INSERT OR IGNORE INTO edges (source, target, kind, metadata, line, col, provenance) VALUES ',
  1566. '(?,?,?,?,?,?,?)',
  1567. rows
  1568. );
  1569. })();
  1570. }
  1571. /**
  1572. * Delete all edges from a source node
  1573. */
  1574. deleteEdgesBySource(sourceId: string): void {
  1575. if (!this.stmts.deleteEdgesBySource) {
  1576. this.stmts.deleteEdgesBySource = this.db.prepare('DELETE FROM edges WHERE source = ?');
  1577. }
  1578. this.stmts.deleteEdgesBySource.run(sourceId);
  1579. }
  1580. /**
  1581. * Get outgoing edges from a node
  1582. */
  1583. getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string): Edge[] {
  1584. if ((kinds && kinds.length > 0) || provenance) {
  1585. let sql = 'SELECT * FROM edges WHERE source = ?';
  1586. const params: (string | number)[] = [sourceId];
  1587. if (kinds && kinds.length > 0) {
  1588. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1589. params.push(...kinds);
  1590. }
  1591. if (provenance) {
  1592. sql += ' AND provenance = ?';
  1593. params.push(provenance);
  1594. }
  1595. const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
  1596. return rows.map(rowToEdge);
  1597. }
  1598. if (!this.stmts.getEdgesBySource) {
  1599. this.stmts.getEdgesBySource = this.db.prepare('SELECT * FROM edges WHERE source = ?');
  1600. }
  1601. const rows = this.stmts.getEdgesBySource.all(sourceId) as EdgeRow[];
  1602. return rows.map(rowToEdge);
  1603. }
  1604. /**
  1605. * Get incoming edges to a node
  1606. */
  1607. getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] {
  1608. if (kinds && kinds.length > 0) {
  1609. const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`;
  1610. const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[];
  1611. return rows.map(rowToEdge);
  1612. }
  1613. if (!this.stmts.getEdgesByTarget) {
  1614. this.stmts.getEdgesByTarget = this.db.prepare('SELECT * FROM edges WHERE target = ?');
  1615. }
  1616. const rows = this.stmts.getEdgesByTarget.all(targetId) as EdgeRow[];
  1617. return rows.map(rowToEdge);
  1618. }
  1619. /**
  1620. * Find all edges where both source and target are in the given node set.
  1621. * Useful for recovering inter-node connectivity after BFS.
  1622. */
  1623. findEdgesBetweenNodes(nodeIds: string[], kinds?: EdgeKind[]): Edge[] {
  1624. if (nodeIds.length === 0) return [];
  1625. const idsJson = JSON.stringify(nodeIds);
  1626. let sql = `SELECT * FROM edges WHERE source IN (SELECT value FROM json_each(?)) AND target IN (SELECT value FROM json_each(?))`;
  1627. const params: string[] = [idsJson, idsJson];
  1628. if (kinds && kinds.length > 0) {
  1629. sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
  1630. params.push(...kinds);
  1631. }
  1632. const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
  1633. return rows.map(rowToEdge);
  1634. }
  1635. /**
  1636. * Distinct file paths that DEPEND ON `filePath`: every file containing a
  1637. * symbol with a cross-file edge (any kind except `contains`) into a symbol
  1638. * of this file. This is the file-level projection of the symbol dependency
  1639. * graph and the basis for blast-radius / `affected` test selection.
  1640. *
  1641. * It deliberately does NOT restrict to `imports` edges. In this graph an
  1642. * `imports` edge connects a file to its own local import declarations
  1643. * (it is always same-file), so an imports-only lookup returns zero
  1644. * cross-file dependents for every file. The real cross-file dependency
  1645. * signal is the resolved call/reference graph — calls, references,
  1646. * instantiates, extends, implements, overrides, type_of, returns,
  1647. * decorates — exactly what {@link GraphTraverser.getImpactRadius} traverses.
  1648. * `contains` is excluded: a parent containing a symbol does not *depend* on
  1649. * it. One indexed query (idx_nodes_file_path + idx_edges_target_kind).
  1650. */
  1651. getDependentFilePaths(filePath: string): string[] {
  1652. const sql = `SELECT DISTINCT src.file_path AS fp
  1653. FROM edges e
  1654. JOIN nodes tgt ON tgt.id = e.target
  1655. JOIN nodes src ON src.id = e.source
  1656. WHERE tgt.file_path = ?
  1657. AND e.kind != 'contains'
  1658. AND src.file_path != ?`;
  1659. const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>;
  1660. return rows.map((r) => r.fp);
  1661. }
  1662. /**
  1663. * Distinct file paths that `filePath` DEPENDS ON — the inverse of
  1664. * {@link getDependentFilePaths}: every file containing a symbol that a
  1665. * symbol of this file has a cross-file edge into. Same edge-kind rules
  1666. * (all kinds except `contains`); same reason imports-only is insufficient.
  1667. */
  1668. getDependencyFilePaths(filePath: string): string[] {
  1669. const sql = `SELECT DISTINCT tgt.file_path AS fp
  1670. FROM edges e
  1671. JOIN nodes src ON src.id = e.source
  1672. JOIN nodes tgt ON tgt.id = e.target
  1673. WHERE src.file_path = ?
  1674. AND e.kind != 'contains'
  1675. AND tgt.file_path != ?`;
  1676. const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>;
  1677. return rows.map((r) => r.fp);
  1678. }
  1679. /**
  1680. * Cross-file edges whose TARGET is a node in `filePath` and whose SOURCE is a
  1681. * node in a *different* file, paired with the target node's (name, kind) so a
  1682. * caller can re-resolve the edge to the re-indexed target's new ID (node IDs
  1683. * are `sha256(filePath:kind:name:line)`, so any line shift in the callee file
  1684. * changes target IDs and a naive re-insert by old ID silently drops them).
  1685. * Used by `storeExtractionResult` to preserve incoming edges across a file
  1686. * re-index (issue #899). Same edge-kind rules as
  1687. * {@link getDependentFilePaths}: all kinds except `contains`.
  1688. */
  1689. getCrossFileIncomingEdgesWithTarget(
  1690. filePath: string
  1691. ): Array<Edge & { targetName: string; targetKind: NodeKind; sourceFilePath: string; sourceLanguage: Language }> {
  1692. const sql = `SELECT e.*, tgt.name AS target_name, tgt.kind AS target_kind,
  1693. src.file_path AS source_file_path, src.language AS source_language
  1694. FROM edges e
  1695. JOIN nodes tgt ON tgt.id = e.target
  1696. JOIN nodes src ON src.id = e.source
  1697. WHERE tgt.file_path = ?
  1698. AND e.kind != 'contains'
  1699. AND src.file_path != ?`;
  1700. const rows = this.db.prepare(sql).all(filePath, filePath) as Array<
  1701. EdgeRow & { target_name: string; target_kind: NodeKind; source_file_path: string; source_language: Language }
  1702. >;
  1703. return rows.map(row => ({
  1704. ...rowToEdge(row),
  1705. targetName: row.target_name,
  1706. targetKind: row.target_kind,
  1707. sourceFilePath: row.source_file_path,
  1708. sourceLanguage: row.source_language,
  1709. }));
  1710. }
  1711. // ===========================================================================
  1712. // File Operations
  1713. // ===========================================================================
  1714. /**
  1715. * Insert or update a file record
  1716. */
  1717. upsertFile(file: FileRecord): void {
  1718. if (!this.stmts.upsertFile) {
  1719. this.stmts.upsertFile = this.db.prepare(`
  1720. INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors)
  1721. VALUES (@path, @contentHash, @language, @size, @modifiedAt, @indexedAt, @nodeCount, @errors)
  1722. ON CONFLICT(path) DO UPDATE SET
  1723. content_hash = @contentHash,
  1724. language = @language,
  1725. size = @size,
  1726. modified_at = @modifiedAt,
  1727. indexed_at = @indexedAt,
  1728. node_count = @nodeCount,
  1729. errors = @errors
  1730. `);
  1731. }
  1732. this.stmts.upsertFile.run({
  1733. path: file.path,
  1734. contentHash: file.contentHash,
  1735. language: file.language,
  1736. size: file.size,
  1737. modifiedAt: file.modifiedAt,
  1738. indexedAt: file.indexedAt,
  1739. nodeCount: file.nodeCount,
  1740. errors: file.errors ? JSON.stringify(file.errors) : null,
  1741. });
  1742. }
  1743. /**
  1744. * Delete a file record and its nodes
  1745. */
  1746. deleteFile(filePath: string): void {
  1747. this.db.transaction(() => {
  1748. this.deleteNodesByFile(filePath);
  1749. if (!this.stmts.deleteFile) {
  1750. this.stmts.deleteFile = this.db.prepare('DELETE FROM files WHERE path = ?');
  1751. }
  1752. this.stmts.deleteFile.run(filePath);
  1753. })();
  1754. }
  1755. /**
  1756. * Get a file record by path
  1757. */
  1758. getFileByPath(filePath: string): FileRecord | null {
  1759. if (!this.stmts.getFileByPath) {
  1760. this.stmts.getFileByPath = this.db.prepare('SELECT * FROM files WHERE path = ?');
  1761. }
  1762. const row = this.stmts.getFileByPath.get(filePath) as FileRow | undefined;
  1763. return row ? rowToFileRecord(row) : null;
  1764. }
  1765. /**
  1766. * Get all tracked files
  1767. */
  1768. getAllFiles(): FileRecord[] {
  1769. if (!this.stmts.getAllFiles) {
  1770. this.stmts.getAllFiles = this.db.prepare('SELECT * FROM files ORDER BY path');
  1771. }
  1772. const rows = this.stmts.getAllFiles.all() as FileRow[];
  1773. return rows.map(rowToFileRecord);
  1774. }
  1775. /**
  1776. * Most recent index timestamp (ms since epoch) across all tracked files, or
  1777. * null when nothing is indexed yet. One indexed aggregate, no per-row scan. (#329)
  1778. */
  1779. getLastIndexedAt(): number | null {
  1780. const row = this.db
  1781. .prepare('SELECT MAX(indexed_at) AS last FROM files')
  1782. .get() as { last: number | null } | undefined;
  1783. return row?.last ?? null;
  1784. }
  1785. /**
  1786. * Get files that need re-indexing (hash changed)
  1787. */
  1788. getStaleFiles(currentHashes: Map<string, string>): FileRecord[] {
  1789. const files = this.getAllFiles();
  1790. return files.filter((f) => {
  1791. const currentHash = currentHashes.get(f.path);
  1792. return currentHash && currentHash !== f.contentHash;
  1793. });
  1794. }
  1795. // ===========================================================================
  1796. // Unresolved References
  1797. // ===========================================================================
  1798. /**
  1799. * Insert an unresolved reference
  1800. */
  1801. insertUnresolvedRef(ref: UnresolvedReference): void {
  1802. if (!this.stmts.insertUnresolved) {
  1803. this.stmts.insertUnresolved = this.db.prepare(`
  1804. INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language)
  1805. VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates, @filePath, @language)
  1806. `);
  1807. }
  1808. this.stmts.insertUnresolved.run({
  1809. fromNodeId: ref.fromNodeId,
  1810. referenceName: ref.referenceName,
  1811. referenceKind: ref.referenceKind,
  1812. line: ref.line,
  1813. col: ref.column,
  1814. candidates: ref.candidates ? JSON.stringify(ref.candidates) : null,
  1815. filePath: ref.filePath ?? '',
  1816. language: ref.language ?? 'unknown',
  1817. });
  1818. }
  1819. /**
  1820. * Insert multiple unresolved references in a transaction
  1821. */
  1822. insertUnresolvedRefsBatch(refs: UnresolvedReference[]): void {
  1823. if (refs.length === 0) return;
  1824. const insert = this.db.transaction(() => {
  1825. const rows: unknown[][] = [];
  1826. for (const ref of refs) {
  1827. rows.push([
  1828. ref.fromNodeId,
  1829. ref.referenceName,
  1830. ref.referenceKind,
  1831. ref.line,
  1832. ref.column,
  1833. ref.candidates ? JSON.stringify(ref.candidates) : null,
  1834. ref.filePath ?? '',
  1835. ref.language ?? 'unknown',
  1836. ]);
  1837. }
  1838. this.runBatched(
  1839. 'insertUnresolvedRefs',
  1840. 'INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language) VALUES ',
  1841. '(?,?,?,?,?,?,?,?)',
  1842. rows
  1843. );
  1844. });
  1845. insert();
  1846. }
  1847. /**
  1848. * Delete unresolved references from a node
  1849. */
  1850. deleteUnresolvedByNode(nodeId: string): void {
  1851. if (!this.stmts.deleteUnresolvedByNode) {
  1852. this.stmts.deleteUnresolvedByNode = this.db.prepare(
  1853. 'DELETE FROM unresolved_refs WHERE from_node_id = ?'
  1854. );
  1855. }
  1856. this.stmts.deleteUnresolvedByNode.run(nodeId);
  1857. }
  1858. /**
  1859. * Get unresolved references by name (for resolution)
  1860. */
  1861. getUnresolvedByName(name: string): UnresolvedReference[] {
  1862. if (!this.stmts.getUnresolvedByName) {
  1863. this.stmts.getUnresolvedByName = this.db.prepare(
  1864. 'SELECT * FROM unresolved_refs WHERE reference_name = ?'
  1865. );
  1866. }
  1867. const rows = this.stmts.getUnresolvedByName.all(name) as UnresolvedRefRow[];
  1868. return rows.map((row) => ({
  1869. fromNodeId: row.from_node_id,
  1870. referenceName: row.reference_name,
  1871. referenceKind: row.reference_kind as EdgeKind,
  1872. line: row.line,
  1873. column: row.col,
  1874. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  1875. filePath: row.file_path,
  1876. language: row.language as Language,
  1877. rowId: row.id,
  1878. }));
  1879. }
  1880. /**
  1881. * Get all unresolved references
  1882. */
  1883. getUnresolvedReferences(): UnresolvedReference[] {
  1884. const rows = this.db.prepare('SELECT * FROM unresolved_refs').all() as UnresolvedRefRow[];
  1885. return rows.map((row) => ({
  1886. fromNodeId: row.from_node_id,
  1887. referenceName: row.reference_name,
  1888. referenceKind: row.reference_kind as EdgeKind,
  1889. line: row.line,
  1890. column: row.col,
  1891. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  1892. filePath: row.file_path,
  1893. language: row.language as Language,
  1894. rowId: row.id,
  1895. }));
  1896. }
  1897. /**
  1898. * Get the count of PENDING (never-attempted) references without loading
  1899. * them into memory. Rows marked status='failed' — attempted by a completed
  1900. * pass, no match — are excluded: they are not outstanding work, only retry
  1901. * candidates for the #1240 sweep, so they must not trip the #1187 orphan
  1902. * sweep or the `status` pending-refs warning.
  1903. */
  1904. getUnresolvedReferencesCount(): number {
  1905. if (!this.stmts.getUnresolvedCount) {
  1906. this.stmts.getUnresolvedCount = this.db.prepare(
  1907. "SELECT COUNT(*) as count FROM unresolved_refs WHERE status = 'pending'"
  1908. );
  1909. }
  1910. const row = this.stmts.getUnresolvedCount.get() as { count: number };
  1911. return row.count;
  1912. }
  1913. /**
  1914. * Get a batch of PENDING unresolved references using LIMIT/OFFSET
  1915. * pagination. Used to process references in bounded memory chunks; failed
  1916. * rows are excluded so the batched drain loop terminates once every row
  1917. * has been attempted.
  1918. */
  1919. getUnresolvedReferencesBatch(offset: number, limit: number): UnresolvedReference[] {
  1920. if (!this.stmts.getUnresolvedBatch) {
  1921. // ORDER BY rowid is load-bearing for the pipelined resolution loop: it
  1922. // prefetches batch k+1 at OFFSET batch_k.length while batch k's rows are
  1923. // still pending, which is only exact under a stable enumeration. (A plain
  1924. // scan and the status index both return rowid order anyway — this pins
  1925. // it.)
  1926. this.stmts.getUnresolvedBatch = this.db.prepare(
  1927. "SELECT * FROM unresolved_refs WHERE status = 'pending' ORDER BY rowid LIMIT ? OFFSET ?"
  1928. );
  1929. }
  1930. const rows = this.stmts.getUnresolvedBatch.all(limit, offset) as UnresolvedRefRow[];
  1931. return rows.map((row) => ({
  1932. fromNodeId: row.from_node_id,
  1933. referenceName: row.reference_name,
  1934. referenceKind: row.reference_kind as EdgeKind,
  1935. line: row.line,
  1936. column: row.col,
  1937. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  1938. filePath: row.file_path,
  1939. language: row.language as Language,
  1940. rowId: row.id,
  1941. }));
  1942. }
  1943. /**
  1944. * Keyset variant of {@link getUnresolvedReferencesBatch} for the batched
  1945. * resolution loop: seek past the last-seen row id instead of OFFSET-walking.
  1946. * OFFSET reads re-scan the accumulated failed-row prefix on every batch —
  1947. * O(failed rows) per read, measured at 54.6s of the kernel-scale batch loop
  1948. * (§7a.2) — while the seek is O(batch) forever. `id` is the rowid alias, so
  1949. * the enumeration order is identical to the OFFSET reader's.
  1950. */
  1951. getUnresolvedReferencesBatchAfter(afterRowId: number, limit: number): UnresolvedReference[] {
  1952. if (!this.stmts.getUnresolvedBatchAfter) {
  1953. this.stmts.getUnresolvedBatchAfter = this.db.prepare(
  1954. "SELECT * FROM unresolved_refs WHERE status = 'pending' AND id > ? ORDER BY id LIMIT ?"
  1955. );
  1956. }
  1957. const rows = this.stmts.getUnresolvedBatchAfter.all(afterRowId, limit) as UnresolvedRefRow[];
  1958. return rows.map((row) => ({
  1959. fromNodeId: row.from_node_id,
  1960. referenceName: row.reference_name,
  1961. referenceKind: row.reference_kind as EdgeKind,
  1962. line: row.line,
  1963. column: row.col,
  1964. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  1965. filePath: row.file_path,
  1966. language: row.language as Language,
  1967. rowId: row.id,
  1968. }));
  1969. }
  1970. /**
  1971. * Get all tracked file paths (lightweight — no full FileRecord objects)
  1972. */
  1973. getAllFilePaths(): string[] {
  1974. if (!this.stmts.getAllFilePaths) {
  1975. this.stmts.getAllFilePaths = this.db.prepare('SELECT path FROM files ORDER BY path');
  1976. }
  1977. const rows = this.stmts.getAllFilePaths.all() as Array<{ path: string }>;
  1978. return rows.map((r) => r.path);
  1979. }
  1980. /**
  1981. * Get all distinct node names (lightweight — just name strings for pre-filtering)
  1982. */
  1983. getAllNodeNames(): string[] {
  1984. if (!this.stmts.getAllNodeNames) {
  1985. this.stmts.getAllNodeNames = this.db.prepare('SELECT DISTINCT name FROM nodes');
  1986. }
  1987. const rows = this.stmts.getAllNodeNames.all() as Array<{ name: string }>;
  1988. return rows.map((r) => r.name);
  1989. }
  1990. /**
  1991. * Stream the distinct node names one row at a time — the incremental
  1992. * counterpart to {@link getAllNodeNames} for callers that need to yield
  1993. * to the event loop mid-scan (resolver cache warm-up on multi-million-node
  1994. * indexes). Fresh statement per call: the iterator holds an open cursor.
  1995. */
  1996. *iterateNodeNames(): IterableIterator<string> {
  1997. const stmt = this.db.prepare('SELECT DISTINCT name FROM nodes');
  1998. for (const row of stmt.iterate()) {
  1999. yield (row as { name: string }).name;
  2000. }
  2001. }
  2002. /**
  2003. * Get unresolved references scoped to specific file paths.
  2004. * Uses the idx_unresolved_file_path index for efficient lookup.
  2005. */
  2006. getUnresolvedReferencesByFiles(filePaths: string[]): UnresolvedReference[] {
  2007. if (filePaths.length === 0) return [];
  2008. // Chunk under SQLite's parameter limit: the first sync of a very large repo
  2009. // passes every changed file here, which an unbounded `IN (...)` would bind
  2010. // as one parameter each — exceeding MAX_VARIABLE_NUMBER and aborting with
  2011. // "too many SQL variables". (#540)
  2012. const rows: UnresolvedRefRow[] = [];
  2013. for (let i = 0; i < filePaths.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  2014. const chunk = filePaths.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  2015. const placeholders = chunk.map(() => '?').join(',');
  2016. const chunkRows = this.db
  2017. .prepare(`SELECT * FROM unresolved_refs WHERE status = 'pending' AND file_path IN (${placeholders})`)
  2018. .all(...chunk) as UnresolvedRefRow[];
  2019. rows.push(...chunkRows);
  2020. }
  2021. return rows.map((row) => ({
  2022. fromNodeId: row.from_node_id,
  2023. referenceName: row.reference_name,
  2024. referenceKind: row.reference_kind as EdgeKind,
  2025. line: row.line,
  2026. column: row.col,
  2027. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  2028. filePath: row.file_path,
  2029. language: row.language as Language,
  2030. rowId: row.id,
  2031. }));
  2032. }
  2033. /**
  2034. * Delete all unresolved references (after resolution)
  2035. */
  2036. clearUnresolvedReferences(): void {
  2037. this.db.exec('DELETE FROM unresolved_refs');
  2038. }
  2039. /**
  2040. * Delete resolved references by their IDs
  2041. */
  2042. deleteResolvedReferences(fromNodeIds: string[]): void {
  2043. if (fromNodeIds.length === 0) return;
  2044. // Chunk under SQLite's parameter limit, matching every other IN-list in
  2045. // this file. The internal resolution path uses deleteSpecificResolvedReferences
  2046. // instead, but QueryBuilder is part of the public API, so a library consumer
  2047. // passing more ids than SQLITE_MAX_VARIABLE_NUMBER (32766 on the bundled
  2048. // node:sqlite) would otherwise hit "too many SQL variables". (#540, #1001)
  2049. for (let i = 0; i < fromNodeIds.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  2050. const chunk = fromNodeIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  2051. const placeholders = chunk.map(() => '?').join(',');
  2052. this.db.prepare(`DELETE FROM unresolved_refs WHERE from_node_id IN (${placeholders})`).run(...chunk);
  2053. }
  2054. }
  2055. /**
  2056. * Delete specific resolved references by (fromNodeId, referenceName, referenceKind) tuples.
  2057. * More precise than deleteResolvedReferences — only removes refs that were actually resolved.
  2058. */
  2059. deleteSpecificResolvedReferences(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): number {
  2060. if (refs.length === 0) return 0;
  2061. const stmt = this.db.prepare(
  2062. 'DELETE FROM unresolved_refs WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?'
  2063. );
  2064. // Returns rows actually removed (SQLite `changes`, summed): the batched
  2065. // resolution loop's non-progress guard keys on this — zero removals from
  2066. // a batch that claimed work is the direct runaway signal (§7a.2).
  2067. let changed = 0;
  2068. const deleteMany = this.db.transaction((items: typeof refs) => {
  2069. for (const ref of items) {
  2070. changed += stmt.run(ref.fromNodeId, ref.referenceName, ref.referenceKind).changes;
  2071. }
  2072. });
  2073. deleteMany(refs);
  2074. return changed;
  2075. }
  2076. /**
  2077. * Delete unresolved-ref rows by row id — the precise cleanup for refs a
  2078. * resolution pass actually processed. The key-tuple variant above also
  2079. * deletes SIBLING rows (same caller calling the same callee at other lines)
  2080. * that a later batch hasn't attempted yet, so when a batch boundary split a
  2081. * caller's same-named call sites, the later sites' edges were silently never
  2082. * created (#1269).
  2083. */
  2084. deleteReferencesByRowIds(rowIds: number[]): number {
  2085. if (rowIds.length === 0) return 0;
  2086. // One transaction for all chunks (each chunk was previously its own
  2087. // implicit transaction = its own WAL commit — measurable on 100k+-ref
  2088. // resolution persists), and the full-size chunk statement is cached so
  2089. // repeat calls skip the re-prepare; only the final partial chunk (if any)
  2090. // prepares ad hoc. Returns rows actually removed (summed `changes`) for
  2091. // the batched loop's non-progress guard (§7a.2).
  2092. let changed = 0;
  2093. this.db.transaction(() => {
  2094. for (let i = 0; i < rowIds.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  2095. const chunk = rowIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  2096. if (chunk.length === SQLITE_PARAM_CHUNK_SIZE) {
  2097. if (!this.stmts.deleteRefsByRowIdsFull) {
  2098. const placeholders = new Array(SQLITE_PARAM_CHUNK_SIZE).fill('?').join(',');
  2099. this.stmts.deleteRefsByRowIdsFull = this.db.prepare(
  2100. `DELETE FROM unresolved_refs WHERE id IN (${placeholders})`
  2101. );
  2102. }
  2103. changed += this.stmts.deleteRefsByRowIdsFull.run(...chunk).changes;
  2104. } else {
  2105. const placeholders = chunk.map(() => '?').join(',');
  2106. changed += this.db.prepare(`DELETE FROM unresolved_refs WHERE id IN (${placeholders})`).run(...chunk).changes;
  2107. }
  2108. }
  2109. })();
  2110. return changed;
  2111. }
  2112. /**
  2113. * Mark refs a completed resolution pass could not resolve as status='failed'
  2114. * instead of deleting them (#1240). Failed rows are invisible to the pending
  2115. * count/batch readers (so drain loops and the #1187 orphan sweep still
  2116. * terminate) but stay queryable by name_tail so a later sync can retry them
  2117. * when a changed file introduces a symbol that could satisfy them. name_tail
  2118. * is (re)written here so rows inserted before the v8 migration get their
  2119. * tail the first time they're attempted.
  2120. */
  2121. markReferencesFailed(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): number {
  2122. if (refs.length === 0) return 0;
  2123. const stmt = this.db.prepare(
  2124. "UPDATE unresolved_refs SET status = 'failed', name_tail = ? WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?"
  2125. );
  2126. let changed = 0;
  2127. const markMany = this.db.transaction((items: typeof refs) => {
  2128. for (const ref of items) {
  2129. changed += stmt.run(referenceNameTail(ref.referenceName), ref.fromNodeId, ref.referenceName, ref.referenceKind).changes;
  2130. }
  2131. });
  2132. markMany(refs);
  2133. return changed;
  2134. }
  2135. /**
  2136. * Park refs as status='failed' by row id — the precise counterpart of
  2137. * markReferencesFailed, for the same reason as deleteReferencesByRowIds:
  2138. * the key-tuple variant also flips same-key sibling rows in later batches
  2139. * to 'failed' before they were ever attempted (#1269). Resolution outcome
  2140. * can differ per call site (receiver-type inference reads the ref's line),
  2141. * so a sibling must not inherit this row's failure.
  2142. */
  2143. markReferencesFailedByRowIds(refs: Array<{ rowId: number; referenceName: string }>): number {
  2144. if (refs.length === 0) return 0;
  2145. const stmt = this.db.prepare(
  2146. "UPDATE unresolved_refs SET status = 'failed', name_tail = ? WHERE id = ?"
  2147. );
  2148. let changed = 0;
  2149. const markMany = this.db.transaction((items: typeof refs) => {
  2150. for (const ref of items) {
  2151. changed += stmt.run(referenceNameTail(ref.referenceName), ref.rowId).changes;
  2152. }
  2153. });
  2154. markMany(refs);
  2155. return changed;
  2156. }
  2157. /**
  2158. * Failed refs whose name tail matches one of the given symbol names — the
  2159. * candidates a sync should retry after files carrying those names changed
  2160. * (#1240). Names matching more than `perNameCeiling` failed refs are
  2161. * skipped entirely: at that population a name is external/builtin noise
  2162. * (`get`, `map`, …) that one new definition won't resolve — the same
  2163. * rationale as resolution's AMBIGUOUS_NAME_CEILING (#999) — and retrying an
  2164. * arbitrary subset would be both wasted work and incoherent coverage.
  2165. */
  2166. getRetryableFailedReferences(names: string[], perNameCeiling: number = 500): UnresolvedReference[] {
  2167. if (names.length === 0) return [];
  2168. // Pass 1: per-tail counts, chunked under the SQLite parameter limit.
  2169. const retryNames: string[] = [];
  2170. for (let i = 0; i < names.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  2171. const chunk = names.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  2172. const placeholders = chunk.map(() => '?').join(',');
  2173. const counts = this.db
  2174. .prepare(
  2175. `SELECT name_tail, COUNT(*) as count FROM unresolved_refs WHERE status = 'failed' AND name_tail IN (${placeholders}) GROUP BY name_tail`
  2176. )
  2177. .all(...chunk) as Array<{ name_tail: string; count: number }>;
  2178. for (const row of counts) {
  2179. if (row.count <= perNameCeiling) retryNames.push(row.name_tail);
  2180. }
  2181. }
  2182. if (retryNames.length === 0) return [];
  2183. // Pass 2: load the surviving rows.
  2184. const rows: UnresolvedRefRow[] = [];
  2185. for (let i = 0; i < retryNames.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  2186. const chunk = retryNames.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  2187. const placeholders = chunk.map(() => '?').join(',');
  2188. const chunkRows = this.db
  2189. .prepare(`SELECT * FROM unresolved_refs WHERE status = 'failed' AND name_tail IN (${placeholders})`)
  2190. .all(...chunk) as UnresolvedRefRow[];
  2191. rows.push(...chunkRows);
  2192. }
  2193. return rows.map((row) => ({
  2194. fromNodeId: row.from_node_id,
  2195. referenceName: row.reference_name,
  2196. referenceKind: row.reference_kind as EdgeKind,
  2197. line: row.line,
  2198. column: row.col,
  2199. candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
  2200. filePath: row.file_path,
  2201. language: row.language as Language,
  2202. rowId: row.id,
  2203. }));
  2204. }
  2205. /**
  2206. * Distinct node names present in the given files — the symbol names a sync
  2207. * pass uses to look up retryable failed refs after those files changed.
  2208. */
  2209. getNodeNamesByFiles(filePaths: string[]): string[] {
  2210. if (filePaths.length === 0) return [];
  2211. const names = new Set<string>();
  2212. for (let i = 0; i < filePaths.length; i += SQLITE_PARAM_CHUNK_SIZE) {
  2213. const chunk = filePaths.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
  2214. const placeholders = chunk.map(() => '?').join(',');
  2215. const rows = this.db
  2216. .prepare(`SELECT DISTINCT name FROM nodes WHERE file_path IN (${placeholders})`)
  2217. .all(...chunk) as Array<{ name: string }>;
  2218. for (const row of rows) names.add(row.name);
  2219. }
  2220. return [...names];
  2221. }
  2222. // ===========================================================================
  2223. // Statistics
  2224. // ===========================================================================
  2225. /**
  2226. * Lightweight (nodes, edges) count snapshot. Used around an index/sync
  2227. * run to compute true additions across extraction + resolution +
  2228. * synthesis — the per-phase counter in the orchestrator only sees
  2229. * extraction's contribution, which is why the CLI summary under-reported
  2230. * the edge count (resolution + synthesizer edges were invisible).
  2231. */
  2232. getNodeAndEdgeCount(): { nodes: number; edges: number } {
  2233. return this.db
  2234. .prepare('SELECT (SELECT COUNT(*) FROM nodes) AS nodes, (SELECT COUNT(*) FROM edges) AS edges')
  2235. .get() as { nodes: number; edges: number };
  2236. }
  2237. /**
  2238. * Get graph statistics
  2239. */
  2240. getStats(): GraphStats {
  2241. // Single query for all three aggregate counts
  2242. const counts = this.db.prepare(`
  2243. SELECT
  2244. (SELECT COUNT(*) FROM nodes) AS node_count,
  2245. (SELECT COUNT(*) FROM edges) AS edge_count,
  2246. (SELECT COUNT(*) FROM files) AS file_count
  2247. `).get() as { node_count: number; edge_count: number; file_count: number };
  2248. const nodesByKind = {} as Record<NodeKind, number>;
  2249. const nodeKindRows = this.db
  2250. .prepare('SELECT kind, COUNT(*) as count FROM nodes GROUP BY kind')
  2251. .all() as Array<{ kind: string; count: number }>;
  2252. for (const row of nodeKindRows) {
  2253. nodesByKind[row.kind as NodeKind] = row.count;
  2254. }
  2255. const edgesByKind = {} as Record<EdgeKind, number>;
  2256. const edgeKindRows = this.db
  2257. .prepare('SELECT kind, COUNT(*) as count FROM edges GROUP BY kind')
  2258. .all() as Array<{ kind: string; count: number }>;
  2259. for (const row of edgeKindRows) {
  2260. edgesByKind[row.kind as EdgeKind] = row.count;
  2261. }
  2262. const filesByLanguage = {} as Record<Language, number>;
  2263. const languageRows = this.db
  2264. .prepare('SELECT language, COUNT(*) as count FROM files GROUP BY language')
  2265. .all() as Array<{ language: string; count: number }>;
  2266. for (const row of languageRows) {
  2267. filesByLanguage[row.language as Language] = row.count;
  2268. }
  2269. return {
  2270. nodeCount: counts.node_count,
  2271. edgeCount: counts.edge_count,
  2272. fileCount: counts.file_count,
  2273. nodesByKind,
  2274. edgesByKind,
  2275. filesByLanguage,
  2276. dbSizeBytes: 0, // Set by caller using DatabaseConnection.getSize()
  2277. walSizeBytes: 0, // Set by caller using DatabaseConnection.getWalSizeBytes()
  2278. lastUpdated: Date.now(),
  2279. };
  2280. }
  2281. // ===========================================================================
  2282. // Project Metadata
  2283. // ===========================================================================
  2284. /**
  2285. * Get a metadata value by key
  2286. */
  2287. getMetadata(key: string): string | null {
  2288. const row = this.db.prepare('SELECT value FROM project_metadata WHERE key = ?').get(key) as { value: string } | undefined;
  2289. return row?.value ?? null;
  2290. }
  2291. /**
  2292. * Set a metadata key-value pair (upsert)
  2293. */
  2294. setMetadata(key: string, value: string): void {
  2295. this.db.prepare(
  2296. 'INSERT INTO project_metadata (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at'
  2297. ).run(key, value, Date.now());
  2298. }
  2299. /**
  2300. * Get all metadata as a key-value record
  2301. */
  2302. getAllMetadata(): Record<string, string> {
  2303. const rows = this.db.prepare('SELECT key, value FROM project_metadata').all() as { key: string; value: string }[];
  2304. const result: Record<string, string> = {};
  2305. for (const row of rows) {
  2306. result[row.key] = row.value;
  2307. }
  2308. return result;
  2309. }
  2310. /**
  2311. * Clear all data from the database
  2312. */
  2313. clear(): void {
  2314. this.nodeCache.clear();
  2315. this.db.transaction(() => {
  2316. this.db.exec('DELETE FROM unresolved_refs');
  2317. this.db.exec('DELETE FROM edges');
  2318. this.db.exec('DELETE FROM nodes');
  2319. this.db.exec('DELETE FROM files');
  2320. })();
  2321. }
  2322. }