Navegador
chore: prep 0.1.0 release - Fix mypy: type-annotate lines[] in rust.py, _store/_loader in server.py - Run ruff format across all source files - Update README language support table (Go, Rust, Java are shipped, not planned) - Update CHANGELOG with full 0.1.0 feature list and quality metrics - Add pytest-asyncio to dev dependencies in pyproject.toml
Commit
758c6b753ff9a637dbc3aa2d20d7c5e0a2104b2e9b6d7f6d968a29551b9c9df7
Parent
7e708ec3514631d…
16 files changed
+18
-8
+3
-1
+113
-41
+58
-47
+66
-35
+1
-4
+93
-58
+128
-76
+100
-61
+34
-17
+186
-123
+103
-55
+103
-57
+148
-94
+16
-9
+13
-5
~
CHANGELOG.md
~
README.md
~
navegador/cli/commands.py
~
navegador/context/loader.py
~
navegador/graph/schema.py
~
navegador/graph/store.py
~
navegador/ingestion/go.py
~
navegador/ingestion/java.py
~
navegador/ingestion/knowledge.py
~
navegador/ingestion/parser.py
~
navegador/ingestion/planopticon.py
~
navegador/ingestion/python.py
~
navegador/ingestion/rust.py
~
navegador/ingestion/typescript.py
~
navegador/ingestion/wiki.py
~
navegador/mcp/server.py
+18
-8
| --- CHANGELOG.md | ||
| +++ CHANGELOG.md | ||
| @@ -1,12 +1,22 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 3 | 3 | ## 0.1.0 — 2026-03-22 |
| 4 | 4 | |
| 5 | -Initial release scaffold. | |
| 6 | - | |
| 7 | -- AST ingestion pipeline (Python + TypeScript via tree-sitter) | |
| 8 | -- Property graph storage via FalkorDB-lite (SQLite) or Redis | |
| 9 | -- Context bundles: file, function, class context loading | |
| 10 | -- MCP server with 7 tools for AI agent integration | |
| 11 | -- CLI: `ingest`, `context`, `search`, `stats`, `mcp` | |
| 12 | -- MkDocs documentation site (navegador.dev) | |
| 5 | +First public release. | |
| 6 | + | |
| 7 | +### Features | |
| 8 | + | |
| 9 | +- **7-language AST ingestion** — Python, TypeScript, JavaScript, Go, Rust, Java via tree-sitter | |
| 10 | +- **Property graph storage** — FalkorDB-lite (SQLite, zero-infra) or Redis-backed FalkorDB | |
| 11 | +- **Context bundles** — file, function, class, concept, and explain context loading | |
| 12 | +- **MCP server** — 7 tools for AI agent integration (`ingest_repo`, `load_file_context`, `load_function_context`, `load_class_context`, `search_symbols`, `query_graph`, `graph_stats`) | |
| 13 | +- **CLI** — `ingest`, `context`, `function`, `class`, `explain`, `search`, `decorated`, `query`, `stats`, `add`, `annotate`, `domain`, `concept`, `wiki ingest`, `planopticon ingest`, `mcp` | |
| 14 | +- **Knowledge ingestion** — concepts, rules, decisions, persons, domains, wiki pages, PlanOpticon video analysis outputs | |
| 15 | +- **Wiki ingestion** — local Markdown directories, GitHub repo docs via API or git clone | |
| 16 | + | |
| 17 | +### Quality | |
| 18 | + | |
| 19 | +- 100% test coverage (426 tests) | |
| 20 | +- mypy clean (`--ignore-missing-imports`) | |
| 21 | +- ruff lint + format passing | |
| 22 | +- CI matrix: Ubuntu + macOS, Python 3.12 / 3.13 / 3.14 | |
| 13 | 23 |
| --- CHANGELOG.md | |
| +++ CHANGELOG.md | |
| @@ -1,12 +1,22 @@ | |
| 1 | # Changelog |
| 2 | |
| 3 | ## 0.1.0 — 2026-03-22 |
| 4 | |
| 5 | Initial release scaffold. |
| 6 | |
| 7 | - AST ingestion pipeline (Python + TypeScript via tree-sitter) |
| 8 | - Property graph storage via FalkorDB-lite (SQLite) or Redis |
| 9 | - Context bundles: file, function, class context loading |
| 10 | - MCP server with 7 tools for AI agent integration |
| 11 | - CLI: `ingest`, `context`, `search`, `stats`, `mcp` |
| 12 | - MkDocs documentation site (navegador.dev) |
| 13 |
| --- CHANGELOG.md | |
| +++ CHANGELOG.md | |
| @@ -1,12 +1,22 @@ | |
| 1 | # Changelog |
| 2 | |
| 3 | ## 0.1.0 — 2026-03-22 |
| 4 | |
| 5 | First public release. |
| 6 | |
| 7 | ### Features |
| 8 | |
| 9 | - **7-language AST ingestion** — Python, TypeScript, JavaScript, Go, Rust, Java via tree-sitter |
| 10 | - **Property graph storage** — FalkorDB-lite (SQLite, zero-infra) or Redis-backed FalkorDB |
| 11 | - **Context bundles** — file, function, class, concept, and explain context loading |
| 12 | - **MCP server** — 7 tools for AI agent integration (`ingest_repo`, `load_file_context`, `load_function_context`, `load_class_context`, `search_symbols`, `query_graph`, `graph_stats`) |
| 13 | - **CLI** — `ingest`, `context`, `function`, `class`, `explain`, `search`, `decorated`, `query`, `stats`, `add`, `annotate`, `domain`, `concept`, `wiki ingest`, `planopticon ingest`, `mcp` |
| 14 | - **Knowledge ingestion** — concepts, rules, decisions, persons, domains, wiki pages, PlanOpticon video analysis outputs |
| 15 | - **Wiki ingestion** — local Markdown directories, GitHub repo docs via API or git clone |
| 16 | |
| 17 | ### Quality |
| 18 | |
| 19 | - 100% test coverage (426 tests) |
| 20 | - mypy clean (`--ignore-missing-imports`) |
| 21 | - ruff lint + format passing |
| 22 | - CI matrix: Ubuntu + macOS, Python 3.12 / 3.13 / 3.14 |
| 23 |
+3
-1
| --- README.md | ||
| +++ README.md | ||
| @@ -101,11 +101,13 @@ | ||
| 101 | 101 | |
| 102 | 102 | | Language | Status | |
| 103 | 103 | |----------|--------| |
| 104 | 104 | | Python | ✅ | |
| 105 | 105 | | TypeScript / JavaScript | ✅ | |
| 106 | -| Go, Rust, Java | Planned | | |
| 106 | +| Go | ✅ | | |
| 107 | +| Rust | ✅ | | |
| 108 | +| Java | ✅ | | |
| 107 | 109 | |
| 108 | 110 | --- |
| 109 | 111 | |
| 110 | 112 | ## License |
| 111 | 113 | |
| 112 | 114 |
| --- README.md | |
| +++ README.md | |
| @@ -101,11 +101,13 @@ | |
| 101 | |
| 102 | | Language | Status | |
| 103 | |----------|--------| |
| 104 | | Python | ✅ | |
| 105 | | TypeScript / JavaScript | ✅ | |
| 106 | | Go, Rust, Java | Planned | |
| 107 | |
| 108 | --- |
| 109 | |
| 110 | ## License |
| 111 | |
| 112 |
| --- README.md | |
| +++ README.md | |
| @@ -101,11 +101,13 @@ | |
| 101 | |
| 102 | | Language | Status | |
| 103 | |----------|--------| |
| 104 | | Python | ✅ | |
| 105 | | TypeScript / JavaScript | ✅ | |
| 106 | | Go | ✅ | |
| 107 | | Rust | ✅ | |
| 108 | | Java | ✅ | |
| 109 | |
| 110 | --- |
| 111 | |
| 112 | ## License |
| 113 | |
| 114 |
+113
-41
| --- navegador/cli/commands.py | ||
| +++ navegador/cli/commands.py | ||
| @@ -18,19 +18,22 @@ | ||
| 18 | 18 | |
| 19 | 19 | DB_OPTION = click.option( |
| 20 | 20 | "--db", default=".navegador/graph.db", show_default=True, help="Graph DB path." |
| 21 | 21 | ) |
| 22 | 22 | FMT_OPTION = click.option( |
| 23 | - "--format", "fmt", | |
| 23 | + "--format", | |
| 24 | + "fmt", | |
| 24 | 25 | type=click.Choice(["markdown", "json"]), |
| 25 | - default="markdown", show_default=True, | |
| 26 | + default="markdown", | |
| 27 | + show_default=True, | |
| 26 | 28 | help="Output format. Use json for agent/pipe consumption.", |
| 27 | 29 | ) |
| 28 | 30 | |
| 29 | 31 | |
| 30 | 32 | def _get_store(db: str): |
| 31 | 33 | from navegador.config import DEFAULT_DB_PATH, get_store |
| 34 | + | |
| 32 | 35 | return get_store(db if db != DEFAULT_DB_PATH else None) |
| 33 | 36 | |
| 34 | 37 | |
| 35 | 38 | def _emit(text: str, fmt: str) -> None: |
| 36 | 39 | if fmt == "json": |
| @@ -38,10 +41,11 @@ | ||
| 38 | 41 | else: |
| 39 | 42 | console.print(text) |
| 40 | 43 | |
| 41 | 44 | |
| 42 | 45 | # ── Root group ──────────────────────────────────────────────────────────────── |
| 46 | + | |
| 43 | 47 | |
| 44 | 48 | @click.group() |
| 45 | 49 | @click.version_option(package_name="navegador") |
| 46 | 50 | def main(): |
| 47 | 51 | """Navegador — project knowledge graph for AI coding agents. |
| @@ -51,15 +55,20 @@ | ||
| 51 | 55 | """ |
| 52 | 56 | logging.basicConfig(level=logging.WARNING) |
| 53 | 57 | |
| 54 | 58 | |
| 55 | 59 | # ── Init ────────────────────────────────────────────────────────────────────── |
| 60 | + | |
| 56 | 61 | |
| 57 | 62 | @main.command() |
| 58 | 63 | @click.argument("path", default=".", type=click.Path()) |
| 59 | -@click.option("--redis", "redis_url", default="", | |
| 60 | - help="Redis URL for centralized/production mode (e.g. redis://host:6379).") | |
| 64 | +@click.option( | |
| 65 | + "--redis", | |
| 66 | + "redis_url", | |
| 67 | + default="", | |
| 68 | + help="Redis URL for centralized/production mode (e.g. redis://host:6379).", | |
| 69 | +) | |
| 61 | 70 | def init(path: str, redis_url: str): |
| 62 | 71 | """Initialise navegador in a project directory. |
| 63 | 72 | |
| 64 | 73 | Creates .navegador/ (gitignored), writes .env.example with storage options. |
| 65 | 74 | |
| @@ -89,10 +98,11 @@ | ||
| 89 | 98 | |
| 90 | 99 | console.print("\nNext: [bold]navegador ingest .[/bold]") |
| 91 | 100 | |
| 92 | 101 | |
| 93 | 102 | # ── CODE: ingest ────────────────────────────────────────────────────────────── |
| 103 | + | |
| 94 | 104 | |
| 95 | 105 | @main.command() |
| 96 | 106 | @click.argument("repo_path", type=click.Path(exists=True)) |
| 97 | 107 | @DB_OPTION |
| 98 | 108 | @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.") |
| @@ -117,18 +127,20 @@ | ||
| 117 | 127 | table.add_row(k.capitalize(), str(v)) |
| 118 | 128 | console.print(table) |
| 119 | 129 | |
| 120 | 130 | |
| 121 | 131 | # ── CODE: context / function / class ───────────────────────────────────────── |
| 132 | + | |
| 122 | 133 | |
| 123 | 134 | @main.command() |
| 124 | 135 | @click.argument("file_path") |
| 125 | 136 | @DB_OPTION |
| 126 | 137 | @FMT_OPTION |
| 127 | 138 | def context(file_path: str, db: str, fmt: str): |
| 128 | 139 | """Load context for a file — all symbols and their relationships.""" |
| 129 | 140 | from navegador.context import ContextLoader |
| 141 | + | |
| 130 | 142 | bundle = ContextLoader(_get_store(db)).load_file(file_path) |
| 131 | 143 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 132 | 144 | |
| 133 | 145 | |
| 134 | 146 | @main.command() |
| @@ -138,10 +150,11 @@ | ||
| 138 | 150 | @DB_OPTION |
| 139 | 151 | @FMT_OPTION |
| 140 | 152 | def function(name: str, file_path: str, db: str, depth: int, fmt: str): |
| 141 | 153 | """Load context for a function — callers, callees, decorators.""" |
| 142 | 154 | from navegador.context import ContextLoader |
| 155 | + | |
| 143 | 156 | bundle = ContextLoader(_get_store(db)).load_function(name, file_path=file_path, depth=depth) |
| 144 | 157 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 145 | 158 | |
| 146 | 159 | |
| 147 | 160 | @main.command("class") |
| @@ -150,41 +163,47 @@ | ||
| 150 | 163 | @DB_OPTION |
| 151 | 164 | @FMT_OPTION |
| 152 | 165 | def class_(name: str, file_path: str, db: str, fmt: str): |
| 153 | 166 | """Load context for a class — methods, inheritance, references.""" |
| 154 | 167 | from navegador.context import ContextLoader |
| 168 | + | |
| 155 | 169 | bundle = ContextLoader(_get_store(db)).load_class(name, file_path=file_path) |
| 156 | 170 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 157 | 171 | |
| 158 | 172 | |
| 159 | 173 | # ── UNIVERSAL: explain ──────────────────────────────────────────────────────── |
| 174 | + | |
| 160 | 175 | |
| 161 | 176 | @main.command() |
| 162 | 177 | @click.argument("name") |
| 163 | 178 | @click.option("--file", "file_path", default="") |
| 164 | 179 | @DB_OPTION |
| 165 | 180 | @FMT_OPTION |
| 166 | 181 | def explain(name: str, file_path: str, db: str, fmt: str): |
| 167 | 182 | """Full picture: all relationships in and out, code and knowledge layers.""" |
| 168 | 183 | from navegador.context import ContextLoader |
| 184 | + | |
| 169 | 185 | bundle = ContextLoader(_get_store(db)).explain(name, file_path=file_path) |
| 170 | 186 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 171 | 187 | |
| 172 | 188 | |
| 173 | 189 | # ── UNIVERSAL: search ───────────────────────────────────────────────────────── |
| 190 | + | |
| 174 | 191 | |
| 175 | 192 | @main.command() |
| 176 | 193 | @click.argument("query") |
| 177 | 194 | @DB_OPTION |
| 178 | 195 | @click.option("--limit", default=20, show_default=True) |
| 179 | -@click.option("--all", "search_all", is_flag=True, | |
| 180 | - help="Include knowledge layer (concepts, rules, wiki).") | |
| 196 | +@click.option( | |
| 197 | + "--all", "search_all", is_flag=True, help="Include knowledge layer (concepts, rules, wiki)." | |
| 198 | +) | |
| 181 | 199 | @click.option("--docs", "by_doc", is_flag=True, help="Search docstrings instead of names.") |
| 182 | 200 | @FMT_OPTION |
| 183 | 201 | def search(query: str, db: str, limit: int, search_all: bool, by_doc: bool, fmt: str): |
| 184 | 202 | """Search symbols, concepts, rules, and wiki pages.""" |
| 185 | 203 | from navegador.context import ContextLoader |
| 204 | + | |
| 186 | 205 | loader = ContextLoader(_get_store(db)) |
| 187 | 206 | |
| 188 | 207 | if by_doc: |
| 189 | 208 | results = loader.search_by_docstring(query, limit=limit) |
| 190 | 209 | elif search_all: |
| @@ -191,16 +210,26 @@ | ||
| 191 | 210 | results = loader.search_all(query, limit=limit) |
| 192 | 211 | else: |
| 193 | 212 | results = loader.search(query, limit=limit) |
| 194 | 213 | |
| 195 | 214 | if fmt == "json": |
| 196 | - click.echo(json.dumps([ | |
| 197 | - {"type": r.type, "name": r.name, "file_path": r.file_path, | |
| 198 | - "line_start": r.line_start, "docstring": r.docstring, | |
| 199 | - "description": r.description} | |
| 200 | - for r in results | |
| 201 | - ], indent=2)) | |
| 215 | + click.echo( | |
| 216 | + json.dumps( | |
| 217 | + [ | |
| 218 | + { | |
| 219 | + "type": r.type, | |
| 220 | + "name": r.name, | |
| 221 | + "file_path": r.file_path, | |
| 222 | + "line_start": r.line_start, | |
| 223 | + "docstring": r.docstring, | |
| 224 | + "description": r.description, | |
| 225 | + } | |
| 226 | + for r in results | |
| 227 | + ], | |
| 228 | + indent=2, | |
| 229 | + ) | |
| 230 | + ) | |
| 202 | 231 | return |
| 203 | 232 | |
| 204 | 233 | if not results: |
| 205 | 234 | console.print("[yellow]No results.[/yellow]") |
| 206 | 235 | return |
| @@ -215,25 +244,32 @@ | ||
| 215 | 244 | table.add_row(r.type, r.name, loc, str(r.line_start or "")) |
| 216 | 245 | console.print(table) |
| 217 | 246 | |
| 218 | 247 | |
| 219 | 248 | # ── CODE: decorator / query ─────────────────────────────────────────────────── |
| 249 | + | |
| 220 | 250 | |
| 221 | 251 | @main.command() |
| 222 | 252 | @click.argument("decorator_name") |
| 223 | 253 | @DB_OPTION |
| 224 | 254 | @FMT_OPTION |
| 225 | 255 | def decorated(decorator_name: str, db: str, fmt: str): |
| 226 | 256 | """Find all functions/methods carrying a decorator.""" |
| 227 | 257 | from navegador.context import ContextLoader |
| 258 | + | |
| 228 | 259 | results = ContextLoader(_get_store(db)).decorated_by(decorator_name) |
| 229 | 260 | |
| 230 | 261 | if fmt == "json": |
| 231 | - click.echo(json.dumps([ | |
| 232 | - {"type": r.type, "name": r.name, "file_path": r.file_path, "line": r.line_start} | |
| 233 | - for r in results | |
| 234 | - ], indent=2)) | |
| 262 | + click.echo( | |
| 263 | + json.dumps( | |
| 264 | + [ | |
| 265 | + {"type": r.type, "name": r.name, "file_path": r.file_path, "line": r.line_start} | |
| 266 | + for r in results | |
| 267 | + ], | |
| 268 | + indent=2, | |
| 269 | + ) | |
| 270 | + ) | |
| 235 | 271 | return |
| 236 | 272 | |
| 237 | 273 | if not results: |
| 238 | 274 | console.print(f"[yellow]No functions decorated with @{decorator_name}[/yellow]") |
| 239 | 275 | return |
| @@ -256,10 +292,11 @@ | ||
| 256 | 292 | result = _get_store(db).query(cypher) |
| 257 | 293 | click.echo(json.dumps(result.result_set or [], default=str, indent=2)) |
| 258 | 294 | |
| 259 | 295 | |
| 260 | 296 | # ── KNOWLEDGE: add group ────────────────────────────────────────────────────── |
| 297 | + | |
| 261 | 298 | |
| 262 | 299 | @main.group() |
| 263 | 300 | def add(): |
| 264 | 301 | """Add knowledge nodes — concepts, rules, decisions, people, domains.""" |
| 265 | 302 | |
| @@ -273,13 +310,13 @@ | ||
| 273 | 310 | @click.option("--wiki", default="", help="Wiki URL or reference.") |
| 274 | 311 | @DB_OPTION |
| 275 | 312 | def add_concept(name: str, desc: str, domain: str, status: str, rules: str, wiki: str, db: str): |
| 276 | 313 | """Add a business concept to the knowledge graph.""" |
| 277 | 314 | from navegador.ingestion import KnowledgeIngester |
| 315 | + | |
| 278 | 316 | k = KnowledgeIngester(_get_store(db)) |
| 279 | - k.add_concept(name, description=desc, domain=domain, status=status, | |
| 280 | - rules=rules, wiki_refs=wiki) | |
| 317 | + k.add_concept(name, description=desc, domain=domain, status=status, rules=rules, wiki_refs=wiki) | |
| 281 | 318 | console.print(f"[green]Concept added:[/green] {name}") |
| 282 | 319 | |
| 283 | 320 | |
| 284 | 321 | @add.command("rule") |
| 285 | 322 | @click.argument("name") |
| @@ -289,10 +326,11 @@ | ||
| 289 | 326 | @click.option("--rationale", default="") |
| 290 | 327 | @DB_OPTION |
| 291 | 328 | def add_rule(name: str, desc: str, domain: str, severity: str, rationale: str, db: str): |
| 292 | 329 | """Add a business rule or constraint.""" |
| 293 | 330 | from navegador.ingestion import KnowledgeIngester |
| 331 | + | |
| 294 | 332 | k = KnowledgeIngester(_get_store(db)) |
| 295 | 333 | k.add_rule(name, description=desc, domain=domain, severity=severity, rationale=rationale) |
| 296 | 334 | console.print(f"[green]Rule added:[/green] {name}") |
| 297 | 335 | |
| 298 | 336 | |
| @@ -301,19 +339,28 @@ | ||
| 301 | 339 | @click.option("--desc", default="") |
| 302 | 340 | @click.option("--domain", default="") |
| 303 | 341 | @click.option("--rationale", default="") |
| 304 | 342 | @click.option("--alternatives", default="") |
| 305 | 343 | @click.option("--date", default="") |
| 306 | -@click.option("--status", default="accepted", | |
| 307 | - type=click.Choice(["proposed", "accepted", "deprecated"])) | |
| 344 | +@click.option( | |
| 345 | + "--status", default="accepted", type=click.Choice(["proposed", "accepted", "deprecated"]) | |
| 346 | +) | |
| 308 | 347 | @DB_OPTION |
| 309 | 348 | def add_decision(name, desc, domain, rationale, alternatives, date, status, db): |
| 310 | 349 | """Add an architectural or product decision.""" |
| 311 | 350 | from navegador.ingestion import KnowledgeIngester |
| 351 | + | |
| 312 | 352 | k = KnowledgeIngester(_get_store(db)) |
| 313 | - k.add_decision(name, description=desc, domain=domain, status=status, | |
| 314 | - rationale=rationale, alternatives=alternatives, date=date) | |
| 353 | + k.add_decision( | |
| 354 | + name, | |
| 355 | + description=desc, | |
| 356 | + domain=domain, | |
| 357 | + status=status, | |
| 358 | + rationale=rationale, | |
| 359 | + alternatives=alternatives, | |
| 360 | + date=date, | |
| 361 | + ) | |
| 315 | 362 | console.print(f"[green]Decision added:[/green] {name}") |
| 316 | 363 | |
| 317 | 364 | |
| 318 | 365 | @add.command("person") |
| 319 | 366 | @click.argument("name") |
| @@ -322,10 +369,11 @@ | ||
| 322 | 369 | @click.option("--team", default="") |
| 323 | 370 | @DB_OPTION |
| 324 | 371 | def add_person(name: str, email: str, role: str, team: str, db: str): |
| 325 | 372 | """Add a person (contributor, owner, stakeholder).""" |
| 326 | 373 | from navegador.ingestion import KnowledgeIngester |
| 374 | + | |
| 327 | 375 | k = KnowledgeIngester(_get_store(db)) |
| 328 | 376 | k.add_person(name, email=email, role=role, team=team) |
| 329 | 377 | console.print(f"[green]Person added:[/green] {name}") |
| 330 | 378 | |
| 331 | 379 | |
| @@ -334,42 +382,50 @@ | ||
| 334 | 382 | @click.option("--desc", default="") |
| 335 | 383 | @DB_OPTION |
| 336 | 384 | def add_domain(name: str, desc: str, db: str): |
| 337 | 385 | """Add a business domain (auth, billing, notifications…).""" |
| 338 | 386 | from navegador.ingestion import KnowledgeIngester |
| 387 | + | |
| 339 | 388 | k = KnowledgeIngester(_get_store(db)) |
| 340 | 389 | k.add_domain(name, description=desc) |
| 341 | 390 | console.print(f"[green]Domain added:[/green] {name}") |
| 342 | 391 | |
| 343 | 392 | |
| 344 | 393 | # ── KNOWLEDGE: annotate ─────────────────────────────────────────────────────── |
| 394 | + | |
| 345 | 395 | |
| 346 | 396 | @main.command() |
| 347 | 397 | @click.argument("code_name") |
| 348 | -@click.option("--type", "code_label", default="Function", | |
| 349 | - type=click.Choice(["Function", "Class", "Method", "File", "Module"])) | |
| 398 | +@click.option( | |
| 399 | + "--type", | |
| 400 | + "code_label", | |
| 401 | + default="Function", | |
| 402 | + type=click.Choice(["Function", "Class", "Method", "File", "Module"]), | |
| 403 | +) | |
| 350 | 404 | @click.option("--concept", default="", help="Link to this concept.") |
| 351 | 405 | @click.option("--rule", default="", help="Link to this rule.") |
| 352 | 406 | @DB_OPTION |
| 353 | 407 | def annotate(code_name: str, code_label: str, concept: str, rule: str, db: str): |
| 354 | 408 | """Link a code node to a concept or rule in the knowledge graph.""" |
| 355 | 409 | from navegador.ingestion import KnowledgeIngester |
| 410 | + | |
| 356 | 411 | k = KnowledgeIngester(_get_store(db)) |
| 357 | - k.annotate_code(code_name, code_label, | |
| 358 | - concept=concept or None, rule=rule or None) | |
| 412 | + k.annotate_code(code_name, code_label, concept=concept or None, rule=rule or None) | |
| 359 | 413 | console.print(f"[green]Annotated:[/green] {code_name}") |
| 360 | 414 | |
| 361 | 415 | |
| 362 | 416 | # ── KNOWLEDGE: domain view ──────────────────────────────────────────────────── |
| 417 | + | |
| 363 | 418 | |
| 364 | 419 | @main.command() |
| 365 | 420 | @click.argument("name") |
| 366 | 421 | @DB_OPTION |
| 367 | 422 | @FMT_OPTION |
| 368 | 423 | def domain(name: str, db: str, fmt: str): |
| 369 | 424 | """Show everything belonging to a domain — code and knowledge.""" |
| 370 | 425 | from navegador.context import ContextLoader |
| 426 | + | |
| 371 | 427 | bundle = ContextLoader(_get_store(db)).load_domain(name) |
| 372 | 428 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 373 | 429 | |
| 374 | 430 | |
| 375 | 431 | @main.command() |
| @@ -377,15 +433,17 @@ | ||
| 377 | 433 | @DB_OPTION |
| 378 | 434 | @FMT_OPTION |
| 379 | 435 | def concept(name: str, db: str, fmt: str): |
| 380 | 436 | """Load a business concept — rules, related concepts, implementing code, wiki.""" |
| 381 | 437 | from navegador.context import ContextLoader |
| 438 | + | |
| 382 | 439 | bundle = ContextLoader(_get_store(db)).load_concept(name) |
| 383 | 440 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 384 | 441 | |
| 385 | 442 | |
| 386 | 443 | # ── KNOWLEDGE: wiki ─────────────────────────────────────────────────────────── |
| 444 | + | |
| 387 | 445 | |
| 388 | 446 | @main.group() |
| 389 | 447 | def wiki(): |
| 390 | 448 | """Ingest and manage wiki pages in the knowledge graph.""" |
| 391 | 449 | |
| @@ -397,10 +455,11 @@ | ||
| 397 | 455 | @click.option("--api", is_flag=True, help="Use GitHub API instead of git clone.") |
| 398 | 456 | @DB_OPTION |
| 399 | 457 | def wiki_ingest(repo: str, wiki_dir: str, token: str, api: bool, db: str): |
| 400 | 458 | """Pull wiki pages into the knowledge graph.""" |
| 401 | 459 | from navegador.ingestion import WikiIngester |
| 460 | + | |
| 402 | 461 | w = WikiIngester(_get_store(db)) |
| 403 | 462 | |
| 404 | 463 | if wiki_dir: |
| 405 | 464 | stats = w.ingest_local(wiki_dir) |
| 406 | 465 | elif repo: |
| @@ -413,32 +472,39 @@ | ||
| 413 | 472 | |
| 414 | 473 | console.print(f"[green]Wiki ingested:[/green] {stats['pages']} pages, {stats['links']} links") |
| 415 | 474 | |
| 416 | 475 | |
| 417 | 476 | # ── Stats ───────────────────────────────────────────────────────────────────── |
| 477 | + | |
| 418 | 478 | |
| 419 | 479 | @main.command() |
| 420 | 480 | @DB_OPTION |
| 421 | 481 | @click.option("--json", "as_json", is_flag=True) |
| 422 | 482 | def stats(db: str, as_json: bool): |
| 423 | 483 | """Graph statistics broken down by node and edge type.""" |
| 424 | 484 | from navegador.graph import queries as q |
| 485 | + | |
| 425 | 486 | store = _get_store(db) |
| 426 | 487 | |
| 427 | - node_rows = (store.query(q.NODE_TYPE_COUNTS).result_set or []) | |
| 428 | - edge_rows = (store.query(q.EDGE_TYPE_COUNTS).result_set or []) | |
| 488 | + node_rows = store.query(q.NODE_TYPE_COUNTS).result_set or [] | |
| 489 | + edge_rows = store.query(q.EDGE_TYPE_COUNTS).result_set or [] | |
| 429 | 490 | |
| 430 | 491 | total_nodes = sum(r[1] for r in node_rows) |
| 431 | 492 | total_edges = sum(r[1] for r in edge_rows) |
| 432 | 493 | |
| 433 | 494 | if as_json: |
| 434 | - click.echo(json.dumps({ | |
| 435 | - "total_nodes": total_nodes, | |
| 436 | - "total_edges": total_edges, | |
| 437 | - "nodes": {r[0]: r[1] for r in node_rows}, | |
| 438 | - "edges": {r[0]: r[1] for r in edge_rows}, | |
| 439 | - }, indent=2)) | |
| 495 | + click.echo( | |
| 496 | + json.dumps( | |
| 497 | + { | |
| 498 | + "total_nodes": total_nodes, | |
| 499 | + "total_edges": total_edges, | |
| 500 | + "nodes": {r[0]: r[1] for r in node_rows}, | |
| 501 | + "edges": {r[0]: r[1] for r in edge_rows}, | |
| 502 | + }, | |
| 503 | + indent=2, | |
| 504 | + ) | |
| 505 | + ) | |
| 440 | 506 | return |
| 441 | 507 | |
| 442 | 508 | node_table = Table(title=f"Nodes ({total_nodes:,})") |
| 443 | 509 | node_table.add_column("Type", style="cyan") |
| 444 | 510 | node_table.add_column("Count", justify="right", style="green") |
| @@ -454,22 +520,27 @@ | ||
| 454 | 520 | console.print(node_table) |
| 455 | 521 | console.print(edge_table) |
| 456 | 522 | |
| 457 | 523 | |
| 458 | 524 | # ── PLANOPTICON ingestion ────────────────────────────────────────────────────── |
| 525 | + | |
| 459 | 526 | |
| 460 | 527 | @main.group() |
| 461 | 528 | def planopticon(): |
| 462 | 529 | """Ingest planopticon output (meetings, videos, docs) into the knowledge graph.""" |
| 463 | 530 | |
| 464 | 531 | |
| 465 | 532 | @planopticon.command("ingest") |
| 466 | 533 | @click.argument("path", type=click.Path(exists=True)) |
| 467 | -@click.option("--type", "input_type", | |
| 468 | - type=click.Choice(["auto", "manifest", "kg", "interchange", "batch"]), | |
| 469 | - default="auto", show_default=True, | |
| 470 | - help="Input format. auto detects from filename.") | |
| 534 | +@click.option( | |
| 535 | + "--type", | |
| 536 | + "input_type", | |
| 537 | + type=click.Choice(["auto", "manifest", "kg", "interchange", "batch"]), | |
| 538 | + default="auto", | |
| 539 | + show_default=True, | |
| 540 | + help="Input format. auto detects from filename.", | |
| 541 | +) | |
| 471 | 542 | @click.option("--source", default="", help="Source label for provenance (e.g. 'Q4 planning').") |
| 472 | 543 | @click.option("--json", "as_json", is_flag=True) |
| 473 | 544 | @DB_OPTION |
| 474 | 545 | def planopticon_ingest(path: str, input_type: str, source: str, as_json: bool, db: str): |
| 475 | 546 | """Load a planopticon output directory or file into the knowledge graph. |
| @@ -509,14 +580,14 @@ | ||
| 509 | 580 | input_type = "kg" |
| 510 | 581 | |
| 511 | 582 | ing = PlanopticonIngester(_get_store(db), source_tag=source) |
| 512 | 583 | |
| 513 | 584 | dispatch = { |
| 514 | - "manifest": ing.ingest_manifest, | |
| 515 | - "kg": ing.ingest_kg, | |
| 585 | + "manifest": ing.ingest_manifest, | |
| 586 | + "kg": ing.ingest_kg, | |
| 516 | 587 | "interchange": ing.ingest_interchange, |
| 517 | - "batch": ing.ingest_batch, | |
| 588 | + "batch": ing.ingest_batch, | |
| 518 | 589 | } |
| 519 | 590 | stats = dispatch[input_type](p) |
| 520 | 591 | |
| 521 | 592 | if as_json: |
| 522 | 593 | click.echo(json.dumps(stats, indent=2)) |
| @@ -528,10 +599,11 @@ | ||
| 528 | 599 | table.add_row(k.capitalize(), str(v)) |
| 529 | 600 | console.print(table) |
| 530 | 601 | |
| 531 | 602 | |
| 532 | 603 | # ── MCP ─────────────────────────────────────────────────────────────────────── |
| 604 | + | |
| 533 | 605 | |
| 534 | 606 | @main.command() |
| 535 | 607 | @DB_OPTION |
| 536 | 608 | def mcp(db: str): |
| 537 | 609 | """Start the MCP server for AI agent integration (stdio).""" |
| 538 | 610 |
| --- navegador/cli/commands.py | |
| +++ navegador/cli/commands.py | |
| @@ -18,19 +18,22 @@ | |
| 18 | |
| 19 | DB_OPTION = click.option( |
| 20 | "--db", default=".navegador/graph.db", show_default=True, help="Graph DB path." |
| 21 | ) |
| 22 | FMT_OPTION = click.option( |
| 23 | "--format", "fmt", |
| 24 | type=click.Choice(["markdown", "json"]), |
| 25 | default="markdown", show_default=True, |
| 26 | help="Output format. Use json for agent/pipe consumption.", |
| 27 | ) |
| 28 | |
| 29 | |
| 30 | def _get_store(db: str): |
| 31 | from navegador.config import DEFAULT_DB_PATH, get_store |
| 32 | return get_store(db if db != DEFAULT_DB_PATH else None) |
| 33 | |
| 34 | |
| 35 | def _emit(text: str, fmt: str) -> None: |
| 36 | if fmt == "json": |
| @@ -38,10 +41,11 @@ | |
| 38 | else: |
| 39 | console.print(text) |
| 40 | |
| 41 | |
| 42 | # ── Root group ──────────────────────────────────────────────────────────────── |
| 43 | |
| 44 | @click.group() |
| 45 | @click.version_option(package_name="navegador") |
| 46 | def main(): |
| 47 | """Navegador — project knowledge graph for AI coding agents. |
| @@ -51,15 +55,20 @@ | |
| 51 | """ |
| 52 | logging.basicConfig(level=logging.WARNING) |
| 53 | |
| 54 | |
| 55 | # ── Init ────────────────────────────────────────────────────────────────────── |
| 56 | |
| 57 | @main.command() |
| 58 | @click.argument("path", default=".", type=click.Path()) |
| 59 | @click.option("--redis", "redis_url", default="", |
| 60 | help="Redis URL for centralized/production mode (e.g. redis://host:6379).") |
| 61 | def init(path: str, redis_url: str): |
| 62 | """Initialise navegador in a project directory. |
| 63 | |
| 64 | Creates .navegador/ (gitignored), writes .env.example with storage options. |
| 65 | |
| @@ -89,10 +98,11 @@ | |
| 89 | |
| 90 | console.print("\nNext: [bold]navegador ingest .[/bold]") |
| 91 | |
| 92 | |
| 93 | # ── CODE: ingest ────────────────────────────────────────────────────────────── |
| 94 | |
| 95 | @main.command() |
| 96 | @click.argument("repo_path", type=click.Path(exists=True)) |
| 97 | @DB_OPTION |
| 98 | @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.") |
| @@ -117,18 +127,20 @@ | |
| 117 | table.add_row(k.capitalize(), str(v)) |
| 118 | console.print(table) |
| 119 | |
| 120 | |
| 121 | # ── CODE: context / function / class ───────────────────────────────────────── |
| 122 | |
| 123 | @main.command() |
| 124 | @click.argument("file_path") |
| 125 | @DB_OPTION |
| 126 | @FMT_OPTION |
| 127 | def context(file_path: str, db: str, fmt: str): |
| 128 | """Load context for a file — all symbols and their relationships.""" |
| 129 | from navegador.context import ContextLoader |
| 130 | bundle = ContextLoader(_get_store(db)).load_file(file_path) |
| 131 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 132 | |
| 133 | |
| 134 | @main.command() |
| @@ -138,10 +150,11 @@ | |
| 138 | @DB_OPTION |
| 139 | @FMT_OPTION |
| 140 | def function(name: str, file_path: str, db: str, depth: int, fmt: str): |
| 141 | """Load context for a function — callers, callees, decorators.""" |
| 142 | from navegador.context import ContextLoader |
| 143 | bundle = ContextLoader(_get_store(db)).load_function(name, file_path=file_path, depth=depth) |
| 144 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 145 | |
| 146 | |
| 147 | @main.command("class") |
| @@ -150,41 +163,47 @@ | |
| 150 | @DB_OPTION |
| 151 | @FMT_OPTION |
| 152 | def class_(name: str, file_path: str, db: str, fmt: str): |
| 153 | """Load context for a class — methods, inheritance, references.""" |
| 154 | from navegador.context import ContextLoader |
| 155 | bundle = ContextLoader(_get_store(db)).load_class(name, file_path=file_path) |
| 156 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 157 | |
| 158 | |
| 159 | # ── UNIVERSAL: explain ──────────────────────────────────────────────────────── |
| 160 | |
| 161 | @main.command() |
| 162 | @click.argument("name") |
| 163 | @click.option("--file", "file_path", default="") |
| 164 | @DB_OPTION |
| 165 | @FMT_OPTION |
| 166 | def explain(name: str, file_path: str, db: str, fmt: str): |
| 167 | """Full picture: all relationships in and out, code and knowledge layers.""" |
| 168 | from navegador.context import ContextLoader |
| 169 | bundle = ContextLoader(_get_store(db)).explain(name, file_path=file_path) |
| 170 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 171 | |
| 172 | |
| 173 | # ── UNIVERSAL: search ───────────────────────────────────────────────────────── |
| 174 | |
| 175 | @main.command() |
| 176 | @click.argument("query") |
| 177 | @DB_OPTION |
| 178 | @click.option("--limit", default=20, show_default=True) |
| 179 | @click.option("--all", "search_all", is_flag=True, |
| 180 | help="Include knowledge layer (concepts, rules, wiki).") |
| 181 | @click.option("--docs", "by_doc", is_flag=True, help="Search docstrings instead of names.") |
| 182 | @FMT_OPTION |
| 183 | def search(query: str, db: str, limit: int, search_all: bool, by_doc: bool, fmt: str): |
| 184 | """Search symbols, concepts, rules, and wiki pages.""" |
| 185 | from navegador.context import ContextLoader |
| 186 | loader = ContextLoader(_get_store(db)) |
| 187 | |
| 188 | if by_doc: |
| 189 | results = loader.search_by_docstring(query, limit=limit) |
| 190 | elif search_all: |
| @@ -191,16 +210,26 @@ | |
| 191 | results = loader.search_all(query, limit=limit) |
| 192 | else: |
| 193 | results = loader.search(query, limit=limit) |
| 194 | |
| 195 | if fmt == "json": |
| 196 | click.echo(json.dumps([ |
| 197 | {"type": r.type, "name": r.name, "file_path": r.file_path, |
| 198 | "line_start": r.line_start, "docstring": r.docstring, |
| 199 | "description": r.description} |
| 200 | for r in results |
| 201 | ], indent=2)) |
| 202 | return |
| 203 | |
| 204 | if not results: |
| 205 | console.print("[yellow]No results.[/yellow]") |
| 206 | return |
| @@ -215,25 +244,32 @@ | |
| 215 | table.add_row(r.type, r.name, loc, str(r.line_start or "")) |
| 216 | console.print(table) |
| 217 | |
| 218 | |
| 219 | # ── CODE: decorator / query ─────────────────────────────────────────────────── |
| 220 | |
| 221 | @main.command() |
| 222 | @click.argument("decorator_name") |
| 223 | @DB_OPTION |
| 224 | @FMT_OPTION |
| 225 | def decorated(decorator_name: str, db: str, fmt: str): |
| 226 | """Find all functions/methods carrying a decorator.""" |
| 227 | from navegador.context import ContextLoader |
| 228 | results = ContextLoader(_get_store(db)).decorated_by(decorator_name) |
| 229 | |
| 230 | if fmt == "json": |
| 231 | click.echo(json.dumps([ |
| 232 | {"type": r.type, "name": r.name, "file_path": r.file_path, "line": r.line_start} |
| 233 | for r in results |
| 234 | ], indent=2)) |
| 235 | return |
| 236 | |
| 237 | if not results: |
| 238 | console.print(f"[yellow]No functions decorated with @{decorator_name}[/yellow]") |
| 239 | return |
| @@ -256,10 +292,11 @@ | |
| 256 | result = _get_store(db).query(cypher) |
| 257 | click.echo(json.dumps(result.result_set or [], default=str, indent=2)) |
| 258 | |
| 259 | |
| 260 | # ── KNOWLEDGE: add group ────────────────────────────────────────────────────── |
| 261 | |
| 262 | @main.group() |
| 263 | def add(): |
| 264 | """Add knowledge nodes — concepts, rules, decisions, people, domains.""" |
| 265 | |
| @@ -273,13 +310,13 @@ | |
| 273 | @click.option("--wiki", default="", help="Wiki URL or reference.") |
| 274 | @DB_OPTION |
| 275 | def add_concept(name: str, desc: str, domain: str, status: str, rules: str, wiki: str, db: str): |
| 276 | """Add a business concept to the knowledge graph.""" |
| 277 | from navegador.ingestion import KnowledgeIngester |
| 278 | k = KnowledgeIngester(_get_store(db)) |
| 279 | k.add_concept(name, description=desc, domain=domain, status=status, |
| 280 | rules=rules, wiki_refs=wiki) |
| 281 | console.print(f"[green]Concept added:[/green] {name}") |
| 282 | |
| 283 | |
| 284 | @add.command("rule") |
| 285 | @click.argument("name") |
| @@ -289,10 +326,11 @@ | |
| 289 | @click.option("--rationale", default="") |
| 290 | @DB_OPTION |
| 291 | def add_rule(name: str, desc: str, domain: str, severity: str, rationale: str, db: str): |
| 292 | """Add a business rule or constraint.""" |
| 293 | from navegador.ingestion import KnowledgeIngester |
| 294 | k = KnowledgeIngester(_get_store(db)) |
| 295 | k.add_rule(name, description=desc, domain=domain, severity=severity, rationale=rationale) |
| 296 | console.print(f"[green]Rule added:[/green] {name}") |
| 297 | |
| 298 | |
| @@ -301,19 +339,28 @@ | |
| 301 | @click.option("--desc", default="") |
| 302 | @click.option("--domain", default="") |
| 303 | @click.option("--rationale", default="") |
| 304 | @click.option("--alternatives", default="") |
| 305 | @click.option("--date", default="") |
| 306 | @click.option("--status", default="accepted", |
| 307 | type=click.Choice(["proposed", "accepted", "deprecated"])) |
| 308 | @DB_OPTION |
| 309 | def add_decision(name, desc, domain, rationale, alternatives, date, status, db): |
| 310 | """Add an architectural or product decision.""" |
| 311 | from navegador.ingestion import KnowledgeIngester |
| 312 | k = KnowledgeIngester(_get_store(db)) |
| 313 | k.add_decision(name, description=desc, domain=domain, status=status, |
| 314 | rationale=rationale, alternatives=alternatives, date=date) |
| 315 | console.print(f"[green]Decision added:[/green] {name}") |
| 316 | |
| 317 | |
| 318 | @add.command("person") |
| 319 | @click.argument("name") |
| @@ -322,10 +369,11 @@ | |
| 322 | @click.option("--team", default="") |
| 323 | @DB_OPTION |
| 324 | def add_person(name: str, email: str, role: str, team: str, db: str): |
| 325 | """Add a person (contributor, owner, stakeholder).""" |
| 326 | from navegador.ingestion import KnowledgeIngester |
| 327 | k = KnowledgeIngester(_get_store(db)) |
| 328 | k.add_person(name, email=email, role=role, team=team) |
| 329 | console.print(f"[green]Person added:[/green] {name}") |
| 330 | |
| 331 | |
| @@ -334,42 +382,50 @@ | |
| 334 | @click.option("--desc", default="") |
| 335 | @DB_OPTION |
| 336 | def add_domain(name: str, desc: str, db: str): |
| 337 | """Add a business domain (auth, billing, notifications…).""" |
| 338 | from navegador.ingestion import KnowledgeIngester |
| 339 | k = KnowledgeIngester(_get_store(db)) |
| 340 | k.add_domain(name, description=desc) |
| 341 | console.print(f"[green]Domain added:[/green] {name}") |
| 342 | |
| 343 | |
| 344 | # ── KNOWLEDGE: annotate ─────────────────────────────────────────────────────── |
| 345 | |
| 346 | @main.command() |
| 347 | @click.argument("code_name") |
| 348 | @click.option("--type", "code_label", default="Function", |
| 349 | type=click.Choice(["Function", "Class", "Method", "File", "Module"])) |
| 350 | @click.option("--concept", default="", help="Link to this concept.") |
| 351 | @click.option("--rule", default="", help="Link to this rule.") |
| 352 | @DB_OPTION |
| 353 | def annotate(code_name: str, code_label: str, concept: str, rule: str, db: str): |
| 354 | """Link a code node to a concept or rule in the knowledge graph.""" |
| 355 | from navegador.ingestion import KnowledgeIngester |
| 356 | k = KnowledgeIngester(_get_store(db)) |
| 357 | k.annotate_code(code_name, code_label, |
| 358 | concept=concept or None, rule=rule or None) |
| 359 | console.print(f"[green]Annotated:[/green] {code_name}") |
| 360 | |
| 361 | |
| 362 | # ── KNOWLEDGE: domain view ──────────────────────────────────────────────────── |
| 363 | |
| 364 | @main.command() |
| 365 | @click.argument("name") |
| 366 | @DB_OPTION |
| 367 | @FMT_OPTION |
| 368 | def domain(name: str, db: str, fmt: str): |
| 369 | """Show everything belonging to a domain — code and knowledge.""" |
| 370 | from navegador.context import ContextLoader |
| 371 | bundle = ContextLoader(_get_store(db)).load_domain(name) |
| 372 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 373 | |
| 374 | |
| 375 | @main.command() |
| @@ -377,15 +433,17 @@ | |
| 377 | @DB_OPTION |
| 378 | @FMT_OPTION |
| 379 | def concept(name: str, db: str, fmt: str): |
| 380 | """Load a business concept — rules, related concepts, implementing code, wiki.""" |
| 381 | from navegador.context import ContextLoader |
| 382 | bundle = ContextLoader(_get_store(db)).load_concept(name) |
| 383 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 384 | |
| 385 | |
| 386 | # ── KNOWLEDGE: wiki ─────────────────────────────────────────────────────────── |
| 387 | |
| 388 | @main.group() |
| 389 | def wiki(): |
| 390 | """Ingest and manage wiki pages in the knowledge graph.""" |
| 391 | |
| @@ -397,10 +455,11 @@ | |
| 397 | @click.option("--api", is_flag=True, help="Use GitHub API instead of git clone.") |
| 398 | @DB_OPTION |
| 399 | def wiki_ingest(repo: str, wiki_dir: str, token: str, api: bool, db: str): |
| 400 | """Pull wiki pages into the knowledge graph.""" |
| 401 | from navegador.ingestion import WikiIngester |
| 402 | w = WikiIngester(_get_store(db)) |
| 403 | |
| 404 | if wiki_dir: |
| 405 | stats = w.ingest_local(wiki_dir) |
| 406 | elif repo: |
| @@ -413,32 +472,39 @@ | |
| 413 | |
| 414 | console.print(f"[green]Wiki ingested:[/green] {stats['pages']} pages, {stats['links']} links") |
| 415 | |
| 416 | |
| 417 | # ── Stats ───────────────────────────────────────────────────────────────────── |
| 418 | |
| 419 | @main.command() |
| 420 | @DB_OPTION |
| 421 | @click.option("--json", "as_json", is_flag=True) |
| 422 | def stats(db: str, as_json: bool): |
| 423 | """Graph statistics broken down by node and edge type.""" |
| 424 | from navegador.graph import queries as q |
| 425 | store = _get_store(db) |
| 426 | |
| 427 | node_rows = (store.query(q.NODE_TYPE_COUNTS).result_set or []) |
| 428 | edge_rows = (store.query(q.EDGE_TYPE_COUNTS).result_set or []) |
| 429 | |
| 430 | total_nodes = sum(r[1] for r in node_rows) |
| 431 | total_edges = sum(r[1] for r in edge_rows) |
| 432 | |
| 433 | if as_json: |
| 434 | click.echo(json.dumps({ |
| 435 | "total_nodes": total_nodes, |
| 436 | "total_edges": total_edges, |
| 437 | "nodes": {r[0]: r[1] for r in node_rows}, |
| 438 | "edges": {r[0]: r[1] for r in edge_rows}, |
| 439 | }, indent=2)) |
| 440 | return |
| 441 | |
| 442 | node_table = Table(title=f"Nodes ({total_nodes:,})") |
| 443 | node_table.add_column("Type", style="cyan") |
| 444 | node_table.add_column("Count", justify="right", style="green") |
| @@ -454,22 +520,27 @@ | |
| 454 | console.print(node_table) |
| 455 | console.print(edge_table) |
| 456 | |
| 457 | |
| 458 | # ── PLANOPTICON ingestion ────────────────────────────────────────────────────── |
| 459 | |
| 460 | @main.group() |
| 461 | def planopticon(): |
| 462 | """Ingest planopticon output (meetings, videos, docs) into the knowledge graph.""" |
| 463 | |
| 464 | |
| 465 | @planopticon.command("ingest") |
| 466 | @click.argument("path", type=click.Path(exists=True)) |
| 467 | @click.option("--type", "input_type", |
| 468 | type=click.Choice(["auto", "manifest", "kg", "interchange", "batch"]), |
| 469 | default="auto", show_default=True, |
| 470 | help="Input format. auto detects from filename.") |
| 471 | @click.option("--source", default="", help="Source label for provenance (e.g. 'Q4 planning').") |
| 472 | @click.option("--json", "as_json", is_flag=True) |
| 473 | @DB_OPTION |
| 474 | def planopticon_ingest(path: str, input_type: str, source: str, as_json: bool, db: str): |
| 475 | """Load a planopticon output directory or file into the knowledge graph. |
| @@ -509,14 +580,14 @@ | |
| 509 | input_type = "kg" |
| 510 | |
| 511 | ing = PlanopticonIngester(_get_store(db), source_tag=source) |
| 512 | |
| 513 | dispatch = { |
| 514 | "manifest": ing.ingest_manifest, |
| 515 | "kg": ing.ingest_kg, |
| 516 | "interchange": ing.ingest_interchange, |
| 517 | "batch": ing.ingest_batch, |
| 518 | } |
| 519 | stats = dispatch[input_type](p) |
| 520 | |
| 521 | if as_json: |
| 522 | click.echo(json.dumps(stats, indent=2)) |
| @@ -528,10 +599,11 @@ | |
| 528 | table.add_row(k.capitalize(), str(v)) |
| 529 | console.print(table) |
| 530 | |
| 531 | |
| 532 | # ── MCP ─────────────────────────────────────────────────────────────────────── |
| 533 | |
| 534 | @main.command() |
| 535 | @DB_OPTION |
| 536 | def mcp(db: str): |
| 537 | """Start the MCP server for AI agent integration (stdio).""" |
| 538 |
| --- navegador/cli/commands.py | |
| +++ navegador/cli/commands.py | |
| @@ -18,19 +18,22 @@ | |
| 18 | |
| 19 | DB_OPTION = click.option( |
| 20 | "--db", default=".navegador/graph.db", show_default=True, help="Graph DB path." |
| 21 | ) |
| 22 | FMT_OPTION = click.option( |
| 23 | "--format", |
| 24 | "fmt", |
| 25 | type=click.Choice(["markdown", "json"]), |
| 26 | default="markdown", |
| 27 | show_default=True, |
| 28 | help="Output format. Use json for agent/pipe consumption.", |
| 29 | ) |
| 30 | |
| 31 | |
| 32 | def _get_store(db: str): |
| 33 | from navegador.config import DEFAULT_DB_PATH, get_store |
| 34 | |
| 35 | return get_store(db if db != DEFAULT_DB_PATH else None) |
| 36 | |
| 37 | |
| 38 | def _emit(text: str, fmt: str) -> None: |
| 39 | if fmt == "json": |
| @@ -38,10 +41,11 @@ | |
| 41 | else: |
| 42 | console.print(text) |
| 43 | |
| 44 | |
| 45 | # ── Root group ──────────────────────────────────────────────────────────────── |
| 46 | |
| 47 | |
| 48 | @click.group() |
| 49 | @click.version_option(package_name="navegador") |
| 50 | def main(): |
| 51 | """Navegador — project knowledge graph for AI coding agents. |
| @@ -51,15 +55,20 @@ | |
| 55 | """ |
| 56 | logging.basicConfig(level=logging.WARNING) |
| 57 | |
| 58 | |
| 59 | # ── Init ────────────────────────────────────────────────────────────────────── |
| 60 | |
| 61 | |
| 62 | @main.command() |
| 63 | @click.argument("path", default=".", type=click.Path()) |
| 64 | @click.option( |
| 65 | "--redis", |
| 66 | "redis_url", |
| 67 | default="", |
| 68 | help="Redis URL for centralized/production mode (e.g. redis://host:6379).", |
| 69 | ) |
| 70 | def init(path: str, redis_url: str): |
| 71 | """Initialise navegador in a project directory. |
| 72 | |
| 73 | Creates .navegador/ (gitignored), writes .env.example with storage options. |
| 74 | |
| @@ -89,10 +98,11 @@ | |
| 98 | |
| 99 | console.print("\nNext: [bold]navegador ingest .[/bold]") |
| 100 | |
| 101 | |
| 102 | # ── CODE: ingest ────────────────────────────────────────────────────────────── |
| 103 | |
| 104 | |
| 105 | @main.command() |
| 106 | @click.argument("repo_path", type=click.Path(exists=True)) |
| 107 | @DB_OPTION |
| 108 | @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.") |
| @@ -117,18 +127,20 @@ | |
| 127 | table.add_row(k.capitalize(), str(v)) |
| 128 | console.print(table) |
| 129 | |
| 130 | |
| 131 | # ── CODE: context / function / class ───────────────────────────────────────── |
| 132 | |
| 133 | |
| 134 | @main.command() |
| 135 | @click.argument("file_path") |
| 136 | @DB_OPTION |
| 137 | @FMT_OPTION |
| 138 | def context(file_path: str, db: str, fmt: str): |
| 139 | """Load context for a file — all symbols and their relationships.""" |
| 140 | from navegador.context import ContextLoader |
| 141 | |
| 142 | bundle = ContextLoader(_get_store(db)).load_file(file_path) |
| 143 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 144 | |
| 145 | |
| 146 | @main.command() |
| @@ -138,10 +150,11 @@ | |
| 150 | @DB_OPTION |
| 151 | @FMT_OPTION |
| 152 | def function(name: str, file_path: str, db: str, depth: int, fmt: str): |
| 153 | """Load context for a function — callers, callees, decorators.""" |
| 154 | from navegador.context import ContextLoader |
| 155 | |
| 156 | bundle = ContextLoader(_get_store(db)).load_function(name, file_path=file_path, depth=depth) |
| 157 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 158 | |
| 159 | |
| 160 | @main.command("class") |
| @@ -150,41 +163,47 @@ | |
| 163 | @DB_OPTION |
| 164 | @FMT_OPTION |
| 165 | def class_(name: str, file_path: str, db: str, fmt: str): |
| 166 | """Load context for a class — methods, inheritance, references.""" |
| 167 | from navegador.context import ContextLoader |
| 168 | |
| 169 | bundle = ContextLoader(_get_store(db)).load_class(name, file_path=file_path) |
| 170 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 171 | |
| 172 | |
| 173 | # ── UNIVERSAL: explain ──────────────────────────────────────────────────────── |
| 174 | |
| 175 | |
| 176 | @main.command() |
| 177 | @click.argument("name") |
| 178 | @click.option("--file", "file_path", default="") |
| 179 | @DB_OPTION |
| 180 | @FMT_OPTION |
| 181 | def explain(name: str, file_path: str, db: str, fmt: str): |
| 182 | """Full picture: all relationships in and out, code and knowledge layers.""" |
| 183 | from navegador.context import ContextLoader |
| 184 | |
| 185 | bundle = ContextLoader(_get_store(db)).explain(name, file_path=file_path) |
| 186 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 187 | |
| 188 | |
| 189 | # ── UNIVERSAL: search ───────────────────────────────────────────────────────── |
| 190 | |
| 191 | |
| 192 | @main.command() |
| 193 | @click.argument("query") |
| 194 | @DB_OPTION |
| 195 | @click.option("--limit", default=20, show_default=True) |
| 196 | @click.option( |
| 197 | "--all", "search_all", is_flag=True, help="Include knowledge layer (concepts, rules, wiki)." |
| 198 | ) |
| 199 | @click.option("--docs", "by_doc", is_flag=True, help="Search docstrings instead of names.") |
| 200 | @FMT_OPTION |
| 201 | def search(query: str, db: str, limit: int, search_all: bool, by_doc: bool, fmt: str): |
| 202 | """Search symbols, concepts, rules, and wiki pages.""" |
| 203 | from navegador.context import ContextLoader |
| 204 | |
| 205 | loader = ContextLoader(_get_store(db)) |
| 206 | |
| 207 | if by_doc: |
| 208 | results = loader.search_by_docstring(query, limit=limit) |
| 209 | elif search_all: |
| @@ -191,16 +210,26 @@ | |
| 210 | results = loader.search_all(query, limit=limit) |
| 211 | else: |
| 212 | results = loader.search(query, limit=limit) |
| 213 | |
| 214 | if fmt == "json": |
| 215 | click.echo( |
| 216 | json.dumps( |
| 217 | [ |
| 218 | { |
| 219 | "type": r.type, |
| 220 | "name": r.name, |
| 221 | "file_path": r.file_path, |
| 222 | "line_start": r.line_start, |
| 223 | "docstring": r.docstring, |
| 224 | "description": r.description, |
| 225 | } |
| 226 | for r in results |
| 227 | ], |
| 228 | indent=2, |
| 229 | ) |
| 230 | ) |
| 231 | return |
| 232 | |
| 233 | if not results: |
| 234 | console.print("[yellow]No results.[/yellow]") |
| 235 | return |
| @@ -215,25 +244,32 @@ | |
| 244 | table.add_row(r.type, r.name, loc, str(r.line_start or "")) |
| 245 | console.print(table) |
| 246 | |
| 247 | |
| 248 | # ── CODE: decorator / query ─────────────────────────────────────────────────── |
| 249 | |
| 250 | |
| 251 | @main.command() |
| 252 | @click.argument("decorator_name") |
| 253 | @DB_OPTION |
| 254 | @FMT_OPTION |
| 255 | def decorated(decorator_name: str, db: str, fmt: str): |
| 256 | """Find all functions/methods carrying a decorator.""" |
| 257 | from navegador.context import ContextLoader |
| 258 | |
| 259 | results = ContextLoader(_get_store(db)).decorated_by(decorator_name) |
| 260 | |
| 261 | if fmt == "json": |
| 262 | click.echo( |
| 263 | json.dumps( |
| 264 | [ |
| 265 | {"type": r.type, "name": r.name, "file_path": r.file_path, "line": r.line_start} |
| 266 | for r in results |
| 267 | ], |
| 268 | indent=2, |
| 269 | ) |
| 270 | ) |
| 271 | return |
| 272 | |
| 273 | if not results: |
| 274 | console.print(f"[yellow]No functions decorated with @{decorator_name}[/yellow]") |
| 275 | return |
| @@ -256,10 +292,11 @@ | |
| 292 | result = _get_store(db).query(cypher) |
| 293 | click.echo(json.dumps(result.result_set or [], default=str, indent=2)) |
| 294 | |
| 295 | |
| 296 | # ── KNOWLEDGE: add group ────────────────────────────────────────────────────── |
| 297 | |
| 298 | |
| 299 | @main.group() |
| 300 | def add(): |
| 301 | """Add knowledge nodes — concepts, rules, decisions, people, domains.""" |
| 302 | |
| @@ -273,13 +310,13 @@ | |
| 310 | @click.option("--wiki", default="", help="Wiki URL or reference.") |
| 311 | @DB_OPTION |
| 312 | def add_concept(name: str, desc: str, domain: str, status: str, rules: str, wiki: str, db: str): |
| 313 | """Add a business concept to the knowledge graph.""" |
| 314 | from navegador.ingestion import KnowledgeIngester |
| 315 | |
| 316 | k = KnowledgeIngester(_get_store(db)) |
| 317 | k.add_concept(name, description=desc, domain=domain, status=status, rules=rules, wiki_refs=wiki) |
| 318 | console.print(f"[green]Concept added:[/green] {name}") |
| 319 | |
| 320 | |
| 321 | @add.command("rule") |
| 322 | @click.argument("name") |
| @@ -289,10 +326,11 @@ | |
| 326 | @click.option("--rationale", default="") |
| 327 | @DB_OPTION |
| 328 | def add_rule(name: str, desc: str, domain: str, severity: str, rationale: str, db: str): |
| 329 | """Add a business rule or constraint.""" |
| 330 | from navegador.ingestion import KnowledgeIngester |
| 331 | |
| 332 | k = KnowledgeIngester(_get_store(db)) |
| 333 | k.add_rule(name, description=desc, domain=domain, severity=severity, rationale=rationale) |
| 334 | console.print(f"[green]Rule added:[/green] {name}") |
| 335 | |
| 336 | |
| @@ -301,19 +339,28 @@ | |
| 339 | @click.option("--desc", default="") |
| 340 | @click.option("--domain", default="") |
| 341 | @click.option("--rationale", default="") |
| 342 | @click.option("--alternatives", default="") |
| 343 | @click.option("--date", default="") |
| 344 | @click.option( |
| 345 | "--status", default="accepted", type=click.Choice(["proposed", "accepted", "deprecated"]) |
| 346 | ) |
| 347 | @DB_OPTION |
| 348 | def add_decision(name, desc, domain, rationale, alternatives, date, status, db): |
| 349 | """Add an architectural or product decision.""" |
| 350 | from navegador.ingestion import KnowledgeIngester |
| 351 | |
| 352 | k = KnowledgeIngester(_get_store(db)) |
| 353 | k.add_decision( |
| 354 | name, |
| 355 | description=desc, |
| 356 | domain=domain, |
| 357 | status=status, |
| 358 | rationale=rationale, |
| 359 | alternatives=alternatives, |
| 360 | date=date, |
| 361 | ) |
| 362 | console.print(f"[green]Decision added:[/green] {name}") |
| 363 | |
| 364 | |
| 365 | @add.command("person") |
| 366 | @click.argument("name") |
| @@ -322,10 +369,11 @@ | |
| 369 | @click.option("--team", default="") |
| 370 | @DB_OPTION |
| 371 | def add_person(name: str, email: str, role: str, team: str, db: str): |
| 372 | """Add a person (contributor, owner, stakeholder).""" |
| 373 | from navegador.ingestion import KnowledgeIngester |
| 374 | |
| 375 | k = KnowledgeIngester(_get_store(db)) |
| 376 | k.add_person(name, email=email, role=role, team=team) |
| 377 | console.print(f"[green]Person added:[/green] {name}") |
| 378 | |
| 379 | |
| @@ -334,42 +382,50 @@ | |
| 382 | @click.option("--desc", default="") |
| 383 | @DB_OPTION |
| 384 | def add_domain(name: str, desc: str, db: str): |
| 385 | """Add a business domain (auth, billing, notifications…).""" |
| 386 | from navegador.ingestion import KnowledgeIngester |
| 387 | |
| 388 | k = KnowledgeIngester(_get_store(db)) |
| 389 | k.add_domain(name, description=desc) |
| 390 | console.print(f"[green]Domain added:[/green] {name}") |
| 391 | |
| 392 | |
| 393 | # ── KNOWLEDGE: annotate ─────────────────────────────────────────────────────── |
| 394 | |
| 395 | |
| 396 | @main.command() |
| 397 | @click.argument("code_name") |
| 398 | @click.option( |
| 399 | "--type", |
| 400 | "code_label", |
| 401 | default="Function", |
| 402 | type=click.Choice(["Function", "Class", "Method", "File", "Module"]), |
| 403 | ) |
| 404 | @click.option("--concept", default="", help="Link to this concept.") |
| 405 | @click.option("--rule", default="", help="Link to this rule.") |
| 406 | @DB_OPTION |
| 407 | def annotate(code_name: str, code_label: str, concept: str, rule: str, db: str): |
| 408 | """Link a code node to a concept or rule in the knowledge graph.""" |
| 409 | from navegador.ingestion import KnowledgeIngester |
| 410 | |
| 411 | k = KnowledgeIngester(_get_store(db)) |
| 412 | k.annotate_code(code_name, code_label, concept=concept or None, rule=rule or None) |
| 413 | console.print(f"[green]Annotated:[/green] {code_name}") |
| 414 | |
| 415 | |
| 416 | # ── KNOWLEDGE: domain view ──────────────────────────────────────────────────── |
| 417 | |
| 418 | |
| 419 | @main.command() |
| 420 | @click.argument("name") |
| 421 | @DB_OPTION |
| 422 | @FMT_OPTION |
| 423 | def domain(name: str, db: str, fmt: str): |
| 424 | """Show everything belonging to a domain — code and knowledge.""" |
| 425 | from navegador.context import ContextLoader |
| 426 | |
| 427 | bundle = ContextLoader(_get_store(db)).load_domain(name) |
| 428 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 429 | |
| 430 | |
| 431 | @main.command() |
| @@ -377,15 +433,17 @@ | |
| 433 | @DB_OPTION |
| 434 | @FMT_OPTION |
| 435 | def concept(name: str, db: str, fmt: str): |
| 436 | """Load a business concept — rules, related concepts, implementing code, wiki.""" |
| 437 | from navegador.context import ContextLoader |
| 438 | |
| 439 | bundle = ContextLoader(_get_store(db)).load_concept(name) |
| 440 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 441 | |
| 442 | |
| 443 | # ── KNOWLEDGE: wiki ─────────────────────────────────────────────────────────── |
| 444 | |
| 445 | |
| 446 | @main.group() |
| 447 | def wiki(): |
| 448 | """Ingest and manage wiki pages in the knowledge graph.""" |
| 449 | |
| @@ -397,10 +455,11 @@ | |
| 455 | @click.option("--api", is_flag=True, help="Use GitHub API instead of git clone.") |
| 456 | @DB_OPTION |
| 457 | def wiki_ingest(repo: str, wiki_dir: str, token: str, api: bool, db: str): |
| 458 | """Pull wiki pages into the knowledge graph.""" |
| 459 | from navegador.ingestion import WikiIngester |
| 460 | |
| 461 | w = WikiIngester(_get_store(db)) |
| 462 | |
| 463 | if wiki_dir: |
| 464 | stats = w.ingest_local(wiki_dir) |
| 465 | elif repo: |
| @@ -413,32 +472,39 @@ | |
| 472 | |
| 473 | console.print(f"[green]Wiki ingested:[/green] {stats['pages']} pages, {stats['links']} links") |
| 474 | |
| 475 | |
| 476 | # ── Stats ───────────────────────────────────────────────────────────────────── |
| 477 | |
| 478 | |
| 479 | @main.command() |
| 480 | @DB_OPTION |
| 481 | @click.option("--json", "as_json", is_flag=True) |
| 482 | def stats(db: str, as_json: bool): |
| 483 | """Graph statistics broken down by node and edge type.""" |
| 484 | from navegador.graph import queries as q |
| 485 | |
| 486 | store = _get_store(db) |
| 487 | |
| 488 | node_rows = store.query(q.NODE_TYPE_COUNTS).result_set or [] |
| 489 | edge_rows = store.query(q.EDGE_TYPE_COUNTS).result_set or [] |
| 490 | |
| 491 | total_nodes = sum(r[1] for r in node_rows) |
| 492 | total_edges = sum(r[1] for r in edge_rows) |
| 493 | |
| 494 | if as_json: |
| 495 | click.echo( |
| 496 | json.dumps( |
| 497 | { |
| 498 | "total_nodes": total_nodes, |
| 499 | "total_edges": total_edges, |
| 500 | "nodes": {r[0]: r[1] for r in node_rows}, |
| 501 | "edges": {r[0]: r[1] for r in edge_rows}, |
| 502 | }, |
| 503 | indent=2, |
| 504 | ) |
| 505 | ) |
| 506 | return |
| 507 | |
| 508 | node_table = Table(title=f"Nodes ({total_nodes:,})") |
| 509 | node_table.add_column("Type", style="cyan") |
| 510 | node_table.add_column("Count", justify="right", style="green") |
| @@ -454,22 +520,27 @@ | |
| 520 | console.print(node_table) |
| 521 | console.print(edge_table) |
| 522 | |
| 523 | |
| 524 | # ── PLANOPTICON ingestion ────────────────────────────────────────────────────── |
| 525 | |
| 526 | |
| 527 | @main.group() |
| 528 | def planopticon(): |
| 529 | """Ingest planopticon output (meetings, videos, docs) into the knowledge graph.""" |
| 530 | |
| 531 | |
| 532 | @planopticon.command("ingest") |
| 533 | @click.argument("path", type=click.Path(exists=True)) |
| 534 | @click.option( |
| 535 | "--type", |
| 536 | "input_type", |
| 537 | type=click.Choice(["auto", "manifest", "kg", "interchange", "batch"]), |
| 538 | default="auto", |
| 539 | show_default=True, |
| 540 | help="Input format. auto detects from filename.", |
| 541 | ) |
| 542 | @click.option("--source", default="", help="Source label for provenance (e.g. 'Q4 planning').") |
| 543 | @click.option("--json", "as_json", is_flag=True) |
| 544 | @DB_OPTION |
| 545 | def planopticon_ingest(path: str, input_type: str, source: str, as_json: bool, db: str): |
| 546 | """Load a planopticon output directory or file into the knowledge graph. |
| @@ -509,14 +580,14 @@ | |
| 580 | input_type = "kg" |
| 581 | |
| 582 | ing = PlanopticonIngester(_get_store(db), source_tag=source) |
| 583 | |
| 584 | dispatch = { |
| 585 | "manifest": ing.ingest_manifest, |
| 586 | "kg": ing.ingest_kg, |
| 587 | "interchange": ing.ingest_interchange, |
| 588 | "batch": ing.ingest_batch, |
| 589 | } |
| 590 | stats = dispatch[input_type](p) |
| 591 | |
| 592 | if as_json: |
| 593 | click.echo(json.dumps(stats, indent=2)) |
| @@ -528,10 +599,11 @@ | |
| 599 | table.add_row(k.capitalize(), str(v)) |
| 600 | console.print(table) |
| 601 | |
| 602 | |
| 603 | # ── MCP ─────────────────────────────────────────────────────────────────────── |
| 604 | |
| 605 | |
| 606 | @main.command() |
| 607 | @DB_OPTION |
| 608 | def mcp(db: str): |
| 609 | """Start the MCP server for AI agent integration (stdio).""" |
| 610 |
+58
-47
| --- navegador/context/loader.py | ||
| +++ navegador/context/loader.py | ||
| @@ -108,21 +108,22 @@ | ||
| 108 | 108 | def load_file(self, file_path: str) -> ContextBundle: |
| 109 | 109 | """All symbols in a file and their relationships.""" |
| 110 | 110 | result = self.store.query(queries.FILE_CONTENTS, {"path": file_path}) |
| 111 | 111 | target = ContextNode(type="File", name=Path(file_path).name, file_path=file_path) |
| 112 | 112 | nodes = [] |
| 113 | - for row in (result.result_set or []): | |
| 114 | - nodes.append(ContextNode( | |
| 115 | - type=row[0] or "Unknown", | |
| 116 | - name=row[1] or "", | |
| 117 | - file_path=file_path, | |
| 118 | - line_start=row[2], | |
| 119 | - docstring=row[3], | |
| 120 | - signature=row[4], | |
| 121 | - )) | |
| 122 | - return ContextBundle(target=target, nodes=nodes, | |
| 123 | - metadata={"query": "file_contents"}) | |
| 113 | + for row in result.result_set or []: | |
| 114 | + nodes.append( | |
| 115 | + ContextNode( | |
| 116 | + type=row[0] or "Unknown", | |
| 117 | + name=row[1] or "", | |
| 118 | + file_path=file_path, | |
| 119 | + line_start=row[2], | |
| 120 | + docstring=row[3], | |
| 121 | + signature=row[4], | |
| 122 | + ) | |
| 123 | + ) | |
| 124 | + return ContextBundle(target=target, nodes=nodes, metadata={"query": "file_contents"}) | |
| 124 | 125 | |
| 125 | 126 | # ── Code: function ──────────────────────────────────────────────────────── |
| 126 | 127 | |
| 127 | 128 | def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle: |
| 128 | 129 | """Callers, callees, decorators — everything touching this function.""" |
| @@ -131,28 +132,32 @@ | ||
| 131 | 132 | edges: list[dict[str, str]] = [] |
| 132 | 133 | |
| 133 | 134 | params = {"name": name, "file_path": file_path, "depth": depth} |
| 134 | 135 | |
| 135 | 136 | callees = self.store.query(queries.CALLEES, params) |
| 136 | - for row in (callees.result_set or []): | |
| 137 | + for row in callees.result_set or []: | |
| 137 | 138 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 138 | 139 | edges.append({"from": name, "type": "CALLS", "to": row[1]}) |
| 139 | 140 | |
| 140 | 141 | callers = self.store.query(queries.CALLERS, params) |
| 141 | - for row in (callers.result_set or []): | |
| 142 | + for row in callers.result_set or []: | |
| 142 | 143 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 143 | 144 | edges.append({"from": row[1], "type": "CALLS", "to": name}) |
| 144 | 145 | |
| 145 | 146 | decorators = self.store.query( |
| 146 | 147 | queries.DECORATORS_FOR, {"name": name, "file_path": file_path} |
| 147 | 148 | ) |
| 148 | - for row in (decorators.result_set or []): | |
| 149 | + for row in decorators.result_set or []: | |
| 149 | 150 | nodes.append(ContextNode(type="Decorator", name=row[0], file_path=row[1])) |
| 150 | 151 | edges.append({"from": row[0], "type": "DECORATES", "to": name}) |
| 151 | 152 | |
| 152 | - return ContextBundle(target=target, nodes=nodes, edges=edges, | |
| 153 | - metadata={"depth": depth, "query": "function_context"}) | |
| 153 | + return ContextBundle( | |
| 154 | + target=target, | |
| 155 | + nodes=nodes, | |
| 156 | + edges=edges, | |
| 157 | + metadata={"depth": depth, "query": "function_context"}, | |
| 158 | + ) | |
| 154 | 159 | |
| 155 | 160 | # ── Code: class ─────────────────────────────────────────────────────────── |
| 156 | 161 | |
| 157 | 162 | def load_class(self, name: str, file_path: str = "") -> ContextBundle: |
| 158 | 163 | """Methods, parent classes, subclasses, references.""" |
| @@ -159,26 +164,27 @@ | ||
| 159 | 164 | target = ContextNode(type="Class", name=name, file_path=file_path) |
| 160 | 165 | nodes: list[ContextNode] = [] |
| 161 | 166 | edges: list[dict[str, str]] = [] |
| 162 | 167 | |
| 163 | 168 | parents = self.store.query(queries.CLASS_HIERARCHY, {"name": name}) |
| 164 | - for row in (parents.result_set or []): | |
| 169 | + for row in parents.result_set or []: | |
| 165 | 170 | nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1])) |
| 166 | 171 | edges.append({"from": name, "type": "INHERITS", "to": row[0]}) |
| 167 | 172 | |
| 168 | 173 | subs = self.store.query(queries.SUBCLASSES, {"name": name}) |
| 169 | - for row in (subs.result_set or []): | |
| 174 | + for row in subs.result_set or []: | |
| 170 | 175 | nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1])) |
| 171 | 176 | edges.append({"from": row[0], "type": "INHERITS", "to": name}) |
| 172 | 177 | |
| 173 | 178 | refs = self.store.query(queries.REFERENCES_TO, {"name": name, "file_path": ""}) |
| 174 | - for row in (refs.result_set or []): | |
| 179 | + for row in refs.result_set or []: | |
| 175 | 180 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 176 | 181 | edges.append({"from": row[1], "type": "REFERENCES", "to": name}) |
| 177 | 182 | |
| 178 | - return ContextBundle(target=target, nodes=nodes, edges=edges, | |
| 179 | - metadata={"query": "class_context"}) | |
| 183 | + return ContextBundle( | |
| 184 | + target=target, nodes=nodes, edges=edges, metadata={"query": "class_context"} | |
| 185 | + ) | |
| 180 | 186 | |
| 181 | 187 | # ── Universal: explain ──────────────────────────────────────────────────── |
| 182 | 188 | |
| 183 | 189 | def explain(self, name: str, file_path: str = "") -> ContextBundle: |
| 184 | 190 | """ |
| @@ -189,105 +195,110 @@ | ||
| 189 | 195 | target = ContextNode(type="Node", name=name, file_path=file_path) |
| 190 | 196 | nodes: list[ContextNode] = [] |
| 191 | 197 | edges: list[dict[str, str]] = [] |
| 192 | 198 | |
| 193 | 199 | outbound = self.store.query(queries.OUTBOUND, params) |
| 194 | - for row in (outbound.result_set or []): | |
| 200 | + for row in outbound.result_set or []: | |
| 195 | 201 | rel, ntype, nname, npath = row[0], row[1], row[2], row[3] |
| 196 | 202 | nodes.append(ContextNode(type=ntype, name=nname, file_path=npath)) |
| 197 | 203 | edges.append({"from": name, "type": rel, "to": nname}) |
| 198 | 204 | |
| 199 | 205 | inbound = self.store.query(queries.INBOUND, params) |
| 200 | - for row in (inbound.result_set or []): | |
| 206 | + for row in inbound.result_set or []: | |
| 201 | 207 | rel, ntype, nname, npath = row[0], row[1], row[2], row[3] |
| 202 | 208 | nodes.append(ContextNode(type=ntype, name=nname, file_path=npath)) |
| 203 | 209 | edges.append({"from": nname, "type": rel, "to": name}) |
| 204 | 210 | |
| 205 | - return ContextBundle(target=target, nodes=nodes, edges=edges, | |
| 206 | - metadata={"query": "explain"}) | |
| 211 | + return ContextBundle(target=target, nodes=nodes, edges=edges, metadata={"query": "explain"}) | |
| 207 | 212 | |
| 208 | 213 | # ── Knowledge: concept ──────────────────────────────────────────────────── |
| 209 | 214 | |
| 210 | 215 | def load_concept(self, name: str) -> ContextBundle: |
| 211 | 216 | """Concept + governing rules + related concepts + implementing code + wiki pages.""" |
| 212 | 217 | result = self.store.query(queries.CONCEPT_CONTEXT, {"name": name}) |
| 213 | 218 | rows = result.result_set or [] |
| 214 | 219 | |
| 215 | 220 | if not rows: |
| 216 | - return ContextBundle(target=ContextNode(type="Concept", name=name), | |
| 217 | - metadata={"query": "concept_context", "found": False}) | |
| 221 | + return ContextBundle( | |
| 222 | + target=ContextNode(type="Concept", name=name), | |
| 223 | + metadata={"query": "concept_context", "found": False}, | |
| 224 | + ) | |
| 218 | 225 | |
| 219 | 226 | row = rows[0] |
| 220 | 227 | target = ContextNode( |
| 221 | - type="Concept", name=row[0], | |
| 222 | - description=row[1], status=row[2], domain=row[3], | |
| 228 | + type="Concept", | |
| 229 | + name=row[0], | |
| 230 | + description=row[1], | |
| 231 | + status=row[2], | |
| 232 | + domain=row[3], | |
| 223 | 233 | ) |
| 224 | 234 | nodes: list[ContextNode] = [] |
| 225 | 235 | edges: list[dict[str, str]] = [] |
| 226 | 236 | |
| 227 | - for cname in (row[4] or []): | |
| 237 | + for cname in row[4] or []: | |
| 228 | 238 | nodes.append(ContextNode(type="Concept", name=cname)) |
| 229 | 239 | edges.append({"from": name, "type": "RELATED_TO", "to": cname}) |
| 230 | - for rname in (row[5] or []): | |
| 240 | + for rname in row[5] or []: | |
| 231 | 241 | nodes.append(ContextNode(type="Rule", name=rname)) |
| 232 | 242 | edges.append({"from": rname, "type": "GOVERNS", "to": name}) |
| 233 | - for wname in (row[6] or []): | |
| 243 | + for wname in row[6] or []: | |
| 234 | 244 | nodes.append(ContextNode(type="WikiPage", name=wname)) |
| 235 | 245 | edges.append({"from": wname, "type": "DOCUMENTS", "to": name}) |
| 236 | - for iname in (row[7] or []): | |
| 246 | + for iname in row[7] or []: | |
| 237 | 247 | nodes.append(ContextNode(type="Code", name=iname)) |
| 238 | 248 | edges.append({"from": iname, "type": "IMPLEMENTS", "to": name}) |
| 239 | 249 | |
| 240 | - return ContextBundle(target=target, nodes=nodes, edges=edges, | |
| 241 | - metadata={"query": "concept_context"}) | |
| 250 | + return ContextBundle( | |
| 251 | + target=target, nodes=nodes, edges=edges, metadata={"query": "concept_context"} | |
| 252 | + ) | |
| 242 | 253 | |
| 243 | 254 | # ── Knowledge: domain ───────────────────────────────────────────────────── |
| 244 | 255 | |
| 245 | 256 | def load_domain(self, domain: str) -> ContextBundle: |
| 246 | 257 | """Everything belonging to a domain — code and knowledge.""" |
| 247 | 258 | result = self.store.query(queries.DOMAIN_CONTENTS, {"domain": domain}) |
| 248 | 259 | target = ContextNode(type="Domain", name=domain) |
| 249 | 260 | nodes = [ |
| 250 | - ContextNode(type=row[0], name=row[1], file_path=row[2], | |
| 251 | - description=row[3] or None) | |
| 261 | + ContextNode(type=row[0], name=row[1], file_path=row[2], description=row[3] or None) | |
| 252 | 262 | for row in (result.result_set or []) |
| 253 | 263 | ] |
| 254 | - return ContextBundle(target=target, nodes=nodes, | |
| 255 | - metadata={"query": "domain_contents"}) | |
| 264 | + return ContextBundle(target=target, nodes=nodes, metadata={"query": "domain_contents"}) | |
| 256 | 265 | |
| 257 | 266 | # ── Search ──────────────────────────────────────────────────────────────── |
| 258 | 267 | |
| 259 | 268 | def search(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 260 | 269 | """Search code symbols by name.""" |
| 261 | 270 | result = self.store.query(queries.SYMBOL_SEARCH, {"query": query, "limit": limit}) |
| 262 | 271 | return [ |
| 263 | - ContextNode(type=row[0], name=row[1], file_path=row[2], | |
| 264 | - line_start=row[3], docstring=row[4]) | |
| 272 | + ContextNode( | |
| 273 | + type=row[0], name=row[1], file_path=row[2], line_start=row[3], docstring=row[4] | |
| 274 | + ) | |
| 265 | 275 | for row in (result.result_set or []) |
| 266 | 276 | ] |
| 267 | 277 | |
| 268 | 278 | def search_all(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 269 | 279 | """Search everything — code symbols, concepts, rules, decisions, wiki.""" |
| 270 | 280 | result = self.store.query(queries.GLOBAL_SEARCH, {"query": query, "limit": limit}) |
| 271 | 281 | return [ |
| 272 | - ContextNode(type=row[0], name=row[1], file_path=row[2], | |
| 273 | - docstring=row[3], line_start=row[4]) | |
| 282 | + ContextNode( | |
| 283 | + type=row[0], name=row[1], file_path=row[2], docstring=row[3], line_start=row[4] | |
| 284 | + ) | |
| 274 | 285 | for row in (result.result_set or []) |
| 275 | 286 | ] |
| 276 | 287 | |
| 277 | 288 | def search_by_docstring(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 278 | 289 | """Search functions/classes whose docstring contains the query.""" |
| 279 | 290 | result = self.store.query(queries.DOCSTRING_SEARCH, {"query": query, "limit": limit}) |
| 280 | 291 | return [ |
| 281 | - ContextNode(type=row[0], name=row[1], file_path=row[2], | |
| 282 | - line_start=row[3], docstring=row[4]) | |
| 292 | + ContextNode( | |
| 293 | + type=row[0], name=row[1], file_path=row[2], line_start=row[3], docstring=row[4] | |
| 294 | + ) | |
| 283 | 295 | for row in (result.result_set or []) |
| 284 | 296 | ] |
| 285 | 297 | |
| 286 | 298 | def decorated_by(self, decorator_name: str) -> list[ContextNode]: |
| 287 | 299 | """All functions/methods carrying a given decorator.""" |
| 288 | - result = self.store.query(queries.DECORATED_BY, | |
| 289 | - {"decorator_name": decorator_name}) | |
| 300 | + result = self.store.query(queries.DECORATED_BY, {"decorator_name": decorator_name}) | |
| 290 | 301 | return [ |
| 291 | 302 | ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]) |
| 292 | 303 | for row in (result.result_set or []) |
| 293 | 304 | ] |
| 294 | 305 |
| --- navegador/context/loader.py | |
| +++ navegador/context/loader.py | |
| @@ -108,21 +108,22 @@ | |
| 108 | def load_file(self, file_path: str) -> ContextBundle: |
| 109 | """All symbols in a file and their relationships.""" |
| 110 | result = self.store.query(queries.FILE_CONTENTS, {"path": file_path}) |
| 111 | target = ContextNode(type="File", name=Path(file_path).name, file_path=file_path) |
| 112 | nodes = [] |
| 113 | for row in (result.result_set or []): |
| 114 | nodes.append(ContextNode( |
| 115 | type=row[0] or "Unknown", |
| 116 | name=row[1] or "", |
| 117 | file_path=file_path, |
| 118 | line_start=row[2], |
| 119 | docstring=row[3], |
| 120 | signature=row[4], |
| 121 | )) |
| 122 | return ContextBundle(target=target, nodes=nodes, |
| 123 | metadata={"query": "file_contents"}) |
| 124 | |
| 125 | # ── Code: function ──────────────────────────────────────────────────────── |
| 126 | |
| 127 | def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle: |
| 128 | """Callers, callees, decorators — everything touching this function.""" |
| @@ -131,28 +132,32 @@ | |
| 131 | edges: list[dict[str, str]] = [] |
| 132 | |
| 133 | params = {"name": name, "file_path": file_path, "depth": depth} |
| 134 | |
| 135 | callees = self.store.query(queries.CALLEES, params) |
| 136 | for row in (callees.result_set or []): |
| 137 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 138 | edges.append({"from": name, "type": "CALLS", "to": row[1]}) |
| 139 | |
| 140 | callers = self.store.query(queries.CALLERS, params) |
| 141 | for row in (callers.result_set or []): |
| 142 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 143 | edges.append({"from": row[1], "type": "CALLS", "to": name}) |
| 144 | |
| 145 | decorators = self.store.query( |
| 146 | queries.DECORATORS_FOR, {"name": name, "file_path": file_path} |
| 147 | ) |
| 148 | for row in (decorators.result_set or []): |
| 149 | nodes.append(ContextNode(type="Decorator", name=row[0], file_path=row[1])) |
| 150 | edges.append({"from": row[0], "type": "DECORATES", "to": name}) |
| 151 | |
| 152 | return ContextBundle(target=target, nodes=nodes, edges=edges, |
| 153 | metadata={"depth": depth, "query": "function_context"}) |
| 154 | |
| 155 | # ── Code: class ─────────────────────────────────────────────────────────── |
| 156 | |
| 157 | def load_class(self, name: str, file_path: str = "") -> ContextBundle: |
| 158 | """Methods, parent classes, subclasses, references.""" |
| @@ -159,26 +164,27 @@ | |
| 159 | target = ContextNode(type="Class", name=name, file_path=file_path) |
| 160 | nodes: list[ContextNode] = [] |
| 161 | edges: list[dict[str, str]] = [] |
| 162 | |
| 163 | parents = self.store.query(queries.CLASS_HIERARCHY, {"name": name}) |
| 164 | for row in (parents.result_set or []): |
| 165 | nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1])) |
| 166 | edges.append({"from": name, "type": "INHERITS", "to": row[0]}) |
| 167 | |
| 168 | subs = self.store.query(queries.SUBCLASSES, {"name": name}) |
| 169 | for row in (subs.result_set or []): |
| 170 | nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1])) |
| 171 | edges.append({"from": row[0], "type": "INHERITS", "to": name}) |
| 172 | |
| 173 | refs = self.store.query(queries.REFERENCES_TO, {"name": name, "file_path": ""}) |
| 174 | for row in (refs.result_set or []): |
| 175 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 176 | edges.append({"from": row[1], "type": "REFERENCES", "to": name}) |
| 177 | |
| 178 | return ContextBundle(target=target, nodes=nodes, edges=edges, |
| 179 | metadata={"query": "class_context"}) |
| 180 | |
| 181 | # ── Universal: explain ──────────────────────────────────────────────────── |
| 182 | |
| 183 | def explain(self, name: str, file_path: str = "") -> ContextBundle: |
| 184 | """ |
| @@ -189,105 +195,110 @@ | |
| 189 | target = ContextNode(type="Node", name=name, file_path=file_path) |
| 190 | nodes: list[ContextNode] = [] |
| 191 | edges: list[dict[str, str]] = [] |
| 192 | |
| 193 | outbound = self.store.query(queries.OUTBOUND, params) |
| 194 | for row in (outbound.result_set or []): |
| 195 | rel, ntype, nname, npath = row[0], row[1], row[2], row[3] |
| 196 | nodes.append(ContextNode(type=ntype, name=nname, file_path=npath)) |
| 197 | edges.append({"from": name, "type": rel, "to": nname}) |
| 198 | |
| 199 | inbound = self.store.query(queries.INBOUND, params) |
| 200 | for row in (inbound.result_set or []): |
| 201 | rel, ntype, nname, npath = row[0], row[1], row[2], row[3] |
| 202 | nodes.append(ContextNode(type=ntype, name=nname, file_path=npath)) |
| 203 | edges.append({"from": nname, "type": rel, "to": name}) |
| 204 | |
| 205 | return ContextBundle(target=target, nodes=nodes, edges=edges, |
| 206 | metadata={"query": "explain"}) |
| 207 | |
| 208 | # ── Knowledge: concept ──────────────────────────────────────────────────── |
| 209 | |
| 210 | def load_concept(self, name: str) -> ContextBundle: |
| 211 | """Concept + governing rules + related concepts + implementing code + wiki pages.""" |
| 212 | result = self.store.query(queries.CONCEPT_CONTEXT, {"name": name}) |
| 213 | rows = result.result_set or [] |
| 214 | |
| 215 | if not rows: |
| 216 | return ContextBundle(target=ContextNode(type="Concept", name=name), |
| 217 | metadata={"query": "concept_context", "found": False}) |
| 218 | |
| 219 | row = rows[0] |
| 220 | target = ContextNode( |
| 221 | type="Concept", name=row[0], |
| 222 | description=row[1], status=row[2], domain=row[3], |
| 223 | ) |
| 224 | nodes: list[ContextNode] = [] |
| 225 | edges: list[dict[str, str]] = [] |
| 226 | |
| 227 | for cname in (row[4] or []): |
| 228 | nodes.append(ContextNode(type="Concept", name=cname)) |
| 229 | edges.append({"from": name, "type": "RELATED_TO", "to": cname}) |
| 230 | for rname in (row[5] or []): |
| 231 | nodes.append(ContextNode(type="Rule", name=rname)) |
| 232 | edges.append({"from": rname, "type": "GOVERNS", "to": name}) |
| 233 | for wname in (row[6] or []): |
| 234 | nodes.append(ContextNode(type="WikiPage", name=wname)) |
| 235 | edges.append({"from": wname, "type": "DOCUMENTS", "to": name}) |
| 236 | for iname in (row[7] or []): |
| 237 | nodes.append(ContextNode(type="Code", name=iname)) |
| 238 | edges.append({"from": iname, "type": "IMPLEMENTS", "to": name}) |
| 239 | |
| 240 | return ContextBundle(target=target, nodes=nodes, edges=edges, |
| 241 | metadata={"query": "concept_context"}) |
| 242 | |
| 243 | # ── Knowledge: domain ───────────────────────────────────────────────────── |
| 244 | |
| 245 | def load_domain(self, domain: str) -> ContextBundle: |
| 246 | """Everything belonging to a domain — code and knowledge.""" |
| 247 | result = self.store.query(queries.DOMAIN_CONTENTS, {"domain": domain}) |
| 248 | target = ContextNode(type="Domain", name=domain) |
| 249 | nodes = [ |
| 250 | ContextNode(type=row[0], name=row[1], file_path=row[2], |
| 251 | description=row[3] or None) |
| 252 | for row in (result.result_set or []) |
| 253 | ] |
| 254 | return ContextBundle(target=target, nodes=nodes, |
| 255 | metadata={"query": "domain_contents"}) |
| 256 | |
| 257 | # ── Search ──────────────────────────────────────────────────────────────── |
| 258 | |
| 259 | def search(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 260 | """Search code symbols by name.""" |
| 261 | result = self.store.query(queries.SYMBOL_SEARCH, {"query": query, "limit": limit}) |
| 262 | return [ |
| 263 | ContextNode(type=row[0], name=row[1], file_path=row[2], |
| 264 | line_start=row[3], docstring=row[4]) |
| 265 | for row in (result.result_set or []) |
| 266 | ] |
| 267 | |
| 268 | def search_all(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 269 | """Search everything — code symbols, concepts, rules, decisions, wiki.""" |
| 270 | result = self.store.query(queries.GLOBAL_SEARCH, {"query": query, "limit": limit}) |
| 271 | return [ |
| 272 | ContextNode(type=row[0], name=row[1], file_path=row[2], |
| 273 | docstring=row[3], line_start=row[4]) |
| 274 | for row in (result.result_set or []) |
| 275 | ] |
| 276 | |
| 277 | def search_by_docstring(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 278 | """Search functions/classes whose docstring contains the query.""" |
| 279 | result = self.store.query(queries.DOCSTRING_SEARCH, {"query": query, "limit": limit}) |
| 280 | return [ |
| 281 | ContextNode(type=row[0], name=row[1], file_path=row[2], |
| 282 | line_start=row[3], docstring=row[4]) |
| 283 | for row in (result.result_set or []) |
| 284 | ] |
| 285 | |
| 286 | def decorated_by(self, decorator_name: str) -> list[ContextNode]: |
| 287 | """All functions/methods carrying a given decorator.""" |
| 288 | result = self.store.query(queries.DECORATED_BY, |
| 289 | {"decorator_name": decorator_name}) |
| 290 | return [ |
| 291 | ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]) |
| 292 | for row in (result.result_set or []) |
| 293 | ] |
| 294 |
| --- navegador/context/loader.py | |
| +++ navegador/context/loader.py | |
| @@ -108,21 +108,22 @@ | |
| 108 | def load_file(self, file_path: str) -> ContextBundle: |
| 109 | """All symbols in a file and their relationships.""" |
| 110 | result = self.store.query(queries.FILE_CONTENTS, {"path": file_path}) |
| 111 | target = ContextNode(type="File", name=Path(file_path).name, file_path=file_path) |
| 112 | nodes = [] |
| 113 | for row in result.result_set or []: |
| 114 | nodes.append( |
| 115 | ContextNode( |
| 116 | type=row[0] or "Unknown", |
| 117 | name=row[1] or "", |
| 118 | file_path=file_path, |
| 119 | line_start=row[2], |
| 120 | docstring=row[3], |
| 121 | signature=row[4], |
| 122 | ) |
| 123 | ) |
| 124 | return ContextBundle(target=target, nodes=nodes, metadata={"query": "file_contents"}) |
| 125 | |
| 126 | # ── Code: function ──────────────────────────────────────────────────────── |
| 127 | |
| 128 | def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle: |
| 129 | """Callers, callees, decorators — everything touching this function.""" |
| @@ -131,28 +132,32 @@ | |
| 132 | edges: list[dict[str, str]] = [] |
| 133 | |
| 134 | params = {"name": name, "file_path": file_path, "depth": depth} |
| 135 | |
| 136 | callees = self.store.query(queries.CALLEES, params) |
| 137 | for row in callees.result_set or []: |
| 138 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 139 | edges.append({"from": name, "type": "CALLS", "to": row[1]}) |
| 140 | |
| 141 | callers = self.store.query(queries.CALLERS, params) |
| 142 | for row in callers.result_set or []: |
| 143 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 144 | edges.append({"from": row[1], "type": "CALLS", "to": name}) |
| 145 | |
| 146 | decorators = self.store.query( |
| 147 | queries.DECORATORS_FOR, {"name": name, "file_path": file_path} |
| 148 | ) |
| 149 | for row in decorators.result_set or []: |
| 150 | nodes.append(ContextNode(type="Decorator", name=row[0], file_path=row[1])) |
| 151 | edges.append({"from": row[0], "type": "DECORATES", "to": name}) |
| 152 | |
| 153 | return ContextBundle( |
| 154 | target=target, |
| 155 | nodes=nodes, |
| 156 | edges=edges, |
| 157 | metadata={"depth": depth, "query": "function_context"}, |
| 158 | ) |
| 159 | |
| 160 | # ── Code: class ─────────────────────────────────────────────────────────── |
| 161 | |
| 162 | def load_class(self, name: str, file_path: str = "") -> ContextBundle: |
| 163 | """Methods, parent classes, subclasses, references.""" |
| @@ -159,26 +164,27 @@ | |
| 164 | target = ContextNode(type="Class", name=name, file_path=file_path) |
| 165 | nodes: list[ContextNode] = [] |
| 166 | edges: list[dict[str, str]] = [] |
| 167 | |
| 168 | parents = self.store.query(queries.CLASS_HIERARCHY, {"name": name}) |
| 169 | for row in parents.result_set or []: |
| 170 | nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1])) |
| 171 | edges.append({"from": name, "type": "INHERITS", "to": row[0]}) |
| 172 | |
| 173 | subs = self.store.query(queries.SUBCLASSES, {"name": name}) |
| 174 | for row in subs.result_set or []: |
| 175 | nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1])) |
| 176 | edges.append({"from": row[0], "type": "INHERITS", "to": name}) |
| 177 | |
| 178 | refs = self.store.query(queries.REFERENCES_TO, {"name": name, "file_path": ""}) |
| 179 | for row in refs.result_set or []: |
| 180 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 181 | edges.append({"from": row[1], "type": "REFERENCES", "to": name}) |
| 182 | |
| 183 | return ContextBundle( |
| 184 | target=target, nodes=nodes, edges=edges, metadata={"query": "class_context"} |
| 185 | ) |
| 186 | |
| 187 | # ── Universal: explain ──────────────────────────────────────────────────── |
| 188 | |
| 189 | def explain(self, name: str, file_path: str = "") -> ContextBundle: |
| 190 | """ |
| @@ -189,105 +195,110 @@ | |
| 195 | target = ContextNode(type="Node", name=name, file_path=file_path) |
| 196 | nodes: list[ContextNode] = [] |
| 197 | edges: list[dict[str, str]] = [] |
| 198 | |
| 199 | outbound = self.store.query(queries.OUTBOUND, params) |
| 200 | for row in outbound.result_set or []: |
| 201 | rel, ntype, nname, npath = row[0], row[1], row[2], row[3] |
| 202 | nodes.append(ContextNode(type=ntype, name=nname, file_path=npath)) |
| 203 | edges.append({"from": name, "type": rel, "to": nname}) |
| 204 | |
| 205 | inbound = self.store.query(queries.INBOUND, params) |
| 206 | for row in inbound.result_set or []: |
| 207 | rel, ntype, nname, npath = row[0], row[1], row[2], row[3] |
| 208 | nodes.append(ContextNode(type=ntype, name=nname, file_path=npath)) |
| 209 | edges.append({"from": nname, "type": rel, "to": name}) |
| 210 | |
| 211 | return ContextBundle(target=target, nodes=nodes, edges=edges, metadata={"query": "explain"}) |
| 212 | |
| 213 | # ── Knowledge: concept ──────────────────────────────────────────────────── |
| 214 | |
| 215 | def load_concept(self, name: str) -> ContextBundle: |
| 216 | """Concept + governing rules + related concepts + implementing code + wiki pages.""" |
| 217 | result = self.store.query(queries.CONCEPT_CONTEXT, {"name": name}) |
| 218 | rows = result.result_set or [] |
| 219 | |
| 220 | if not rows: |
| 221 | return ContextBundle( |
| 222 | target=ContextNode(type="Concept", name=name), |
| 223 | metadata={"query": "concept_context", "found": False}, |
| 224 | ) |
| 225 | |
| 226 | row = rows[0] |
| 227 | target = ContextNode( |
| 228 | type="Concept", |
| 229 | name=row[0], |
| 230 | description=row[1], |
| 231 | status=row[2], |
| 232 | domain=row[3], |
| 233 | ) |
| 234 | nodes: list[ContextNode] = [] |
| 235 | edges: list[dict[str, str]] = [] |
| 236 | |
| 237 | for cname in row[4] or []: |
| 238 | nodes.append(ContextNode(type="Concept", name=cname)) |
| 239 | edges.append({"from": name, "type": "RELATED_TO", "to": cname}) |
| 240 | for rname in row[5] or []: |
| 241 | nodes.append(ContextNode(type="Rule", name=rname)) |
| 242 | edges.append({"from": rname, "type": "GOVERNS", "to": name}) |
| 243 | for wname in row[6] or []: |
| 244 | nodes.append(ContextNode(type="WikiPage", name=wname)) |
| 245 | edges.append({"from": wname, "type": "DOCUMENTS", "to": name}) |
| 246 | for iname in row[7] or []: |
| 247 | nodes.append(ContextNode(type="Code", name=iname)) |
| 248 | edges.append({"from": iname, "type": "IMPLEMENTS", "to": name}) |
| 249 | |
| 250 | return ContextBundle( |
| 251 | target=target, nodes=nodes, edges=edges, metadata={"query": "concept_context"} |
| 252 | ) |
| 253 | |
| 254 | # ── Knowledge: domain ───────────────────────────────────────────────────── |
| 255 | |
| 256 | def load_domain(self, domain: str) -> ContextBundle: |
| 257 | """Everything belonging to a domain — code and knowledge.""" |
| 258 | result = self.store.query(queries.DOMAIN_CONTENTS, {"domain": domain}) |
| 259 | target = ContextNode(type="Domain", name=domain) |
| 260 | nodes = [ |
| 261 | ContextNode(type=row[0], name=row[1], file_path=row[2], description=row[3] or None) |
| 262 | for row in (result.result_set or []) |
| 263 | ] |
| 264 | return ContextBundle(target=target, nodes=nodes, metadata={"query": "domain_contents"}) |
| 265 | |
| 266 | # ── Search ──────────────────────────────────────────────────────────────── |
| 267 | |
| 268 | def search(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 269 | """Search code symbols by name.""" |
| 270 | result = self.store.query(queries.SYMBOL_SEARCH, {"query": query, "limit": limit}) |
| 271 | return [ |
| 272 | ContextNode( |
| 273 | type=row[0], name=row[1], file_path=row[2], line_start=row[3], docstring=row[4] |
| 274 | ) |
| 275 | for row in (result.result_set or []) |
| 276 | ] |
| 277 | |
| 278 | def search_all(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 279 | """Search everything — code symbols, concepts, rules, decisions, wiki.""" |
| 280 | result = self.store.query(queries.GLOBAL_SEARCH, {"query": query, "limit": limit}) |
| 281 | return [ |
| 282 | ContextNode( |
| 283 | type=row[0], name=row[1], file_path=row[2], docstring=row[3], line_start=row[4] |
| 284 | ) |
| 285 | for row in (result.result_set or []) |
| 286 | ] |
| 287 | |
| 288 | def search_by_docstring(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 289 | """Search functions/classes whose docstring contains the query.""" |
| 290 | result = self.store.query(queries.DOCSTRING_SEARCH, {"query": query, "limit": limit}) |
| 291 | return [ |
| 292 | ContextNode( |
| 293 | type=row[0], name=row[1], file_path=row[2], line_start=row[3], docstring=row[4] |
| 294 | ) |
| 295 | for row in (result.result_set or []) |
| 296 | ] |
| 297 | |
| 298 | def decorated_by(self, decorator_name: str) -> list[ContextNode]: |
| 299 | """All functions/methods carrying a given decorator.""" |
| 300 | result = self.store.query(queries.DECORATED_BY, {"decorator_name": decorator_name}) |
| 301 | return [ |
| 302 | ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3]) |
| 303 | for row in (result.result_set or []) |
| 304 | ] |
| 305 |
+66
-35
| --- navegador/graph/schema.py | ||
| +++ navegador/graph/schema.py | ||
| @@ -25,38 +25,38 @@ | ||
| 25 | 25 | Variable = "Variable" |
| 26 | 26 | Import = "Import" |
| 27 | 27 | Decorator = "Decorator" |
| 28 | 28 | |
| 29 | 29 | # ── Knowledge layer ─────────────────────────────────────────────────────── |
| 30 | - Domain = "Domain" # logical grouping (auth, billing, notifications…) | |
| 31 | - Concept = "Concept" # a named business entity or idea | |
| 32 | - Rule = "Rule" # a constraint, invariant, or business rule | |
| 33 | - Decision = "Decision" # an architectural or product decision + rationale | |
| 34 | - WikiPage = "WikiPage" # a page from the project wiki (GitHub, Confluence…) | |
| 35 | - Person = "Person" # a contributor, owner, or stakeholder | |
| 30 | + Domain = "Domain" # logical grouping (auth, billing, notifications…) | |
| 31 | + Concept = "Concept" # a named business entity or idea | |
| 32 | + Rule = "Rule" # a constraint, invariant, or business rule | |
| 33 | + Decision = "Decision" # an architectural or product decision + rationale | |
| 34 | + WikiPage = "WikiPage" # a page from the project wiki (GitHub, Confluence…) | |
| 35 | + Person = "Person" # a contributor, owner, or stakeholder | |
| 36 | 36 | |
| 37 | 37 | |
| 38 | 38 | class EdgeType(StrEnum): |
| 39 | 39 | # ── Code structural ─────────────────────────────────────────────────────── |
| 40 | - CONTAINS = "CONTAINS" # File/Class -CONTAINS-> Function/Class/Variable | |
| 41 | - DEFINES = "DEFINES" # Module -DEFINES-> Class/Function | |
| 42 | - IMPORTS = "IMPORTS" # File -IMPORTS-> Module/File | |
| 43 | - DEPENDS_ON = "DEPENDS_ON" # module/package-level dependency | |
| 44 | - CALLS = "CALLS" # Function -CALLS-> Function | |
| 45 | - REFERENCES = "REFERENCES" # Function/Class -REFERENCES-> Variable/Class | |
| 46 | - INHERITS = "INHERITS" # Class -INHERITS-> Class | |
| 47 | - IMPLEMENTS = "IMPLEMENTS" # Class/Function -IMPLEMENTS-> Concept/Rule | |
| 48 | - DECORATES = "DECORATES" # Decorator -DECORATES-> Function/Class | |
| 40 | + CONTAINS = "CONTAINS" # File/Class -CONTAINS-> Function/Class/Variable | |
| 41 | + DEFINES = "DEFINES" # Module -DEFINES-> Class/Function | |
| 42 | + IMPORTS = "IMPORTS" # File -IMPORTS-> Module/File | |
| 43 | + DEPENDS_ON = "DEPENDS_ON" # module/package-level dependency | |
| 44 | + CALLS = "CALLS" # Function -CALLS-> Function | |
| 45 | + REFERENCES = "REFERENCES" # Function/Class -REFERENCES-> Variable/Class | |
| 46 | + INHERITS = "INHERITS" # Class -INHERITS-> Class | |
| 47 | + IMPLEMENTS = "IMPLEMENTS" # Class/Function -IMPLEMENTS-> Concept/Rule | |
| 48 | + DECORATES = "DECORATES" # Decorator -DECORATES-> Function/Class | |
| 49 | 49 | |
| 50 | 50 | # ── Knowledge structural ────────────────────────────────────────────────── |
| 51 | - BELONGS_TO = "BELONGS_TO" # any node -BELONGS_TO-> Domain | |
| 52 | - RELATED_TO = "RELATED_TO" # Concept -RELATED_TO-> Concept (bidirectional intent) | |
| 53 | - GOVERNS = "GOVERNS" # Rule -GOVERNS-> Concept/Function/Class | |
| 54 | - DOCUMENTS = "DOCUMENTS" # WikiPage/Decision -DOCUMENTS-> any node | |
| 55 | - ANNOTATES = "ANNOTATES" # Concept/Rule -ANNOTATES-> code node (lightweight link) | |
| 56 | - ASSIGNED_TO = "ASSIGNED_TO" # any node -ASSIGNED_TO-> Person (ownership) | |
| 57 | - DECIDED_BY = "DECIDED_BY" # Decision -DECIDED_BY-> Person | |
| 51 | + BELONGS_TO = "BELONGS_TO" # any node -BELONGS_TO-> Domain | |
| 52 | + RELATED_TO = "RELATED_TO" # Concept -RELATED_TO-> Concept (bidirectional intent) | |
| 53 | + GOVERNS = "GOVERNS" # Rule -GOVERNS-> Concept/Function/Class | |
| 54 | + DOCUMENTS = "DOCUMENTS" # WikiPage/Decision -DOCUMENTS-> any node | |
| 55 | + ANNOTATES = "ANNOTATES" # Concept/Rule -ANNOTATES-> code node (lightweight link) | |
| 56 | + ASSIGNED_TO = "ASSIGNED_TO" # any node -ASSIGNED_TO-> Person (ownership) | |
| 57 | + DECIDED_BY = "DECIDED_BY" # Decision -DECIDED_BY-> Person | |
| 58 | 58 | |
| 59 | 59 | |
| 60 | 60 | # ── Property keys per node label ────────────────────────────────────────────── |
| 61 | 61 | |
| 62 | 62 | NODE_PROPS = { |
| @@ -64,37 +64,68 @@ | ||
| 64 | 64 | NodeLabel.Repository: ["name", "path", "language", "description"], |
| 65 | 65 | NodeLabel.File: ["name", "path", "language", "size", "line_count"], |
| 66 | 66 | NodeLabel.Module: ["name", "file_path", "docstring"], |
| 67 | 67 | NodeLabel.Class: ["name", "file_path", "line_start", "line_end", "docstring", "source"], |
| 68 | 68 | NodeLabel.Function: [ |
| 69 | - "name", "file_path", "line_start", "line_end", "docstring", "source", "signature", | |
| 69 | + "name", | |
| 70 | + "file_path", | |
| 71 | + "line_start", | |
| 72 | + "line_end", | |
| 73 | + "docstring", | |
| 74 | + "source", | |
| 75 | + "signature", | |
| 70 | 76 | ], |
| 71 | 77 | NodeLabel.Method: [ |
| 72 | - "name", "file_path", "line_start", "line_end", | |
| 73 | - "docstring", "source", "signature", "class_name", | |
| 78 | + "name", | |
| 79 | + "file_path", | |
| 80 | + "line_start", | |
| 81 | + "line_end", | |
| 82 | + "docstring", | |
| 83 | + "source", | |
| 84 | + "signature", | |
| 85 | + "class_name", | |
| 74 | 86 | ], |
| 75 | 87 | NodeLabel.Variable: ["name", "file_path", "line_start", "type_annotation"], |
| 76 | 88 | NodeLabel.Import: ["name", "file_path", "line_start", "module", "alias"], |
| 77 | 89 | NodeLabel.Decorator: ["name", "file_path", "line_start"], |
| 78 | - | |
| 79 | 90 | # Knowledge layer |
| 80 | 91 | NodeLabel.Domain: ["name", "description"], |
| 81 | 92 | NodeLabel.Concept: [ |
| 82 | - "name", "description", "domain", "status", | |
| 83 | - "rules", "examples", "wiki_refs", | |
| 93 | + "name", | |
| 94 | + "description", | |
| 95 | + "domain", | |
| 96 | + "status", | |
| 97 | + "rules", | |
| 98 | + "examples", | |
| 99 | + "wiki_refs", | |
| 84 | 100 | ], |
| 85 | 101 | NodeLabel.Rule: [ |
| 86 | - "name", "description", "domain", "severity", # info|warning|critical | |
| 87 | - "rationale", "examples", | |
| 102 | + "name", | |
| 103 | + "description", | |
| 104 | + "domain", | |
| 105 | + "severity", # info|warning|critical | |
| 106 | + "rationale", | |
| 107 | + "examples", | |
| 88 | 108 | ], |
| 89 | 109 | NodeLabel.Decision: [ |
| 90 | - "name", "description", "domain", "status", # proposed|accepted|deprecated | |
| 91 | - "rationale", "alternatives", "date", | |
| 110 | + "name", | |
| 111 | + "description", | |
| 112 | + "domain", | |
| 113 | + "status", # proposed|accepted|deprecated | |
| 114 | + "rationale", | |
| 115 | + "alternatives", | |
| 116 | + "date", | |
| 92 | 117 | ], |
| 93 | 118 | NodeLabel.WikiPage: [ |
| 94 | - "name", "url", "source", # github|confluence|notion|local | |
| 95 | - "content", "updated_at", | |
| 119 | + "name", | |
| 120 | + "url", | |
| 121 | + "source", # github|confluence|notion|local | |
| 122 | + "content", | |
| 123 | + "updated_at", | |
| 96 | 124 | ], |
| 97 | 125 | NodeLabel.Person: [ |
| 98 | - "name", "email", "role", "team", | |
| 126 | + "name", | |
| 127 | + "email", | |
| 128 | + "role", | |
| 129 | + "team", | |
| 99 | 130 | ], |
| 100 | 131 | } |
| 101 | 132 |
| --- navegador/graph/schema.py | |
| +++ navegador/graph/schema.py | |
| @@ -25,38 +25,38 @@ | |
| 25 | Variable = "Variable" |
| 26 | Import = "Import" |
| 27 | Decorator = "Decorator" |
| 28 | |
| 29 | # ── Knowledge layer ─────────────────────────────────────────────────────── |
| 30 | Domain = "Domain" # logical grouping (auth, billing, notifications…) |
| 31 | Concept = "Concept" # a named business entity or idea |
| 32 | Rule = "Rule" # a constraint, invariant, or business rule |
| 33 | Decision = "Decision" # an architectural or product decision + rationale |
| 34 | WikiPage = "WikiPage" # a page from the project wiki (GitHub, Confluence…) |
| 35 | Person = "Person" # a contributor, owner, or stakeholder |
| 36 | |
| 37 | |
| 38 | class EdgeType(StrEnum): |
| 39 | # ── Code structural ─────────────────────────────────────────────────────── |
| 40 | CONTAINS = "CONTAINS" # File/Class -CONTAINS-> Function/Class/Variable |
| 41 | DEFINES = "DEFINES" # Module -DEFINES-> Class/Function |
| 42 | IMPORTS = "IMPORTS" # File -IMPORTS-> Module/File |
| 43 | DEPENDS_ON = "DEPENDS_ON" # module/package-level dependency |
| 44 | CALLS = "CALLS" # Function -CALLS-> Function |
| 45 | REFERENCES = "REFERENCES" # Function/Class -REFERENCES-> Variable/Class |
| 46 | INHERITS = "INHERITS" # Class -INHERITS-> Class |
| 47 | IMPLEMENTS = "IMPLEMENTS" # Class/Function -IMPLEMENTS-> Concept/Rule |
| 48 | DECORATES = "DECORATES" # Decorator -DECORATES-> Function/Class |
| 49 | |
| 50 | # ── Knowledge structural ────────────────────────────────────────────────── |
| 51 | BELONGS_TO = "BELONGS_TO" # any node -BELONGS_TO-> Domain |
| 52 | RELATED_TO = "RELATED_TO" # Concept -RELATED_TO-> Concept (bidirectional intent) |
| 53 | GOVERNS = "GOVERNS" # Rule -GOVERNS-> Concept/Function/Class |
| 54 | DOCUMENTS = "DOCUMENTS" # WikiPage/Decision -DOCUMENTS-> any node |
| 55 | ANNOTATES = "ANNOTATES" # Concept/Rule -ANNOTATES-> code node (lightweight link) |
| 56 | ASSIGNED_TO = "ASSIGNED_TO" # any node -ASSIGNED_TO-> Person (ownership) |
| 57 | DECIDED_BY = "DECIDED_BY" # Decision -DECIDED_BY-> Person |
| 58 | |
| 59 | |
| 60 | # ── Property keys per node label ────────────────────────────────────────────── |
| 61 | |
| 62 | NODE_PROPS = { |
| @@ -64,37 +64,68 @@ | |
| 64 | NodeLabel.Repository: ["name", "path", "language", "description"], |
| 65 | NodeLabel.File: ["name", "path", "language", "size", "line_count"], |
| 66 | NodeLabel.Module: ["name", "file_path", "docstring"], |
| 67 | NodeLabel.Class: ["name", "file_path", "line_start", "line_end", "docstring", "source"], |
| 68 | NodeLabel.Function: [ |
| 69 | "name", "file_path", "line_start", "line_end", "docstring", "source", "signature", |
| 70 | ], |
| 71 | NodeLabel.Method: [ |
| 72 | "name", "file_path", "line_start", "line_end", |
| 73 | "docstring", "source", "signature", "class_name", |
| 74 | ], |
| 75 | NodeLabel.Variable: ["name", "file_path", "line_start", "type_annotation"], |
| 76 | NodeLabel.Import: ["name", "file_path", "line_start", "module", "alias"], |
| 77 | NodeLabel.Decorator: ["name", "file_path", "line_start"], |
| 78 | |
| 79 | # Knowledge layer |
| 80 | NodeLabel.Domain: ["name", "description"], |
| 81 | NodeLabel.Concept: [ |
| 82 | "name", "description", "domain", "status", |
| 83 | "rules", "examples", "wiki_refs", |
| 84 | ], |
| 85 | NodeLabel.Rule: [ |
| 86 | "name", "description", "domain", "severity", # info|warning|critical |
| 87 | "rationale", "examples", |
| 88 | ], |
| 89 | NodeLabel.Decision: [ |
| 90 | "name", "description", "domain", "status", # proposed|accepted|deprecated |
| 91 | "rationale", "alternatives", "date", |
| 92 | ], |
| 93 | NodeLabel.WikiPage: [ |
| 94 | "name", "url", "source", # github|confluence|notion|local |
| 95 | "content", "updated_at", |
| 96 | ], |
| 97 | NodeLabel.Person: [ |
| 98 | "name", "email", "role", "team", |
| 99 | ], |
| 100 | } |
| 101 |
| --- navegador/graph/schema.py | |
| +++ navegador/graph/schema.py | |
| @@ -25,38 +25,38 @@ | |
| 25 | Variable = "Variable" |
| 26 | Import = "Import" |
| 27 | Decorator = "Decorator" |
| 28 | |
| 29 | # ── Knowledge layer ─────────────────────────────────────────────────────── |
| 30 | Domain = "Domain" # logical grouping (auth, billing, notifications…) |
| 31 | Concept = "Concept" # a named business entity or idea |
| 32 | Rule = "Rule" # a constraint, invariant, or business rule |
| 33 | Decision = "Decision" # an architectural or product decision + rationale |
| 34 | WikiPage = "WikiPage" # a page from the project wiki (GitHub, Confluence…) |
| 35 | Person = "Person" # a contributor, owner, or stakeholder |
| 36 | |
| 37 | |
| 38 | class EdgeType(StrEnum): |
| 39 | # ── Code structural ─────────────────────────────────────────────────────── |
| 40 | CONTAINS = "CONTAINS" # File/Class -CONTAINS-> Function/Class/Variable |
| 41 | DEFINES = "DEFINES" # Module -DEFINES-> Class/Function |
| 42 | IMPORTS = "IMPORTS" # File -IMPORTS-> Module/File |
| 43 | DEPENDS_ON = "DEPENDS_ON" # module/package-level dependency |
| 44 | CALLS = "CALLS" # Function -CALLS-> Function |
| 45 | REFERENCES = "REFERENCES" # Function/Class -REFERENCES-> Variable/Class |
| 46 | INHERITS = "INHERITS" # Class -INHERITS-> Class |
| 47 | IMPLEMENTS = "IMPLEMENTS" # Class/Function -IMPLEMENTS-> Concept/Rule |
| 48 | DECORATES = "DECORATES" # Decorator -DECORATES-> Function/Class |
| 49 | |
| 50 | # ── Knowledge structural ────────────────────────────────────────────────── |
| 51 | BELONGS_TO = "BELONGS_TO" # any node -BELONGS_TO-> Domain |
| 52 | RELATED_TO = "RELATED_TO" # Concept -RELATED_TO-> Concept (bidirectional intent) |
| 53 | GOVERNS = "GOVERNS" # Rule -GOVERNS-> Concept/Function/Class |
| 54 | DOCUMENTS = "DOCUMENTS" # WikiPage/Decision -DOCUMENTS-> any node |
| 55 | ANNOTATES = "ANNOTATES" # Concept/Rule -ANNOTATES-> code node (lightweight link) |
| 56 | ASSIGNED_TO = "ASSIGNED_TO" # any node -ASSIGNED_TO-> Person (ownership) |
| 57 | DECIDED_BY = "DECIDED_BY" # Decision -DECIDED_BY-> Person |
| 58 | |
| 59 | |
| 60 | # ── Property keys per node label ────────────────────────────────────────────── |
| 61 | |
| 62 | NODE_PROPS = { |
| @@ -64,37 +64,68 @@ | |
| 64 | NodeLabel.Repository: ["name", "path", "language", "description"], |
| 65 | NodeLabel.File: ["name", "path", "language", "size", "line_count"], |
| 66 | NodeLabel.Module: ["name", "file_path", "docstring"], |
| 67 | NodeLabel.Class: ["name", "file_path", "line_start", "line_end", "docstring", "source"], |
| 68 | NodeLabel.Function: [ |
| 69 | "name", |
| 70 | "file_path", |
| 71 | "line_start", |
| 72 | "line_end", |
| 73 | "docstring", |
| 74 | "source", |
| 75 | "signature", |
| 76 | ], |
| 77 | NodeLabel.Method: [ |
| 78 | "name", |
| 79 | "file_path", |
| 80 | "line_start", |
| 81 | "line_end", |
| 82 | "docstring", |
| 83 | "source", |
| 84 | "signature", |
| 85 | "class_name", |
| 86 | ], |
| 87 | NodeLabel.Variable: ["name", "file_path", "line_start", "type_annotation"], |
| 88 | NodeLabel.Import: ["name", "file_path", "line_start", "module", "alias"], |
| 89 | NodeLabel.Decorator: ["name", "file_path", "line_start"], |
| 90 | # Knowledge layer |
| 91 | NodeLabel.Domain: ["name", "description"], |
| 92 | NodeLabel.Concept: [ |
| 93 | "name", |
| 94 | "description", |
| 95 | "domain", |
| 96 | "status", |
| 97 | "rules", |
| 98 | "examples", |
| 99 | "wiki_refs", |
| 100 | ], |
| 101 | NodeLabel.Rule: [ |
| 102 | "name", |
| 103 | "description", |
| 104 | "domain", |
| 105 | "severity", # info|warning|critical |
| 106 | "rationale", |
| 107 | "examples", |
| 108 | ], |
| 109 | NodeLabel.Decision: [ |
| 110 | "name", |
| 111 | "description", |
| 112 | "domain", |
| 113 | "status", # proposed|accepted|deprecated |
| 114 | "rationale", |
| 115 | "alternatives", |
| 116 | "date", |
| 117 | ], |
| 118 | NodeLabel.WikiPage: [ |
| 119 | "name", |
| 120 | "url", |
| 121 | "source", # github|confluence|notion|local |
| 122 | "content", |
| 123 | "updated_at", |
| 124 | ], |
| 125 | NodeLabel.Person: [ |
| 126 | "name", |
| 127 | "email", |
| 128 | "role", |
| 129 | "team", |
| 130 | ], |
| 131 | } |
| 132 |
+1
-4
| --- navegador/graph/store.py | ||
| +++ navegador/graph/store.py | ||
| @@ -74,14 +74,11 @@ | ||
| 74 | 74 | return self._graph.query(cypher, params or {}) |
| 75 | 75 | |
| 76 | 76 | def create_node(self, label: str, props: dict[str, Any]) -> None: |
| 77 | 77 | """Upsert a node by (label, name, file_path).""" |
| 78 | 78 | prop_str = ", ".join(f"n.{k} = ${k}" for k in props) |
| 79 | - cypher = ( | |
| 80 | - f"MERGE (n:{label} {{name: $name, file_path: $file_path}}) " | |
| 81 | - f"SET {prop_str}" | |
| 82 | - ) | |
| 79 | + cypher = f"MERGE (n:{label} {{name: $name, file_path: $file_path}}) SET {prop_str}" | |
| 83 | 80 | self.query(cypher, props) |
| 84 | 81 | |
| 85 | 82 | def create_edge( |
| 86 | 83 | self, |
| 87 | 84 | from_label: str, |
| 88 | 85 |
| --- navegador/graph/store.py | |
| +++ navegador/graph/store.py | |
| @@ -74,14 +74,11 @@ | |
| 74 | return self._graph.query(cypher, params or {}) |
| 75 | |
| 76 | def create_node(self, label: str, props: dict[str, Any]) -> None: |
| 77 | """Upsert a node by (label, name, file_path).""" |
| 78 | prop_str = ", ".join(f"n.{k} = ${k}" for k in props) |
| 79 | cypher = ( |
| 80 | f"MERGE (n:{label} {{name: $name, file_path: $file_path}}) " |
| 81 | f"SET {prop_str}" |
| 82 | ) |
| 83 | self.query(cypher, props) |
| 84 | |
| 85 | def create_edge( |
| 86 | self, |
| 87 | from_label: str, |
| 88 |
| --- navegador/graph/store.py | |
| +++ navegador/graph/store.py | |
| @@ -74,14 +74,11 @@ | |
| 74 | return self._graph.query(cypher, params or {}) |
| 75 | |
| 76 | def create_node(self, label: str, props: dict[str, Any]) -> None: |
| 77 | """Upsert a node by (label, name, file_path).""" |
| 78 | prop_str = ", ".join(f"n.{k} = ${k}" for k in props) |
| 79 | cypher = f"MERGE (n:{label} {{name: $name, file_path: $file_path}}) SET {prop_str}" |
| 80 | self.query(cypher, props) |
| 81 | |
| 82 | def create_edge( |
| 83 | self, |
| 84 | from_label: str, |
| 85 |
+93
-58
| --- navegador/ingestion/go.py | ||
| +++ navegador/ingestion/go.py | ||
| @@ -15,46 +15,50 @@ | ||
| 15 | 15 | |
| 16 | 16 | def _get_go_language(): |
| 17 | 17 | try: |
| 18 | 18 | import tree_sitter_go as tsgo # type: ignore[import] |
| 19 | 19 | from tree_sitter import Language |
| 20 | + | |
| 20 | 21 | return Language(tsgo.language()) |
| 21 | 22 | except ImportError as e: |
| 22 | 23 | raise ImportError("Install tree-sitter-go: pip install tree-sitter-go") from e |
| 23 | 24 | |
| 24 | 25 | |
| 25 | 26 | def _node_text(node, source: bytes) -> str: |
| 26 | - return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") | |
| 27 | + return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") | |
| 27 | 28 | |
| 28 | 29 | |
| 29 | 30 | class GoParser(LanguageParser): |
| 30 | 31 | """Parses Go source files into the navegador graph.""" |
| 31 | 32 | |
| 32 | 33 | def __init__(self) -> None: |
| 33 | 34 | from tree_sitter import Parser # type: ignore[import] |
| 35 | + | |
| 34 | 36 | self._parser = Parser(_get_go_language()) |
| 35 | 37 | |
| 36 | 38 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 37 | 39 | source = path.read_bytes() |
| 38 | 40 | tree = self._parser.parse(source) |
| 39 | 41 | rel_path = str(path.relative_to(repo_root)) |
| 40 | 42 | |
| 41 | - store.create_node(NodeLabel.File, { | |
| 42 | - "name": path.name, | |
| 43 | - "path": rel_path, | |
| 44 | - "language": "go", | |
| 45 | - "line_count": source.count(b"\n"), | |
| 46 | - }) | |
| 43 | + store.create_node( | |
| 44 | + NodeLabel.File, | |
| 45 | + { | |
| 46 | + "name": path.name, | |
| 47 | + "path": rel_path, | |
| 48 | + "language": "go", | |
| 49 | + "line_count": source.count(b"\n"), | |
| 50 | + }, | |
| 51 | + ) | |
| 47 | 52 | |
| 48 | 53 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 49 | 54 | self._walk(tree.root_node, source, rel_path, store, stats) |
| 50 | 55 | return stats |
| 51 | 56 | |
| 52 | 57 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 53 | 58 | |
| 54 | - def _walk(self, node, source: bytes, file_path: str, | |
| 55 | - store: GraphStore, stats: dict) -> None: | |
| 59 | + def _walk(self, node, source: bytes, file_path: str, store: GraphStore, stats: dict) -> None: | |
| 56 | 60 | if node.type == "function_declaration": |
| 57 | 61 | self._handle_function(node, source, file_path, store, stats, receiver=None) |
| 58 | 62 | return |
| 59 | 63 | if node.type == "method_declaration": |
| 60 | 64 | self._handle_method(node, source, file_path, store, stats) |
| @@ -68,59 +72,70 @@ | ||
| 68 | 72 | for child in node.children: |
| 69 | 73 | self._walk(child, source, file_path, store, stats) |
| 70 | 74 | |
| 71 | 75 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 72 | 76 | |
| 73 | - def _handle_function(self, node, source: bytes, file_path: str, | |
| 74 | - store: GraphStore, stats: dict, | |
| 75 | - receiver: str | None) -> None: | |
| 77 | + def _handle_function( | |
| 78 | + self, | |
| 79 | + node, | |
| 80 | + source: bytes, | |
| 81 | + file_path: str, | |
| 82 | + store: GraphStore, | |
| 83 | + stats: dict, | |
| 84 | + receiver: str | None, | |
| 85 | + ) -> None: | |
| 76 | 86 | name_node = node.child_by_field_name("name") |
| 77 | 87 | if not name_node: |
| 78 | 88 | return |
| 79 | 89 | name = _node_text(name_node, source) |
| 80 | 90 | label = NodeLabel.Method if receiver else NodeLabel.Function |
| 81 | 91 | |
| 82 | - store.create_node(label, { | |
| 83 | - "name": name, | |
| 84 | - "file_path": file_path, | |
| 85 | - "line_start": node.start_point[0] + 1, | |
| 86 | - "line_end": node.end_point[0] + 1, | |
| 87 | - "docstring": "", | |
| 88 | - "class_name": receiver or "", | |
| 89 | - }) | |
| 92 | + store.create_node( | |
| 93 | + label, | |
| 94 | + { | |
| 95 | + "name": name, | |
| 96 | + "file_path": file_path, | |
| 97 | + "line_start": node.start_point[0] + 1, | |
| 98 | + "line_end": node.end_point[0] + 1, | |
| 99 | + "docstring": "", | |
| 100 | + "class_name": receiver or "", | |
| 101 | + }, | |
| 102 | + ) | |
| 90 | 103 | |
| 91 | 104 | container_label = NodeLabel.Class if receiver else NodeLabel.File |
| 92 | 105 | container_key = ( |
| 93 | - {"name": receiver, "file_path": file_path} | |
| 94 | - if receiver else {"path": file_path} | |
| 106 | + {"name": receiver, "file_path": file_path} if receiver else {"path": file_path} | |
| 95 | 107 | ) |
| 96 | 108 | store.create_edge( |
| 97 | - container_label, container_key, | |
| 109 | + container_label, | |
| 110 | + container_key, | |
| 98 | 111 | EdgeType.CONTAINS, |
| 99 | - label, {"name": name, "file_path": file_path}, | |
| 112 | + label, | |
| 113 | + {"name": name, "file_path": file_path}, | |
| 100 | 114 | ) |
| 101 | 115 | stats["functions"] += 1 |
| 102 | 116 | stats["edges"] += 1 |
| 103 | 117 | |
| 104 | 118 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 105 | 119 | |
| 106 | - def _handle_method(self, node, source: bytes, file_path: str, | |
| 107 | - store: GraphStore, stats: dict) -> None: | |
| 120 | + def _handle_method( | |
| 121 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 122 | + ) -> None: | |
| 108 | 123 | receiver_type = "" |
| 109 | 124 | recv_node = node.child_by_field_name("receiver") |
| 110 | 125 | if recv_node: |
| 111 | 126 | for child in recv_node.children: |
| 112 | 127 | if child.type == "parameter_declaration": |
| 113 | 128 | for c in child.children: |
| 114 | 129 | if c.type in ("type_identifier", "pointer_type"): |
| 115 | 130 | receiver_type = _node_text(c, source).lstrip("*").strip() |
| 116 | 131 | break |
| 117 | - self._handle_function(node, source, file_path, store, stats, | |
| 118 | - receiver=receiver_type or None) | |
| 132 | + self._handle_function(node, source, file_path, store, stats, receiver=receiver_type or None) | |
| 119 | 133 | |
| 120 | - def _handle_type(self, node, source: bytes, file_path: str, | |
| 121 | - store: GraphStore, stats: dict) -> None: | |
| 134 | + def _handle_type( | |
| 135 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 136 | + ) -> None: | |
| 122 | 137 | for child in node.children: |
| 123 | 138 | if child.type != "type_spec": |
| 124 | 139 | continue |
| 125 | 140 | name_node = child.child_by_field_name("name") |
| 126 | 141 | type_node = child.child_by_field_name("type") |
| @@ -128,71 +143,91 @@ | ||
| 128 | 143 | continue |
| 129 | 144 | if type_node.type not in ("struct_type", "interface_type"): |
| 130 | 145 | continue |
| 131 | 146 | name = _node_text(name_node, source) |
| 132 | 147 | kind = "struct" if type_node.type == "struct_type" else "interface" |
| 133 | - store.create_node(NodeLabel.Class, { | |
| 134 | - "name": name, | |
| 135 | - "file_path": file_path, | |
| 136 | - "line_start": node.start_point[0] + 1, | |
| 137 | - "line_end": node.end_point[0] + 1, | |
| 138 | - "docstring": kind, | |
| 139 | - }) | |
| 148 | + store.create_node( | |
| 149 | + NodeLabel.Class, | |
| 150 | + { | |
| 151 | + "name": name, | |
| 152 | + "file_path": file_path, | |
| 153 | + "line_start": node.start_point[0] + 1, | |
| 154 | + "line_end": node.end_point[0] + 1, | |
| 155 | + "docstring": kind, | |
| 156 | + }, | |
| 157 | + ) | |
| 140 | 158 | store.create_edge( |
| 141 | - NodeLabel.File, {"path": file_path}, | |
| 159 | + NodeLabel.File, | |
| 160 | + {"path": file_path}, | |
| 142 | 161 | EdgeType.CONTAINS, |
| 143 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 162 | + NodeLabel.Class, | |
| 163 | + {"name": name, "file_path": file_path}, | |
| 144 | 164 | ) |
| 145 | 165 | stats["classes"] += 1 |
| 146 | 166 | stats["edges"] += 1 |
| 147 | 167 | |
| 148 | - def _handle_import(self, node, source: bytes, file_path: str, | |
| 149 | - store: GraphStore, stats: dict) -> None: | |
| 168 | + def _handle_import( | |
| 169 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 170 | + ) -> None: | |
| 150 | 171 | line_start = node.start_point[0] + 1 |
| 151 | 172 | for child in node.children: |
| 152 | 173 | if child.type == "import_spec": |
| 153 | 174 | self._ingest_import_spec(child, source, file_path, line_start, store, stats) |
| 154 | 175 | elif child.type == "import_spec_list": |
| 155 | 176 | for spec in child.children: |
| 156 | 177 | if spec.type == "import_spec": |
| 157 | - self._ingest_import_spec(spec, source, file_path, line_start, | |
| 158 | - store, stats) | |
| 178 | + self._ingest_import_spec(spec, source, file_path, line_start, store, stats) | |
| 159 | 179 | |
| 160 | - def _ingest_import_spec(self, spec, source: bytes, file_path: str, | |
| 161 | - line_start: int, store: GraphStore, stats: dict) -> None: | |
| 180 | + def _ingest_import_spec( | |
| 181 | + self, spec, source: bytes, file_path: str, line_start: int, store: GraphStore, stats: dict | |
| 182 | + ) -> None: | |
| 162 | 183 | path_node = spec.child_by_field_name("path") |
| 163 | 184 | if not path_node: |
| 164 | 185 | return |
| 165 | 186 | module = _node_text(path_node, source).strip('"') |
| 166 | - store.create_node(NodeLabel.Import, { | |
| 167 | - "name": module, | |
| 168 | - "file_path": file_path, | |
| 169 | - "line_start": line_start, | |
| 170 | - "module": module, | |
| 171 | - }) | |
| 187 | + store.create_node( | |
| 188 | + NodeLabel.Import, | |
| 189 | + { | |
| 190 | + "name": module, | |
| 191 | + "file_path": file_path, | |
| 192 | + "line_start": line_start, | |
| 193 | + "module": module, | |
| 194 | + }, | |
| 195 | + ) | |
| 172 | 196 | store.create_edge( |
| 173 | - NodeLabel.File, {"path": file_path}, | |
| 197 | + NodeLabel.File, | |
| 198 | + {"path": file_path}, | |
| 174 | 199 | EdgeType.IMPORTS, |
| 175 | - NodeLabel.Import, {"name": module, "file_path": file_path}, | |
| 200 | + NodeLabel.Import, | |
| 201 | + {"name": module, "file_path": file_path}, | |
| 176 | 202 | ) |
| 177 | 203 | stats["edges"] += 1 |
| 178 | 204 | |
| 179 | - def _extract_calls(self, fn_node, source: bytes, file_path: str, | |
| 180 | - fn_name: str, fn_label: str, | |
| 181 | - store: GraphStore, stats: dict) -> None: | |
| 205 | + def _extract_calls( | |
| 206 | + self, | |
| 207 | + fn_node, | |
| 208 | + source: bytes, | |
| 209 | + file_path: str, | |
| 210 | + fn_name: str, | |
| 211 | + fn_label: str, | |
| 212 | + store: GraphStore, | |
| 213 | + stats: dict, | |
| 214 | + ) -> None: | |
| 182 | 215 | def walk(node): |
| 183 | 216 | if node.type == "call_expression": |
| 184 | 217 | func = node.child_by_field_name("function") |
| 185 | 218 | if func: |
| 186 | 219 | callee = _node_text(func, source).split(".")[-1] |
| 187 | 220 | store.create_edge( |
| 188 | - fn_label, {"name": fn_name, "file_path": file_path}, | |
| 221 | + fn_label, | |
| 222 | + {"name": fn_name, "file_path": file_path}, | |
| 189 | 223 | EdgeType.CALLS, |
| 190 | - NodeLabel.Function, {"name": callee, "file_path": file_path}, | |
| 224 | + NodeLabel.Function, | |
| 225 | + {"name": callee, "file_path": file_path}, | |
| 191 | 226 | ) |
| 192 | 227 | stats["edges"] += 1 |
| 193 | 228 | for child in node.children: |
| 194 | 229 | walk(child) |
| 195 | 230 | |
| 196 | 231 | body = fn_node.child_by_field_name("body") |
| 197 | 232 | if body: |
| 198 | 233 | walk(body) |
| 199 | 234 |
| --- navegador/ingestion/go.py | |
| +++ navegador/ingestion/go.py | |
| @@ -15,46 +15,50 @@ | |
| 15 | |
| 16 | def _get_go_language(): |
| 17 | try: |
| 18 | import tree_sitter_go as tsgo # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | return Language(tsgo.language()) |
| 21 | except ImportError as e: |
| 22 | raise ImportError("Install tree-sitter-go: pip install tree-sitter-go") from e |
| 23 | |
| 24 | |
| 25 | def _node_text(node, source: bytes) -> str: |
| 26 | return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") |
| 27 | |
| 28 | |
| 29 | class GoParser(LanguageParser): |
| 30 | """Parses Go source files into the navegador graph.""" |
| 31 | |
| 32 | def __init__(self) -> None: |
| 33 | from tree_sitter import Parser # type: ignore[import] |
| 34 | self._parser = Parser(_get_go_language()) |
| 35 | |
| 36 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 37 | source = path.read_bytes() |
| 38 | tree = self._parser.parse(source) |
| 39 | rel_path = str(path.relative_to(repo_root)) |
| 40 | |
| 41 | store.create_node(NodeLabel.File, { |
| 42 | "name": path.name, |
| 43 | "path": rel_path, |
| 44 | "language": "go", |
| 45 | "line_count": source.count(b"\n"), |
| 46 | }) |
| 47 | |
| 48 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 49 | self._walk(tree.root_node, source, rel_path, store, stats) |
| 50 | return stats |
| 51 | |
| 52 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 53 | |
| 54 | def _walk(self, node, source: bytes, file_path: str, |
| 55 | store: GraphStore, stats: dict) -> None: |
| 56 | if node.type == "function_declaration": |
| 57 | self._handle_function(node, source, file_path, store, stats, receiver=None) |
| 58 | return |
| 59 | if node.type == "method_declaration": |
| 60 | self._handle_method(node, source, file_path, store, stats) |
| @@ -68,59 +72,70 @@ | |
| 68 | for child in node.children: |
| 69 | self._walk(child, source, file_path, store, stats) |
| 70 | |
| 71 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 72 | |
| 73 | def _handle_function(self, node, source: bytes, file_path: str, |
| 74 | store: GraphStore, stats: dict, |
| 75 | receiver: str | None) -> None: |
| 76 | name_node = node.child_by_field_name("name") |
| 77 | if not name_node: |
| 78 | return |
| 79 | name = _node_text(name_node, source) |
| 80 | label = NodeLabel.Method if receiver else NodeLabel.Function |
| 81 | |
| 82 | store.create_node(label, { |
| 83 | "name": name, |
| 84 | "file_path": file_path, |
| 85 | "line_start": node.start_point[0] + 1, |
| 86 | "line_end": node.end_point[0] + 1, |
| 87 | "docstring": "", |
| 88 | "class_name": receiver or "", |
| 89 | }) |
| 90 | |
| 91 | container_label = NodeLabel.Class if receiver else NodeLabel.File |
| 92 | container_key = ( |
| 93 | {"name": receiver, "file_path": file_path} |
| 94 | if receiver else {"path": file_path} |
| 95 | ) |
| 96 | store.create_edge( |
| 97 | container_label, container_key, |
| 98 | EdgeType.CONTAINS, |
| 99 | label, {"name": name, "file_path": file_path}, |
| 100 | ) |
| 101 | stats["functions"] += 1 |
| 102 | stats["edges"] += 1 |
| 103 | |
| 104 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 105 | |
| 106 | def _handle_method(self, node, source: bytes, file_path: str, |
| 107 | store: GraphStore, stats: dict) -> None: |
| 108 | receiver_type = "" |
| 109 | recv_node = node.child_by_field_name("receiver") |
| 110 | if recv_node: |
| 111 | for child in recv_node.children: |
| 112 | if child.type == "parameter_declaration": |
| 113 | for c in child.children: |
| 114 | if c.type in ("type_identifier", "pointer_type"): |
| 115 | receiver_type = _node_text(c, source).lstrip("*").strip() |
| 116 | break |
| 117 | self._handle_function(node, source, file_path, store, stats, |
| 118 | receiver=receiver_type or None) |
| 119 | |
| 120 | def _handle_type(self, node, source: bytes, file_path: str, |
| 121 | store: GraphStore, stats: dict) -> None: |
| 122 | for child in node.children: |
| 123 | if child.type != "type_spec": |
| 124 | continue |
| 125 | name_node = child.child_by_field_name("name") |
| 126 | type_node = child.child_by_field_name("type") |
| @@ -128,71 +143,91 @@ | |
| 128 | continue |
| 129 | if type_node.type not in ("struct_type", "interface_type"): |
| 130 | continue |
| 131 | name = _node_text(name_node, source) |
| 132 | kind = "struct" if type_node.type == "struct_type" else "interface" |
| 133 | store.create_node(NodeLabel.Class, { |
| 134 | "name": name, |
| 135 | "file_path": file_path, |
| 136 | "line_start": node.start_point[0] + 1, |
| 137 | "line_end": node.end_point[0] + 1, |
| 138 | "docstring": kind, |
| 139 | }) |
| 140 | store.create_edge( |
| 141 | NodeLabel.File, {"path": file_path}, |
| 142 | EdgeType.CONTAINS, |
| 143 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 144 | ) |
| 145 | stats["classes"] += 1 |
| 146 | stats["edges"] += 1 |
| 147 | |
| 148 | def _handle_import(self, node, source: bytes, file_path: str, |
| 149 | store: GraphStore, stats: dict) -> None: |
| 150 | line_start = node.start_point[0] + 1 |
| 151 | for child in node.children: |
| 152 | if child.type == "import_spec": |
| 153 | self._ingest_import_spec(child, source, file_path, line_start, store, stats) |
| 154 | elif child.type == "import_spec_list": |
| 155 | for spec in child.children: |
| 156 | if spec.type == "import_spec": |
| 157 | self._ingest_import_spec(spec, source, file_path, line_start, |
| 158 | store, stats) |
| 159 | |
| 160 | def _ingest_import_spec(self, spec, source: bytes, file_path: str, |
| 161 | line_start: int, store: GraphStore, stats: dict) -> None: |
| 162 | path_node = spec.child_by_field_name("path") |
| 163 | if not path_node: |
| 164 | return |
| 165 | module = _node_text(path_node, source).strip('"') |
| 166 | store.create_node(NodeLabel.Import, { |
| 167 | "name": module, |
| 168 | "file_path": file_path, |
| 169 | "line_start": line_start, |
| 170 | "module": module, |
| 171 | }) |
| 172 | store.create_edge( |
| 173 | NodeLabel.File, {"path": file_path}, |
| 174 | EdgeType.IMPORTS, |
| 175 | NodeLabel.Import, {"name": module, "file_path": file_path}, |
| 176 | ) |
| 177 | stats["edges"] += 1 |
| 178 | |
| 179 | def _extract_calls(self, fn_node, source: bytes, file_path: str, |
| 180 | fn_name: str, fn_label: str, |
| 181 | store: GraphStore, stats: dict) -> None: |
| 182 | def walk(node): |
| 183 | if node.type == "call_expression": |
| 184 | func = node.child_by_field_name("function") |
| 185 | if func: |
| 186 | callee = _node_text(func, source).split(".")[-1] |
| 187 | store.create_edge( |
| 188 | fn_label, {"name": fn_name, "file_path": file_path}, |
| 189 | EdgeType.CALLS, |
| 190 | NodeLabel.Function, {"name": callee, "file_path": file_path}, |
| 191 | ) |
| 192 | stats["edges"] += 1 |
| 193 | for child in node.children: |
| 194 | walk(child) |
| 195 | |
| 196 | body = fn_node.child_by_field_name("body") |
| 197 | if body: |
| 198 | walk(body) |
| 199 |
| --- navegador/ingestion/go.py | |
| +++ navegador/ingestion/go.py | |
| @@ -15,46 +15,50 @@ | |
| 15 | |
| 16 | def _get_go_language(): |
| 17 | try: |
| 18 | import tree_sitter_go as tsgo # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | |
| 21 | return Language(tsgo.language()) |
| 22 | except ImportError as e: |
| 23 | raise ImportError("Install tree-sitter-go: pip install tree-sitter-go") from e |
| 24 | |
| 25 | |
| 26 | def _node_text(node, source: bytes) -> str: |
| 27 | return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") |
| 28 | |
| 29 | |
| 30 | class GoParser(LanguageParser): |
| 31 | """Parses Go source files into the navegador graph.""" |
| 32 | |
| 33 | def __init__(self) -> None: |
| 34 | from tree_sitter import Parser # type: ignore[import] |
| 35 | |
| 36 | self._parser = Parser(_get_go_language()) |
| 37 | |
| 38 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 39 | source = path.read_bytes() |
| 40 | tree = self._parser.parse(source) |
| 41 | rel_path = str(path.relative_to(repo_root)) |
| 42 | |
| 43 | store.create_node( |
| 44 | NodeLabel.File, |
| 45 | { |
| 46 | "name": path.name, |
| 47 | "path": rel_path, |
| 48 | "language": "go", |
| 49 | "line_count": source.count(b"\n"), |
| 50 | }, |
| 51 | ) |
| 52 | |
| 53 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 54 | self._walk(tree.root_node, source, rel_path, store, stats) |
| 55 | return stats |
| 56 | |
| 57 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 58 | |
| 59 | def _walk(self, node, source: bytes, file_path: str, store: GraphStore, stats: dict) -> None: |
| 60 | if node.type == "function_declaration": |
| 61 | self._handle_function(node, source, file_path, store, stats, receiver=None) |
| 62 | return |
| 63 | if node.type == "method_declaration": |
| 64 | self._handle_method(node, source, file_path, store, stats) |
| @@ -68,59 +72,70 @@ | |
| 72 | for child in node.children: |
| 73 | self._walk(child, source, file_path, store, stats) |
| 74 | |
| 75 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 76 | |
| 77 | def _handle_function( |
| 78 | self, |
| 79 | node, |
| 80 | source: bytes, |
| 81 | file_path: str, |
| 82 | store: GraphStore, |
| 83 | stats: dict, |
| 84 | receiver: str | None, |
| 85 | ) -> None: |
| 86 | name_node = node.child_by_field_name("name") |
| 87 | if not name_node: |
| 88 | return |
| 89 | name = _node_text(name_node, source) |
| 90 | label = NodeLabel.Method if receiver else NodeLabel.Function |
| 91 | |
| 92 | store.create_node( |
| 93 | label, |
| 94 | { |
| 95 | "name": name, |
| 96 | "file_path": file_path, |
| 97 | "line_start": node.start_point[0] + 1, |
| 98 | "line_end": node.end_point[0] + 1, |
| 99 | "docstring": "", |
| 100 | "class_name": receiver or "", |
| 101 | }, |
| 102 | ) |
| 103 | |
| 104 | container_label = NodeLabel.Class if receiver else NodeLabel.File |
| 105 | container_key = ( |
| 106 | {"name": receiver, "file_path": file_path} if receiver else {"path": file_path} |
| 107 | ) |
| 108 | store.create_edge( |
| 109 | container_label, |
| 110 | container_key, |
| 111 | EdgeType.CONTAINS, |
| 112 | label, |
| 113 | {"name": name, "file_path": file_path}, |
| 114 | ) |
| 115 | stats["functions"] += 1 |
| 116 | stats["edges"] += 1 |
| 117 | |
| 118 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 119 | |
| 120 | def _handle_method( |
| 121 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 122 | ) -> None: |
| 123 | receiver_type = "" |
| 124 | recv_node = node.child_by_field_name("receiver") |
| 125 | if recv_node: |
| 126 | for child in recv_node.children: |
| 127 | if child.type == "parameter_declaration": |
| 128 | for c in child.children: |
| 129 | if c.type in ("type_identifier", "pointer_type"): |
| 130 | receiver_type = _node_text(c, source).lstrip("*").strip() |
| 131 | break |
| 132 | self._handle_function(node, source, file_path, store, stats, receiver=receiver_type or None) |
| 133 | |
| 134 | def _handle_type( |
| 135 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 136 | ) -> None: |
| 137 | for child in node.children: |
| 138 | if child.type != "type_spec": |
| 139 | continue |
| 140 | name_node = child.child_by_field_name("name") |
| 141 | type_node = child.child_by_field_name("type") |
| @@ -128,71 +143,91 @@ | |
| 143 | continue |
| 144 | if type_node.type not in ("struct_type", "interface_type"): |
| 145 | continue |
| 146 | name = _node_text(name_node, source) |
| 147 | kind = "struct" if type_node.type == "struct_type" else "interface" |
| 148 | store.create_node( |
| 149 | NodeLabel.Class, |
| 150 | { |
| 151 | "name": name, |
| 152 | "file_path": file_path, |
| 153 | "line_start": node.start_point[0] + 1, |
| 154 | "line_end": node.end_point[0] + 1, |
| 155 | "docstring": kind, |
| 156 | }, |
| 157 | ) |
| 158 | store.create_edge( |
| 159 | NodeLabel.File, |
| 160 | {"path": file_path}, |
| 161 | EdgeType.CONTAINS, |
| 162 | NodeLabel.Class, |
| 163 | {"name": name, "file_path": file_path}, |
| 164 | ) |
| 165 | stats["classes"] += 1 |
| 166 | stats["edges"] += 1 |
| 167 | |
| 168 | def _handle_import( |
| 169 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 170 | ) -> None: |
| 171 | line_start = node.start_point[0] + 1 |
| 172 | for child in node.children: |
| 173 | if child.type == "import_spec": |
| 174 | self._ingest_import_spec(child, source, file_path, line_start, store, stats) |
| 175 | elif child.type == "import_spec_list": |
| 176 | for spec in child.children: |
| 177 | if spec.type == "import_spec": |
| 178 | self._ingest_import_spec(spec, source, file_path, line_start, store, stats) |
| 179 | |
| 180 | def _ingest_import_spec( |
| 181 | self, spec, source: bytes, file_path: str, line_start: int, store: GraphStore, stats: dict |
| 182 | ) -> None: |
| 183 | path_node = spec.child_by_field_name("path") |
| 184 | if not path_node: |
| 185 | return |
| 186 | module = _node_text(path_node, source).strip('"') |
| 187 | store.create_node( |
| 188 | NodeLabel.Import, |
| 189 | { |
| 190 | "name": module, |
| 191 | "file_path": file_path, |
| 192 | "line_start": line_start, |
| 193 | "module": module, |
| 194 | }, |
| 195 | ) |
| 196 | store.create_edge( |
| 197 | NodeLabel.File, |
| 198 | {"path": file_path}, |
| 199 | EdgeType.IMPORTS, |
| 200 | NodeLabel.Import, |
| 201 | {"name": module, "file_path": file_path}, |
| 202 | ) |
| 203 | stats["edges"] += 1 |
| 204 | |
| 205 | def _extract_calls( |
| 206 | self, |
| 207 | fn_node, |
| 208 | source: bytes, |
| 209 | file_path: str, |
| 210 | fn_name: str, |
| 211 | fn_label: str, |
| 212 | store: GraphStore, |
| 213 | stats: dict, |
| 214 | ) -> None: |
| 215 | def walk(node): |
| 216 | if node.type == "call_expression": |
| 217 | func = node.child_by_field_name("function") |
| 218 | if func: |
| 219 | callee = _node_text(func, source).split(".")[-1] |
| 220 | store.create_edge( |
| 221 | fn_label, |
| 222 | {"name": fn_name, "file_path": file_path}, |
| 223 | EdgeType.CALLS, |
| 224 | NodeLabel.Function, |
| 225 | {"name": callee, "file_path": file_path}, |
| 226 | ) |
| 227 | stats["edges"] += 1 |
| 228 | for child in node.children: |
| 229 | walk(child) |
| 230 | |
| 231 | body = fn_node.child_by_field_name("body") |
| 232 | if body: |
| 233 | walk(body) |
| 234 |
+128
-76
| --- navegador/ingestion/java.py | ||
| +++ navegador/ingestion/java.py | ||
| @@ -15,17 +15,18 @@ | ||
| 15 | 15 | |
| 16 | 16 | def _get_java_language(): |
| 17 | 17 | try: |
| 18 | 18 | import tree_sitter_java as tsjava # type: ignore[import] |
| 19 | 19 | from tree_sitter import Language |
| 20 | + | |
| 20 | 21 | return Language(tsjava.language()) |
| 21 | 22 | except ImportError as e: |
| 22 | 23 | raise ImportError("Install tree-sitter-java: pip install tree-sitter-java") from e |
| 23 | 24 | |
| 24 | 25 | |
| 25 | 26 | def _node_text(node, source: bytes) -> str: |
| 26 | - return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") | |
| 27 | + return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") | |
| 27 | 28 | |
| 28 | 29 | |
| 29 | 30 | def _javadoc(node, source: bytes) -> str: |
| 30 | 31 | """Return the Javadoc (/** ... */) comment preceding a node, if any.""" |
| 31 | 32 | parent = node.parent |
| @@ -46,32 +47,43 @@ | ||
| 46 | 47 | class JavaParser(LanguageParser): |
| 47 | 48 | """Parses Java source files into the navegador graph.""" |
| 48 | 49 | |
| 49 | 50 | def __init__(self) -> None: |
| 50 | 51 | from tree_sitter import Parser # type: ignore[import] |
| 52 | + | |
| 51 | 53 | self._parser = Parser(_get_java_language()) |
| 52 | 54 | |
| 53 | 55 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 54 | 56 | source = path.read_bytes() |
| 55 | 57 | tree = self._parser.parse(source) |
| 56 | 58 | rel_path = str(path.relative_to(repo_root)) |
| 57 | 59 | |
| 58 | - store.create_node(NodeLabel.File, { | |
| 59 | - "name": path.name, | |
| 60 | - "path": rel_path, | |
| 61 | - "language": "java", | |
| 62 | - "line_count": source.count(b"\n"), | |
| 63 | - }) | |
| 60 | + store.create_node( | |
| 61 | + NodeLabel.File, | |
| 62 | + { | |
| 63 | + "name": path.name, | |
| 64 | + "path": rel_path, | |
| 65 | + "language": "java", | |
| 66 | + "line_count": source.count(b"\n"), | |
| 67 | + }, | |
| 68 | + ) | |
| 64 | 69 | |
| 65 | 70 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 66 | 71 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 67 | 72 | return stats |
| 68 | 73 | |
| 69 | 74 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 70 | 75 | |
| 71 | - def _walk(self, node, source: bytes, file_path: str, | |
| 72 | - store: GraphStore, stats: dict, class_name: str | None) -> None: | |
| 76 | + def _walk( | |
| 77 | + self, | |
| 78 | + node, | |
| 79 | + source: bytes, | |
| 80 | + file_path: str, | |
| 81 | + store: GraphStore, | |
| 82 | + stats: dict, | |
| 83 | + class_name: str | None, | |
| 84 | + ) -> None: | |
| 73 | 85 | if node.type in ("class_declaration", "record_declaration"): |
| 74 | 86 | self._handle_class(node, source, file_path, store, stats) |
| 75 | 87 | return |
| 76 | 88 | if node.type == "interface_declaration": |
| 77 | 89 | self._handle_interface(node, source, file_path, store, stats) |
| @@ -82,29 +94,35 @@ | ||
| 82 | 94 | for child in node.children: |
| 83 | 95 | self._walk(child, source, file_path, store, stats, class_name) |
| 84 | 96 | |
| 85 | 97 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 86 | 98 | |
| 87 | - def _handle_class(self, node, source: bytes, file_path: str, | |
| 88 | - store: GraphStore, stats: dict) -> None: | |
| 99 | + def _handle_class( | |
| 100 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 101 | + ) -> None: | |
| 89 | 102 | name_node = node.child_by_field_name("name") |
| 90 | 103 | if not name_node: |
| 91 | 104 | return |
| 92 | 105 | name = _node_text(name_node, source) |
| 93 | 106 | docstring = _javadoc(node, source) |
| 94 | 107 | |
| 95 | - store.create_node(NodeLabel.Class, { | |
| 96 | - "name": name, | |
| 97 | - "file_path": file_path, | |
| 98 | - "line_start": node.start_point[0] + 1, | |
| 99 | - "line_end": node.end_point[0] + 1, | |
| 100 | - "docstring": docstring, | |
| 101 | - }) | |
| 108 | + store.create_node( | |
| 109 | + NodeLabel.Class, | |
| 110 | + { | |
| 111 | + "name": name, | |
| 112 | + "file_path": file_path, | |
| 113 | + "line_start": node.start_point[0] + 1, | |
| 114 | + "line_end": node.end_point[0] + 1, | |
| 115 | + "docstring": docstring, | |
| 116 | + }, | |
| 117 | + ) | |
| 102 | 118 | store.create_edge( |
| 103 | - NodeLabel.File, {"path": file_path}, | |
| 119 | + NodeLabel.File, | |
| 120 | + {"path": file_path}, | |
| 104 | 121 | EdgeType.CONTAINS, |
| 105 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 122 | + NodeLabel.Class, | |
| 123 | + {"name": name, "file_path": file_path}, | |
| 106 | 124 | ) |
| 107 | 125 | stats["classes"] += 1 |
| 108 | 126 | stats["edges"] += 1 |
| 109 | 127 | |
| 110 | 128 | # Superclass → INHERITS edge |
| @@ -112,135 +130,169 @@ | ||
| 112 | 130 | if superclass: |
| 113 | 131 | for child in superclass.children: |
| 114 | 132 | if child.type == "type_identifier": |
| 115 | 133 | parent_name = _node_text(child, source) |
| 116 | 134 | store.create_edge( |
| 117 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 135 | + NodeLabel.Class, | |
| 136 | + {"name": name, "file_path": file_path}, | |
| 118 | 137 | EdgeType.INHERITS, |
| 119 | - NodeLabel.Class, {"name": parent_name, "file_path": file_path}, | |
| 138 | + NodeLabel.Class, | |
| 139 | + {"name": parent_name, "file_path": file_path}, | |
| 120 | 140 | ) |
| 121 | 141 | stats["edges"] += 1 |
| 122 | 142 | break |
| 123 | 143 | |
| 124 | 144 | # Walk class body for methods and constructors |
| 125 | 145 | body = node.child_by_field_name("body") |
| 126 | 146 | if body: |
| 127 | 147 | for child in body.children: |
| 128 | 148 | if child.type in ("method_declaration", "constructor_declaration"): |
| 129 | - self._handle_method(child, source, file_path, store, stats, | |
| 130 | - class_name=name) | |
| 131 | - elif child.type in ("class_declaration", "record_declaration", | |
| 132 | - "interface_declaration"): | |
| 149 | + self._handle_method(child, source, file_path, store, stats, class_name=name) | |
| 150 | + elif child.type in ( | |
| 151 | + "class_declaration", | |
| 152 | + "record_declaration", | |
| 153 | + "interface_declaration", | |
| 154 | + ): | |
| 133 | 155 | # Nested class — register but don't recurse into methods |
| 134 | 156 | inner_name_node = child.child_by_field_name("name") |
| 135 | 157 | if inner_name_node: |
| 136 | 158 | inner_name = _node_text(inner_name_node, source) |
| 137 | - store.create_node(NodeLabel.Class, { | |
| 138 | - "name": inner_name, | |
| 139 | - "file_path": file_path, | |
| 140 | - "line_start": child.start_point[0] + 1, | |
| 141 | - "line_end": child.end_point[0] + 1, | |
| 142 | - "docstring": "", | |
| 143 | - }) | |
| 159 | + store.create_node( | |
| 160 | + NodeLabel.Class, | |
| 161 | + { | |
| 162 | + "name": inner_name, | |
| 163 | + "file_path": file_path, | |
| 164 | + "line_start": child.start_point[0] + 1, | |
| 165 | + "line_end": child.end_point[0] + 1, | |
| 166 | + "docstring": "", | |
| 167 | + }, | |
| 168 | + ) | |
| 144 | 169 | store.create_edge( |
| 145 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 170 | + NodeLabel.Class, | |
| 171 | + {"name": name, "file_path": file_path}, | |
| 146 | 172 | EdgeType.CONTAINS, |
| 147 | - NodeLabel.Class, {"name": inner_name, "file_path": file_path}, | |
| 173 | + NodeLabel.Class, | |
| 174 | + {"name": inner_name, "file_path": file_path}, | |
| 148 | 175 | ) |
| 149 | 176 | stats["classes"] += 1 |
| 150 | 177 | stats["edges"] += 1 |
| 151 | 178 | |
| 152 | - def _handle_interface(self, node, source: bytes, file_path: str, | |
| 153 | - store: GraphStore, stats: dict) -> None: | |
| 179 | + def _handle_interface( | |
| 180 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 181 | + ) -> None: | |
| 154 | 182 | name_node = node.child_by_field_name("name") |
| 155 | 183 | if not name_node: |
| 156 | 184 | return |
| 157 | 185 | name = _node_text(name_node, source) |
| 158 | 186 | docstring = _javadoc(node, source) |
| 159 | 187 | |
| 160 | - store.create_node(NodeLabel.Class, { | |
| 161 | - "name": name, | |
| 162 | - "file_path": file_path, | |
| 163 | - "line_start": node.start_point[0] + 1, | |
| 164 | - "line_end": node.end_point[0] + 1, | |
| 165 | - "docstring": f"interface: {docstring}".strip(": "), | |
| 166 | - }) | |
| 188 | + store.create_node( | |
| 189 | + NodeLabel.Class, | |
| 190 | + { | |
| 191 | + "name": name, | |
| 192 | + "file_path": file_path, | |
| 193 | + "line_start": node.start_point[0] + 1, | |
| 194 | + "line_end": node.end_point[0] + 1, | |
| 195 | + "docstring": f"interface: {docstring}".strip(": "), | |
| 196 | + }, | |
| 197 | + ) | |
| 167 | 198 | store.create_edge( |
| 168 | - NodeLabel.File, {"path": file_path}, | |
| 199 | + NodeLabel.File, | |
| 200 | + {"path": file_path}, | |
| 169 | 201 | EdgeType.CONTAINS, |
| 170 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 202 | + NodeLabel.Class, | |
| 203 | + {"name": name, "file_path": file_path}, | |
| 171 | 204 | ) |
| 172 | 205 | stats["classes"] += 1 |
| 173 | 206 | stats["edges"] += 1 |
| 174 | 207 | |
| 175 | 208 | # Walk interface body for method signatures |
| 176 | 209 | body = node.child_by_field_name("body") |
| 177 | 210 | if body: |
| 178 | 211 | for child in body.children: |
| 179 | 212 | if child.type == "method_declaration": |
| 180 | - self._handle_method(child, source, file_path, store, stats, | |
| 181 | - class_name=name) | |
| 213 | + self._handle_method(child, source, file_path, store, stats, class_name=name) | |
| 182 | 214 | |
| 183 | - def _handle_method(self, node, source: bytes, file_path: str, | |
| 184 | - store: GraphStore, stats: dict, class_name: str) -> None: | |
| 215 | + def _handle_method( | |
| 216 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict, class_name: str | |
| 217 | + ) -> None: | |
| 185 | 218 | name_node = node.child_by_field_name("name") |
| 186 | 219 | if not name_node: |
| 187 | 220 | return |
| 188 | 221 | name = _node_text(name_node, source) |
| 189 | 222 | docstring = _javadoc(node, source) |
| 190 | 223 | |
| 191 | - store.create_node(NodeLabel.Method, { | |
| 192 | - "name": name, | |
| 193 | - "file_path": file_path, | |
| 194 | - "line_start": node.start_point[0] + 1, | |
| 195 | - "line_end": node.end_point[0] + 1, | |
| 196 | - "docstring": docstring, | |
| 197 | - "class_name": class_name, | |
| 198 | - }) | |
| 224 | + store.create_node( | |
| 225 | + NodeLabel.Method, | |
| 226 | + { | |
| 227 | + "name": name, | |
| 228 | + "file_path": file_path, | |
| 229 | + "line_start": node.start_point[0] + 1, | |
| 230 | + "line_end": node.end_point[0] + 1, | |
| 231 | + "docstring": docstring, | |
| 232 | + "class_name": class_name, | |
| 233 | + }, | |
| 234 | + ) | |
| 199 | 235 | store.create_edge( |
| 200 | - NodeLabel.Class, {"name": class_name, "file_path": file_path}, | |
| 236 | + NodeLabel.Class, | |
| 237 | + {"name": class_name, "file_path": file_path}, | |
| 201 | 238 | EdgeType.CONTAINS, |
| 202 | - NodeLabel.Method, {"name": name, "file_path": file_path}, | |
| 239 | + NodeLabel.Method, | |
| 240 | + {"name": name, "file_path": file_path}, | |
| 203 | 241 | ) |
| 204 | 242 | stats["functions"] += 1 |
| 205 | 243 | stats["edges"] += 1 |
| 206 | 244 | |
| 207 | 245 | self._extract_calls(node, source, file_path, name, store, stats) |
| 208 | 246 | |
| 209 | - def _handle_import(self, node, source: bytes, file_path: str, | |
| 210 | - store: GraphStore, stats: dict) -> None: | |
| 247 | + def _handle_import( | |
| 248 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 249 | + ) -> None: | |
| 211 | 250 | # import java.util.List; → strip keyword + semicolon |
| 212 | 251 | raw = _node_text(node, source) |
| 213 | - module = (raw.removeprefix("import").removeprefix(" static") | |
| 214 | - .removesuffix(";").strip()) | |
| 215 | - store.create_node(NodeLabel.Import, { | |
| 216 | - "name": module, | |
| 217 | - "file_path": file_path, | |
| 218 | - "line_start": node.start_point[0] + 1, | |
| 219 | - "module": module, | |
| 220 | - }) | |
| 252 | + module = raw.removeprefix("import").removeprefix(" static").removesuffix(";").strip() | |
| 253 | + store.create_node( | |
| 254 | + NodeLabel.Import, | |
| 255 | + { | |
| 256 | + "name": module, | |
| 257 | + "file_path": file_path, | |
| 258 | + "line_start": node.start_point[0] + 1, | |
| 259 | + "module": module, | |
| 260 | + }, | |
| 261 | + ) | |
| 221 | 262 | store.create_edge( |
| 222 | - NodeLabel.File, {"path": file_path}, | |
| 263 | + NodeLabel.File, | |
| 264 | + {"path": file_path}, | |
| 223 | 265 | EdgeType.IMPORTS, |
| 224 | - NodeLabel.Import, {"name": module, "file_path": file_path}, | |
| 266 | + NodeLabel.Import, | |
| 267 | + {"name": module, "file_path": file_path}, | |
| 225 | 268 | ) |
| 226 | 269 | stats["edges"] += 1 |
| 227 | 270 | |
| 228 | - def _extract_calls(self, method_node, source: bytes, file_path: str, | |
| 229 | - method_name: str, store: GraphStore, stats: dict) -> None: | |
| 271 | + def _extract_calls( | |
| 272 | + self, | |
| 273 | + method_node, | |
| 274 | + source: bytes, | |
| 275 | + file_path: str, | |
| 276 | + method_name: str, | |
| 277 | + store: GraphStore, | |
| 278 | + stats: dict, | |
| 279 | + ) -> None: | |
| 230 | 280 | def walk(node): |
| 231 | 281 | if node.type == "method_invocation": |
| 232 | 282 | name_node = node.child_by_field_name("name") |
| 233 | 283 | if name_node: |
| 234 | 284 | callee = _node_text(name_node, source) |
| 235 | 285 | store.create_edge( |
| 236 | - NodeLabel.Method, {"name": method_name, "file_path": file_path}, | |
| 286 | + NodeLabel.Method, | |
| 287 | + {"name": method_name, "file_path": file_path}, | |
| 237 | 288 | EdgeType.CALLS, |
| 238 | - NodeLabel.Function, {"name": callee, "file_path": file_path}, | |
| 289 | + NodeLabel.Function, | |
| 290 | + {"name": callee, "file_path": file_path}, | |
| 239 | 291 | ) |
| 240 | 292 | stats["edges"] += 1 |
| 241 | 293 | for child in node.children: |
| 242 | 294 | walk(child) |
| 243 | 295 | |
| 244 | 296 | body = method_node.child_by_field_name("body") |
| 245 | 297 | if body: |
| 246 | 298 | walk(body) |
| 247 | 299 |
| --- navegador/ingestion/java.py | |
| +++ navegador/ingestion/java.py | |
| @@ -15,17 +15,18 @@ | |
| 15 | |
| 16 | def _get_java_language(): |
| 17 | try: |
| 18 | import tree_sitter_java as tsjava # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | return Language(tsjava.language()) |
| 21 | except ImportError as e: |
| 22 | raise ImportError("Install tree-sitter-java: pip install tree-sitter-java") from e |
| 23 | |
| 24 | |
| 25 | def _node_text(node, source: bytes) -> str: |
| 26 | return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") |
| 27 | |
| 28 | |
| 29 | def _javadoc(node, source: bytes) -> str: |
| 30 | """Return the Javadoc (/** ... */) comment preceding a node, if any.""" |
| 31 | parent = node.parent |
| @@ -46,32 +47,43 @@ | |
| 46 | class JavaParser(LanguageParser): |
| 47 | """Parses Java source files into the navegador graph.""" |
| 48 | |
| 49 | def __init__(self) -> None: |
| 50 | from tree_sitter import Parser # type: ignore[import] |
| 51 | self._parser = Parser(_get_java_language()) |
| 52 | |
| 53 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 54 | source = path.read_bytes() |
| 55 | tree = self._parser.parse(source) |
| 56 | rel_path = str(path.relative_to(repo_root)) |
| 57 | |
| 58 | store.create_node(NodeLabel.File, { |
| 59 | "name": path.name, |
| 60 | "path": rel_path, |
| 61 | "language": "java", |
| 62 | "line_count": source.count(b"\n"), |
| 63 | }) |
| 64 | |
| 65 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 66 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 67 | return stats |
| 68 | |
| 69 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 70 | |
| 71 | def _walk(self, node, source: bytes, file_path: str, |
| 72 | store: GraphStore, stats: dict, class_name: str | None) -> None: |
| 73 | if node.type in ("class_declaration", "record_declaration"): |
| 74 | self._handle_class(node, source, file_path, store, stats) |
| 75 | return |
| 76 | if node.type == "interface_declaration": |
| 77 | self._handle_interface(node, source, file_path, store, stats) |
| @@ -82,29 +94,35 @@ | |
| 82 | for child in node.children: |
| 83 | self._walk(child, source, file_path, store, stats, class_name) |
| 84 | |
| 85 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 86 | |
| 87 | def _handle_class(self, node, source: bytes, file_path: str, |
| 88 | store: GraphStore, stats: dict) -> None: |
| 89 | name_node = node.child_by_field_name("name") |
| 90 | if not name_node: |
| 91 | return |
| 92 | name = _node_text(name_node, source) |
| 93 | docstring = _javadoc(node, source) |
| 94 | |
| 95 | store.create_node(NodeLabel.Class, { |
| 96 | "name": name, |
| 97 | "file_path": file_path, |
| 98 | "line_start": node.start_point[0] + 1, |
| 99 | "line_end": node.end_point[0] + 1, |
| 100 | "docstring": docstring, |
| 101 | }) |
| 102 | store.create_edge( |
| 103 | NodeLabel.File, {"path": file_path}, |
| 104 | EdgeType.CONTAINS, |
| 105 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 106 | ) |
| 107 | stats["classes"] += 1 |
| 108 | stats["edges"] += 1 |
| 109 | |
| 110 | # Superclass → INHERITS edge |
| @@ -112,135 +130,169 @@ | |
| 112 | if superclass: |
| 113 | for child in superclass.children: |
| 114 | if child.type == "type_identifier": |
| 115 | parent_name = _node_text(child, source) |
| 116 | store.create_edge( |
| 117 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 118 | EdgeType.INHERITS, |
| 119 | NodeLabel.Class, {"name": parent_name, "file_path": file_path}, |
| 120 | ) |
| 121 | stats["edges"] += 1 |
| 122 | break |
| 123 | |
| 124 | # Walk class body for methods and constructors |
| 125 | body = node.child_by_field_name("body") |
| 126 | if body: |
| 127 | for child in body.children: |
| 128 | if child.type in ("method_declaration", "constructor_declaration"): |
| 129 | self._handle_method(child, source, file_path, store, stats, |
| 130 | class_name=name) |
| 131 | elif child.type in ("class_declaration", "record_declaration", |
| 132 | "interface_declaration"): |
| 133 | # Nested class — register but don't recurse into methods |
| 134 | inner_name_node = child.child_by_field_name("name") |
| 135 | if inner_name_node: |
| 136 | inner_name = _node_text(inner_name_node, source) |
| 137 | store.create_node(NodeLabel.Class, { |
| 138 | "name": inner_name, |
| 139 | "file_path": file_path, |
| 140 | "line_start": child.start_point[0] + 1, |
| 141 | "line_end": child.end_point[0] + 1, |
| 142 | "docstring": "", |
| 143 | }) |
| 144 | store.create_edge( |
| 145 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 146 | EdgeType.CONTAINS, |
| 147 | NodeLabel.Class, {"name": inner_name, "file_path": file_path}, |
| 148 | ) |
| 149 | stats["classes"] += 1 |
| 150 | stats["edges"] += 1 |
| 151 | |
| 152 | def _handle_interface(self, node, source: bytes, file_path: str, |
| 153 | store: GraphStore, stats: dict) -> None: |
| 154 | name_node = node.child_by_field_name("name") |
| 155 | if not name_node: |
| 156 | return |
| 157 | name = _node_text(name_node, source) |
| 158 | docstring = _javadoc(node, source) |
| 159 | |
| 160 | store.create_node(NodeLabel.Class, { |
| 161 | "name": name, |
| 162 | "file_path": file_path, |
| 163 | "line_start": node.start_point[0] + 1, |
| 164 | "line_end": node.end_point[0] + 1, |
| 165 | "docstring": f"interface: {docstring}".strip(": "), |
| 166 | }) |
| 167 | store.create_edge( |
| 168 | NodeLabel.File, {"path": file_path}, |
| 169 | EdgeType.CONTAINS, |
| 170 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 171 | ) |
| 172 | stats["classes"] += 1 |
| 173 | stats["edges"] += 1 |
| 174 | |
| 175 | # Walk interface body for method signatures |
| 176 | body = node.child_by_field_name("body") |
| 177 | if body: |
| 178 | for child in body.children: |
| 179 | if child.type == "method_declaration": |
| 180 | self._handle_method(child, source, file_path, store, stats, |
| 181 | class_name=name) |
| 182 | |
| 183 | def _handle_method(self, node, source: bytes, file_path: str, |
| 184 | store: GraphStore, stats: dict, class_name: str) -> None: |
| 185 | name_node = node.child_by_field_name("name") |
| 186 | if not name_node: |
| 187 | return |
| 188 | name = _node_text(name_node, source) |
| 189 | docstring = _javadoc(node, source) |
| 190 | |
| 191 | store.create_node(NodeLabel.Method, { |
| 192 | "name": name, |
| 193 | "file_path": file_path, |
| 194 | "line_start": node.start_point[0] + 1, |
| 195 | "line_end": node.end_point[0] + 1, |
| 196 | "docstring": docstring, |
| 197 | "class_name": class_name, |
| 198 | }) |
| 199 | store.create_edge( |
| 200 | NodeLabel.Class, {"name": class_name, "file_path": file_path}, |
| 201 | EdgeType.CONTAINS, |
| 202 | NodeLabel.Method, {"name": name, "file_path": file_path}, |
| 203 | ) |
| 204 | stats["functions"] += 1 |
| 205 | stats["edges"] += 1 |
| 206 | |
| 207 | self._extract_calls(node, source, file_path, name, store, stats) |
| 208 | |
| 209 | def _handle_import(self, node, source: bytes, file_path: str, |
| 210 | store: GraphStore, stats: dict) -> None: |
| 211 | # import java.util.List; → strip keyword + semicolon |
| 212 | raw = _node_text(node, source) |
| 213 | module = (raw.removeprefix("import").removeprefix(" static") |
| 214 | .removesuffix(";").strip()) |
| 215 | store.create_node(NodeLabel.Import, { |
| 216 | "name": module, |
| 217 | "file_path": file_path, |
| 218 | "line_start": node.start_point[0] + 1, |
| 219 | "module": module, |
| 220 | }) |
| 221 | store.create_edge( |
| 222 | NodeLabel.File, {"path": file_path}, |
| 223 | EdgeType.IMPORTS, |
| 224 | NodeLabel.Import, {"name": module, "file_path": file_path}, |
| 225 | ) |
| 226 | stats["edges"] += 1 |
| 227 | |
| 228 | def _extract_calls(self, method_node, source: bytes, file_path: str, |
| 229 | method_name: str, store: GraphStore, stats: dict) -> None: |
| 230 | def walk(node): |
| 231 | if node.type == "method_invocation": |
| 232 | name_node = node.child_by_field_name("name") |
| 233 | if name_node: |
| 234 | callee = _node_text(name_node, source) |
| 235 | store.create_edge( |
| 236 | NodeLabel.Method, {"name": method_name, "file_path": file_path}, |
| 237 | EdgeType.CALLS, |
| 238 | NodeLabel.Function, {"name": callee, "file_path": file_path}, |
| 239 | ) |
| 240 | stats["edges"] += 1 |
| 241 | for child in node.children: |
| 242 | walk(child) |
| 243 | |
| 244 | body = method_node.child_by_field_name("body") |
| 245 | if body: |
| 246 | walk(body) |
| 247 |
| --- navegador/ingestion/java.py | |
| +++ navegador/ingestion/java.py | |
| @@ -15,17 +15,18 @@ | |
| 15 | |
| 16 | def _get_java_language(): |
| 17 | try: |
| 18 | import tree_sitter_java as tsjava # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | |
| 21 | return Language(tsjava.language()) |
| 22 | except ImportError as e: |
| 23 | raise ImportError("Install tree-sitter-java: pip install tree-sitter-java") from e |
| 24 | |
| 25 | |
| 26 | def _node_text(node, source: bytes) -> str: |
| 27 | return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") |
| 28 | |
| 29 | |
| 30 | def _javadoc(node, source: bytes) -> str: |
| 31 | """Return the Javadoc (/** ... */) comment preceding a node, if any.""" |
| 32 | parent = node.parent |
| @@ -46,32 +47,43 @@ | |
| 47 | class JavaParser(LanguageParser): |
| 48 | """Parses Java source files into the navegador graph.""" |
| 49 | |
| 50 | def __init__(self) -> None: |
| 51 | from tree_sitter import Parser # type: ignore[import] |
| 52 | |
| 53 | self._parser = Parser(_get_java_language()) |
| 54 | |
| 55 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 56 | source = path.read_bytes() |
| 57 | tree = self._parser.parse(source) |
| 58 | rel_path = str(path.relative_to(repo_root)) |
| 59 | |
| 60 | store.create_node( |
| 61 | NodeLabel.File, |
| 62 | { |
| 63 | "name": path.name, |
| 64 | "path": rel_path, |
| 65 | "language": "java", |
| 66 | "line_count": source.count(b"\n"), |
| 67 | }, |
| 68 | ) |
| 69 | |
| 70 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 71 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 72 | return stats |
| 73 | |
| 74 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 75 | |
| 76 | def _walk( |
| 77 | self, |
| 78 | node, |
| 79 | source: bytes, |
| 80 | file_path: str, |
| 81 | store: GraphStore, |
| 82 | stats: dict, |
| 83 | class_name: str | None, |
| 84 | ) -> None: |
| 85 | if node.type in ("class_declaration", "record_declaration"): |
| 86 | self._handle_class(node, source, file_path, store, stats) |
| 87 | return |
| 88 | if node.type == "interface_declaration": |
| 89 | self._handle_interface(node, source, file_path, store, stats) |
| @@ -82,29 +94,35 @@ | |
| 94 | for child in node.children: |
| 95 | self._walk(child, source, file_path, store, stats, class_name) |
| 96 | |
| 97 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 98 | |
| 99 | def _handle_class( |
| 100 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 101 | ) -> None: |
| 102 | name_node = node.child_by_field_name("name") |
| 103 | if not name_node: |
| 104 | return |
| 105 | name = _node_text(name_node, source) |
| 106 | docstring = _javadoc(node, source) |
| 107 | |
| 108 | store.create_node( |
| 109 | NodeLabel.Class, |
| 110 | { |
| 111 | "name": name, |
| 112 | "file_path": file_path, |
| 113 | "line_start": node.start_point[0] + 1, |
| 114 | "line_end": node.end_point[0] + 1, |
| 115 | "docstring": docstring, |
| 116 | }, |
| 117 | ) |
| 118 | store.create_edge( |
| 119 | NodeLabel.File, |
| 120 | {"path": file_path}, |
| 121 | EdgeType.CONTAINS, |
| 122 | NodeLabel.Class, |
| 123 | {"name": name, "file_path": file_path}, |
| 124 | ) |
| 125 | stats["classes"] += 1 |
| 126 | stats["edges"] += 1 |
| 127 | |
| 128 | # Superclass → INHERITS edge |
| @@ -112,135 +130,169 @@ | |
| 130 | if superclass: |
| 131 | for child in superclass.children: |
| 132 | if child.type == "type_identifier": |
| 133 | parent_name = _node_text(child, source) |
| 134 | store.create_edge( |
| 135 | NodeLabel.Class, |
| 136 | {"name": name, "file_path": file_path}, |
| 137 | EdgeType.INHERITS, |
| 138 | NodeLabel.Class, |
| 139 | {"name": parent_name, "file_path": file_path}, |
| 140 | ) |
| 141 | stats["edges"] += 1 |
| 142 | break |
| 143 | |
| 144 | # Walk class body for methods and constructors |
| 145 | body = node.child_by_field_name("body") |
| 146 | if body: |
| 147 | for child in body.children: |
| 148 | if child.type in ("method_declaration", "constructor_declaration"): |
| 149 | self._handle_method(child, source, file_path, store, stats, class_name=name) |
| 150 | elif child.type in ( |
| 151 | "class_declaration", |
| 152 | "record_declaration", |
| 153 | "interface_declaration", |
| 154 | ): |
| 155 | # Nested class — register but don't recurse into methods |
| 156 | inner_name_node = child.child_by_field_name("name") |
| 157 | if inner_name_node: |
| 158 | inner_name = _node_text(inner_name_node, source) |
| 159 | store.create_node( |
| 160 | NodeLabel.Class, |
| 161 | { |
| 162 | "name": inner_name, |
| 163 | "file_path": file_path, |
| 164 | "line_start": child.start_point[0] + 1, |
| 165 | "line_end": child.end_point[0] + 1, |
| 166 | "docstring": "", |
| 167 | }, |
| 168 | ) |
| 169 | store.create_edge( |
| 170 | NodeLabel.Class, |
| 171 | {"name": name, "file_path": file_path}, |
| 172 | EdgeType.CONTAINS, |
| 173 | NodeLabel.Class, |
| 174 | {"name": inner_name, "file_path": file_path}, |
| 175 | ) |
| 176 | stats["classes"] += 1 |
| 177 | stats["edges"] += 1 |
| 178 | |
| 179 | def _handle_interface( |
| 180 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 181 | ) -> None: |
| 182 | name_node = node.child_by_field_name("name") |
| 183 | if not name_node: |
| 184 | return |
| 185 | name = _node_text(name_node, source) |
| 186 | docstring = _javadoc(node, source) |
| 187 | |
| 188 | store.create_node( |
| 189 | NodeLabel.Class, |
| 190 | { |
| 191 | "name": name, |
| 192 | "file_path": file_path, |
| 193 | "line_start": node.start_point[0] + 1, |
| 194 | "line_end": node.end_point[0] + 1, |
| 195 | "docstring": f"interface: {docstring}".strip(": "), |
| 196 | }, |
| 197 | ) |
| 198 | store.create_edge( |
| 199 | NodeLabel.File, |
| 200 | {"path": file_path}, |
| 201 | EdgeType.CONTAINS, |
| 202 | NodeLabel.Class, |
| 203 | {"name": name, "file_path": file_path}, |
| 204 | ) |
| 205 | stats["classes"] += 1 |
| 206 | stats["edges"] += 1 |
| 207 | |
| 208 | # Walk interface body for method signatures |
| 209 | body = node.child_by_field_name("body") |
| 210 | if body: |
| 211 | for child in body.children: |
| 212 | if child.type == "method_declaration": |
| 213 | self._handle_method(child, source, file_path, store, stats, class_name=name) |
| 214 | |
| 215 | def _handle_method( |
| 216 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict, class_name: str |
| 217 | ) -> None: |
| 218 | name_node = node.child_by_field_name("name") |
| 219 | if not name_node: |
| 220 | return |
| 221 | name = _node_text(name_node, source) |
| 222 | docstring = _javadoc(node, source) |
| 223 | |
| 224 | store.create_node( |
| 225 | NodeLabel.Method, |
| 226 | { |
| 227 | "name": name, |
| 228 | "file_path": file_path, |
| 229 | "line_start": node.start_point[0] + 1, |
| 230 | "line_end": node.end_point[0] + 1, |
| 231 | "docstring": docstring, |
| 232 | "class_name": class_name, |
| 233 | }, |
| 234 | ) |
| 235 | store.create_edge( |
| 236 | NodeLabel.Class, |
| 237 | {"name": class_name, "file_path": file_path}, |
| 238 | EdgeType.CONTAINS, |
| 239 | NodeLabel.Method, |
| 240 | {"name": name, "file_path": file_path}, |
| 241 | ) |
| 242 | stats["functions"] += 1 |
| 243 | stats["edges"] += 1 |
| 244 | |
| 245 | self._extract_calls(node, source, file_path, name, store, stats) |
| 246 | |
| 247 | def _handle_import( |
| 248 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 249 | ) -> None: |
| 250 | # import java.util.List; → strip keyword + semicolon |
| 251 | raw = _node_text(node, source) |
| 252 | module = raw.removeprefix("import").removeprefix(" static").removesuffix(";").strip() |
| 253 | store.create_node( |
| 254 | NodeLabel.Import, |
| 255 | { |
| 256 | "name": module, |
| 257 | "file_path": file_path, |
| 258 | "line_start": node.start_point[0] + 1, |
| 259 | "module": module, |
| 260 | }, |
| 261 | ) |
| 262 | store.create_edge( |
| 263 | NodeLabel.File, |
| 264 | {"path": file_path}, |
| 265 | EdgeType.IMPORTS, |
| 266 | NodeLabel.Import, |
| 267 | {"name": module, "file_path": file_path}, |
| 268 | ) |
| 269 | stats["edges"] += 1 |
| 270 | |
| 271 | def _extract_calls( |
| 272 | self, |
| 273 | method_node, |
| 274 | source: bytes, |
| 275 | file_path: str, |
| 276 | method_name: str, |
| 277 | store: GraphStore, |
| 278 | stats: dict, |
| 279 | ) -> None: |
| 280 | def walk(node): |
| 281 | if node.type == "method_invocation": |
| 282 | name_node = node.child_by_field_name("name") |
| 283 | if name_node: |
| 284 | callee = _node_text(name_node, source) |
| 285 | store.create_edge( |
| 286 | NodeLabel.Method, |
| 287 | {"name": method_name, "file_path": file_path}, |
| 288 | EdgeType.CALLS, |
| 289 | NodeLabel.Function, |
| 290 | {"name": callee, "file_path": file_path}, |
| 291 | ) |
| 292 | stats["edges"] += 1 |
| 293 | for child in node.children: |
| 294 | walk(child) |
| 295 | |
| 296 | body = method_node.child_by_field_name("body") |
| 297 | if body: |
| 298 | walk(body) |
| 299 |
+100
-61
| --- navegador/ingestion/knowledge.py | ||
| +++ navegador/ingestion/knowledge.py | ||
| @@ -35,14 +35,17 @@ | ||
| 35 | 35 | self.store = store |
| 36 | 36 | |
| 37 | 37 | # ── Domains ─────────────────────────────────────────────────────────────── |
| 38 | 38 | |
| 39 | 39 | def add_domain(self, name: str, description: str = "") -> None: |
| 40 | - self.store.create_node(NodeLabel.Domain, { | |
| 41 | - "name": name, | |
| 42 | - "description": description, | |
| 43 | - }) | |
| 40 | + self.store.create_node( | |
| 41 | + NodeLabel.Domain, | |
| 42 | + { | |
| 43 | + "name": name, | |
| 44 | + "description": description, | |
| 45 | + }, | |
| 46 | + ) | |
| 44 | 47 | logger.info("Domain: %s", name) |
| 45 | 48 | |
| 46 | 49 | # ── Concepts ────────────────────────────────────────────────────────────── |
| 47 | 50 | |
| 48 | 51 | def add_concept( |
| @@ -53,29 +56,34 @@ | ||
| 53 | 56 | status: str = "", |
| 54 | 57 | rules: str = "", |
| 55 | 58 | examples: str = "", |
| 56 | 59 | wiki_refs: str = "", |
| 57 | 60 | ) -> None: |
| 58 | - self.store.create_node(NodeLabel.Concept, { | |
| 59 | - "name": name, | |
| 60 | - "description": description, | |
| 61 | - "domain": domain, | |
| 62 | - "status": status, | |
| 63 | - "rules": rules, | |
| 64 | - "examples": examples, | |
| 65 | - "wiki_refs": wiki_refs, | |
| 66 | - }) | |
| 61 | + self.store.create_node( | |
| 62 | + NodeLabel.Concept, | |
| 63 | + { | |
| 64 | + "name": name, | |
| 65 | + "description": description, | |
| 66 | + "domain": domain, | |
| 67 | + "status": status, | |
| 68 | + "rules": rules, | |
| 69 | + "examples": examples, | |
| 70 | + "wiki_refs": wiki_refs, | |
| 71 | + }, | |
| 72 | + ) | |
| 67 | 73 | if domain: |
| 68 | 74 | self._link_to_domain(name, NodeLabel.Concept, domain) |
| 69 | 75 | logger.info("Concept: %s", name) |
| 70 | 76 | |
| 71 | 77 | def relate_concepts(self, a: str, b: str) -> None: |
| 72 | 78 | """Mark two concepts as related (bidirectional intent).""" |
| 73 | 79 | self.store.create_edge( |
| 74 | - NodeLabel.Concept, {"name": a}, | |
| 80 | + NodeLabel.Concept, | |
| 81 | + {"name": a}, | |
| 75 | 82 | EdgeType.RELATED_TO, |
| 76 | - NodeLabel.Concept, {"name": b}, | |
| 83 | + NodeLabel.Concept, | |
| 84 | + {"name": b}, | |
| 77 | 85 | ) |
| 78 | 86 | |
| 79 | 87 | # ── Rules ───────────────────────────────────────────────────────────────── |
| 80 | 88 | |
| 81 | 89 | def add_rule( |
| @@ -85,27 +93,32 @@ | ||
| 85 | 93 | domain: str = "", |
| 86 | 94 | severity: str = "info", |
| 87 | 95 | rationale: str = "", |
| 88 | 96 | examples: str = "", |
| 89 | 97 | ) -> None: |
| 90 | - self.store.create_node(NodeLabel.Rule, { | |
| 91 | - "name": name, | |
| 92 | - "description": description, | |
| 93 | - "domain": domain, | |
| 94 | - "severity": severity, | |
| 95 | - "rationale": rationale, | |
| 96 | - "examples": examples, | |
| 97 | - }) | |
| 98 | + self.store.create_node( | |
| 99 | + NodeLabel.Rule, | |
| 100 | + { | |
| 101 | + "name": name, | |
| 102 | + "description": description, | |
| 103 | + "domain": domain, | |
| 104 | + "severity": severity, | |
| 105 | + "rationale": rationale, | |
| 106 | + "examples": examples, | |
| 107 | + }, | |
| 108 | + ) | |
| 98 | 109 | if domain: |
| 99 | 110 | self._link_to_domain(name, NodeLabel.Rule, domain) |
| 100 | 111 | logger.info("Rule: %s", name) |
| 101 | 112 | |
| 102 | 113 | def rule_governs(self, rule_name: str, target_name: str, target_label: NodeLabel) -> None: |
| 103 | 114 | self.store.create_edge( |
| 104 | - NodeLabel.Rule, {"name": rule_name}, | |
| 115 | + NodeLabel.Rule, | |
| 116 | + {"name": rule_name}, | |
| 105 | 117 | EdgeType.GOVERNS, |
| 106 | - target_label, {"name": target_name}, | |
| 118 | + target_label, | |
| 119 | + {"name": target_name}, | |
| 107 | 120 | ) |
| 108 | 121 | |
| 109 | 122 | # ── Decisions ───────────────────────────────────────────────────────────── |
| 110 | 123 | |
| 111 | 124 | def add_decision( |
| @@ -116,19 +129,22 @@ | ||
| 116 | 129 | status: str = "accepted", |
| 117 | 130 | rationale: str = "", |
| 118 | 131 | alternatives: str = "", |
| 119 | 132 | date: str = "", |
| 120 | 133 | ) -> None: |
| 121 | - self.store.create_node(NodeLabel.Decision, { | |
| 122 | - "name": name, | |
| 123 | - "description": description, | |
| 124 | - "domain": domain, | |
| 125 | - "status": status, | |
| 126 | - "rationale": rationale, | |
| 127 | - "alternatives": alternatives, | |
| 128 | - "date": date, | |
| 129 | - }) | |
| 134 | + self.store.create_node( | |
| 135 | + NodeLabel.Decision, | |
| 136 | + { | |
| 137 | + "name": name, | |
| 138 | + "description": description, | |
| 139 | + "domain": domain, | |
| 140 | + "status": status, | |
| 141 | + "rationale": rationale, | |
| 142 | + "alternatives": alternatives, | |
| 143 | + "date": date, | |
| 144 | + }, | |
| 145 | + ) | |
| 130 | 146 | if domain: |
| 131 | 147 | self._link_to_domain(name, NodeLabel.Decision, domain) |
| 132 | 148 | logger.info("Decision: %s", name) |
| 133 | 149 | |
| 134 | 150 | # ── People ──────────────────────────────────────────────────────────────── |
| @@ -138,24 +154,29 @@ | ||
| 138 | 154 | name: str, |
| 139 | 155 | email: str = "", |
| 140 | 156 | role: str = "", |
| 141 | 157 | team: str = "", |
| 142 | 158 | ) -> None: |
| 143 | - self.store.create_node(NodeLabel.Person, { | |
| 144 | - "name": name, | |
| 145 | - "email": email, | |
| 146 | - "role": role, | |
| 147 | - "team": team, | |
| 148 | - }) | |
| 159 | + self.store.create_node( | |
| 160 | + NodeLabel.Person, | |
| 161 | + { | |
| 162 | + "name": name, | |
| 163 | + "email": email, | |
| 164 | + "role": role, | |
| 165 | + "team": team, | |
| 166 | + }, | |
| 167 | + ) | |
| 149 | 168 | logger.info("Person: %s", name) |
| 150 | 169 | |
| 151 | 170 | def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None: |
| 152 | 171 | """Assign a person as owner of any node.""" |
| 153 | 172 | self.store.create_edge( |
| 154 | - target_label, {"name": target_name}, | |
| 173 | + target_label, | |
| 174 | + {"name": target_name}, | |
| 155 | 175 | EdgeType.ASSIGNED_TO, |
| 156 | - NodeLabel.Person, {"name": person_name}, | |
| 176 | + NodeLabel.Person, | |
| 177 | + {"name": person_name}, | |
| 157 | 178 | ) |
| 158 | 179 | |
| 159 | 180 | # ── Wiki pages ──────────────────────────────────────────────────────────── |
| 160 | 181 | |
| 161 | 182 | def wiki_page( |
| @@ -164,25 +185,35 @@ | ||
| 164 | 185 | url: str = "", |
| 165 | 186 | source: str = "github", |
| 166 | 187 | content: str = "", |
| 167 | 188 | updated_at: str = "", |
| 168 | 189 | ) -> None: |
| 169 | - self.store.create_node(NodeLabel.WikiPage, { | |
| 170 | - "name": name, | |
| 171 | - "url": url, | |
| 172 | - "source": source, | |
| 173 | - "content": content, | |
| 174 | - "updated_at": updated_at, | |
| 175 | - }) | |
| 190 | + self.store.create_node( | |
| 191 | + NodeLabel.WikiPage, | |
| 192 | + { | |
| 193 | + "name": name, | |
| 194 | + "url": url, | |
| 195 | + "source": source, | |
| 196 | + "content": content, | |
| 197 | + "updated_at": updated_at, | |
| 198 | + }, | |
| 199 | + ) | |
| 176 | 200 | logger.info("WikiPage: %s", name) |
| 177 | 201 | |
| 178 | - def wiki_documents(self, wiki_page_name: str, target_name: str, | |
| 179 | - target_props: dict[str, Any], target_label: NodeLabel) -> None: | |
| 202 | + def wiki_documents( | |
| 203 | + self, | |
| 204 | + wiki_page_name: str, | |
| 205 | + target_name: str, | |
| 206 | + target_props: dict[str, Any], | |
| 207 | + target_label: NodeLabel, | |
| 208 | + ) -> None: | |
| 180 | 209 | self.store.create_edge( |
| 181 | - NodeLabel.WikiPage, {"name": wiki_page_name}, | |
| 210 | + NodeLabel.WikiPage, | |
| 211 | + {"name": wiki_page_name}, | |
| 182 | 212 | EdgeType.DOCUMENTS, |
| 183 | - target_label, target_props, | |
| 213 | + target_label, | |
| 214 | + target_props, | |
| 184 | 215 | ) |
| 185 | 216 | |
| 186 | 217 | # ── Code ↔ Knowledge bridges ────────────────────────────────────────────── |
| 187 | 218 | |
| 188 | 219 | def annotate_code( |
| @@ -197,35 +228,43 @@ | ||
| 197 | 228 | code_label should be a string matching a NodeLabel value. |
| 198 | 229 | """ |
| 199 | 230 | label = NodeLabel(code_label) |
| 200 | 231 | if concept: |
| 201 | 232 | self.store.create_edge( |
| 202 | - NodeLabel.Concept, {"name": concept}, | |
| 233 | + NodeLabel.Concept, | |
| 234 | + {"name": concept}, | |
| 203 | 235 | EdgeType.ANNOTATES, |
| 204 | - label, {"name": code_name}, | |
| 236 | + label, | |
| 237 | + {"name": code_name}, | |
| 205 | 238 | ) |
| 206 | 239 | if rule: |
| 207 | 240 | self.store.create_edge( |
| 208 | - NodeLabel.Rule, {"name": rule}, | |
| 241 | + NodeLabel.Rule, | |
| 242 | + {"name": rule}, | |
| 209 | 243 | EdgeType.ANNOTATES, |
| 210 | - label, {"name": code_name}, | |
| 244 | + label, | |
| 245 | + {"name": code_name}, | |
| 211 | 246 | ) |
| 212 | 247 | |
| 213 | 248 | def code_implements(self, code_name: str, code_label: str, concept_name: str) -> None: |
| 214 | 249 | """Mark a function/class as implementing a concept.""" |
| 215 | 250 | label = NodeLabel(code_label) |
| 216 | 251 | self.store.create_edge( |
| 217 | - label, {"name": code_name}, | |
| 252 | + label, | |
| 253 | + {"name": code_name}, | |
| 218 | 254 | EdgeType.IMPLEMENTS, |
| 219 | - NodeLabel.Concept, {"name": concept_name}, | |
| 255 | + NodeLabel.Concept, | |
| 256 | + {"name": concept_name}, | |
| 220 | 257 | ) |
| 221 | 258 | |
| 222 | 259 | # ── Helpers ─────────────────────────────────────────────────────────────── |
| 223 | 260 | |
| 224 | 261 | def _link_to_domain(self, name: str, label: NodeLabel, domain: str) -> None: |
| 225 | 262 | # Ensure domain node exists |
| 226 | 263 | self.store.create_node(NodeLabel.Domain, {"name": domain, "description": ""}) |
| 227 | 264 | self.store.create_edge( |
| 228 | - label, {"name": name}, | |
| 265 | + label, | |
| 266 | + {"name": name}, | |
| 229 | 267 | EdgeType.BELONGS_TO, |
| 230 | - NodeLabel.Domain, {"name": domain}, | |
| 268 | + NodeLabel.Domain, | |
| 269 | + {"name": domain}, | |
| 231 | 270 | ) |
| 232 | 271 |
| --- navegador/ingestion/knowledge.py | |
| +++ navegador/ingestion/knowledge.py | |
| @@ -35,14 +35,17 @@ | |
| 35 | self.store = store |
| 36 | |
| 37 | # ── Domains ─────────────────────────────────────────────────────────────── |
| 38 | |
| 39 | def add_domain(self, name: str, description: str = "") -> None: |
| 40 | self.store.create_node(NodeLabel.Domain, { |
| 41 | "name": name, |
| 42 | "description": description, |
| 43 | }) |
| 44 | logger.info("Domain: %s", name) |
| 45 | |
| 46 | # ── Concepts ────────────────────────────────────────────────────────────── |
| 47 | |
| 48 | def add_concept( |
| @@ -53,29 +56,34 @@ | |
| 53 | status: str = "", |
| 54 | rules: str = "", |
| 55 | examples: str = "", |
| 56 | wiki_refs: str = "", |
| 57 | ) -> None: |
| 58 | self.store.create_node(NodeLabel.Concept, { |
| 59 | "name": name, |
| 60 | "description": description, |
| 61 | "domain": domain, |
| 62 | "status": status, |
| 63 | "rules": rules, |
| 64 | "examples": examples, |
| 65 | "wiki_refs": wiki_refs, |
| 66 | }) |
| 67 | if domain: |
| 68 | self._link_to_domain(name, NodeLabel.Concept, domain) |
| 69 | logger.info("Concept: %s", name) |
| 70 | |
| 71 | def relate_concepts(self, a: str, b: str) -> None: |
| 72 | """Mark two concepts as related (bidirectional intent).""" |
| 73 | self.store.create_edge( |
| 74 | NodeLabel.Concept, {"name": a}, |
| 75 | EdgeType.RELATED_TO, |
| 76 | NodeLabel.Concept, {"name": b}, |
| 77 | ) |
| 78 | |
| 79 | # ── Rules ───────────────────────────────────────────────────────────────── |
| 80 | |
| 81 | def add_rule( |
| @@ -85,27 +93,32 @@ | |
| 85 | domain: str = "", |
| 86 | severity: str = "info", |
| 87 | rationale: str = "", |
| 88 | examples: str = "", |
| 89 | ) -> None: |
| 90 | self.store.create_node(NodeLabel.Rule, { |
| 91 | "name": name, |
| 92 | "description": description, |
| 93 | "domain": domain, |
| 94 | "severity": severity, |
| 95 | "rationale": rationale, |
| 96 | "examples": examples, |
| 97 | }) |
| 98 | if domain: |
| 99 | self._link_to_domain(name, NodeLabel.Rule, domain) |
| 100 | logger.info("Rule: %s", name) |
| 101 | |
| 102 | def rule_governs(self, rule_name: str, target_name: str, target_label: NodeLabel) -> None: |
| 103 | self.store.create_edge( |
| 104 | NodeLabel.Rule, {"name": rule_name}, |
| 105 | EdgeType.GOVERNS, |
| 106 | target_label, {"name": target_name}, |
| 107 | ) |
| 108 | |
| 109 | # ── Decisions ───────────────────────────────────────────────────────────── |
| 110 | |
| 111 | def add_decision( |
| @@ -116,19 +129,22 @@ | |
| 116 | status: str = "accepted", |
| 117 | rationale: str = "", |
| 118 | alternatives: str = "", |
| 119 | date: str = "", |
| 120 | ) -> None: |
| 121 | self.store.create_node(NodeLabel.Decision, { |
| 122 | "name": name, |
| 123 | "description": description, |
| 124 | "domain": domain, |
| 125 | "status": status, |
| 126 | "rationale": rationale, |
| 127 | "alternatives": alternatives, |
| 128 | "date": date, |
| 129 | }) |
| 130 | if domain: |
| 131 | self._link_to_domain(name, NodeLabel.Decision, domain) |
| 132 | logger.info("Decision: %s", name) |
| 133 | |
| 134 | # ── People ──────────────────────────────────────────────────────────────── |
| @@ -138,24 +154,29 @@ | |
| 138 | name: str, |
| 139 | email: str = "", |
| 140 | role: str = "", |
| 141 | team: str = "", |
| 142 | ) -> None: |
| 143 | self.store.create_node(NodeLabel.Person, { |
| 144 | "name": name, |
| 145 | "email": email, |
| 146 | "role": role, |
| 147 | "team": team, |
| 148 | }) |
| 149 | logger.info("Person: %s", name) |
| 150 | |
| 151 | def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None: |
| 152 | """Assign a person as owner of any node.""" |
| 153 | self.store.create_edge( |
| 154 | target_label, {"name": target_name}, |
| 155 | EdgeType.ASSIGNED_TO, |
| 156 | NodeLabel.Person, {"name": person_name}, |
| 157 | ) |
| 158 | |
| 159 | # ── Wiki pages ──────────────────────────────────────────────────────────── |
| 160 | |
| 161 | def wiki_page( |
| @@ -164,25 +185,35 @@ | |
| 164 | url: str = "", |
| 165 | source: str = "github", |
| 166 | content: str = "", |
| 167 | updated_at: str = "", |
| 168 | ) -> None: |
| 169 | self.store.create_node(NodeLabel.WikiPage, { |
| 170 | "name": name, |
| 171 | "url": url, |
| 172 | "source": source, |
| 173 | "content": content, |
| 174 | "updated_at": updated_at, |
| 175 | }) |
| 176 | logger.info("WikiPage: %s", name) |
| 177 | |
| 178 | def wiki_documents(self, wiki_page_name: str, target_name: str, |
| 179 | target_props: dict[str, Any], target_label: NodeLabel) -> None: |
| 180 | self.store.create_edge( |
| 181 | NodeLabel.WikiPage, {"name": wiki_page_name}, |
| 182 | EdgeType.DOCUMENTS, |
| 183 | target_label, target_props, |
| 184 | ) |
| 185 | |
| 186 | # ── Code ↔ Knowledge bridges ────────────────────────────────────────────── |
| 187 | |
| 188 | def annotate_code( |
| @@ -197,35 +228,43 @@ | |
| 197 | code_label should be a string matching a NodeLabel value. |
| 198 | """ |
| 199 | label = NodeLabel(code_label) |
| 200 | if concept: |
| 201 | self.store.create_edge( |
| 202 | NodeLabel.Concept, {"name": concept}, |
| 203 | EdgeType.ANNOTATES, |
| 204 | label, {"name": code_name}, |
| 205 | ) |
| 206 | if rule: |
| 207 | self.store.create_edge( |
| 208 | NodeLabel.Rule, {"name": rule}, |
| 209 | EdgeType.ANNOTATES, |
| 210 | label, {"name": code_name}, |
| 211 | ) |
| 212 | |
| 213 | def code_implements(self, code_name: str, code_label: str, concept_name: str) -> None: |
| 214 | """Mark a function/class as implementing a concept.""" |
| 215 | label = NodeLabel(code_label) |
| 216 | self.store.create_edge( |
| 217 | label, {"name": code_name}, |
| 218 | EdgeType.IMPLEMENTS, |
| 219 | NodeLabel.Concept, {"name": concept_name}, |
| 220 | ) |
| 221 | |
| 222 | # ── Helpers ─────────────────────────────────────────────────────────────── |
| 223 | |
| 224 | def _link_to_domain(self, name: str, label: NodeLabel, domain: str) -> None: |
| 225 | # Ensure domain node exists |
| 226 | self.store.create_node(NodeLabel.Domain, {"name": domain, "description": ""}) |
| 227 | self.store.create_edge( |
| 228 | label, {"name": name}, |
| 229 | EdgeType.BELONGS_TO, |
| 230 | NodeLabel.Domain, {"name": domain}, |
| 231 | ) |
| 232 |
| --- navegador/ingestion/knowledge.py | |
| +++ navegador/ingestion/knowledge.py | |
| @@ -35,14 +35,17 @@ | |
| 35 | self.store = store |
| 36 | |
| 37 | # ── Domains ─────────────────────────────────────────────────────────────── |
| 38 | |
| 39 | def add_domain(self, name: str, description: str = "") -> None: |
| 40 | self.store.create_node( |
| 41 | NodeLabel.Domain, |
| 42 | { |
| 43 | "name": name, |
| 44 | "description": description, |
| 45 | }, |
| 46 | ) |
| 47 | logger.info("Domain: %s", name) |
| 48 | |
| 49 | # ── Concepts ────────────────────────────────────────────────────────────── |
| 50 | |
| 51 | def add_concept( |
| @@ -53,29 +56,34 @@ | |
| 56 | status: str = "", |
| 57 | rules: str = "", |
| 58 | examples: str = "", |
| 59 | wiki_refs: str = "", |
| 60 | ) -> None: |
| 61 | self.store.create_node( |
| 62 | NodeLabel.Concept, |
| 63 | { |
| 64 | "name": name, |
| 65 | "description": description, |
| 66 | "domain": domain, |
| 67 | "status": status, |
| 68 | "rules": rules, |
| 69 | "examples": examples, |
| 70 | "wiki_refs": wiki_refs, |
| 71 | }, |
| 72 | ) |
| 73 | if domain: |
| 74 | self._link_to_domain(name, NodeLabel.Concept, domain) |
| 75 | logger.info("Concept: %s", name) |
| 76 | |
| 77 | def relate_concepts(self, a: str, b: str) -> None: |
| 78 | """Mark two concepts as related (bidirectional intent).""" |
| 79 | self.store.create_edge( |
| 80 | NodeLabel.Concept, |
| 81 | {"name": a}, |
| 82 | EdgeType.RELATED_TO, |
| 83 | NodeLabel.Concept, |
| 84 | {"name": b}, |
| 85 | ) |
| 86 | |
| 87 | # ── Rules ───────────────────────────────────────────────────────────────── |
| 88 | |
| 89 | def add_rule( |
| @@ -85,27 +93,32 @@ | |
| 93 | domain: str = "", |
| 94 | severity: str = "info", |
| 95 | rationale: str = "", |
| 96 | examples: str = "", |
| 97 | ) -> None: |
| 98 | self.store.create_node( |
| 99 | NodeLabel.Rule, |
| 100 | { |
| 101 | "name": name, |
| 102 | "description": description, |
| 103 | "domain": domain, |
| 104 | "severity": severity, |
| 105 | "rationale": rationale, |
| 106 | "examples": examples, |
| 107 | }, |
| 108 | ) |
| 109 | if domain: |
| 110 | self._link_to_domain(name, NodeLabel.Rule, domain) |
| 111 | logger.info("Rule: %s", name) |
| 112 | |
| 113 | def rule_governs(self, rule_name: str, target_name: str, target_label: NodeLabel) -> None: |
| 114 | self.store.create_edge( |
| 115 | NodeLabel.Rule, |
| 116 | {"name": rule_name}, |
| 117 | EdgeType.GOVERNS, |
| 118 | target_label, |
| 119 | {"name": target_name}, |
| 120 | ) |
| 121 | |
| 122 | # ── Decisions ───────────────────────────────────────────────────────────── |
| 123 | |
| 124 | def add_decision( |
| @@ -116,19 +129,22 @@ | |
| 129 | status: str = "accepted", |
| 130 | rationale: str = "", |
| 131 | alternatives: str = "", |
| 132 | date: str = "", |
| 133 | ) -> None: |
| 134 | self.store.create_node( |
| 135 | NodeLabel.Decision, |
| 136 | { |
| 137 | "name": name, |
| 138 | "description": description, |
| 139 | "domain": domain, |
| 140 | "status": status, |
| 141 | "rationale": rationale, |
| 142 | "alternatives": alternatives, |
| 143 | "date": date, |
| 144 | }, |
| 145 | ) |
| 146 | if domain: |
| 147 | self._link_to_domain(name, NodeLabel.Decision, domain) |
| 148 | logger.info("Decision: %s", name) |
| 149 | |
| 150 | # ── People ──────────────────────────────────────────────────────────────── |
| @@ -138,24 +154,29 @@ | |
| 154 | name: str, |
| 155 | email: str = "", |
| 156 | role: str = "", |
| 157 | team: str = "", |
| 158 | ) -> None: |
| 159 | self.store.create_node( |
| 160 | NodeLabel.Person, |
| 161 | { |
| 162 | "name": name, |
| 163 | "email": email, |
| 164 | "role": role, |
| 165 | "team": team, |
| 166 | }, |
| 167 | ) |
| 168 | logger.info("Person: %s", name) |
| 169 | |
| 170 | def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None: |
| 171 | """Assign a person as owner of any node.""" |
| 172 | self.store.create_edge( |
| 173 | target_label, |
| 174 | {"name": target_name}, |
| 175 | EdgeType.ASSIGNED_TO, |
| 176 | NodeLabel.Person, |
| 177 | {"name": person_name}, |
| 178 | ) |
| 179 | |
| 180 | # ── Wiki pages ──────────────────────────────────────────────────────────── |
| 181 | |
| 182 | def wiki_page( |
| @@ -164,25 +185,35 @@ | |
| 185 | url: str = "", |
| 186 | source: str = "github", |
| 187 | content: str = "", |
| 188 | updated_at: str = "", |
| 189 | ) -> None: |
| 190 | self.store.create_node( |
| 191 | NodeLabel.WikiPage, |
| 192 | { |
| 193 | "name": name, |
| 194 | "url": url, |
| 195 | "source": source, |
| 196 | "content": content, |
| 197 | "updated_at": updated_at, |
| 198 | }, |
| 199 | ) |
| 200 | logger.info("WikiPage: %s", name) |
| 201 | |
| 202 | def wiki_documents( |
| 203 | self, |
| 204 | wiki_page_name: str, |
| 205 | target_name: str, |
| 206 | target_props: dict[str, Any], |
| 207 | target_label: NodeLabel, |
| 208 | ) -> None: |
| 209 | self.store.create_edge( |
| 210 | NodeLabel.WikiPage, |
| 211 | {"name": wiki_page_name}, |
| 212 | EdgeType.DOCUMENTS, |
| 213 | target_label, |
| 214 | target_props, |
| 215 | ) |
| 216 | |
| 217 | # ── Code ↔ Knowledge bridges ────────────────────────────────────────────── |
| 218 | |
| 219 | def annotate_code( |
| @@ -197,35 +228,43 @@ | |
| 228 | code_label should be a string matching a NodeLabel value. |
| 229 | """ |
| 230 | label = NodeLabel(code_label) |
| 231 | if concept: |
| 232 | self.store.create_edge( |
| 233 | NodeLabel.Concept, |
| 234 | {"name": concept}, |
| 235 | EdgeType.ANNOTATES, |
| 236 | label, |
| 237 | {"name": code_name}, |
| 238 | ) |
| 239 | if rule: |
| 240 | self.store.create_edge( |
| 241 | NodeLabel.Rule, |
| 242 | {"name": rule}, |
| 243 | EdgeType.ANNOTATES, |
| 244 | label, |
| 245 | {"name": code_name}, |
| 246 | ) |
| 247 | |
| 248 | def code_implements(self, code_name: str, code_label: str, concept_name: str) -> None: |
| 249 | """Mark a function/class as implementing a concept.""" |
| 250 | label = NodeLabel(code_label) |
| 251 | self.store.create_edge( |
| 252 | label, |
| 253 | {"name": code_name}, |
| 254 | EdgeType.IMPLEMENTS, |
| 255 | NodeLabel.Concept, |
| 256 | {"name": concept_name}, |
| 257 | ) |
| 258 | |
| 259 | # ── Helpers ─────────────────────────────────────────────────────────────── |
| 260 | |
| 261 | def _link_to_domain(self, name: str, label: NodeLabel, domain: str) -> None: |
| 262 | # Ensure domain node exists |
| 263 | self.store.create_node(NodeLabel.Domain, {"name": domain, "description": ""}) |
| 264 | self.store.create_edge( |
| 265 | label, |
| 266 | {"name": name}, |
| 267 | EdgeType.BELONGS_TO, |
| 268 | NodeLabel.Domain, |
| 269 | {"name": domain}, |
| 270 | ) |
| 271 |
+34
-17
| --- navegador/ingestion/parser.py | ||
| +++ navegador/ingestion/parser.py | ||
| @@ -19,17 +19,17 @@ | ||
| 19 | 19 | |
| 20 | 20 | logger = logging.getLogger(__name__) |
| 21 | 21 | |
| 22 | 22 | # File extensions → language key |
| 23 | 23 | LANGUAGE_MAP: dict[str, str] = { |
| 24 | - ".py": "python", | |
| 25 | - ".ts": "typescript", | |
| 26 | - ".tsx": "typescript", | |
| 27 | - ".js": "javascript", | |
| 28 | - ".jsx": "javascript", | |
| 29 | - ".go": "go", | |
| 30 | - ".rs": "rust", | |
| 24 | + ".py": "python", | |
| 25 | + ".ts": "typescript", | |
| 26 | + ".tsx": "typescript", | |
| 27 | + ".js": "javascript", | |
| 28 | + ".jsx": "javascript", | |
| 29 | + ".go": "go", | |
| 30 | + ".rs": "rust", | |
| 31 | 31 | ".java": "java", |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | |
| 35 | 35 | class RepoIngester: |
| @@ -63,14 +63,17 @@ | ||
| 63 | 63 | |
| 64 | 64 | if clear: |
| 65 | 65 | self.store.clear() |
| 66 | 66 | |
| 67 | 67 | # Create repository node |
| 68 | - self.store.create_node(NodeLabel.Repository, { | |
| 69 | - "name": repo_path.name, | |
| 70 | - "path": str(repo_path), | |
| 71 | - }) | |
| 68 | + self.store.create_node( | |
| 69 | + NodeLabel.Repository, | |
| 70 | + { | |
| 71 | + "name": repo_path.name, | |
| 72 | + "path": str(repo_path), | |
| 73 | + }, | |
| 74 | + ) | |
| 72 | 75 | |
| 73 | 76 | stats: dict[str, int] = {"files": 0, "functions": 0, "classes": 0, "edges": 0} |
| 74 | 77 | |
| 75 | 78 | for source_file in self._iter_source_files(repo_path): |
| 76 | 79 | language = LANGUAGE_MAP.get(source_file.suffix) |
| @@ -86,21 +89,30 @@ | ||
| 86 | 89 | except Exception: |
| 87 | 90 | logger.exception("Failed to parse %s", source_file) |
| 88 | 91 | |
| 89 | 92 | logger.info( |
| 90 | 93 | "Ingested %s: %d files, %d functions, %d classes", |
| 91 | - repo_path.name, stats["files"], stats["functions"], stats["classes"], | |
| 94 | + repo_path.name, | |
| 95 | + stats["files"], | |
| 96 | + stats["functions"], | |
| 97 | + stats["classes"], | |
| 92 | 98 | ) |
| 93 | 99 | return stats |
| 94 | 100 | |
| 95 | 101 | def _iter_source_files(self, repo_path: Path): |
| 96 | 102 | skip_dirs = { |
| 97 | - ".git", ".venv", "venv", "node_modules", "__pycache__", | |
| 98 | - "dist", "build", ".next", | |
| 99 | - "target", # Rust / Java (Maven/Gradle) | |
| 100 | - "vendor", # Go modules cache | |
| 101 | - ".gradle", # Gradle cache | |
| 103 | + ".git", | |
| 104 | + ".venv", | |
| 105 | + "venv", | |
| 106 | + "node_modules", | |
| 107 | + "__pycache__", | |
| 108 | + "dist", | |
| 109 | + "build", | |
| 110 | + ".next", | |
| 111 | + "target", # Rust / Java (Maven/Gradle) | |
| 112 | + "vendor", # Go modules cache | |
| 113 | + ".gradle", # Gradle cache | |
| 102 | 114 | } |
| 103 | 115 | for path in repo_path.rglob("*"): |
| 104 | 116 | if path.is_file() and path.suffix in LANGUAGE_MAP: |
| 105 | 117 | if not any(part in skip_dirs for part in path.parts): |
| 106 | 118 | yield path |
| @@ -107,22 +119,27 @@ | ||
| 107 | 119 | |
| 108 | 120 | def _get_parser(self, language: str) -> "LanguageParser": |
| 109 | 121 | if language not in self._parsers: |
| 110 | 122 | if language == "python": |
| 111 | 123 | from navegador.ingestion.python import PythonParser |
| 124 | + | |
| 112 | 125 | self._parsers[language] = PythonParser() |
| 113 | 126 | elif language in ("typescript", "javascript"): |
| 114 | 127 | from navegador.ingestion.typescript import TypeScriptParser |
| 128 | + | |
| 115 | 129 | self._parsers[language] = TypeScriptParser(language) |
| 116 | 130 | elif language == "go": |
| 117 | 131 | from navegador.ingestion.go import GoParser |
| 132 | + | |
| 118 | 133 | self._parsers[language] = GoParser() |
| 119 | 134 | elif language == "rust": |
| 120 | 135 | from navegador.ingestion.rust import RustParser |
| 136 | + | |
| 121 | 137 | self._parsers[language] = RustParser() |
| 122 | 138 | elif language == "java": |
| 123 | 139 | from navegador.ingestion.java import JavaParser |
| 140 | + | |
| 124 | 141 | self._parsers[language] = JavaParser() |
| 125 | 142 | else: |
| 126 | 143 | raise ValueError(f"Unsupported language: {language}") |
| 127 | 144 | return self._parsers[language] |
| 128 | 145 | |
| 129 | 146 |
| --- navegador/ingestion/parser.py | |
| +++ navegador/ingestion/parser.py | |
| @@ -19,17 +19,17 @@ | |
| 19 | |
| 20 | logger = logging.getLogger(__name__) |
| 21 | |
| 22 | # File extensions → language key |
| 23 | LANGUAGE_MAP: dict[str, str] = { |
| 24 | ".py": "python", |
| 25 | ".ts": "typescript", |
| 26 | ".tsx": "typescript", |
| 27 | ".js": "javascript", |
| 28 | ".jsx": "javascript", |
| 29 | ".go": "go", |
| 30 | ".rs": "rust", |
| 31 | ".java": "java", |
| 32 | } |
| 33 | |
| 34 | |
| 35 | class RepoIngester: |
| @@ -63,14 +63,17 @@ | |
| 63 | |
| 64 | if clear: |
| 65 | self.store.clear() |
| 66 | |
| 67 | # Create repository node |
| 68 | self.store.create_node(NodeLabel.Repository, { |
| 69 | "name": repo_path.name, |
| 70 | "path": str(repo_path), |
| 71 | }) |
| 72 | |
| 73 | stats: dict[str, int] = {"files": 0, "functions": 0, "classes": 0, "edges": 0} |
| 74 | |
| 75 | for source_file in self._iter_source_files(repo_path): |
| 76 | language = LANGUAGE_MAP.get(source_file.suffix) |
| @@ -86,21 +89,30 @@ | |
| 86 | except Exception: |
| 87 | logger.exception("Failed to parse %s", source_file) |
| 88 | |
| 89 | logger.info( |
| 90 | "Ingested %s: %d files, %d functions, %d classes", |
| 91 | repo_path.name, stats["files"], stats["functions"], stats["classes"], |
| 92 | ) |
| 93 | return stats |
| 94 | |
| 95 | def _iter_source_files(self, repo_path: Path): |
| 96 | skip_dirs = { |
| 97 | ".git", ".venv", "venv", "node_modules", "__pycache__", |
| 98 | "dist", "build", ".next", |
| 99 | "target", # Rust / Java (Maven/Gradle) |
| 100 | "vendor", # Go modules cache |
| 101 | ".gradle", # Gradle cache |
| 102 | } |
| 103 | for path in repo_path.rglob("*"): |
| 104 | if path.is_file() and path.suffix in LANGUAGE_MAP: |
| 105 | if not any(part in skip_dirs for part in path.parts): |
| 106 | yield path |
| @@ -107,22 +119,27 @@ | |
| 107 | |
| 108 | def _get_parser(self, language: str) -> "LanguageParser": |
| 109 | if language not in self._parsers: |
| 110 | if language == "python": |
| 111 | from navegador.ingestion.python import PythonParser |
| 112 | self._parsers[language] = PythonParser() |
| 113 | elif language in ("typescript", "javascript"): |
| 114 | from navegador.ingestion.typescript import TypeScriptParser |
| 115 | self._parsers[language] = TypeScriptParser(language) |
| 116 | elif language == "go": |
| 117 | from navegador.ingestion.go import GoParser |
| 118 | self._parsers[language] = GoParser() |
| 119 | elif language == "rust": |
| 120 | from navegador.ingestion.rust import RustParser |
| 121 | self._parsers[language] = RustParser() |
| 122 | elif language == "java": |
| 123 | from navegador.ingestion.java import JavaParser |
| 124 | self._parsers[language] = JavaParser() |
| 125 | else: |
| 126 | raise ValueError(f"Unsupported language: {language}") |
| 127 | return self._parsers[language] |
| 128 | |
| 129 |
| --- navegador/ingestion/parser.py | |
| +++ navegador/ingestion/parser.py | |
| @@ -19,17 +19,17 @@ | |
| 19 | |
| 20 | logger = logging.getLogger(__name__) |
| 21 | |
| 22 | # File extensions → language key |
| 23 | LANGUAGE_MAP: dict[str, str] = { |
| 24 | ".py": "python", |
| 25 | ".ts": "typescript", |
| 26 | ".tsx": "typescript", |
| 27 | ".js": "javascript", |
| 28 | ".jsx": "javascript", |
| 29 | ".go": "go", |
| 30 | ".rs": "rust", |
| 31 | ".java": "java", |
| 32 | } |
| 33 | |
| 34 | |
| 35 | class RepoIngester: |
| @@ -63,14 +63,17 @@ | |
| 63 | |
| 64 | if clear: |
| 65 | self.store.clear() |
| 66 | |
| 67 | # Create repository node |
| 68 | self.store.create_node( |
| 69 | NodeLabel.Repository, |
| 70 | { |
| 71 | "name": repo_path.name, |
| 72 | "path": str(repo_path), |
| 73 | }, |
| 74 | ) |
| 75 | |
| 76 | stats: dict[str, int] = {"files": 0, "functions": 0, "classes": 0, "edges": 0} |
| 77 | |
| 78 | for source_file in self._iter_source_files(repo_path): |
| 79 | language = LANGUAGE_MAP.get(source_file.suffix) |
| @@ -86,21 +89,30 @@ | |
| 89 | except Exception: |
| 90 | logger.exception("Failed to parse %s", source_file) |
| 91 | |
| 92 | logger.info( |
| 93 | "Ingested %s: %d files, %d functions, %d classes", |
| 94 | repo_path.name, |
| 95 | stats["files"], |
| 96 | stats["functions"], |
| 97 | stats["classes"], |
| 98 | ) |
| 99 | return stats |
| 100 | |
| 101 | def _iter_source_files(self, repo_path: Path): |
| 102 | skip_dirs = { |
| 103 | ".git", |
| 104 | ".venv", |
| 105 | "venv", |
| 106 | "node_modules", |
| 107 | "__pycache__", |
| 108 | "dist", |
| 109 | "build", |
| 110 | ".next", |
| 111 | "target", # Rust / Java (Maven/Gradle) |
| 112 | "vendor", # Go modules cache |
| 113 | ".gradle", # Gradle cache |
| 114 | } |
| 115 | for path in repo_path.rglob("*"): |
| 116 | if path.is_file() and path.suffix in LANGUAGE_MAP: |
| 117 | if not any(part in skip_dirs for part in path.parts): |
| 118 | yield path |
| @@ -107,22 +119,27 @@ | |
| 119 | |
| 120 | def _get_parser(self, language: str) -> "LanguageParser": |
| 121 | if language not in self._parsers: |
| 122 | if language == "python": |
| 123 | from navegador.ingestion.python import PythonParser |
| 124 | |
| 125 | self._parsers[language] = PythonParser() |
| 126 | elif language in ("typescript", "javascript"): |
| 127 | from navegador.ingestion.typescript import TypeScriptParser |
| 128 | |
| 129 | self._parsers[language] = TypeScriptParser(language) |
| 130 | elif language == "go": |
| 131 | from navegador.ingestion.go import GoParser |
| 132 | |
| 133 | self._parsers[language] = GoParser() |
| 134 | elif language == "rust": |
| 135 | from navegador.ingestion.rust import RustParser |
| 136 | |
| 137 | self._parsers[language] = RustParser() |
| 138 | elif language == "java": |
| 139 | from navegador.ingestion.java import JavaParser |
| 140 | |
| 141 | self._parsers[language] = JavaParser() |
| 142 | else: |
| 143 | raise ValueError(f"Unsupported language: {language}") |
| 144 | return self._parsers[language] |
| 145 | |
| 146 |
+186
-123
| --- navegador/ingestion/planopticon.py | ||
| +++ navegador/ingestion/planopticon.py | ||
| @@ -45,53 +45,53 @@ | ||
| 45 | 45 | logger = logging.getLogger(__name__) |
| 46 | 46 | |
| 47 | 47 | # ── Relationship type mapping ───────────────────────────────────────────────── |
| 48 | 48 | |
| 49 | 49 | EDGE_MAP: dict[str, EdgeType] = { |
| 50 | - "related_to": EdgeType.RELATED_TO, | |
| 51 | - "uses": EdgeType.DEPENDS_ON, | |
| 52 | - "depends_on": EdgeType.DEPENDS_ON, | |
| 53 | - "built_on": EdgeType.DEPENDS_ON, | |
| 54 | - "implements": EdgeType.IMPLEMENTS, | |
| 55 | - "requires": EdgeType.DEPENDS_ON, | |
| 56 | - "blocked_by": EdgeType.DEPENDS_ON, | |
| 57 | - "has_risk": EdgeType.RELATED_TO, | |
| 58 | - "addresses": EdgeType.RELATED_TO, | |
| 50 | + "related_to": EdgeType.RELATED_TO, | |
| 51 | + "uses": EdgeType.DEPENDS_ON, | |
| 52 | + "depends_on": EdgeType.DEPENDS_ON, | |
| 53 | + "built_on": EdgeType.DEPENDS_ON, | |
| 54 | + "implements": EdgeType.IMPLEMENTS, | |
| 55 | + "requires": EdgeType.DEPENDS_ON, | |
| 56 | + "blocked_by": EdgeType.DEPENDS_ON, | |
| 57 | + "has_risk": EdgeType.RELATED_TO, | |
| 58 | + "addresses": EdgeType.RELATED_TO, | |
| 59 | 59 | "has_tradeoff": EdgeType.RELATED_TO, |
| 60 | - "delivers": EdgeType.IMPLEMENTS, | |
| 61 | - "parent_of": EdgeType.CONTAINS, | |
| 62 | - "assigned_to": EdgeType.ASSIGNED_TO, | |
| 63 | - "owned_by": EdgeType.ASSIGNED_TO, | |
| 64 | - "owns": EdgeType.ASSIGNED_TO, | |
| 65 | - "employed_by": EdgeType.ASSIGNED_TO, | |
| 66 | - "works_with": EdgeType.RELATED_TO, | |
| 67 | - "governs": EdgeType.GOVERNS, | |
| 68 | - "documents": EdgeType.DOCUMENTS, | |
| 60 | + "delivers": EdgeType.IMPLEMENTS, | |
| 61 | + "parent_of": EdgeType.CONTAINS, | |
| 62 | + "assigned_to": EdgeType.ASSIGNED_TO, | |
| 63 | + "owned_by": EdgeType.ASSIGNED_TO, | |
| 64 | + "owns": EdgeType.ASSIGNED_TO, | |
| 65 | + "employed_by": EdgeType.ASSIGNED_TO, | |
| 66 | + "works_with": EdgeType.RELATED_TO, | |
| 67 | + "governs": EdgeType.GOVERNS, | |
| 68 | + "documents": EdgeType.DOCUMENTS, | |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | 71 | # planopticon node type → navegador NodeLabel |
| 72 | 72 | NODE_TYPE_MAP: dict[str, NodeLabel] = { |
| 73 | - "concept": NodeLabel.Concept, | |
| 74 | - "technology": NodeLabel.Concept, | |
| 73 | + "concept": NodeLabel.Concept, | |
| 74 | + "technology": NodeLabel.Concept, | |
| 75 | 75 | "organization": NodeLabel.Concept, |
| 76 | - "diagram": NodeLabel.WikiPage, | |
| 77 | - "time": NodeLabel.Concept, | |
| 78 | - "person": NodeLabel.Person, | |
| 76 | + "diagram": NodeLabel.WikiPage, | |
| 77 | + "time": NodeLabel.Concept, | |
| 78 | + "person": NodeLabel.Person, | |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | 81 | # planning_type → navegador NodeLabel |
| 82 | 82 | PLANNING_TYPE_MAP: dict[str, NodeLabel] = { |
| 83 | - "decision": NodeLabel.Decision, | |
| 83 | + "decision": NodeLabel.Decision, | |
| 84 | 84 | "requirement": NodeLabel.Rule, |
| 85 | - "constraint": NodeLabel.Rule, | |
| 86 | - "risk": NodeLabel.Rule, | |
| 87 | - "goal": NodeLabel.Concept, | |
| 88 | - "assumption": NodeLabel.Concept, | |
| 89 | - "feature": NodeLabel.Concept, | |
| 90 | - "milestone": NodeLabel.Concept, | |
| 91 | - "task": NodeLabel.Concept, | |
| 92 | - "dependency": NodeLabel.Concept, | |
| 85 | + "constraint": NodeLabel.Rule, | |
| 86 | + "risk": NodeLabel.Rule, | |
| 87 | + "goal": NodeLabel.Concept, | |
| 88 | + "assumption": NodeLabel.Concept, | |
| 89 | + "feature": NodeLabel.Concept, | |
| 90 | + "milestone": NodeLabel.Concept, | |
| 91 | + "task": NodeLabel.Concept, | |
| 92 | + "dependency": NodeLabel.Concept, | |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | 95 | |
| 96 | 96 | class PlanopticonIngester: |
| 97 | 97 | """ |
| @@ -144,11 +144,13 @@ | ||
| 144 | 144 | for diagram in manifest.get("diagrams", []): |
| 145 | 145 | self._ingest_diagram(diagram, base_dir, title) |
| 146 | 146 | |
| 147 | 147 | logger.info( |
| 148 | 148 | "PlanopticonIngester (%s): nodes=%d edges=%d", |
| 149 | - title, stats.get("nodes", 0), stats.get("edges", 0), | |
| 149 | + title, | |
| 150 | + stats.get("nodes", 0), | |
| 151 | + stats.get("edges", 0), | |
| 150 | 152 | ) |
| 151 | 153 | return stats |
| 152 | 154 | |
| 153 | 155 | def ingest_kg(self, kg_path: str | Path) -> dict[str, int]: |
| 154 | 156 | """ |
| @@ -247,37 +249,48 @@ | ||
| 247 | 249 | |
| 248 | 250 | descriptions = node.get("descriptions", []) |
| 249 | 251 | description = descriptions[0] if descriptions else node.get("description", "") |
| 250 | 252 | |
| 251 | 253 | if label == NodeLabel.Person: |
| 252 | - self.store.create_node(NodeLabel.Person, { | |
| 253 | - "name": name, | |
| 254 | - "email": "", | |
| 255 | - "role": node.get("role", ""), | |
| 256 | - "team": node.get("organization", ""), | |
| 257 | - }) | |
| 254 | + self.store.create_node( | |
| 255 | + NodeLabel.Person, | |
| 256 | + { | |
| 257 | + "name": name, | |
| 258 | + "email": "", | |
| 259 | + "role": node.get("role", ""), | |
| 260 | + "team": node.get("organization", ""), | |
| 261 | + }, | |
| 262 | + ) | |
| 258 | 263 | elif label == NodeLabel.WikiPage: |
| 259 | - self.store.create_node(NodeLabel.WikiPage, { | |
| 260 | - "name": name, | |
| 261 | - "url": node.get("source", ""), | |
| 262 | - "source": self.source_tag, | |
| 263 | - "content": description[:4000], | |
| 264 | - }) | |
| 264 | + self.store.create_node( | |
| 265 | + NodeLabel.WikiPage, | |
| 266 | + { | |
| 267 | + "name": name, | |
| 268 | + "url": node.get("source", ""), | |
| 269 | + "source": self.source_tag, | |
| 270 | + "content": description[:4000], | |
| 271 | + }, | |
| 272 | + ) | |
| 265 | 273 | else: |
| 266 | 274 | domain = "organization" if raw_type == "organization" else node.get("domain", "") |
| 267 | - self.store.create_node(NodeLabel.Concept, { | |
| 268 | - "name": name, | |
| 269 | - "description": description, | |
| 270 | - "domain": domain, | |
| 271 | - "status": node.get("status", ""), | |
| 272 | - }) | |
| 275 | + self.store.create_node( | |
| 276 | + NodeLabel.Concept, | |
| 277 | + { | |
| 278 | + "name": name, | |
| 279 | + "description": description, | |
| 280 | + "domain": domain, | |
| 281 | + "status": node.get("status", ""), | |
| 282 | + }, | |
| 283 | + ) | |
| 273 | 284 | if domain: |
| 274 | 285 | self._ensure_domain(domain) |
| 275 | 286 | self.store.create_edge( |
| 276 | - NodeLabel.Concept, {"name": name}, | |
| 287 | + NodeLabel.Concept, | |
| 288 | + {"name": name}, | |
| 277 | 289 | EdgeType.BELONGS_TO, |
| 278 | - NodeLabel.Domain, {"name": domain}, | |
| 290 | + NodeLabel.Domain, | |
| 291 | + {"name": domain}, | |
| 279 | 292 | ) |
| 280 | 293 | |
| 281 | 294 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 282 | 295 | |
| 283 | 296 | # Provenance: link to source WikiPage if present |
| @@ -296,39 +309,50 @@ | ||
| 296 | 309 | domain = entity.get("domain", "") |
| 297 | 310 | status = entity.get("status", "") |
| 298 | 311 | priority = entity.get("priority", "") |
| 299 | 312 | |
| 300 | 313 | if label == NodeLabel.Decision: |
| 301 | - self.store.create_node(NodeLabel.Decision, { | |
| 302 | - "name": name, | |
| 303 | - "description": description, | |
| 304 | - "domain": domain, | |
| 305 | - "status": status or "accepted", | |
| 306 | - "rationale": entity.get("rationale", ""), | |
| 307 | - }) | |
| 314 | + self.store.create_node( | |
| 315 | + NodeLabel.Decision, | |
| 316 | + { | |
| 317 | + "name": name, | |
| 318 | + "description": description, | |
| 319 | + "domain": domain, | |
| 320 | + "status": status or "accepted", | |
| 321 | + "rationale": entity.get("rationale", ""), | |
| 322 | + }, | |
| 323 | + ) | |
| 308 | 324 | elif label == NodeLabel.Rule: |
| 309 | - self.store.create_node(NodeLabel.Rule, { | |
| 310 | - "name": name, | |
| 311 | - "description": description, | |
| 312 | - "domain": domain, | |
| 313 | - "severity": "critical" if priority == "high" else "info", | |
| 314 | - "rationale": entity.get("rationale", ""), | |
| 315 | - }) | |
| 325 | + self.store.create_node( | |
| 326 | + NodeLabel.Rule, | |
| 327 | + { | |
| 328 | + "name": name, | |
| 329 | + "description": description, | |
| 330 | + "domain": domain, | |
| 331 | + "severity": "critical" if priority == "high" else "info", | |
| 332 | + "rationale": entity.get("rationale", ""), | |
| 333 | + }, | |
| 334 | + ) | |
| 316 | 335 | else: |
| 317 | - self.store.create_node(NodeLabel.Concept, { | |
| 318 | - "name": name, | |
| 319 | - "description": description, | |
| 320 | - "domain": domain, | |
| 321 | - "status": status, | |
| 322 | - }) | |
| 336 | + self.store.create_node( | |
| 337 | + NodeLabel.Concept, | |
| 338 | + { | |
| 339 | + "name": name, | |
| 340 | + "description": description, | |
| 341 | + "domain": domain, | |
| 342 | + "status": status, | |
| 343 | + }, | |
| 344 | + ) | |
| 323 | 345 | |
| 324 | 346 | if domain: |
| 325 | 347 | self._ensure_domain(domain) |
| 326 | 348 | self.store.create_edge( |
| 327 | - label, {"name": name}, | |
| 349 | + label, | |
| 350 | + {"name": name}, | |
| 328 | 351 | EdgeType.BELONGS_TO, |
| 329 | - NodeLabel.Domain, {"name": domain}, | |
| 352 | + NodeLabel.Domain, | |
| 353 | + {"name": domain}, | |
| 330 | 354 | ) |
| 331 | 355 | |
| 332 | 356 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 333 | 357 | |
| 334 | 358 | def _ingest_kg_relationship(self, rel: dict[str, Any]) -> None: |
| @@ -340,15 +364,19 @@ | ||
| 340 | 364 | return |
| 341 | 365 | |
| 342 | 366 | edge_type = EDGE_MAP.get(rel_type, EdgeType.RELATED_TO) |
| 343 | 367 | |
| 344 | 368 | # We don't know the exact label of each node — use a label-agnostic match |
| 345 | - cypher = """ | |
| 369 | + cypher = ( | |
| 370 | + """ | |
| 346 | 371 | MATCH (a), (b) |
| 347 | 372 | WHERE a.name = $src AND b.name = $tgt |
| 348 | - MERGE (a)-[r:""" + edge_type + """]->(b) | |
| 373 | + MERGE (a)-[r:""" | |
| 374 | + + edge_type | |
| 375 | + + """]->(b) | |
| 349 | 376 | """ |
| 377 | + ) | |
| 350 | 378 | try: |
| 351 | 379 | self.store.query(cypher, {"src": src, "tgt": tgt}) |
| 352 | 380 | self._stats["edges"] = self._stats.get("edges", 0) + 1 |
| 353 | 381 | except Exception: |
| 354 | 382 | logger.warning("Could not create edge %s -[%s]-> %s", src, edge_type, tgt) |
| @@ -358,22 +386,27 @@ | ||
| 358 | 386 | point = (kp.get("point") or "").strip() |
| 359 | 387 | if not point: |
| 360 | 388 | continue |
| 361 | 389 | topic = kp.get("topic") or "" |
| 362 | 390 | name = point[:120] # use the point text as the concept name |
| 363 | - self.store.create_node(NodeLabel.Concept, { | |
| 364 | - "name": name, | |
| 365 | - "description": kp.get("details", ""), | |
| 366 | - "domain": topic, | |
| 367 | - "status": "key_point", | |
| 368 | - }) | |
| 391 | + self.store.create_node( | |
| 392 | + NodeLabel.Concept, | |
| 393 | + { | |
| 394 | + "name": name, | |
| 395 | + "description": kp.get("details", ""), | |
| 396 | + "domain": topic, | |
| 397 | + "status": "key_point", | |
| 398 | + }, | |
| 399 | + ) | |
| 369 | 400 | if topic: |
| 370 | 401 | self._ensure_domain(topic) |
| 371 | 402 | self.store.create_edge( |
| 372 | - NodeLabel.Concept, {"name": name}, | |
| 403 | + NodeLabel.Concept, | |
| 404 | + {"name": name}, | |
| 373 | 405 | EdgeType.BELONGS_TO, |
| 374 | - NodeLabel.Domain, {"name": topic}, | |
| 406 | + NodeLabel.Domain, | |
| 407 | + {"name": topic}, | |
| 375 | 408 | ) |
| 376 | 409 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 377 | 410 | |
| 378 | 411 | def _ingest_action_items(self, action_items: list[dict], source: str) -> None: |
| 379 | 412 | for item in action_items: |
| @@ -381,27 +414,38 @@ | ||
| 381 | 414 | assignee = (item.get("assignee") or "").strip() |
| 382 | 415 | if not action: |
| 383 | 416 | continue |
| 384 | 417 | |
| 385 | 418 | # Action → Rule (it's a commitment / obligation) |
| 386 | - self.store.create_node(NodeLabel.Rule, { | |
| 387 | - "name": action[:120], | |
| 388 | - "description": item.get("context", ""), | |
| 389 | - "domain": source, | |
| 390 | - "severity": item.get("priority", "info"), | |
| 391 | - "rationale": f"Action item from {source}", | |
| 392 | - }) | |
| 419 | + self.store.create_node( | |
| 420 | + NodeLabel.Rule, | |
| 421 | + { | |
| 422 | + "name": action[:120], | |
| 423 | + "description": item.get("context", ""), | |
| 424 | + "domain": source, | |
| 425 | + "severity": item.get("priority", "info"), | |
| 426 | + "rationale": f"Action item from {source}", | |
| 427 | + }, | |
| 428 | + ) | |
| 393 | 429 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 394 | 430 | |
| 395 | 431 | if assignee: |
| 396 | - self.store.create_node(NodeLabel.Person, { | |
| 397 | - "name": assignee, "email": "", "role": "", "team": "", | |
| 398 | - }) | |
| 432 | + self.store.create_node( | |
| 433 | + NodeLabel.Person, | |
| 434 | + { | |
| 435 | + "name": assignee, | |
| 436 | + "email": "", | |
| 437 | + "role": "", | |
| 438 | + "team": "", | |
| 439 | + }, | |
| 440 | + ) | |
| 399 | 441 | self.store.create_edge( |
| 400 | - NodeLabel.Rule, {"name": action[:120]}, | |
| 442 | + NodeLabel.Rule, | |
| 443 | + {"name": action[:120]}, | |
| 401 | 444 | EdgeType.ASSIGNED_TO, |
| 402 | - NodeLabel.Person, {"name": assignee}, | |
| 445 | + NodeLabel.Person, | |
| 446 | + {"name": assignee}, | |
| 403 | 447 | ) |
| 404 | 448 | self._stats["edges"] = self._stats.get("edges", 0) + 1 |
| 405 | 449 | |
| 406 | 450 | def _ingest_diagram(self, diagram: dict[str, Any], base_dir: Path, source: str) -> None: |
| 407 | 451 | dtype = diagram.get("diagram_type", "diagram") |
| @@ -409,57 +453,74 @@ | ||
| 409 | 453 | mermaid = diagram.get("mermaid", "") |
| 410 | 454 | ts = diagram.get("timestamp") |
| 411 | 455 | name = f"{dtype.capitalize()} @ {ts:.0f}s" if ts is not None else f"{dtype.capitalize()}" |
| 412 | 456 | |
| 413 | 457 | content = mermaid or desc |
| 414 | - self.store.create_node(NodeLabel.WikiPage, { | |
| 415 | - "name": name, | |
| 416 | - "url": diagram.get("image_path", ""), | |
| 417 | - "source": source, | |
| 418 | - "content": content[:4000], | |
| 419 | - }) | |
| 458 | + self.store.create_node( | |
| 459 | + NodeLabel.WikiPage, | |
| 460 | + { | |
| 461 | + "name": name, | |
| 462 | + "url": diagram.get("image_path", ""), | |
| 463 | + "source": source, | |
| 464 | + "content": content[:4000], | |
| 465 | + }, | |
| 466 | + ) | |
| 420 | 467 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 421 | 468 | |
| 422 | 469 | # Link diagram elements as concepts |
| 423 | 470 | for element in diagram.get("elements", []): |
| 424 | 471 | element = element.strip() |
| 425 | 472 | if not element: |
| 426 | 473 | continue |
| 427 | - self.store.create_node(NodeLabel.Concept, { | |
| 428 | - "name": element, "description": "", "domain": source, "status": "", | |
| 429 | - }) | |
| 474 | + self.store.create_node( | |
| 475 | + NodeLabel.Concept, | |
| 476 | + { | |
| 477 | + "name": element, | |
| 478 | + "description": "", | |
| 479 | + "domain": source, | |
| 480 | + "status": "", | |
| 481 | + }, | |
| 482 | + ) | |
| 430 | 483 | self.store.create_edge( |
| 431 | - NodeLabel.WikiPage, {"name": name}, | |
| 484 | + NodeLabel.WikiPage, | |
| 485 | + {"name": name}, | |
| 432 | 486 | EdgeType.DOCUMENTS, |
| 433 | - NodeLabel.Concept, {"name": element}, | |
| 487 | + NodeLabel.Concept, | |
| 488 | + {"name": element}, | |
| 434 | 489 | ) |
| 435 | 490 | self._stats["edges"] = self._stats.get("edges", 0) + 1 |
| 436 | 491 | |
| 437 | 492 | def _ingest_source(self, source: dict[str, Any]) -> None: |
| 438 | 493 | name = (source.get("title") or source.get("source_id") or "").strip() |
| 439 | 494 | if not name: |
| 440 | 495 | return |
| 441 | - self.store.create_node(NodeLabel.WikiPage, { | |
| 442 | - "name": name, | |
| 443 | - "url": source.get("url") or source.get("path") or "", | |
| 444 | - "source": source.get("source_type", ""), | |
| 445 | - "content": "", | |
| 446 | - "updated_at": source.get("ingested_at", ""), | |
| 447 | - }) | |
| 496 | + self.store.create_node( | |
| 497 | + NodeLabel.WikiPage, | |
| 498 | + { | |
| 499 | + "name": name, | |
| 500 | + "url": source.get("url") or source.get("path") or "", | |
| 501 | + "source": source.get("source_type", ""), | |
| 502 | + "content": "", | |
| 503 | + "updated_at": source.get("ingested_at", ""), | |
| 504 | + }, | |
| 505 | + ) | |
| 448 | 506 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 449 | 507 | |
| 450 | 508 | def _ingest_artifact(self, artifact: dict[str, Any], project_name: str) -> None: |
| 451 | 509 | name = (artifact.get("name") or "").strip() |
| 452 | 510 | if not name: |
| 453 | 511 | return |
| 454 | 512 | content = artifact.get("content", "") |
| 455 | - self.store.create_node(NodeLabel.WikiPage, { | |
| 456 | - "name": name, | |
| 457 | - "url": "", | |
| 458 | - "source": project_name, | |
| 459 | - "content": content[:4000], | |
| 460 | - }) | |
| 513 | + self.store.create_node( | |
| 514 | + NodeLabel.WikiPage, | |
| 515 | + { | |
| 516 | + "name": name, | |
| 517 | + "url": "", | |
| 518 | + "source": project_name, | |
| 519 | + "content": content[:4000], | |
| 520 | + }, | |
| 521 | + ) | |
| 461 | 522 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 462 | 523 | |
| 463 | 524 | # ── Helpers ─────────────────────────────────────────────────────────────── |
| 464 | 525 | |
| 465 | 526 | def _ensure_domain(self, name: str) -> None: |
| @@ -467,13 +528,15 @@ | ||
| 467 | 528 | |
| 468 | 529 | def _lazy_wiki_link(self, name: str, label: NodeLabel, source_id: str) -> None: |
| 469 | 530 | """Create a DOCUMENTS edge from a WikiPage to this node if the page exists.""" |
| 470 | 531 | try: |
| 471 | 532 | self.store.create_edge( |
| 472 | - NodeLabel.WikiPage, {"name": source_id}, | |
| 533 | + NodeLabel.WikiPage, | |
| 534 | + {"name": source_id}, | |
| 473 | 535 | EdgeType.DOCUMENTS, |
| 474 | - label, {"name": name}, | |
| 536 | + label, | |
| 537 | + {"name": name}, | |
| 475 | 538 | ) |
| 476 | 539 | except Exception: |
| 477 | 540 | logger.debug("Could not link %s to wiki page %s", name, source_id) |
| 478 | 541 | |
| 479 | 542 | def _load_json(self, path: Path) -> Any: |
| 480 | 543 |
| --- navegador/ingestion/planopticon.py | |
| +++ navegador/ingestion/planopticon.py | |
| @@ -45,53 +45,53 @@ | |
| 45 | logger = logging.getLogger(__name__) |
| 46 | |
| 47 | # ── Relationship type mapping ───────────────────────────────────────────────── |
| 48 | |
| 49 | EDGE_MAP: dict[str, EdgeType] = { |
| 50 | "related_to": EdgeType.RELATED_TO, |
| 51 | "uses": EdgeType.DEPENDS_ON, |
| 52 | "depends_on": EdgeType.DEPENDS_ON, |
| 53 | "built_on": EdgeType.DEPENDS_ON, |
| 54 | "implements": EdgeType.IMPLEMENTS, |
| 55 | "requires": EdgeType.DEPENDS_ON, |
| 56 | "blocked_by": EdgeType.DEPENDS_ON, |
| 57 | "has_risk": EdgeType.RELATED_TO, |
| 58 | "addresses": EdgeType.RELATED_TO, |
| 59 | "has_tradeoff": EdgeType.RELATED_TO, |
| 60 | "delivers": EdgeType.IMPLEMENTS, |
| 61 | "parent_of": EdgeType.CONTAINS, |
| 62 | "assigned_to": EdgeType.ASSIGNED_TO, |
| 63 | "owned_by": EdgeType.ASSIGNED_TO, |
| 64 | "owns": EdgeType.ASSIGNED_TO, |
| 65 | "employed_by": EdgeType.ASSIGNED_TO, |
| 66 | "works_with": EdgeType.RELATED_TO, |
| 67 | "governs": EdgeType.GOVERNS, |
| 68 | "documents": EdgeType.DOCUMENTS, |
| 69 | } |
| 70 | |
| 71 | # planopticon node type → navegador NodeLabel |
| 72 | NODE_TYPE_MAP: dict[str, NodeLabel] = { |
| 73 | "concept": NodeLabel.Concept, |
| 74 | "technology": NodeLabel.Concept, |
| 75 | "organization": NodeLabel.Concept, |
| 76 | "diagram": NodeLabel.WikiPage, |
| 77 | "time": NodeLabel.Concept, |
| 78 | "person": NodeLabel.Person, |
| 79 | } |
| 80 | |
| 81 | # planning_type → navegador NodeLabel |
| 82 | PLANNING_TYPE_MAP: dict[str, NodeLabel] = { |
| 83 | "decision": NodeLabel.Decision, |
| 84 | "requirement": NodeLabel.Rule, |
| 85 | "constraint": NodeLabel.Rule, |
| 86 | "risk": NodeLabel.Rule, |
| 87 | "goal": NodeLabel.Concept, |
| 88 | "assumption": NodeLabel.Concept, |
| 89 | "feature": NodeLabel.Concept, |
| 90 | "milestone": NodeLabel.Concept, |
| 91 | "task": NodeLabel.Concept, |
| 92 | "dependency": NodeLabel.Concept, |
| 93 | } |
| 94 | |
| 95 | |
| 96 | class PlanopticonIngester: |
| 97 | """ |
| @@ -144,11 +144,13 @@ | |
| 144 | for diagram in manifest.get("diagrams", []): |
| 145 | self._ingest_diagram(diagram, base_dir, title) |
| 146 | |
| 147 | logger.info( |
| 148 | "PlanopticonIngester (%s): nodes=%d edges=%d", |
| 149 | title, stats.get("nodes", 0), stats.get("edges", 0), |
| 150 | ) |
| 151 | return stats |
| 152 | |
| 153 | def ingest_kg(self, kg_path: str | Path) -> dict[str, int]: |
| 154 | """ |
| @@ -247,37 +249,48 @@ | |
| 247 | |
| 248 | descriptions = node.get("descriptions", []) |
| 249 | description = descriptions[0] if descriptions else node.get("description", "") |
| 250 | |
| 251 | if label == NodeLabel.Person: |
| 252 | self.store.create_node(NodeLabel.Person, { |
| 253 | "name": name, |
| 254 | "email": "", |
| 255 | "role": node.get("role", ""), |
| 256 | "team": node.get("organization", ""), |
| 257 | }) |
| 258 | elif label == NodeLabel.WikiPage: |
| 259 | self.store.create_node(NodeLabel.WikiPage, { |
| 260 | "name": name, |
| 261 | "url": node.get("source", ""), |
| 262 | "source": self.source_tag, |
| 263 | "content": description[:4000], |
| 264 | }) |
| 265 | else: |
| 266 | domain = "organization" if raw_type == "organization" else node.get("domain", "") |
| 267 | self.store.create_node(NodeLabel.Concept, { |
| 268 | "name": name, |
| 269 | "description": description, |
| 270 | "domain": domain, |
| 271 | "status": node.get("status", ""), |
| 272 | }) |
| 273 | if domain: |
| 274 | self._ensure_domain(domain) |
| 275 | self.store.create_edge( |
| 276 | NodeLabel.Concept, {"name": name}, |
| 277 | EdgeType.BELONGS_TO, |
| 278 | NodeLabel.Domain, {"name": domain}, |
| 279 | ) |
| 280 | |
| 281 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 282 | |
| 283 | # Provenance: link to source WikiPage if present |
| @@ -296,39 +309,50 @@ | |
| 296 | domain = entity.get("domain", "") |
| 297 | status = entity.get("status", "") |
| 298 | priority = entity.get("priority", "") |
| 299 | |
| 300 | if label == NodeLabel.Decision: |
| 301 | self.store.create_node(NodeLabel.Decision, { |
| 302 | "name": name, |
| 303 | "description": description, |
| 304 | "domain": domain, |
| 305 | "status": status or "accepted", |
| 306 | "rationale": entity.get("rationale", ""), |
| 307 | }) |
| 308 | elif label == NodeLabel.Rule: |
| 309 | self.store.create_node(NodeLabel.Rule, { |
| 310 | "name": name, |
| 311 | "description": description, |
| 312 | "domain": domain, |
| 313 | "severity": "critical" if priority == "high" else "info", |
| 314 | "rationale": entity.get("rationale", ""), |
| 315 | }) |
| 316 | else: |
| 317 | self.store.create_node(NodeLabel.Concept, { |
| 318 | "name": name, |
| 319 | "description": description, |
| 320 | "domain": domain, |
| 321 | "status": status, |
| 322 | }) |
| 323 | |
| 324 | if domain: |
| 325 | self._ensure_domain(domain) |
| 326 | self.store.create_edge( |
| 327 | label, {"name": name}, |
| 328 | EdgeType.BELONGS_TO, |
| 329 | NodeLabel.Domain, {"name": domain}, |
| 330 | ) |
| 331 | |
| 332 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 333 | |
| 334 | def _ingest_kg_relationship(self, rel: dict[str, Any]) -> None: |
| @@ -340,15 +364,19 @@ | |
| 340 | return |
| 341 | |
| 342 | edge_type = EDGE_MAP.get(rel_type, EdgeType.RELATED_TO) |
| 343 | |
| 344 | # We don't know the exact label of each node — use a label-agnostic match |
| 345 | cypher = """ |
| 346 | MATCH (a), (b) |
| 347 | WHERE a.name = $src AND b.name = $tgt |
| 348 | MERGE (a)-[r:""" + edge_type + """]->(b) |
| 349 | """ |
| 350 | try: |
| 351 | self.store.query(cypher, {"src": src, "tgt": tgt}) |
| 352 | self._stats["edges"] = self._stats.get("edges", 0) + 1 |
| 353 | except Exception: |
| 354 | logger.warning("Could not create edge %s -[%s]-> %s", src, edge_type, tgt) |
| @@ -358,22 +386,27 @@ | |
| 358 | point = (kp.get("point") or "").strip() |
| 359 | if not point: |
| 360 | continue |
| 361 | topic = kp.get("topic") or "" |
| 362 | name = point[:120] # use the point text as the concept name |
| 363 | self.store.create_node(NodeLabel.Concept, { |
| 364 | "name": name, |
| 365 | "description": kp.get("details", ""), |
| 366 | "domain": topic, |
| 367 | "status": "key_point", |
| 368 | }) |
| 369 | if topic: |
| 370 | self._ensure_domain(topic) |
| 371 | self.store.create_edge( |
| 372 | NodeLabel.Concept, {"name": name}, |
| 373 | EdgeType.BELONGS_TO, |
| 374 | NodeLabel.Domain, {"name": topic}, |
| 375 | ) |
| 376 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 377 | |
| 378 | def _ingest_action_items(self, action_items: list[dict], source: str) -> None: |
| 379 | for item in action_items: |
| @@ -381,27 +414,38 @@ | |
| 381 | assignee = (item.get("assignee") or "").strip() |
| 382 | if not action: |
| 383 | continue |
| 384 | |
| 385 | # Action → Rule (it's a commitment / obligation) |
| 386 | self.store.create_node(NodeLabel.Rule, { |
| 387 | "name": action[:120], |
| 388 | "description": item.get("context", ""), |
| 389 | "domain": source, |
| 390 | "severity": item.get("priority", "info"), |
| 391 | "rationale": f"Action item from {source}", |
| 392 | }) |
| 393 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 394 | |
| 395 | if assignee: |
| 396 | self.store.create_node(NodeLabel.Person, { |
| 397 | "name": assignee, "email": "", "role": "", "team": "", |
| 398 | }) |
| 399 | self.store.create_edge( |
| 400 | NodeLabel.Rule, {"name": action[:120]}, |
| 401 | EdgeType.ASSIGNED_TO, |
| 402 | NodeLabel.Person, {"name": assignee}, |
| 403 | ) |
| 404 | self._stats["edges"] = self._stats.get("edges", 0) + 1 |
| 405 | |
| 406 | def _ingest_diagram(self, diagram: dict[str, Any], base_dir: Path, source: str) -> None: |
| 407 | dtype = diagram.get("diagram_type", "diagram") |
| @@ -409,57 +453,74 @@ | |
| 409 | mermaid = diagram.get("mermaid", "") |
| 410 | ts = diagram.get("timestamp") |
| 411 | name = f"{dtype.capitalize()} @ {ts:.0f}s" if ts is not None else f"{dtype.capitalize()}" |
| 412 | |
| 413 | content = mermaid or desc |
| 414 | self.store.create_node(NodeLabel.WikiPage, { |
| 415 | "name": name, |
| 416 | "url": diagram.get("image_path", ""), |
| 417 | "source": source, |
| 418 | "content": content[:4000], |
| 419 | }) |
| 420 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 421 | |
| 422 | # Link diagram elements as concepts |
| 423 | for element in diagram.get("elements", []): |
| 424 | element = element.strip() |
| 425 | if not element: |
| 426 | continue |
| 427 | self.store.create_node(NodeLabel.Concept, { |
| 428 | "name": element, "description": "", "domain": source, "status": "", |
| 429 | }) |
| 430 | self.store.create_edge( |
| 431 | NodeLabel.WikiPage, {"name": name}, |
| 432 | EdgeType.DOCUMENTS, |
| 433 | NodeLabel.Concept, {"name": element}, |
| 434 | ) |
| 435 | self._stats["edges"] = self._stats.get("edges", 0) + 1 |
| 436 | |
| 437 | def _ingest_source(self, source: dict[str, Any]) -> None: |
| 438 | name = (source.get("title") or source.get("source_id") or "").strip() |
| 439 | if not name: |
| 440 | return |
| 441 | self.store.create_node(NodeLabel.WikiPage, { |
| 442 | "name": name, |
| 443 | "url": source.get("url") or source.get("path") or "", |
| 444 | "source": source.get("source_type", ""), |
| 445 | "content": "", |
| 446 | "updated_at": source.get("ingested_at", ""), |
| 447 | }) |
| 448 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 449 | |
| 450 | def _ingest_artifact(self, artifact: dict[str, Any], project_name: str) -> None: |
| 451 | name = (artifact.get("name") or "").strip() |
| 452 | if not name: |
| 453 | return |
| 454 | content = artifact.get("content", "") |
| 455 | self.store.create_node(NodeLabel.WikiPage, { |
| 456 | "name": name, |
| 457 | "url": "", |
| 458 | "source": project_name, |
| 459 | "content": content[:4000], |
| 460 | }) |
| 461 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 462 | |
| 463 | # ── Helpers ─────────────────────────────────────────────────────────────── |
| 464 | |
| 465 | def _ensure_domain(self, name: str) -> None: |
| @@ -467,13 +528,15 @@ | |
| 467 | |
| 468 | def _lazy_wiki_link(self, name: str, label: NodeLabel, source_id: str) -> None: |
| 469 | """Create a DOCUMENTS edge from a WikiPage to this node if the page exists.""" |
| 470 | try: |
| 471 | self.store.create_edge( |
| 472 | NodeLabel.WikiPage, {"name": source_id}, |
| 473 | EdgeType.DOCUMENTS, |
| 474 | label, {"name": name}, |
| 475 | ) |
| 476 | except Exception: |
| 477 | logger.debug("Could not link %s to wiki page %s", name, source_id) |
| 478 | |
| 479 | def _load_json(self, path: Path) -> Any: |
| 480 |
| --- navegador/ingestion/planopticon.py | |
| +++ navegador/ingestion/planopticon.py | |
| @@ -45,53 +45,53 @@ | |
| 45 | logger = logging.getLogger(__name__) |
| 46 | |
| 47 | # ── Relationship type mapping ───────────────────────────────────────────────── |
| 48 | |
| 49 | EDGE_MAP: dict[str, EdgeType] = { |
| 50 | "related_to": EdgeType.RELATED_TO, |
| 51 | "uses": EdgeType.DEPENDS_ON, |
| 52 | "depends_on": EdgeType.DEPENDS_ON, |
| 53 | "built_on": EdgeType.DEPENDS_ON, |
| 54 | "implements": EdgeType.IMPLEMENTS, |
| 55 | "requires": EdgeType.DEPENDS_ON, |
| 56 | "blocked_by": EdgeType.DEPENDS_ON, |
| 57 | "has_risk": EdgeType.RELATED_TO, |
| 58 | "addresses": EdgeType.RELATED_TO, |
| 59 | "has_tradeoff": EdgeType.RELATED_TO, |
| 60 | "delivers": EdgeType.IMPLEMENTS, |
| 61 | "parent_of": EdgeType.CONTAINS, |
| 62 | "assigned_to": EdgeType.ASSIGNED_TO, |
| 63 | "owned_by": EdgeType.ASSIGNED_TO, |
| 64 | "owns": EdgeType.ASSIGNED_TO, |
| 65 | "employed_by": EdgeType.ASSIGNED_TO, |
| 66 | "works_with": EdgeType.RELATED_TO, |
| 67 | "governs": EdgeType.GOVERNS, |
| 68 | "documents": EdgeType.DOCUMENTS, |
| 69 | } |
| 70 | |
| 71 | # planopticon node type → navegador NodeLabel |
| 72 | NODE_TYPE_MAP: dict[str, NodeLabel] = { |
| 73 | "concept": NodeLabel.Concept, |
| 74 | "technology": NodeLabel.Concept, |
| 75 | "organization": NodeLabel.Concept, |
| 76 | "diagram": NodeLabel.WikiPage, |
| 77 | "time": NodeLabel.Concept, |
| 78 | "person": NodeLabel.Person, |
| 79 | } |
| 80 | |
| 81 | # planning_type → navegador NodeLabel |
| 82 | PLANNING_TYPE_MAP: dict[str, NodeLabel] = { |
| 83 | "decision": NodeLabel.Decision, |
| 84 | "requirement": NodeLabel.Rule, |
| 85 | "constraint": NodeLabel.Rule, |
| 86 | "risk": NodeLabel.Rule, |
| 87 | "goal": NodeLabel.Concept, |
| 88 | "assumption": NodeLabel.Concept, |
| 89 | "feature": NodeLabel.Concept, |
| 90 | "milestone": NodeLabel.Concept, |
| 91 | "task": NodeLabel.Concept, |
| 92 | "dependency": NodeLabel.Concept, |
| 93 | } |
| 94 | |
| 95 | |
| 96 | class PlanopticonIngester: |
| 97 | """ |
| @@ -144,11 +144,13 @@ | |
| 144 | for diagram in manifest.get("diagrams", []): |
| 145 | self._ingest_diagram(diagram, base_dir, title) |
| 146 | |
| 147 | logger.info( |
| 148 | "PlanopticonIngester (%s): nodes=%d edges=%d", |
| 149 | title, |
| 150 | stats.get("nodes", 0), |
| 151 | stats.get("edges", 0), |
| 152 | ) |
| 153 | return stats |
| 154 | |
| 155 | def ingest_kg(self, kg_path: str | Path) -> dict[str, int]: |
| 156 | """ |
| @@ -247,37 +249,48 @@ | |
| 249 | |
| 250 | descriptions = node.get("descriptions", []) |
| 251 | description = descriptions[0] if descriptions else node.get("description", "") |
| 252 | |
| 253 | if label == NodeLabel.Person: |
| 254 | self.store.create_node( |
| 255 | NodeLabel.Person, |
| 256 | { |
| 257 | "name": name, |
| 258 | "email": "", |
| 259 | "role": node.get("role", ""), |
| 260 | "team": node.get("organization", ""), |
| 261 | }, |
| 262 | ) |
| 263 | elif label == NodeLabel.WikiPage: |
| 264 | self.store.create_node( |
| 265 | NodeLabel.WikiPage, |
| 266 | { |
| 267 | "name": name, |
| 268 | "url": node.get("source", ""), |
| 269 | "source": self.source_tag, |
| 270 | "content": description[:4000], |
| 271 | }, |
| 272 | ) |
| 273 | else: |
| 274 | domain = "organization" if raw_type == "organization" else node.get("domain", "") |
| 275 | self.store.create_node( |
| 276 | NodeLabel.Concept, |
| 277 | { |
| 278 | "name": name, |
| 279 | "description": description, |
| 280 | "domain": domain, |
| 281 | "status": node.get("status", ""), |
| 282 | }, |
| 283 | ) |
| 284 | if domain: |
| 285 | self._ensure_domain(domain) |
| 286 | self.store.create_edge( |
| 287 | NodeLabel.Concept, |
| 288 | {"name": name}, |
| 289 | EdgeType.BELONGS_TO, |
| 290 | NodeLabel.Domain, |
| 291 | {"name": domain}, |
| 292 | ) |
| 293 | |
| 294 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 295 | |
| 296 | # Provenance: link to source WikiPage if present |
| @@ -296,39 +309,50 @@ | |
| 309 | domain = entity.get("domain", "") |
| 310 | status = entity.get("status", "") |
| 311 | priority = entity.get("priority", "") |
| 312 | |
| 313 | if label == NodeLabel.Decision: |
| 314 | self.store.create_node( |
| 315 | NodeLabel.Decision, |
| 316 | { |
| 317 | "name": name, |
| 318 | "description": description, |
| 319 | "domain": domain, |
| 320 | "status": status or "accepted", |
| 321 | "rationale": entity.get("rationale", ""), |
| 322 | }, |
| 323 | ) |
| 324 | elif label == NodeLabel.Rule: |
| 325 | self.store.create_node( |
| 326 | NodeLabel.Rule, |
| 327 | { |
| 328 | "name": name, |
| 329 | "description": description, |
| 330 | "domain": domain, |
| 331 | "severity": "critical" if priority == "high" else "info", |
| 332 | "rationale": entity.get("rationale", ""), |
| 333 | }, |
| 334 | ) |
| 335 | else: |
| 336 | self.store.create_node( |
| 337 | NodeLabel.Concept, |
| 338 | { |
| 339 | "name": name, |
| 340 | "description": description, |
| 341 | "domain": domain, |
| 342 | "status": status, |
| 343 | }, |
| 344 | ) |
| 345 | |
| 346 | if domain: |
| 347 | self._ensure_domain(domain) |
| 348 | self.store.create_edge( |
| 349 | label, |
| 350 | {"name": name}, |
| 351 | EdgeType.BELONGS_TO, |
| 352 | NodeLabel.Domain, |
| 353 | {"name": domain}, |
| 354 | ) |
| 355 | |
| 356 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 357 | |
| 358 | def _ingest_kg_relationship(self, rel: dict[str, Any]) -> None: |
| @@ -340,15 +364,19 @@ | |
| 364 | return |
| 365 | |
| 366 | edge_type = EDGE_MAP.get(rel_type, EdgeType.RELATED_TO) |
| 367 | |
| 368 | # We don't know the exact label of each node — use a label-agnostic match |
| 369 | cypher = ( |
| 370 | """ |
| 371 | MATCH (a), (b) |
| 372 | WHERE a.name = $src AND b.name = $tgt |
| 373 | MERGE (a)-[r:""" |
| 374 | + edge_type |
| 375 | + """]->(b) |
| 376 | """ |
| 377 | ) |
| 378 | try: |
| 379 | self.store.query(cypher, {"src": src, "tgt": tgt}) |
| 380 | self._stats["edges"] = self._stats.get("edges", 0) + 1 |
| 381 | except Exception: |
| 382 | logger.warning("Could not create edge %s -[%s]-> %s", src, edge_type, tgt) |
| @@ -358,22 +386,27 @@ | |
| 386 | point = (kp.get("point") or "").strip() |
| 387 | if not point: |
| 388 | continue |
| 389 | topic = kp.get("topic") or "" |
| 390 | name = point[:120] # use the point text as the concept name |
| 391 | self.store.create_node( |
| 392 | NodeLabel.Concept, |
| 393 | { |
| 394 | "name": name, |
| 395 | "description": kp.get("details", ""), |
| 396 | "domain": topic, |
| 397 | "status": "key_point", |
| 398 | }, |
| 399 | ) |
| 400 | if topic: |
| 401 | self._ensure_domain(topic) |
| 402 | self.store.create_edge( |
| 403 | NodeLabel.Concept, |
| 404 | {"name": name}, |
| 405 | EdgeType.BELONGS_TO, |
| 406 | NodeLabel.Domain, |
| 407 | {"name": topic}, |
| 408 | ) |
| 409 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 410 | |
| 411 | def _ingest_action_items(self, action_items: list[dict], source: str) -> None: |
| 412 | for item in action_items: |
| @@ -381,27 +414,38 @@ | |
| 414 | assignee = (item.get("assignee") or "").strip() |
| 415 | if not action: |
| 416 | continue |
| 417 | |
| 418 | # Action → Rule (it's a commitment / obligation) |
| 419 | self.store.create_node( |
| 420 | NodeLabel.Rule, |
| 421 | { |
| 422 | "name": action[:120], |
| 423 | "description": item.get("context", ""), |
| 424 | "domain": source, |
| 425 | "severity": item.get("priority", "info"), |
| 426 | "rationale": f"Action item from {source}", |
| 427 | }, |
| 428 | ) |
| 429 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 430 | |
| 431 | if assignee: |
| 432 | self.store.create_node( |
| 433 | NodeLabel.Person, |
| 434 | { |
| 435 | "name": assignee, |
| 436 | "email": "", |
| 437 | "role": "", |
| 438 | "team": "", |
| 439 | }, |
| 440 | ) |
| 441 | self.store.create_edge( |
| 442 | NodeLabel.Rule, |
| 443 | {"name": action[:120]}, |
| 444 | EdgeType.ASSIGNED_TO, |
| 445 | NodeLabel.Person, |
| 446 | {"name": assignee}, |
| 447 | ) |
| 448 | self._stats["edges"] = self._stats.get("edges", 0) + 1 |
| 449 | |
| 450 | def _ingest_diagram(self, diagram: dict[str, Any], base_dir: Path, source: str) -> None: |
| 451 | dtype = diagram.get("diagram_type", "diagram") |
| @@ -409,57 +453,74 @@ | |
| 453 | mermaid = diagram.get("mermaid", "") |
| 454 | ts = diagram.get("timestamp") |
| 455 | name = f"{dtype.capitalize()} @ {ts:.0f}s" if ts is not None else f"{dtype.capitalize()}" |
| 456 | |
| 457 | content = mermaid or desc |
| 458 | self.store.create_node( |
| 459 | NodeLabel.WikiPage, |
| 460 | { |
| 461 | "name": name, |
| 462 | "url": diagram.get("image_path", ""), |
| 463 | "source": source, |
| 464 | "content": content[:4000], |
| 465 | }, |
| 466 | ) |
| 467 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 468 | |
| 469 | # Link diagram elements as concepts |
| 470 | for element in diagram.get("elements", []): |
| 471 | element = element.strip() |
| 472 | if not element: |
| 473 | continue |
| 474 | self.store.create_node( |
| 475 | NodeLabel.Concept, |
| 476 | { |
| 477 | "name": element, |
| 478 | "description": "", |
| 479 | "domain": source, |
| 480 | "status": "", |
| 481 | }, |
| 482 | ) |
| 483 | self.store.create_edge( |
| 484 | NodeLabel.WikiPage, |
| 485 | {"name": name}, |
| 486 | EdgeType.DOCUMENTS, |
| 487 | NodeLabel.Concept, |
| 488 | {"name": element}, |
| 489 | ) |
| 490 | self._stats["edges"] = self._stats.get("edges", 0) + 1 |
| 491 | |
| 492 | def _ingest_source(self, source: dict[str, Any]) -> None: |
| 493 | name = (source.get("title") or source.get("source_id") or "").strip() |
| 494 | if not name: |
| 495 | return |
| 496 | self.store.create_node( |
| 497 | NodeLabel.WikiPage, |
| 498 | { |
| 499 | "name": name, |
| 500 | "url": source.get("url") or source.get("path") or "", |
| 501 | "source": source.get("source_type", ""), |
| 502 | "content": "", |
| 503 | "updated_at": source.get("ingested_at", ""), |
| 504 | }, |
| 505 | ) |
| 506 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 507 | |
| 508 | def _ingest_artifact(self, artifact: dict[str, Any], project_name: str) -> None: |
| 509 | name = (artifact.get("name") or "").strip() |
| 510 | if not name: |
| 511 | return |
| 512 | content = artifact.get("content", "") |
| 513 | self.store.create_node( |
| 514 | NodeLabel.WikiPage, |
| 515 | { |
| 516 | "name": name, |
| 517 | "url": "", |
| 518 | "source": project_name, |
| 519 | "content": content[:4000], |
| 520 | }, |
| 521 | ) |
| 522 | self._stats["nodes"] = self._stats.get("nodes", 0) + 1 |
| 523 | |
| 524 | # ── Helpers ─────────────────────────────────────────────────────────────── |
| 525 | |
| 526 | def _ensure_domain(self, name: str) -> None: |
| @@ -467,13 +528,15 @@ | |
| 528 | |
| 529 | def _lazy_wiki_link(self, name: str, label: NodeLabel, source_id: str) -> None: |
| 530 | """Create a DOCUMENTS edge from a WikiPage to this node if the page exists.""" |
| 531 | try: |
| 532 | self.store.create_edge( |
| 533 | NodeLabel.WikiPage, |
| 534 | {"name": source_id}, |
| 535 | EdgeType.DOCUMENTS, |
| 536 | label, |
| 537 | {"name": name}, |
| 538 | ) |
| 539 | except Exception: |
| 540 | logger.debug("Could not link %s to wiki page %s", name, source_id) |
| 541 | |
| 542 | def _load_json(self, path: Path) -> Any: |
| 543 |
+103
-55
| --- navegador/ingestion/python.py | ||
| +++ navegador/ingestion/python.py | ||
| @@ -15,25 +15,25 @@ | ||
| 15 | 15 | |
| 16 | 16 | def _get_python_language(): |
| 17 | 17 | try: |
| 18 | 18 | import tree_sitter_python as tspython # type: ignore[import] |
| 19 | 19 | from tree_sitter import Language |
| 20 | + | |
| 20 | 21 | return Language(tspython.language()) |
| 21 | 22 | except ImportError as e: |
| 22 | - raise ImportError( | |
| 23 | - "Install tree-sitter-python: pip install tree-sitter-python" | |
| 24 | - ) from e | |
| 23 | + raise ImportError("Install tree-sitter-python: pip install tree-sitter-python") from e | |
| 25 | 24 | |
| 26 | 25 | |
| 27 | 26 | def _get_parser(): |
| 28 | 27 | from tree_sitter import Parser # type: ignore[import] |
| 28 | + | |
| 29 | 29 | parser = Parser(_get_python_language()) |
| 30 | 30 | return parser |
| 31 | 31 | |
| 32 | 32 | |
| 33 | 33 | def _node_text(node, source: bytes) -> str: |
| 34 | - return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") | |
| 34 | + return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") | |
| 35 | 35 | |
| 36 | 36 | |
| 37 | 37 | def _get_docstring(node, source: bytes) -> str | None: |
| 38 | 38 | """Extract the first string literal from a function/class body as docstring.""" |
| 39 | 39 | body = next((c for c in node.children if c.type == "block"), None) |
| @@ -61,22 +61,32 @@ | ||
| 61 | 61 | rel_path = str(path.relative_to(repo_root)) |
| 62 | 62 | |
| 63 | 63 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 64 | 64 | |
| 65 | 65 | # File node |
| 66 | - store.create_node(NodeLabel.File, { | |
| 67 | - "name": path.name, | |
| 68 | - "path": rel_path, | |
| 69 | - "language": "python", | |
| 70 | - "line_count": source.count(b"\n"), | |
| 71 | - }) | |
| 66 | + store.create_node( | |
| 67 | + NodeLabel.File, | |
| 68 | + { | |
| 69 | + "name": path.name, | |
| 70 | + "path": rel_path, | |
| 71 | + "language": "python", | |
| 72 | + "line_count": source.count(b"\n"), | |
| 73 | + }, | |
| 74 | + ) | |
| 72 | 75 | |
| 73 | 76 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 74 | 77 | return stats |
| 75 | 78 | |
| 76 | - def _walk(self, node, source: bytes, file_path: str, store: GraphStore, | |
| 77 | - stats: dict, class_name: str | None) -> None: | |
| 79 | + def _walk( | |
| 80 | + self, | |
| 81 | + node, | |
| 82 | + source: bytes, | |
| 83 | + file_path: str, | |
| 84 | + store: GraphStore, | |
| 85 | + stats: dict, | |
| 86 | + class_name: str | None, | |
| 87 | + ) -> None: | |
| 78 | 88 | if node.type == "import_statement": |
| 79 | 89 | self._handle_import(node, source, file_path, store, stats) |
| 80 | 90 | |
| 81 | 91 | elif node.type == "import_from_statement": |
| 82 | 92 | self._handle_import_from(node, source, file_path, store, stats) |
| @@ -90,70 +100,88 @@ | ||
| 90 | 100 | return # function walker handles children |
| 91 | 101 | |
| 92 | 102 | for child in node.children: |
| 93 | 103 | self._walk(child, source, file_path, store, stats, class_name) |
| 94 | 104 | |
| 95 | - def _handle_import(self, node, source: bytes, file_path: str, | |
| 96 | - store: GraphStore, stats: dict) -> None: | |
| 105 | + def _handle_import( | |
| 106 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 107 | + ) -> None: | |
| 97 | 108 | for child in node.children: |
| 98 | 109 | if child.type == "dotted_name": |
| 99 | 110 | name = _node_text(child, source) |
| 100 | - store.create_node(NodeLabel.Import, { | |
| 101 | - "name": name, | |
| 102 | - "file_path": file_path, | |
| 103 | - "line_start": node.start_point[0] + 1, | |
| 104 | - "module": name, | |
| 105 | - }) | |
| 111 | + store.create_node( | |
| 112 | + NodeLabel.Import, | |
| 113 | + { | |
| 114 | + "name": name, | |
| 115 | + "file_path": file_path, | |
| 116 | + "line_start": node.start_point[0] + 1, | |
| 117 | + "module": name, | |
| 118 | + }, | |
| 119 | + ) | |
| 106 | 120 | store.create_edge( |
| 107 | - NodeLabel.File, {"path": file_path}, | |
| 121 | + NodeLabel.File, | |
| 122 | + {"path": file_path}, | |
| 108 | 123 | EdgeType.IMPORTS, |
| 109 | - NodeLabel.Import, {"name": name, "file_path": file_path}, | |
| 124 | + NodeLabel.Import, | |
| 125 | + {"name": name, "file_path": file_path}, | |
| 110 | 126 | ) |
| 111 | 127 | stats["edges"] += 1 |
| 112 | 128 | |
| 113 | - def _handle_import_from(self, node, source: bytes, file_path: str, | |
| 114 | - store: GraphStore, stats: dict) -> None: | |
| 129 | + def _handle_import_from( | |
| 130 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 131 | + ) -> None: | |
| 115 | 132 | module = "" |
| 116 | 133 | for child in node.children: |
| 117 | 134 | if child.type in ("dotted_name", "relative_import"): |
| 118 | 135 | module = _node_text(child, source) |
| 119 | 136 | break |
| 120 | 137 | for child in node.children: |
| 121 | 138 | if child.type == "import_from_member": |
| 122 | 139 | name = _node_text(child, source) |
| 123 | - store.create_node(NodeLabel.Import, { | |
| 124 | - "name": name, | |
| 125 | - "file_path": file_path, | |
| 126 | - "line_start": node.start_point[0] + 1, | |
| 127 | - "module": module, | |
| 128 | - }) | |
| 140 | + store.create_node( | |
| 141 | + NodeLabel.Import, | |
| 142 | + { | |
| 143 | + "name": name, | |
| 144 | + "file_path": file_path, | |
| 145 | + "line_start": node.start_point[0] + 1, | |
| 146 | + "module": module, | |
| 147 | + }, | |
| 148 | + ) | |
| 129 | 149 | store.create_edge( |
| 130 | - NodeLabel.File, {"path": file_path}, | |
| 150 | + NodeLabel.File, | |
| 151 | + {"path": file_path}, | |
| 131 | 152 | EdgeType.IMPORTS, |
| 132 | - NodeLabel.Import, {"name": name, "file_path": file_path}, | |
| 153 | + NodeLabel.Import, | |
| 154 | + {"name": name, "file_path": file_path}, | |
| 133 | 155 | ) |
| 134 | 156 | stats["edges"] += 1 |
| 135 | 157 | |
| 136 | - def _handle_class(self, node, source: bytes, file_path: str, | |
| 137 | - store: GraphStore, stats: dict) -> None: | |
| 158 | + def _handle_class( | |
| 159 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 160 | + ) -> None: | |
| 138 | 161 | name_node = next((c for c in node.children if c.type == "identifier"), None) |
| 139 | 162 | if not name_node: |
| 140 | 163 | return |
| 141 | 164 | name = _node_text(name_node, source) |
| 142 | 165 | docstring = _get_docstring(node, source) |
| 143 | 166 | |
| 144 | - store.create_node(NodeLabel.Class, { | |
| 145 | - "name": name, | |
| 146 | - "file_path": file_path, | |
| 147 | - "line_start": node.start_point[0] + 1, | |
| 148 | - "line_end": node.end_point[0] + 1, | |
| 149 | - "docstring": docstring or "", | |
| 150 | - }) | |
| 167 | + store.create_node( | |
| 168 | + NodeLabel.Class, | |
| 169 | + { | |
| 170 | + "name": name, | |
| 171 | + "file_path": file_path, | |
| 172 | + "line_start": node.start_point[0] + 1, | |
| 173 | + "line_end": node.end_point[0] + 1, | |
| 174 | + "docstring": docstring or "", | |
| 175 | + }, | |
| 176 | + ) | |
| 151 | 177 | store.create_edge( |
| 152 | - NodeLabel.File, {"path": file_path}, | |
| 178 | + NodeLabel.File, | |
| 179 | + {"path": file_path}, | |
| 153 | 180 | EdgeType.CONTAINS, |
| 154 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 181 | + NodeLabel.Class, | |
| 182 | + {"name": name, "file_path": file_path}, | |
| 155 | 183 | ) |
| 156 | 184 | stats["classes"] += 1 |
| 157 | 185 | stats["edges"] += 1 |
| 158 | 186 | |
| 159 | 187 | # Inheritance |
| @@ -161,13 +189,15 @@ | ||
| 161 | 189 | if child.type == "argument_list": |
| 162 | 190 | for arg in child.children: |
| 163 | 191 | if arg.type == "identifier": |
| 164 | 192 | parent_name = _node_text(arg, source) |
| 165 | 193 | store.create_edge( |
| 166 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 194 | + NodeLabel.Class, | |
| 195 | + {"name": name, "file_path": file_path}, | |
| 167 | 196 | EdgeType.INHERITS, |
| 168 | - NodeLabel.Class, {"name": parent_name, "file_path": file_path}, | |
| 197 | + NodeLabel.Class, | |
| 198 | + {"name": parent_name, "file_path": file_path}, | |
| 169 | 199 | ) |
| 170 | 200 | stats["edges"] += 1 |
| 171 | 201 | |
| 172 | 202 | # Walk class body for methods |
| 173 | 203 | body = next((c for c in node.children if c.type == "block"), None) |
| @@ -174,12 +204,19 @@ | ||
| 174 | 204 | if body: |
| 175 | 205 | for child in body.children: |
| 176 | 206 | if child.type == "function_definition": |
| 177 | 207 | self._handle_function(child, source, file_path, store, stats, class_name=name) |
| 178 | 208 | |
| 179 | - def _handle_function(self, node, source: bytes, file_path: str, | |
| 180 | - store: GraphStore, stats: dict, class_name: str | None) -> None: | |
| 209 | + def _handle_function( | |
| 210 | + self, | |
| 211 | + node, | |
| 212 | + source: bytes, | |
| 213 | + file_path: str, | |
| 214 | + store: GraphStore, | |
| 215 | + stats: dict, | |
| 216 | + class_name: str | None, | |
| 217 | + ) -> None: | |
| 181 | 218 | name_node = next((c for c in node.children if c.type == "identifier"), None) |
| 182 | 219 | if not name_node: |
| 183 | 220 | return |
| 184 | 221 | name = _node_text(name_node, source) |
| 185 | 222 | docstring = _get_docstring(node, source) |
| @@ -195,40 +232,51 @@ | ||
| 195 | 232 | } |
| 196 | 233 | store.create_node(label, props) |
| 197 | 234 | |
| 198 | 235 | container_label = NodeLabel.Class if class_name else NodeLabel.File |
| 199 | 236 | container_key = ( |
| 200 | - {"name": class_name, "file_path": file_path} | |
| 201 | - if class_name | |
| 202 | - else {"path": file_path} | |
| 237 | + {"name": class_name, "file_path": file_path} if class_name else {"path": file_path} | |
| 203 | 238 | ) |
| 204 | 239 | store.create_edge( |
| 205 | - container_label, container_key, EdgeType.CONTAINS, label, | |
| 240 | + container_label, | |
| 241 | + container_key, | |
| 242 | + EdgeType.CONTAINS, | |
| 243 | + label, | |
| 206 | 244 | {"name": name, "file_path": file_path}, |
| 207 | 245 | ) |
| 208 | 246 | stats["functions"] += 1 |
| 209 | 247 | stats["edges"] += 1 |
| 210 | 248 | |
| 211 | 249 | # Call edges — find all call expressions in the body |
| 212 | 250 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 213 | 251 | |
| 214 | - def _extract_calls(self, fn_node, source: bytes, file_path: str, fn_name: str, | |
| 215 | - fn_label: str, store: GraphStore, stats: dict) -> None: | |
| 252 | + def _extract_calls( | |
| 253 | + self, | |
| 254 | + fn_node, | |
| 255 | + source: bytes, | |
| 256 | + file_path: str, | |
| 257 | + fn_name: str, | |
| 258 | + fn_label: str, | |
| 259 | + store: GraphStore, | |
| 260 | + stats: dict, | |
| 261 | + ) -> None: | |
| 216 | 262 | def walk_calls(node): |
| 217 | 263 | if node.type == "call": |
| 218 | 264 | func = next( |
| 219 | 265 | (c for c in node.children if c.type in ("identifier", "attribute")), None |
| 220 | 266 | ) |
| 221 | 267 | if func: |
| 222 | 268 | callee_name = _node_text(func, source).split(".")[-1] |
| 223 | 269 | store.create_edge( |
| 224 | - fn_label, {"name": fn_name, "file_path": file_path}, | |
| 270 | + fn_label, | |
| 271 | + {"name": fn_name, "file_path": file_path}, | |
| 225 | 272 | EdgeType.CALLS, |
| 226 | - NodeLabel.Function, {"name": callee_name, "file_path": file_path}, | |
| 273 | + NodeLabel.Function, | |
| 274 | + {"name": callee_name, "file_path": file_path}, | |
| 227 | 275 | ) |
| 228 | 276 | stats["edges"] += 1 |
| 229 | 277 | for child in node.children: |
| 230 | 278 | walk_calls(child) |
| 231 | 279 | |
| 232 | 280 | body = next((c for c in fn_node.children if c.type == "block"), None) |
| 233 | 281 | if body: |
| 234 | 282 | walk_calls(body) |
| 235 | 283 |
| --- navegador/ingestion/python.py | |
| +++ navegador/ingestion/python.py | |
| @@ -15,25 +15,25 @@ | |
| 15 | |
| 16 | def _get_python_language(): |
| 17 | try: |
| 18 | import tree_sitter_python as tspython # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | return Language(tspython.language()) |
| 21 | except ImportError as e: |
| 22 | raise ImportError( |
| 23 | "Install tree-sitter-python: pip install tree-sitter-python" |
| 24 | ) from e |
| 25 | |
| 26 | |
| 27 | def _get_parser(): |
| 28 | from tree_sitter import Parser # type: ignore[import] |
| 29 | parser = Parser(_get_python_language()) |
| 30 | return parser |
| 31 | |
| 32 | |
| 33 | def _node_text(node, source: bytes) -> str: |
| 34 | return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") |
| 35 | |
| 36 | |
| 37 | def _get_docstring(node, source: bytes) -> str | None: |
| 38 | """Extract the first string literal from a function/class body as docstring.""" |
| 39 | body = next((c for c in node.children if c.type == "block"), None) |
| @@ -61,22 +61,32 @@ | |
| 61 | rel_path = str(path.relative_to(repo_root)) |
| 62 | |
| 63 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 64 | |
| 65 | # File node |
| 66 | store.create_node(NodeLabel.File, { |
| 67 | "name": path.name, |
| 68 | "path": rel_path, |
| 69 | "language": "python", |
| 70 | "line_count": source.count(b"\n"), |
| 71 | }) |
| 72 | |
| 73 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 74 | return stats |
| 75 | |
| 76 | def _walk(self, node, source: bytes, file_path: str, store: GraphStore, |
| 77 | stats: dict, class_name: str | None) -> None: |
| 78 | if node.type == "import_statement": |
| 79 | self._handle_import(node, source, file_path, store, stats) |
| 80 | |
| 81 | elif node.type == "import_from_statement": |
| 82 | self._handle_import_from(node, source, file_path, store, stats) |
| @@ -90,70 +100,88 @@ | |
| 90 | return # function walker handles children |
| 91 | |
| 92 | for child in node.children: |
| 93 | self._walk(child, source, file_path, store, stats, class_name) |
| 94 | |
| 95 | def _handle_import(self, node, source: bytes, file_path: str, |
| 96 | store: GraphStore, stats: dict) -> None: |
| 97 | for child in node.children: |
| 98 | if child.type == "dotted_name": |
| 99 | name = _node_text(child, source) |
| 100 | store.create_node(NodeLabel.Import, { |
| 101 | "name": name, |
| 102 | "file_path": file_path, |
| 103 | "line_start": node.start_point[0] + 1, |
| 104 | "module": name, |
| 105 | }) |
| 106 | store.create_edge( |
| 107 | NodeLabel.File, {"path": file_path}, |
| 108 | EdgeType.IMPORTS, |
| 109 | NodeLabel.Import, {"name": name, "file_path": file_path}, |
| 110 | ) |
| 111 | stats["edges"] += 1 |
| 112 | |
| 113 | def _handle_import_from(self, node, source: bytes, file_path: str, |
| 114 | store: GraphStore, stats: dict) -> None: |
| 115 | module = "" |
| 116 | for child in node.children: |
| 117 | if child.type in ("dotted_name", "relative_import"): |
| 118 | module = _node_text(child, source) |
| 119 | break |
| 120 | for child in node.children: |
| 121 | if child.type == "import_from_member": |
| 122 | name = _node_text(child, source) |
| 123 | store.create_node(NodeLabel.Import, { |
| 124 | "name": name, |
| 125 | "file_path": file_path, |
| 126 | "line_start": node.start_point[0] + 1, |
| 127 | "module": module, |
| 128 | }) |
| 129 | store.create_edge( |
| 130 | NodeLabel.File, {"path": file_path}, |
| 131 | EdgeType.IMPORTS, |
| 132 | NodeLabel.Import, {"name": name, "file_path": file_path}, |
| 133 | ) |
| 134 | stats["edges"] += 1 |
| 135 | |
| 136 | def _handle_class(self, node, source: bytes, file_path: str, |
| 137 | store: GraphStore, stats: dict) -> None: |
| 138 | name_node = next((c for c in node.children if c.type == "identifier"), None) |
| 139 | if not name_node: |
| 140 | return |
| 141 | name = _node_text(name_node, source) |
| 142 | docstring = _get_docstring(node, source) |
| 143 | |
| 144 | store.create_node(NodeLabel.Class, { |
| 145 | "name": name, |
| 146 | "file_path": file_path, |
| 147 | "line_start": node.start_point[0] + 1, |
| 148 | "line_end": node.end_point[0] + 1, |
| 149 | "docstring": docstring or "", |
| 150 | }) |
| 151 | store.create_edge( |
| 152 | NodeLabel.File, {"path": file_path}, |
| 153 | EdgeType.CONTAINS, |
| 154 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 155 | ) |
| 156 | stats["classes"] += 1 |
| 157 | stats["edges"] += 1 |
| 158 | |
| 159 | # Inheritance |
| @@ -161,13 +189,15 @@ | |
| 161 | if child.type == "argument_list": |
| 162 | for arg in child.children: |
| 163 | if arg.type == "identifier": |
| 164 | parent_name = _node_text(arg, source) |
| 165 | store.create_edge( |
| 166 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 167 | EdgeType.INHERITS, |
| 168 | NodeLabel.Class, {"name": parent_name, "file_path": file_path}, |
| 169 | ) |
| 170 | stats["edges"] += 1 |
| 171 | |
| 172 | # Walk class body for methods |
| 173 | body = next((c for c in node.children if c.type == "block"), None) |
| @@ -174,12 +204,19 @@ | |
| 174 | if body: |
| 175 | for child in body.children: |
| 176 | if child.type == "function_definition": |
| 177 | self._handle_function(child, source, file_path, store, stats, class_name=name) |
| 178 | |
| 179 | def _handle_function(self, node, source: bytes, file_path: str, |
| 180 | store: GraphStore, stats: dict, class_name: str | None) -> None: |
| 181 | name_node = next((c for c in node.children if c.type == "identifier"), None) |
| 182 | if not name_node: |
| 183 | return |
| 184 | name = _node_text(name_node, source) |
| 185 | docstring = _get_docstring(node, source) |
| @@ -195,40 +232,51 @@ | |
| 195 | } |
| 196 | store.create_node(label, props) |
| 197 | |
| 198 | container_label = NodeLabel.Class if class_name else NodeLabel.File |
| 199 | container_key = ( |
| 200 | {"name": class_name, "file_path": file_path} |
| 201 | if class_name |
| 202 | else {"path": file_path} |
| 203 | ) |
| 204 | store.create_edge( |
| 205 | container_label, container_key, EdgeType.CONTAINS, label, |
| 206 | {"name": name, "file_path": file_path}, |
| 207 | ) |
| 208 | stats["functions"] += 1 |
| 209 | stats["edges"] += 1 |
| 210 | |
| 211 | # Call edges — find all call expressions in the body |
| 212 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 213 | |
| 214 | def _extract_calls(self, fn_node, source: bytes, file_path: str, fn_name: str, |
| 215 | fn_label: str, store: GraphStore, stats: dict) -> None: |
| 216 | def walk_calls(node): |
| 217 | if node.type == "call": |
| 218 | func = next( |
| 219 | (c for c in node.children if c.type in ("identifier", "attribute")), None |
| 220 | ) |
| 221 | if func: |
| 222 | callee_name = _node_text(func, source).split(".")[-1] |
| 223 | store.create_edge( |
| 224 | fn_label, {"name": fn_name, "file_path": file_path}, |
| 225 | EdgeType.CALLS, |
| 226 | NodeLabel.Function, {"name": callee_name, "file_path": file_path}, |
| 227 | ) |
| 228 | stats["edges"] += 1 |
| 229 | for child in node.children: |
| 230 | walk_calls(child) |
| 231 | |
| 232 | body = next((c for c in fn_node.children if c.type == "block"), None) |
| 233 | if body: |
| 234 | walk_calls(body) |
| 235 |
| --- navegador/ingestion/python.py | |
| +++ navegador/ingestion/python.py | |
| @@ -15,25 +15,25 @@ | |
| 15 | |
| 16 | def _get_python_language(): |
| 17 | try: |
| 18 | import tree_sitter_python as tspython # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | |
| 21 | return Language(tspython.language()) |
| 22 | except ImportError as e: |
| 23 | raise ImportError("Install tree-sitter-python: pip install tree-sitter-python") from e |
| 24 | |
| 25 | |
| 26 | def _get_parser(): |
| 27 | from tree_sitter import Parser # type: ignore[import] |
| 28 | |
| 29 | parser = Parser(_get_python_language()) |
| 30 | return parser |
| 31 | |
| 32 | |
| 33 | def _node_text(node, source: bytes) -> str: |
| 34 | return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") |
| 35 | |
| 36 | |
| 37 | def _get_docstring(node, source: bytes) -> str | None: |
| 38 | """Extract the first string literal from a function/class body as docstring.""" |
| 39 | body = next((c for c in node.children if c.type == "block"), None) |
| @@ -61,22 +61,32 @@ | |
| 61 | rel_path = str(path.relative_to(repo_root)) |
| 62 | |
| 63 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 64 | |
| 65 | # File node |
| 66 | store.create_node( |
| 67 | NodeLabel.File, |
| 68 | { |
| 69 | "name": path.name, |
| 70 | "path": rel_path, |
| 71 | "language": "python", |
| 72 | "line_count": source.count(b"\n"), |
| 73 | }, |
| 74 | ) |
| 75 | |
| 76 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 77 | return stats |
| 78 | |
| 79 | def _walk( |
| 80 | self, |
| 81 | node, |
| 82 | source: bytes, |
| 83 | file_path: str, |
| 84 | store: GraphStore, |
| 85 | stats: dict, |
| 86 | class_name: str | None, |
| 87 | ) -> None: |
| 88 | if node.type == "import_statement": |
| 89 | self._handle_import(node, source, file_path, store, stats) |
| 90 | |
| 91 | elif node.type == "import_from_statement": |
| 92 | self._handle_import_from(node, source, file_path, store, stats) |
| @@ -90,70 +100,88 @@ | |
| 100 | return # function walker handles children |
| 101 | |
| 102 | for child in node.children: |
| 103 | self._walk(child, source, file_path, store, stats, class_name) |
| 104 | |
| 105 | def _handle_import( |
| 106 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 107 | ) -> None: |
| 108 | for child in node.children: |
| 109 | if child.type == "dotted_name": |
| 110 | name = _node_text(child, source) |
| 111 | store.create_node( |
| 112 | NodeLabel.Import, |
| 113 | { |
| 114 | "name": name, |
| 115 | "file_path": file_path, |
| 116 | "line_start": node.start_point[0] + 1, |
| 117 | "module": name, |
| 118 | }, |
| 119 | ) |
| 120 | store.create_edge( |
| 121 | NodeLabel.File, |
| 122 | {"path": file_path}, |
| 123 | EdgeType.IMPORTS, |
| 124 | NodeLabel.Import, |
| 125 | {"name": name, "file_path": file_path}, |
| 126 | ) |
| 127 | stats["edges"] += 1 |
| 128 | |
| 129 | def _handle_import_from( |
| 130 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 131 | ) -> None: |
| 132 | module = "" |
| 133 | for child in node.children: |
| 134 | if child.type in ("dotted_name", "relative_import"): |
| 135 | module = _node_text(child, source) |
| 136 | break |
| 137 | for child in node.children: |
| 138 | if child.type == "import_from_member": |
| 139 | name = _node_text(child, source) |
| 140 | store.create_node( |
| 141 | NodeLabel.Import, |
| 142 | { |
| 143 | "name": name, |
| 144 | "file_path": file_path, |
| 145 | "line_start": node.start_point[0] + 1, |
| 146 | "module": module, |
| 147 | }, |
| 148 | ) |
| 149 | store.create_edge( |
| 150 | NodeLabel.File, |
| 151 | {"path": file_path}, |
| 152 | EdgeType.IMPORTS, |
| 153 | NodeLabel.Import, |
| 154 | {"name": name, "file_path": file_path}, |
| 155 | ) |
| 156 | stats["edges"] += 1 |
| 157 | |
| 158 | def _handle_class( |
| 159 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 160 | ) -> None: |
| 161 | name_node = next((c for c in node.children if c.type == "identifier"), None) |
| 162 | if not name_node: |
| 163 | return |
| 164 | name = _node_text(name_node, source) |
| 165 | docstring = _get_docstring(node, source) |
| 166 | |
| 167 | store.create_node( |
| 168 | NodeLabel.Class, |
| 169 | { |
| 170 | "name": name, |
| 171 | "file_path": file_path, |
| 172 | "line_start": node.start_point[0] + 1, |
| 173 | "line_end": node.end_point[0] + 1, |
| 174 | "docstring": docstring or "", |
| 175 | }, |
| 176 | ) |
| 177 | store.create_edge( |
| 178 | NodeLabel.File, |
| 179 | {"path": file_path}, |
| 180 | EdgeType.CONTAINS, |
| 181 | NodeLabel.Class, |
| 182 | {"name": name, "file_path": file_path}, |
| 183 | ) |
| 184 | stats["classes"] += 1 |
| 185 | stats["edges"] += 1 |
| 186 | |
| 187 | # Inheritance |
| @@ -161,13 +189,15 @@ | |
| 189 | if child.type == "argument_list": |
| 190 | for arg in child.children: |
| 191 | if arg.type == "identifier": |
| 192 | parent_name = _node_text(arg, source) |
| 193 | store.create_edge( |
| 194 | NodeLabel.Class, |
| 195 | {"name": name, "file_path": file_path}, |
| 196 | EdgeType.INHERITS, |
| 197 | NodeLabel.Class, |
| 198 | {"name": parent_name, "file_path": file_path}, |
| 199 | ) |
| 200 | stats["edges"] += 1 |
| 201 | |
| 202 | # Walk class body for methods |
| 203 | body = next((c for c in node.children if c.type == "block"), None) |
| @@ -174,12 +204,19 @@ | |
| 204 | if body: |
| 205 | for child in body.children: |
| 206 | if child.type == "function_definition": |
| 207 | self._handle_function(child, source, file_path, store, stats, class_name=name) |
| 208 | |
| 209 | def _handle_function( |
| 210 | self, |
| 211 | node, |
| 212 | source: bytes, |
| 213 | file_path: str, |
| 214 | store: GraphStore, |
| 215 | stats: dict, |
| 216 | class_name: str | None, |
| 217 | ) -> None: |
| 218 | name_node = next((c for c in node.children if c.type == "identifier"), None) |
| 219 | if not name_node: |
| 220 | return |
| 221 | name = _node_text(name_node, source) |
| 222 | docstring = _get_docstring(node, source) |
| @@ -195,40 +232,51 @@ | |
| 232 | } |
| 233 | store.create_node(label, props) |
| 234 | |
| 235 | container_label = NodeLabel.Class if class_name else NodeLabel.File |
| 236 | container_key = ( |
| 237 | {"name": class_name, "file_path": file_path} if class_name else {"path": file_path} |
| 238 | ) |
| 239 | store.create_edge( |
| 240 | container_label, |
| 241 | container_key, |
| 242 | EdgeType.CONTAINS, |
| 243 | label, |
| 244 | {"name": name, "file_path": file_path}, |
| 245 | ) |
| 246 | stats["functions"] += 1 |
| 247 | stats["edges"] += 1 |
| 248 | |
| 249 | # Call edges — find all call expressions in the body |
| 250 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 251 | |
| 252 | def _extract_calls( |
| 253 | self, |
| 254 | fn_node, |
| 255 | source: bytes, |
| 256 | file_path: str, |
| 257 | fn_name: str, |
| 258 | fn_label: str, |
| 259 | store: GraphStore, |
| 260 | stats: dict, |
| 261 | ) -> None: |
| 262 | def walk_calls(node): |
| 263 | if node.type == "call": |
| 264 | func = next( |
| 265 | (c for c in node.children if c.type in ("identifier", "attribute")), None |
| 266 | ) |
| 267 | if func: |
| 268 | callee_name = _node_text(func, source).split(".")[-1] |
| 269 | store.create_edge( |
| 270 | fn_label, |
| 271 | {"name": fn_name, "file_path": file_path}, |
| 272 | EdgeType.CALLS, |
| 273 | NodeLabel.Function, |
| 274 | {"name": callee_name, "file_path": file_path}, |
| 275 | ) |
| 276 | stats["edges"] += 1 |
| 277 | for child in node.children: |
| 278 | walk_calls(child) |
| 279 | |
| 280 | body = next((c for c in fn_node.children if c.type == "block"), None) |
| 281 | if body: |
| 282 | walk_calls(body) |
| 283 |
+103
-57
| --- navegador/ingestion/rust.py | ||
| +++ navegador/ingestion/rust.py | ||
| @@ -15,17 +15,18 @@ | ||
| 15 | 15 | |
| 16 | 16 | def _get_rust_language(): |
| 17 | 17 | try: |
| 18 | 18 | import tree_sitter_rust as tsrust # type: ignore[import] |
| 19 | 19 | from tree_sitter import Language |
| 20 | + | |
| 20 | 21 | return Language(tsrust.language()) |
| 21 | 22 | except ImportError as e: |
| 22 | 23 | raise ImportError("Install tree-sitter-rust: pip install tree-sitter-rust") from e |
| 23 | 24 | |
| 24 | 25 | |
| 25 | 26 | def _node_text(node, source: bytes) -> str: |
| 26 | - return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") | |
| 27 | + return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") | |
| 27 | 28 | |
| 28 | 29 | |
| 29 | 30 | def _doc_comment(node, source: bytes) -> str: |
| 30 | 31 | """Collect preceding /// doc-comment lines from siblings.""" |
| 31 | 32 | parent = node.parent |
| @@ -33,11 +34,11 @@ | ||
| 33 | 34 | return "" |
| 34 | 35 | siblings = list(parent.children) |
| 35 | 36 | idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) |
| 36 | 37 | if idx <= 0: |
| 37 | 38 | return "" |
| 38 | - lines = [] | |
| 39 | + lines: list[str] = [] | |
| 39 | 40 | for i in range(idx - 1, -1, -1): |
| 40 | 41 | sib = siblings[i] |
| 41 | 42 | raw = _node_text(sib, source) |
| 42 | 43 | if sib.type == "line_comment" and raw.startswith("///"): |
| 43 | 44 | lines.insert(0, raw.lstrip("/").strip()) |
| @@ -49,32 +50,43 @@ | ||
| 49 | 50 | class RustParser(LanguageParser): |
| 50 | 51 | """Parses Rust source files into the navegador graph.""" |
| 51 | 52 | |
| 52 | 53 | def __init__(self) -> None: |
| 53 | 54 | from tree_sitter import Parser # type: ignore[import] |
| 55 | + | |
| 54 | 56 | self._parser = Parser(_get_rust_language()) |
| 55 | 57 | |
| 56 | 58 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 57 | 59 | source = path.read_bytes() |
| 58 | 60 | tree = self._parser.parse(source) |
| 59 | 61 | rel_path = str(path.relative_to(repo_root)) |
| 60 | 62 | |
| 61 | - store.create_node(NodeLabel.File, { | |
| 62 | - "name": path.name, | |
| 63 | - "path": rel_path, | |
| 64 | - "language": "rust", | |
| 65 | - "line_count": source.count(b"\n"), | |
| 66 | - }) | |
| 63 | + store.create_node( | |
| 64 | + NodeLabel.File, | |
| 65 | + { | |
| 66 | + "name": path.name, | |
| 67 | + "path": rel_path, | |
| 68 | + "language": "rust", | |
| 69 | + "line_count": source.count(b"\n"), | |
| 70 | + }, | |
| 71 | + ) | |
| 67 | 72 | |
| 68 | 73 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 69 | 74 | self._walk(tree.root_node, source, rel_path, store, stats, impl_type=None) |
| 70 | 75 | return stats |
| 71 | 76 | |
| 72 | 77 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 73 | 78 | |
| 74 | - def _walk(self, node, source: bytes, file_path: str, | |
| 75 | - store: GraphStore, stats: dict, impl_type: str | None) -> None: | |
| 79 | + def _walk( | |
| 80 | + self, | |
| 81 | + node, | |
| 82 | + source: bytes, | |
| 83 | + file_path: str, | |
| 84 | + store: GraphStore, | |
| 85 | + stats: dict, | |
| 86 | + impl_type: str | None, | |
| 87 | + ) -> None: | |
| 76 | 88 | if node.type == "function_item": |
| 77 | 89 | self._handle_function(node, source, file_path, store, stats, impl_type) |
| 78 | 90 | return |
| 79 | 91 | if node.type == "impl_item": |
| 80 | 92 | self._handle_impl(node, source, file_path, store, stats) |
| @@ -88,115 +100,149 @@ | ||
| 88 | 100 | for child in node.children: |
| 89 | 101 | self._walk(child, source, file_path, store, stats, impl_type) |
| 90 | 102 | |
| 91 | 103 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 92 | 104 | |
| 93 | - def _handle_function(self, node, source: bytes, file_path: str, | |
| 94 | - store: GraphStore, stats: dict, | |
| 95 | - impl_type: str | None) -> None: | |
| 105 | + def _handle_function( | |
| 106 | + self, | |
| 107 | + node, | |
| 108 | + source: bytes, | |
| 109 | + file_path: str, | |
| 110 | + store: GraphStore, | |
| 111 | + stats: dict, | |
| 112 | + impl_type: str | None, | |
| 113 | + ) -> None: | |
| 96 | 114 | name_node = node.child_by_field_name("name") |
| 97 | 115 | if not name_node: |
| 98 | 116 | return |
| 99 | 117 | name = _node_text(name_node, source) |
| 100 | 118 | docstring = _doc_comment(node, source) |
| 101 | 119 | label = NodeLabel.Method if impl_type else NodeLabel.Function |
| 102 | 120 | |
| 103 | - store.create_node(label, { | |
| 104 | - "name": name, | |
| 105 | - "file_path": file_path, | |
| 106 | - "line_start": node.start_point[0] + 1, | |
| 107 | - "line_end": node.end_point[0] + 1, | |
| 108 | - "docstring": docstring, | |
| 109 | - "class_name": impl_type or "", | |
| 110 | - }) | |
| 121 | + store.create_node( | |
| 122 | + label, | |
| 123 | + { | |
| 124 | + "name": name, | |
| 125 | + "file_path": file_path, | |
| 126 | + "line_start": node.start_point[0] + 1, | |
| 127 | + "line_end": node.end_point[0] + 1, | |
| 128 | + "docstring": docstring, | |
| 129 | + "class_name": impl_type or "", | |
| 130 | + }, | |
| 131 | + ) | |
| 111 | 132 | |
| 112 | 133 | container_label = NodeLabel.Class if impl_type else NodeLabel.File |
| 113 | 134 | container_key = ( |
| 114 | - {"name": impl_type, "file_path": file_path} | |
| 115 | - if impl_type else {"path": file_path} | |
| 135 | + {"name": impl_type, "file_path": file_path} if impl_type else {"path": file_path} | |
| 116 | 136 | ) |
| 117 | 137 | store.create_edge( |
| 118 | - container_label, container_key, | |
| 138 | + container_label, | |
| 139 | + container_key, | |
| 119 | 140 | EdgeType.CONTAINS, |
| 120 | - label, {"name": name, "file_path": file_path}, | |
| 141 | + label, | |
| 142 | + {"name": name, "file_path": file_path}, | |
| 121 | 143 | ) |
| 122 | 144 | stats["functions"] += 1 |
| 123 | 145 | stats["edges"] += 1 |
| 124 | 146 | |
| 125 | 147 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 126 | 148 | |
| 127 | - def _handle_impl(self, node, source: bytes, file_path: str, | |
| 128 | - store: GraphStore, stats: dict) -> None: | |
| 149 | + def _handle_impl( | |
| 150 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 151 | + ) -> None: | |
| 129 | 152 | type_node = node.child_by_field_name("type") |
| 130 | 153 | impl_type_name = _node_text(type_node, source) if type_node else "" |
| 131 | 154 | |
| 132 | 155 | body = node.child_by_field_name("body") |
| 133 | 156 | if body: |
| 134 | 157 | for child in body.children: |
| 135 | 158 | if child.type == "function_item": |
| 136 | - self._handle_function(child, source, file_path, store, stats, | |
| 137 | - impl_type=impl_type_name or None) | |
| 159 | + self._handle_function( | |
| 160 | + child, source, file_path, store, stats, impl_type=impl_type_name or None | |
| 161 | + ) | |
| 138 | 162 | |
| 139 | - def _handle_type(self, node, source: bytes, file_path: str, | |
| 140 | - store: GraphStore, stats: dict) -> None: | |
| 163 | + def _handle_type( | |
| 164 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 165 | + ) -> None: | |
| 141 | 166 | name_node = node.child_by_field_name("name") |
| 142 | 167 | if not name_node: |
| 143 | 168 | return |
| 144 | 169 | name = _node_text(name_node, source) |
| 145 | - kind = {"struct_item": "struct", "enum_item": "enum", | |
| 146 | - "trait_item": "trait"}.get(node.type, "") | |
| 170 | + kind = {"struct_item": "struct", "enum_item": "enum", "trait_item": "trait"}.get( | |
| 171 | + node.type, "" | |
| 172 | + ) | |
| 147 | 173 | docstring = _doc_comment(node, source) |
| 148 | 174 | |
| 149 | - store.create_node(NodeLabel.Class, { | |
| 150 | - "name": name, | |
| 151 | - "file_path": file_path, | |
| 152 | - "line_start": node.start_point[0] + 1, | |
| 153 | - "line_end": node.end_point[0] + 1, | |
| 154 | - "docstring": f"{kind}: {docstring}".strip(": ") if kind else docstring, | |
| 155 | - }) | |
| 175 | + store.create_node( | |
| 176 | + NodeLabel.Class, | |
| 177 | + { | |
| 178 | + "name": name, | |
| 179 | + "file_path": file_path, | |
| 180 | + "line_start": node.start_point[0] + 1, | |
| 181 | + "line_end": node.end_point[0] + 1, | |
| 182 | + "docstring": f"{kind}: {docstring}".strip(": ") if kind else docstring, | |
| 183 | + }, | |
| 184 | + ) | |
| 156 | 185 | store.create_edge( |
| 157 | - NodeLabel.File, {"path": file_path}, | |
| 186 | + NodeLabel.File, | |
| 187 | + {"path": file_path}, | |
| 158 | 188 | EdgeType.CONTAINS, |
| 159 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 189 | + NodeLabel.Class, | |
| 190 | + {"name": name, "file_path": file_path}, | |
| 160 | 191 | ) |
| 161 | 192 | stats["classes"] += 1 |
| 162 | 193 | stats["edges"] += 1 |
| 163 | 194 | |
| 164 | - def _handle_use(self, node, source: bytes, file_path: str, | |
| 165 | - store: GraphStore, stats: dict) -> None: | |
| 195 | + def _handle_use( | |
| 196 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 197 | + ) -> None: | |
| 166 | 198 | raw = _node_text(node, source) |
| 167 | 199 | module = raw.removeprefix("use ").removesuffix(";").strip() |
| 168 | - store.create_node(NodeLabel.Import, { | |
| 169 | - "name": module, | |
| 170 | - "file_path": file_path, | |
| 171 | - "line_start": node.start_point[0] + 1, | |
| 172 | - "module": module, | |
| 173 | - }) | |
| 200 | + store.create_node( | |
| 201 | + NodeLabel.Import, | |
| 202 | + { | |
| 203 | + "name": module, | |
| 204 | + "file_path": file_path, | |
| 205 | + "line_start": node.start_point[0] + 1, | |
| 206 | + "module": module, | |
| 207 | + }, | |
| 208 | + ) | |
| 174 | 209 | store.create_edge( |
| 175 | - NodeLabel.File, {"path": file_path}, | |
| 210 | + NodeLabel.File, | |
| 211 | + {"path": file_path}, | |
| 176 | 212 | EdgeType.IMPORTS, |
| 177 | - NodeLabel.Import, {"name": module, "file_path": file_path}, | |
| 213 | + NodeLabel.Import, | |
| 214 | + {"name": module, "file_path": file_path}, | |
| 178 | 215 | ) |
| 179 | 216 | stats["edges"] += 1 |
| 180 | 217 | |
| 181 | - def _extract_calls(self, fn_node, source: bytes, file_path: str, | |
| 182 | - fn_name: str, fn_label: str, | |
| 183 | - store: GraphStore, stats: dict) -> None: | |
| 218 | + def _extract_calls( | |
| 219 | + self, | |
| 220 | + fn_node, | |
| 221 | + source: bytes, | |
| 222 | + file_path: str, | |
| 223 | + fn_name: str, | |
| 224 | + fn_label: str, | |
| 225 | + store: GraphStore, | |
| 226 | + stats: dict, | |
| 227 | + ) -> None: | |
| 184 | 228 | def walk(node): |
| 185 | 229 | if node.type == "call_expression": |
| 186 | 230 | func = node.child_by_field_name("function") |
| 187 | 231 | if func: |
| 188 | 232 | text = _node_text(func, source) |
| 189 | 233 | # Handle Foo::bar() and obj.method() |
| 190 | 234 | callee = text.replace("::", ".").split(".")[-1] |
| 191 | 235 | store.create_edge( |
| 192 | - fn_label, {"name": fn_name, "file_path": file_path}, | |
| 236 | + fn_label, | |
| 237 | + {"name": fn_name, "file_path": file_path}, | |
| 193 | 238 | EdgeType.CALLS, |
| 194 | - NodeLabel.Function, {"name": callee, "file_path": file_path}, | |
| 239 | + NodeLabel.Function, | |
| 240 | + {"name": callee, "file_path": file_path}, | |
| 195 | 241 | ) |
| 196 | 242 | stats["edges"] += 1 |
| 197 | 243 | for child in node.children: |
| 198 | 244 | walk(child) |
| 199 | 245 | |
| 200 | 246 | body = fn_node.child_by_field_name("body") |
| 201 | 247 | if body: |
| 202 | 248 | walk(body) |
| 203 | 249 |
| --- navegador/ingestion/rust.py | |
| +++ navegador/ingestion/rust.py | |
| @@ -15,17 +15,18 @@ | |
| 15 | |
| 16 | def _get_rust_language(): |
| 17 | try: |
| 18 | import tree_sitter_rust as tsrust # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | return Language(tsrust.language()) |
| 21 | except ImportError as e: |
| 22 | raise ImportError("Install tree-sitter-rust: pip install tree-sitter-rust") from e |
| 23 | |
| 24 | |
| 25 | def _node_text(node, source: bytes) -> str: |
| 26 | return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") |
| 27 | |
| 28 | |
| 29 | def _doc_comment(node, source: bytes) -> str: |
| 30 | """Collect preceding /// doc-comment lines from siblings.""" |
| 31 | parent = node.parent |
| @@ -33,11 +34,11 @@ | |
| 33 | return "" |
| 34 | siblings = list(parent.children) |
| 35 | idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) |
| 36 | if idx <= 0: |
| 37 | return "" |
| 38 | lines = [] |
| 39 | for i in range(idx - 1, -1, -1): |
| 40 | sib = siblings[i] |
| 41 | raw = _node_text(sib, source) |
| 42 | if sib.type == "line_comment" and raw.startswith("///"): |
| 43 | lines.insert(0, raw.lstrip("/").strip()) |
| @@ -49,32 +50,43 @@ | |
| 49 | class RustParser(LanguageParser): |
| 50 | """Parses Rust source files into the navegador graph.""" |
| 51 | |
| 52 | def __init__(self) -> None: |
| 53 | from tree_sitter import Parser # type: ignore[import] |
| 54 | self._parser = Parser(_get_rust_language()) |
| 55 | |
| 56 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 57 | source = path.read_bytes() |
| 58 | tree = self._parser.parse(source) |
| 59 | rel_path = str(path.relative_to(repo_root)) |
| 60 | |
| 61 | store.create_node(NodeLabel.File, { |
| 62 | "name": path.name, |
| 63 | "path": rel_path, |
| 64 | "language": "rust", |
| 65 | "line_count": source.count(b"\n"), |
| 66 | }) |
| 67 | |
| 68 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 69 | self._walk(tree.root_node, source, rel_path, store, stats, impl_type=None) |
| 70 | return stats |
| 71 | |
| 72 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 73 | |
| 74 | def _walk(self, node, source: bytes, file_path: str, |
| 75 | store: GraphStore, stats: dict, impl_type: str | None) -> None: |
| 76 | if node.type == "function_item": |
| 77 | self._handle_function(node, source, file_path, store, stats, impl_type) |
| 78 | return |
| 79 | if node.type == "impl_item": |
| 80 | self._handle_impl(node, source, file_path, store, stats) |
| @@ -88,115 +100,149 @@ | |
| 88 | for child in node.children: |
| 89 | self._walk(child, source, file_path, store, stats, impl_type) |
| 90 | |
| 91 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 92 | |
| 93 | def _handle_function(self, node, source: bytes, file_path: str, |
| 94 | store: GraphStore, stats: dict, |
| 95 | impl_type: str | None) -> None: |
| 96 | name_node = node.child_by_field_name("name") |
| 97 | if not name_node: |
| 98 | return |
| 99 | name = _node_text(name_node, source) |
| 100 | docstring = _doc_comment(node, source) |
| 101 | label = NodeLabel.Method if impl_type else NodeLabel.Function |
| 102 | |
| 103 | store.create_node(label, { |
| 104 | "name": name, |
| 105 | "file_path": file_path, |
| 106 | "line_start": node.start_point[0] + 1, |
| 107 | "line_end": node.end_point[0] + 1, |
| 108 | "docstring": docstring, |
| 109 | "class_name": impl_type or "", |
| 110 | }) |
| 111 | |
| 112 | container_label = NodeLabel.Class if impl_type else NodeLabel.File |
| 113 | container_key = ( |
| 114 | {"name": impl_type, "file_path": file_path} |
| 115 | if impl_type else {"path": file_path} |
| 116 | ) |
| 117 | store.create_edge( |
| 118 | container_label, container_key, |
| 119 | EdgeType.CONTAINS, |
| 120 | label, {"name": name, "file_path": file_path}, |
| 121 | ) |
| 122 | stats["functions"] += 1 |
| 123 | stats["edges"] += 1 |
| 124 | |
| 125 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 126 | |
| 127 | def _handle_impl(self, node, source: bytes, file_path: str, |
| 128 | store: GraphStore, stats: dict) -> None: |
| 129 | type_node = node.child_by_field_name("type") |
| 130 | impl_type_name = _node_text(type_node, source) if type_node else "" |
| 131 | |
| 132 | body = node.child_by_field_name("body") |
| 133 | if body: |
| 134 | for child in body.children: |
| 135 | if child.type == "function_item": |
| 136 | self._handle_function(child, source, file_path, store, stats, |
| 137 | impl_type=impl_type_name or None) |
| 138 | |
| 139 | def _handle_type(self, node, source: bytes, file_path: str, |
| 140 | store: GraphStore, stats: dict) -> None: |
| 141 | name_node = node.child_by_field_name("name") |
| 142 | if not name_node: |
| 143 | return |
| 144 | name = _node_text(name_node, source) |
| 145 | kind = {"struct_item": "struct", "enum_item": "enum", |
| 146 | "trait_item": "trait"}.get(node.type, "") |
| 147 | docstring = _doc_comment(node, source) |
| 148 | |
| 149 | store.create_node(NodeLabel.Class, { |
| 150 | "name": name, |
| 151 | "file_path": file_path, |
| 152 | "line_start": node.start_point[0] + 1, |
| 153 | "line_end": node.end_point[0] + 1, |
| 154 | "docstring": f"{kind}: {docstring}".strip(": ") if kind else docstring, |
| 155 | }) |
| 156 | store.create_edge( |
| 157 | NodeLabel.File, {"path": file_path}, |
| 158 | EdgeType.CONTAINS, |
| 159 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 160 | ) |
| 161 | stats["classes"] += 1 |
| 162 | stats["edges"] += 1 |
| 163 | |
| 164 | def _handle_use(self, node, source: bytes, file_path: str, |
| 165 | store: GraphStore, stats: dict) -> None: |
| 166 | raw = _node_text(node, source) |
| 167 | module = raw.removeprefix("use ").removesuffix(";").strip() |
| 168 | store.create_node(NodeLabel.Import, { |
| 169 | "name": module, |
| 170 | "file_path": file_path, |
| 171 | "line_start": node.start_point[0] + 1, |
| 172 | "module": module, |
| 173 | }) |
| 174 | store.create_edge( |
| 175 | NodeLabel.File, {"path": file_path}, |
| 176 | EdgeType.IMPORTS, |
| 177 | NodeLabel.Import, {"name": module, "file_path": file_path}, |
| 178 | ) |
| 179 | stats["edges"] += 1 |
| 180 | |
| 181 | def _extract_calls(self, fn_node, source: bytes, file_path: str, |
| 182 | fn_name: str, fn_label: str, |
| 183 | store: GraphStore, stats: dict) -> None: |
| 184 | def walk(node): |
| 185 | if node.type == "call_expression": |
| 186 | func = node.child_by_field_name("function") |
| 187 | if func: |
| 188 | text = _node_text(func, source) |
| 189 | # Handle Foo::bar() and obj.method() |
| 190 | callee = text.replace("::", ".").split(".")[-1] |
| 191 | store.create_edge( |
| 192 | fn_label, {"name": fn_name, "file_path": file_path}, |
| 193 | EdgeType.CALLS, |
| 194 | NodeLabel.Function, {"name": callee, "file_path": file_path}, |
| 195 | ) |
| 196 | stats["edges"] += 1 |
| 197 | for child in node.children: |
| 198 | walk(child) |
| 199 | |
| 200 | body = fn_node.child_by_field_name("body") |
| 201 | if body: |
| 202 | walk(body) |
| 203 |
| --- navegador/ingestion/rust.py | |
| +++ navegador/ingestion/rust.py | |
| @@ -15,17 +15,18 @@ | |
| 15 | |
| 16 | def _get_rust_language(): |
| 17 | try: |
| 18 | import tree_sitter_rust as tsrust # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | |
| 21 | return Language(tsrust.language()) |
| 22 | except ImportError as e: |
| 23 | raise ImportError("Install tree-sitter-rust: pip install tree-sitter-rust") from e |
| 24 | |
| 25 | |
| 26 | def _node_text(node, source: bytes) -> str: |
| 27 | return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") |
| 28 | |
| 29 | |
| 30 | def _doc_comment(node, source: bytes) -> str: |
| 31 | """Collect preceding /// doc-comment lines from siblings.""" |
| 32 | parent = node.parent |
| @@ -33,11 +34,11 @@ | |
| 34 | return "" |
| 35 | siblings = list(parent.children) |
| 36 | idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) |
| 37 | if idx <= 0: |
| 38 | return "" |
| 39 | lines: list[str] = [] |
| 40 | for i in range(idx - 1, -1, -1): |
| 41 | sib = siblings[i] |
| 42 | raw = _node_text(sib, source) |
| 43 | if sib.type == "line_comment" and raw.startswith("///"): |
| 44 | lines.insert(0, raw.lstrip("/").strip()) |
| @@ -49,32 +50,43 @@ | |
| 50 | class RustParser(LanguageParser): |
| 51 | """Parses Rust source files into the navegador graph.""" |
| 52 | |
| 53 | def __init__(self) -> None: |
| 54 | from tree_sitter import Parser # type: ignore[import] |
| 55 | |
| 56 | self._parser = Parser(_get_rust_language()) |
| 57 | |
| 58 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 59 | source = path.read_bytes() |
| 60 | tree = self._parser.parse(source) |
| 61 | rel_path = str(path.relative_to(repo_root)) |
| 62 | |
| 63 | store.create_node( |
| 64 | NodeLabel.File, |
| 65 | { |
| 66 | "name": path.name, |
| 67 | "path": rel_path, |
| 68 | "language": "rust", |
| 69 | "line_count": source.count(b"\n"), |
| 70 | }, |
| 71 | ) |
| 72 | |
| 73 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 74 | self._walk(tree.root_node, source, rel_path, store, stats, impl_type=None) |
| 75 | return stats |
| 76 | |
| 77 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 78 | |
| 79 | def _walk( |
| 80 | self, |
| 81 | node, |
| 82 | source: bytes, |
| 83 | file_path: str, |
| 84 | store: GraphStore, |
| 85 | stats: dict, |
| 86 | impl_type: str | None, |
| 87 | ) -> None: |
| 88 | if node.type == "function_item": |
| 89 | self._handle_function(node, source, file_path, store, stats, impl_type) |
| 90 | return |
| 91 | if node.type == "impl_item": |
| 92 | self._handle_impl(node, source, file_path, store, stats) |
| @@ -88,115 +100,149 @@ | |
| 100 | for child in node.children: |
| 101 | self._walk(child, source, file_path, store, stats, impl_type) |
| 102 | |
| 103 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 104 | |
| 105 | def _handle_function( |
| 106 | self, |
| 107 | node, |
| 108 | source: bytes, |
| 109 | file_path: str, |
| 110 | store: GraphStore, |
| 111 | stats: dict, |
| 112 | impl_type: str | None, |
| 113 | ) -> None: |
| 114 | name_node = node.child_by_field_name("name") |
| 115 | if not name_node: |
| 116 | return |
| 117 | name = _node_text(name_node, source) |
| 118 | docstring = _doc_comment(node, source) |
| 119 | label = NodeLabel.Method if impl_type else NodeLabel.Function |
| 120 | |
| 121 | store.create_node( |
| 122 | label, |
| 123 | { |
| 124 | "name": name, |
| 125 | "file_path": file_path, |
| 126 | "line_start": node.start_point[0] + 1, |
| 127 | "line_end": node.end_point[0] + 1, |
| 128 | "docstring": docstring, |
| 129 | "class_name": impl_type or "", |
| 130 | }, |
| 131 | ) |
| 132 | |
| 133 | container_label = NodeLabel.Class if impl_type else NodeLabel.File |
| 134 | container_key = ( |
| 135 | {"name": impl_type, "file_path": file_path} if impl_type else {"path": file_path} |
| 136 | ) |
| 137 | store.create_edge( |
| 138 | container_label, |
| 139 | container_key, |
| 140 | EdgeType.CONTAINS, |
| 141 | label, |
| 142 | {"name": name, "file_path": file_path}, |
| 143 | ) |
| 144 | stats["functions"] += 1 |
| 145 | stats["edges"] += 1 |
| 146 | |
| 147 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 148 | |
| 149 | def _handle_impl( |
| 150 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 151 | ) -> None: |
| 152 | type_node = node.child_by_field_name("type") |
| 153 | impl_type_name = _node_text(type_node, source) if type_node else "" |
| 154 | |
| 155 | body = node.child_by_field_name("body") |
| 156 | if body: |
| 157 | for child in body.children: |
| 158 | if child.type == "function_item": |
| 159 | self._handle_function( |
| 160 | child, source, file_path, store, stats, impl_type=impl_type_name or None |
| 161 | ) |
| 162 | |
| 163 | def _handle_type( |
| 164 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 165 | ) -> None: |
| 166 | name_node = node.child_by_field_name("name") |
| 167 | if not name_node: |
| 168 | return |
| 169 | name = _node_text(name_node, source) |
| 170 | kind = {"struct_item": "struct", "enum_item": "enum", "trait_item": "trait"}.get( |
| 171 | node.type, "" |
| 172 | ) |
| 173 | docstring = _doc_comment(node, source) |
| 174 | |
| 175 | store.create_node( |
| 176 | NodeLabel.Class, |
| 177 | { |
| 178 | "name": name, |
| 179 | "file_path": file_path, |
| 180 | "line_start": node.start_point[0] + 1, |
| 181 | "line_end": node.end_point[0] + 1, |
| 182 | "docstring": f"{kind}: {docstring}".strip(": ") if kind else docstring, |
| 183 | }, |
| 184 | ) |
| 185 | store.create_edge( |
| 186 | NodeLabel.File, |
| 187 | {"path": file_path}, |
| 188 | EdgeType.CONTAINS, |
| 189 | NodeLabel.Class, |
| 190 | {"name": name, "file_path": file_path}, |
| 191 | ) |
| 192 | stats["classes"] += 1 |
| 193 | stats["edges"] += 1 |
| 194 | |
| 195 | def _handle_use( |
| 196 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 197 | ) -> None: |
| 198 | raw = _node_text(node, source) |
| 199 | module = raw.removeprefix("use ").removesuffix(";").strip() |
| 200 | store.create_node( |
| 201 | NodeLabel.Import, |
| 202 | { |
| 203 | "name": module, |
| 204 | "file_path": file_path, |
| 205 | "line_start": node.start_point[0] + 1, |
| 206 | "module": module, |
| 207 | }, |
| 208 | ) |
| 209 | store.create_edge( |
| 210 | NodeLabel.File, |
| 211 | {"path": file_path}, |
| 212 | EdgeType.IMPORTS, |
| 213 | NodeLabel.Import, |
| 214 | {"name": module, "file_path": file_path}, |
| 215 | ) |
| 216 | stats["edges"] += 1 |
| 217 | |
| 218 | def _extract_calls( |
| 219 | self, |
| 220 | fn_node, |
| 221 | source: bytes, |
| 222 | file_path: str, |
| 223 | fn_name: str, |
| 224 | fn_label: str, |
| 225 | store: GraphStore, |
| 226 | stats: dict, |
| 227 | ) -> None: |
| 228 | def walk(node): |
| 229 | if node.type == "call_expression": |
| 230 | func = node.child_by_field_name("function") |
| 231 | if func: |
| 232 | text = _node_text(func, source) |
| 233 | # Handle Foo::bar() and obj.method() |
| 234 | callee = text.replace("::", ".").split(".")[-1] |
| 235 | store.create_edge( |
| 236 | fn_label, |
| 237 | {"name": fn_name, "file_path": file_path}, |
| 238 | EdgeType.CALLS, |
| 239 | NodeLabel.Function, |
| 240 | {"name": callee, "file_path": file_path}, |
| 241 | ) |
| 242 | stats["edges"] += 1 |
| 243 | for child in node.children: |
| 244 | walk(child) |
| 245 | |
| 246 | body = fn_node.child_by_field_name("body") |
| 247 | if body: |
| 248 | walk(body) |
| 249 |
+148
-94
| --- navegador/ingestion/typescript.py | ||
| +++ navegador/ingestion/typescript.py | ||
| @@ -20,23 +20,25 @@ | ||
| 20 | 20 | def _get_ts_language(language: str): |
| 21 | 21 | try: |
| 22 | 22 | if language == "typescript": |
| 23 | 23 | import tree_sitter_typescript as tsts # type: ignore[import] |
| 24 | 24 | from tree_sitter import Language |
| 25 | + | |
| 25 | 26 | return Language(tsts.language_typescript()) |
| 26 | 27 | else: |
| 27 | 28 | import tree_sitter_javascript as tsjs # type: ignore[import] |
| 28 | 29 | from tree_sitter import Language |
| 30 | + | |
| 29 | 31 | return Language(tsjs.language()) |
| 30 | 32 | except ImportError as e: |
| 31 | 33 | raise ImportError( |
| 32 | 34 | f"Install tree-sitter-{language}: pip install tree-sitter-{language}" |
| 33 | 35 | ) from e |
| 34 | 36 | |
| 35 | 37 | |
| 36 | 38 | def _node_text(node, source: bytes) -> str: |
| 37 | - return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") | |
| 39 | + return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") | |
| 38 | 40 | |
| 39 | 41 | |
| 40 | 42 | def _jsdoc(node, source: bytes) -> str: |
| 41 | 43 | """Return the JSDoc comment (/** ... */) preceding a node, if any.""" |
| 42 | 44 | parent = node.parent |
| @@ -57,33 +59,44 @@ | ||
| 57 | 59 | class TypeScriptParser(LanguageParser): |
| 58 | 60 | """Parses TypeScript/JavaScript source files into the navegador graph.""" |
| 59 | 61 | |
| 60 | 62 | def __init__(self, language: str = "typescript") -> None: |
| 61 | 63 | from tree_sitter import Parser # type: ignore[import] |
| 64 | + | |
| 62 | 65 | self._parser = Parser(_get_ts_language(language)) |
| 63 | 66 | self._language = language |
| 64 | 67 | |
| 65 | 68 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 66 | 69 | source = path.read_bytes() |
| 67 | 70 | tree = self._parser.parse(source) |
| 68 | 71 | rel_path = str(path.relative_to(repo_root)) |
| 69 | 72 | |
| 70 | - store.create_node(NodeLabel.File, { | |
| 71 | - "name": path.name, | |
| 72 | - "path": rel_path, | |
| 73 | - "language": self._language, | |
| 74 | - "line_count": source.count(b"\n"), | |
| 75 | - }) | |
| 73 | + store.create_node( | |
| 74 | + NodeLabel.File, | |
| 75 | + { | |
| 76 | + "name": path.name, | |
| 77 | + "path": rel_path, | |
| 78 | + "language": self._language, | |
| 79 | + "line_count": source.count(b"\n"), | |
| 80 | + }, | |
| 81 | + ) | |
| 76 | 82 | |
| 77 | 83 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 78 | 84 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 79 | 85 | return stats |
| 80 | 86 | |
| 81 | 87 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 82 | 88 | |
| 83 | - def _walk(self, node, source: bytes, file_path: str, | |
| 84 | - store: GraphStore, stats: dict, class_name: str | None) -> None: | |
| 89 | + def _walk( | |
| 90 | + self, | |
| 91 | + node, | |
| 92 | + source: bytes, | |
| 93 | + file_path: str, | |
| 94 | + store: GraphStore, | |
| 95 | + stats: dict, | |
| 96 | + class_name: str | None, | |
| 97 | + ) -> None: | |
| 85 | 98 | if node.type in ("class_declaration", "abstract_class_declaration"): |
| 86 | 99 | self._handle_class(node, source, file_path, store, stats) |
| 87 | 100 | return |
| 88 | 101 | if node.type in ("interface_declaration", "type_alias_declaration"): |
| 89 | 102 | self._handle_interface(node, source, file_path, store, stats) |
| @@ -110,31 +123,35 @@ | ||
| 110 | 123 | for child in node.children: |
| 111 | 124 | self._walk(child, source, file_path, store, stats, class_name) |
| 112 | 125 | |
| 113 | 126 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 114 | 127 | |
| 115 | - def _handle_class(self, node, source: bytes, file_path: str, | |
| 116 | - store: GraphStore, stats: dict) -> None: | |
| 117 | - name_node = next( | |
| 118 | - (c for c in node.children if c.type == "type_identifier"), None | |
| 119 | - ) | |
| 128 | + def _handle_class( | |
| 129 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 130 | + ) -> None: | |
| 131 | + name_node = next((c for c in node.children if c.type == "type_identifier"), None) | |
| 120 | 132 | if not name_node: |
| 121 | 133 | return |
| 122 | 134 | name = _node_text(name_node, source) |
| 123 | 135 | docstring = _jsdoc(node, source) |
| 124 | 136 | |
| 125 | - store.create_node(NodeLabel.Class, { | |
| 126 | - "name": name, | |
| 127 | - "file_path": file_path, | |
| 128 | - "line_start": node.start_point[0] + 1, | |
| 129 | - "line_end": node.end_point[0] + 1, | |
| 130 | - "docstring": docstring, | |
| 131 | - }) | |
| 137 | + store.create_node( | |
| 138 | + NodeLabel.Class, | |
| 139 | + { | |
| 140 | + "name": name, | |
| 141 | + "file_path": file_path, | |
| 142 | + "line_start": node.start_point[0] + 1, | |
| 143 | + "line_end": node.end_point[0] + 1, | |
| 144 | + "docstring": docstring, | |
| 145 | + }, | |
| 146 | + ) | |
| 132 | 147 | store.create_edge( |
| 133 | - NodeLabel.File, {"path": file_path}, | |
| 148 | + NodeLabel.File, | |
| 149 | + {"path": file_path}, | |
| 134 | 150 | EdgeType.CONTAINS, |
| 135 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 151 | + NodeLabel.Class, | |
| 152 | + {"name": name, "file_path": file_path}, | |
| 136 | 153 | ) |
| 137 | 154 | stats["classes"] += 1 |
| 138 | 155 | stats["edges"] += 1 |
| 139 | 156 | |
| 140 | 157 | # Inheritance: extends clause |
| @@ -144,172 +161,209 @@ | ||
| 144 | 161 | if child.type == "extends_clause": |
| 145 | 162 | for c in child.children: |
| 146 | 163 | if c.type == "identifier": |
| 147 | 164 | parent_name = _node_text(c, source) |
| 148 | 165 | store.create_edge( |
| 149 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 166 | + NodeLabel.Class, | |
| 167 | + {"name": name, "file_path": file_path}, | |
| 150 | 168 | EdgeType.INHERITS, |
| 151 | - NodeLabel.Class, {"name": parent_name, "file_path": file_path}, | |
| 169 | + NodeLabel.Class, | |
| 170 | + {"name": parent_name, "file_path": file_path}, | |
| 152 | 171 | ) |
| 153 | 172 | stats["edges"] += 1 |
| 154 | 173 | |
| 155 | 174 | body = next((c for c in node.children if c.type == "class_body"), None) |
| 156 | 175 | if body: |
| 157 | 176 | for child in body.children: |
| 158 | 177 | if child.type == "method_definition": |
| 159 | - self._handle_function(child, source, file_path, store, stats, | |
| 160 | - class_name=name) | |
| 161 | - | |
| 162 | - def _handle_interface(self, node, source: bytes, file_path: str, | |
| 163 | - store: GraphStore, stats: dict) -> None: | |
| 164 | - name_node = next( | |
| 165 | - (c for c in node.children if c.type == "type_identifier"), None | |
| 166 | - ) | |
| 178 | + self._handle_function(child, source, file_path, store, stats, class_name=name) | |
| 179 | + | |
| 180 | + def _handle_interface( | |
| 181 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 182 | + ) -> None: | |
| 183 | + name_node = next((c for c in node.children if c.type == "type_identifier"), None) | |
| 167 | 184 | if not name_node: |
| 168 | 185 | return |
| 169 | 186 | name = _node_text(name_node, source) |
| 170 | 187 | docstring = _jsdoc(node, source) |
| 171 | 188 | kind = "interface" if node.type == "interface_declaration" else "type" |
| 172 | 189 | |
| 173 | - store.create_node(NodeLabel.Class, { | |
| 174 | - "name": name, | |
| 175 | - "file_path": file_path, | |
| 176 | - "line_start": node.start_point[0] + 1, | |
| 177 | - "line_end": node.end_point[0] + 1, | |
| 178 | - "docstring": f"{kind}: {docstring}".strip(": "), | |
| 179 | - }) | |
| 190 | + store.create_node( | |
| 191 | + NodeLabel.Class, | |
| 192 | + { | |
| 193 | + "name": name, | |
| 194 | + "file_path": file_path, | |
| 195 | + "line_start": node.start_point[0] + 1, | |
| 196 | + "line_end": node.end_point[0] + 1, | |
| 197 | + "docstring": f"{kind}: {docstring}".strip(": "), | |
| 198 | + }, | |
| 199 | + ) | |
| 180 | 200 | store.create_edge( |
| 181 | - NodeLabel.File, {"path": file_path}, | |
| 201 | + NodeLabel.File, | |
| 202 | + {"path": file_path}, | |
| 182 | 203 | EdgeType.CONTAINS, |
| 183 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 204 | + NodeLabel.Class, | |
| 205 | + {"name": name, "file_path": file_path}, | |
| 184 | 206 | ) |
| 185 | 207 | stats["classes"] += 1 |
| 186 | 208 | stats["edges"] += 1 |
| 187 | 209 | |
| 188 | - def _handle_function(self, node, source: bytes, file_path: str, | |
| 189 | - store: GraphStore, stats: dict, | |
| 190 | - class_name: str | None) -> None: | |
| 210 | + def _handle_function( | |
| 211 | + self, | |
| 212 | + node, | |
| 213 | + source: bytes, | |
| 214 | + file_path: str, | |
| 215 | + store: GraphStore, | |
| 216 | + stats: dict, | |
| 217 | + class_name: str | None, | |
| 218 | + ) -> None: | |
| 191 | 219 | name_node = next( |
| 192 | - (c for c in node.children | |
| 193 | - if c.type in ("identifier", "property_identifier")), None | |
| 220 | + (c for c in node.children if c.type in ("identifier", "property_identifier")), None | |
| 194 | 221 | ) |
| 195 | 222 | if not name_node: |
| 196 | 223 | return |
| 197 | 224 | name = _node_text(name_node, source) |
| 198 | 225 | if name in ("constructor", "get", "set", "static", "async"): |
| 199 | 226 | # These are keywords, not useful names — look for next identifier |
| 200 | 227 | name_node = next( |
| 201 | - (c for c in node.children | |
| 202 | - if c.type in ("identifier", "property_identifier") and c != name_node), | |
| 228 | + ( | |
| 229 | + c | |
| 230 | + for c in node.children | |
| 231 | + if c.type in ("identifier", "property_identifier") and c != name_node | |
| 232 | + ), | |
| 203 | 233 | None, |
| 204 | 234 | ) |
| 205 | 235 | if not name_node: |
| 206 | 236 | return |
| 207 | 237 | name = _node_text(name_node, source) |
| 208 | 238 | |
| 209 | 239 | docstring = _jsdoc(node, source) |
| 210 | 240 | label = NodeLabel.Method if class_name else NodeLabel.Function |
| 211 | 241 | |
| 212 | - store.create_node(label, { | |
| 213 | - "name": name, | |
| 214 | - "file_path": file_path, | |
| 215 | - "line_start": node.start_point[0] + 1, | |
| 216 | - "line_end": node.end_point[0] + 1, | |
| 217 | - "docstring": docstring, | |
| 218 | - "class_name": class_name or "", | |
| 219 | - }) | |
| 242 | + store.create_node( | |
| 243 | + label, | |
| 244 | + { | |
| 245 | + "name": name, | |
| 246 | + "file_path": file_path, | |
| 247 | + "line_start": node.start_point[0] + 1, | |
| 248 | + "line_end": node.end_point[0] + 1, | |
| 249 | + "docstring": docstring, | |
| 250 | + "class_name": class_name or "", | |
| 251 | + }, | |
| 252 | + ) | |
| 220 | 253 | |
| 221 | 254 | container_label = NodeLabel.Class if class_name else NodeLabel.File |
| 222 | 255 | container_key = ( |
| 223 | - {"name": class_name, "file_path": file_path} | |
| 224 | - if class_name else {"path": file_path} | |
| 256 | + {"name": class_name, "file_path": file_path} if class_name else {"path": file_path} | |
| 225 | 257 | ) |
| 226 | 258 | store.create_edge( |
| 227 | - container_label, container_key, | |
| 259 | + container_label, | |
| 260 | + container_key, | |
| 228 | 261 | EdgeType.CONTAINS, |
| 229 | - label, {"name": name, "file_path": file_path}, | |
| 262 | + label, | |
| 263 | + {"name": name, "file_path": file_path}, | |
| 230 | 264 | ) |
| 231 | 265 | stats["functions"] += 1 |
| 232 | 266 | stats["edges"] += 1 |
| 233 | 267 | |
| 234 | 268 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 235 | 269 | |
| 236 | - def _handle_lexical(self, node, source: bytes, file_path: str, | |
| 237 | - store: GraphStore, stats: dict) -> None: | |
| 270 | + def _handle_lexical( | |
| 271 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 272 | + ) -> None: | |
| 238 | 273 | """Handle: const foo = () => {} and const bar = function() {}""" |
| 239 | 274 | for child in node.children: |
| 240 | 275 | if child.type != "variable_declarator": |
| 241 | 276 | continue |
| 242 | 277 | name_node = child.child_by_field_name("name") |
| 243 | 278 | value_node = child.child_by_field_name("value") |
| 244 | 279 | if not name_node or not value_node: |
| 245 | 280 | continue |
| 246 | - if value_node.type not in ("arrow_function", "function_expression", | |
| 247 | - "function"): | |
| 281 | + if value_node.type not in ("arrow_function", "function_expression", "function"): | |
| 248 | 282 | continue |
| 249 | 283 | name = _node_text(name_node, source) |
| 250 | 284 | docstring = _jsdoc(node, source) |
| 251 | 285 | |
| 252 | - store.create_node(NodeLabel.Function, { | |
| 253 | - "name": name, | |
| 254 | - "file_path": file_path, | |
| 255 | - "line_start": node.start_point[0] + 1, | |
| 256 | - "line_end": node.end_point[0] + 1, | |
| 257 | - "docstring": docstring, | |
| 258 | - "class_name": "", | |
| 259 | - }) | |
| 286 | + store.create_node( | |
| 287 | + NodeLabel.Function, | |
| 288 | + { | |
| 289 | + "name": name, | |
| 290 | + "file_path": file_path, | |
| 291 | + "line_start": node.start_point[0] + 1, | |
| 292 | + "line_end": node.end_point[0] + 1, | |
| 293 | + "docstring": docstring, | |
| 294 | + "class_name": "", | |
| 295 | + }, | |
| 296 | + ) | |
| 260 | 297 | store.create_edge( |
| 261 | - NodeLabel.File, {"path": file_path}, | |
| 298 | + NodeLabel.File, | |
| 299 | + {"path": file_path}, | |
| 262 | 300 | EdgeType.CONTAINS, |
| 263 | - NodeLabel.Function, {"name": name, "file_path": file_path}, | |
| 301 | + NodeLabel.Function, | |
| 302 | + {"name": name, "file_path": file_path}, | |
| 264 | 303 | ) |
| 265 | 304 | stats["functions"] += 1 |
| 266 | 305 | stats["edges"] += 1 |
| 267 | 306 | |
| 268 | - self._extract_calls(value_node, source, file_path, name, | |
| 269 | - NodeLabel.Function, store, stats) | |
| 307 | + self._extract_calls( | |
| 308 | + value_node, source, file_path, name, NodeLabel.Function, store, stats | |
| 309 | + ) | |
| 270 | 310 | |
| 271 | - def _handle_import(self, node, source: bytes, file_path: str, | |
| 272 | - store: GraphStore, stats: dict) -> None: | |
| 311 | + def _handle_import( | |
| 312 | + self, node, source: bytes, file_path: str, store: GraphStore, stats: dict | |
| 313 | + ) -> None: | |
| 273 | 314 | for child in node.children: |
| 274 | 315 | if child.type == "string": |
| 275 | 316 | module = _node_text(child, source).strip("'\"") |
| 276 | - store.create_node(NodeLabel.Import, { | |
| 277 | - "name": module, | |
| 278 | - "file_path": file_path, | |
| 279 | - "line_start": node.start_point[0] + 1, | |
| 280 | - "module": module, | |
| 281 | - }) | |
| 317 | + store.create_node( | |
| 318 | + NodeLabel.Import, | |
| 319 | + { | |
| 320 | + "name": module, | |
| 321 | + "file_path": file_path, | |
| 322 | + "line_start": node.start_point[0] + 1, | |
| 323 | + "module": module, | |
| 324 | + }, | |
| 325 | + ) | |
| 282 | 326 | store.create_edge( |
| 283 | - NodeLabel.File, {"path": file_path}, | |
| 327 | + NodeLabel.File, | |
| 328 | + {"path": file_path}, | |
| 284 | 329 | EdgeType.IMPORTS, |
| 285 | - NodeLabel.Import, {"name": module, "file_path": file_path}, | |
| 330 | + NodeLabel.Import, | |
| 331 | + {"name": module, "file_path": file_path}, | |
| 286 | 332 | ) |
| 287 | 333 | stats["edges"] += 1 |
| 288 | 334 | break |
| 289 | 335 | |
| 290 | - def _extract_calls(self, fn_node, source: bytes, file_path: str, | |
| 291 | - fn_name: str, fn_label: str, | |
| 292 | - store: GraphStore, stats: dict) -> None: | |
| 336 | + def _extract_calls( | |
| 337 | + self, | |
| 338 | + fn_node, | |
| 339 | + source: bytes, | |
| 340 | + file_path: str, | |
| 341 | + fn_name: str, | |
| 342 | + fn_label: str, | |
| 343 | + store: GraphStore, | |
| 344 | + stats: dict, | |
| 345 | + ) -> None: | |
| 293 | 346 | def walk(node): |
| 294 | 347 | if node.type == "call_expression": |
| 295 | 348 | func = node.child_by_field_name("function") |
| 296 | 349 | if func: |
| 297 | 350 | text = _node_text(func, source) |
| 298 | 351 | callee = text.split(".")[-1] |
| 299 | 352 | store.create_edge( |
| 300 | - fn_label, {"name": fn_name, "file_path": file_path}, | |
| 353 | + fn_label, | |
| 354 | + {"name": fn_name, "file_path": file_path}, | |
| 301 | 355 | EdgeType.CALLS, |
| 302 | - NodeLabel.Function, {"name": callee, "file_path": file_path}, | |
| 356 | + NodeLabel.Function, | |
| 357 | + {"name": callee, "file_path": file_path}, | |
| 303 | 358 | ) |
| 304 | 359 | stats["edges"] += 1 |
| 305 | 360 | for child in node.children: |
| 306 | 361 | walk(child) |
| 307 | 362 | |
| 308 | 363 | # Body is statement_block for functions, expression for arrow fns |
| 309 | 364 | body = next( |
| 310 | - (c for c in fn_node.children | |
| 311 | - if c.type in ("statement_block", "expression_statement")), | |
| 365 | + (c for c in fn_node.children if c.type in ("statement_block", "expression_statement")), | |
| 312 | 366 | None, |
| 313 | 367 | ) |
| 314 | 368 | if body: |
| 315 | 369 | walk(body) |
| 316 | 370 |
| --- navegador/ingestion/typescript.py | |
| +++ navegador/ingestion/typescript.py | |
| @@ -20,23 +20,25 @@ | |
| 20 | def _get_ts_language(language: str): |
| 21 | try: |
| 22 | if language == "typescript": |
| 23 | import tree_sitter_typescript as tsts # type: ignore[import] |
| 24 | from tree_sitter import Language |
| 25 | return Language(tsts.language_typescript()) |
| 26 | else: |
| 27 | import tree_sitter_javascript as tsjs # type: ignore[import] |
| 28 | from tree_sitter import Language |
| 29 | return Language(tsjs.language()) |
| 30 | except ImportError as e: |
| 31 | raise ImportError( |
| 32 | f"Install tree-sitter-{language}: pip install tree-sitter-{language}" |
| 33 | ) from e |
| 34 | |
| 35 | |
| 36 | def _node_text(node, source: bytes) -> str: |
| 37 | return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") |
| 38 | |
| 39 | |
| 40 | def _jsdoc(node, source: bytes) -> str: |
| 41 | """Return the JSDoc comment (/** ... */) preceding a node, if any.""" |
| 42 | parent = node.parent |
| @@ -57,33 +59,44 @@ | |
| 57 | class TypeScriptParser(LanguageParser): |
| 58 | """Parses TypeScript/JavaScript source files into the navegador graph.""" |
| 59 | |
| 60 | def __init__(self, language: str = "typescript") -> None: |
| 61 | from tree_sitter import Parser # type: ignore[import] |
| 62 | self._parser = Parser(_get_ts_language(language)) |
| 63 | self._language = language |
| 64 | |
| 65 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 66 | source = path.read_bytes() |
| 67 | tree = self._parser.parse(source) |
| 68 | rel_path = str(path.relative_to(repo_root)) |
| 69 | |
| 70 | store.create_node(NodeLabel.File, { |
| 71 | "name": path.name, |
| 72 | "path": rel_path, |
| 73 | "language": self._language, |
| 74 | "line_count": source.count(b"\n"), |
| 75 | }) |
| 76 | |
| 77 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 78 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 79 | return stats |
| 80 | |
| 81 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 82 | |
| 83 | def _walk(self, node, source: bytes, file_path: str, |
| 84 | store: GraphStore, stats: dict, class_name: str | None) -> None: |
| 85 | if node.type in ("class_declaration", "abstract_class_declaration"): |
| 86 | self._handle_class(node, source, file_path, store, stats) |
| 87 | return |
| 88 | if node.type in ("interface_declaration", "type_alias_declaration"): |
| 89 | self._handle_interface(node, source, file_path, store, stats) |
| @@ -110,31 +123,35 @@ | |
| 110 | for child in node.children: |
| 111 | self._walk(child, source, file_path, store, stats, class_name) |
| 112 | |
| 113 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 114 | |
| 115 | def _handle_class(self, node, source: bytes, file_path: str, |
| 116 | store: GraphStore, stats: dict) -> None: |
| 117 | name_node = next( |
| 118 | (c for c in node.children if c.type == "type_identifier"), None |
| 119 | ) |
| 120 | if not name_node: |
| 121 | return |
| 122 | name = _node_text(name_node, source) |
| 123 | docstring = _jsdoc(node, source) |
| 124 | |
| 125 | store.create_node(NodeLabel.Class, { |
| 126 | "name": name, |
| 127 | "file_path": file_path, |
| 128 | "line_start": node.start_point[0] + 1, |
| 129 | "line_end": node.end_point[0] + 1, |
| 130 | "docstring": docstring, |
| 131 | }) |
| 132 | store.create_edge( |
| 133 | NodeLabel.File, {"path": file_path}, |
| 134 | EdgeType.CONTAINS, |
| 135 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 136 | ) |
| 137 | stats["classes"] += 1 |
| 138 | stats["edges"] += 1 |
| 139 | |
| 140 | # Inheritance: extends clause |
| @@ -144,172 +161,209 @@ | |
| 144 | if child.type == "extends_clause": |
| 145 | for c in child.children: |
| 146 | if c.type == "identifier": |
| 147 | parent_name = _node_text(c, source) |
| 148 | store.create_edge( |
| 149 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 150 | EdgeType.INHERITS, |
| 151 | NodeLabel.Class, {"name": parent_name, "file_path": file_path}, |
| 152 | ) |
| 153 | stats["edges"] += 1 |
| 154 | |
| 155 | body = next((c for c in node.children if c.type == "class_body"), None) |
| 156 | if body: |
| 157 | for child in body.children: |
| 158 | if child.type == "method_definition": |
| 159 | self._handle_function(child, source, file_path, store, stats, |
| 160 | class_name=name) |
| 161 | |
| 162 | def _handle_interface(self, node, source: bytes, file_path: str, |
| 163 | store: GraphStore, stats: dict) -> None: |
| 164 | name_node = next( |
| 165 | (c for c in node.children if c.type == "type_identifier"), None |
| 166 | ) |
| 167 | if not name_node: |
| 168 | return |
| 169 | name = _node_text(name_node, source) |
| 170 | docstring = _jsdoc(node, source) |
| 171 | kind = "interface" if node.type == "interface_declaration" else "type" |
| 172 | |
| 173 | store.create_node(NodeLabel.Class, { |
| 174 | "name": name, |
| 175 | "file_path": file_path, |
| 176 | "line_start": node.start_point[0] + 1, |
| 177 | "line_end": node.end_point[0] + 1, |
| 178 | "docstring": f"{kind}: {docstring}".strip(": "), |
| 179 | }) |
| 180 | store.create_edge( |
| 181 | NodeLabel.File, {"path": file_path}, |
| 182 | EdgeType.CONTAINS, |
| 183 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 184 | ) |
| 185 | stats["classes"] += 1 |
| 186 | stats["edges"] += 1 |
| 187 | |
| 188 | def _handle_function(self, node, source: bytes, file_path: str, |
| 189 | store: GraphStore, stats: dict, |
| 190 | class_name: str | None) -> None: |
| 191 | name_node = next( |
| 192 | (c for c in node.children |
| 193 | if c.type in ("identifier", "property_identifier")), None |
| 194 | ) |
| 195 | if not name_node: |
| 196 | return |
| 197 | name = _node_text(name_node, source) |
| 198 | if name in ("constructor", "get", "set", "static", "async"): |
| 199 | # These are keywords, not useful names — look for next identifier |
| 200 | name_node = next( |
| 201 | (c for c in node.children |
| 202 | if c.type in ("identifier", "property_identifier") and c != name_node), |
| 203 | None, |
| 204 | ) |
| 205 | if not name_node: |
| 206 | return |
| 207 | name = _node_text(name_node, source) |
| 208 | |
| 209 | docstring = _jsdoc(node, source) |
| 210 | label = NodeLabel.Method if class_name else NodeLabel.Function |
| 211 | |
| 212 | store.create_node(label, { |
| 213 | "name": name, |
| 214 | "file_path": file_path, |
| 215 | "line_start": node.start_point[0] + 1, |
| 216 | "line_end": node.end_point[0] + 1, |
| 217 | "docstring": docstring, |
| 218 | "class_name": class_name or "", |
| 219 | }) |
| 220 | |
| 221 | container_label = NodeLabel.Class if class_name else NodeLabel.File |
| 222 | container_key = ( |
| 223 | {"name": class_name, "file_path": file_path} |
| 224 | if class_name else {"path": file_path} |
| 225 | ) |
| 226 | store.create_edge( |
| 227 | container_label, container_key, |
| 228 | EdgeType.CONTAINS, |
| 229 | label, {"name": name, "file_path": file_path}, |
| 230 | ) |
| 231 | stats["functions"] += 1 |
| 232 | stats["edges"] += 1 |
| 233 | |
| 234 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 235 | |
| 236 | def _handle_lexical(self, node, source: bytes, file_path: str, |
| 237 | store: GraphStore, stats: dict) -> None: |
| 238 | """Handle: const foo = () => {} and const bar = function() {}""" |
| 239 | for child in node.children: |
| 240 | if child.type != "variable_declarator": |
| 241 | continue |
| 242 | name_node = child.child_by_field_name("name") |
| 243 | value_node = child.child_by_field_name("value") |
| 244 | if not name_node or not value_node: |
| 245 | continue |
| 246 | if value_node.type not in ("arrow_function", "function_expression", |
| 247 | "function"): |
| 248 | continue |
| 249 | name = _node_text(name_node, source) |
| 250 | docstring = _jsdoc(node, source) |
| 251 | |
| 252 | store.create_node(NodeLabel.Function, { |
| 253 | "name": name, |
| 254 | "file_path": file_path, |
| 255 | "line_start": node.start_point[0] + 1, |
| 256 | "line_end": node.end_point[0] + 1, |
| 257 | "docstring": docstring, |
| 258 | "class_name": "", |
| 259 | }) |
| 260 | store.create_edge( |
| 261 | NodeLabel.File, {"path": file_path}, |
| 262 | EdgeType.CONTAINS, |
| 263 | NodeLabel.Function, {"name": name, "file_path": file_path}, |
| 264 | ) |
| 265 | stats["functions"] += 1 |
| 266 | stats["edges"] += 1 |
| 267 | |
| 268 | self._extract_calls(value_node, source, file_path, name, |
| 269 | NodeLabel.Function, store, stats) |
| 270 | |
| 271 | def _handle_import(self, node, source: bytes, file_path: str, |
| 272 | store: GraphStore, stats: dict) -> None: |
| 273 | for child in node.children: |
| 274 | if child.type == "string": |
| 275 | module = _node_text(child, source).strip("'\"") |
| 276 | store.create_node(NodeLabel.Import, { |
| 277 | "name": module, |
| 278 | "file_path": file_path, |
| 279 | "line_start": node.start_point[0] + 1, |
| 280 | "module": module, |
| 281 | }) |
| 282 | store.create_edge( |
| 283 | NodeLabel.File, {"path": file_path}, |
| 284 | EdgeType.IMPORTS, |
| 285 | NodeLabel.Import, {"name": module, "file_path": file_path}, |
| 286 | ) |
| 287 | stats["edges"] += 1 |
| 288 | break |
| 289 | |
| 290 | def _extract_calls(self, fn_node, source: bytes, file_path: str, |
| 291 | fn_name: str, fn_label: str, |
| 292 | store: GraphStore, stats: dict) -> None: |
| 293 | def walk(node): |
| 294 | if node.type == "call_expression": |
| 295 | func = node.child_by_field_name("function") |
| 296 | if func: |
| 297 | text = _node_text(func, source) |
| 298 | callee = text.split(".")[-1] |
| 299 | store.create_edge( |
| 300 | fn_label, {"name": fn_name, "file_path": file_path}, |
| 301 | EdgeType.CALLS, |
| 302 | NodeLabel.Function, {"name": callee, "file_path": file_path}, |
| 303 | ) |
| 304 | stats["edges"] += 1 |
| 305 | for child in node.children: |
| 306 | walk(child) |
| 307 | |
| 308 | # Body is statement_block for functions, expression for arrow fns |
| 309 | body = next( |
| 310 | (c for c in fn_node.children |
| 311 | if c.type in ("statement_block", "expression_statement")), |
| 312 | None, |
| 313 | ) |
| 314 | if body: |
| 315 | walk(body) |
| 316 |
| --- navegador/ingestion/typescript.py | |
| +++ navegador/ingestion/typescript.py | |
| @@ -20,23 +20,25 @@ | |
| 20 | def _get_ts_language(language: str): |
| 21 | try: |
| 22 | if language == "typescript": |
| 23 | import tree_sitter_typescript as tsts # type: ignore[import] |
| 24 | from tree_sitter import Language |
| 25 | |
| 26 | return Language(tsts.language_typescript()) |
| 27 | else: |
| 28 | import tree_sitter_javascript as tsjs # type: ignore[import] |
| 29 | from tree_sitter import Language |
| 30 | |
| 31 | return Language(tsjs.language()) |
| 32 | except ImportError as e: |
| 33 | raise ImportError( |
| 34 | f"Install tree-sitter-{language}: pip install tree-sitter-{language}" |
| 35 | ) from e |
| 36 | |
| 37 | |
| 38 | def _node_text(node, source: bytes) -> str: |
| 39 | return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") |
| 40 | |
| 41 | |
| 42 | def _jsdoc(node, source: bytes) -> str: |
| 43 | """Return the JSDoc comment (/** ... */) preceding a node, if any.""" |
| 44 | parent = node.parent |
| @@ -57,33 +59,44 @@ | |
| 59 | class TypeScriptParser(LanguageParser): |
| 60 | """Parses TypeScript/JavaScript source files into the navegador graph.""" |
| 61 | |
| 62 | def __init__(self, language: str = "typescript") -> None: |
| 63 | from tree_sitter import Parser # type: ignore[import] |
| 64 | |
| 65 | self._parser = Parser(_get_ts_language(language)) |
| 66 | self._language = language |
| 67 | |
| 68 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 69 | source = path.read_bytes() |
| 70 | tree = self._parser.parse(source) |
| 71 | rel_path = str(path.relative_to(repo_root)) |
| 72 | |
| 73 | store.create_node( |
| 74 | NodeLabel.File, |
| 75 | { |
| 76 | "name": path.name, |
| 77 | "path": rel_path, |
| 78 | "language": self._language, |
| 79 | "line_count": source.count(b"\n"), |
| 80 | }, |
| 81 | ) |
| 82 | |
| 83 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 84 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 85 | return stats |
| 86 | |
| 87 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 88 | |
| 89 | def _walk( |
| 90 | self, |
| 91 | node, |
| 92 | source: bytes, |
| 93 | file_path: str, |
| 94 | store: GraphStore, |
| 95 | stats: dict, |
| 96 | class_name: str | None, |
| 97 | ) -> None: |
| 98 | if node.type in ("class_declaration", "abstract_class_declaration"): |
| 99 | self._handle_class(node, source, file_path, store, stats) |
| 100 | return |
| 101 | if node.type in ("interface_declaration", "type_alias_declaration"): |
| 102 | self._handle_interface(node, source, file_path, store, stats) |
| @@ -110,31 +123,35 @@ | |
| 123 | for child in node.children: |
| 124 | self._walk(child, source, file_path, store, stats, class_name) |
| 125 | |
| 126 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 127 | |
| 128 | def _handle_class( |
| 129 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 130 | ) -> None: |
| 131 | name_node = next((c for c in node.children if c.type == "type_identifier"), None) |
| 132 | if not name_node: |
| 133 | return |
| 134 | name = _node_text(name_node, source) |
| 135 | docstring = _jsdoc(node, source) |
| 136 | |
| 137 | store.create_node( |
| 138 | NodeLabel.Class, |
| 139 | { |
| 140 | "name": name, |
| 141 | "file_path": file_path, |
| 142 | "line_start": node.start_point[0] + 1, |
| 143 | "line_end": node.end_point[0] + 1, |
| 144 | "docstring": docstring, |
| 145 | }, |
| 146 | ) |
| 147 | store.create_edge( |
| 148 | NodeLabel.File, |
| 149 | {"path": file_path}, |
| 150 | EdgeType.CONTAINS, |
| 151 | NodeLabel.Class, |
| 152 | {"name": name, "file_path": file_path}, |
| 153 | ) |
| 154 | stats["classes"] += 1 |
| 155 | stats["edges"] += 1 |
| 156 | |
| 157 | # Inheritance: extends clause |
| @@ -144,172 +161,209 @@ | |
| 161 | if child.type == "extends_clause": |
| 162 | for c in child.children: |
| 163 | if c.type == "identifier": |
| 164 | parent_name = _node_text(c, source) |
| 165 | store.create_edge( |
| 166 | NodeLabel.Class, |
| 167 | {"name": name, "file_path": file_path}, |
| 168 | EdgeType.INHERITS, |
| 169 | NodeLabel.Class, |
| 170 | {"name": parent_name, "file_path": file_path}, |
| 171 | ) |
| 172 | stats["edges"] += 1 |
| 173 | |
| 174 | body = next((c for c in node.children if c.type == "class_body"), None) |
| 175 | if body: |
| 176 | for child in body.children: |
| 177 | if child.type == "method_definition": |
| 178 | self._handle_function(child, source, file_path, store, stats, class_name=name) |
| 179 | |
| 180 | def _handle_interface( |
| 181 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 182 | ) -> None: |
| 183 | name_node = next((c for c in node.children if c.type == "type_identifier"), None) |
| 184 | if not name_node: |
| 185 | return |
| 186 | name = _node_text(name_node, source) |
| 187 | docstring = _jsdoc(node, source) |
| 188 | kind = "interface" if node.type == "interface_declaration" else "type" |
| 189 | |
| 190 | store.create_node( |
| 191 | NodeLabel.Class, |
| 192 | { |
| 193 | "name": name, |
| 194 | "file_path": file_path, |
| 195 | "line_start": node.start_point[0] + 1, |
| 196 | "line_end": node.end_point[0] + 1, |
| 197 | "docstring": f"{kind}: {docstring}".strip(": "), |
| 198 | }, |
| 199 | ) |
| 200 | store.create_edge( |
| 201 | NodeLabel.File, |
| 202 | {"path": file_path}, |
| 203 | EdgeType.CONTAINS, |
| 204 | NodeLabel.Class, |
| 205 | {"name": name, "file_path": file_path}, |
| 206 | ) |
| 207 | stats["classes"] += 1 |
| 208 | stats["edges"] += 1 |
| 209 | |
| 210 | def _handle_function( |
| 211 | self, |
| 212 | node, |
| 213 | source: bytes, |
| 214 | file_path: str, |
| 215 | store: GraphStore, |
| 216 | stats: dict, |
| 217 | class_name: str | None, |
| 218 | ) -> None: |
| 219 | name_node = next( |
| 220 | (c for c in node.children if c.type in ("identifier", "property_identifier")), None |
| 221 | ) |
| 222 | if not name_node: |
| 223 | return |
| 224 | name = _node_text(name_node, source) |
| 225 | if name in ("constructor", "get", "set", "static", "async"): |
| 226 | # These are keywords, not useful names — look for next identifier |
| 227 | name_node = next( |
| 228 | ( |
| 229 | c |
| 230 | for c in node.children |
| 231 | if c.type in ("identifier", "property_identifier") and c != name_node |
| 232 | ), |
| 233 | None, |
| 234 | ) |
| 235 | if not name_node: |
| 236 | return |
| 237 | name = _node_text(name_node, source) |
| 238 | |
| 239 | docstring = _jsdoc(node, source) |
| 240 | label = NodeLabel.Method if class_name else NodeLabel.Function |
| 241 | |
| 242 | store.create_node( |
| 243 | label, |
| 244 | { |
| 245 | "name": name, |
| 246 | "file_path": file_path, |
| 247 | "line_start": node.start_point[0] + 1, |
| 248 | "line_end": node.end_point[0] + 1, |
| 249 | "docstring": docstring, |
| 250 | "class_name": class_name or "", |
| 251 | }, |
| 252 | ) |
| 253 | |
| 254 | container_label = NodeLabel.Class if class_name else NodeLabel.File |
| 255 | container_key = ( |
| 256 | {"name": class_name, "file_path": file_path} if class_name else {"path": file_path} |
| 257 | ) |
| 258 | store.create_edge( |
| 259 | container_label, |
| 260 | container_key, |
| 261 | EdgeType.CONTAINS, |
| 262 | label, |
| 263 | {"name": name, "file_path": file_path}, |
| 264 | ) |
| 265 | stats["functions"] += 1 |
| 266 | stats["edges"] += 1 |
| 267 | |
| 268 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 269 | |
| 270 | def _handle_lexical( |
| 271 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 272 | ) -> None: |
| 273 | """Handle: const foo = () => {} and const bar = function() {}""" |
| 274 | for child in node.children: |
| 275 | if child.type != "variable_declarator": |
| 276 | continue |
| 277 | name_node = child.child_by_field_name("name") |
| 278 | value_node = child.child_by_field_name("value") |
| 279 | if not name_node or not value_node: |
| 280 | continue |
| 281 | if value_node.type not in ("arrow_function", "function_expression", "function"): |
| 282 | continue |
| 283 | name = _node_text(name_node, source) |
| 284 | docstring = _jsdoc(node, source) |
| 285 | |
| 286 | store.create_node( |
| 287 | NodeLabel.Function, |
| 288 | { |
| 289 | "name": name, |
| 290 | "file_path": file_path, |
| 291 | "line_start": node.start_point[0] + 1, |
| 292 | "line_end": node.end_point[0] + 1, |
| 293 | "docstring": docstring, |
| 294 | "class_name": "", |
| 295 | }, |
| 296 | ) |
| 297 | store.create_edge( |
| 298 | NodeLabel.File, |
| 299 | {"path": file_path}, |
| 300 | EdgeType.CONTAINS, |
| 301 | NodeLabel.Function, |
| 302 | {"name": name, "file_path": file_path}, |
| 303 | ) |
| 304 | stats["functions"] += 1 |
| 305 | stats["edges"] += 1 |
| 306 | |
| 307 | self._extract_calls( |
| 308 | value_node, source, file_path, name, NodeLabel.Function, store, stats |
| 309 | ) |
| 310 | |
| 311 | def _handle_import( |
| 312 | self, node, source: bytes, file_path: str, store: GraphStore, stats: dict |
| 313 | ) -> None: |
| 314 | for child in node.children: |
| 315 | if child.type == "string": |
| 316 | module = _node_text(child, source).strip("'\"") |
| 317 | store.create_node( |
| 318 | NodeLabel.Import, |
| 319 | { |
| 320 | "name": module, |
| 321 | "file_path": file_path, |
| 322 | "line_start": node.start_point[0] + 1, |
| 323 | "module": module, |
| 324 | }, |
| 325 | ) |
| 326 | store.create_edge( |
| 327 | NodeLabel.File, |
| 328 | {"path": file_path}, |
| 329 | EdgeType.IMPORTS, |
| 330 | NodeLabel.Import, |
| 331 | {"name": module, "file_path": file_path}, |
| 332 | ) |
| 333 | stats["edges"] += 1 |
| 334 | break |
| 335 | |
| 336 | def _extract_calls( |
| 337 | self, |
| 338 | fn_node, |
| 339 | source: bytes, |
| 340 | file_path: str, |
| 341 | fn_name: str, |
| 342 | fn_label: str, |
| 343 | store: GraphStore, |
| 344 | stats: dict, |
| 345 | ) -> None: |
| 346 | def walk(node): |
| 347 | if node.type == "call_expression": |
| 348 | func = node.child_by_field_name("function") |
| 349 | if func: |
| 350 | text = _node_text(func, source) |
| 351 | callee = text.split(".")[-1] |
| 352 | store.create_edge( |
| 353 | fn_label, |
| 354 | {"name": fn_name, "file_path": file_path}, |
| 355 | EdgeType.CALLS, |
| 356 | NodeLabel.Function, |
| 357 | {"name": callee, "file_path": file_path}, |
| 358 | ) |
| 359 | stats["edges"] += 1 |
| 360 | for child in node.children: |
| 361 | walk(child) |
| 362 | |
| 363 | # Body is statement_block for functions, expression for arrow fns |
| 364 | body = next( |
| 365 | (c for c in fn_node.children if c.type in ("statement_block", "expression_statement")), |
| 366 | None, |
| 367 | ) |
| 368 | if body: |
| 369 | walk(body) |
| 370 |
+16
-9
| --- navegador/ingestion/wiki.py | ||
| +++ navegador/ingestion/wiki.py | ||
| @@ -103,11 +103,12 @@ | ||
| 103 | 103 | clone_dir = Path(clone_dir) |
| 104 | 104 | |
| 105 | 105 | logger.info("Cloning wiki %s → %s", wiki_url, clone_dir) |
| 106 | 106 | result = subprocess.run( |
| 107 | 107 | ["git", "clone", "--depth=1", wiki_url, str(clone_dir)], |
| 108 | - capture_output=True, text=True, | |
| 108 | + capture_output=True, | |
| 109 | + text=True, | |
| 109 | 110 | ) |
| 110 | 111 | if result.returncode != 0: |
| 111 | 112 | # Wiki may not exist yet — treat as empty |
| 112 | 113 | logger.warning("Wiki clone failed: %s", result.stderr.strip()) |
| 113 | 114 | return {"pages": 0, "links": 0} |
| @@ -137,10 +138,11 @@ | ||
| 137 | 138 | try: |
| 138 | 139 | req = urllib.request.Request(url, headers=headers) |
| 139 | 140 | with urllib.request.urlopen(req, timeout=10) as resp: |
| 140 | 141 | import base64 |
| 141 | 142 | import json as _json |
| 143 | + | |
| 142 | 144 | data = _json.loads(resp.read().decode()) |
| 143 | 145 | content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501 |
| 144 | 146 | page_name = Path(path).stem.replace("-", " ").replace("_", " ") |
| 145 | 147 | html_url = data.get("html_url", "") |
| 146 | 148 | links = self._ingest_page(page_name, content, source="github", url=html_url) |
| @@ -160,16 +162,19 @@ | ||
| 160 | 162 | content: str, |
| 161 | 163 | source: str, |
| 162 | 164 | url: str, |
| 163 | 165 | ) -> int: |
| 164 | 166 | """Store one wiki page and return the number of DOCUMENTS links created.""" |
| 165 | - self.store.create_node(NodeLabel.WikiPage, { | |
| 166 | - "name": name, | |
| 167 | - "url": url, | |
| 168 | - "source": source, | |
| 169 | - "content": content[:4000], # cap stored content | |
| 170 | - }) | |
| 167 | + self.store.create_node( | |
| 168 | + NodeLabel.WikiPage, | |
| 169 | + { | |
| 170 | + "name": name, | |
| 171 | + "url": url, | |
| 172 | + "source": source, | |
| 173 | + "content": content[:4000], # cap stored content | |
| 174 | + }, | |
| 175 | + ) | |
| 171 | 176 | |
| 172 | 177 | links = 0 |
| 173 | 178 | for term in _extract_terms(content): |
| 174 | 179 | links += self._try_link(name, term) |
| 175 | 180 | |
| @@ -195,12 +200,14 @@ | ||
| 195 | 200 | |
| 196 | 201 | label_str, node_name = rows[0][0], rows[0][1] |
| 197 | 202 | try: |
| 198 | 203 | label = NodeLabel(label_str) |
| 199 | 204 | self.store.create_edge( |
| 200 | - NodeLabel.WikiPage, {"name": wiki_page_name}, | |
| 205 | + NodeLabel.WikiPage, | |
| 206 | + {"name": wiki_page_name}, | |
| 201 | 207 | EdgeType.DOCUMENTS, |
| 202 | - label, {"name": node_name}, | |
| 208 | + label, | |
| 209 | + {"name": node_name}, | |
| 203 | 210 | ) |
| 204 | 211 | return 1 |
| 205 | 212 | except ValueError: |
| 206 | 213 | return 0 |
| 207 | 214 |
| --- navegador/ingestion/wiki.py | |
| +++ navegador/ingestion/wiki.py | |
| @@ -103,11 +103,12 @@ | |
| 103 | clone_dir = Path(clone_dir) |
| 104 | |
| 105 | logger.info("Cloning wiki %s → %s", wiki_url, clone_dir) |
| 106 | result = subprocess.run( |
| 107 | ["git", "clone", "--depth=1", wiki_url, str(clone_dir)], |
| 108 | capture_output=True, text=True, |
| 109 | ) |
| 110 | if result.returncode != 0: |
| 111 | # Wiki may not exist yet — treat as empty |
| 112 | logger.warning("Wiki clone failed: %s", result.stderr.strip()) |
| 113 | return {"pages": 0, "links": 0} |
| @@ -137,10 +138,11 @@ | |
| 137 | try: |
| 138 | req = urllib.request.Request(url, headers=headers) |
| 139 | with urllib.request.urlopen(req, timeout=10) as resp: |
| 140 | import base64 |
| 141 | import json as _json |
| 142 | data = _json.loads(resp.read().decode()) |
| 143 | content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501 |
| 144 | page_name = Path(path).stem.replace("-", " ").replace("_", " ") |
| 145 | html_url = data.get("html_url", "") |
| 146 | links = self._ingest_page(page_name, content, source="github", url=html_url) |
| @@ -160,16 +162,19 @@ | |
| 160 | content: str, |
| 161 | source: str, |
| 162 | url: str, |
| 163 | ) -> int: |
| 164 | """Store one wiki page and return the number of DOCUMENTS links created.""" |
| 165 | self.store.create_node(NodeLabel.WikiPage, { |
| 166 | "name": name, |
| 167 | "url": url, |
| 168 | "source": source, |
| 169 | "content": content[:4000], # cap stored content |
| 170 | }) |
| 171 | |
| 172 | links = 0 |
| 173 | for term in _extract_terms(content): |
| 174 | links += self._try_link(name, term) |
| 175 | |
| @@ -195,12 +200,14 @@ | |
| 195 | |
| 196 | label_str, node_name = rows[0][0], rows[0][1] |
| 197 | try: |
| 198 | label = NodeLabel(label_str) |
| 199 | self.store.create_edge( |
| 200 | NodeLabel.WikiPage, {"name": wiki_page_name}, |
| 201 | EdgeType.DOCUMENTS, |
| 202 | label, {"name": node_name}, |
| 203 | ) |
| 204 | return 1 |
| 205 | except ValueError: |
| 206 | return 0 |
| 207 |
| --- navegador/ingestion/wiki.py | |
| +++ navegador/ingestion/wiki.py | |
| @@ -103,11 +103,12 @@ | |
| 103 | clone_dir = Path(clone_dir) |
| 104 | |
| 105 | logger.info("Cloning wiki %s → %s", wiki_url, clone_dir) |
| 106 | result = subprocess.run( |
| 107 | ["git", "clone", "--depth=1", wiki_url, str(clone_dir)], |
| 108 | capture_output=True, |
| 109 | text=True, |
| 110 | ) |
| 111 | if result.returncode != 0: |
| 112 | # Wiki may not exist yet — treat as empty |
| 113 | logger.warning("Wiki clone failed: %s", result.stderr.strip()) |
| 114 | return {"pages": 0, "links": 0} |
| @@ -137,10 +138,11 @@ | |
| 138 | try: |
| 139 | req = urllib.request.Request(url, headers=headers) |
| 140 | with urllib.request.urlopen(req, timeout=10) as resp: |
| 141 | import base64 |
| 142 | import json as _json |
| 143 | |
| 144 | data = _json.loads(resp.read().decode()) |
| 145 | content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501 |
| 146 | page_name = Path(path).stem.replace("-", " ").replace("_", " ") |
| 147 | html_url = data.get("html_url", "") |
| 148 | links = self._ingest_page(page_name, content, source="github", url=html_url) |
| @@ -160,16 +162,19 @@ | |
| 162 | content: str, |
| 163 | source: str, |
| 164 | url: str, |
| 165 | ) -> int: |
| 166 | """Store one wiki page and return the number of DOCUMENTS links created.""" |
| 167 | self.store.create_node( |
| 168 | NodeLabel.WikiPage, |
| 169 | { |
| 170 | "name": name, |
| 171 | "url": url, |
| 172 | "source": source, |
| 173 | "content": content[:4000], # cap stored content |
| 174 | }, |
| 175 | ) |
| 176 | |
| 177 | links = 0 |
| 178 | for term in _extract_terms(content): |
| 179 | links += self._try_link(name, term) |
| 180 | |
| @@ -195,12 +200,14 @@ | |
| 200 | |
| 201 | label_str, node_name = rows[0][0], rows[0][1] |
| 202 | try: |
| 203 | label = NodeLabel(label_str) |
| 204 | self.store.create_edge( |
| 205 | NodeLabel.WikiPage, |
| 206 | {"name": wiki_page_name}, |
| 207 | EdgeType.DOCUMENTS, |
| 208 | label, |
| 209 | {"name": node_name}, |
| 210 | ) |
| 211 | return 1 |
| 212 | except ValueError: |
| 213 | return 0 |
| 214 |
+13
-5
| --- navegador/mcp/server.py | ||
| +++ navegador/mcp/server.py | ||
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | navegador mcp --db .navegador/graph.db |
| 6 | 6 | """ |
| 7 | 7 | |
| 8 | 8 | import json |
| 9 | 9 | import logging |
| 10 | +from typing import Any | |
| 10 | 11 | |
| 11 | 12 | logger = logging.getLogger(__name__) |
| 12 | 13 | |
| 13 | 14 | |
| 14 | 15 | def create_mcp_server(store_factory): |
| @@ -25,12 +26,12 @@ | ||
| 25 | 26 | raise ImportError("Install mcp: pip install mcp") from e |
| 26 | 27 | |
| 27 | 28 | from navegador.context import ContextLoader |
| 28 | 29 | |
| 29 | 30 | server = Server("navegador") |
| 30 | - _store = None | |
| 31 | - _loader = None | |
| 31 | + _store: Any = None | |
| 32 | + _loader: ContextLoader | None = None | |
| 32 | 33 | |
| 33 | 34 | def _get_loader() -> ContextLoader: |
| 34 | 35 | nonlocal _store, _loader |
| 35 | 36 | if _loader is None: |
| 36 | 37 | _store = store_factory() |
| @@ -65,11 +66,13 @@ | ||
| 65 | 66 | "file_path": { |
| 66 | 67 | "type": "string", |
| 67 | 68 | "description": "Relative file path within the ingested repo.", |
| 68 | 69 | }, |
| 69 | 70 | "format": { |
| 70 | - "type": "string", "enum": ["json", "markdown"], "default": "markdown", | |
| 71 | + "type": "string", | |
| 72 | + "enum": ["json", "markdown"], | |
| 73 | + "default": "markdown", | |
| 71 | 74 | }, |
| 72 | 75 | }, |
| 73 | 76 | "required": ["file_path"], |
| 74 | 77 | }, |
| 75 | 78 | ), |
| @@ -81,11 +84,13 @@ | ||
| 81 | 84 | "properties": { |
| 82 | 85 | "name": {"type": "string", "description": "Function name."}, |
| 83 | 86 | "file_path": {"type": "string", "description": "Relative file path."}, |
| 84 | 87 | "depth": {"type": "integer", "default": 2}, |
| 85 | 88 | "format": { |
| 86 | - "type": "string", "enum": ["json", "markdown"], "default": "markdown", | |
| 89 | + "type": "string", | |
| 90 | + "enum": ["json", "markdown"], | |
| 91 | + "default": "markdown", | |
| 87 | 92 | }, |
| 88 | 93 | }, |
| 89 | 94 | "required": ["name", "file_path"], |
| 90 | 95 | }, |
| 91 | 96 | ), |
| @@ -96,11 +101,13 @@ | ||
| 96 | 101 | "type": "object", |
| 97 | 102 | "properties": { |
| 98 | 103 | "name": {"type": "string", "description": "Class name."}, |
| 99 | 104 | "file_path": {"type": "string", "description": "Relative file path."}, |
| 100 | 105 | "format": { |
| 101 | - "type": "string", "enum": ["json", "markdown"], "default": "markdown", | |
| 106 | + "type": "string", | |
| 107 | + "enum": ["json", "markdown"], | |
| 108 | + "default": "markdown", | |
| 102 | 109 | }, |
| 103 | 110 | }, |
| 104 | 111 | "required": ["name", "file_path"], |
| 105 | 112 | }, |
| 106 | 113 | ), |
| @@ -138,10 +145,11 @@ | ||
| 138 | 145 | async def call_tool(name: str, arguments: dict) -> list[TextContent]: |
| 139 | 146 | loader = _get_loader() |
| 140 | 147 | |
| 141 | 148 | if name == "ingest_repo": |
| 142 | 149 | from navegador.ingestion import RepoIngester |
| 150 | + | |
| 143 | 151 | ingester = RepoIngester(loader.store) |
| 144 | 152 | stats = ingester.ingest(arguments["path"], clear=arguments.get("clear", False)) |
| 145 | 153 | return [TextContent(type="text", text=json.dumps(stats, indent=2))] |
| 146 | 154 | |
| 147 | 155 | elif name == "load_file_context": |
| 148 | 156 |
| --- navegador/mcp/server.py | |
| +++ navegador/mcp/server.py | |
| @@ -5,10 +5,11 @@ | |
| 5 | navegador mcp --db .navegador/graph.db |
| 6 | """ |
| 7 | |
| 8 | import json |
| 9 | import logging |
| 10 | |
| 11 | logger = logging.getLogger(__name__) |
| 12 | |
| 13 | |
| 14 | def create_mcp_server(store_factory): |
| @@ -25,12 +26,12 @@ | |
| 25 | raise ImportError("Install mcp: pip install mcp") from e |
| 26 | |
| 27 | from navegador.context import ContextLoader |
| 28 | |
| 29 | server = Server("navegador") |
| 30 | _store = None |
| 31 | _loader = None |
| 32 | |
| 33 | def _get_loader() -> ContextLoader: |
| 34 | nonlocal _store, _loader |
| 35 | if _loader is None: |
| 36 | _store = store_factory() |
| @@ -65,11 +66,13 @@ | |
| 65 | "file_path": { |
| 66 | "type": "string", |
| 67 | "description": "Relative file path within the ingested repo.", |
| 68 | }, |
| 69 | "format": { |
| 70 | "type": "string", "enum": ["json", "markdown"], "default": "markdown", |
| 71 | }, |
| 72 | }, |
| 73 | "required": ["file_path"], |
| 74 | }, |
| 75 | ), |
| @@ -81,11 +84,13 @@ | |
| 81 | "properties": { |
| 82 | "name": {"type": "string", "description": "Function name."}, |
| 83 | "file_path": {"type": "string", "description": "Relative file path."}, |
| 84 | "depth": {"type": "integer", "default": 2}, |
| 85 | "format": { |
| 86 | "type": "string", "enum": ["json", "markdown"], "default": "markdown", |
| 87 | }, |
| 88 | }, |
| 89 | "required": ["name", "file_path"], |
| 90 | }, |
| 91 | ), |
| @@ -96,11 +101,13 @@ | |
| 96 | "type": "object", |
| 97 | "properties": { |
| 98 | "name": {"type": "string", "description": "Class name."}, |
| 99 | "file_path": {"type": "string", "description": "Relative file path."}, |
| 100 | "format": { |
| 101 | "type": "string", "enum": ["json", "markdown"], "default": "markdown", |
| 102 | }, |
| 103 | }, |
| 104 | "required": ["name", "file_path"], |
| 105 | }, |
| 106 | ), |
| @@ -138,10 +145,11 @@ | |
| 138 | async def call_tool(name: str, arguments: dict) -> list[TextContent]: |
| 139 | loader = _get_loader() |
| 140 | |
| 141 | if name == "ingest_repo": |
| 142 | from navegador.ingestion import RepoIngester |
| 143 | ingester = RepoIngester(loader.store) |
| 144 | stats = ingester.ingest(arguments["path"], clear=arguments.get("clear", False)) |
| 145 | return [TextContent(type="text", text=json.dumps(stats, indent=2))] |
| 146 | |
| 147 | elif name == "load_file_context": |
| 148 |
| --- navegador/mcp/server.py | |
| +++ navegador/mcp/server.py | |
| @@ -5,10 +5,11 @@ | |
| 5 | navegador mcp --db .navegador/graph.db |
| 6 | """ |
| 7 | |
| 8 | import json |
| 9 | import logging |
| 10 | from typing import Any |
| 11 | |
| 12 | logger = logging.getLogger(__name__) |
| 13 | |
| 14 | |
| 15 | def create_mcp_server(store_factory): |
| @@ -25,12 +26,12 @@ | |
| 26 | raise ImportError("Install mcp: pip install mcp") from e |
| 27 | |
| 28 | from navegador.context import ContextLoader |
| 29 | |
| 30 | server = Server("navegador") |
| 31 | _store: Any = None |
| 32 | _loader: ContextLoader | None = None |
| 33 | |
| 34 | def _get_loader() -> ContextLoader: |
| 35 | nonlocal _store, _loader |
| 36 | if _loader is None: |
| 37 | _store = store_factory() |
| @@ -65,11 +66,13 @@ | |
| 66 | "file_path": { |
| 67 | "type": "string", |
| 68 | "description": "Relative file path within the ingested repo.", |
| 69 | }, |
| 70 | "format": { |
| 71 | "type": "string", |
| 72 | "enum": ["json", "markdown"], |
| 73 | "default": "markdown", |
| 74 | }, |
| 75 | }, |
| 76 | "required": ["file_path"], |
| 77 | }, |
| 78 | ), |
| @@ -81,11 +84,13 @@ | |
| 84 | "properties": { |
| 85 | "name": {"type": "string", "description": "Function name."}, |
| 86 | "file_path": {"type": "string", "description": "Relative file path."}, |
| 87 | "depth": {"type": "integer", "default": 2}, |
| 88 | "format": { |
| 89 | "type": "string", |
| 90 | "enum": ["json", "markdown"], |
| 91 | "default": "markdown", |
| 92 | }, |
| 93 | }, |
| 94 | "required": ["name", "file_path"], |
| 95 | }, |
| 96 | ), |
| @@ -96,11 +101,13 @@ | |
| 101 | "type": "object", |
| 102 | "properties": { |
| 103 | "name": {"type": "string", "description": "Class name."}, |
| 104 | "file_path": {"type": "string", "description": "Relative file path."}, |
| 105 | "format": { |
| 106 | "type": "string", |
| 107 | "enum": ["json", "markdown"], |
| 108 | "default": "markdown", |
| 109 | }, |
| 110 | }, |
| 111 | "required": ["name", "file_path"], |
| 112 | }, |
| 113 | ), |
| @@ -138,10 +145,11 @@ | |
| 145 | async def call_tool(name: str, arguments: dict) -> list[TextContent]: |
| 146 | loader = _get_loader() |
| 147 | |
| 148 | if name == "ingest_repo": |
| 149 | from navegador.ingestion import RepoIngester |
| 150 | |
| 151 | ingester = RepoIngester(loader.store) |
| 152 | stats = ingester.ingest(arguments["path"], clear=arguments.get("clear", False)) |
| 153 | return [TextContent(type="text", text=json.dumps(stats, indent=2))] |
| 154 | |
| 155 | elif name == "load_file_context": |
| 156 |