codegraph-nav 0.1.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 (41) hide show
  1. codegraph_nav/__init__.py +194 -0
  2. codegraph_nav/ast_grep_analyzer.py +448 -0
  3. codegraph_nav/cli.py +223 -0
  4. codegraph_nav/code_navigator.py +1328 -0
  5. codegraph_nav/code_search.py +1009 -0
  6. codegraph_nav/colors.py +209 -0
  7. codegraph_nav/completions.py +354 -0
  8. codegraph_nav/dart_analyzer.py +301 -0
  9. codegraph_nav/dependency_graph.py +814 -0
  10. codegraph_nav/domain/__init__.py +20 -0
  11. codegraph_nav/domain/routes.py +337 -0
  12. codegraph_nav/domain/schemas.py +229 -0
  13. codegraph_nav/domain/tags.py +87 -0
  14. codegraph_nav/exporters.py +563 -0
  15. codegraph_nav/go_analyzer.py +273 -0
  16. codegraph_nav/graph/__init__.py +72 -0
  17. codegraph_nav/graph/builder.py +409 -0
  18. codegraph_nav/graph/communities.py +402 -0
  19. codegraph_nav/graph/flows.py +311 -0
  20. codegraph_nav/graph/query.py +380 -0
  21. codegraph_nav/graph/schema.py +266 -0
  22. codegraph_nav/graph/search.py +257 -0
  23. codegraph_nav/graph/store.py +517 -0
  24. codegraph_nav/hints.py +195 -0
  25. codegraph_nav/import_resolver.py +891 -0
  26. codegraph_nav/js_ts_analyzer.py +564 -0
  27. codegraph_nav/line_reader.py +664 -0
  28. codegraph_nav/mcp/__init__.py +39 -0
  29. codegraph_nav/mcp/__main__.py +5 -0
  30. codegraph_nav/mcp/server.py +2228 -0
  31. codegraph_nav/py.typed +2 -0
  32. codegraph_nav/ruby_analyzer.py +259 -0
  33. codegraph_nav/rust_analyzer.py +379 -0
  34. codegraph_nav/token_efficient_renderer.py +743 -0
  35. codegraph_nav/watcher.py +382 -0
  36. codegraph_nav-0.1.0.dist-info/METADATA +487 -0
  37. codegraph_nav-0.1.0.dist-info/RECORD +41 -0
  38. codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
  39. codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
  40. codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. codegraph_nav-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env python3
