codegraph-py 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. codegraph/__init__.py +12 -0
  2. codegraph/__main__.py +6 -0
  3. codegraph/cli.py +911 -0
  4. codegraph/codegraph.py +618 -0
  5. codegraph/context/__init__.py +216 -0
  6. codegraph/db/__init__.py +11 -0
  7. codegraph/db/connection.py +163 -0
  8. codegraph/db/queries.py +752 -0
  9. codegraph/db/schema.sql +151 -0
  10. codegraph/directory.py +160 -0
  11. codegraph/errors.py +101 -0
  12. codegraph/extraction/__init__.py +956 -0
  13. codegraph/extraction/languages/__init__.py +64 -0
  14. codegraph/extraction/languages/base.py +132 -0
  15. codegraph/extraction/languages/c_cfg.py +53 -0
  16. codegraph/extraction/languages/cpp_cfg.py +60 -0
  17. codegraph/extraction/languages/dart_cfg.py +53 -0
  18. codegraph/extraction/languages/go_cfg.py +70 -0
  19. codegraph/extraction/languages/java_cfg.py +62 -0
  20. codegraph/extraction/languages/javascript_cfg.py +84 -0
  21. codegraph/extraction/languages/kotlin_cfg.py +52 -0
  22. codegraph/extraction/languages/lua_cfg.py +65 -0
  23. codegraph/extraction/languages/python_cfg.py +75 -0
  24. codegraph/extraction/languages/ruby_cfg.py +48 -0
  25. codegraph/extraction/languages/rust_cfg.py +68 -0
  26. codegraph/extraction/languages/scala_cfg.py +59 -0
  27. codegraph/extraction/languages/swift_cfg.py +64 -0
  28. codegraph/extraction/languages/typescript_cfg.py +89 -0
  29. codegraph/extraction/tree_sitter_extractor.py +689 -0
  30. codegraph/graph/__init__.py +685 -0
  31. codegraph/installer/__init__.py +6 -0
  32. codegraph/mcp/__init__.py +668 -0
  33. codegraph/project_config.py +191 -0
  34. codegraph/resolution/__init__.py +337 -0
  35. codegraph/search/__init__.py +653 -0
  36. codegraph/sync/__init__.py +204 -0
  37. codegraph/types.py +334 -0
  38. codegraph/ui/__init__.py +11 -0
  39. codegraph/utils.py +218 -0
  40. codegraph_py-1.0.0.dist-info/METADATA +238 -0
  41. codegraph_py-1.0.0.dist-info/RECORD +44 -0
  42. codegraph_py-1.0.0.dist-info/WHEEL +5 -0
  43. codegraph_py-1.0.0.dist-info/entry_points.txt +2 -0
  44. codegraph_py-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,752 @@
