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