2
+ """Watch mode for automatic code map updates.
3
+
4
+ Monitors a codebase for file changes and automatically updates the code map
5
+ when changes are detected. Uses polling (no external dependencies).
6
+
7
+ Example:
8
+ Command line usage:
9
+ $ codegraph-nav watch /path/to/project -o .codegraph.json
10
+
11
+ Python API usage:
12
+ >>> watcher = CodegraphWatcher('/path/to/project', '.codegraph.json')
13
+ >>> watcher.start() # Blocks until Ctrl+C
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ import signal
20
+ import sys
21
+ import tempfile
22
+ import time
23
+ from pathlib import Path
24
+
25
+ from .code_navigator import DEFAULT_IGNORE_PATTERNS, LANGUAGE_EXTENSIONS, CodeNavigator
26
+ from .colors import get_colors
27
+
28
+ __version__ = "0.1.0"
29
+
30
+
31
+ class CodegraphWatcher:
32
+ """Watches a codebase for changes and updates the code map automatically.
33
+
34
+ Attributes:
35
+ root_path: Path to the codebase root.
36
+ output_path: Path to the output .codegraph.json file.
37
+ ignore_patterns: Patterns to ignore.
38
+ debounce: Seconds to wait after change before updating.
39
+ git_only: Only watch git-tracked files.
40
+ use_gitignore: Use .gitignore patterns.
41
+
42
+ Example:
43
+ >>> watcher = CodegraphWatcher('/my/project', '.codegraph.json')
44
+ >>> watcher.start()
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ root_path: str,
50
+ output_path: str = ".codegraph.json",
51
+ ignore_patterns: list[str] | None = None,
52
+ debounce: float = 1.0,
53
+ git_only: bool = False,
54
+ use_gitignore: bool = False,
55
+ poll_interval: float = 1.0,
56
+ compact: bool = False,
57
+ no_color: bool = False,
58
+ ):
59
+ """Initialize the watcher.
60
+
61
+ Args:
62
+ root_path: Path to the codebase root.
63
+ output_path: Path to output file (default: .codegraph.json).
64
+ ignore_patterns: Additional patterns to ignore.
65
+ debounce: Seconds to wait after change before updating (default: 1.0).
66
+ git_only: Only watch git-tracked files.
67
+ use_gitignore: Use .gitignore patterns.
68
+ poll_interval: Seconds between polls (default: 1.0).
69
+ compact: Output compact JSON.
70
+ no_color: Disable colored output.
71
+ """
72
+ self.root_path = Path(root_path).resolve()
73
+ self.output_path = output_path
74
+ if not os.path.isabs(self.output_path):
75
+ self.output_path = str(self.root_path / self.output_path)
76
+
77
+ self.ignore_patterns = list(ignore_patterns or DEFAULT_IGNORE_PATTERNS)
78
+ self.debounce = debounce
79
+ self.git_only = git_only
80
+ self.use_gitignore = use_gitignore
81
+ self.poll_interval = poll_interval
82
+ self.compact = compact
83
+ self.no_color = no_color
84
+
85
+ self._running = False
86
+ self._file_hashes: dict[str, str] = {}
87
+ self._last_change_time: float = 0
88
+ self._pending_update = False
89
+ self._colors = get_colors(no_color=no_color)
90
+
91
+ def _get_watched_files(self) -> set[Path]:
92
+ """Get all files that should be watched.
93
+
94
+ Returns:
95
+ Set of file paths to watch.
96
+ """
97
+ files = set()
98
+
99
+ for root, dirs, filenames in os.walk(self.root_path):
100
+ # Filter directories
101
+ dirs[:] = [d for d in dirs if not self._should_ignore(Path(root) / d)]
102
+
103
+ for filename in filenames:
104
+ file_path = Path(root) / filename
105
+ if self._should_ignore(file_path):
106
+ continue
107
+
108
+ # Check if it's a supported language file
109
+ ext = file_path.suffix.lower()
110
+ is_supported = any(ext in exts for exts in LANGUAGE_EXTENSIONS.values())
111
+ if is_supported:
112
+ files.add(file_path)
113
+
114
+ return files
115
+
116
+ def _should_ignore(self, path: Path) -> bool:
117
+ """Check if a path should be ignored.
118
+
119
+ Args:
120
+ path: Path to check.
121
+
122
+ Returns:
123
+ True if the path should be ignored.
124
+ """
125
+ import fnmatch
126
+
127
+ path_str = str(path)
128
+ name = path.name
129
+
130
+ for pattern in self.ignore_patterns:
131
+ if fnmatch.fnmatch(name, pattern):
132
+ return True
133
+ if pattern in path_str:
134
+ return True
135
+
136
+ return False
137
+
138
+ def _hash_file(self, file_path: Path) -> str | None:
139
+ """Calculate hash of a file's content.
140
+
141
+ Args:
142
+ file_path: Path to the file.
143
+
144
+ Returns:
145
+ Hash string, or None if file cannot be read (deleted, permission denied, etc).
146
+ """
147
+ try:
148
+ # Check if file exists and is a regular file (not symlink pointing elsewhere)
149
+ if not file_path.is_file():
150
+ return None
151
+ from . import compute_content_hash
152
+
153
+ content = file_path.read_text(encoding="utf-8", errors="replace")
154
+ return compute_content_hash(content)
155
+ except OSError:
156
+ # File may have been deleted, or permission denied - this is expected
157
+ # during rapid file changes (TOCTOU race condition handling)
158
+ return None
159
+
160
+ def _check_for_changes(self) -> bool:
161
+ """Check if any watched files have changed.
162
+
163
+ Handles TOCTOU (time-of-check to time-of-use) race conditions by:
164
+ - Tracking files that become unreadable (deleted during scan)
165
+ - Using explicit markers for inaccessible files
166
+ - Gracefully handling files that disappear between listing and hashing
167
+
168
+ Returns:
169
+ True if changes were detected.
170
+ """
171
+ current_files = self._get_watched_files()
172
+ current_hashes: dict[str, str] = {}
173
+
174
+ for file_path in current_files:
175
+ try:
176
+ rel_path = str(file_path.relative_to(self.root_path))
177
+ except ValueError:
178
+ # File somehow escaped root (shouldn't happen, but be safe)
179
+ continue
180
+
181
+ file_hash = self._hash_file(file_path)
182
+ if file_hash is not None:
183
+ current_hashes[rel_path] = file_hash
184
+ else:
185
+ # File existed in listing but couldn't be read (TOCTOU race)
186
+ # Mark as inaccessible so we detect when it becomes readable again
187
+ current_hashes[rel_path] = "__INACCESSIBLE__"
188
+
189
+ # Check for changes
190
+ changed = False
191
+
192
+ # New or modified files
193
+ for rel_path, file_hash in current_hashes.items():
194
+ if rel_path not in self._file_hashes:
195
+ changed = True
196
+ break
197
+ if self._file_hashes[rel_path] != file_hash:
198
+ changed = True
199
+ break
200
+
201
+ # Deleted files
202
+ if not changed:
203
+ for rel_path in self._file_hashes:
204
+ if rel_path not in current_hashes:
205
+ changed = True
206
+ break
207
+
208
+ # Update stored hashes
209
+ self._file_hashes = current_hashes
210
+ return changed
211
+
212
+ def _update_map(self) -> None:
213
+ """Update the code map."""
214
+ c = self._colors
215
+
216
+ print(f"\n{c.cyan('Updating code map...')}", file=sys.stderr)
217
+ start_time = time.time()
218
+
219
+ try:
220
+ mapper = CodeNavigator(
221
+ str(self.root_path),
222
+ self.ignore_patterns,
223
+ git_only=self.git_only,
224
+ use_gitignore=self.use_gitignore,
225
+ )
226
+
227
+ # Use incremental scan if map exists
228
+ if os.path.exists(self.output_path):
229
+ code_map = mapper.scan_incremental(self.output_path)
230
+ else:
231
+ code_map = mapper.scan()
232
+
233
+ # Write the map atomically (write to temp file, then rename)
234
+ # This prevents corruption if disk is full or process is interrupted
235
+ output_dir = os.path.dirname(self.output_path) or "."
236
+ tmp_fd = None
237
+ tmp_path = None
238
+ try:
239
+ tmp_fd, tmp_path = tempfile.mkstemp(
240
+ suffix=".json.tmp", dir=output_dir, prefix=".codegraph_"
241
+ )
242
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
243
+ tmp_fd = None # os.fdopen takes ownership
244
+ if self.compact:
245
+ json.dump(code_map, f, separators=(",", ":"))
246
+ else:
247
+ json.dump(code_map, f, indent=2)
248
+
249
+ # Atomic rename (on same filesystem)
250
+ shutil.move(tmp_path, self.output_path)
251
+ tmp_path = None # Successfully moved
252
+ finally:
253
+ # Clean up temp file if write or move failed
254
+ if tmp_fd is not None:
255
+ os.close(tmp_fd)
256
+ if tmp_path is not None and os.path.exists(tmp_path):
257
+ try:
258
+ os.unlink(tmp_path)
259
+ except OSError:
260
+ pass
261
+
262
+ elapsed = time.time() - start_time
263
+ stats = code_map["stats"]
264
+
265
+ if "files_unchanged" in stats:
266
+ print(
267
+ f"{c.success('✓')} Updated in {elapsed:.2f}s: "
268
+ f"{c.dim(str(stats.get('files_unchanged', 0)))} unchanged, "
269
+ f"{c.yellow(str(stats.get('files_modified', 0)))} modified, "
270
+ f"{c.green(str(stats.get('files_added', 0)))} added, "
271
+ f"{c.magenta(str(stats.get('files_deleted', 0)))} deleted",
272
+ file=sys.stderr,
273
+ )
274
+ else:
275
+ print(
276
+ f"{c.success('✓')} Generated in {elapsed:.2f}s: "
277
+ f"{c.green(str(stats['files_processed']))} files, "
278
+ f"{c.green(str(stats['symbols_found']))} symbols",
279
+ file=sys.stderr,
280
+ )
281
+
282
+ except OSError as e:
283
+ print(f"{c.error('✗')} Disk error updating map: {e}", file=sys.stderr)
284
+ except (json.JSONDecodeError, TypeError) as e:
285
+ print(f"{c.error('✗')} Data error in map: {e}", file=sys.stderr)
286
+ except Exception as e:
287
+ print(
288
+ f"{c.error('✗')} Unexpected error updating map: {type(e).__name__}: {e}",
289
+ file=sys.stderr,
290
+ )
291
+
292
+ def _initial_scan(self) -> None:
293
+ """Perform initial scan to populate file hashes."""
294
+ c = self._colors
295
+ print(f"{c.cyan('Performing initial scan...')}", file=sys.stderr)
296
+
297
+ # Get initial file hashes
298
+ for file_path in self._get_watched_files():
299
+ rel_path = str(file_path.relative_to(self.root_path))
300
+ file_hash = self._hash_file(file_path)
301
+ if file_hash:
302
+ self._file_hashes[rel_path] = file_hash
303
+
304
+ # Generate initial map if it doesn't exist
305
+ if not os.path.exists(self.output_path):
306
+ self._update_map()
307
+
308
+ print(
309
+ f"{c.success('✓')} Watching {c.green(str(len(self._file_hashes)))} files",
310
+ file=sys.stderr,
311
+ )
312
+ print(f"{c.dim('Press Ctrl+C to stop')}", file=sys.stderr)
313
+
314
+ def start(self) -> None:
315
+ """Start watching for changes. Blocks until interrupted.
316
+
317
+ Use Ctrl+C to stop watching.
318
+ """
319
+ c = self._colors
320
+ self._running = True
321
+
322
+ # Handle Ctrl+C gracefully
323
+ def signal_handler(signum, frame):
324
+ self._running = False
325
+ print(f"\n{c.dim('Stopping watcher...')}", file=sys.stderr)
326
+
327
+ signal.signal(signal.SIGINT, signal_handler)
328
+ signal.signal(signal.SIGTERM, signal_handler)
329
+
330
+ print(f"{c.bold('Code Map Watcher')}", file=sys.stderr)
331
+ print(f" Root: {c.cyan(str(self.root_path))}", file=sys.stderr)
332
+ print(f" Output: {c.cyan(self.output_path)}", file=sys.stderr)
333
+
334
+ self._initial_scan()
335
+
336
+ while self._running:
337
+ try:
338
+ if self._check_for_changes():
339
+ self._last_change_time = time.time()
340
+ self._pending_update = True
341
+
342
+ # Debounce: wait for debounce period after last change
343
+ if self._pending_update:
344
+ if time.time() - self._last_change_time >= self.debounce:
345
+ self._update_map()
346
+ self._pending_update = False
347
+
348
+ time.sleep(self.poll_interval)
349
+
350
+ except Exception as e:
351
+ print(f"{c.error('Error')}: {e}", file=sys.stderr)
352
+ time.sleep(self.poll_interval)
353
+
354
+ print(f"{c.success('✓')} Watcher stopped", file=sys.stderr)
355
+
356
+ def stop(self) -> None:
357
+ """Stop watching."""
358
+ self._running = False
359
+
360
+
361
+ def run_watch(args) -> None:
362
+ """Run the watch command.
363
+
364
+ Args:
365
+ args: Parsed command-line arguments.
366
+ """
367
+ ignore_patterns = DEFAULT_IGNORE_PATTERNS.copy()
368
+ if getattr(args, "ignore", None):
369
+ ignore_patterns.extend(args.ignore)
370
+
371
+ watcher = CodegraphWatcher(
372
+ root_path=args.path,
373
+ output_path=getattr(args, "output", ".codegraph.json"),
374
+ ignore_patterns=ignore_patterns,
375
+ debounce=getattr(args, "debounce", 1.0),
376
+ git_only=getattr(args, "git_only", False),
377
+ use_gitignore=getattr(args, "use_gitignore", False),
378
+ compact=getattr(args, "compact", False),
379
+ no_color=getattr(args, "no_color", False),
380
+ )
381
+
382
+ watcher.start()