1
+ """
2
+ CodeGraph Query Builder
3
+
4
+ All database CRUD operations and query methods.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import json
11
+ import sqlite3
12
+ import time
13
+ from typing import Any, Dict, List, Optional, Set, Tuple
14
+ from collections import OrderedDict
15
+
16
+ from codegraph.types import (
17
+ Node, Edge, FileRecord, UnresolvedReference,
18
+ SearchResult, SearchOptions, GraphStats,
19
+ )
20
+ from codegraph.errors import DatabaseError
21
+
22
+
23
+ class QueryBuilder:
24
+ """Build and execute database queries."""
25
+
26
+ def __init__(self, db: sqlite3.Connection, db_path: str = ''):
27
+ self._db = db
28
+ self._db_path_value = db_path
29
+ self._node_cache: Dict[str, Node] = OrderedDict()
30
+ self._cache_max = 1000
31
+ self._project_name_tokens: List[str] = []
32
+
33
+ def set_project_name_tokens(self, tokens: List[str]) -> None:
34
+ """Set project name tokens for search ranking."""
35
+ self._project_name_tokens = tokens
36
+
37
+ # =========================================================================
38
+ # Node Operations
39
+ # =========================================================================
40
+
41
+ def insert_node(self, node: Node) -> None:
42
+ """Insert or replace a node."""
43
+ self._db.execute(
44
+ '''INSERT OR REPLACE INTO nodes
45
+ (id, kind, name, qualified_name, file_path, language,
46
+ start_line, end_line, start_column, end_column,
47
+ docstring, signature, visibility,
48
+ is_exported, is_async, is_static, is_abstract,
49
+ decorators, type_parameters, return_type, updated_at)
50
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
51
+ (
52
+ node.id, node.kind, node.name, node.qualified_name,
53
+ node.file_path, node.language,
54
+ node.start_line, node.end_line,
55
+ node.start_column, node.end_column,
56
+ node.docstring, node.signature, node.visibility,
57
+ 1 if node.is_exported else 0,
58
+ 1 if node.is_async else 0,
59
+ 1 if node.is_static else 0,
60
+ 1 if node.is_abstract else 0,
61
+ json.dumps(node.decorators) if node.decorators else None,
62
+ json.dumps(node.type_parameters) if node.type_parameters else None,
63
+ node.return_type,
64
+ node.updated_at or int(time.time() * 1000),
65
+ )
66
+ )
67
+
68
+ def insert_nodes(self, nodes: List[Node]) -> None:
69
+ """Insert multiple nodes in a transaction."""
70
+ with self._db:
71
+ for node in nodes:
72
+ self.insert_node(node)
73
+
74
+ def update_node(self, node: Node) -> None:
75
+ """Update an existing node."""
76
+ self._db.execute(
77
+ '''UPDATE nodes SET
78
+ kind=?, name=?, qualified_name=?, file_path=?, language=?,
79
+ start_line=?, end_line=?, start_column=?, end_column=?,
80
+ docstring=?, signature=?, visibility=?,
81
+ is_exported=?, is_async=?, is_static=?, is_abstract=?,
82
+ decorators=?, type_parameters=?, return_type=?, updated_at=?
83
+ WHERE id=?''',
84
+ (
85
+ node.kind, node.name, node.qualified_name,
86
+ node.file_path, node.language,
87
+ node.start_line, node.end_line,
88
+ node.start_column, node.end_column,
89
+ node.docstring, node.signature, node.visibility,
90
+ 1 if node.is_exported else 0,
91
+ 1 if node.is_async else 0,
92
+ 1 if node.is_static else 0,
93
+ 1 if node.is_abstract else 0,
94
+ json.dumps(node.decorators) if node.decorators else None,
95
+ json.dumps(node.type_parameters) if node.type_parameters else None,
96
+ node.return_type,
97
+ node.updated_at or int(time.time() * 1000),
98
+ node.id,
99
+ )
100
+ )
101
+
102
+ def delete_node(self, node_id: str) -> None:
103
+ """Delete a node by ID."""
104
+ self._db.execute('DELETE FROM nodes WHERE id = ?', (node_id,))
105
+ self._node_cache.pop(node_id, None)
106
+
107
+ def delete_nodes_by_file(self, file_path: str) -> None:
108
+ """Delete all nodes in a file."""
109
+ self._db.execute('DELETE FROM nodes WHERE file_path = ?', (file_path,))
110
+
111
+ def get_node_by_id(self, node_id: str) -> Optional[Node]:
112
+ """Get a node by its ID (with LRU cache)."""
113
+ # Check cache
114
+ if node_id in self._node_cache:
115
+ node = self._node_cache.pop(node_id)
116
+ self._node_cache[node_id] = node
117
+ return node
118
+
119
+ cur = self._db.execute('SELECT * FROM nodes WHERE id = ?', (node_id,))
120
+ row = cur.fetchone()
121
+ if not row:
122
+ return None
123
+
124
+ node = self._row_to_node(row)
125
+
126
+ # Update cache
127
+ self._node_cache[node_id] = node
128
+ if len(self._node_cache) > self._cache_max:
129
+ self._node_cache.pop(next(iter(self._node_cache)), None)
130
+
131
+ return node
132
+
133
+ def get_nodes_by_ids(self, node_ids: List[str]) -> List[Node]:
134
+ """Get multiple nodes by their IDs."""
135
+ if not node_ids:
136
+ return []
137
+
138
+ # Check cache first
139
+ result: List[Node] = []
140
+ uncached: List[str] = []
141
+ for nid in node_ids:
142
+ if nid in self._node_cache:
143
+ result.append(self._node_cache[nid])
144
+ else:
145
+ uncached.append(nid)
146
+
147
+ if uncached:
148
+ placeholders = ','.join('?' * len(uncached))
149
+ cur = self._db.execute(
150
+ f'SELECT * FROM nodes WHERE id IN ({placeholders})', uncached
151
+ )
152
+ for row in cur.fetchall():
153
+ node = self._row_to_node(row)
154
+ result.append(node)
155
+ self._node_cache[node.id] = node
156
+
157
+ return result
158
+
159
+ def get_nodes_by_file(self, file_path: str) -> List[Node]:
160
+ """Get all nodes in a file, ordered by start line."""
161
+ cur = self._db.execute(
162
+ 'SELECT * FROM nodes WHERE file_path = ? ORDER BY start_line',
163
+ (file_path,)
164
+ )
165
+ return [self._row_to_node(row) for row in cur.fetchall()]
166
+
167
+ def get_nodes_by_kind(self, kind: str) -> List[Node]:
168
+ """Get all nodes of a specific kind."""
169
+ cur = self._db.execute(
170
+ 'SELECT * FROM nodes WHERE kind = ? ORDER BY name', (kind,)
171
+ )
172
+ return [self._row_to_node(row) for row in cur.fetchall()]
173
+
174
+ def get_nodes_by_name(self, name: str) -> List[Node]:
175
+ """Get nodes by exact name match."""
176
+ cur = self._db.execute(
177
+ 'SELECT * FROM nodes WHERE name = ? ORDER BY file_path, start_line',
178
+ (name,)
179
+ )
180
+ return [self._row_to_node(row) for row in cur.fetchall()]
181
+
182
+ def get_nodes_by_name_prefix(self, prefix: str) -> List[Node]:
183
+ """Get nodes by name prefix (range scan)."""
184
+ end = prefix + '\uffff'
185
+ cur = self._db.execute(
186
+ 'SELECT * FROM nodes WHERE name >= ? AND name < ? ORDER BY name',
187
+ (prefix, end)
188
+ )
189
+ return [self._row_to_node(row) for row in cur.fetchall()]
190
+
191
+ def get_nodes_by_qualified_name(self, qname: str) -> List[Node]:
192
+ """Get nodes by exact qualified name."""
193
+ cur = self._db.execute(
194
+ 'SELECT * FROM nodes WHERE qualified_name = ?', (qname,)
195
+ )
196
+ return [self._row_to_node(row) for row in cur.fetchall()]
197
+
198
+ # =========================================================================
199
+ # Edge Operations
200
+ # =========================================================================
201
+
202
+ def insert_edge(self, edge: Edge) -> None:
203
+ """Insert an edge (with conflict ignore and FK error handling)."""
204
+ try:
205
+ self._db.execute(
206
+ '''INSERT OR IGNORE INTO edges
207
+ (source, target, kind, metadata, line, col, provenance)
208
+ VALUES (?, ?, ?, ?, ?, ?, ?)''',
209
+ (
210
+ edge.source, edge.target, edge.kind,
211
+ json.dumps(edge.metadata) if edge.metadata else None,
212
+ edge.line, edge.column, edge.provenance,
213
+ )
214
+ )
215
+ except Exception:
216
+ # Gracefully handle FK violations (e.g., import edges to external modules)
217
+ pass
218
+
219
+ def insert_edges(self, edges: List[Edge]) -> None:
220
+ """Insert multiple edges in a transaction."""
221
+ with self._db:
222
+ for edge in edges:
223
+ self.insert_edge(edge)
224
+
225
+ def delete_edges_for_file(self, file_path: str) -> None:
226
+ """Delete all edges referencing nodes in a file."""
227
+ self._db.execute(
228
+ '''DELETE FROM edges WHERE source IN
229
+ (SELECT id FROM nodes WHERE file_path = ?)
230
+ OR target IN (SELECT id FROM nodes WHERE file_path = ?)''',
231
+ (file_path, file_path)
232
+ )
233
+
234
+ def get_edges_by_source(self, source_id: str) -> List[Edge]:
235
+ """Get all edges from a source node."""
236
+ cur = self._db.execute(
237
+ 'SELECT * FROM edges WHERE source = ?', (source_id,)
238
+ )
239
+ return [self._row_to_edge(row) for row in cur.fetchall()]
240
+
241
+ def get_edges_by_target(self, target_id: str) -> List[Edge]:
242
+ """Get all edges targeting a node."""
243
+ cur = self._db.execute(
244
+ 'SELECT * FROM edges WHERE target = ?', (target_id,)
245
+ )
246
+ return [self._row_to_edge(row) for row in cur.fetchall()]
247
+
248
+ def get_edges(self, source: str, target: str, kind: str) -> List[Edge]:
249
+ """Get edges matching source, target, and kind."""
250
+ cur = self._db.execute(
251
+ 'SELECT * FROM edges WHERE source = ? AND target = ? AND kind = ?',
252
+ (source, target, kind)
253
+ )
254
+ return [self._row_to_edge(row) for row in cur.fetchall()]
255
+
256
+ def get_outgoing_edges(self, source_id: str,
257
+ kinds: Optional[List[str]] = None) -> List[Edge]:
258
+ """Get outgoing edges, optionally filtered by kind."""
259
+ if kinds:
260
+ placeholders = ','.join('?' * len(kinds))
261
+ cur = self._db.execute(
262
+ f'SELECT * FROM edges WHERE source = ? AND kind IN ({placeholders})',
263
+ [source_id] + kinds
264
+ )
265
+ else:
266
+ cur = self._db.execute(
267
+ 'SELECT * FROM edges WHERE source = ?', (source_id,)
268
+ )
269
+ return [self._row_to_edge(row) for row in cur.fetchall()]
270
+
271
+ def get_incoming_edges(self, target_id: str,
272
+ kinds: Optional[List[str]] = None) -> List[Edge]:
273
+ """Get incoming edges, optionally filtered by kind."""
274
+ if kinds:
275
+ placeholders = ','.join('?' * len(kinds))
276
+ cur = self._db.execute(
277
+ f'SELECT * FROM edges WHERE target = ? AND kind IN ({placeholders})',
278
+ [target_id] + kinds
279
+ )
280
+ else:
281
+ cur = self._db.execute(
282
+ 'SELECT * FROM edges WHERE target = ?', (target_id,)
283
+ )
284
+ return [self._row_to_edge(row) for row in cur.fetchall()]
285
+
286
+ def get_outgoing_edges_batch(self, source_ids: List[str],
287
+ kinds: Optional[List[str]] = None) -> Dict[str, List[Edge]]:
288
+ """Get outgoing edges for multiple source nodes (batched)."""
289
+ if not source_ids:
290
+ return {}
291
+
292
+ result: Dict[str, List[Edge]] = {sid: [] for sid in source_ids}
293
+ placeholders = ','.join('?' * len(source_ids))
294
+
295
+ if kinds:
296
+ kind_placeholders = ','.join('?' * len(kinds))
297
+ cur = self._db.execute(
298
+ f'SELECT * FROM edges WHERE source IN ({placeholders}) AND kind IN ({kind_placeholders})',
299
+ source_ids + kinds
300
+ )
301
+ else:
302
+ cur = self._db.execute(
303
+ f'SELECT * FROM edges WHERE source IN ({placeholders})', source_ids
304
+ )
305
+
306
+ for row in cur.fetchall():
307
+ edge = self._row_to_edge(row)
308
+ if edge.source in result:
309
+ result[edge.source].append(edge)
310
+ return result
311
+
312
+ def get_incoming_edges_batch(self, target_ids: List[str],
313
+ kinds: Optional[List[str]] = None) -> Dict[str, List[Edge]]:
314
+ """Get incoming edges for multiple target nodes (batched)."""
315
+ if not target_ids:
316
+ return {}
317
+
318
+ result: Dict[str, List[Edge]] = {tid: [] for tid in target_ids}
319
+ placeholders = ','.join('?' * len(target_ids))
320
+
321
+ if kinds:
322
+ kind_placeholders = ','.join('?' * len(kinds))
323
+ cur = self._db.execute(
324
+ f'SELECT * FROM edges WHERE target IN ({placeholders}) AND kind IN ({kind_placeholders})',
325
+ target_ids + kinds
326
+ )
327
+ else:
328
+ cur = self._db.execute(
329
+ f'SELECT * FROM edges WHERE target IN ({placeholders})', target_ids
330
+ )
331
+
332
+ for row in cur.fetchall():
333
+ edge = self._row_to_edge(row)
334
+ if edge.target in result:
335
+ result[edge.target].append(edge)
336
+ return result
337
+
338
+ # =========================================================================
339
+ # File Operations
340
+ # =========================================================================
341
+
342
+ def upsert_file(self, record: FileRecord) -> None:
343
+ """Insert or update a file record."""
344
+ errors_json = None
345
+ if record.errors:
346
+ errors_json = json.dumps(
347
+ [dataclasses.asdict(e) for e in record.errors]
348
+ )
349
+ self._db.execute(
350
+ '''INSERT OR REPLACE INTO files
351
+ (path, content_hash, language, size, modified_at, indexed_at, node_count, errors)
352
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
353
+ (
354
+ record.path, record.content_hash, record.language,
355
+ record.size, record.modified_at, record.indexed_at,
356
+ record.node_count,
357
+ errors_json,
358
+ )
359
+ )
360
+
361
+ def delete_file(self, file_path: str) -> None:
362
+ """Delete a file record."""
363
+ self._db.execute('DELETE FROM files WHERE path = ?', (file_path,))
364
+
365
+ def get_file(self, file_path: str) -> Optional[FileRecord]:
366
+ """Get a file record by path."""
367
+ cur = self._db.execute('SELECT * FROM files WHERE path = ?', (file_path,))
368
+ row = cur.fetchone()
369
+ if not row:
370
+ return None
371
+ return FileRecord(
372
+ path=row['path'],
373
+ content_hash=row['content_hash'],
374
+ language=row['language'],
375
+ size=row['size'],
376
+ modified_at=row['modified_at'],
377
+ indexed_at=row['indexed_at'],
378
+ node_count=row['node_count'],
379
+ errors=json.loads(row['errors']) if row['errors'] else None,
380
+ )
381
+
382
+ def get_all_files(self) -> List[FileRecord]:
383
+ """Get all file records."""
384
+ cur = self._db.execute('SELECT * FROM files')
385
+ return [
386
+ FileRecord(
387
+ path=row['path'],
388
+ content_hash=row['content_hash'],
389
+ language=row['language'],
390
+ size=row['size'],
391
+ modified_at=row['modified_at'],
392
+ indexed_at=row['indexed_at'],
393
+ node_count=row['node_count'],
394
+ errors=json.loads(row['errors']) if row['errors'] else None,
395
+ )
396
+ for row in cur.fetchall()
397
+ ]
398
+
399
+ def get_all_file_paths(self) -> List[str]:
400
+ """Get all tracked file paths."""
401
+ cur = self._db.execute('SELECT path FROM files')
402
+ return [row['path'] for row in cur.fetchall()]
403
+
404
+ # =========================================================================
405
+ # Unresolved Reference Operations
406
+ # =========================================================================
407
+
408
+ def insert_unresolved_ref(self, ref: UnresolvedReference) -> None:
409
+ """Insert an unresolved reference."""
410
+ self._db.execute(
411
+ '''INSERT INTO unresolved_refs
412
+ (from_node_id, reference_name, reference_kind, line, col,
413
+ candidates, file_path, language)
414
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
415
+ (
416
+ ref.from_node_id, ref.reference_name, ref.reference_kind,
417
+ ref.line, ref.column,
418
+ json.dumps(ref.candidates) if ref.candidates else None,
419
+ ref.file_path or '', ref.language or 'unknown',
420
+ )
421
+ )
422
+
423
+ def delete_unresolved_refs_for_file(self, file_path: str) -> None:
424
+ """Delete unresolved refs for a file."""
425
+ self._db.execute(
426
+ 'DELETE FROM unresolved_refs WHERE file_path = ?', (file_path,)
427
+ )
428
+
429
+ def get_unresolved_refs(self) -> List[UnresolvedReference]:
430
+ """Get all unresolved references."""
431
+ cur = self._db.execute('SELECT * FROM unresolved_refs')
432
+ return [self._row_to_unresolved(row) for row in cur.fetchall()]
433
+
434
+ def get_unresolved_refs_count(self) -> int:
435
+ """Get count of unresolved references."""
436
+ cur = self._db.execute('SELECT COUNT(*) FROM unresolved_refs')
437
+ return cur.fetchone()[0]
438
+
439
+ def get_unresolved_refs_by_name(self, name: str) -> List[UnresolvedReference]:
440
+ """Get unresolved references by reference name."""
441
+ cur = self._db.execute(
442
+ 'SELECT * FROM unresolved_refs WHERE reference_name = ?', (name,)
443
+ )
444
+ return [self._row_to_unresolved(row) for row in cur.fetchall()]
445
+
446
+ def clear_unresolved_refs(self) -> None:
447
+ """Clear all unresolved references."""
448
+ self._db.execute('DELETE FROM unresolved_refs')
449
+
450
+ # =========================================================================
451
+ # Search (FTS5)
452
+ # =========================================================================
453
+
454
+ def search_nodes(self, query: str,
455
+ options: Optional[SearchOptions] = None) -> List[SearchResult]:
456
+ """Search nodes using FTS5 full-text search."""
457
+ opts = options or SearchOptions()
458
+ results: List[SearchResult] = []
459
+
460
+ try:
461
+ # FTS5 query with prefix matching
462
+ fts_query = ' OR '.join(
463
+ f'"{word}"*' if len(word) > 1 else word
464
+ for word in query.split()
465
+ if word.strip()
466
+ )
467
+
468
+ if fts_query:
469
+ cur = self._db.execute(
470
+ '''SELECT n.*, rank FROM nodes_fts
471
+ JOIN nodes n ON nodes_fts.id = n.id
472
+ WHERE nodes_fts MATCH ?
473
+ ORDER BY rank
474
+ LIMIT ? OFFSET ?''',
475
+ (fts_query, opts.limit, opts.offset)
476
+ )
477
+ for row in cur.fetchall():
478
+ node = self._row_to_node(row)
479
+ if self._matches_filters(node, opts):
480
+ results.append(SearchResult(
481
+ node=node,
482
+ score=1.0 - float(row['rank']) / 100.0 if row['rank'] else 0.0,
483
+ ))
484
+ except Exception:
485
+ pass
486
+
487
+ # Fallback: LIKE search if FTS returns nothing
488
+ if not results:
489
+ for word in query.split():
490
+ if not word.strip():
491
+ continue
492
+ like_pattern = f'%{word}%'
493
+ cur = self._db.execute(
494
+ '''SELECT * FROM nodes WHERE
495
+ name LIKE ? OR qualified_name LIKE ?
496
+ ORDER BY
497
+ CASE WHEN name = ? THEN 0
498
+ WHEN name LIKE ? THEN 1
499
+ ELSE 2 END
500
+ LIMIT ?''',
501
+ (like_pattern, like_pattern, word, f'{word}%', opts.limit)
502
+ )
503
+ for row in cur.fetchall():
504
+ node = self._row_to_node(row)
505
+ if self._matches_filters(node, opts):
506
+ results.append(SearchResult(node=node, score=0.5))
507
+
508
+ return results
509
+
510
+ def _matches_filters(self, node: Node, opts: SearchOptions) -> bool:
511
+ """Check if a node matches search filters."""
512
+ if opts.kinds and node.kind not in opts.kinds:
513
+ return False
514
+ if opts.languages and node.language not in opts.languages:
515
+ return False
516
+ if opts.include_patterns:
517
+ import fnmatch
518
+ if not any(fnmatch.fnmatch(node.file_path, p) for p in opts.include_patterns):
519
+ return False
520
+ if opts.exclude_patterns:
521
+ import fnmatch
522
+ if any(fnmatch.fnmatch(node.file_path, p) for p in opts.exclude_patterns):
523
+ return False
524
+ return True
525
+
526
+ # =========================================================================
527
+ # Name Segment Vocabulary
528
+ # =========================================================================
529
+
530
+ def insert_name_segment(self, segment: str, name: str) -> None:
531
+ """Insert a name segment mapping."""
532
+ try:
533
+ self._db.execute(
534
+ 'INSERT OR IGNORE INTO name_segment_vocab (segment, name) VALUES (?, ?)',
535
+ (segment, name)
536
+ )
537
+ except Exception:
538
+ pass
539
+
540
+ def clear_name_segment_vocab(self) -> None:
541
+ """Clear all name segment entries."""
542
+ try:
543
+ self._db.execute('DELETE FROM name_segment_vocab')
544
+ except Exception:
545
+ pass
546
+
547
+ def get_names_by_segment(self, segment: str) -> List[str]:
548
+ """Get symbol names matching a prose segment."""
549
+ cur = self._db.execute(
550
+ 'SELECT name FROM name_segment_vocab WHERE segment = ?', (segment,)
551
+ )
552
+ return [row['name'] for row in cur.fetchall()]
553
+
554
+ # =========================================================================
555
+ # Statistics
556
+ # =========================================================================
557
+
558
+ def get_stats(self) -> GraphStats:
559
+ """Get graph statistics."""
560
+ stats = GraphStats()
561
+
562
+ try:
563
+ cur = self._db.execute('SELECT COUNT(*) FROM nodes')
564
+ stats.node_count = cur.fetchone()[0]
565
+ except Exception:
566
+ pass
567
+
568
+ try:
569
+ cur = self._db.execute('SELECT COUNT(*) FROM edges')
570
+ stats.edge_count = cur.fetchone()[0]
571
+ except Exception:
572
+ pass
573
+
574
+ try:
575
+ cur = self._db.execute('SELECT COUNT(*) FROM files')
576
+ stats.file_count = cur.fetchone()[0]
577
+ except Exception:
578
+ pass
579
+
580
+ try:
581
+ cur = self._db.execute(
582
+ 'SELECT kind, COUNT(*) as cnt FROM nodes GROUP BY kind'
583
+ )
584
+ for row in cur.fetchall():
585
+ stats.nodes_by_kind[row['kind']] = row['cnt']
586
+ except Exception:
587
+ pass
588
+
589
+ try:
590
+ cur = self._db.execute(
591
+ 'SELECT kind, COUNT(*) as cnt FROM edges GROUP BY kind'
592
+ )
593
+ for row in cur.fetchall():
594
+ stats.edges_by_kind[row['kind']] = row['cnt']
595
+ except Exception:
596
+ pass
597
+
598
+ try:
599
+ cur = self._db.execute(
600
+ 'SELECT language, COUNT(*) as cnt FROM files GROUP BY language'
601
+ )
602
+ for row in cur.fetchall():
603
+ stats.files_by_language[row['language']] = row['cnt']
604
+ except Exception:
605
+ pass
606
+
607
+ try:
608
+ stats.db_size_bytes = os.path.getsize(self._db_path)
609
+ except Exception:
610
+ pass
611
+
612
+ try:
613
+ stats.last_updated = int(time.time() * 1000)
614
+ except Exception:
615
+ pass
616
+
617
+ return stats
618
+
619
+ def get_node_and_edge_count(self) -> Tuple[int, int]:
620
+ """Get total node and edge counts."""
621
+ try:
622
+ cur = self._db.execute('SELECT COUNT(*) FROM nodes')
623
+ nodes = cur.fetchone()[0]
624
+ except Exception:
625
+ nodes = 0
626
+ try:
627
+ cur = self._db.execute('SELECT COUNT(*) FROM edges')
628
+ edges = cur.fetchone()[0]
629
+ except Exception:
630
+ edges = 0
631
+ return (nodes, edges)
632
+
633
+ def get_changed_files(self, file_records: Dict[str, FileRecord]) -> Tuple[
634
+ List[str], List[str], List[str]]:
635
+ """Compare files table with current records to find changes.
636
+
637
+ Returns (added, modified, removed) file paths.
638
+ """
639
+ indexed_paths = set(self.get_all_file_paths())
640
+ current_paths = set(file_records.keys())
641
+
642
+ added = list(current_paths - indexed_paths)
643
+ removed = list(indexed_paths - current_paths)
644
+
645
+ modified = []
646
+ for path in current_paths & indexed_paths:
647
+ cur = self._db.execute(
648
+ 'SELECT content_hash FROM files WHERE path = ?', (path,)
649
+ )
650
+ row = cur.fetchone()
651
+ if row and row['content_hash'] != file_records[path].content_hash:
652
+ modified.append(path)
653
+
654
+ return (added, modified, removed)
655
+
656
+ # =========================================================================
657
+ # Metadata
658
+ # =========================================================================
659
+
660
+ def set_metadata(self, key: str, value: str) -> None:
661
+ """Set a metadata key-value pair."""
662
+ try:
663
+ self._db.execute(
664
+ '''INSERT OR REPLACE INTO project_metadata (key, value, updated_at)
665
+ VALUES (?, ?, ?)''',
666
+ (key, value, int(time.time() * 1000))
667
+ )
668
+ except Exception:
669
+ pass
670
+
671
+ def get_metadata(self, key: str) -> Optional[str]:
672
+ """Get a metadata value."""
673
+ try:
674
+ cur = self._db.execute(
675
+ 'SELECT value FROM project_metadata WHERE key = ?', (key,)
676
+ )
677
+ row = cur.fetchone()
678
+ return row['value'] if row else None
679
+ except Exception:
680
+ return None
681
+
682
+ # =========================================================================
683
+ # Helper methods
684
+ # =========================================================================
685
+
686
+
687
+ @property
688
+ def _db_path(self) -> str:
689
+ """Get the database file path."""
690
+ if self._db_path_value:
691
+ return self._db_path_value
692
+ try:
693
+ cur = self._db.execute("PRAGMA database_list")
694
+ row = cur.fetchone()
695
+ if row and row[2]:
696
+ return row[2]
697
+ except Exception:
698
+ pass
699
+ return ''
700
+
701
+ def _row_to_node(self, row: sqlite3.Row) -> Node:
702
+ """Convert a database row to a Node object."""
703
+ return Node(
704
+ id=row['id'],
705
+ kind=row['kind'],
706
+ name=row['name'],
707
+ qualified_name=row['qualified_name'],
708
+ file_path=row['file_path'],
709
+ language=row['language'],
710
+ start_line=row['start_line'],
711
+ end_line=row['end_line'],
712
+ start_column=row['start_column'],
713
+ end_column=row['end_column'],
714
+ docstring=row['docstring'],
715
+ signature=row['signature'],
716
+ visibility=row['visibility'],
717
+ is_exported=bool(row['is_exported']),
718
+ is_async=bool(row['is_async']),
719
+ is_static=bool(row['is_static']),
720
+ is_abstract=bool(row['is_abstract']),
721
+ decorators=json.loads(row['decorators']) if row['decorators'] else None,
722
+ type_parameters=json.loads(row['type_parameters']) if row['type_parameters'] else None,
723
+ return_type=row['return_type'],
724
+ updated_at=row['updated_at'],
725
+ )
726
+
727
+ def _row_to_edge(self, row: sqlite3.Row) -> Edge:
728
+ """Convert a database row to an Edge object."""
729
+ return Edge(
730
+ id=row['id'],
731
+ source=row['source'],
732
+ target=row['target'],
733
+ kind=row['kind'],
734
+ metadata=json.loads(row['metadata']) if row['metadata'] else None,
735
+ line=row['line'],
736
+ column=row['col'],
737
+ provenance=row['provenance'],
738
+ )
739
+
740
+ def _row_to_unresolved(self, row: sqlite3.Row) -> UnresolvedReference:
741
+ """Convert a database row to an UnresolvedReference object."""
742
+ return UnresolvedReference(
743
+ id=row['id'],
744
+ from_node_id=row['from_node_id'],
745
+ reference_name=row['reference_name'],
746
+ reference_kind=row['reference_kind'],
747
+ line=row['line'],
748
+ column=row['col'],
749
+ candidates=json.loads(row['candidates']) if row['candidates'] else None,
750
+ file_path=row['file_path'],
751
+ language=row['language'],
752
+ )