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.
- codegraph_nav/__init__.py +194 -0
- codegraph_nav/ast_grep_analyzer.py +448 -0
- codegraph_nav/cli.py +223 -0
- codegraph_nav/code_navigator.py +1328 -0
- codegraph_nav/code_search.py +1009 -0
- codegraph_nav/colors.py +209 -0
- codegraph_nav/completions.py +354 -0
- codegraph_nav/dart_analyzer.py +301 -0
- codegraph_nav/dependency_graph.py +814 -0
- codegraph_nav/domain/__init__.py +20 -0
- codegraph_nav/domain/routes.py +337 -0
- codegraph_nav/domain/schemas.py +229 -0
- codegraph_nav/domain/tags.py +87 -0
- codegraph_nav/exporters.py +563 -0
- codegraph_nav/go_analyzer.py +273 -0
- codegraph_nav/graph/__init__.py +72 -0
- codegraph_nav/graph/builder.py +409 -0
- codegraph_nav/graph/communities.py +402 -0
- codegraph_nav/graph/flows.py +311 -0
- codegraph_nav/graph/query.py +380 -0
- codegraph_nav/graph/schema.py +266 -0
- codegraph_nav/graph/search.py +257 -0
- codegraph_nav/graph/store.py +517 -0
- codegraph_nav/hints.py +195 -0
- codegraph_nav/import_resolver.py +891 -0
- codegraph_nav/js_ts_analyzer.py +564 -0
- codegraph_nav/line_reader.py +664 -0
- codegraph_nav/mcp/__init__.py +39 -0
- codegraph_nav/mcp/__main__.py +5 -0
- codegraph_nav/mcp/server.py +2228 -0
- codegraph_nav/py.typed +2 -0
- codegraph_nav/ruby_analyzer.py +259 -0
- codegraph_nav/rust_analyzer.py +379 -0
- codegraph_nav/token_efficient_renderer.py +743 -0
- codegraph_nav/watcher.py +382 -0
- codegraph_nav-0.1.0.dist-info/METADATA +487 -0
- codegraph_nav-0.1.0.dist-info/RECORD +41 -0
- codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
- codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
- codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
- codegraph_nav-0.1.0.dist-info/top_level.txt +1 -0
codegraph_nav/watcher.py
ADDED
|
@@ -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()
|