Navegador

navegador / navegador / ingestion / parser.py
Blame History Raw 434 lines
1
"""
2
RepoIngester — walks a repository, parses source files with tree-sitter,
3
and writes nodes + edges into the GraphStore.
4
5
Supported languages (all via tree-sitter):
6
Python .py
7
TypeScript .ts .tsx
8
JavaScript .js .jsx
9
Go .go
10
Rust .rs
11
Java .java
12
Kotlin .kt .kts
13
C# .cs
14
PHP .php
15
Ruby .rb
16
Swift .swift
17
C .c .h
18
C++ .cpp .hpp .cc .cxx
19
20
Infrastructure-as-Code:
21
HCL .tf .hcl (Terraform / OpenTofu)
22
Puppet .pp
23
Bash .sh .bash .zsh
24
Ansible .yml .yaml (detected heuristically, not via extension)
25
"""
26
27
import hashlib
28
import logging
29
import time
30
from pathlib import Path
31
32
from navegador.graph import queries
33
from navegador.graph.schema import NodeLabel
34
from navegador.graph.store import GraphStore
35
36
logger = logging.getLogger(__name__)
37
38
# File extensions → language key
39
LANGUAGE_MAP: dict[str, str] = {
40
".py": "python",
41
".ts": "typescript",
42
".tsx": "typescript",
43
".js": "javascript",
44
".jsx": "javascript",
45
".go": "go",
46
".rs": "rust",
47
".java": "java",
48
".kt": "kotlin",
49
".kts": "kotlin",
50
".cs": "csharp",
51
".php": "php",
52
".rb": "ruby",
53
".swift": "swift",
54
".c": "c",
55
".h": "c",
56
".cpp": "cpp",
57
".hpp": "cpp",
58
".cc": "cpp",
59
".cxx": "cpp",
60
".tf": "hcl",
61
".hcl": "hcl",
62
".pp": "puppet",
63
".sh": "bash",
64
".bash": "bash",
65
".zsh": "bash",
66
}
67
68
69
class RepoIngester:
70
"""
71
Parses a local code repository and populates a GraphStore.
72
73
Usage:
74
store = GraphStore.sqlite(".navegador/graph.db")
75
ingester = RepoIngester(store)
76
stats = ingester.ingest("/path/to/repo")
77
78
Args:
79
store: The graph store to write nodes and edges into.
80
redact: When True, file contents are scanned for sensitive patterns
81
(API keys, passwords, tokens, …) and any matches are replaced
82
with ``[REDACTED]`` before the content is stored in graph nodes.
83
"""
84
85
def __init__(self, store: GraphStore, redact: bool = False) -> None:
86
self.store = store
87
self.redact = redact
88
self._parsers: dict[str, "LanguageParser"] = {}
89
if redact:
90
from navegador.security import SensitiveContentDetector
91
92
self._detector = SensitiveContentDetector()
93
else:
94
self._detector = None # type: ignore[assignment]
95
96
def ingest(
97
self,
98
repo_path: str | Path,
99
clear: bool = False,
100
incremental: bool = False,
101
) -> dict[str, int]:
102
"""
103
Ingest a repository into the graph.
104
105
Args:
106
repo_path: Path to the repository root.
107
clear: If True, wipe the graph before ingesting.
108
incremental: If True, skip files whose content hash hasn't changed.
109
110
Returns:
111
Dict with counts: files, functions, classes, edges, skipped.
112
"""
113
repo_path = Path(repo_path).resolve()
114
if not repo_path.exists():
115
raise FileNotFoundError(f"Repository not found: {repo_path}")
116
117
if clear:
118
self.store.clear()
119
120
# Create repository node
121
self.store.create_node(
122
NodeLabel.Repository,
123
{
124
"name": repo_path.name,
125
"path": str(repo_path),
126
"file_path": "",
127
},
128
)
129
130
stats: dict[str, int] = {
131
"files": 0,
132
"functions": 0,
133
"classes": 0,
134
"edges": 0,
135
"skipped": 0,
136
}
137
138
for source_file in self._iter_source_files(repo_path):
139
language = LANGUAGE_MAP.get(source_file.suffix)
140
if not language:
141
continue
142
143
rel_path = str(source_file.relative_to(repo_path))
144
content_hash = _file_hash(source_file)
145
146
if incremental and self._file_unchanged(rel_path, content_hash):
147
stats["skipped"] += 1
148
continue
149
150
if incremental:
151
self._clear_file_subgraph(rel_path)
152
153
parse_path, effective_root = self._maybe_redact_to_tmp(source_file, repo_path)
154
try:
155
parser = self._get_parser(language)
156
file_stats = parser.parse_file(parse_path, effective_root, self.store)
157
stats["files"] += 1
158
stats["functions"] += file_stats.get("functions", 0)
159
stats["classes"] += file_stats.get("classes", 0)
160
stats["edges"] += file_stats.get("edges", 0)
161
162
self._store_file_hash(rel_path, content_hash)
163
except Exception:
164
logger.exception("Failed to parse %s", source_file)
165
finally:
166
# Remove the temporary redacted directory if one was created
167
if effective_root is not repo_path:
168
import shutil
169
170
shutil.rmtree(effective_root, ignore_errors=True)
171
172
# Ansible pass — heuristically detect and parse Ansible YAML files
173
self._ingest_ansible(repo_path, stats, incremental)
174
175
logger.info(
176
"Ingested %s: %d files, %d functions, %d classes, %d skipped",
177
repo_path.name,
178
stats["files"],
179
stats["functions"],
180
stats["classes"],
181
stats["skipped"],
182
)
183
return stats
184
185
def watch(
186
self,
187
repo_path: str | Path,
188
interval: float = 2.0,
189
callback=None,
190
) -> None:
191
"""
192
Watch a repo for changes and re-ingest incrementally.
193
194
Args:
195
repo_path: Path to the repository root.
196
interval: Seconds between polls.
197
callback: Optional callable receiving stats dict after each cycle.
198
If callback returns False, the watch loop stops.
199
"""
200
repo_path = Path(repo_path).resolve()
201
if not repo_path.exists():
202
raise FileNotFoundError(f"Repository not found: {repo_path}")
203
204
# Initial full ingest
205
stats = self.ingest(repo_path, incremental=True)
206
if callback and callback(stats) is False:
207
return
208
209
while True:
210
time.sleep(interval)
211
stats = self.ingest(repo_path, incremental=True)
212
if callback and callback(stats) is False:
213
return
214
215
def _file_unchanged(self, rel_path: str, content_hash: str) -> bool:
216
result = self.store.query(queries.FILE_HASH, {"path": rel_path})
217
rows = result.result_set or []
218
if not rows or rows[0][0] is None:
219
return False
220
return rows[0][0] == content_hash
221
222
def _clear_file_subgraph(self, rel_path: str) -> None:
223
self.store.query(queries.DELETE_FILE_SUBGRAPH, {"path": rel_path})
224
225
def _store_file_hash(self, rel_path: str, content_hash: str) -> None:
226
self.store.query(
227
"MATCH (f:File {path: $path}) SET f.content_hash = $hash",
228
{"path": rel_path, "hash": content_hash},
229
)
230
231
def _maybe_redact_to_tmp(self, source_file: Path, repo_root: Path) -> tuple[Path, Path]:
232
"""
233
If redaction is enabled, return a *(parse_path, effective_repo_root)*
234
tuple where *parse_path* can be passed to ``parser.parse_file`` and
235
``parse_path.relative_to(effective_repo_root)`` still yields the
236
correct relative path for graph node naming.
237
238
When redaction is disabled or the file has no sensitive content, both
239
returned values are the originals unchanged.
240
241
The caller is responsible for deleting the temp directory when it is
242
no longer needed.
243
"""
244
if not self.redact or self._detector is None:
245
return source_file, repo_root
246
247
try:
248
original = source_file.read_text(encoding="utf-8", errors="replace")
249
except OSError:
250
return source_file, repo_root
251
252
redacted = self._detector.redact(original)
253
if redacted == original:
254
return source_file, repo_root
255
256
# Mirror the file at the same relative path inside a temp directory so
257
# that parse_path.relative_to(tmp_root) == source_file.relative_to(repo_root).
258
import tempfile
259
260
rel = source_file.relative_to(repo_root)
261
tmp_root = Path(tempfile.mkdtemp())
262
tmp_file = tmp_root / rel
263
tmp_file.parent.mkdir(parents=True, exist_ok=True)
264
tmp_file.write_text(redacted, encoding="utf-8")
265
return tmp_file, tmp_root
266
267
def _iter_source_files(self, repo_path: Path):
268
skip_dirs = {
269
".git",
270
".venv",
271
"venv",
272
"node_modules",
273
"__pycache__",
274
"dist",
275
"build",
276
".next",
277
"target", # Rust / Java (Maven/Gradle)
278
"vendor", # Go modules cache
279
".gradle", # Gradle cache
280
}
281
for path in repo_path.rglob("*"):
282
if path.is_file() and path.suffix in LANGUAGE_MAP:
283
if not any(part in skip_dirs for part in path.parts):
284
yield path
285
286
def _ingest_ansible(self, repo_path: Path, stats: dict[str, int], incremental: bool) -> None:
287
"""Detect and parse Ansible YAML files (playbooks, roles, tasks)."""
288
from navegador.ingestion.ansible import AnsibleParser
289
290
is_ansible_file = AnsibleParser.is_ansible_file
291
292
ansible_parser: AnsibleParser | None = None
293
294
for path in repo_path.rglob("*.yml"):
295
if not path.is_file():
296
continue
297
if any(part in (".git", ".venv", "venv", "node_modules") for part in path.parts):
298
continue
299
if not is_ansible_file(path, repo_path):
300
continue
301
302
rel_path = str(path.relative_to(repo_path))
303
content_hash = _file_hash(path)
304
305
if incremental and self._file_unchanged(rel_path, content_hash):
306
stats["skipped"] += 1
307
continue
308
309
if incremental:
310
self._clear_file_subgraph(rel_path)
311
312
if ansible_parser is None:
313
ansible_parser = AnsibleParser()
314
try:
315
file_stats = ansible_parser.parse_file(path, repo_path, self.store)
316
stats["files"] += 1
317
stats["functions"] += file_stats.get("functions", 0)
318
stats["classes"] += file_stats.get("classes", 0)
319
stats["edges"] += file_stats.get("edges", 0)
320
self._store_file_hash(rel_path, content_hash)
321
except Exception:
322
logger.exception("Failed to parse Ansible file %s", path)
323
324
# Also check .yaml extension
325
for path in repo_path.rglob("*.yaml"):
326
if not path.is_file():
327
continue
328
if any(part in (".git", ".venv", "venv", "node_modules") for part in path.parts):
329
continue
330
if not is_ansible_file(path, repo_path):
331
continue
332
333
rel_path = str(path.relative_to(repo_path))
334
content_hash = _file_hash(path)
335
336
if incremental and self._file_unchanged(rel_path, content_hash):
337
stats["skipped"] += 1
338
continue
339
340
if incremental:
341
self._clear_file_subgraph(rel_path)
342
343
if ansible_parser is None:
344
ansible_parser = AnsibleParser()
345
try:
346
file_stats = ansible_parser.parse_file(path, repo_path, self.store)
347
stats["files"] += 1
348
stats["functions"] += file_stats.get("functions", 0)
349
stats["classes"] += file_stats.get("classes", 0)
350
stats["edges"] += file_stats.get("edges", 0)
351
self._store_file_hash(rel_path, content_hash)
352
except Exception:
353
logger.exception("Failed to parse Ansible file %s", path)
354
355
def _get_parser(self, language: str) -> "LanguageParser":
356
if language not in self._parsers:
357
if language == "python":
358
from navegador.ingestion.python import PythonParser
359
360
self._parsers[language] = PythonParser()
361
elif language in ("typescript", "javascript"):
362
from navegador.ingestion.typescript import TypeScriptParser
363
364
self._parsers[language] = TypeScriptParser(language)
365
elif language == "go":
366
from navegador.ingestion.go import GoParser
367
368
self._parsers[language] = GoParser()
369
elif language == "rust":
370
from navegador.ingestion.rust import RustParser
371
372
self._parsers[language] = RustParser()
373
elif language == "java":
374
from navegador.ingestion.java import JavaParser
375
376
self._parsers[language] = JavaParser()
377
elif language == "kotlin":
378
from navegador.ingestion.kotlin import KotlinParser
379
380
self._parsers[language] = KotlinParser()
381
elif language == "csharp":
382
from navegador.ingestion.csharp import CSharpParser
383
384
self._parsers[language] = CSharpParser()
385
elif language == "php":
386
from navegador.ingestion.php import PHPParser
387
388
self._parsers[language] = PHPParser()
389
elif language == "ruby":
390
from navegador.ingestion.ruby import RubyParser
391
392
self._parsers[language] = RubyParser()
393
elif language == "swift":
394
from navegador.ingestion.swift import SwiftParser
395
396
self._parsers[language] = SwiftParser()
397
elif language == "c":
398
from navegador.ingestion.c import CParser
399
400
self._parsers[language] = CParser()
401
elif language == "cpp":
402
from navegador.ingestion.cpp import CppParser
403
404
self._parsers[language] = CppParser()
405
elif language == "hcl":
406
from navegador.ingestion.hcl import HCLParser
407
408
self._parsers[language] = HCLParser()
409
elif language == "puppet":
410
from navegador.ingestion.puppet import PuppetParser
411
412
self._parsers[language] = PuppetParser()
413
elif language == "bash":
414
from navegador.ingestion.bash import BashParser
415
416
self._parsers[language] = BashParser()
417
else:
418
raise ValueError(f"Unsupported language: {language}")
419
return self._parsers[language]
420
421
422
def _file_hash(path: Path) -> str:
423
"""SHA-256 content hash for a file."""
424
h = hashlib.sha256()
425
h.update(path.read_bytes())
426
return h.hexdigest()
427
428
429
class LanguageParser:
430
"""Base class for language-specific AST parsers."""
431
432
def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]:
433
raise NotImplementedError
434

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button