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.
- codegraph/__init__.py +12 -0
- codegraph/__main__.py +6 -0
- codegraph/cli.py +911 -0
- codegraph/codegraph.py +618 -0
- codegraph/context/__init__.py +216 -0
- codegraph/db/__init__.py +11 -0
- codegraph/db/connection.py +163 -0
- codegraph/db/queries.py +752 -0
- codegraph/db/schema.sql +151 -0
- codegraph/directory.py +160 -0
- codegraph/errors.py +101 -0
- codegraph/extraction/__init__.py +956 -0
- codegraph/extraction/languages/__init__.py +64 -0
- codegraph/extraction/languages/base.py +132 -0
- codegraph/extraction/languages/c_cfg.py +53 -0
- codegraph/extraction/languages/cpp_cfg.py +60 -0
- codegraph/extraction/languages/dart_cfg.py +53 -0
- codegraph/extraction/languages/go_cfg.py +70 -0
- codegraph/extraction/languages/java_cfg.py +62 -0
- codegraph/extraction/languages/javascript_cfg.py +84 -0
- codegraph/extraction/languages/kotlin_cfg.py +52 -0
- codegraph/extraction/languages/lua_cfg.py +65 -0
- codegraph/extraction/languages/python_cfg.py +75 -0
- codegraph/extraction/languages/ruby_cfg.py +48 -0
- codegraph/extraction/languages/rust_cfg.py +68 -0
- codegraph/extraction/languages/scala_cfg.py +59 -0
- codegraph/extraction/languages/swift_cfg.py +64 -0
- codegraph/extraction/languages/typescript_cfg.py +89 -0
- codegraph/extraction/tree_sitter_extractor.py +689 -0
- codegraph/graph/__init__.py +685 -0
- codegraph/installer/__init__.py +6 -0
- codegraph/mcp/__init__.py +668 -0
- codegraph/project_config.py +191 -0
- codegraph/resolution/__init__.py +337 -0
- codegraph/search/__init__.py +653 -0
- codegraph/sync/__init__.py +204 -0
- codegraph/types.py +334 -0
- codegraph/ui/__init__.py +11 -0
- codegraph/utils.py +218 -0
- codegraph_py-1.0.0.dist-info/METADATA +238 -0
- codegraph_py-1.0.0.dist-info/RECORD +44 -0
- codegraph_py-1.0.0.dist-info/WHEEL +5 -0
- codegraph_py-1.0.0.dist-info/entry_points.txt +2 -0
- codegraph_py-1.0.0.dist-info/top_level.txt +1 -0
codegraph/codegraph.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeGraph Main Class
|
|
3
|
+
|
|
4
|
+
Primary interface for interacting with the code knowledge graph.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Dict, List, Optional, Set, Tuple, Callable, Any
|
|
13
|
+
|
|
14
|
+
from codegraph.types import (
|
|
15
|
+
Node, Edge, Subgraph, GraphStats, TaskContext,
|
|
16
|
+
SearchResult, SearchOptions, TraversalOptions,
|
|
17
|
+
SegmentMatch, BuildContextOptions, FileRecord,
|
|
18
|
+
)
|
|
19
|
+
from codegraph.db import DatabaseConnection, QueryBuilder, get_database_path
|
|
20
|
+
from codegraph.directory import (
|
|
21
|
+
get_codegraph_dir, is_initialized, create_directory,
|
|
22
|
+
remove_directory, validate_directory, find_nearest_codegraph_root,
|
|
23
|
+
derive_project_name_tokens,
|
|
24
|
+
)
|
|
25
|
+
from codegraph.errors import CodeGraphError
|
|
26
|
+
from codegraph.extraction import (
|
|
27
|
+
ExtractionOrchestrator, IndexProgress,
|
|
28
|
+
)
|
|
29
|
+
from codegraph.resolution import ReferenceResolver, create_resolver
|
|
30
|
+
from codegraph.graph import GraphTraverser, GraphQueryManager
|
|
31
|
+
from codegraph.context import ContextBuilder, create_context_builder
|
|
32
|
+
from codegraph.sync import FileWatcher, WatchOptions, PendingFile
|
|
33
|
+
|
|
34
|
+
__version__ = "1.0.0"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class IndexResult:
|
|
39
|
+
"""Result of a full project index operation."""
|
|
40
|
+
success: bool = False
|
|
41
|
+
files_indexed: int = 0
|
|
42
|
+
files_skipped: int = 0
|
|
43
|
+
files_errored: int = 0
|
|
44
|
+
nodes_created: int = 0
|
|
45
|
+
edges_created: int = 0
|
|
46
|
+
duration_ms: float = 0.0
|
|
47
|
+
errors: List[Dict] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class SyncResult:
|
|
52
|
+
"""Result of a sync operation."""
|
|
53
|
+
files_checked: int = 0
|
|
54
|
+
files_added: int = 0
|
|
55
|
+
files_modified: int = 0
|
|
56
|
+
files_removed: int = 0
|
|
57
|
+
nodes_updated: int = 0
|
|
58
|
+
duration_ms: float = 0.0
|
|
59
|
+
changed_file_paths: List[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CodeGraph:
|
|
63
|
+
"""
|
|
64
|
+
Main CodeGraph class providing the primary interface for interacting
|
|
65
|
+
with the code knowledge graph.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, db: DatabaseConnection, queries: QueryBuilder,
|
|
69
|
+
project_root: str):
|
|
70
|
+
self._db = db
|
|
71
|
+
self._queries = queries
|
|
72
|
+
self._project_root = project_root
|
|
73
|
+
self._watcher: Optional[FileWatcher] = None
|
|
74
|
+
|
|
75
|
+
# Initialize layers
|
|
76
|
+
self._wire_layers()
|
|
77
|
+
|
|
78
|
+
def _wire_layers(self) -> None:
|
|
79
|
+
"""Build the extraction/graph/context layers."""
|
|
80
|
+
try:
|
|
81
|
+
tokens = derive_project_name_tokens(self._project_root)
|
|
82
|
+
self._queries.set_project_name_tokens(tokens)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
self._orchestrator = ExtractionOrchestrator(
|
|
87
|
+
self._project_root, self._queries
|
|
88
|
+
)
|
|
89
|
+
self._resolver = create_resolver(self._project_root, self._queries)
|
|
90
|
+
self._graph_manager = GraphQueryManager(self._db.get_db(), self._db.get_path())
|
|
91
|
+
self._traverser = GraphTraverser(self._db.get_db(), self._db.get_path())
|
|
92
|
+
self._context_builder = create_context_builder(
|
|
93
|
+
self._project_root, self._queries, self._traverser
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# =========================================================================
|
|
97
|
+
# Lifecycle Methods
|
|
98
|
+
# =========================================================================
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
async def init(project_root: str,
|
|
102
|
+
options: Optional[Dict] = None) -> 'CodeGraph':
|
|
103
|
+
"""Initialize a new CodeGraph project."""
|
|
104
|
+
resolved = os.path.abspath(project_root)
|
|
105
|
+
|
|
106
|
+
if is_initialized(resolved):
|
|
107
|
+
raise CodeGraphError(f'CodeGraph already initialized in {resolved}')
|
|
108
|
+
|
|
109
|
+
create_directory(resolved)
|
|
110
|
+
db_path = get_database_path(resolved)
|
|
111
|
+
db = DatabaseConnection.initialize(db_path)
|
|
112
|
+
queries = QueryBuilder(db.get_db(), db_path)
|
|
113
|
+
|
|
114
|
+
instance = CodeGraph(db, queries, resolved)
|
|
115
|
+
|
|
116
|
+
# Run initial indexing if requested
|
|
117
|
+
if options and options.get('index', True):
|
|
118
|
+
await instance.index_all(
|
|
119
|
+
on_progress=options.get('on_progress')
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return instance
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def init_sync(project_root: str) -> 'CodeGraph':
|
|
126
|
+
"""Initialize synchronously (without indexing)."""
|
|
127
|
+
resolved = os.path.abspath(project_root)
|
|
128
|
+
|
|
129
|
+
if is_initialized(resolved):
|
|
130
|
+
raise CodeGraphError(f'CodeGraph already initialized in {resolved}')
|
|
131
|
+
|
|
132
|
+
create_directory(resolved)
|
|
133
|
+
db_path = get_database_path(resolved)
|
|
134
|
+
db = DatabaseConnection.initialize(db_path)
|
|
135
|
+
queries = QueryBuilder(db.get_db(), db_path)
|
|
136
|
+
|
|
137
|
+
return CodeGraph(db, queries, resolved)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
async def open(project_root: str,
|
|
141
|
+
options: Optional[Dict] = None) -> 'CodeGraph':
|
|
142
|
+
"""Open an existing CodeGraph project."""
|
|
143
|
+
resolved = os.path.abspath(project_root)
|
|
144
|
+
|
|
145
|
+
if not is_initialized(resolved):
|
|
146
|
+
raise CodeGraphError(
|
|
147
|
+
f'CodeGraph not initialized in {resolved}. Run init() first.'
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
valid, errors = validate_directory(resolved)
|
|
151
|
+
if not valid:
|
|
152
|
+
raise CodeGraphError(
|
|
153
|
+
f'Invalid CodeGraph directory: {", ".join(errors)}'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
db_path = get_database_path(resolved)
|
|
157
|
+
db = DatabaseConnection.open(db_path)
|
|
158
|
+
queries = QueryBuilder(db.get_db(), db_path)
|
|
159
|
+
|
|
160
|
+
instance = CodeGraph(db, queries, resolved)
|
|
161
|
+
|
|
162
|
+
# Sync if requested
|
|
163
|
+
if options and options.get('sync', False):
|
|
164
|
+
await instance.sync()
|
|
165
|
+
|
|
166
|
+
return instance
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def open_sync(project_root: str) -> 'CodeGraph':
|
|
170
|
+
"""Open synchronously (without sync)."""
|
|
171
|
+
resolved = os.path.abspath(project_root)
|
|
172
|
+
|
|
173
|
+
if not is_initialized(resolved):
|
|
174
|
+
raise CodeGraphError(
|
|
175
|
+
f'CodeGraph not initialized in {resolved}. Run init() first.'
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
valid, errors = validate_directory(resolved)
|
|
179
|
+
if not valid:
|
|
180
|
+
raise CodeGraphError(
|
|
181
|
+
f'Invalid CodeGraph directory: {", ".join(errors)}'
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
db_path = get_database_path(resolved)
|
|
185
|
+
db = DatabaseConnection.open(db_path)
|
|
186
|
+
queries = QueryBuilder(db.get_db(), db_path)
|
|
187
|
+
|
|
188
|
+
return CodeGraph(db, queries, resolved)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def is_initialized(project_root: str) -> bool:
|
|
192
|
+
"""Check if a directory has been initialized."""
|
|
193
|
+
return is_initialized(os.path.abspath(project_root))
|
|
194
|
+
|
|
195
|
+
def close(self) -> None:
|
|
196
|
+
"""Close the CodeGraph instance and release resources."""
|
|
197
|
+
self.unwatch()
|
|
198
|
+
self._db.close()
|
|
199
|
+
|
|
200
|
+
def destroy(self) -> None:
|
|
201
|
+
"""Alias for close()."""
|
|
202
|
+
self.close()
|
|
203
|
+
|
|
204
|
+
def get_project_root(self) -> str:
|
|
205
|
+
"""Get the project root directory."""
|
|
206
|
+
return self._project_root
|
|
207
|
+
|
|
208
|
+
# =========================================================================
|
|
209
|
+
# Indexing
|
|
210
|
+
# =========================================================================
|
|
211
|
+
|
|
212
|
+
def index_all(self, on_progress=None, signal=None,
|
|
213
|
+
verbose: bool = False) -> IndexResult:
|
|
214
|
+
"""Index all files in the project."""
|
|
215
|
+
start_time = time.time()
|
|
216
|
+
|
|
217
|
+
# Use the orchestrator's sync method which does a full scan + index
|
|
218
|
+
sync_result = self._orchestrator.sync(force=False)
|
|
219
|
+
|
|
220
|
+
# Build the result
|
|
221
|
+
errors = []
|
|
222
|
+
if sync_result.total_errors > 0:
|
|
223
|
+
errors = [{'message': str(e), 'severity': 'warning'}
|
|
224
|
+
for e in sync_result.progress.errors] if sync_result.progress else []
|
|
225
|
+
|
|
226
|
+
result = IndexResult(
|
|
227
|
+
success=True,
|
|
228
|
+
files_indexed=len(sync_result.indexed_files),
|
|
229
|
+
files_skipped=len(sync_result.skipped_files),
|
|
230
|
+
files_errored=sync_result.total_errors,
|
|
231
|
+
nodes_created=sync_result.total_nodes,
|
|
232
|
+
edges_created=sync_result.total_edges,
|
|
233
|
+
duration_ms=sync_result.duration_ms,
|
|
234
|
+
errors=errors,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if result.files_indexed > 0:
|
|
238
|
+
self._resolver.initialize()
|
|
239
|
+
self._resolver.run_post_extract()
|
|
240
|
+
|
|
241
|
+
# Resolve references
|
|
242
|
+
ref_count = self._queries.get_unresolved_refs_count()
|
|
243
|
+
if ref_count > 0:
|
|
244
|
+
self._resolve_references()
|
|
245
|
+
|
|
246
|
+
# Update metadata
|
|
247
|
+
self._queries.set_metadata('last_indexed_at', str(int(time.time() * 1000)))
|
|
248
|
+
self._queries.set_metadata('index_state', 'complete')
|
|
249
|
+
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
def index_files(self, file_paths: List[str]) -> IndexResult:
|
|
253
|
+
"""Index specific files."""
|
|
254
|
+
start_time = time.time()
|
|
255
|
+
total_nodes = 0
|
|
256
|
+
total_edges = 0
|
|
257
|
+
errors = []
|
|
258
|
+
|
|
259
|
+
for fp in file_paths:
|
|
260
|
+
file_result = self._orchestrator.index_file(fp)
|
|
261
|
+
if file_result.success:
|
|
262
|
+
total_nodes += file_result.nodes_count
|
|
263
|
+
total_edges += file_result.edges_count
|
|
264
|
+
else:
|
|
265
|
+
for e in file_result.errors:
|
|
266
|
+
errors.append({'message': e.message, 'severity': 'error'})
|
|
267
|
+
|
|
268
|
+
# Re-resolve references after partial re-index
|
|
269
|
+
if total_nodes > 0:
|
|
270
|
+
ref_count = self._queries.get_unresolved_refs_count()
|
|
271
|
+
if ref_count > 0:
|
|
272
|
+
self._resolver.initialize()
|
|
273
|
+
self._resolve_references()
|
|
274
|
+
|
|
275
|
+
return IndexResult(
|
|
276
|
+
success=len(errors) == 0,
|
|
277
|
+
files_indexed=len(file_paths) - len(errors),
|
|
278
|
+
files_errored=len(errors),
|
|
279
|
+
nodes_created=total_nodes,
|
|
280
|
+
edges_created=total_edges,
|
|
281
|
+
duration_ms=(time.time() - start_time) * 1000,
|
|
282
|
+
errors=errors,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def sync(self, on_progress=None) -> SyncResult:
|
|
286
|
+
"""Sync changes since last index."""
|
|
287
|
+
start_time = time.time()
|
|
288
|
+
|
|
289
|
+
# Get existing files
|
|
290
|
+
existing = set(self._queries.get_all_file_paths())
|
|
291
|
+
|
|
292
|
+
# Scan current files
|
|
293
|
+
current_files = set(self._orchestrator.scan_files())
|
|
294
|
+
|
|
295
|
+
added = list(current_files - existing)
|
|
296
|
+
removed = list(existing - current_files)
|
|
297
|
+
|
|
298
|
+
# Check for modified files
|
|
299
|
+
modified = []
|
|
300
|
+
for fp in (current_files & existing):
|
|
301
|
+
rec = self._queries.get_file(fp)
|
|
302
|
+
if rec:
|
|
303
|
+
filepath = os.path.join(self._project_root, fp)
|
|
304
|
+
if os.path.isfile(filepath):
|
|
305
|
+
current_mtime = int(os.path.getmtime(filepath) * 1000)
|
|
306
|
+
if current_mtime > rec.modified_at:
|
|
307
|
+
modified.append(fp)
|
|
308
|
+
|
|
309
|
+
# Index changed files
|
|
310
|
+
nodes_updated = 0
|
|
311
|
+
for fp in added + modified:
|
|
312
|
+
file_result = self._orchestrator.index_file(fp)
|
|
313
|
+
if file_result.success:
|
|
314
|
+
nodes_updated += file_result.nodes_count
|
|
315
|
+
|
|
316
|
+
# Remove deleted files
|
|
317
|
+
for fp in removed:
|
|
318
|
+
self._queries.delete_file(fp)
|
|
319
|
+
self._queries.delete_nodes_by_file(fp)
|
|
320
|
+
|
|
321
|
+
result = SyncResult(
|
|
322
|
+
files_checked=len(current_files),
|
|
323
|
+
files_added=len(added),
|
|
324
|
+
files_modified=len(modified),
|
|
325
|
+
files_removed=len(removed),
|
|
326
|
+
nodes_updated=nodes_updated,
|
|
327
|
+
duration_ms=(time.time() - start_time) * 1000,
|
|
328
|
+
changed_file_paths=added + modified,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
def uninitialize(self) -> None:
|
|
334
|
+
"""Remove CodeGraph from the project."""
|
|
335
|
+
self.close()
|
|
336
|
+
remove_directory(self._project_root)
|
|
337
|
+
|
|
338
|
+
# =========================================================================
|
|
339
|
+
# Query Methods
|
|
340
|
+
# =========================================================================
|
|
341
|
+
|
|
342
|
+
def search_nodes(self, query: str,
|
|
343
|
+
options: Optional[SearchOptions] = None) -> List[SearchResult]:
|
|
344
|
+
"""Search for symbols in the codebase."""
|
|
345
|
+
return self._queries.search_nodes(query, options)
|
|
346
|
+
|
|
347
|
+
def get_node(self, node_id: str) -> Optional[Node]:
|
|
348
|
+
"""Get a node by ID."""
|
|
349
|
+
return self._queries.get_node_by_id(node_id)
|
|
350
|
+
|
|
351
|
+
def get_nodes_by_name(self, name: str) -> List[Node]:
|
|
352
|
+
"""Get nodes by exact name."""
|
|
353
|
+
return self._queries.get_nodes_by_name(name)
|
|
354
|
+
|
|
355
|
+
def get_nodes_by_file(self, file_path: str) -> List[Node]:
|
|
356
|
+
"""Get all nodes in a file."""
|
|
357
|
+
return self._queries.get_nodes_by_file(file_path)
|
|
358
|
+
|
|
359
|
+
def get_callers(self, node_id: str, max_depth: int = 1) -> List[Tuple[Node, Edge]]:
|
|
360
|
+
"""Find what calls a function/method."""
|
|
361
|
+
nodes = self._traverser.getCallers(node_id, max_depth > 1)
|
|
362
|
+
result = []
|
|
363
|
+
for n in nodes:
|
|
364
|
+
edges = self._queries.get_outgoing_edges(n.id, ['calls', 'references'])
|
|
365
|
+
for e in edges:
|
|
366
|
+
if e.target == node_id:
|
|
367
|
+
result.append((n, e))
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
def get_callees(self, node_id: str, max_depth: int = 1) -> List[Tuple[Node, Edge]]:
|
|
371
|
+
"""Find what a function/method calls."""
|
|
372
|
+
nodes = self._traverser.getCallees(node_id, max_depth > 1)
|
|
373
|
+
result = []
|
|
374
|
+
for n in nodes:
|
|
375
|
+
edges = self._queries.get_incoming_edges(n.id, ['calls', 'references'])
|
|
376
|
+
for e in edges:
|
|
377
|
+
if e.source == node_id:
|
|
378
|
+
result.append((n, e))
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
def get_impact_radius(self, node_id: str, max_depth: int = 3) -> Subgraph:
|
|
382
|
+
"""Analyze what code is affected by changing a symbol."""
|
|
383
|
+
tr = self._traverser.getImpactRadius(node_id, radius=max_depth)
|
|
384
|
+
sub = Subgraph()
|
|
385
|
+
for n in tr.nodes:
|
|
386
|
+
sub.nodes[n.id] = n
|
|
387
|
+
sub.edges = tr.edges
|
|
388
|
+
sub.roots = [node_id]
|
|
389
|
+
sub.confidence = 'high'
|
|
390
|
+
return sub
|
|
391
|
+
|
|
392
|
+
def get_context(self, node_id: str, depth: int = 2) -> 'Context':
|
|
393
|
+
"""Get context for a node."""
|
|
394
|
+
from codegraph.types import Context
|
|
395
|
+
return self._context_builder.build_context(node_id, depth)
|
|
396
|
+
|
|
397
|
+
def get_call_graph(self, node_id: str, depth: int = 2) -> Subgraph:
|
|
398
|
+
"""Get the call graph for a function."""
|
|
399
|
+
tr = self._traverser.bfs_traverse(
|
|
400
|
+
node_id, max_depth=depth,
|
|
401
|
+
edge_kinds=['calls', 'references'],
|
|
402
|
+
direction='both'
|
|
403
|
+
)
|
|
404
|
+
sub = Subgraph()
|
|
405
|
+
for n in tr.nodes:
|
|
406
|
+
sub.nodes[n.id] = n
|
|
407
|
+
sub.edges = tr.edges
|
|
408
|
+
sub.roots = [node_id]
|
|
409
|
+
sub.confidence = 'high'
|
|
410
|
+
return sub
|
|
411
|
+
|
|
412
|
+
def find_path(self, from_id: str, to_id: str,
|
|
413
|
+
edge_kinds: Optional[List[str]] = None) -> List[Edge]:
|
|
414
|
+
"""Find a path between two nodes."""
|
|
415
|
+
result = self._traverser.findPath(from_id, to_id, edge_kinds)
|
|
416
|
+
return result.edges if hasattr(result, 'edges') else []
|
|
417
|
+
|
|
418
|
+
def get_type_hierarchy(self, node_id: str) -> Tuple[List[Node], List[Node]]:
|
|
419
|
+
"""Get the type hierarchy (ancestors, descendants)."""
|
|
420
|
+
hierarchy = self._traverser.getTypeHierarchy(node_id)
|
|
421
|
+
return hierarchy.get('super', []), hierarchy.get('sub', [])
|
|
422
|
+
|
|
423
|
+
def get_file_dependencies(self, file_path: str) -> List[Node]:
|
|
424
|
+
"""Get file dependencies (other files this file imports)."""
|
|
425
|
+
deps = self._graph_manager.getFileDependencies(file_path)
|
|
426
|
+
# Flatten the dependency dict
|
|
427
|
+
result = []
|
|
428
|
+
for dep_list in deps.values():
|
|
429
|
+
for dep in dep_list:
|
|
430
|
+
if hasattr(dep, 'file_path') and dep.file_path:
|
|
431
|
+
nodes = self._queries.get_nodes_by_file(dep.file_path)
|
|
432
|
+
result.extend(nodes)
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
def get_file_dependents(self, file_path: str) -> List[Node]:
|
|
436
|
+
"""Get files that depend on this file."""
|
|
437
|
+
nodes = self._queries.get_nodes_by_file(file_path)
|
|
438
|
+
dependents = []
|
|
439
|
+
for n in nodes:
|
|
440
|
+
edges = self._queries.get_incoming_edges(n.id)
|
|
441
|
+
for e in edges:
|
|
442
|
+
source = self._queries.get_node_by_id(e.source)
|
|
443
|
+
if source and source.file_path != file_path:
|
|
444
|
+
dependents.append(source)
|
|
445
|
+
return dependents
|
|
446
|
+
|
|
447
|
+
def find_circular_dependencies(self) -> List[List[str]]:
|
|
448
|
+
"""Find circular dependencies between files."""
|
|
449
|
+
return self._graph_manager.findCircularDependencies()
|
|
450
|
+
|
|
451
|
+
def find_dead_code(self, kinds: Optional[List[str]] = None) -> List[Node]:
|
|
452
|
+
"""Find potentially dead code (nodes with no incoming references)."""
|
|
453
|
+
dead = self._graph_manager.findDeadCode()
|
|
454
|
+
result = []
|
|
455
|
+
for nodes in dead.values():
|
|
456
|
+
if kinds:
|
|
457
|
+
nodes = [n for n in nodes if n.kind in kinds]
|
|
458
|
+
result.extend(nodes)
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
def get_exported_symbols(self, file_path: str) -> List[Node]:
|
|
462
|
+
"""Get exported symbols from a file."""
|
|
463
|
+
return self._graph_manager.getExportedSymbols(file_path)
|
|
464
|
+
|
|
465
|
+
def build_task_context(self, query: str,
|
|
466
|
+
options: Optional[BuildContextOptions] = None) -> TaskContext:
|
|
467
|
+
"""Build context for a task."""
|
|
468
|
+
return self._context_builder.build_task_context(query, options)
|
|
469
|
+
|
|
470
|
+
# =========================================================================
|
|
471
|
+
# Statistics
|
|
472
|
+
# =========================================================================
|
|
473
|
+
|
|
474
|
+
def get_stats(self) -> GraphStats:
|
|
475
|
+
"""Get graph statistics."""
|
|
476
|
+
return self._queries.get_stats()
|
|
477
|
+
|
|
478
|
+
def get_backend(self) -> str:
|
|
479
|
+
"""Get the database backend name."""
|
|
480
|
+
return 'sqlite3'
|
|
481
|
+
|
|
482
|
+
def get_journal_mode(self) -> Optional[str]:
|
|
483
|
+
"""Get the effective journal mode."""
|
|
484
|
+
return self._db.get_journal_mode()
|
|
485
|
+
|
|
486
|
+
def get_index_state(self) -> Optional[str]:
|
|
487
|
+
"""Get the index state metadata."""
|
|
488
|
+
return self._queries.get_metadata('index_state')
|
|
489
|
+
|
|
490
|
+
def get_last_indexed_at(self) -> Optional[int]:
|
|
491
|
+
"""Get timestamp of last successful index."""
|
|
492
|
+
val = self._queries.get_metadata('last_indexed_at')
|
|
493
|
+
return int(val) if val else None
|
|
494
|
+
|
|
495
|
+
def get_index_build_info(self) -> Dict[str, Optional[str]]:
|
|
496
|
+
"""Get build info about the index."""
|
|
497
|
+
return {
|
|
498
|
+
'version': self._queries.get_metadata('indexed_with_version'),
|
|
499
|
+
'extraction_version': self._queries.get_metadata('indexed_with_extraction_version'),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
def is_index_stale(self) -> bool:
|
|
503
|
+
"""Check if the index was built by an older engine version."""
|
|
504
|
+
build_ver = self._queries.get_metadata('indexed_with_extraction_version')
|
|
505
|
+
if not build_ver:
|
|
506
|
+
return True
|
|
507
|
+
try:
|
|
508
|
+
from codegraph.extraction import EXTRACTION_VERSION
|
|
509
|
+
return int(build_ver) < EXTRACTION_VERSION
|
|
510
|
+
except Exception:
|
|
511
|
+
return False
|
|
512
|
+
|
|
513
|
+
def get_pending_reference_count(self) -> int:
|
|
514
|
+
"""Get count of unresolved references."""
|
|
515
|
+
return self._queries.get_unresolved_refs_count()
|
|
516
|
+
|
|
517
|
+
def get_changed_files(self) -> Dict[str, List[str]]:
|
|
518
|
+
"""Get lists of changed files since last index."""
|
|
519
|
+
existing = set(self._queries.get_all_file_paths())
|
|
520
|
+
current_files = set(self._orchestrator.scan_files())
|
|
521
|
+
|
|
522
|
+
added = list(current_files - existing)
|
|
523
|
+
removed = list(existing - current_files)
|
|
524
|
+
|
|
525
|
+
modified = []
|
|
526
|
+
for fp in (current_files & existing):
|
|
527
|
+
rec = self._queries.get_file(fp)
|
|
528
|
+
if rec:
|
|
529
|
+
filepath = os.path.join(self._project_root, fp)
|
|
530
|
+
if os.path.isfile(filepath):
|
|
531
|
+
current_mtime = int(os.path.getmtime(filepath) * 1000)
|
|
532
|
+
if current_mtime > rec.modified_at:
|
|
533
|
+
modified.append(fp)
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
'added': added,
|
|
537
|
+
'modified': modified,
|
|
538
|
+
'removed': removed,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
# =========================================================================
|
|
542
|
+
# Segment Matching (for prompt hooks)
|
|
543
|
+
# =========================================================================
|
|
544
|
+
|
|
545
|
+
def get_segment_matches(self, prompt_words: List[str]) -> List[SegmentMatch]:
|
|
546
|
+
"""Find symbol names that match prose words from a prompt."""
|
|
547
|
+
from codegraph.search import extract_prose_candidates, split_identifier_segments
|
|
548
|
+
|
|
549
|
+
matches: List[SegmentMatch] = []
|
|
550
|
+
seen: Set[str] = set()
|
|
551
|
+
|
|
552
|
+
for word in prompt_words:
|
|
553
|
+
word_lower = word.lower()
|
|
554
|
+
if len(word_lower) < 2:
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
# Look up in segment vocabulary
|
|
558
|
+
names = self._queries.get_names_by_segment(word_lower)
|
|
559
|
+
for name in names:
|
|
560
|
+
if name in seen:
|
|
561
|
+
continue
|
|
562
|
+
seen.add(name)
|
|
563
|
+
|
|
564
|
+
# Verify the symbol exists in the graph
|
|
565
|
+
nodes = self._queries.get_nodes_by_name(name)
|
|
566
|
+
for node in nodes:
|
|
567
|
+
matches.append(SegmentMatch(
|
|
568
|
+
name=name,
|
|
569
|
+
kind=node.kind,
|
|
570
|
+
file_path=node.file_path,
|
|
571
|
+
start_line=node.start_line,
|
|
572
|
+
matched_words=[word_lower],
|
|
573
|
+
))
|
|
574
|
+
|
|
575
|
+
return matches
|
|
576
|
+
|
|
577
|
+
# =========================================================================
|
|
578
|
+
# File Watching
|
|
579
|
+
# =========================================================================
|
|
580
|
+
|
|
581
|
+
def watch(self, on_change: Optional[Callable[[List[PendingFile]], None]] = None,
|
|
582
|
+
options: Optional[WatchOptions] = None) -> FileWatcher:
|
|
583
|
+
"""Start watching the project for file changes."""
|
|
584
|
+
if self._watcher:
|
|
585
|
+
self._watcher.stop()
|
|
586
|
+
|
|
587
|
+
self._watcher = FileWatcher(
|
|
588
|
+
self._project_root,
|
|
589
|
+
on_change=on_change or self._on_watch_change,
|
|
590
|
+
options=options,
|
|
591
|
+
)
|
|
592
|
+
self._watcher.start()
|
|
593
|
+
return self._watcher
|
|
594
|
+
|
|
595
|
+
def unwatch(self) -> None:
|
|
596
|
+
"""Stop watching for file changes."""
|
|
597
|
+
if self._watcher:
|
|
598
|
+
self._watcher.stop()
|
|
599
|
+
self._watcher = None
|
|
600
|
+
|
|
601
|
+
def _on_watch_change(self, pending: List[PendingFile]) -> None:
|
|
602
|
+
"""Default handler for file changes - triggers a sync."""
|
|
603
|
+
try:
|
|
604
|
+
import asyncio
|
|
605
|
+
asyncio.run(self.sync())
|
|
606
|
+
except Exception:
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
# =========================================================================
|
|
610
|
+
# Internal
|
|
611
|
+
# =========================================================================
|
|
612
|
+
|
|
613
|
+
def _resolve_references(self) -> None:
|
|
614
|
+
"""Resolve all pending unresolved references."""
|
|
615
|
+
result = self._resolver.resolve_all()
|
|
616
|
+
if result.resolved_count > 0:
|
|
617
|
+
self._resolver.resolve_chained_calls_via_conformance()
|
|
618
|
+
self._resolver.resolve_deferred_this_member_refs()
|