Navegador
feat: expand to full project knowledge graph — concepts, rules, decisions, wiki, agent hooks
Commit
4be35958af682d078c7193d8baf8058e8f0f309c0b2c22cce33f11bb650f98c1
Parent
e72710e6001cfc9…
13 files changed
+87
+116
+98
+60
+73
+159
+288
-55
+163
-38
+158
-16
+55
-8
+3
-1
+206
+147
+
hooks/NAVEGADOR.md
+
hooks/bootstrap.sh
+
hooks/claude-hook.py
+
hooks/gemini-hook.py
+
hooks/openai-hook.py
+
hooks/openai-tools.json
~
navegador/cli/commands.py
~
navegador/context/loader.py
~
navegador/graph/queries.py
~
navegador/graph/schema.py
~
navegador/ingestion/__init__.py
+
navegador/ingestion/knowledge.py
+
navegador/ingestion/wiki.py
+87
| --- a/hooks/NAVEGADOR.md | ||
| +++ b/hooks/NAVEGADOR.md | ||
| @@ -0,0 +1,87 @@ | ||
| 1 | +# Navegador — Project Knowledge Graph | |
| 2 | + | |
| 3 | +This project uses **navegador** as its system of record for both code structure | |
| 4 | +and business knowledge. The graph lives at `.navegador/graph.db`. | |
| 5 | + | |
| 6 | +## What's in the graph | |
| 7 | + | |
| 8 | +- **Code layer** — all functions, classes, files, call graphs, decorators, imports | |
| 9 | +- **Knowledge layer** — business concepts, rules, architectural decisions, people, domains | |
| 10 | +- **Wiki** — pages from this repo's GitHub wiki, linked to code and concepts | |
| 11 | + | |
| 12 | +## How to query it | |
| 13 | + | |
| 14 | +```bash | |
| 15 | +# Find anything by name (code + knowledge) | |
| 16 | +navegador search "payment" --all | |
| 17 | + | |
| 18 | +# Full picture for any function, class, or concept | |
| 19 | +navegador explain validate_token | |
| 20 | +navegador explain Payment --format json | |
| 21 | + | |
| 22 | +# Code-specific | |
| 23 | +navegador function get_user --depth 3 | |
| 24 | +navegador class AuthService | |
| 25 | +navegador decorated login_required | |
| 26 | + | |
| 27 | +# Business knowledge | |
| 28 | +navegador concept Invoice | |
| 29 | +navegador domain billing | |
| 30 | + | |
| 31 | +# Raw Cypher when you need it | |
| 32 | +navegador query "MATCH (r:Rule)-[:GOVERNS]->(c:Concept) RETURN r.name, c.name LIMIT 20" | |
| 33 | +``` | |
| 34 | + | |
| 35 | +## Keeping the graph up to date | |
| 36 | + | |
| 37 | +When you add or modify code, re-ingest the repo: | |
| 38 | +```bash | |
| 39 | +navegador ingest . --json | |
| 40 | +``` | |
| 41 | + | |
| 42 | +When you establish a new business rule or make an architectural decision: | |
| 43 | +```bash | |
| 44 | +navegador add rule "Invoices require approval before payment" \ | |
| 45 | + --domain billing --severity critical \ | |
| 46 | + --rationale "Prevents unapproved charges per finance policy" | |
| 47 | + | |
| 48 | +navegador add decision "Use JWT for stateless auth" \ | |
| 49 | + --domain auth --rationale "Horizontal scaling requirement" \ | |
| 50 | + --alternatives "Session cookies, OAuth opaque tokens" | |
| 51 | + | |
| 52 | +navegador annotate validate_token --type Function --rule "JWT tokens must expire" | |
| 53 | +``` | |
| 54 | + | |
| 55 | +When a wiki page is added or updated: | |
| 56 | +```bash | |
| 57 | +navegador wiki ingest --repo OWNER/REPO --token $GITHUB_TOKEN | |
| 58 | +``` | |
| 59 | + | |
| 60 | +## Agent rules | |
| 61 | + | |
| 62 | +You (the AI agent) MUST treat navegador as the source of truth for this project. | |
| 63 | + | |
| 64 | +1. **Before editing code** — query the graph to understand what you're touching: | |
| 65 | + ```bash | |
| 66 | + navegador explain <name> | |
| 67 | + navegador function <name> --format json | |
| 68 | + ``` | |
| 69 | + | |
| 70 | +2. **After editing code** — re-ingest to keep the graph current: | |
| 71 | + ```bash | |
| 72 | + navegador ingest . --json | |
| 73 | + ``` | |
| 74 | + | |
| 75 | +3. **When you establish a rule or make a decision** — record it: | |
| 76 | + ```bash | |
| 77 | + navegador add decision "..." --rationale "..." | |
| 78 | + navegador add rule "..." --severity warning --domain ... | |
| 79 | + ``` | |
| 80 | + | |
| 81 | +4. **When you discover a concept** (a named business entity) — add it: | |
| 82 | + ```bash | |
| 83 | + navegador add concept "..." --desc "..." --domain ... | |
| 84 | + ``` | |
| 85 | + | |
| 86 | +5. **Never assume** what calls what, what a class inherits, or what a decorator | |
| 87 | + does. Query first. |
| --- a/hooks/NAVEGADOR.md | |
| +++ b/hooks/NAVEGADOR.md | |
| @@ -0,0 +1,87 @@ | |
| --- a/hooks/NAVEGADOR.md | |
| +++ b/hooks/NAVEGADOR.md | |
| @@ -0,0 +1,87 @@ | |
| 1 | # Navegador — Project Knowledge Graph |
| 2 | |
| 3 | This project uses **navegador** as its system of record for both code structure |
| 4 | and business knowledge. The graph lives at `.navegador/graph.db`. |
| 5 | |
| 6 | ## What's in the graph |
| 7 | |
| 8 | - **Code layer** — all functions, classes, files, call graphs, decorators, imports |
| 9 | - **Knowledge layer** — business concepts, rules, architectural decisions, people, domains |
| 10 | - **Wiki** — pages from this repo's GitHub wiki, linked to code and concepts |
| 11 | |
| 12 | ## How to query it |
| 13 | |
| 14 | ```bash |
| 15 | # Find anything by name (code + knowledge) |
| 16 | navegador search "payment" --all |
| 17 | |
| 18 | # Full picture for any function, class, or concept |
| 19 | navegador explain validate_token |
| 20 | navegador explain Payment --format json |
| 21 | |
| 22 | # Code-specific |
| 23 | navegador function get_user --depth 3 |
| 24 | navegador class AuthService |
| 25 | navegador decorated login_required |
| 26 | |
| 27 | # Business knowledge |
| 28 | navegador concept Invoice |
| 29 | navegador domain billing |
| 30 | |
| 31 | # Raw Cypher when you need it |
| 32 | navegador query "MATCH (r:Rule)-[:GOVERNS]->(c:Concept) RETURN r.name, c.name LIMIT 20" |
| 33 | ``` |
| 34 | |
| 35 | ## Keeping the graph up to date |
| 36 | |
| 37 | When you add or modify code, re-ingest the repo: |
| 38 | ```bash |
| 39 | navegador ingest . --json |
| 40 | ``` |
| 41 | |
| 42 | When you establish a new business rule or make an architectural decision: |
| 43 | ```bash |
| 44 | navegador add rule "Invoices require approval before payment" \ |
| 45 | --domain billing --severity critical \ |
| 46 | --rationale "Prevents unapproved charges per finance policy" |
| 47 | |
| 48 | navegador add decision "Use JWT for stateless auth" \ |
| 49 | --domain auth --rationale "Horizontal scaling requirement" \ |
| 50 | --alternatives "Session cookies, OAuth opaque tokens" |
| 51 | |
| 52 | navegador annotate validate_token --type Function --rule "JWT tokens must expire" |
| 53 | ``` |
| 54 | |
| 55 | When a wiki page is added or updated: |
| 56 | ```bash |
| 57 | navegador wiki ingest --repo OWNER/REPO --token $GITHUB_TOKEN |
| 58 | ``` |
| 59 | |
| 60 | ## Agent rules |
| 61 | |
| 62 | You (the AI agent) MUST treat navegador as the source of truth for this project. |
| 63 | |
| 64 | 1. **Before editing code** — query the graph to understand what you're touching: |
| 65 | ```bash |
| 66 | navegador explain <name> |
| 67 | navegador function <name> --format json |
| 68 | ``` |
| 69 | |
| 70 | 2. **After editing code** — re-ingest to keep the graph current: |
| 71 | ```bash |
| 72 | navegador ingest . --json |
| 73 | ``` |
| 74 | |
| 75 | 3. **When you establish a rule or make a decision** — record it: |
| 76 | ```bash |
| 77 | navegador add decision "..." --rationale "..." |
| 78 | navegador add rule "..." --severity warning --domain ... |
| 79 | ``` |
| 80 | |
| 81 | 4. **When you discover a concept** (a named business entity) — add it: |
| 82 | ```bash |
| 83 | navegador add concept "..." --desc "..." --domain ... |
| 84 | ``` |
| 85 | |
| 86 | 5. **Never assume** what calls what, what a class inherits, or what a decorator |
| 87 | does. Query first. |
+116
| --- a/hooks/bootstrap.sh | ||
| +++ b/hooks/bootstrap.sh | ||
| @@ -0,0 +1,116 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# navegador bootstrap — install, initialise, and ingest a project | |
| 3 | +# | |
| 4 | +# Usage: | |
| 5 | +# curl -fsSL https://raw.githubusercontent.com/ConflictHQ/navegador/main/hooks/bootstrap.sh | bash | |
| 6 | +# # or locally: | |
| 7 | +# bash hooks/bootstrap.sh [--repo owner/repo] [--wiki] [--agent claude|gemini|openai] | |
| 8 | + | |
| 9 | +set -euo pipefail | |
| 10 | + | |
| 11 | +NAV_DB="${NAVEGADOR_DB:-.navegador/graph.db}" | |
| 12 | +REPO_PATH="${REPO_PATH:-.}" | |
| 13 | +GITHUB_REPO="${GITHUB_REPO:-}" | |
| 14 | +INSTALL_AGENT="${INSTALL_AGENT:-}" | |
| 15 | +INGEST_WIKI=false | |
| 16 | + | |
| 17 | +# ── Parse args ──────────────────────────────────────────────────────────────── | |
| 18 | +while [[ $# -gt 0 ]]; do | |
| 19 | + case $1 in | |
| 20 | + --repo) GITHUB_REPO="$2"; shift 2 ;; | |
| 21 | + --wiki) INGEST_WIKI=true; shift ;; | |
| 22 | + --agent) INSTALL_AGENT="$2"; shift 2 ;; | |
| 23 | + --db) NAV_DB="$2"; shift 2 ;; | |
| 24 | + *) echo "Unknown option: $1"; exit 1 ;; | |
| 25 | + esac | |
| 26 | +done | |
| 27 | + | |
| 28 | +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| 29 | +echo " Navegador bootstrap" | |
| 30 | +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| 31 | + | |
| 32 | +# ── Install ─────────────────────────────────────────────────────────────────── | |
| 33 | +if ! command -v navegador &>/dev/null; then | |
| 34 | + echo "→ Installing navegador..." | |
| 35 | + pip install "navegador[sqlite]" --quiet | |
| 36 | +else | |
| 37 | + echo "→ navegador $(navegador --version 2>&1 | head -1) already installed" | |
| 38 | +fi | |
| 39 | + | |
| 40 | +# ── Initialise DB directory ─────────────────────────────────────────────────── | |
| 41 | +mkdir -p "$(dirname "$NAV_DB")" | |
| 42 | +echo "→ Graph DB: $NAV_DB" | |
| 43 | + | |
| 44 | +# ── Ingest code ─────────────────────────────────────────────────────────────── | |
| 45 | +echo "→ Ingesting code from $REPO_PATH ..." | |
| 46 | +navegador --db "$NAV_DB" ingest "$REPO_PATH" --json | \ | |
| 47 | + python3 -c "import json,sys; d=json.load(sys.stdin); print(f\" files={d['files']} functions={d['functions']} classes={d['classes']} edges={d['edges']}\")" | |
| 48 | + | |
| 49 | +# ── Ingest wiki ─────────────────────────────────────────────────────────────── | |
| 50 | +if [[ "$INGEST_WIKI" == "true" && -n "$GITHUB_REPO" ]]; then | |
| 51 | + echo "→ Ingesting GitHub wiki for $GITHUB_REPO ..." | |
| 52 | + navegador --db "$NAV_DB" wiki ingest --repo "$GITHUB_REPO" ${GITHUB_TOKEN:+--token "$GITHUB_TOKEN"} || true | |
| 53 | +fi | |
| 54 | + | |
| 55 | +# ── Install agent hook ──────────────────────────────────────────────────────── | |
| 56 | +HOOK_SRC_BASE="https://raw.githubusercontent.com/ConflictHQ/navegador/main/hooks" | |
| 57 | + | |
| 58 | +install_claude_hook() { | |
| 59 | + mkdir -p .claude/hooks | |
| 60 | + curl -fsSL "$HOOK_SRC_BASE/claude-hook.py" -o .claude/hooks/navegador.py | |
| 61 | + chmod +x .claude/hooks/navegador.py | |
| 62 | + | |
| 63 | + SETTINGS=".claude/settings.json" | |
| 64 | + if [[ ! -f "$SETTINGS" ]]; then | |
| 65 | + cat > "$SETTINGS" <<'JSON' | |
| 66 | +{ | |
| 67 | + "hooks": { | |
| 68 | + "PostToolUse": [ | |
| 69 | + { | |
| 70 | + "matcher": "Edit|Write", | |
| 71 | + "hooks": [{ "type": "command", "command": "python3 .claude/hooks/navegador.py" }] | |
| 72 | + } | |
| 73 | + ] | |
| 74 | + } | |
| 75 | +} | |
| 76 | +JSON | |
| 77 | + echo " Created $SETTINGS" | |
| 78 | + else | |
| 79 | + echo " $SETTINGS exists — add the hook manually (see .claude/hooks/navegador.py)" | |
| 80 | + fi | |
| 81 | +} | |
| 82 | + | |
| 83 | +install_gemini_hook() { | |
| 84 | + mkdir -p .gemini/hooks | |
| 85 | + curl -fsSL "$HOOK_SRC_BASE/gemini-hook.py" -o .gemini/hooks/navegador.py | |
| 86 | + chmod +x .gemini/hooks/navegador.py | |
| 87 | + echo " Add to GEMINI.md: python3 .gemini/hooks/navegador.py <tool> <file>" | |
| 88 | +} | |
| 89 | + | |
| 90 | +install_openai_hook() { | |
| 91 | + curl -fsSL "$HOOK_SRC_BASE/openai-hook.py" -o navegador-openai.py | |
| 92 | + chmod +x navegador-openai.py | |
| 93 | + echo " Register tool schemas from hooks/openai-tools.json with your assistant" | |
| 94 | +} | |
| 95 | + | |
| 96 | +case "$INSTALL_AGENT" in | |
| 97 | + claude) echo "→ Installing Claude Code hook..."; install_claude_hook ;; | |
| 98 | + gemini) echo "→ Installing Gemini CLI hook..."; install_gemini_hook ;; | |
| 99 | + openai) echo "→ Installing OpenAI hook..."; install_openai_hook ;; | |
| 100 | + "") ;; | |
| 101 | + *) echo "Unknown agent: $INSTALL_AGENT (use claude|gemini|openai)" ;; | |
| 102 | +esac | |
| 103 | + | |
| 104 | +# ── Stats ───────────────────────────────────────────────────────────────────── | |
| 105 | +echo "" | |
| 106 | +echo "→ Graph stats:" | |
| 107 | +navegador --db "$NAV_DB" stats 2>/dev/null || true | |
| 108 | + | |
| 109 | +echo "" | |
| 110 | +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | |
| 111 | +echo " Done. Quick start:" | |
| 112 | +echo " navegador search \"your query\"" | |
| 113 | +echo " navegador explain MyClass" | |
| 114 | +echo " navegador stats" | |
| 115 | +echo " navegador add concept \"Payment\" --domain billing" | |
| 116 | +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| --- a/hooks/bootstrap.sh | |
| +++ b/hooks/bootstrap.sh | |
| @@ -0,0 +1,116 @@ | |
| --- a/hooks/bootstrap.sh | |
| +++ b/hooks/bootstrap.sh | |
| @@ -0,0 +1,116 @@ | |
| 1 | #!/usr/bin/env bash |
| 2 | # navegador bootstrap — install, initialise, and ingest a project |
| 3 | # |
| 4 | # Usage: |
| 5 | # curl -fsSL https://raw.githubusercontent.com/ConflictHQ/navegador/main/hooks/bootstrap.sh | bash |
| 6 | # # or locally: |
| 7 | # bash hooks/bootstrap.sh [--repo owner/repo] [--wiki] [--agent claude|gemini|openai] |
| 8 | |
| 9 | set -euo pipefail |
| 10 | |
| 11 | NAV_DB="${NAVEGADOR_DB:-.navegador/graph.db}" |
| 12 | REPO_PATH="${REPO_PATH:-.}" |
| 13 | GITHUB_REPO="${GITHUB_REPO:-}" |
| 14 | INSTALL_AGENT="${INSTALL_AGENT:-}" |
| 15 | INGEST_WIKI=false |
| 16 | |
| 17 | # ── Parse args ──────────────────────────────────────────────────────────────── |
| 18 | while [[ $# -gt 0 ]]; do |
| 19 | case $1 in |
| 20 | --repo) GITHUB_REPO="$2"; shift 2 ;; |
| 21 | --wiki) INGEST_WIKI=true; shift ;; |
| 22 | --agent) INSTALL_AGENT="$2"; shift 2 ;; |
| 23 | --db) NAV_DB="$2"; shift 2 ;; |
| 24 | *) echo "Unknown option: $1"; exit 1 ;; |
| 25 | esac |
| 26 | done |
| 27 | |
| 28 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 29 | echo " Navegador bootstrap" |
| 30 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 31 | |
| 32 | # ── Install ─────────────────────────────────────────────────────────────────── |
| 33 | if ! command -v navegador &>/dev/null; then |
| 34 | echo "→ Installing navegador..." |
| 35 | pip install "navegador[sqlite]" --quiet |
| 36 | else |
| 37 | echo "→ navegador $(navegador --version 2>&1 | head -1) already installed" |
| 38 | fi |
| 39 | |
| 40 | # ── Initialise DB directory ─────────────────────────────────────────────────── |
| 41 | mkdir -p "$(dirname "$NAV_DB")" |
| 42 | echo "→ Graph DB: $NAV_DB" |
| 43 | |
| 44 | # ── Ingest code ─────────────────────────────────────────────────────────────── |
| 45 | echo "→ Ingesting code from $REPO_PATH ..." |
| 46 | navegador --db "$NAV_DB" ingest "$REPO_PATH" --json | \ |
| 47 | python3 -c "import json,sys; d=json.load(sys.stdin); print(f\" files={d['files']} functions={d['functions']} classes={d['classes']} edges={d['edges']}\")" |
| 48 | |
| 49 | # ── Ingest wiki ─────────────────────────────────────────────────────────────── |
| 50 | if [[ "$INGEST_WIKI" == "true" && -n "$GITHUB_REPO" ]]; then |
| 51 | echo "→ Ingesting GitHub wiki for $GITHUB_REPO ..." |
| 52 | navegador --db "$NAV_DB" wiki ingest --repo "$GITHUB_REPO" ${GITHUB_TOKEN:+--token "$GITHUB_TOKEN"} || true |
| 53 | fi |
| 54 | |
| 55 | # ── Install agent hook ──────────────────────────────────────────────────────── |
| 56 | HOOK_SRC_BASE="https://raw.githubusercontent.com/ConflictHQ/navegador/main/hooks" |
| 57 | |
| 58 | install_claude_hook() { |
| 59 | mkdir -p .claude/hooks |
| 60 | curl -fsSL "$HOOK_SRC_BASE/claude-hook.py" -o .claude/hooks/navegador.py |
| 61 | chmod +x .claude/hooks/navegador.py |
| 62 | |
| 63 | SETTINGS=".claude/settings.json" |
| 64 | if [[ ! -f "$SETTINGS" ]]; then |
| 65 | cat > "$SETTINGS" <<'JSON' |
| 66 | { |
| 67 | "hooks": { |
| 68 | "PostToolUse": [ |
| 69 | { |
| 70 | "matcher": "Edit|Write", |
| 71 | "hooks": [{ "type": "command", "command": "python3 .claude/hooks/navegador.py" }] |
| 72 | } |
| 73 | ] |
| 74 | } |
| 75 | } |
| 76 | JSON |
| 77 | echo " Created $SETTINGS" |
| 78 | else |
| 79 | echo " $SETTINGS exists — add the hook manually (see .claude/hooks/navegador.py)" |
| 80 | fi |
| 81 | } |
| 82 | |
| 83 | install_gemini_hook() { |
| 84 | mkdir -p .gemini/hooks |
| 85 | curl -fsSL "$HOOK_SRC_BASE/gemini-hook.py" -o .gemini/hooks/navegador.py |
| 86 | chmod +x .gemini/hooks/navegador.py |
| 87 | echo " Add to GEMINI.md: python3 .gemini/hooks/navegador.py <tool> <file>" |
| 88 | } |
| 89 | |
| 90 | install_openai_hook() { |
| 91 | curl -fsSL "$HOOK_SRC_BASE/openai-hook.py" -o navegador-openai.py |
| 92 | chmod +x navegador-openai.py |
| 93 | echo " Register tool schemas from hooks/openai-tools.json with your assistant" |
| 94 | } |
| 95 | |
| 96 | case "$INSTALL_AGENT" in |
| 97 | claude) echo "→ Installing Claude Code hook..."; install_claude_hook ;; |
| 98 | gemini) echo "→ Installing Gemini CLI hook..."; install_gemini_hook ;; |
| 99 | openai) echo "→ Installing OpenAI hook..."; install_openai_hook ;; |
| 100 | "") ;; |
| 101 | *) echo "Unknown agent: $INSTALL_AGENT (use claude|gemini|openai)" ;; |
| 102 | esac |
| 103 | |
| 104 | # ── Stats ───────────────────────────────────────────────────────────────────── |
| 105 | echo "" |
| 106 | echo "→ Graph stats:" |
| 107 | navegador --db "$NAV_DB" stats 2>/dev/null || true |
| 108 | |
| 109 | echo "" |
| 110 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
| 111 | echo " Done. Quick start:" |
| 112 | echo " navegador search \"your query\"" |
| 113 | echo " navegador explain MyClass" |
| 114 | echo " navegador stats" |
| 115 | echo " navegador add concept \"Payment\" --domain billing" |
| 116 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" |
+98
| --- a/hooks/claude-hook.py | ||
| +++ b/hooks/claude-hook.py | ||
| @@ -0,0 +1,98 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +Navegador hook for Claude Code. | |
| 4 | + | |
| 5 | +Fires on PostToolUse to keep the knowledge graph in sync as Claude works: | |
| 6 | + - Tracks files Claude edits or creates | |
| 7 | + - Re-ingests changed files into the graph | |
| 8 | + - Logs decisions and notes Claude produces (when it writes to DECISIONS.md) | |
| 9 | + | |
| 10 | +Install: | |
| 11 | + Copy to your project root as .claude/hooks/navegador.py | |
| 12 | + Add to .claude/settings.json: | |
| 13 | + | |
| 14 | + { | |
| 15 | + "hooks": { | |
| 16 | + "PostToolUse": [ | |
| 17 | + { | |
| 18 | + "matcher": "Edit|Write|Bash", | |
| 19 | + "hooks": [{ "type": "command", "command": "python3 .claude/hooks/navegador.py" }] | |
| 20 | + } | |
| 21 | + ] | |
| 22 | + } | |
| 23 | + } | |
| 24 | +""" | |
| 25 | + | |
| 26 | +import json | |
| 27 | +import os | |
| 28 | +import subprocess | |
| 29 | +import sys | |
| 30 | + | |
| 31 | +NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db") | |
| 32 | +NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador") | |
| 33 | + | |
| 34 | +# File extensions that navegador can ingest | |
| 35 | +INGESTABLE = {".py", ".ts", ".tsx", ".js", ".jsx"} | |
| 36 | + | |
| 37 | + | |
| 38 | +def run_nav(*args) -> str: | |
| 39 | + result = subprocess.run( | |
| 40 | + [NAV_CMD, "--db", NAV_DB, *args], | |
| 41 | + capture_output=True, text=True, | |
| 42 | + ) | |
| 43 | + return result.stdout.strip() | |
| 44 | + | |
| 45 | + | |
| 46 | +def main(): | |
| 47 | + try: | |
| 48 | + payload = json.loads(sys.stdin.read()) | |
| 49 | + except Exception: | |
| 50 | + sys.exit(0) | |
| 51 | + | |
| 52 | + tool = payload.get("tool_name", "") | |
| 53 | + inp = payload.get("tool_input", {}) | |
| 54 | + | |
| 55 | + # Re-ingest any source file Claude edited | |
| 56 | + if tool in ("Edit", "Write"): | |
| 57 | + file_path = inp.get("file_path", "") | |
| 58 | + ext = os.path.splitext(file_path)[1] | |
| 59 | + if ext in INGESTABLE and os.path.exists(file_path): | |
| 60 | + # Ingest just the repo containing this file (fast on small repos) | |
| 61 | + repo_root = _find_repo_root(file_path) | |
| 62 | + if repo_root: | |
| 63 | + run_nav("ingest", repo_root) | |
| 64 | + | |
| 65 | + # Watch for DECISIONS.md updates — extract new entries into the graph | |
| 66 | + if tool in ("Edit", "Write"): | |
| 67 | + if inp.get("file_path", "").endswith("DECISIONS.md"): | |
| 68 | + _sync_decisions() | |
| 69 | + | |
| 70 | + | |
| 71 | +def _find_repo_root(path: str) -> str | None: | |
| 72 | + """Walk up to find the git root.""" | |
| 73 | + d = os.path.dirname(os.path.abspath(path)) | |
| 74 | + while d != os.path.dirname(d): | |
| 75 | + if os.path.exists(os.path.join(d, ".git")): | |
| 76 | + return d | |
| 77 | + d = os.path.dirname(d) | |
| 78 | + return None | |
| 79 | + | |
| 80 | + | |
| 81 | +def _sync_decisions(): | |
| 82 | + """Parse DECISIONS.md and upsert Decision nodes.""" | |
| 83 | + if not os.path.exists("DECISIONS.md"): | |
| 84 | + return | |
| 85 | + content = open("DECISIONS.md").read() | |
| 86 | + # Simple heuristic: ## headings are decision names | |
| 87 | + import re | |
| 88 | + for match in re.finditer(r"^##\s+(.+)", content, re.MULTILINE): | |
| 89 | + name = match.group(1).strip() | |
| 90 | + # Find the body until the next heading | |
| 91 | + start = match.end() | |
| 92 | + next_h = re.search(r"^##", content[start:], re.MULTILINE) | |
| 93 | + body = content[start: start + next_h.start() if next_h else len(content)].strip() | |
| 94 | + run_nav("add", "decision", name, "--desc", body[:500]) | |
| 95 | + | |
| 96 | + | |
| 97 | +if __name__ == "__main__": | |
| 98 | + main() |
| --- a/hooks/claude-hook.py | |
| +++ b/hooks/claude-hook.py | |
| @@ -0,0 +1,98 @@ | |
| --- a/hooks/claude-hook.py | |
| +++ b/hooks/claude-hook.py | |
| @@ -0,0 +1,98 @@ | |
| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | Navegador hook for Claude Code. |
| 4 | |
| 5 | Fires on PostToolUse to keep the knowledge graph in sync as Claude works: |
| 6 | - Tracks files Claude edits or creates |
| 7 | - Re-ingests changed files into the graph |
| 8 | - Logs decisions and notes Claude produces (when it writes to DECISIONS.md) |
| 9 | |
| 10 | Install: |
| 11 | Copy to your project root as .claude/hooks/navegador.py |
| 12 | Add to .claude/settings.json: |
| 13 | |
| 14 | { |
| 15 | "hooks": { |
| 16 | "PostToolUse": [ |
| 17 | { |
| 18 | "matcher": "Edit|Write|Bash", |
| 19 | "hooks": [{ "type": "command", "command": "python3 .claude/hooks/navegador.py" }] |
| 20 | } |
| 21 | ] |
| 22 | } |
| 23 | } |
| 24 | """ |
| 25 | |
| 26 | import json |
| 27 | import os |
| 28 | import subprocess |
| 29 | import sys |
| 30 | |
| 31 | NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db") |
| 32 | NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador") |
| 33 | |
| 34 | # File extensions that navegador can ingest |
| 35 | INGESTABLE = {".py", ".ts", ".tsx", ".js", ".jsx"} |
| 36 | |
| 37 | |
| 38 | def run_nav(*args) -> str: |
| 39 | result = subprocess.run( |
| 40 | [NAV_CMD, "--db", NAV_DB, *args], |
| 41 | capture_output=True, text=True, |
| 42 | ) |
| 43 | return result.stdout.strip() |
| 44 | |
| 45 | |
| 46 | def main(): |
| 47 | try: |
| 48 | payload = json.loads(sys.stdin.read()) |
| 49 | except Exception: |
| 50 | sys.exit(0) |
| 51 | |
| 52 | tool = payload.get("tool_name", "") |
| 53 | inp = payload.get("tool_input", {}) |
| 54 | |
| 55 | # Re-ingest any source file Claude edited |
| 56 | if tool in ("Edit", "Write"): |
| 57 | file_path = inp.get("file_path", "") |
| 58 | ext = os.path.splitext(file_path)[1] |
| 59 | if ext in INGESTABLE and os.path.exists(file_path): |
| 60 | # Ingest just the repo containing this file (fast on small repos) |
| 61 | repo_root = _find_repo_root(file_path) |
| 62 | if repo_root: |
| 63 | run_nav("ingest", repo_root) |
| 64 | |
| 65 | # Watch for DECISIONS.md updates — extract new entries into the graph |
| 66 | if tool in ("Edit", "Write"): |
| 67 | if inp.get("file_path", "").endswith("DECISIONS.md"): |
| 68 | _sync_decisions() |
| 69 | |
| 70 | |
| 71 | def _find_repo_root(path: str) -> str | None: |
| 72 | """Walk up to find the git root.""" |
| 73 | d = os.path.dirname(os.path.abspath(path)) |
| 74 | while d != os.path.dirname(d): |
| 75 | if os.path.exists(os.path.join(d, ".git")): |
| 76 | return d |
| 77 | d = os.path.dirname(d) |
| 78 | return None |
| 79 | |
| 80 | |
| 81 | def _sync_decisions(): |
| 82 | """Parse DECISIONS.md and upsert Decision nodes.""" |
| 83 | if not os.path.exists("DECISIONS.md"): |
| 84 | return |
| 85 | content = open("DECISIONS.md").read() |
| 86 | # Simple heuristic: ## headings are decision names |
| 87 | import re |
| 88 | for match in re.finditer(r"^##\s+(.+)", content, re.MULTILINE): |
| 89 | name = match.group(1).strip() |
| 90 | # Find the body until the next heading |
| 91 | start = match.end() |
| 92 | next_h = re.search(r"^##", content[start:], re.MULTILINE) |
| 93 | body = content[start: start + next_h.start() if next_h else len(content)].strip() |
| 94 | run_nav("add", "decision", name, "--desc", body[:500]) |
| 95 | |
| 96 | |
| 97 | if __name__ == "__main__": |
| 98 | main() |
+60
| --- a/hooks/gemini-hook.py | ||
| +++ b/hooks/gemini-hook.py | ||
| @@ -0,0 +1,60 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +Navegador hook for Gemini CLI (gemini-cli). | |
| 4 | + | |
| 5 | +Gemini CLI supports tool hooks via GEMINI.md + shell scripts executed | |
| 6 | +after tool calls. This script is designed to be invoked as a post-tool hook. | |
| 7 | + | |
| 8 | +Install: | |
| 9 | + Copy to your project root as .gemini/hooks/navegador.py | |
| 10 | + Reference in GEMINI.md: | |
| 11 | + | |
| 12 | + ## Hooks | |
| 13 | + After editing or creating any source file, run: | |
| 14 | + python3 .gemini/hooks/navegador.py <tool_name> <file_path> | |
| 15 | + | |
| 16 | + This keeps the navegador knowledge graph in sync with your changes. | |
| 17 | + | |
| 18 | +Usage (called by gemini-cli hook runner): | |
| 19 | + python3 navegador.py edit src/auth.py | |
| 20 | + python3 navegador.py write src/new_module.py | |
| 21 | +""" | |
| 22 | + | |
| 23 | +import os | |
| 24 | +import subprocess | |
| 25 | +import sys | |
| 26 | + | |
| 27 | +NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db") | |
| 28 | +NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador") | |
| 29 | +INGESTABLE = {".py", ".ts", ".tsx", ".js", ".jsx"} | |
| 30 | + | |
| 31 | + | |
| 32 | +def run_nav(*args): | |
| 33 | + subprocess.run([NAV_CMD, "--db", NAV_DB, *args], capture_output=True) | |
| 34 | + | |
| 35 | + | |
| 36 | +def main(): | |
| 37 | + args = sys.argv[1:] | |
| 38 | + if len(args) < 2: | |
| 39 | + sys.exit(0) | |
| 40 | + | |
| 41 | + _tool, file_path = args[0], args[1] | |
| 42 | + ext = os.path.splitext(file_path)[1] | |
| 43 | + | |
| 44 | + if ext in INGESTABLE and os.path.exists(file_path): | |
| 45 | + repo_root = _find_repo_root(file_path) | |
| 46 | + if repo_root: | |
| 47 | + run_nav("ingest", repo_root) | |
| 48 | + | |
| 49 | + | |
| 50 | +def _find_repo_root(path: str) -> str | None: | |
| 51 | + d = os.path.dirname(os.path.abspath(path)) | |
| 52 | + while d != os.path.dirname(d): | |
| 53 | + if os.path.exists(os.path.join(d, ".git")): | |
| 54 | + return d | |
| 55 | + d = os.path.dirname(d) | |
| 56 | + return None | |
| 57 | + | |
| 58 | + | |
| 59 | +if __name__ == "__main__": | |
| 60 | + main() |
| --- a/hooks/gemini-hook.py | |
| +++ b/hooks/gemini-hook.py | |
| @@ -0,0 +1,60 @@ | |
| --- a/hooks/gemini-hook.py | |
| +++ b/hooks/gemini-hook.py | |
| @@ -0,0 +1,60 @@ | |
| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | Navegador hook for Gemini CLI (gemini-cli). |
| 4 | |
| 5 | Gemini CLI supports tool hooks via GEMINI.md + shell scripts executed |
| 6 | after tool calls. This script is designed to be invoked as a post-tool hook. |
| 7 | |
| 8 | Install: |
| 9 | Copy to your project root as .gemini/hooks/navegador.py |
| 10 | Reference in GEMINI.md: |
| 11 | |
| 12 | ## Hooks |
| 13 | After editing or creating any source file, run: |
| 14 | python3 .gemini/hooks/navegador.py <tool_name> <file_path> |
| 15 | |
| 16 | This keeps the navegador knowledge graph in sync with your changes. |
| 17 | |
| 18 | Usage (called by gemini-cli hook runner): |
| 19 | python3 navegador.py edit src/auth.py |
| 20 | python3 navegador.py write src/new_module.py |
| 21 | """ |
| 22 | |
| 23 | import os |
| 24 | import subprocess |
| 25 | import sys |
| 26 | |
| 27 | NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db") |
| 28 | NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador") |
| 29 | INGESTABLE = {".py", ".ts", ".tsx", ".js", ".jsx"} |
| 30 | |
| 31 | |
| 32 | def run_nav(*args): |
| 33 | subprocess.run([NAV_CMD, "--db", NAV_DB, *args], capture_output=True) |
| 34 | |
| 35 | |
| 36 | def main(): |
| 37 | args = sys.argv[1:] |
| 38 | if len(args) < 2: |
| 39 | sys.exit(0) |
| 40 | |
| 41 | _tool, file_path = args[0], args[1] |
| 42 | ext = os.path.splitext(file_path)[1] |
| 43 | |
| 44 | if ext in INGESTABLE and os.path.exists(file_path): |
| 45 | repo_root = _find_repo_root(file_path) |
| 46 | if repo_root: |
| 47 | run_nav("ingest", repo_root) |
| 48 | |
| 49 | |
| 50 | def _find_repo_root(path: str) -> str | None: |
| 51 | d = os.path.dirname(os.path.abspath(path)) |
| 52 | while d != os.path.dirname(d): |
| 53 | if os.path.exists(os.path.join(d, ".git")): |
| 54 | return d |
| 55 | d = os.path.dirname(d) |
| 56 | return None |
| 57 | |
| 58 | |
| 59 | if __name__ == "__main__": |
| 60 | main() |
+73
| --- a/hooks/openai-hook.py | ||
| +++ b/hooks/openai-hook.py | ||
| @@ -0,0 +1,73 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +Navegador hook for OpenAI Codex / ChatGPT with function calling. | |
| 4 | + | |
| 5 | +OpenAI agents can call navegador directly as a function tool. Register the | |
| 6 | +functions below in your assistant's tool list, then call this script to | |
| 7 | +dispatch them. | |
| 8 | + | |
| 9 | +Install: | |
| 10 | + Use the function schemas in openai-tools.json alongside this dispatcher. | |
| 11 | + Your agent calls: python3 navegador-openai.py <json_args> | |
| 12 | + | |
| 13 | +Example tool call your agent would make: | |
| 14 | + {"name": "nav_search", "arguments": {"query": "authentication", "all": true}} | |
| 15 | + | |
| 16 | +This script reads JSON from stdin or argv[1] and dispatches to navegador CLI. | |
| 17 | +""" | |
| 18 | + | |
| 19 | +import json | |
| 20 | +import os | |
| 21 | +import subprocess | |
| 22 | +import sys | |
| 23 | + | |
| 24 | +NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db") | |
| 25 | +NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador") | |
| 26 | + | |
| 27 | +DISPATCH = { | |
| 28 | + "nav_ingest": lambda a: [NAV_CMD, "ingest", a["path"], "--json"], | |
| 29 | + "nav_context": lambda a: [NAV_CMD, "context", a["file_path"], "--format", "json"], | |
| 30 | + "nav_function": lambda a: [NAV_CMD, "function", a["name"], | |
| 31 | + "--file", a.get("file_path", ""), "--format", "json"], | |
| 32 | + "nav_class": lambda a: [NAV_CMD, "class", a["name"], | |
| 33 | + "--file", a.get("file_path", ""), "--format", "json"], | |
| 34 | + "nav_explain": lambda a: [NAV_CMD, "explain", a["name"], "--format", "json"], | |
| 35 | + "nav_search": lambda a: [NAV_CMD, "search", a["query"], | |
| 36 | + "--format", "json", | |
| 37 | + *( ["--all"] if a.get("all") else [] ), | |
| 38 | + *( ["--docs"] if a.get("by_docstring") else [] )], | |
| 39 | + "nav_concept": lambda a: [NAV_CMD, "concept", a["name"], "--format", "json"], | |
| 40 | + "nav_domain": lambda a: [NAV_CMD, "domain", a["name"], "--format", "json"], | |
| 41 | + "nav_stats": lambda a: [NAV_CMD, "stats", "--json"], | |
| 42 | + "nav_query": lambda a: [NAV_CMD, "query", a["cypher"]], | |
| 43 | + "nav_decorated": lambda a: [NAV_CMD, "decorated", a["decorator"], "--format", "json"], | |
| 44 | +} | |
| 45 | + | |
| 46 | + | |
| 47 | +def main(): | |
| 48 | + raw = sys.argv[1] if len(sys.argv) > 1 else sys.stdin.read() | |
| 49 | + try: | |
| 50 | + call = json.loads(raw) | |
| 51 | + except json.JSONDecodeError as e: | |
| 52 | + print(json.dumps({"error": str(e)})) | |
| 53 | + sys.exit(1) | |
| 54 | + | |
| 55 | + name = call.get("name") or call.get("function", {}).get("name", "") | |
| 56 | + arguments = call.get("arguments") or call.get("function", {}).get("arguments", {}) | |
| 57 | + if isinstance(arguments, str): | |
| 58 | + arguments = json.loads(arguments) | |
| 59 | + | |
| 60 | + if name not in DISPATCH: | |
| 61 | + print(json.dumps({"error": f"Unknown tool: {name}"})) | |
| 62 | + sys.exit(1) | |
| 63 | + | |
| 64 | + cmd = DISPATCH[name](arguments) | |
| 65 | + result = subprocess.run( | |
| 66 | + ["--db", NAV_DB] and ([cmd[0]] + ["--db", NAV_DB] + cmd[1:]), | |
| 67 | + capture_output=True, text=True, | |
| 68 | + ) | |
| 69 | + sys.stdout.write(result.stdout or result.stderr) | |
| 70 | + | |
| 71 | + | |
| 72 | +if __name__ == "__main__": | |
| 73 | + main() |
| --- a/hooks/openai-hook.py | |
| +++ b/hooks/openai-hook.py | |
| @@ -0,0 +1,73 @@ | |
| --- a/hooks/openai-hook.py | |
| +++ b/hooks/openai-hook.py | |
| @@ -0,0 +1,73 @@ | |
| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | Navegador hook for OpenAI Codex / ChatGPT with function calling. |
| 4 | |
| 5 | OpenAI agents can call navegador directly as a function tool. Register the |
| 6 | functions below in your assistant's tool list, then call this script to |
| 7 | dispatch them. |
| 8 | |
| 9 | Install: |
| 10 | Use the function schemas in openai-tools.json alongside this dispatcher. |
| 11 | Your agent calls: python3 navegador-openai.py <json_args> |
| 12 | |
| 13 | Example tool call your agent would make: |
| 14 | {"name": "nav_search", "arguments": {"query": "authentication", "all": true}} |
| 15 | |
| 16 | This script reads JSON from stdin or argv[1] and dispatches to navegador CLI. |
| 17 | """ |
| 18 | |
| 19 | import json |
| 20 | import os |
| 21 | import subprocess |
| 22 | import sys |
| 23 | |
| 24 | NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db") |
| 25 | NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador") |
| 26 | |
| 27 | DISPATCH = { |
| 28 | "nav_ingest": lambda a: [NAV_CMD, "ingest", a["path"], "--json"], |
| 29 | "nav_context": lambda a: [NAV_CMD, "context", a["file_path"], "--format", "json"], |
| 30 | "nav_function": lambda a: [NAV_CMD, "function", a["name"], |
| 31 | "--file", a.get("file_path", ""), "--format", "json"], |
| 32 | "nav_class": lambda a: [NAV_CMD, "class", a["name"], |
| 33 | "--file", a.get("file_path", ""), "--format", "json"], |
| 34 | "nav_explain": lambda a: [NAV_CMD, "explain", a["name"], "--format", "json"], |
| 35 | "nav_search": lambda a: [NAV_CMD, "search", a["query"], |
| 36 | "--format", "json", |
| 37 | *( ["--all"] if a.get("all") else [] ), |
| 38 | *( ["--docs"] if a.get("by_docstring") else [] )], |
| 39 | "nav_concept": lambda a: [NAV_CMD, "concept", a["name"], "--format", "json"], |
| 40 | "nav_domain": lambda a: [NAV_CMD, "domain", a["name"], "--format", "json"], |
| 41 | "nav_stats": lambda a: [NAV_CMD, "stats", "--json"], |
| 42 | "nav_query": lambda a: [NAV_CMD, "query", a["cypher"]], |
| 43 | "nav_decorated": lambda a: [NAV_CMD, "decorated", a["decorator"], "--format", "json"], |
| 44 | } |
| 45 | |
| 46 | |
| 47 | def main(): |
| 48 | raw = sys.argv[1] if len(sys.argv) > 1 else sys.stdin.read() |
| 49 | try: |
| 50 | call = json.loads(raw) |
| 51 | except json.JSONDecodeError as e: |
| 52 | print(json.dumps({"error": str(e)})) |
| 53 | sys.exit(1) |
| 54 | |
| 55 | name = call.get("name") or call.get("function", {}).get("name", "") |
| 56 | arguments = call.get("arguments") or call.get("function", {}).get("arguments", {}) |
| 57 | if isinstance(arguments, str): |
| 58 | arguments = json.loads(arguments) |
| 59 | |
| 60 | if name not in DISPATCH: |
| 61 | print(json.dumps({"error": f"Unknown tool: {name}"})) |
| 62 | sys.exit(1) |
| 63 | |
| 64 | cmd = DISPATCH[name](arguments) |
| 65 | result = subprocess.run( |
| 66 | ["--db", NAV_DB] and ([cmd[0]] + ["--db", NAV_DB] + cmd[1:]), |
| 67 | capture_output=True, text=True, |
| 68 | ) |
| 69 | sys.stdout.write(result.stdout or result.stderr) |
| 70 | |
| 71 | |
| 72 | if __name__ == "__main__": |
| 73 | main() |
+159
| --- a/hooks/openai-tools.json | ||
| +++ b/hooks/openai-tools.json | ||
| @@ -0,0 +1,159 @@ | ||
| 1 | +[ | |
| 2 | + { | |
| 3 | + "type": "function", | |
| 4 | + "function": { | |
| 5 | + "name": "nav_search", | |
| 6 | + "description": "Search the project knowledge graph for symbols, concepts, rules, and wiki pages. Set all=true to include the knowledge layer.", | |
| 7 | + "parameters": { | |
| 8 | + "type": "object", | |
| 9 | + "properties": { | |
| 10 | + "query": { "type": "string", "description": "Search term" }, | |
| 11 | + "all": { "type": "boolean", "description": "Include concepts, rules, decisions, wiki (default: false)" }, | |
| 12 | + "by_docstring": { "type": "boolean", "description": "Search docstring content instead of names" } | |
| 13 | + }, | |
| 14 | + "required": ["query"] | |
| 15 | + } | |
| 16 | + } | |
| 17 | + }, | |
| 18 | + { | |
| 19 | + "type": "function", | |
| 20 | + "function": { | |
| 21 | + "name": "nav_explain", | |
| 22 | + "description": "Full picture for any named node — all inbound and outbound relationships across code and knowledge layers.", | |
| 23 | + "parameters": { | |
| 24 | + "type": "object", | |
| 25 | + "properties": { | |
| 26 | + "name": { "type": "string" }, | |
| 27 | + "file_path": { "type": "string", "description": "Optional — narrow to a specific file" } | |
| 28 | + }, | |
| 29 | + "required": ["name"] | |
| 30 | + } | |
| 31 | + } | |
| 32 | + }, | |
| 33 | + { | |
| 34 | + "type": "function", | |
| 35 | + "function": { | |
| 36 | + "name": "nav_function", | |
| 37 | + "description": "Load context for a function — callers, callees, and decorators.", | |
| 38 | + "parameters": { | |
| 39 | + "type": "object", | |
| 40 | + "properties": { | |
| 41 | + "name": { "type": "string" }, | |
| 42 | + "file_path": { "type": "string" }, | |
| 43 | + "depth": { "type": "integer", "default": 2 } | |
| 44 | + }, | |
| 45 | + "required": ["name"] | |
| 46 | + } | |
| 47 | + } | |
| 48 | + }, | |
| 49 | + { | |
| 50 | + "type": "function", | |
| 51 | + "function": { | |
| 52 | + "name": "nav_class", | |
| 53 | + "description": "Load context for a class — methods, inheritance chain, and references.", | |
| 54 | + "parameters": { | |
| 55 | + "type": "object", | |
| 56 | + "properties": { | |
| 57 | + "name": { "type": "string" }, | |
| 58 | + "file_path": { "type": "string" } | |
| 59 | + }, | |
| 60 | + "required": ["name"] | |
| 61 | + } | |
| 62 | + } | |
| 63 | + }, | |
| 64 | + { | |
| 65 | + "type": "function", | |
| 66 | + "function": { | |
| 67 | + "name": "nav_context", | |
| 68 | + "description": "All symbols in a file — functions, classes, imports — with line numbers and docstrings.", | |
| 69 | + "parameters": { | |
| 70 | + "type": "object", | |
| 71 | + "properties": { | |
| 72 | + "file_path": { "type": "string" } | |
| 73 | + }, | |
| 74 | + "required": ["file_path"] | |
| 75 | + } | |
| 76 | + } | |
| 77 | + }, | |
| 78 | + { | |
| 79 | + "type": "function", | |
| 80 | + "function": { | |
| 81 | + "name": "nav_concept", | |
| 82 | + "description": "Load a business concept — governing rules, related concepts, implementing code, and wiki references.", | |
| 83 | + "parameters": { | |
| 84 | + "type": "object", | |
| 85 | + "properties": { | |
| 86 | + "name": { "type": "string" } | |
| 87 | + }, | |
| 88 | + "required": ["name"] | |
| 89 | + } | |
| 90 | + } | |
| 91 | + }, | |
| 92 | + { | |
| 93 | + "type": "function", | |
| 94 | + "function": { | |
| 95 | + "name": "nav_domain", | |
| 96 | + "description": "Show everything in a business domain — code and knowledge nodes.", | |
| 97 | + "parameters": { | |
| 98 | + "type": "object", | |
| 99 | + "properties": { | |
| 100 | + "name": { "type": "string" } | |
| 101 | + }, | |
| 102 | + "required": ["name"] | |
| 103 | + } | |
| 104 | + } | |
| 105 | + }, | |
| 106 | + { | |
| 107 | + "type": "function", | |
| 108 | + "function": { | |
| 109 | + "name": "nav_decorated", | |
| 110 | + "description": "Find all functions/methods carrying a specific decorator (e.g. login_required, cached).", | |
| 111 | + "parameters": { | |
| 112 | + "type": "object", | |
| 113 | + "properties": { | |
| 114 | + "decorator": { "type": "string" } | |
| 115 | + }, | |
| 116 | + "required": ["decorator"] | |
| 117 | + } | |
| 118 | + } | |
| 119 | + }, | |
| 120 | + { | |
| 121 | + "type": "function", | |
| 122 | + "function": { | |
| 123 | + "name": "nav_query", | |
| 124 | + "description": "Execute a raw Cypher query against the knowledge graph. Returns JSON.", | |
| 125 | + "parameters": { | |
| 126 | + "type": "object", | |
| 127 | + "properties": { | |
| 128 | + "cypher": { "type": "string" } | |
| 129 | + }, | |
| 130 | + "required": ["cypher"] | |
| 131 | + } | |
| 132 | + } | |
| 133 | + }, | |
| 134 | + { | |
| 135 | + "type": "function", | |
| 136 | + "function": { | |
| 137 | + "name": "nav_stats", | |
| 138 | + "description": "Node and edge counts broken down by type — shows what's in the graph.", | |
| 139 | + "parameters": { | |
| 140 | + "type": "object", | |
| 141 | + "properties": {} | |
| 142 | + } | |
| 143 | + } | |
| 144 | + }, | |
| 145 | + { | |
| 146 | + "type": "function", | |
| 147 | + "function": { | |
| 148 | + "name": "nav_ingest", | |
| 149 | + "description": "Ingest a code repository into the knowledge graph (AST parsing + call graph).", | |
| 150 | + "parameters": { | |
| 151 | + "type": "object", | |
| 152 | + "properties": { | |
| 153 | + "path": { "type": "string", "description": "Absolute path to the repository root" } | |
| 154 | + }, | |
| 155 | + "required": ["path"] | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | +] |
| --- a/hooks/openai-tools.json | |
| +++ b/hooks/openai-tools.json | |
| @@ -0,0 +1,159 @@ | |
| --- a/hooks/openai-tools.json | |
| +++ b/hooks/openai-tools.json | |
| @@ -0,0 +1,159 @@ | |
| 1 | [ |
| 2 | { |
| 3 | "type": "function", |
| 4 | "function": { |
| 5 | "name": "nav_search", |
| 6 | "description": "Search the project knowledge graph for symbols, concepts, rules, and wiki pages. Set all=true to include the knowledge layer.", |
| 7 | "parameters": { |
| 8 | "type": "object", |
| 9 | "properties": { |
| 10 | "query": { "type": "string", "description": "Search term" }, |
| 11 | "all": { "type": "boolean", "description": "Include concepts, rules, decisions, wiki (default: false)" }, |
| 12 | "by_docstring": { "type": "boolean", "description": "Search docstring content instead of names" } |
| 13 | }, |
| 14 | "required": ["query"] |
| 15 | } |
| 16 | } |
| 17 | }, |
| 18 | { |
| 19 | "type": "function", |
| 20 | "function": { |
| 21 | "name": "nav_explain", |
| 22 | "description": "Full picture for any named node — all inbound and outbound relationships across code and knowledge layers.", |
| 23 | "parameters": { |
| 24 | "type": "object", |
| 25 | "properties": { |
| 26 | "name": { "type": "string" }, |
| 27 | "file_path": { "type": "string", "description": "Optional — narrow to a specific file" } |
| 28 | }, |
| 29 | "required": ["name"] |
| 30 | } |
| 31 | } |
| 32 | }, |
| 33 | { |
| 34 | "type": "function", |
| 35 | "function": { |
| 36 | "name": "nav_function", |
| 37 | "description": "Load context for a function — callers, callees, and decorators.", |
| 38 | "parameters": { |
| 39 | "type": "object", |
| 40 | "properties": { |
| 41 | "name": { "type": "string" }, |
| 42 | "file_path": { "type": "string" }, |
| 43 | "depth": { "type": "integer", "default": 2 } |
| 44 | }, |
| 45 | "required": ["name"] |
| 46 | } |
| 47 | } |
| 48 | }, |
| 49 | { |
| 50 | "type": "function", |
| 51 | "function": { |
| 52 | "name": "nav_class", |
| 53 | "description": "Load context for a class — methods, inheritance chain, and references.", |
| 54 | "parameters": { |
| 55 | "type": "object", |
| 56 | "properties": { |
| 57 | "name": { "type": "string" }, |
| 58 | "file_path": { "type": "string" } |
| 59 | }, |
| 60 | "required": ["name"] |
| 61 | } |
| 62 | } |
| 63 | }, |
| 64 | { |
| 65 | "type": "function", |
| 66 | "function": { |
| 67 | "name": "nav_context", |
| 68 | "description": "All symbols in a file — functions, classes, imports — with line numbers and docstrings.", |
| 69 | "parameters": { |
| 70 | "type": "object", |
| 71 | "properties": { |
| 72 | "file_path": { "type": "string" } |
| 73 | }, |
| 74 | "required": ["file_path"] |
| 75 | } |
| 76 | } |
| 77 | }, |
| 78 | { |
| 79 | "type": "function", |
| 80 | "function": { |
| 81 | "name": "nav_concept", |
| 82 | "description": "Load a business concept — governing rules, related concepts, implementing code, and wiki references.", |
| 83 | "parameters": { |
| 84 | "type": "object", |
| 85 | "properties": { |
| 86 | "name": { "type": "string" } |
| 87 | }, |
| 88 | "required": ["name"] |
| 89 | } |
| 90 | } |
| 91 | }, |
| 92 | { |
| 93 | "type": "function", |
| 94 | "function": { |
| 95 | "name": "nav_domain", |
| 96 | "description": "Show everything in a business domain — code and knowledge nodes.", |
| 97 | "parameters": { |
| 98 | "type": "object", |
| 99 | "properties": { |
| 100 | "name": { "type": "string" } |
| 101 | }, |
| 102 | "required": ["name"] |
| 103 | } |
| 104 | } |
| 105 | }, |
| 106 | { |
| 107 | "type": "function", |
| 108 | "function": { |
| 109 | "name": "nav_decorated", |
| 110 | "description": "Find all functions/methods carrying a specific decorator (e.g. login_required, cached).", |
| 111 | "parameters": { |
| 112 | "type": "object", |
| 113 | "properties": { |
| 114 | "decorator": { "type": "string" } |
| 115 | }, |
| 116 | "required": ["decorator"] |
| 117 | } |
| 118 | } |
| 119 | }, |
| 120 | { |
| 121 | "type": "function", |
| 122 | "function": { |
| 123 | "name": "nav_query", |
| 124 | "description": "Execute a raw Cypher query against the knowledge graph. Returns JSON.", |
| 125 | "parameters": { |
| 126 | "type": "object", |
| 127 | "properties": { |
| 128 | "cypher": { "type": "string" } |
| 129 | }, |
| 130 | "required": ["cypher"] |
| 131 | } |
| 132 | } |
| 133 | }, |
| 134 | { |
| 135 | "type": "function", |
| 136 | "function": { |
| 137 | "name": "nav_stats", |
| 138 | "description": "Node and edge counts broken down by type — shows what's in the graph.", |
| 139 | "parameters": { |
| 140 | "type": "object", |
| 141 | "properties": {} |
| 142 | } |
| 143 | } |
| 144 | }, |
| 145 | { |
| 146 | "type": "function", |
| 147 | "function": { |
| 148 | "name": "nav_ingest", |
| 149 | "description": "Ingest a code repository into the knowledge graph (AST parsing + call graph).", |
| 150 | "parameters": { |
| 151 | "type": "object", |
| 152 | "properties": { |
| 153 | "path": { "type": "string", "description": "Absolute path to the repository root" } |
| 154 | }, |
| 155 | "required": ["path"] |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | ] |
+288
-55
| --- navegador/cli/commands.py | ||
| +++ navegador/cli/commands.py | ||
| @@ -1,13 +1,15 @@ | ||
| 1 | 1 | """ |
| 2 | -Navegador CLI — ingest repos, load context, serve MCP. | |
| 2 | +Navegador CLI — the single interface to your project's knowledge graph. | |
| 3 | 3 | |
| 4 | -All commands support --format json for clean stdout output suitable for | |
| 5 | -piping to agents or other tools without MCP overhead. | |
| 4 | + CODE: ingest, context, function, class, search, query | |
| 5 | + KNOWLEDGE: add (concept/rule/decision/person/domain), wiki, annotate, domain | |
| 6 | + UNIVERSAL: explain, search (spans both layers), stats | |
| 6 | 7 | """ |
| 7 | 8 | |
| 8 | 9 | import asyncio |
| 10 | +import json | |
| 9 | 11 | import logging |
| 10 | 12 | |
| 11 | 13 | import click |
| 12 | 14 | from rich.console import Console |
| 13 | 15 | from rich.table import Table |
| @@ -18,12 +20,11 @@ | ||
| 18 | 20 | "--db", default=".navegador/graph.db", show_default=True, help="Graph DB path." |
| 19 | 21 | ) |
| 20 | 22 | FMT_OPTION = click.option( |
| 21 | 23 | "--format", "fmt", |
| 22 | 24 | type=click.Choice(["markdown", "json"]), |
| 23 | - default="markdown", | |
| 24 | - show_default=True, | |
| 25 | + default="markdown", show_default=True, | |
| 25 | 26 | help="Output format. Use json for agent/pipe consumption.", |
| 26 | 27 | ) |
| 27 | 28 | |
| 28 | 29 | |
| 29 | 30 | def _get_store(db: str): |
| @@ -30,33 +31,38 @@ | ||
| 30 | 31 | from navegador.graph import GraphStore |
| 31 | 32 | return GraphStore.sqlite(db) |
| 32 | 33 | |
| 33 | 34 | |
| 34 | 35 | def _emit(text: str, fmt: str) -> None: |
| 35 | - """Print text — raw to stdout for json, rich for markdown.""" | |
| 36 | 36 | if fmt == "json": |
| 37 | 37 | click.echo(text) |
| 38 | 38 | else: |
| 39 | 39 | console.print(text) |
| 40 | 40 | |
| 41 | 41 | |
| 42 | +# ── Root group ──────────────────────────────────────────────────────────────── | |
| 43 | + | |
| 42 | 44 | @click.group() |
| 43 | 45 | @click.version_option(package_name="navegador") |
| 44 | 46 | def main(): |
| 45 | - """Navegador — AST + knowledge graph context engine for AI coding agents.""" | |
| 47 | + """Navegador — project knowledge graph for AI coding agents. | |
| 48 | + | |
| 49 | + Combines code structure (AST, call graphs) with business knowledge | |
| 50 | + (concepts, rules, decisions, wiki) into a single queryable graph. | |
| 51 | + """ | |
| 46 | 52 | logging.basicConfig(level=logging.WARNING) |
| 47 | 53 | |
| 54 | + | |
| 55 | +# ── CODE: ingest ────────────────────────────────────────────────────────────── | |
| 48 | 56 | |
| 49 | 57 | @main.command() |
| 50 | 58 | @click.argument("repo_path", type=click.Path(exists=True)) |
| 51 | 59 | @DB_OPTION |
| 52 | 60 | @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.") |
| 53 | 61 | @click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.") |
| 54 | 62 | def ingest(repo_path: str, db: str, clear: bool, as_json: bool): |
| 55 | - """Ingest a repository into the navegador graph.""" | |
| 56 | - import json | |
| 57 | - | |
| 63 | + """Ingest a repository's code into the graph (AST + call graph).""" | |
| 58 | 64 | from navegador.ingestion import RepoIngester |
| 59 | 65 | |
| 60 | 66 | store = _get_store(db) |
| 61 | 67 | ingester = RepoIngester(store) |
| 62 | 68 | |
| @@ -64,87 +70,139 @@ | ||
| 64 | 70 | stats = ingester.ingest(repo_path, clear=clear) |
| 65 | 71 | click.echo(json.dumps(stats, indent=2)) |
| 66 | 72 | else: |
| 67 | 73 | with console.status(f"[bold]Ingesting[/bold] {repo_path}..."): |
| 68 | 74 | stats = ingester.ingest(repo_path, clear=clear) |
| 69 | - table = Table(title="Ingestion complete", show_header=True) | |
| 75 | + table = Table(title="Ingestion complete") | |
| 70 | 76 | table.add_column("Metric", style="cyan") |
| 71 | 77 | table.add_column("Count", justify="right", style="green") |
| 72 | 78 | for k, v in stats.items(): |
| 73 | 79 | table.add_row(k.capitalize(), str(v)) |
| 74 | 80 | console.print(table) |
| 75 | 81 | |
| 82 | + | |
| 83 | +# ── CODE: context / function / class ───────────────────────────────────────── | |
| 76 | 84 | |
| 77 | 85 | @main.command() |
| 78 | 86 | @click.argument("file_path") |
| 79 | 87 | @DB_OPTION |
| 80 | 88 | @FMT_OPTION |
| 81 | 89 | def context(file_path: str, db: str, fmt: str): |
| 82 | 90 | """Load context for a file — all symbols and their relationships.""" |
| 83 | 91 | from navegador.context import ContextLoader |
| 84 | - | |
| 85 | - loader = ContextLoader(_get_store(db)) | |
| 86 | - bundle = loader.load_file(file_path) | |
| 92 | + bundle = ContextLoader(_get_store(db)).load_file(file_path) | |
| 87 | 93 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 88 | 94 | |
| 89 | 95 | |
| 90 | 96 | @main.command() |
| 91 | 97 | @click.argument("name") |
| 92 | -@click.option("--file", "file_path", default="", help="File path to narrow the search.") | |
| 93 | -@click.option("--depth", default=2, show_default=True, help="Call graph traversal depth.") | |
| 98 | +@click.option("--file", "file_path", default="", help="Narrow to a specific file.") | |
| 99 | +@click.option("--depth", default=2, show_default=True) | |
| 94 | 100 | @DB_OPTION |
| 95 | 101 | @FMT_OPTION |
| 96 | 102 | def function(name: str, file_path: str, db: str, depth: int, fmt: str): |
| 97 | - """Load context for a function — callers, callees, signature.""" | |
| 103 | + """Load context for a function — callers, callees, decorators.""" | |
| 98 | 104 | from navegador.context import ContextLoader |
| 99 | - | |
| 100 | - loader = ContextLoader(_get_store(db)) | |
| 101 | - bundle = loader.load_function(name, file_path=file_path, depth=depth) | |
| 105 | + bundle = ContextLoader(_get_store(db)).load_function(name, file_path=file_path, depth=depth) | |
| 102 | 106 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 103 | 107 | |
| 104 | 108 | |
| 105 | 109 | @main.command("class") |
| 106 | 110 | @click.argument("name") |
| 107 | -@click.option("--file", "file_path", default="", help="File path to narrow the search.") | |
| 111 | +@click.option("--file", "file_path", default="", help="Narrow to a specific file.") | |
| 108 | 112 | @DB_OPTION |
| 109 | 113 | @FMT_OPTION |
| 110 | 114 | def class_(name: str, file_path: str, db: str, fmt: str): |
| 111 | - """Load context for a class — methods, inheritance, subclasses.""" | |
| 115 | + """Load context for a class — methods, inheritance, references.""" | |
| 116 | + from navegador.context import ContextLoader | |
| 117 | + bundle = ContextLoader(_get_store(db)).load_class(name, file_path=file_path) | |
| 118 | + _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) | |
| 119 | + | |
| 120 | + | |
| 121 | +# ── UNIVERSAL: explain ──────────────────────────────────────────────────────── | |
| 122 | + | |
| 123 | +@main.command() | |
| 124 | +@click.argument("name") | |
| 125 | +@click.option("--file", "file_path", default="") | |
| 126 | +@DB_OPTION | |
| 127 | +@FMT_OPTION | |
| 128 | +def explain(name: str, file_path: str, db: str, fmt: str): | |
| 129 | + """Full picture: all relationships in and out, code and knowledge layers.""" | |
| 112 | 130 | from navegador.context import ContextLoader |
| 113 | - | |
| 114 | - loader = ContextLoader(_get_store(db)) | |
| 115 | - bundle = loader.load_class(name, file_path=file_path) | |
| 131 | + bundle = ContextLoader(_get_store(db)).explain(name, file_path=file_path) | |
| 116 | 132 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 117 | 133 | |
| 134 | + | |
| 135 | +# ── UNIVERSAL: search ───────────────────────────────────────────────────────── | |
| 118 | 136 | |
| 119 | 137 | @main.command() |
| 120 | 138 | @click.argument("query") |
| 121 | 139 | @DB_OPTION |
| 122 | 140 | @click.option("--limit", default=20, show_default=True) |
| 141 | +@click.option("--all", "search_all", is_flag=True, | |
| 142 | + help="Include knowledge layer (concepts, rules, wiki).") | |
| 143 | +@click.option("--docs", "by_doc", is_flag=True, help="Search docstrings instead of names.") | |
| 123 | 144 | @FMT_OPTION |
| 124 | -def search(query: str, db: str, limit: int, fmt: str): | |
| 125 | - """Search for symbols (functions, classes, methods) by name.""" | |
| 126 | - import json | |
| 127 | - | |
| 145 | +def search(query: str, db: str, limit: int, search_all: bool, by_doc: bool, fmt: str): | |
| 146 | + """Search symbols, concepts, rules, and wiki pages.""" | |
| 128 | 147 | from navegador.context import ContextLoader |
| 129 | - | |
| 130 | 148 | loader = ContextLoader(_get_store(db)) |
| 131 | - results = loader.search(query, limit=limit) | |
| 149 | + | |
| 150 | + if by_doc: | |
| 151 | + results = loader.search_by_docstring(query, limit=limit) | |
| 152 | + elif search_all: | |
| 153 | + results = loader.search_all(query, limit=limit) | |
| 154 | + else: | |
| 155 | + results = loader.search(query, limit=limit) | |
| 132 | 156 | |
| 133 | 157 | if fmt == "json": |
| 134 | 158 | click.echo(json.dumps([ |
| 135 | 159 | {"type": r.type, "name": r.name, "file_path": r.file_path, |
| 136 | - "line_start": r.line_start, "docstring": r.docstring} | |
| 160 | + "line_start": r.line_start, "docstring": r.docstring, | |
| 161 | + "description": r.description} | |
| 137 | 162 | for r in results |
| 138 | 163 | ], indent=2)) |
| 139 | 164 | return |
| 140 | 165 | |
| 141 | 166 | if not results: |
| 142 | 167 | console.print("[yellow]No results.[/yellow]") |
| 143 | 168 | return |
| 144 | 169 | |
| 145 | - table = Table(title=f"Search: {query!r}", show_header=True) | |
| 170 | + table = Table(title=f"Search: {query!r}") | |
| 171 | + table.add_column("Type", style="cyan") | |
| 172 | + table.add_column("Name", style="bold") | |
| 173 | + table.add_column("File / Domain") | |
| 174 | + table.add_column("Line", justify="right") | |
| 175 | + for r in results: | |
| 176 | + loc = r.file_path or r.domain or "" | |
| 177 | + table.add_row(r.type, r.name, loc, str(r.line_start or "")) | |
| 178 | + console.print(table) | |
| 179 | + | |
| 180 | + | |
| 181 | +# ── CODE: decorator / query ─────────────────────────────────────────────────── | |
| 182 | + | |
| 183 | +@main.command() | |
| 184 | +@click.argument("decorator_name") | |
| 185 | +@DB_OPTION | |
| 186 | +@FMT_OPTION | |
| 187 | +def decorated(decorator_name: str, db: str, fmt: str): | |
| 188 | + """Find all functions/methods carrying a decorator.""" | |
| 189 | + from navegador.context import ContextLoader | |
| 190 | + results = ContextLoader(_get_store(db)).decorated_by(decorator_name) | |
| 191 | + | |
| 192 | + if fmt == "json": | |
| 193 | + click.echo(json.dumps([ | |
| 194 | + {"type": r.type, "name": r.name, "file_path": r.file_path, "line": r.line_start} | |
| 195 | + for r in results | |
| 196 | + ], indent=2)) | |
| 197 | + return | |
| 198 | + | |
| 199 | + if not results: | |
| 200 | + console.print(f"[yellow]No functions decorated with @{decorator_name}[/yellow]") | |
| 201 | + return | |
| 202 | + | |
| 203 | + table = Table(title=f"@{decorator_name}") | |
| 146 | 204 | table.add_column("Type", style="cyan") |
| 147 | 205 | table.add_column("Name", style="bold") |
| 148 | 206 | table.add_column("File") |
| 149 | 207 | table.add_column("Line", justify="right") |
| 150 | 208 | for r in results: |
| @@ -154,39 +212,214 @@ | ||
| 154 | 212 | |
| 155 | 213 | @main.command() |
| 156 | 214 | @click.argument("cypher") |
| 157 | 215 | @DB_OPTION |
| 158 | 216 | def query(cypher: str, db: str): |
| 159 | - """Run a raw Cypher query and print results as JSON.""" | |
| 160 | - import json | |
| 161 | - | |
| 162 | - store = _get_store(db) | |
| 163 | - result = store.query(cypher) | |
| 164 | - rows = result.result_set or [] | |
| 165 | - click.echo(json.dumps(rows, default=str, indent=2)) | |
| 166 | - | |
| 217 | + """Run a raw Cypher query — output is always JSON.""" | |
| 218 | + result = _get_store(db).query(cypher) | |
| 219 | + click.echo(json.dumps(result.result_set or [], default=str, indent=2)) | |
| 220 | + | |
| 221 | + | |
| 222 | +# ── KNOWLEDGE: add group ────────────────────────────────────────────────────── | |
| 223 | + | |
| 224 | +@main.group() | |
| 225 | +def add(): | |
| 226 | + """Add knowledge nodes — concepts, rules, decisions, people, domains.""" | |
| 227 | + | |
| 228 | + | |
| 229 | +@add.command("concept") | |
| 230 | +@click.argument("name") | |
| 231 | +@click.option("--desc", default="", help="Description / definition.") | |
| 232 | +@click.option("--domain", default="") | |
| 233 | +@click.option("--status", default="", help="e.g. stable, proposed, deprecated") | |
| 234 | +@click.option("--rules", default="", help="Comma-separated rule names.") | |
| 235 | +@click.option("--wiki", default="", help="Wiki URL or reference.") | |
| 236 | +@DB_OPTION | |
| 237 | +def add_concept(name: str, desc: str, domain: str, status: str, rules: str, wiki: str, db: str): | |
| 238 | + """Add a business concept to the knowledge graph.""" | |
| 239 | + from navegador.ingestion import KnowledgeIngester | |
| 240 | + k = KnowledgeIngester(_get_store(db)) | |
| 241 | + k.add_concept(name, description=desc, domain=domain, status=status, | |
| 242 | + rules=rules, wiki_refs=wiki) | |
| 243 | + console.print(f"[green]Concept added:[/green] {name}") | |
| 244 | + | |
| 245 | + | |
| 246 | +@add.command("rule") | |
| 247 | +@click.argument("name") | |
| 248 | +@click.option("--desc", default="") | |
| 249 | +@click.option("--domain", default="") | |
| 250 | +@click.option("--severity", default="info", type=click.Choice(["info", "warning", "critical"])) | |
| 251 | +@click.option("--rationale", default="") | |
| 252 | +@DB_OPTION | |
| 253 | +def add_rule(name: str, desc: str, domain: str, severity: str, rationale: str, db: str): | |
| 254 | + """Add a business rule or constraint.""" | |
| 255 | + from navegador.ingestion import KnowledgeIngester | |
| 256 | + k = KnowledgeIngester(_get_store(db)) | |
| 257 | + k.add_rule(name, description=desc, domain=domain, severity=severity, rationale=rationale) | |
| 258 | + console.print(f"[green]Rule added:[/green] {name}") | |
| 259 | + | |
| 260 | + | |
| 261 | +@add.command("decision") | |
| 262 | +@click.argument("name") | |
| 263 | +@click.option("--desc", default="") | |
| 264 | +@click.option("--domain", default="") | |
| 265 | +@click.option("--rationale", default="") | |
| 266 | +@click.option("--alternatives", default="") | |
| 267 | +@click.option("--date", default="") | |
| 268 | +@click.option("--status", default="accepted", | |
| 269 | + type=click.Choice(["proposed", "accepted", "deprecated"])) | |
| 270 | +@DB_OPTION | |
| 271 | +def add_decision(name, desc, domain, rationale, alternatives, date, status, db): | |
| 272 | + """Add an architectural or product decision.""" | |
| 273 | + from navegador.ingestion import KnowledgeIngester | |
| 274 | + k = KnowledgeIngester(_get_store(db)) | |
| 275 | + k.add_decision(name, description=desc, domain=domain, status=status, | |
| 276 | + rationale=rationale, alternatives=alternatives, date=date) | |
| 277 | + console.print(f"[green]Decision added:[/green] {name}") | |
| 278 | + | |
| 279 | + | |
| 280 | +@add.command("person") | |
| 281 | +@click.argument("name") | |
| 282 | +@click.option("--email", default="") | |
| 283 | +@click.option("--role", default="") | |
| 284 | +@click.option("--team", default="") | |
| 285 | +@DB_OPTION | |
| 286 | +def add_person(name: str, email: str, role: str, team: str, db: str): | |
| 287 | + """Add a person (contributor, owner, stakeholder).""" | |
| 288 | + from navegador.ingestion import KnowledgeIngester | |
| 289 | + k = KnowledgeIngester(_get_store(db)) | |
| 290 | + k.add_person(name, email=email, role=role, team=team) | |
| 291 | + console.print(f"[green]Person added:[/green] {name}") | |
| 292 | + | |
| 293 | + | |
| 294 | +@add.command("domain") | |
| 295 | +@click.argument("name") | |
| 296 | +@click.option("--desc", default="") | |
| 297 | +@DB_OPTION | |
| 298 | +def add_domain(name: str, desc: str, db: str): | |
| 299 | + """Add a business domain (auth, billing, notifications…).""" | |
| 300 | + from navegador.ingestion import KnowledgeIngester | |
| 301 | + k = KnowledgeIngester(_get_store(db)) | |
| 302 | + k.add_domain(name, description=desc) | |
| 303 | + console.print(f"[green]Domain added:[/green] {name}") | |
| 304 | + | |
| 305 | + | |
| 306 | +# ── KNOWLEDGE: annotate ─────────────────────────────────────────────────────── | |
| 307 | + | |
| 308 | +@main.command() | |
| 309 | +@click.argument("code_name") | |
| 310 | +@click.option("--type", "code_label", default="Function", | |
| 311 | + type=click.Choice(["Function", "Class", "Method", "File", "Module"])) | |
| 312 | +@click.option("--concept", default="", help="Link to this concept.") | |
| 313 | +@click.option("--rule", default="", help="Link to this rule.") | |
| 314 | +@DB_OPTION | |
| 315 | +def annotate(code_name: str, code_label: str, concept: str, rule: str, db: str): | |
| 316 | + """Link a code node to a concept or rule in the knowledge graph.""" | |
| 317 | + from navegador.ingestion import KnowledgeIngester | |
| 318 | + k = KnowledgeIngester(_get_store(db)) | |
| 319 | + k.annotate_code(code_name, code_label, | |
| 320 | + concept=concept or None, rule=rule or None) | |
| 321 | + console.print(f"[green]Annotated:[/green] {code_name}") | |
| 322 | + | |
| 323 | + | |
| 324 | +# ── KNOWLEDGE: domain view ──────────────────────────────────────────────────── | |
| 325 | + | |
| 326 | +@main.command() | |
| 327 | +@click.argument("name") | |
| 328 | +@DB_OPTION | |
| 329 | +@FMT_OPTION | |
| 330 | +def domain(name: str, db: str, fmt: str): | |
| 331 | + """Show everything belonging to a domain — code and knowledge.""" | |
| 332 | + from navegador.context import ContextLoader | |
| 333 | + bundle = ContextLoader(_get_store(db)).load_domain(name) | |
| 334 | + _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) | |
| 335 | + | |
| 336 | + | |
| 337 | +@main.command() | |
| 338 | +@click.argument("name") | |
| 339 | +@DB_OPTION | |
| 340 | +@FMT_OPTION | |
| 341 | +def concept(name: str, db: str, fmt: str): | |
| 342 | + """Load a business concept — rules, related concepts, implementing code, wiki.""" | |
| 343 | + from navegador.context import ContextLoader | |
| 344 | + bundle = ContextLoader(_get_store(db)).load_concept(name) | |
| 345 | + _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) | |
| 346 | + | |
| 347 | + | |
| 348 | +# ── KNOWLEDGE: wiki ─────────────────────────────────────────────────────────── | |
| 349 | + | |
| 350 | +@main.group() | |
| 351 | +def wiki(): | |
| 352 | + """Ingest and manage wiki pages in the knowledge graph.""" | |
| 353 | + | |
| 354 | + | |
| 355 | +@wiki.command("ingest") | |
| 356 | +@click.option("--repo", default="", help="GitHub repo (owner/repo) — clones the wiki.") | |
| 357 | +@click.option("--dir", "wiki_dir", default="", help="Local directory of markdown files.") | |
| 358 | +@click.option("--token", default="", envvar="GITHUB_TOKEN", help="GitHub token.") | |
| 359 | +@click.option("--api", is_flag=True, help="Use GitHub API instead of git clone.") | |
| 360 | +@DB_OPTION | |
| 361 | +def wiki_ingest(repo: str, wiki_dir: str, token: str, api: bool, db: str): | |
| 362 | + """Pull wiki pages into the knowledge graph.""" | |
| 363 | + from navegador.ingestion import WikiIngester | |
| 364 | + w = WikiIngester(_get_store(db)) | |
| 365 | + | |
| 366 | + if wiki_dir: | |
| 367 | + stats = w.ingest_local(wiki_dir) | |
| 368 | + elif repo: | |
| 369 | + if api: | |
| 370 | + stats = w.ingest_github_api(repo, token=token) | |
| 371 | + else: | |
| 372 | + stats = w.ingest_github(repo, token=token) | |
| 373 | + else: | |
| 374 | + raise click.UsageError("Provide --repo or --dir") | |
| 375 | + | |
| 376 | + console.print(f"[green]Wiki ingested:[/green] {stats['pages']} pages, {stats['links']} links") | |
| 377 | + | |
| 378 | + | |
| 379 | +# ── Stats ───────────────────────────────────────────────────────────────────── | |
| 167 | 380 | |
| 168 | 381 | @main.command() |
| 169 | 382 | @DB_OPTION |
| 170 | -@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") | |
| 383 | +@click.option("--json", "as_json", is_flag=True) | |
| 171 | 384 | def stats(db: str, as_json: bool): |
| 172 | - """Show graph statistics.""" | |
| 173 | - import json | |
| 174 | - | |
| 385 | + """Graph statistics broken down by node and edge type.""" | |
| 386 | + from navegador.graph import queries as q | |
| 175 | 387 | store = _get_store(db) |
| 176 | - data = {"nodes": store.node_count(), "edges": store.edge_count()} | |
| 388 | + | |
| 389 | + node_rows = (store.query(q.NODE_TYPE_COUNTS).result_set or []) | |
| 390 | + edge_rows = (store.query(q.EDGE_TYPE_COUNTS).result_set or []) | |
| 391 | + | |
| 392 | + total_nodes = sum(r[1] for r in node_rows) | |
| 393 | + total_edges = sum(r[1] for r in edge_rows) | |
| 177 | 394 | |
| 178 | 395 | if as_json: |
| 179 | - click.echo(json.dumps(data, indent=2)) | |
| 180 | - else: | |
| 181 | - table = Table(title="Graph stats", show_header=True) | |
| 182 | - table.add_column("Metric", style="cyan") | |
| 183 | - table.add_column("Count", justify="right", style="green") | |
| 184 | - for k, v in data.items(): | |
| 185 | - table.add_row(k.capitalize(), str(v)) | |
| 186 | - console.print(table) | |
| 187 | - | |
| 396 | + click.echo(json.dumps({ | |
| 397 | + "total_nodes": total_nodes, | |
| 398 | + "total_edges": total_edges, | |
| 399 | + "nodes": {r[0]: r[1] for r in node_rows}, | |
| 400 | + "edges": {r[0]: r[1] for r in edge_rows}, | |
| 401 | + }, indent=2)) | |
| 402 | + return | |
| 403 | + | |
| 404 | + node_table = Table(title=f"Nodes ({total_nodes:,})") | |
| 405 | + node_table.add_column("Type", style="cyan") | |
| 406 | + node_table.add_column("Count", justify="right", style="green") | |
| 407 | + for row in node_rows: | |
| 408 | + node_table.add_row(row[0], f"{row[1]:,}") | |
| 409 | + | |
| 410 | + edge_table = Table(title=f"Edges ({total_edges:,})") | |
| 411 | + edge_table.add_column("Type", style="cyan") | |
| 412 | + edge_table.add_column("Count", justify="right", style="green") | |
| 413 | + for row in edge_rows: | |
| 414 | + edge_table.add_row(row[0], f"{row[1]:,}") | |
| 415 | + | |
| 416 | + console.print(node_table) | |
| 417 | + console.print(edge_table) | |
| 418 | + | |
| 419 | + | |
| 420 | +# ── MCP ─────────────────────────────────────────────────────────────────────── | |
| 188 | 421 | |
| 189 | 422 | @main.command() |
| 190 | 423 | @DB_OPTION |
| 191 | 424 | def mcp(db: str): |
| 192 | 425 | """Start the MCP server for AI agent integration (stdio).""" |
| 193 | 426 |
| --- navegador/cli/commands.py | |
| +++ navegador/cli/commands.py | |
| @@ -1,13 +1,15 @@ | |
| 1 | """ |
| 2 | Navegador CLI — ingest repos, load context, serve MCP. |
| 3 | |
| 4 | All commands support --format json for clean stdout output suitable for |
| 5 | piping to agents or other tools without MCP overhead. |
| 6 | """ |
| 7 | |
| 8 | import asyncio |
| 9 | import logging |
| 10 | |
| 11 | import click |
| 12 | from rich.console import Console |
| 13 | from rich.table import Table |
| @@ -18,12 +20,11 @@ | |
| 18 | "--db", default=".navegador/graph.db", show_default=True, help="Graph DB path." |
| 19 | ) |
| 20 | FMT_OPTION = click.option( |
| 21 | "--format", "fmt", |
| 22 | type=click.Choice(["markdown", "json"]), |
| 23 | default="markdown", |
| 24 | show_default=True, |
| 25 | help="Output format. Use json for agent/pipe consumption.", |
| 26 | ) |
| 27 | |
| 28 | |
| 29 | def _get_store(db: str): |
| @@ -30,33 +31,38 @@ | |
| 30 | from navegador.graph import GraphStore |
| 31 | return GraphStore.sqlite(db) |
| 32 | |
| 33 | |
| 34 | def _emit(text: str, fmt: str) -> None: |
| 35 | """Print text — raw to stdout for json, rich for markdown.""" |
| 36 | if fmt == "json": |
| 37 | click.echo(text) |
| 38 | else: |
| 39 | console.print(text) |
| 40 | |
| 41 | |
| 42 | @click.group() |
| 43 | @click.version_option(package_name="navegador") |
| 44 | def main(): |
| 45 | """Navegador — AST + knowledge graph context engine for AI coding agents.""" |
| 46 | logging.basicConfig(level=logging.WARNING) |
| 47 | |
| 48 | |
| 49 | @main.command() |
| 50 | @click.argument("repo_path", type=click.Path(exists=True)) |
| 51 | @DB_OPTION |
| 52 | @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.") |
| 53 | @click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.") |
| 54 | def ingest(repo_path: str, db: str, clear: bool, as_json: bool): |
| 55 | """Ingest a repository into the navegador graph.""" |
| 56 | import json |
| 57 | |
| 58 | from navegador.ingestion import RepoIngester |
| 59 | |
| 60 | store = _get_store(db) |
| 61 | ingester = RepoIngester(store) |
| 62 | |
| @@ -64,87 +70,139 @@ | |
| 64 | stats = ingester.ingest(repo_path, clear=clear) |
| 65 | click.echo(json.dumps(stats, indent=2)) |
| 66 | else: |
| 67 | with console.status(f"[bold]Ingesting[/bold] {repo_path}..."): |
| 68 | stats = ingester.ingest(repo_path, clear=clear) |
| 69 | table = Table(title="Ingestion complete", show_header=True) |
| 70 | table.add_column("Metric", style="cyan") |
| 71 | table.add_column("Count", justify="right", style="green") |
| 72 | for k, v in stats.items(): |
| 73 | table.add_row(k.capitalize(), str(v)) |
| 74 | console.print(table) |
| 75 | |
| 76 | |
| 77 | @main.command() |
| 78 | @click.argument("file_path") |
| 79 | @DB_OPTION |
| 80 | @FMT_OPTION |
| 81 | def context(file_path: str, db: str, fmt: str): |
| 82 | """Load context for a file — all symbols and their relationships.""" |
| 83 | from navegador.context import ContextLoader |
| 84 | |
| 85 | loader = ContextLoader(_get_store(db)) |
| 86 | bundle = loader.load_file(file_path) |
| 87 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 88 | |
| 89 | |
| 90 | @main.command() |
| 91 | @click.argument("name") |
| 92 | @click.option("--file", "file_path", default="", help="File path to narrow the search.") |
| 93 | @click.option("--depth", default=2, show_default=True, help="Call graph traversal depth.") |
| 94 | @DB_OPTION |
| 95 | @FMT_OPTION |
| 96 | def function(name: str, file_path: str, db: str, depth: int, fmt: str): |
| 97 | """Load context for a function — callers, callees, signature.""" |
| 98 | from navegador.context import ContextLoader |
| 99 | |
| 100 | loader = ContextLoader(_get_store(db)) |
| 101 | bundle = loader.load_function(name, file_path=file_path, depth=depth) |
| 102 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 103 | |
| 104 | |
| 105 | @main.command("class") |
| 106 | @click.argument("name") |
| 107 | @click.option("--file", "file_path", default="", help="File path to narrow the search.") |
| 108 | @DB_OPTION |
| 109 | @FMT_OPTION |
| 110 | def class_(name: str, file_path: str, db: str, fmt: str): |
| 111 | """Load context for a class — methods, inheritance, subclasses.""" |
| 112 | from navegador.context import ContextLoader |
| 113 | |
| 114 | loader = ContextLoader(_get_store(db)) |
| 115 | bundle = loader.load_class(name, file_path=file_path) |
| 116 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 117 | |
| 118 | |
| 119 | @main.command() |
| 120 | @click.argument("query") |
| 121 | @DB_OPTION |
| 122 | @click.option("--limit", default=20, show_default=True) |
| 123 | @FMT_OPTION |
| 124 | def search(query: str, db: str, limit: int, fmt: str): |
| 125 | """Search for symbols (functions, classes, methods) by name.""" |
| 126 | import json |
| 127 | |
| 128 | from navegador.context import ContextLoader |
| 129 | |
| 130 | loader = ContextLoader(_get_store(db)) |
| 131 | results = loader.search(query, limit=limit) |
| 132 | |
| 133 | if fmt == "json": |
| 134 | click.echo(json.dumps([ |
| 135 | {"type": r.type, "name": r.name, "file_path": r.file_path, |
| 136 | "line_start": r.line_start, "docstring": r.docstring} |
| 137 | for r in results |
| 138 | ], indent=2)) |
| 139 | return |
| 140 | |
| 141 | if not results: |
| 142 | console.print("[yellow]No results.[/yellow]") |
| 143 | return |
| 144 | |
| 145 | table = Table(title=f"Search: {query!r}", show_header=True) |
| 146 | table.add_column("Type", style="cyan") |
| 147 | table.add_column("Name", style="bold") |
| 148 | table.add_column("File") |
| 149 | table.add_column("Line", justify="right") |
| 150 | for r in results: |
| @@ -154,39 +212,214 @@ | |
| 154 | |
| 155 | @main.command() |
| 156 | @click.argument("cypher") |
| 157 | @DB_OPTION |
| 158 | def query(cypher: str, db: str): |
| 159 | """Run a raw Cypher query and print results as JSON.""" |
| 160 | import json |
| 161 | |
| 162 | store = _get_store(db) |
| 163 | result = store.query(cypher) |
| 164 | rows = result.result_set or [] |
| 165 | click.echo(json.dumps(rows, default=str, indent=2)) |
| 166 | |
| 167 | |
| 168 | @main.command() |
| 169 | @DB_OPTION |
| 170 | @click.option("--json", "as_json", is_flag=True, help="Output as JSON.") |
| 171 | def stats(db: str, as_json: bool): |
| 172 | """Show graph statistics.""" |
| 173 | import json |
| 174 | |
| 175 | store = _get_store(db) |
| 176 | data = {"nodes": store.node_count(), "edges": store.edge_count()} |
| 177 | |
| 178 | if as_json: |
| 179 | click.echo(json.dumps(data, indent=2)) |
| 180 | else: |
| 181 | table = Table(title="Graph stats", show_header=True) |
| 182 | table.add_column("Metric", style="cyan") |
| 183 | table.add_column("Count", justify="right", style="green") |
| 184 | for k, v in data.items(): |
| 185 | table.add_row(k.capitalize(), str(v)) |
| 186 | console.print(table) |
| 187 | |
| 188 | |
| 189 | @main.command() |
| 190 | @DB_OPTION |
| 191 | def mcp(db: str): |
| 192 | """Start the MCP server for AI agent integration (stdio).""" |
| 193 |
| --- navegador/cli/commands.py | |
| +++ navegador/cli/commands.py | |
| @@ -1,13 +1,15 @@ | |
| 1 | """ |
| 2 | Navegador CLI — the single interface to your project's knowledge graph. |
| 3 | |
| 4 | CODE: ingest, context, function, class, search, query |
| 5 | KNOWLEDGE: add (concept/rule/decision/person/domain), wiki, annotate, domain |
| 6 | UNIVERSAL: explain, search (spans both layers), stats |
| 7 | """ |
| 8 | |
| 9 | import asyncio |
| 10 | import json |
| 11 | import logging |
| 12 | |
| 13 | import click |
| 14 | from rich.console import Console |
| 15 | from rich.table import Table |
| @@ -18,12 +20,11 @@ | |
| 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): |
| @@ -30,33 +31,38 @@ | |
| 31 | from navegador.graph import GraphStore |
| 32 | return GraphStore.sqlite(db) |
| 33 | |
| 34 | |
| 35 | def _emit(text: str, fmt: str) -> None: |
| 36 | if fmt == "json": |
| 37 | click.echo(text) |
| 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. |
| 48 | |
| 49 | Combines code structure (AST, call graphs) with business knowledge |
| 50 | (concepts, rules, decisions, wiki) into a single queryable graph. |
| 51 | """ |
| 52 | logging.basicConfig(level=logging.WARNING) |
| 53 | |
| 54 | |
| 55 | # ── CODE: ingest ────────────────────────────────────────────────────────────── |
| 56 | |
| 57 | @main.command() |
| 58 | @click.argument("repo_path", type=click.Path(exists=True)) |
| 59 | @DB_OPTION |
| 60 | @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.") |
| 61 | @click.option("--json", "as_json", is_flag=True, help="Output stats as JSON.") |
| 62 | def ingest(repo_path: str, db: str, clear: bool, as_json: bool): |
| 63 | """Ingest a repository's code into the graph (AST + call graph).""" |
| 64 | from navegador.ingestion import RepoIngester |
| 65 | |
| 66 | store = _get_store(db) |
| 67 | ingester = RepoIngester(store) |
| 68 | |
| @@ -64,87 +70,139 @@ | |
| 70 | stats = ingester.ingest(repo_path, clear=clear) |
| 71 | click.echo(json.dumps(stats, indent=2)) |
| 72 | else: |
| 73 | with console.status(f"[bold]Ingesting[/bold] {repo_path}..."): |
| 74 | stats = ingester.ingest(repo_path, clear=clear) |
| 75 | table = Table(title="Ingestion complete") |
| 76 | table.add_column("Metric", style="cyan") |
| 77 | table.add_column("Count", justify="right", style="green") |
| 78 | for k, v in stats.items(): |
| 79 | table.add_row(k.capitalize(), str(v)) |
| 80 | console.print(table) |
| 81 | |
| 82 | |
| 83 | # ── CODE: context / function / class ───────────────────────────────────────── |
| 84 | |
| 85 | @main.command() |
| 86 | @click.argument("file_path") |
| 87 | @DB_OPTION |
| 88 | @FMT_OPTION |
| 89 | def context(file_path: str, db: str, fmt: str): |
| 90 | """Load context for a file — all symbols and their relationships.""" |
| 91 | from navegador.context import ContextLoader |
| 92 | bundle = ContextLoader(_get_store(db)).load_file(file_path) |
| 93 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 94 | |
| 95 | |
| 96 | @main.command() |
| 97 | @click.argument("name") |
| 98 | @click.option("--file", "file_path", default="", help="Narrow to a specific file.") |
| 99 | @click.option("--depth", default=2, show_default=True) |
| 100 | @DB_OPTION |
| 101 | @FMT_OPTION |
| 102 | def function(name: str, file_path: str, db: str, depth: int, fmt: str): |
| 103 | """Load context for a function — callers, callees, decorators.""" |
| 104 | from navegador.context import ContextLoader |
| 105 | bundle = ContextLoader(_get_store(db)).load_function(name, file_path=file_path, depth=depth) |
| 106 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 107 | |
| 108 | |
| 109 | @main.command("class") |
| 110 | @click.argument("name") |
| 111 | @click.option("--file", "file_path", default="", help="Narrow to a specific file.") |
| 112 | @DB_OPTION |
| 113 | @FMT_OPTION |
| 114 | def class_(name: str, file_path: str, db: str, fmt: str): |
| 115 | """Load context for a class — methods, inheritance, references.""" |
| 116 | from navegador.context import ContextLoader |
| 117 | bundle = ContextLoader(_get_store(db)).load_class(name, file_path=file_path) |
| 118 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 119 | |
| 120 | |
| 121 | # ── UNIVERSAL: explain ──────────────────────────────────────────────────────── |
| 122 | |
| 123 | @main.command() |
| 124 | @click.argument("name") |
| 125 | @click.option("--file", "file_path", default="") |
| 126 | @DB_OPTION |
| 127 | @FMT_OPTION |
| 128 | def explain(name: str, file_path: str, db: str, fmt: str): |
| 129 | """Full picture: all relationships in and out, code and knowledge layers.""" |
| 130 | from navegador.context import ContextLoader |
| 131 | bundle = ContextLoader(_get_store(db)).explain(name, file_path=file_path) |
| 132 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 133 | |
| 134 | |
| 135 | # ── UNIVERSAL: search ───────────────────────────────────────────────────────── |
| 136 | |
| 137 | @main.command() |
| 138 | @click.argument("query") |
| 139 | @DB_OPTION |
| 140 | @click.option("--limit", default=20, show_default=True) |
| 141 | @click.option("--all", "search_all", is_flag=True, |
| 142 | help="Include knowledge layer (concepts, rules, wiki).") |
| 143 | @click.option("--docs", "by_doc", is_flag=True, help="Search docstrings instead of names.") |
| 144 | @FMT_OPTION |
| 145 | def search(query: str, db: str, limit: int, search_all: bool, by_doc: bool, fmt: str): |
| 146 | """Search symbols, concepts, rules, and wiki pages.""" |
| 147 | from navegador.context import ContextLoader |
| 148 | loader = ContextLoader(_get_store(db)) |
| 149 | |
| 150 | if by_doc: |
| 151 | results = loader.search_by_docstring(query, limit=limit) |
| 152 | elif search_all: |
| 153 | results = loader.search_all(query, limit=limit) |
| 154 | else: |
| 155 | results = loader.search(query, limit=limit) |
| 156 | |
| 157 | if fmt == "json": |
| 158 | click.echo(json.dumps([ |
| 159 | {"type": r.type, "name": r.name, "file_path": r.file_path, |
| 160 | "line_start": r.line_start, "docstring": r.docstring, |
| 161 | "description": r.description} |
| 162 | for r in results |
| 163 | ], indent=2)) |
| 164 | return |
| 165 | |
| 166 | if not results: |
| 167 | console.print("[yellow]No results.[/yellow]") |
| 168 | return |
| 169 | |
| 170 | table = Table(title=f"Search: {query!r}") |
| 171 | table.add_column("Type", style="cyan") |
| 172 | table.add_column("Name", style="bold") |
| 173 | table.add_column("File / Domain") |
| 174 | table.add_column("Line", justify="right") |
| 175 | for r in results: |
| 176 | loc = r.file_path or r.domain or "" |
| 177 | table.add_row(r.type, r.name, loc, str(r.line_start or "")) |
| 178 | console.print(table) |
| 179 | |
| 180 | |
| 181 | # ── CODE: decorator / query ─────────────────────────────────────────────────── |
| 182 | |
| 183 | @main.command() |
| 184 | @click.argument("decorator_name") |
| 185 | @DB_OPTION |
| 186 | @FMT_OPTION |
| 187 | def decorated(decorator_name: str, db: str, fmt: str): |
| 188 | """Find all functions/methods carrying a decorator.""" |
| 189 | from navegador.context import ContextLoader |
| 190 | results = ContextLoader(_get_store(db)).decorated_by(decorator_name) |
| 191 | |
| 192 | if fmt == "json": |
| 193 | click.echo(json.dumps([ |
| 194 | {"type": r.type, "name": r.name, "file_path": r.file_path, "line": r.line_start} |
| 195 | for r in results |
| 196 | ], indent=2)) |
| 197 | return |
| 198 | |
| 199 | if not results: |
| 200 | console.print(f"[yellow]No functions decorated with @{decorator_name}[/yellow]") |
| 201 | return |
| 202 | |
| 203 | table = Table(title=f"@{decorator_name}") |
| 204 | table.add_column("Type", style="cyan") |
| 205 | table.add_column("Name", style="bold") |
| 206 | table.add_column("File") |
| 207 | table.add_column("Line", justify="right") |
| 208 | for r in results: |
| @@ -154,39 +212,214 @@ | |
| 212 | |
| 213 | @main.command() |
| 214 | @click.argument("cypher") |
| 215 | @DB_OPTION |
| 216 | def query(cypher: str, db: str): |
| 217 | """Run a raw Cypher query — output is always JSON.""" |
| 218 | result = _get_store(db).query(cypher) |
| 219 | click.echo(json.dumps(result.result_set or [], default=str, indent=2)) |
| 220 | |
| 221 | |
| 222 | # ── KNOWLEDGE: add group ────────────────────────────────────────────────────── |
| 223 | |
| 224 | @main.group() |
| 225 | def add(): |
| 226 | """Add knowledge nodes — concepts, rules, decisions, people, domains.""" |
| 227 | |
| 228 | |
| 229 | @add.command("concept") |
| 230 | @click.argument("name") |
| 231 | @click.option("--desc", default="", help="Description / definition.") |
| 232 | @click.option("--domain", default="") |
| 233 | @click.option("--status", default="", help="e.g. stable, proposed, deprecated") |
| 234 | @click.option("--rules", default="", help="Comma-separated rule names.") |
| 235 | @click.option("--wiki", default="", help="Wiki URL or reference.") |
| 236 | @DB_OPTION |
| 237 | def add_concept(name: str, desc: str, domain: str, status: str, rules: str, wiki: str, db: str): |
| 238 | """Add a business concept to the knowledge graph.""" |
| 239 | from navegador.ingestion import KnowledgeIngester |
| 240 | k = KnowledgeIngester(_get_store(db)) |
| 241 | k.add_concept(name, description=desc, domain=domain, status=status, |
| 242 | rules=rules, wiki_refs=wiki) |
| 243 | console.print(f"[green]Concept added:[/green] {name}") |
| 244 | |
| 245 | |
| 246 | @add.command("rule") |
| 247 | @click.argument("name") |
| 248 | @click.option("--desc", default="") |
| 249 | @click.option("--domain", default="") |
| 250 | @click.option("--severity", default="info", type=click.Choice(["info", "warning", "critical"])) |
| 251 | @click.option("--rationale", default="") |
| 252 | @DB_OPTION |
| 253 | def add_rule(name: str, desc: str, domain: str, severity: str, rationale: str, db: str): |
| 254 | """Add a business rule or constraint.""" |
| 255 | from navegador.ingestion import KnowledgeIngester |
| 256 | k = KnowledgeIngester(_get_store(db)) |
| 257 | k.add_rule(name, description=desc, domain=domain, severity=severity, rationale=rationale) |
| 258 | console.print(f"[green]Rule added:[/green] {name}") |
| 259 | |
| 260 | |
| 261 | @add.command("decision") |
| 262 | @click.argument("name") |
| 263 | @click.option("--desc", default="") |
| 264 | @click.option("--domain", default="") |
| 265 | @click.option("--rationale", default="") |
| 266 | @click.option("--alternatives", default="") |
| 267 | @click.option("--date", default="") |
| 268 | @click.option("--status", default="accepted", |
| 269 | type=click.Choice(["proposed", "accepted", "deprecated"])) |
| 270 | @DB_OPTION |
| 271 | def add_decision(name, desc, domain, rationale, alternatives, date, status, db): |
| 272 | """Add an architectural or product decision.""" |
| 273 | from navegador.ingestion import KnowledgeIngester |
| 274 | k = KnowledgeIngester(_get_store(db)) |
| 275 | k.add_decision(name, description=desc, domain=domain, status=status, |
| 276 | rationale=rationale, alternatives=alternatives, date=date) |
| 277 | console.print(f"[green]Decision added:[/green] {name}") |
| 278 | |
| 279 | |
| 280 | @add.command("person") |
| 281 | @click.argument("name") |
| 282 | @click.option("--email", default="") |
| 283 | @click.option("--role", default="") |
| 284 | @click.option("--team", default="") |
| 285 | @DB_OPTION |
| 286 | def add_person(name: str, email: str, role: str, team: str, db: str): |
| 287 | """Add a person (contributor, owner, stakeholder).""" |
| 288 | from navegador.ingestion import KnowledgeIngester |
| 289 | k = KnowledgeIngester(_get_store(db)) |
| 290 | k.add_person(name, email=email, role=role, team=team) |
| 291 | console.print(f"[green]Person added:[/green] {name}") |
| 292 | |
| 293 | |
| 294 | @add.command("domain") |
| 295 | @click.argument("name") |
| 296 | @click.option("--desc", default="") |
| 297 | @DB_OPTION |
| 298 | def add_domain(name: str, desc: str, db: str): |
| 299 | """Add a business domain (auth, billing, notifications…).""" |
| 300 | from navegador.ingestion import KnowledgeIngester |
| 301 | k = KnowledgeIngester(_get_store(db)) |
| 302 | k.add_domain(name, description=desc) |
| 303 | console.print(f"[green]Domain added:[/green] {name}") |
| 304 | |
| 305 | |
| 306 | # ── KNOWLEDGE: annotate ─────────────────────────────────────────────────────── |
| 307 | |
| 308 | @main.command() |
| 309 | @click.argument("code_name") |
| 310 | @click.option("--type", "code_label", default="Function", |
| 311 | type=click.Choice(["Function", "Class", "Method", "File", "Module"])) |
| 312 | @click.option("--concept", default="", help="Link to this concept.") |
| 313 | @click.option("--rule", default="", help="Link to this rule.") |
| 314 | @DB_OPTION |
| 315 | def annotate(code_name: str, code_label: str, concept: str, rule: str, db: str): |
| 316 | """Link a code node to a concept or rule in the knowledge graph.""" |
| 317 | from navegador.ingestion import KnowledgeIngester |
| 318 | k = KnowledgeIngester(_get_store(db)) |
| 319 | k.annotate_code(code_name, code_label, |
| 320 | concept=concept or None, rule=rule or None) |
| 321 | console.print(f"[green]Annotated:[/green] {code_name}") |
| 322 | |
| 323 | |
| 324 | # ── KNOWLEDGE: domain view ──────────────────────────────────────────────────── |
| 325 | |
| 326 | @main.command() |
| 327 | @click.argument("name") |
| 328 | @DB_OPTION |
| 329 | @FMT_OPTION |
| 330 | def domain(name: str, db: str, fmt: str): |
| 331 | """Show everything belonging to a domain — code and knowledge.""" |
| 332 | from navegador.context import ContextLoader |
| 333 | bundle = ContextLoader(_get_store(db)).load_domain(name) |
| 334 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 335 | |
| 336 | |
| 337 | @main.command() |
| 338 | @click.argument("name") |
| 339 | @DB_OPTION |
| 340 | @FMT_OPTION |
| 341 | def concept(name: str, db: str, fmt: str): |
| 342 | """Load a business concept — rules, related concepts, implementing code, wiki.""" |
| 343 | from navegador.context import ContextLoader |
| 344 | bundle = ContextLoader(_get_store(db)).load_concept(name) |
| 345 | _emit(bundle.to_json() if fmt == "json" else bundle.to_markdown(), fmt) |
| 346 | |
| 347 | |
| 348 | # ── KNOWLEDGE: wiki ─────────────────────────────────────────────────────────── |
| 349 | |
| 350 | @main.group() |
| 351 | def wiki(): |
| 352 | """Ingest and manage wiki pages in the knowledge graph.""" |
| 353 | |
| 354 | |
| 355 | @wiki.command("ingest") |
| 356 | @click.option("--repo", default="", help="GitHub repo (owner/repo) — clones the wiki.") |
| 357 | @click.option("--dir", "wiki_dir", default="", help="Local directory of markdown files.") |
| 358 | @click.option("--token", default="", envvar="GITHUB_TOKEN", help="GitHub token.") |
| 359 | @click.option("--api", is_flag=True, help="Use GitHub API instead of git clone.") |
| 360 | @DB_OPTION |
| 361 | def wiki_ingest(repo: str, wiki_dir: str, token: str, api: bool, db: str): |
| 362 | """Pull wiki pages into the knowledge graph.""" |
| 363 | from navegador.ingestion import WikiIngester |
| 364 | w = WikiIngester(_get_store(db)) |
| 365 | |
| 366 | if wiki_dir: |
| 367 | stats = w.ingest_local(wiki_dir) |
| 368 | elif repo: |
| 369 | if api: |
| 370 | stats = w.ingest_github_api(repo, token=token) |
| 371 | else: |
| 372 | stats = w.ingest_github(repo, token=token) |
| 373 | else: |
| 374 | raise click.UsageError("Provide --repo or --dir") |
| 375 | |
| 376 | console.print(f"[green]Wiki ingested:[/green] {stats['pages']} pages, {stats['links']} links") |
| 377 | |
| 378 | |
| 379 | # ── Stats ───────────────────────────────────────────────────────────────────── |
| 380 | |
| 381 | @main.command() |
| 382 | @DB_OPTION |
| 383 | @click.option("--json", "as_json", is_flag=True) |
| 384 | def stats(db: str, as_json: bool): |
| 385 | """Graph statistics broken down by node and edge type.""" |
| 386 | from navegador.graph import queries as q |
| 387 | store = _get_store(db) |
| 388 | |
| 389 | node_rows = (store.query(q.NODE_TYPE_COUNTS).result_set or []) |
| 390 | edge_rows = (store.query(q.EDGE_TYPE_COUNTS).result_set or []) |
| 391 | |
| 392 | total_nodes = sum(r[1] for r in node_rows) |
| 393 | total_edges = sum(r[1] for r in edge_rows) |
| 394 | |
| 395 | if as_json: |
| 396 | click.echo(json.dumps({ |
| 397 | "total_nodes": total_nodes, |
| 398 | "total_edges": total_edges, |
| 399 | "nodes": {r[0]: r[1] for r in node_rows}, |
| 400 | "edges": {r[0]: r[1] for r in edge_rows}, |
| 401 | }, indent=2)) |
| 402 | return |
| 403 | |
| 404 | node_table = Table(title=f"Nodes ({total_nodes:,})") |
| 405 | node_table.add_column("Type", style="cyan") |
| 406 | node_table.add_column("Count", justify="right", style="green") |
| 407 | for row in node_rows: |
| 408 | node_table.add_row(row[0], f"{row[1]:,}") |
| 409 | |
| 410 | edge_table = Table(title=f"Edges ({total_edges:,})") |
| 411 | edge_table.add_column("Type", style="cyan") |
| 412 | edge_table.add_column("Count", justify="right", style="green") |
| 413 | for row in edge_rows: |
| 414 | edge_table.add_row(row[0], f"{row[1]:,}") |
| 415 | |
| 416 | console.print(node_table) |
| 417 | console.print(edge_table) |
| 418 | |
| 419 | |
| 420 | # ── MCP ─────────────────────────────────────────────────────────────────────── |
| 421 | |
| 422 | @main.command() |
| 423 | @DB_OPTION |
| 424 | def mcp(db: str): |
| 425 | """Start the MCP server for AI agent integration (stdio).""" |
| 426 |
+163
-38
| --- navegador/context/loader.py | ||
| +++ navegador/context/loader.py | ||
| @@ -1,17 +1,13 @@ | ||
| 1 | 1 | """ |
| 2 | -ContextLoader — builds structured context bundles from the graph. | |
| 3 | - | |
| 4 | -A context bundle contains: | |
| 5 | -- The target node (file / function / class) | |
| 6 | -- Its immediate neighbors up to a configurable depth | |
| 7 | -- Relationships between those nodes | |
| 8 | -- Source snippets (optional) | |
| 9 | - | |
| 10 | -Output can be: | |
| 11 | -- dict (structured JSON-serialisable) | |
| 12 | -- markdown string (for direct paste into AI chat) | |
| 2 | +ContextLoader — builds structured context bundles from the navegador graph. | |
| 3 | + | |
| 4 | +Operates across both layers: | |
| 5 | + CODE — files, functions, classes, call graphs, decorators, references | |
| 6 | + KNOWLEDGE — concepts, rules, decisions, wiki pages, domains | |
| 7 | + | |
| 8 | +Output can be dict, JSON string, or markdown for direct paste into AI chat. | |
| 13 | 9 | """ |
| 14 | 10 | |
| 15 | 11 | import json |
| 16 | 12 | import logging |
| 17 | 13 | from dataclasses import dataclass, field |
| @@ -25,15 +21,18 @@ | ||
| 25 | 21 | |
| 26 | 22 | @dataclass |
| 27 | 23 | class ContextNode: |
| 28 | 24 | type: str |
| 29 | 25 | name: str |
| 30 | - file_path: str | |
| 26 | + file_path: str = "" | |
| 31 | 27 | line_start: int | None = None |
| 32 | 28 | docstring: str | None = None |
| 33 | 29 | signature: str | None = None |
| 34 | 30 | source: str | None = None |
| 31 | + description: str | None = None | |
| 32 | + domain: str | None = None | |
| 33 | + status: str | None = None | |
| 35 | 34 | |
| 36 | 35 | |
| 37 | 36 | @dataclass |
| 38 | 37 | class ContextBundle: |
| 39 | 38 | target: ContextNode |
| @@ -53,24 +52,31 @@ | ||
| 53 | 52 | return json.dumps(self.to_dict(), indent=indent) |
| 54 | 53 | |
| 55 | 54 | def to_markdown(self) -> str: |
| 56 | 55 | lines = [ |
| 57 | 56 | f"# Context: `{self.target.name}`", |
| 58 | - f"**File:** `{self.target.file_path}`", | |
| 59 | 57 | f"**Type:** {self.target.type}", |
| 60 | 58 | ] |
| 61 | - if self.target.docstring: | |
| 62 | - lines += ["", f"> {self.target.docstring}"] | |
| 59 | + if self.target.file_path: | |
| 60 | + lines.append(f"**File:** `{self.target.file_path}`") | |
| 61 | + if self.target.domain: | |
| 62 | + lines.append(f"**Domain:** {self.target.domain}") | |
| 63 | + if self.target.status: | |
| 64 | + lines.append(f"**Status:** {self.target.status}") | |
| 65 | + if self.target.docstring or self.target.description: | |
| 66 | + lines += ["", f"> {self.target.docstring or self.target.description}"] | |
| 63 | 67 | if self.target.signature: |
| 64 | 68 | lines += ["", f"```python\n{self.target.signature}\n```"] |
| 65 | 69 | |
| 66 | 70 | if self.nodes: |
| 67 | 71 | lines += ["", "## Related nodes", ""] |
| 68 | 72 | for node in self.nodes: |
| 69 | - lines.append(f"- **{node.type}** `{node.name}` — `{node.file_path}`") | |
| 70 | - if node.docstring: | |
| 71 | - lines.append(f" > {node.docstring}") | |
| 73 | + loc = f"`{node.file_path}`" if node.file_path else "" | |
| 74 | + lines.append(f"- **{node.type}** `{node.name}` {loc}".strip()) | |
| 75 | + summary = node.docstring or node.description | |
| 76 | + if summary: | |
| 77 | + lines.append(f" > {summary}") | |
| 72 | 78 | |
| 73 | 79 | if self.edges: |
| 74 | 80 | lines += ["", "## Relationships", ""] |
| 75 | 81 | for edge in self.edges: |
| 76 | 82 | lines.append(f"- `{edge['from']}` **{edge['type']}** `{edge['to']}`") |
| @@ -78,29 +84,33 @@ | ||
| 78 | 84 | return "\n".join(lines) |
| 79 | 85 | |
| 80 | 86 | |
| 81 | 87 | class ContextLoader: |
| 82 | 88 | """ |
| 83 | - Loads structured context bundles from the navegador graph. | |
| 89 | + Loads context bundles from the navegador graph — code and knowledge layers. | |
| 84 | 90 | |
| 85 | 91 | Usage: |
| 86 | 92 | store = GraphStore.sqlite() |
| 87 | 93 | loader = ContextLoader(store) |
| 88 | 94 | |
| 89 | 95 | bundle = loader.load_file("src/auth.py") |
| 90 | - bundle = loader.load_function("get_user", file_path="src/auth.py") | |
| 91 | - bundle = loader.load_class("AuthService", file_path="src/auth.py") | |
| 96 | + bundle = loader.load_function("validate_token") | |
| 97 | + bundle = loader.load_class("AuthService") | |
| 98 | + bundle = loader.explain("validate_token") # full picture | |
| 99 | + bundle = loader.load_concept("JWT") | |
| 100 | + bundle = loader.load_domain("auth") | |
| 92 | 101 | """ |
| 93 | 102 | |
| 94 | 103 | def __init__(self, store: GraphStore) -> None: |
| 95 | 104 | self.store = store |
| 96 | 105 | |
| 97 | - def load_file(self, file_path: str, depth: int = 2) -> ContextBundle: | |
| 98 | - """Load context for an entire file — all its symbols + their dependencies.""" | |
| 106 | + # ── Code: file ──────────────────────────────────────────────────────────── | |
| 107 | + | |
| 108 | + def load_file(self, file_path: str) -> ContextBundle: | |
| 109 | + """All symbols in a file and their relationships.""" | |
| 99 | 110 | result = self.store.query(queries.FILE_CONTENTS, {"path": file_path}) |
| 100 | 111 | target = ContextNode(type="File", name=Path(file_path).name, file_path=file_path) |
| 101 | - | |
| 102 | 112 | nodes = [] |
| 103 | 113 | for row in (result.result_set or []): |
| 104 | 114 | nodes.append(ContextNode( |
| 105 | 115 | type=row[0] or "Unknown", |
| 106 | 116 | name=row[1] or "", |
| @@ -107,42 +117,47 @@ | ||
| 107 | 117 | file_path=file_path, |
| 108 | 118 | line_start=row[2], |
| 109 | 119 | docstring=row[3], |
| 110 | 120 | signature=row[4], |
| 111 | 121 | )) |
| 122 | + return ContextBundle(target=target, nodes=nodes, | |
| 123 | + metadata={"query": "file_contents"}) | |
| 112 | 124 | |
| 113 | - return ContextBundle( | |
| 114 | - target=target, | |
| 115 | - nodes=nodes, | |
| 116 | - metadata={"depth": depth, "query": "file_contents"}, | |
| 117 | - ) | |
| 125 | + # ── Code: function ──────────────────────────────────────────────────────── | |
| 118 | 126 | |
| 119 | 127 | def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle: |
| 120 | - """Load context for a function — its callers and callees.""" | |
| 128 | + """Callers, callees, decorators — everything touching this function.""" | |
| 121 | 129 | target = ContextNode(type="Function", name=name, file_path=file_path) |
| 122 | 130 | nodes: list[ContextNode] = [] |
| 123 | 131 | edges: list[dict[str, str]] = [] |
| 124 | 132 | |
| 125 | - callees = self.store.query( | |
| 126 | - queries.CALLEES, {"name": name, "file_path": file_path, "depth": depth} | |
| 127 | - ) | |
| 133 | + params = {"name": name, "file_path": file_path, "depth": depth} | |
| 134 | + | |
| 135 | + callees = self.store.query(queries.CALLEES, params) | |
| 128 | 136 | for row in (callees.result_set or []): |
| 129 | 137 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 130 | 138 | edges.append({"from": name, "type": "CALLS", "to": row[1]}) |
| 131 | 139 | |
| 132 | - callers = self.store.query( | |
| 133 | - queries.CALLERS, {"name": name, "file_path": file_path, "depth": depth} | |
| 134 | - ) | |
| 140 | + callers = self.store.query(queries.CALLERS, params) | |
| 135 | 141 | for row in (callers.result_set or []): |
| 136 | 142 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 137 | 143 | edges.append({"from": row[1], "type": "CALLS", "to": name}) |
| 138 | 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 | + | |
| 139 | 152 | return ContextBundle(target=target, nodes=nodes, edges=edges, |
| 140 | 153 | metadata={"depth": depth, "query": "function_context"}) |
| 141 | 154 | |
| 155 | + # ── Code: class ─────────────────────────────────────────────────────────── | |
| 156 | + | |
| 142 | 157 | def load_class(self, name: str, file_path: str = "") -> ContextBundle: |
| 143 | - """Load context for a class — its methods, parent classes, and subclasses.""" | |
| 158 | + """Methods, parent classes, subclasses, references.""" | |
| 144 | 159 | target = ContextNode(type="Class", name=name, file_path=file_path) |
| 145 | 160 | nodes: list[ContextNode] = [] |
| 146 | 161 | edges: list[dict[str, str]] = [] |
| 147 | 162 | |
| 148 | 163 | parents = self.store.query(queries.CLASS_HIERARCHY, {"name": name}) |
| @@ -153,16 +168,126 @@ | ||
| 153 | 168 | subs = self.store.query(queries.SUBCLASSES, {"name": name}) |
| 154 | 169 | for row in (subs.result_set or []): |
| 155 | 170 | nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1])) |
| 156 | 171 | edges.append({"from": row[0], "type": "INHERITS", "to": name}) |
| 157 | 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 | + | |
| 158 | 178 | return ContextBundle(target=target, nodes=nodes, edges=edges, |
| 159 | 179 | metadata={"query": "class_context"}) |
| 160 | 180 | |
| 181 | + # ── Universal: explain ──────────────────────────────────────────────────── | |
| 182 | + | |
| 183 | + def explain(self, name: str, file_path: str = "") -> ContextBundle: | |
| 184 | + """ | |
| 185 | + Full picture: all inbound and outbound relationships for any node, | |
| 186 | + across both code and knowledge layers. | |
| 187 | + """ | |
| 188 | + params = {"name": name, "file_path": file_path} | |
| 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 | + | |
| 161 | 259 | def search(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 162 | - """Fuzzy search symbols (functions, classes, methods) by name.""" | |
| 260 | + """Search code symbols by name.""" | |
| 163 | 261 | result = self.store.query(queries.SYMBOL_SEARCH, {"query": query, "limit": limit}) |
| 164 | 262 | return [ |
| 165 | 263 | ContextNode(type=row[0], name=row[1], file_path=row[2], |
| 166 | 264 | line_start=row[3], docstring=row[4]) |
| 167 | 265 | for row in (result.result_set or []) |
| 168 | 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 | + ] | |
| 169 | 294 |
| --- navegador/context/loader.py | |
| +++ navegador/context/loader.py | |
| @@ -1,17 +1,13 @@ | |
| 1 | """ |
| 2 | ContextLoader — builds structured context bundles from the graph. |
| 3 | |
| 4 | A context bundle contains: |
| 5 | - The target node (file / function / class) |
| 6 | - Its immediate neighbors up to a configurable depth |
| 7 | - Relationships between those nodes |
| 8 | - Source snippets (optional) |
| 9 | |
| 10 | Output can be: |
| 11 | - dict (structured JSON-serialisable) |
| 12 | - markdown string (for direct paste into AI chat) |
| 13 | """ |
| 14 | |
| 15 | import json |
| 16 | import logging |
| 17 | from dataclasses import dataclass, field |
| @@ -25,15 +21,18 @@ | |
| 25 | |
| 26 | @dataclass |
| 27 | class ContextNode: |
| 28 | type: str |
| 29 | name: str |
| 30 | file_path: str |
| 31 | line_start: int | None = None |
| 32 | docstring: str | None = None |
| 33 | signature: str | None = None |
| 34 | source: str | None = None |
| 35 | |
| 36 | |
| 37 | @dataclass |
| 38 | class ContextBundle: |
| 39 | target: ContextNode |
| @@ -53,24 +52,31 @@ | |
| 53 | return json.dumps(self.to_dict(), indent=indent) |
| 54 | |
| 55 | def to_markdown(self) -> str: |
| 56 | lines = [ |
| 57 | f"# Context: `{self.target.name}`", |
| 58 | f"**File:** `{self.target.file_path}`", |
| 59 | f"**Type:** {self.target.type}", |
| 60 | ] |
| 61 | if self.target.docstring: |
| 62 | lines += ["", f"> {self.target.docstring}"] |
| 63 | if self.target.signature: |
| 64 | lines += ["", f"```python\n{self.target.signature}\n```"] |
| 65 | |
| 66 | if self.nodes: |
| 67 | lines += ["", "## Related nodes", ""] |
| 68 | for node in self.nodes: |
| 69 | lines.append(f"- **{node.type}** `{node.name}` — `{node.file_path}`") |
| 70 | if node.docstring: |
| 71 | lines.append(f" > {node.docstring}") |
| 72 | |
| 73 | if self.edges: |
| 74 | lines += ["", "## Relationships", ""] |
| 75 | for edge in self.edges: |
| 76 | lines.append(f"- `{edge['from']}` **{edge['type']}** `{edge['to']}`") |
| @@ -78,29 +84,33 @@ | |
| 78 | return "\n".join(lines) |
| 79 | |
| 80 | |
| 81 | class ContextLoader: |
| 82 | """ |
| 83 | Loads structured context bundles from the navegador graph. |
| 84 | |
| 85 | Usage: |
| 86 | store = GraphStore.sqlite() |
| 87 | loader = ContextLoader(store) |
| 88 | |
| 89 | bundle = loader.load_file("src/auth.py") |
| 90 | bundle = loader.load_function("get_user", file_path="src/auth.py") |
| 91 | bundle = loader.load_class("AuthService", file_path="src/auth.py") |
| 92 | """ |
| 93 | |
| 94 | def __init__(self, store: GraphStore) -> None: |
| 95 | self.store = store |
| 96 | |
| 97 | def load_file(self, file_path: str, depth: int = 2) -> ContextBundle: |
| 98 | """Load context for an entire file — all its symbols + their dependencies.""" |
| 99 | result = self.store.query(queries.FILE_CONTENTS, {"path": file_path}) |
| 100 | target = ContextNode(type="File", name=Path(file_path).name, file_path=file_path) |
| 101 | |
| 102 | nodes = [] |
| 103 | for row in (result.result_set or []): |
| 104 | nodes.append(ContextNode( |
| 105 | type=row[0] or "Unknown", |
| 106 | name=row[1] or "", |
| @@ -107,42 +117,47 @@ | |
| 107 | file_path=file_path, |
| 108 | line_start=row[2], |
| 109 | docstring=row[3], |
| 110 | signature=row[4], |
| 111 | )) |
| 112 | |
| 113 | return ContextBundle( |
| 114 | target=target, |
| 115 | nodes=nodes, |
| 116 | metadata={"depth": depth, "query": "file_contents"}, |
| 117 | ) |
| 118 | |
| 119 | def load_function(self, name: str, file_path: str = "", depth: int = 2) -> ContextBundle: |
| 120 | """Load context for a function — its callers and callees.""" |
| 121 | target = ContextNode(type="Function", name=name, file_path=file_path) |
| 122 | nodes: list[ContextNode] = [] |
| 123 | edges: list[dict[str, str]] = [] |
| 124 | |
| 125 | callees = self.store.query( |
| 126 | queries.CALLEES, {"name": name, "file_path": file_path, "depth": depth} |
| 127 | ) |
| 128 | for row in (callees.result_set or []): |
| 129 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 130 | edges.append({"from": name, "type": "CALLS", "to": row[1]}) |
| 131 | |
| 132 | callers = self.store.query( |
| 133 | queries.CALLERS, {"name": name, "file_path": file_path, "depth": depth} |
| 134 | ) |
| 135 | for row in (callers.result_set or []): |
| 136 | nodes.append(ContextNode(type=row[0], name=row[1], file_path=row[2], line_start=row[3])) |
| 137 | edges.append({"from": row[1], "type": "CALLS", "to": name}) |
| 138 | |
| 139 | return ContextBundle(target=target, nodes=nodes, edges=edges, |
| 140 | metadata={"depth": depth, "query": "function_context"}) |
| 141 | |
| 142 | def load_class(self, name: str, file_path: str = "") -> ContextBundle: |
| 143 | """Load context for a class — its methods, parent classes, and subclasses.""" |
| 144 | target = ContextNode(type="Class", name=name, file_path=file_path) |
| 145 | nodes: list[ContextNode] = [] |
| 146 | edges: list[dict[str, str]] = [] |
| 147 | |
| 148 | parents = self.store.query(queries.CLASS_HIERARCHY, {"name": name}) |
| @@ -153,16 +168,126 @@ | |
| 153 | subs = self.store.query(queries.SUBCLASSES, {"name": name}) |
| 154 | for row in (subs.result_set or []): |
| 155 | nodes.append(ContextNode(type="Class", name=row[0], file_path=row[1])) |
| 156 | edges.append({"from": row[0], "type": "INHERITS", "to": name}) |
| 157 | |
| 158 | return ContextBundle(target=target, nodes=nodes, edges=edges, |
| 159 | metadata={"query": "class_context"}) |
| 160 | |
| 161 | def search(self, query: str, limit: int = 20) -> list[ContextNode]: |
| 162 | """Fuzzy search symbols (functions, classes, methods) by name.""" |
| 163 | result = self.store.query(queries.SYMBOL_SEARCH, {"query": query, "limit": limit}) |
| 164 | return [ |
| 165 | ContextNode(type=row[0], name=row[1], file_path=row[2], |
| 166 | line_start=row[3], docstring=row[4]) |
| 167 | for row in (result.result_set or []) |
| 168 | ] |
| 169 |
| --- navegador/context/loader.py | |
| +++ navegador/context/loader.py | |
| @@ -1,17 +1,13 @@ | |
| 1 | """ |
| 2 | ContextLoader — builds structured context bundles from the navegador graph. |
| 3 | |
| 4 | Operates across both layers: |
| 5 | CODE — files, functions, classes, call graphs, decorators, references |
| 6 | KNOWLEDGE — concepts, rules, decisions, wiki pages, domains |
| 7 | |
| 8 | Output can be dict, JSON string, or markdown for direct paste into AI chat. |
| 9 | """ |
| 10 | |
| 11 | import json |
| 12 | import logging |
| 13 | from dataclasses import dataclass, field |
| @@ -25,15 +21,18 @@ | |
| 21 | |
| 22 | @dataclass |
| 23 | class ContextNode: |
| 24 | type: str |
| 25 | name: str |
| 26 | file_path: str = "" |
| 27 | line_start: int | None = None |
| 28 | docstring: str | None = None |
| 29 | signature: str | None = None |
| 30 | source: str | None = None |
| 31 | description: str | None = None |
| 32 | domain: str | None = None |
| 33 | status: str | None = None |
| 34 | |
| 35 | |
| 36 | @dataclass |
| 37 | class ContextBundle: |
| 38 | target: ContextNode |
| @@ -53,24 +52,31 @@ | |
| 52 | return json.dumps(self.to_dict(), indent=indent) |
| 53 | |
| 54 | def to_markdown(self) -> str: |
| 55 | lines = [ |
| 56 | f"# Context: `{self.target.name}`", |
| 57 | f"**Type:** {self.target.type}", |
| 58 | ] |
| 59 | if self.target.file_path: |
| 60 | lines.append(f"**File:** `{self.target.file_path}`") |
| 61 | if self.target.domain: |
| 62 | lines.append(f"**Domain:** {self.target.domain}") |
| 63 | if self.target.status: |
| 64 | lines.append(f"**Status:** {self.target.status}") |
| 65 | if self.target.docstring or self.target.description: |
| 66 | lines += ["", f"> {self.target.docstring or self.target.description}"] |
| 67 | if self.target.signature: |
| 68 | lines += ["", f"```python\n{self.target.signature}\n```"] |
| 69 | |
| 70 | if self.nodes: |
| 71 | lines += ["", "## Related nodes", ""] |
| 72 | for node in self.nodes: |
| 73 | loc = f"`{node.file_path}`" if node.file_path else "" |
| 74 | lines.append(f"- **{node.type}** `{node.name}` {loc}".strip()) |
| 75 | summary = node.docstring or node.description |
| 76 | if summary: |
| 77 | lines.append(f" > {summary}") |
| 78 | |
| 79 | if self.edges: |
| 80 | lines += ["", "## Relationships", ""] |
| 81 | for edge in self.edges: |
| 82 | lines.append(f"- `{edge['from']}` **{edge['type']}** `{edge['to']}`") |
| @@ -78,29 +84,33 @@ | |
| 84 | return "\n".join(lines) |
| 85 | |
| 86 | |
| 87 | class ContextLoader: |
| 88 | """ |
| 89 | Loads context bundles from the navegador graph — code and knowledge layers. |
| 90 | |
| 91 | Usage: |
| 92 | store = GraphStore.sqlite() |
| 93 | loader = ContextLoader(store) |
| 94 | |
| 95 | bundle = loader.load_file("src/auth.py") |
| 96 | bundle = loader.load_function("validate_token") |
| 97 | bundle = loader.load_class("AuthService") |
| 98 | bundle = loader.explain("validate_token") # full picture |
| 99 | bundle = loader.load_concept("JWT") |
| 100 | bundle = loader.load_domain("auth") |
| 101 | """ |
| 102 | |
| 103 | def __init__(self, store: GraphStore) -> None: |
| 104 | self.store = store |
| 105 | |
| 106 | # ── Code: file ──────────────────────────────────────────────────────────── |
| 107 | |
| 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 "", |
| @@ -107,42 +117,47 @@ | |
| 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.""" |
| 129 | target = ContextNode(type="Function", name=name, file_path=file_path) |
| 130 | nodes: list[ContextNode] = [] |
| 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 | 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}) |
| @@ -153,16 +168,126 @@ | |
| 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 | """ |
| 185 | Full picture: all inbound and outbound relationships for any node, |
| 186 | across both code and knowledge layers. |
| 187 | """ |
| 188 | params = {"name": name, "file_path": file_path} |
| 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 |
+158
-16
| --- navegador/graph/queries.py | ||
| +++ navegador/graph/queries.py | ||
| @@ -1,60 +1,202 @@ | ||
| 1 | 1 | """ |
| 2 | -Common Cypher query templates for navegador context loading. | |
| 2 | +Cypher query templates for navegador. | |
| 3 | + | |
| 4 | +Parameters passed as $name are substituted by FalkorDB at query time. | |
| 5 | +Optional file_path filtering: when file_path is "" the WHERE clause is omitted | |
| 6 | +so callers/callees/references work across the whole graph by name alone. | |
| 3 | 7 | """ |
| 4 | 8 | |
| 5 | -# Find all nodes contained in a file | |
| 9 | +# ── Code: file contents ─────────────────────────────────────────────────────── | |
| 10 | + | |
| 6 | 11 | FILE_CONTENTS = """ |
| 7 | 12 | MATCH (f:File {path: $path})-[:CONTAINS]->(n) |
| 8 | 13 | RETURN labels(n)[0] AS type, n.name AS name, n.line_start AS line, |
| 9 | 14 | n.docstring AS docstring, n.signature AS signature |
| 10 | 15 | ORDER BY n.line_start |
| 11 | 16 | """ |
| 12 | 17 | |
| 13 | -# Find everything a function/file directly imports | |
| 14 | 18 | DIRECT_IMPORTS = """ |
| 15 | 19 | MATCH (n {file_path: $file_path, name: $name})-[:IMPORTS]->(dep) |
| 16 | 20 | RETURN labels(dep)[0] AS type, dep.name AS name, dep.file_path AS file_path |
| 17 | 21 | """ |
| 18 | 22 | |
| 19 | -# Find callers of a function (up to N hops) | |
| 23 | +# ── Code: call graph ────────────────────────────────────────────────────────── | |
| 24 | + | |
| 25 | +# file_path is optional — if empty, match by name only across all files | |
| 20 | 26 | CALLERS = """ |
| 21 | -MATCH (caller)-[:CALLS*1..$depth]->(fn {name: $name, file_path: $file_path}) | |
| 27 | +MATCH (caller)-[:CALLS*1..$depth]->(fn) | |
| 28 | +WHERE fn.name = $name AND ($file_path = '' OR fn.file_path = $file_path) | |
| 22 | 29 | RETURN DISTINCT labels(caller)[0] AS type, caller.name AS name, |
| 23 | 30 | caller.file_path AS file_path, caller.line_start AS line |
| 24 | 31 | """ |
| 25 | 32 | |
| 26 | -# Find callees of a function (what it calls, up to N hops) | |
| 27 | 33 | CALLEES = """ |
| 28 | -MATCH (fn {name: $name, file_path: $file_path})-[:CALLS*1..$depth]->(callee) | |
| 34 | +MATCH (fn)-[:CALLS*1..$depth]->(callee) | |
| 35 | +WHERE fn.name = $name AND ($file_path = '' OR fn.file_path = $file_path) | |
| 29 | 36 | RETURN DISTINCT labels(callee)[0] AS type, callee.name AS name, |
| 30 | 37 | callee.file_path AS file_path, callee.line_start AS line |
| 31 | 38 | """ |
| 32 | 39 | |
| 33 | -# Class hierarchy | |
| 40 | +# ── Code: class hierarchy ───────────────────────────────────────────────────── | |
| 41 | + | |
| 34 | 42 | CLASS_HIERARCHY = """ |
| 35 | 43 | MATCH (c:Class {name: $name})-[:INHERITS*]->(parent) |
| 36 | 44 | RETURN parent.name AS name, parent.file_path AS file_path |
| 37 | 45 | """ |
| 38 | 46 | |
| 39 | -# All subclasses | |
| 40 | 47 | SUBCLASSES = """ |
| 41 | 48 | MATCH (child:Class)-[:INHERITS*]->(c:Class {name: $name}) |
| 42 | 49 | RETURN child.name AS name, child.file_path AS file_path |
| 43 | 50 | """ |
| 44 | 51 | |
| 45 | -# Context bundle: a file + everything 1-2 hops out | |
| 46 | -CONTEXT_BUNDLE = """ | |
| 47 | -MATCH (root {file_path: $file_path}) | |
| 48 | -OPTIONAL MATCH (root)-[r1:CALLS|IMPORTS|CONTAINS|INHERITS*1..2]-(neighbor) | |
| 49 | -RETURN root, collect(DISTINCT neighbor) AS neighbors, | |
| 50 | - collect(DISTINCT type(r1)) AS edge_types | |
| 52 | +# ── Code: decorators ───────────────────────────────────────────────────────── | |
| 53 | + | |
| 54 | +# All functions/methods carrying a given decorator | |
| 55 | +DECORATED_BY = """ | |
| 56 | +MATCH (d:Decorator {name: $decorator_name})-[:DECORATES]->(n) | |
| 57 | +RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path, | |
| 58 | + n.line_start AS line | |
| 59 | +""" | |
| 60 | + | |
| 61 | +# All decorators on a given function/method | |
| 62 | +DECORATORS_FOR = """ | |
| 63 | +MATCH (d:Decorator)-[:DECORATES]->(n) | |
| 64 | +WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path) | |
| 65 | +RETURN d.name AS decorator, d.file_path AS file_path, d.line_start AS line | |
| 66 | +""" | |
| 67 | + | |
| 68 | +# ── Code: references ───────────────────────────────────────────────────────── | |
| 69 | + | |
| 70 | +REFERENCES_TO = """ | |
| 71 | +MATCH (src)-[:REFERENCES]->(tgt) | |
| 72 | +WHERE tgt.name = $name AND ($file_path = '' OR tgt.file_path = $file_path) | |
| 73 | +RETURN DISTINCT labels(src)[0] AS type, src.name AS name, | |
| 74 | + src.file_path AS file_path, src.line_start AS line | |
| 75 | +""" | |
| 76 | + | |
| 77 | +# ── Universal: neighbor traversal ──────────────────────────────────────────── | |
| 78 | + | |
| 79 | +# All nodes reachable from a named node within N hops (any edge type) | |
| 80 | +NEIGHBORS = """ | |
| 81 | +MATCH (root) | |
| 82 | +WHERE root.name = $name AND ($file_path = '' OR root.file_path = $file_path) | |
| 83 | +OPTIONAL MATCH (root)-[r*1..$depth]-(neighbor) | |
| 84 | +RETURN DISTINCT | |
| 85 | + labels(root)[0] AS root_type, | |
| 86 | + root.name AS root_name, | |
| 87 | + labels(neighbor)[0] AS neighbor_type, | |
| 88 | + neighbor.name AS neighbor_name, | |
| 89 | + neighbor.file_path AS neighbor_file_path | |
| 51 | 90 | """ |
| 52 | 91 | |
| 53 | -# Symbol search | |
| 92 | +# ── Universal: search ───────────────────────────────────────────────────────── | |
| 93 | + | |
| 94 | +# Search code symbols by name substring | |
| 54 | 95 | SYMBOL_SEARCH = """ |
| 55 | 96 | MATCH (n) |
| 56 | 97 | WHERE (n:Function OR n:Class OR n:Method) AND n.name CONTAINS $query |
| 57 | 98 | RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path, |
| 58 | 99 | n.line_start AS line, n.docstring AS docstring |
| 59 | 100 | LIMIT $limit |
| 60 | 101 | """ |
| 102 | + | |
| 103 | +# Search code symbols by docstring content | |
| 104 | +DOCSTRING_SEARCH = """ | |
| 105 | +MATCH (n) | |
| 106 | +WHERE (n:Function OR n:Class OR n:Method) | |
| 107 | + AND n.docstring IS NOT NULL | |
| 108 | + AND toLower(n.docstring) CONTAINS toLower($query) | |
| 109 | +RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path, | |
| 110 | + n.line_start AS line, n.docstring AS docstring | |
| 111 | +LIMIT $limit | |
| 112 | +""" | |
| 113 | + | |
| 114 | +# Search knowledge layer (concepts, rules, decisions, wiki) by name or description | |
| 115 | +KNOWLEDGE_SEARCH = """ | |
| 116 | +MATCH (n) | |
| 117 | +WHERE (n:Concept OR n:Rule OR n:Decision OR n:WikiPage) | |
| 118 | + AND (toLower(n.name) CONTAINS toLower($query) | |
| 119 | + OR (n.description IS NOT NULL AND toLower(n.description) CONTAINS toLower($query))) | |
| 120 | +RETURN labels(n)[0] AS type, n.name AS name, n.description AS description, | |
| 121 | + n.domain AS domain, n.status AS status | |
| 122 | +LIMIT $limit | |
| 123 | +""" | |
| 124 | + | |
| 125 | +# Search everything — code + knowledge — in one query | |
| 126 | +GLOBAL_SEARCH = """ | |
| 127 | +MATCH (n) | |
| 128 | +WHERE (n:Function OR n:Class OR n:Method OR n:Concept OR n:Rule OR n:Decision OR n:WikiPage) | |
| 129 | + AND (toLower(n.name) CONTAINS toLower($query) | |
| 130 | + OR (n.docstring IS NOT NULL AND toLower(n.docstring) CONTAINS toLower($query)) | |
| 131 | + OR (n.description IS NOT NULL AND toLower(n.description) CONTAINS toLower($query))) | |
| 132 | +RETURN labels(n)[0] AS type, n.name AS name, | |
| 133 | + coalesce(n.file_path, '') AS file_path, | |
| 134 | + coalesce(n.docstring, n.description, '') AS summary, | |
| 135 | + n.line_start AS line | |
| 136 | +LIMIT $limit | |
| 137 | +""" | |
| 138 | + | |
| 139 | +# ── Knowledge: domain ───────────────────────────────────────────────────────── | |
| 140 | + | |
| 141 | +DOMAIN_CONTENTS = """ | |
| 142 | +MATCH (n)-[:BELONGS_TO]->(d:Domain {name: $domain}) | |
| 143 | +RETURN labels(n)[0] AS type, n.name AS name, | |
| 144 | + coalesce(n.file_path, '') AS file_path, | |
| 145 | + coalesce(n.docstring, n.description, '') AS summary | |
| 146 | +ORDER BY labels(n)[0], n.name | |
| 147 | +""" | |
| 148 | + | |
| 149 | +# ── Knowledge: concept context ─────────────────────────────────────────────── | |
| 150 | + | |
| 151 | +CONCEPT_CONTEXT = """ | |
| 152 | +MATCH (c:Concept {name: $name}) | |
| 153 | +OPTIONAL MATCH (c)-[:RELATED_TO]-(related:Concept) | |
| 154 | +OPTIONAL MATCH (rule:Rule)-[:GOVERNS]->(c) | |
| 155 | +OPTIONAL MATCH (wiki:WikiPage)-[:DOCUMENTS]->(c) | |
| 156 | +OPTIONAL MATCH (impl)-[:IMPLEMENTS]->(c) | |
| 157 | +OPTIONAL MATCH (c)-[:BELONGS_TO]->(domain:Domain) | |
| 158 | +RETURN | |
| 159 | + c.name AS name, c.description AS description, | |
| 160 | + c.status AS status, c.domain AS domain, | |
| 161 | + collect(DISTINCT related.name) AS related_concepts, | |
| 162 | + collect(DISTINCT rule.name) AS governing_rules, | |
| 163 | + collect(DISTINCT wiki.name) AS wiki_pages, | |
| 164 | + collect(DISTINCT impl.name) AS implemented_by, | |
| 165 | + collect(DISTINCT domain.name) AS domains | |
| 166 | +""" | |
| 167 | + | |
| 168 | +# ── Explain: full picture for any named node ────────────────────────────────── | |
| 169 | + | |
| 170 | +# All outbound relationships from a node | |
| 171 | +OUTBOUND = """ | |
| 172 | +MATCH (n)-[r]->(neighbor) | |
| 173 | +WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path) | |
| 174 | +RETURN type(r) AS rel, labels(neighbor)[0] AS neighbor_type, | |
| 175 | + neighbor.name AS neighbor_name, | |
| 176 | + coalesce(neighbor.file_path, '') AS neighbor_file_path | |
| 177 | +ORDER BY rel, neighbor_name | |
| 178 | +""" | |
| 179 | + | |
| 180 | +# All inbound relationships to a node | |
| 181 | +INBOUND = """ | |
| 182 | +MATCH (neighbor)-[r]->(n) | |
| 183 | +WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path) | |
| 184 | +RETURN type(r) AS rel, labels(neighbor)[0] AS neighbor_type, | |
| 185 | + neighbor.name AS neighbor_name, | |
| 186 | + coalesce(neighbor.file_path, '') AS neighbor_file_path | |
| 187 | +ORDER BY rel, neighbor_name | |
| 188 | +""" | |
| 189 | + | |
| 190 | +# ── Stats ───────────────────────────────────────────────────────────────────── | |
| 191 | + | |
| 192 | +NODE_TYPE_COUNTS = """ | |
| 193 | +MATCH (n) | |
| 194 | +RETURN labels(n)[0] AS type, count(n) AS count | |
| 195 | +ORDER BY count DESC | |
| 196 | +""" | |
| 197 | + | |
| 198 | +EDGE_TYPE_COUNTS = """ | |
| 199 | +MATCH ()-[r]->() | |
| 200 | +RETURN type(r) AS type, count(r) AS count | |
| 201 | +ORDER BY count DESC | |
| 202 | +""" | |
| 61 | 203 |
| --- navegador/graph/queries.py | |
| +++ navegador/graph/queries.py | |
| @@ -1,60 +1,202 @@ | |
| 1 | """ |
| 2 | Common Cypher query templates for navegador context loading. |
| 3 | """ |
| 4 | |
| 5 | # Find all nodes contained in a file |
| 6 | FILE_CONTENTS = """ |
| 7 | MATCH (f:File {path: $path})-[:CONTAINS]->(n) |
| 8 | RETURN labels(n)[0] AS type, n.name AS name, n.line_start AS line, |
| 9 | n.docstring AS docstring, n.signature AS signature |
| 10 | ORDER BY n.line_start |
| 11 | """ |
| 12 | |
| 13 | # Find everything a function/file directly imports |
| 14 | DIRECT_IMPORTS = """ |
| 15 | MATCH (n {file_path: $file_path, name: $name})-[:IMPORTS]->(dep) |
| 16 | RETURN labels(dep)[0] AS type, dep.name AS name, dep.file_path AS file_path |
| 17 | """ |
| 18 | |
| 19 | # Find callers of a function (up to N hops) |
| 20 | CALLERS = """ |
| 21 | MATCH (caller)-[:CALLS*1..$depth]->(fn {name: $name, file_path: $file_path}) |
| 22 | RETURN DISTINCT labels(caller)[0] AS type, caller.name AS name, |
| 23 | caller.file_path AS file_path, caller.line_start AS line |
| 24 | """ |
| 25 | |
| 26 | # Find callees of a function (what it calls, up to N hops) |
| 27 | CALLEES = """ |
| 28 | MATCH (fn {name: $name, file_path: $file_path})-[:CALLS*1..$depth]->(callee) |
| 29 | RETURN DISTINCT labels(callee)[0] AS type, callee.name AS name, |
| 30 | callee.file_path AS file_path, callee.line_start AS line |
| 31 | """ |
| 32 | |
| 33 | # Class hierarchy |
| 34 | CLASS_HIERARCHY = """ |
| 35 | MATCH (c:Class {name: $name})-[:INHERITS*]->(parent) |
| 36 | RETURN parent.name AS name, parent.file_path AS file_path |
| 37 | """ |
| 38 | |
| 39 | # All subclasses |
| 40 | SUBCLASSES = """ |
| 41 | MATCH (child:Class)-[:INHERITS*]->(c:Class {name: $name}) |
| 42 | RETURN child.name AS name, child.file_path AS file_path |
| 43 | """ |
| 44 | |
| 45 | # Context bundle: a file + everything 1-2 hops out |
| 46 | CONTEXT_BUNDLE = """ |
| 47 | MATCH (root {file_path: $file_path}) |
| 48 | OPTIONAL MATCH (root)-[r1:CALLS|IMPORTS|CONTAINS|INHERITS*1..2]-(neighbor) |
| 49 | RETURN root, collect(DISTINCT neighbor) AS neighbors, |
| 50 | collect(DISTINCT type(r1)) AS edge_types |
| 51 | """ |
| 52 | |
| 53 | # Symbol search |
| 54 | SYMBOL_SEARCH = """ |
| 55 | MATCH (n) |
| 56 | WHERE (n:Function OR n:Class OR n:Method) AND n.name CONTAINS $query |
| 57 | RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path, |
| 58 | n.line_start AS line, n.docstring AS docstring |
| 59 | LIMIT $limit |
| 60 | """ |
| 61 |
| --- navegador/graph/queries.py | |
| +++ navegador/graph/queries.py | |
| @@ -1,60 +1,202 @@ | |
| 1 | """ |
| 2 | Cypher query templates for navegador. |
| 3 | |
| 4 | Parameters passed as $name are substituted by FalkorDB at query time. |
| 5 | Optional file_path filtering: when file_path is "" the WHERE clause is omitted |
| 6 | so callers/callees/references work across the whole graph by name alone. |
| 7 | """ |
| 8 | |
| 9 | # ── Code: file contents ─────────────────────────────────────────────────────── |
| 10 | |
| 11 | FILE_CONTENTS = """ |
| 12 | MATCH (f:File {path: $path})-[:CONTAINS]->(n) |
| 13 | RETURN labels(n)[0] AS type, n.name AS name, n.line_start AS line, |
| 14 | n.docstring AS docstring, n.signature AS signature |
| 15 | ORDER BY n.line_start |
| 16 | """ |
| 17 | |
| 18 | DIRECT_IMPORTS = """ |
| 19 | MATCH (n {file_path: $file_path, name: $name})-[:IMPORTS]->(dep) |
| 20 | RETURN labels(dep)[0] AS type, dep.name AS name, dep.file_path AS file_path |
| 21 | """ |
| 22 | |
| 23 | # ── Code: call graph ────────────────────────────────────────────────────────── |
| 24 | |
| 25 | # file_path is optional — if empty, match by name only across all files |
| 26 | CALLERS = """ |
| 27 | MATCH (caller)-[:CALLS*1..$depth]->(fn) |
| 28 | WHERE fn.name = $name AND ($file_path = '' OR fn.file_path = $file_path) |
| 29 | RETURN DISTINCT labels(caller)[0] AS type, caller.name AS name, |
| 30 | caller.file_path AS file_path, caller.line_start AS line |
| 31 | """ |
| 32 | |
| 33 | CALLEES = """ |
| 34 | MATCH (fn)-[:CALLS*1..$depth]->(callee) |
| 35 | WHERE fn.name = $name AND ($file_path = '' OR fn.file_path = $file_path) |
| 36 | RETURN DISTINCT labels(callee)[0] AS type, callee.name AS name, |
| 37 | callee.file_path AS file_path, callee.line_start AS line |
| 38 | """ |
| 39 | |
| 40 | # ── Code: class hierarchy ───────────────────────────────────────────────────── |
| 41 | |
| 42 | CLASS_HIERARCHY = """ |
| 43 | MATCH (c:Class {name: $name})-[:INHERITS*]->(parent) |
| 44 | RETURN parent.name AS name, parent.file_path AS file_path |
| 45 | """ |
| 46 | |
| 47 | SUBCLASSES = """ |
| 48 | MATCH (child:Class)-[:INHERITS*]->(c:Class {name: $name}) |
| 49 | RETURN child.name AS name, child.file_path AS file_path |
| 50 | """ |
| 51 | |
| 52 | # ── Code: decorators ───────────────────────────────────────────────────────── |
| 53 | |
| 54 | # All functions/methods carrying a given decorator |
| 55 | DECORATED_BY = """ |
| 56 | MATCH (d:Decorator {name: $decorator_name})-[:DECORATES]->(n) |
| 57 | RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path, |
| 58 | n.line_start AS line |
| 59 | """ |
| 60 | |
| 61 | # All decorators on a given function/method |
| 62 | DECORATORS_FOR = """ |
| 63 | MATCH (d:Decorator)-[:DECORATES]->(n) |
| 64 | WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path) |
| 65 | RETURN d.name AS decorator, d.file_path AS file_path, d.line_start AS line |
| 66 | """ |
| 67 | |
| 68 | # ── Code: references ───────────────────────────────────────────────────────── |
| 69 | |
| 70 | REFERENCES_TO = """ |
| 71 | MATCH (src)-[:REFERENCES]->(tgt) |
| 72 | WHERE tgt.name = $name AND ($file_path = '' OR tgt.file_path = $file_path) |
| 73 | RETURN DISTINCT labels(src)[0] AS type, src.name AS name, |
| 74 | src.file_path AS file_path, src.line_start AS line |
| 75 | """ |
| 76 | |
| 77 | # ── Universal: neighbor traversal ──────────────────────────────────────────── |
| 78 | |
| 79 | # All nodes reachable from a named node within N hops (any edge type) |
| 80 | NEIGHBORS = """ |
| 81 | MATCH (root) |
| 82 | WHERE root.name = $name AND ($file_path = '' OR root.file_path = $file_path) |
| 83 | OPTIONAL MATCH (root)-[r*1..$depth]-(neighbor) |
| 84 | RETURN DISTINCT |
| 85 | labels(root)[0] AS root_type, |
| 86 | root.name AS root_name, |
| 87 | labels(neighbor)[0] AS neighbor_type, |
| 88 | neighbor.name AS neighbor_name, |
| 89 | neighbor.file_path AS neighbor_file_path |
| 90 | """ |
| 91 | |
| 92 | # ── Universal: search ───────────────────────────────────────────────────────── |
| 93 | |
| 94 | # Search code symbols by name substring |
| 95 | SYMBOL_SEARCH = """ |
| 96 | MATCH (n) |
| 97 | WHERE (n:Function OR n:Class OR n:Method) AND n.name CONTAINS $query |
| 98 | RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path, |
| 99 | n.line_start AS line, n.docstring AS docstring |
| 100 | LIMIT $limit |
| 101 | """ |
| 102 | |
| 103 | # Search code symbols by docstring content |
| 104 | DOCSTRING_SEARCH = """ |
| 105 | MATCH (n) |
| 106 | WHERE (n:Function OR n:Class OR n:Method) |
| 107 | AND n.docstring IS NOT NULL |
| 108 | AND toLower(n.docstring) CONTAINS toLower($query) |
| 109 | RETURN labels(n)[0] AS type, n.name AS name, n.file_path AS file_path, |
| 110 | n.line_start AS line, n.docstring AS docstring |
| 111 | LIMIT $limit |
| 112 | """ |
| 113 | |
| 114 | # Search knowledge layer (concepts, rules, decisions, wiki) by name or description |
| 115 | KNOWLEDGE_SEARCH = """ |
| 116 | MATCH (n) |
| 117 | WHERE (n:Concept OR n:Rule OR n:Decision OR n:WikiPage) |
| 118 | AND (toLower(n.name) CONTAINS toLower($query) |
| 119 | OR (n.description IS NOT NULL AND toLower(n.description) CONTAINS toLower($query))) |
| 120 | RETURN labels(n)[0] AS type, n.name AS name, n.description AS description, |
| 121 | n.domain AS domain, n.status AS status |
| 122 | LIMIT $limit |
| 123 | """ |
| 124 | |
| 125 | # Search everything — code + knowledge — in one query |
| 126 | GLOBAL_SEARCH = """ |
| 127 | MATCH (n) |
| 128 | WHERE (n:Function OR n:Class OR n:Method OR n:Concept OR n:Rule OR n:Decision OR n:WikiPage) |
| 129 | AND (toLower(n.name) CONTAINS toLower($query) |
| 130 | OR (n.docstring IS NOT NULL AND toLower(n.docstring) CONTAINS toLower($query)) |
| 131 | OR (n.description IS NOT NULL AND toLower(n.description) CONTAINS toLower($query))) |
| 132 | RETURN labels(n)[0] AS type, n.name AS name, |
| 133 | coalesce(n.file_path, '') AS file_path, |
| 134 | coalesce(n.docstring, n.description, '') AS summary, |
| 135 | n.line_start AS line |
| 136 | LIMIT $limit |
| 137 | """ |
| 138 | |
| 139 | # ── Knowledge: domain ───────────────────────────────────────────────────────── |
| 140 | |
| 141 | DOMAIN_CONTENTS = """ |
| 142 | MATCH (n)-[:BELONGS_TO]->(d:Domain {name: $domain}) |
| 143 | RETURN labels(n)[0] AS type, n.name AS name, |
| 144 | coalesce(n.file_path, '') AS file_path, |
| 145 | coalesce(n.docstring, n.description, '') AS summary |
| 146 | ORDER BY labels(n)[0], n.name |
| 147 | """ |
| 148 | |
| 149 | # ── Knowledge: concept context ─────────────────────────────────────────────── |
| 150 | |
| 151 | CONCEPT_CONTEXT = """ |
| 152 | MATCH (c:Concept {name: $name}) |
| 153 | OPTIONAL MATCH (c)-[:RELATED_TO]-(related:Concept) |
| 154 | OPTIONAL MATCH (rule:Rule)-[:GOVERNS]->(c) |
| 155 | OPTIONAL MATCH (wiki:WikiPage)-[:DOCUMENTS]->(c) |
| 156 | OPTIONAL MATCH (impl)-[:IMPLEMENTS]->(c) |
| 157 | OPTIONAL MATCH (c)-[:BELONGS_TO]->(domain:Domain) |
| 158 | RETURN |
| 159 | c.name AS name, c.description AS description, |
| 160 | c.status AS status, c.domain AS domain, |
| 161 | collect(DISTINCT related.name) AS related_concepts, |
| 162 | collect(DISTINCT rule.name) AS governing_rules, |
| 163 | collect(DISTINCT wiki.name) AS wiki_pages, |
| 164 | collect(DISTINCT impl.name) AS implemented_by, |
| 165 | collect(DISTINCT domain.name) AS domains |
| 166 | """ |
| 167 | |
| 168 | # ── Explain: full picture for any named node ────────────────────────────────── |
| 169 | |
| 170 | # All outbound relationships from a node |
| 171 | OUTBOUND = """ |
| 172 | MATCH (n)-[r]->(neighbor) |
| 173 | WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path) |
| 174 | RETURN type(r) AS rel, labels(neighbor)[0] AS neighbor_type, |
| 175 | neighbor.name AS neighbor_name, |
| 176 | coalesce(neighbor.file_path, '') AS neighbor_file_path |
| 177 | ORDER BY rel, neighbor_name |
| 178 | """ |
| 179 | |
| 180 | # All inbound relationships to a node |
| 181 | INBOUND = """ |
| 182 | MATCH (neighbor)-[r]->(n) |
| 183 | WHERE n.name = $name AND ($file_path = '' OR n.file_path = $file_path) |
| 184 | RETURN type(r) AS rel, labels(neighbor)[0] AS neighbor_type, |
| 185 | neighbor.name AS neighbor_name, |
| 186 | coalesce(neighbor.file_path, '') AS neighbor_file_path |
| 187 | ORDER BY rel, neighbor_name |
| 188 | """ |
| 189 | |
| 190 | # ── Stats ───────────────────────────────────────────────────────────────────── |
| 191 | |
| 192 | NODE_TYPE_COUNTS = """ |
| 193 | MATCH (n) |
| 194 | RETURN labels(n)[0] AS type, count(n) AS count |
| 195 | ORDER BY count DESC |
| 196 | """ |
| 197 | |
| 198 | EDGE_TYPE_COUNTS = """ |
| 199 | MATCH ()-[r]->() |
| 200 | RETURN type(r) AS type, count(r) AS count |
| 201 | ORDER BY count DESC |
| 202 | """ |
| 203 |
+55
-8
| --- navegador/graph/schema.py | ||
| +++ navegador/graph/schema.py | ||
| @@ -1,15 +1,23 @@ | ||
| 1 | 1 | """ |
| 2 | 2 | Graph schema — node labels and edge types for the navegador property graph. |
| 3 | 3 | |
| 4 | -Node properties vary by label but all share: name, file_path, line_start, line_end. | |
| 4 | +Navegador maintains two complementary layers in one graph: | |
| 5 | + | |
| 6 | + CODE layer — AST-derived structure (files, functions, classes, calls, imports) | |
| 7 | + KNOWLEDGE layer — business context (concepts, rules, decisions, wiki, people) | |
| 8 | + | |
| 9 | +The two layers are connected by IMPLEMENTS, DOCUMENTS, GOVERNS, and ANNOTATES | |
| 10 | +edges, so agents can traverse from a function straight to the business rule it | |
| 11 | +enforces, or from a wiki page down to the exact code that implements it. | |
| 5 | 12 | """ |
| 6 | 13 | |
| 7 | 14 | from enum import StrEnum |
| 8 | 15 | |
| 9 | 16 | |
| 10 | 17 | class NodeLabel(StrEnum): |
| 18 | + # ── Code layer ──────────────────────────────────────────────────────────── | |
| 11 | 19 | Repository = "Repository" |
| 12 | 20 | File = "File" |
| 13 | 21 | Module = "Module" |
| 14 | 22 | Class = "Class" |
| 15 | 23 | Function = "Function" |
| @@ -16,28 +24,45 @@ | ||
| 16 | 24 | Method = "Method" |
| 17 | 25 | Variable = "Variable" |
| 18 | 26 | Import = "Import" |
| 19 | 27 | Decorator = "Decorator" |
| 20 | 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 | + | |
| 21 | 37 | |
| 22 | 38 | class EdgeType(StrEnum): |
| 23 | - # structural | |
| 24 | - CONTAINS = "CONTAINS" # File -CONTAINS-> Function/Class/Variable | |
| 39 | + # ── Code structural ─────────────────────────────────────────────────────── | |
| 40 | + CONTAINS = "CONTAINS" # File/Class -CONTAINS-> Function/Class/Variable | |
| 25 | 41 | DEFINES = "DEFINES" # Module -DEFINES-> Class/Function |
| 26 | - # dependencies | |
| 27 | 42 | IMPORTS = "IMPORTS" # File -IMPORTS-> Module/File |
| 28 | - DEPENDS_ON = "DEPENDS_ON" # Module/Package level dependency | |
| 29 | - # code relationships | |
| 43 | + DEPENDS_ON = "DEPENDS_ON" # module/package-level dependency | |
| 30 | 44 | CALLS = "CALLS" # Function -CALLS-> Function |
| 31 | 45 | REFERENCES = "REFERENCES" # Function/Class -REFERENCES-> Variable/Class |
| 32 | 46 | INHERITS = "INHERITS" # Class -INHERITS-> Class |
| 33 | - IMPLEMENTS = "IMPLEMENTS" # Class -IMPLEMENTS-> Class (for interfaces/ABCs) | |
| 47 | + IMPLEMENTS = "IMPLEMENTS" # Class/Function -IMPLEMENTS-> Concept/Rule | |
| 34 | 48 | DECORATES = "DECORATES" # Decorator -DECORATES-> Function/Class |
| 35 | 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 | + | |
| 36 | 59 | |
| 37 | -# Common property keys per node label | |
| 60 | +# ── Property keys per node label ────────────────────────────────────────────── | |
| 61 | + | |
| 38 | 62 | NODE_PROPS = { |
| 63 | + # Code layer | |
| 39 | 64 | NodeLabel.Repository: ["name", "path", "language", "description"], |
| 40 | 65 | NodeLabel.File: ["name", "path", "language", "size", "line_count"], |
| 41 | 66 | NodeLabel.Module: ["name", "file_path", "docstring"], |
| 42 | 67 | NodeLabel.Class: ["name", "file_path", "line_start", "line_end", "docstring", "source"], |
| 43 | 68 | NodeLabel.Function: [ |
| @@ -48,6 +73,28 @@ | ||
| 48 | 73 | "docstring", "source", "signature", "class_name", |
| 49 | 74 | ], |
| 50 | 75 | NodeLabel.Variable: ["name", "file_path", "line_start", "type_annotation"], |
| 51 | 76 | NodeLabel.Import: ["name", "file_path", "line_start", "module", "alias"], |
| 52 | 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 | + ], | |
| 53 | 100 | } |
| 54 | 101 |
| --- navegador/graph/schema.py | |
| +++ navegador/graph/schema.py | |
| @@ -1,15 +1,23 @@ | |
| 1 | """ |
| 2 | Graph schema — node labels and edge types for the navegador property graph. |
| 3 | |
| 4 | Node properties vary by label but all share: name, file_path, line_start, line_end. |
| 5 | """ |
| 6 | |
| 7 | from enum import StrEnum |
| 8 | |
| 9 | |
| 10 | class NodeLabel(StrEnum): |
| 11 | Repository = "Repository" |
| 12 | File = "File" |
| 13 | Module = "Module" |
| 14 | Class = "Class" |
| 15 | Function = "Function" |
| @@ -16,28 +24,45 @@ | |
| 16 | Method = "Method" |
| 17 | Variable = "Variable" |
| 18 | Import = "Import" |
| 19 | Decorator = "Decorator" |
| 20 | |
| 21 | |
| 22 | class EdgeType(StrEnum): |
| 23 | # structural |
| 24 | CONTAINS = "CONTAINS" # File -CONTAINS-> Function/Class/Variable |
| 25 | DEFINES = "DEFINES" # Module -DEFINES-> Class/Function |
| 26 | # dependencies |
| 27 | IMPORTS = "IMPORTS" # File -IMPORTS-> Module/File |
| 28 | DEPENDS_ON = "DEPENDS_ON" # Module/Package level dependency |
| 29 | # code relationships |
| 30 | CALLS = "CALLS" # Function -CALLS-> Function |
| 31 | REFERENCES = "REFERENCES" # Function/Class -REFERENCES-> Variable/Class |
| 32 | INHERITS = "INHERITS" # Class -INHERITS-> Class |
| 33 | IMPLEMENTS = "IMPLEMENTS" # Class -IMPLEMENTS-> Class (for interfaces/ABCs) |
| 34 | DECORATES = "DECORATES" # Decorator -DECORATES-> Function/Class |
| 35 | |
| 36 | |
| 37 | # Common property keys per node label |
| 38 | NODE_PROPS = { |
| 39 | NodeLabel.Repository: ["name", "path", "language", "description"], |
| 40 | NodeLabel.File: ["name", "path", "language", "size", "line_count"], |
| 41 | NodeLabel.Module: ["name", "file_path", "docstring"], |
| 42 | NodeLabel.Class: ["name", "file_path", "line_start", "line_end", "docstring", "source"], |
| 43 | NodeLabel.Function: [ |
| @@ -48,6 +73,28 @@ | |
| 48 | "docstring", "source", "signature", "class_name", |
| 49 | ], |
| 50 | NodeLabel.Variable: ["name", "file_path", "line_start", "type_annotation"], |
| 51 | NodeLabel.Import: ["name", "file_path", "line_start", "module", "alias"], |
| 52 | NodeLabel.Decorator: ["name", "file_path", "line_start"], |
| 53 | } |
| 54 |
| --- navegador/graph/schema.py | |
| +++ navegador/graph/schema.py | |
| @@ -1,15 +1,23 @@ | |
| 1 | """ |
| 2 | Graph schema — node labels and edge types for the navegador property graph. |
| 3 | |
| 4 | Navegador maintains two complementary layers in one graph: |
| 5 | |
| 6 | CODE layer — AST-derived structure (files, functions, classes, calls, imports) |
| 7 | KNOWLEDGE layer — business context (concepts, rules, decisions, wiki, people) |
| 8 | |
| 9 | The two layers are connected by IMPLEMENTS, DOCUMENTS, GOVERNS, and ANNOTATES |
| 10 | edges, so agents can traverse from a function straight to the business rule it |
| 11 | enforces, or from a wiki page down to the exact code that implements it. |
| 12 | """ |
| 13 | |
| 14 | from enum import StrEnum |
| 15 | |
| 16 | |
| 17 | class NodeLabel(StrEnum): |
| 18 | # ── Code layer ──────────────────────────────────────────────────────────── |
| 19 | Repository = "Repository" |
| 20 | File = "File" |
| 21 | Module = "Module" |
| 22 | Class = "Class" |
| 23 | Function = "Function" |
| @@ -16,28 +24,45 @@ | |
| 24 | Method = "Method" |
| 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 = { |
| 63 | # Code layer |
| 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: [ |
| @@ -48,6 +73,28 @@ | |
| 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 |
+3
-1
| --- navegador/ingestion/__init__.py | ||
| +++ navegador/ingestion/__init__.py | ||
| @@ -1,3 +1,5 @@ | ||
| 1 | +from .knowledge import KnowledgeIngester | |
| 1 | 2 | from .parser import RepoIngester |
| 3 | +from .wiki import WikiIngester | |
| 2 | 4 | |
| 3 | -__all__ = ["RepoIngester"] | |
| 5 | +__all__ = ["RepoIngester", "KnowledgeIngester", "WikiIngester"] | |
| 4 | 6 | |
| 5 | 7 | ADDED navegador/ingestion/knowledge.py |
| 6 | 8 | ADDED navegador/ingestion/wiki.py |
| --- navegador/ingestion/__init__.py | |
| +++ navegador/ingestion/__init__.py | |
| @@ -1,3 +1,5 @@ | |
| 1 | from .parser import RepoIngester |
| 2 | |
| 3 | __all__ = ["RepoIngester"] |
| 4 | |
| 5 | DDED navegador/ingestion/knowledge.py |
| 6 | DDED navegador/ingestion/wiki.py |
| --- navegador/ingestion/__init__.py | |
| +++ navegador/ingestion/__init__.py | |
| @@ -1,3 +1,5 @@ | |
| 1 | from .knowledge import KnowledgeIngester |
| 2 | from .parser import RepoIngester |
| 3 | from .wiki import WikiIngester |
| 4 | |
| 5 | __all__ = ["RepoIngester", "KnowledgeIngester", "WikiIngester"] |
| 6 | |
| 7 | DDED navegador/ingestion/knowledge.py |
| 8 | DDED navegador/ingestion/wiki.py |
| --- a/navegador/ingestion/knowledge.py | ||
| +++ b/navegador/ingestion/knowledge.py | ||
| @@ -0,0 +1,206 @@ | ||
| 1 | +""" | |
| 2 | +KnowledgeIngester — manual curation of business concepts, rules, decisions, | |
| 3 | +people, and domains into the navegador graph. | |
| 4 | + | |
| 5 | +These are the things that don't live in code but belong in the knowledge graph: | |
| 6 | +business rules, architectural decisions, domain groupings, ownership, wiki refs. | |
| 7 | +""" | |
| 8 | + | |
| 9 | +import logging | |
| 10 | +from typing import Any | |
| 11 | + | |
| 12 | +from navegador.graph.schema import EdgeType, NodeLabel | |
| 13 | +from navegador.graph.store import GraphStore | |
| 14 | + | |
| 15 | +logger = logging.getLogger(__name__) | |
| 16 | + | |
| 17 | + | |
| 18 | +class KnowledgeIngester: | |
| 19 | + """ | |
| 20 | + Writes business knowledge nodes and relationships into the graph. | |
| 21 | + | |
| 22 | + Usage: | |
| 23 | + store = GraphStore.sqlite(".navegador/graph.db") | |
| 24 | + k = KnowledgeIngester(store) | |
| 25 | + | |
| 26 | + k.add_domain("auth", description="Authentication and authorisation") | |
| 27 | + k.add_concept("JWT", description="Stateless token auth", domain="auth") | |
| 28 | + k.add_rule("tokens must expire", domain="auth", severity="critical", | |
| 29 | + rationale="Security requirement per OWASP") | |
| 30 | + k.annotate_code("validate_token", "Function", concept="JWT") | |
| 31 | + k.wiki_page("JWT Auth", url="...", content="...") | |
| 32 | + """ | |
| 33 | + | |
| 34 | + def __init__(self, store: GraphStore) -> None: | |
| 35 | + self.store = store | |
| 36 | + | |
| 37 | + # ── Domains ─────────────────────────────────────────────────────────────── | |
| 38 | + | |
| 39 | + def add_domain(self, name: str, description: str = "") -> None: | |
| 40 | + self.store.create_node(e.create_node( | |
| 41 | + { | |
| 42 | + } ) | |
| 43 | + if domain:Domain: %s", name) | |
| 44 | + | |
| 45 | + # ── Concepts ────────────────────────────────────────────────────────────── | |
| 46 | + | |
| 47 | + def add_concept( | |
| 48 | + self, | |
| 49 | + name: str, | |
| 50 | + description: str = "", | |
| 51 | + domain: str = "", | |
| 52 | + status: str = "", | |
| 53 | + rules: str = "", | |
| 54 | + examples: str = "", | |
| 55 | + wiki_refs: str = "", | |
| 56 | + ) -> None: | |
| 57 | + NodeLabel.Concept, { | |
| 58 | + me": name, | |
| 59 | + anual curation of business con""" | |
| 60 | +KnowledgeIngester — manu"rules": rut( | |
| 61 | + self, | |
| 62 | +──────── | |
| 63 | + | |
| 64 | + def description="Authentication and }e: str, | |
| 65 | + description: str = "", | |
| 66 | + domain: str = "", | |
| 67 | + Stateless token auth", domain="auth") | |
| 68 | + k.add_rule("tokens must expire", domain="auth", severity="critical", | |
| 69 | + rationale="Security requirement per OWASP") | |
| 70 | + k.annotate_code("validate_tokek.wiki_page("JWT Auth", url="...", content="...") | |
| 71 | + """ | |
| 72 | + | |
| 73 | + def __init__(sNodeLabel.Rule, { | |
| 74 | + me": name, | |
| 75 | + anual curation of business con""" | |
| 76 | +KnowledgeIngester — manual c──────── | |
| 77 | + | |
| 78 | + }e: str, | |
| 79 | + description: str = "", | |
| 80 | + domain: str = "", | |
| 81 | + status: str = "", | |
| 82 | + rules: str = "", | |
| 83 | + examples: str = "", | |
| 84 | + wiki_refs: str = "", | |
| 85 | + ) -> None: | |
| 86 | + self.store.create_node( | |
| 87 | + NodeLabel.Concept, | |
| 88 | + { | |
| 89 | + { | |
| 90 | + "name": name, | |
| 91 | + "description": description, | |
| 92 | + target_label, | |
| 93 | + {"na "", | |
| 94 | + teaDecision ────────────────────────────────────────────────────� | |
| 95 | + | |
| 96 | + def add_decision( | |
| 97 | + self, | |
| 98 | + name: str, | |
| 99 | + description: str = "", | |
| 100 | + domain: str = "", | |
| 101 | + status: str = "accepted", | |
| 102 | + rationale: str = "", | |
| 103 | + alternatives: str = "", | |
| 104 | + date: str = "", | |
| 105 | + ) -> None: | |
| 106 | + self.store.create_node(NodeLabel.Decision, { | |
| 107 | + me": name, | |
| 108 | + anual curation of business con""" | |
| 109 | +KnowledgeIngester — manu"alternatives":"date": date, | |
| 110 | +cisions,"e: str, | |
| 111 | + description: str = "", | |
| 112 | + domain: str = "", | |
| 113 | + NodeLabel.Person, { | |
| 114 | + "email": email, | |
| 115 | + l": email, | |
| 116 | + "team": team, | |
| 117 | +cisions," ) | |
| 118 | + if domain:Person: %s", name) | |
| 119 | + | |
| 120 | + def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None: | |
| 121 | + """Assign a person as owner of any node.""" | |
| 122 | + self.store.create_edge( | |
| 123 | + target_label, target_label, | |
| 124 | + {"name": target_name}, | |
| 125 | + EdgeType.ASSIGNED_TO, | |
| 126 | + {"name": person_name}, | |
| 127 | + ) | |
| 128 | + | |
| 129 | + # ── Wiki pages ──────────────────────────────────────────────────────────── | |
| 130 | + | |
| 131 | + def wiki_page( | |
| 132 | + self, | |
| 133 | + name: str, | |
| 134 | + url: str = "", | |
| 135 | + source: str = "github", | |
| 136 | + content: str = "", | |
| 137 | + updated_at: str = "", | |
| 138 | + ) -> None: | |
| 139 | + self.store.create_node(NodeLabel.WikiPage, { | |
| 140 | + ": status, | |
| 141 | + "alternatives": al "alternatives": alternative, | |
| 142 | + }, | |
| 143 | + ) | |
| 144 | + } ) | |
| 145 | + if domain: | |
| 146 | + self._link_to_domain(name, NodeLself, wiki_page def assign(selfelf, name: str, des ───────────ign(self, target_name: scription, | |
| 147 | +ncept, | |
| 148 | + { | |
| 149 | + WikiPage,��─────────────── | |
| 150 | + | |
| 151 | + def add_person( | |
| 152 | + self, | |
| 153 | + "", | |
| 154 | + role: str = "", | |
| 155 | + team: str = "", | |
| 156 | + ) -> None: | |
| 157 | + self.store.create_node( | |
| 158 | + NodeLabel.Person, | |
| 159 | + { | |
| 160 | + "name": name, | |
| 161 | + "email": email, | |
| 162 | + "role": role, | |
| 163 | + "team": team, | |
| 164 | + }, | |
| 165 | + ) | |
| 166 | + logger.info("Person: %s", name) | |
| 167 | + | |
| 168 | + def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None: | |
| 169 | + """Assign a person as owner of any node.""" | |
| 170 | + self.store.create_edge( | |
| 171 | + target_label, | |
| 172 | + {"name": target_name}, | |
| 173 | + EdgeType.ASSIGNED_TO, | |
| 174 | + NodeLabel.Person, | |
| 175 | + {"name": per"name": person_name}, | |
| 176 | + ) | |
| 177 | + | |
| 178 | + # ── Wiki pages ─────── {"name": code_name}, | |
| 179 | + �────────────────────────────� | |
| 180 | + self, | |
| 181 | + name: str, | |
| 182 | + url: str = "", | |
| 183 | + source: st {"name": code_name}, | |
| 184 | + pdated_at: str = "", | |
| 185 | + ) -> None: | |
| 186 | + self.store.create_node( | |
| 187 | + NodeLabel.WikiPage, | |
| 188 | + { | |
| 189 | + "name": name, | |
| 190 | + "url": url, | |
| 191 | + "source": source, | |
| 192 | + {"name": code_name}, | |
| 193 | + EdgeType.IMPLEMENTS, | |
| 194 | + NodeLabel.Concept,eLabel.Concept, | |
| 195 | + {"name": concept_name}, | |
| 196 | + ) | |
| 197 | + | |
| 198 | + # ── Helpers ─────────────────────────────────────────────────────────────── | |
| 199 | + | |
| 200 | + def _link_to_domain(self, name: str, label: NodeLabel, domain: str) -> None: | |
| 201 | + # Ensure domain node exists | |
| 202 | + self.store.create_node(NodeLabel.Domain, {"name": domain, "description": " label, | |
| 203 | + {"name": name}, | |
| 204 | + EdgeType.BELONGS_TO, | |
| 205 | + {"name": domain}, | |
| 206 | + ) |
| --- a/navegador/ingestion/knowledge.py | |
| +++ b/navegador/ingestion/knowledge.py | |
| @@ -0,0 +1,206 @@ | |
| --- a/navegador/ingestion/knowledge.py | |
| +++ b/navegador/ingestion/knowledge.py | |
| @@ -0,0 +1,206 @@ | |
| 1 | """ |
| 2 | KnowledgeIngester — manual curation of business concepts, rules, decisions, |
| 3 | people, and domains into the navegador graph. |
| 4 | |
| 5 | These are the things that don't live in code but belong in the knowledge graph: |
| 6 | business rules, architectural decisions, domain groupings, ownership, wiki refs. |
| 7 | """ |
| 8 | |
| 9 | import logging |
| 10 | from typing import Any |
| 11 | |
| 12 | from navegador.graph.schema import EdgeType, NodeLabel |
| 13 | from navegador.graph.store import GraphStore |
| 14 | |
| 15 | logger = logging.getLogger(__name__) |
| 16 | |
| 17 | |
| 18 | class KnowledgeIngester: |
| 19 | """ |
| 20 | Writes business knowledge nodes and relationships into the graph. |
| 21 | |
| 22 | Usage: |
| 23 | store = GraphStore.sqlite(".navegador/graph.db") |
| 24 | k = KnowledgeIngester(store) |
| 25 | |
| 26 | k.add_domain("auth", description="Authentication and authorisation") |
| 27 | k.add_concept("JWT", description="Stateless token auth", domain="auth") |
| 28 | k.add_rule("tokens must expire", domain="auth", severity="critical", |
| 29 | rationale="Security requirement per OWASP") |
| 30 | k.annotate_code("validate_token", "Function", concept="JWT") |
| 31 | k.wiki_page("JWT Auth", url="...", content="...") |
| 32 | """ |
| 33 | |
| 34 | def __init__(self, store: GraphStore) -> None: |
| 35 | self.store = store |
| 36 | |
| 37 | # ── Domains ─────────────────────────────────────────────────────────────── |
| 38 | |
| 39 | def add_domain(self, name: str, description: str = "") -> None: |
| 40 | self.store.create_node(e.create_node( |
| 41 | { |
| 42 | } ) |
| 43 | if domain:Domain: %s", name) |
| 44 | |
| 45 | # ── Concepts ────────────────────────────────────────────────────────────── |
| 46 | |
| 47 | def add_concept( |
| 48 | self, |
| 49 | name: str, |
| 50 | description: str = "", |
| 51 | domain: str = "", |
| 52 | status: str = "", |
| 53 | rules: str = "", |
| 54 | examples: str = "", |
| 55 | wiki_refs: str = "", |
| 56 | ) -> None: |
| 57 | NodeLabel.Concept, { |
| 58 | me": name, |
| 59 | anual curation of business con""" |
| 60 | KnowledgeIngester — manu"rules": rut( |
| 61 | self, |
| 62 | ──────── |
| 63 | |
| 64 | def description="Authentication and }e: str, |
| 65 | description: str = "", |
| 66 | domain: str = "", |
| 67 | Stateless token auth", domain="auth") |
| 68 | k.add_rule("tokens must expire", domain="auth", severity="critical", |
| 69 | rationale="Security requirement per OWASP") |
| 70 | k.annotate_code("validate_tokek.wiki_page("JWT Auth", url="...", content="...") |
| 71 | """ |
| 72 | |
| 73 | def __init__(sNodeLabel.Rule, { |
| 74 | me": name, |
| 75 | anual curation of business con""" |
| 76 | KnowledgeIngester — manual c──────── |
| 77 | |
| 78 | }e: str, |
| 79 | description: str = "", |
| 80 | domain: str = "", |
| 81 | status: str = "", |
| 82 | rules: str = "", |
| 83 | examples: str = "", |
| 84 | wiki_refs: str = "", |
| 85 | ) -> None: |
| 86 | self.store.create_node( |
| 87 | NodeLabel.Concept, |
| 88 | { |
| 89 | { |
| 90 | "name": name, |
| 91 | "description": description, |
| 92 | target_label, |
| 93 | {"na "", |
| 94 | teaDecision ────────────────────────────────────────────────────� |
| 95 | |
| 96 | def add_decision( |
| 97 | self, |
| 98 | name: str, |
| 99 | description: str = "", |
| 100 | domain: str = "", |
| 101 | status: str = "accepted", |
| 102 | rationale: str = "", |
| 103 | alternatives: str = "", |
| 104 | date: str = "", |
| 105 | ) -> None: |
| 106 | self.store.create_node(NodeLabel.Decision, { |
| 107 | me": name, |
| 108 | anual curation of business con""" |
| 109 | KnowledgeIngester — manu"alternatives":"date": date, |
| 110 | cisions,"e: str, |
| 111 | description: str = "", |
| 112 | domain: str = "", |
| 113 | NodeLabel.Person, { |
| 114 | "email": email, |
| 115 | l": email, |
| 116 | "team": team, |
| 117 | cisions," ) |
| 118 | if domain:Person: %s", name) |
| 119 | |
| 120 | def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None: |
| 121 | """Assign a person as owner of any node.""" |
| 122 | self.store.create_edge( |
| 123 | target_label, target_label, |
| 124 | {"name": target_name}, |
| 125 | EdgeType.ASSIGNED_TO, |
| 126 | {"name": person_name}, |
| 127 | ) |
| 128 | |
| 129 | # ── Wiki pages ──────────────────────────────────────────────────────────── |
| 130 | |
| 131 | def wiki_page( |
| 132 | self, |
| 133 | name: str, |
| 134 | url: str = "", |
| 135 | source: str = "github", |
| 136 | content: str = "", |
| 137 | updated_at: str = "", |
| 138 | ) -> None: |
| 139 | self.store.create_node(NodeLabel.WikiPage, { |
| 140 | ": status, |
| 141 | "alternatives": al "alternatives": alternative, |
| 142 | }, |
| 143 | ) |
| 144 | } ) |
| 145 | if domain: |
| 146 | self._link_to_domain(name, NodeLself, wiki_page def assign(selfelf, name: str, des ───────────ign(self, target_name: scription, |
| 147 | ncept, |
| 148 | { |
| 149 | WikiPage,��─────────────── |
| 150 | |
| 151 | def add_person( |
| 152 | self, |
| 153 | "", |
| 154 | role: str = "", |
| 155 | team: str = "", |
| 156 | ) -> None: |
| 157 | self.store.create_node( |
| 158 | NodeLabel.Person, |
| 159 | { |
| 160 | "name": name, |
| 161 | "email": email, |
| 162 | "role": role, |
| 163 | "team": team, |
| 164 | }, |
| 165 | ) |
| 166 | logger.info("Person: %s", name) |
| 167 | |
| 168 | def assign(self, target_name: str, target_label: NodeLabel, person_name: str) -> None: |
| 169 | """Assign a person as owner of any node.""" |
| 170 | self.store.create_edge( |
| 171 | target_label, |
| 172 | {"name": target_name}, |
| 173 | EdgeType.ASSIGNED_TO, |
| 174 | NodeLabel.Person, |
| 175 | {"name": per"name": person_name}, |
| 176 | ) |
| 177 | |
| 178 | # ── Wiki pages ─────── {"name": code_name}, |
| 179 | �────────────────────────────� |
| 180 | self, |
| 181 | name: str, |
| 182 | url: str = "", |
| 183 | source: st {"name": code_name}, |
| 184 | pdated_at: str = "", |
| 185 | ) -> None: |
| 186 | self.store.create_node( |
| 187 | NodeLabel.WikiPage, |
| 188 | { |
| 189 | "name": name, |
| 190 | "url": url, |
| 191 | "source": source, |
| 192 | {"name": code_name}, |
| 193 | EdgeType.IMPLEMENTS, |
| 194 | NodeLabel.Concept,eLabel.Concept, |
| 195 | {"name": concept_name}, |
| 196 | ) |
| 197 | |
| 198 | # ── Helpers ─────────────────────────────────────────────────────────────── |
| 199 | |
| 200 | def _link_to_domain(self, name: str, label: NodeLabel, domain: str) -> None: |
| 201 | # Ensure domain node exists |
| 202 | self.store.create_node(NodeLabel.Domain, {"name": domain, "description": " label, |
| 203 | {"name": name}, |
| 204 | EdgeType.BELONGS_TO, |
| 205 | {"name": domain}, |
| 206 | ) |
+147
| --- a/navegador/ingestion/wiki.py | ||
| +++ b/navegador/ingestion/wiki.py | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +""" | |
| 2 | +WikiIngester — pulls pages from a project wiki into the knowledge graph. | |
| 3 | + | |
| 4 | +Supports: | |
| 5 | + - GitHub wiki (cloned as a git repo at <repo>.wiki.git) | |
| 6 | + - Local markdown directory (any folder of .md files) | |
| 7 | + | |
| 8 | +Each page becomes a WikiPage node. Headings and bold terms are scanned for | |
| 9 | +names that match existing Concept/Function/Class nodes — matches get a | |
| 10 | +DOCUMENTS edge so agents can traverse wiki ↔ code. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +import logging | |
| 14 | +import re | |
| 15 | +import urllib.request | |
| 16 | +from pathlib import Path | |
| 17 | + | |
| 18 | +from navegador.graph.schema import EdgeType, NodeLabel | |
| 19 | +from navegador.graph.store import GraphStore | |
| 20 | + | |
| 21 | +logger = logging.getLogger(__name__) | |
| 22 | + | |
| 23 | +_HEADING_RE = re.compile(r"^#{1,6}\s+(.+)", re.MULTILINE) | |
| 24 | +_BOLD_RE = re.compile(r"\*\*(.+?)\*\*|__(.+?)__") | |
| 25 | + | |
| 26 | + | |
| 27 | +def _extract_terms(markdown: str) -> list[str]: | |
| 28 | + """Pull heading text and bold terms out of a markdown string.""" | |
| 29 | + terms = [m.group(1).strip() for m in _HEADING_RE.finditer(markdown)] | |
| 30 | + for m in _BOLD_RE.finditer(markdown): | |
| 31 | + term = (m.group(1) or m.group(2) or "").strip() | |
| 32 | + if term: | |
| 33 | + terms.append(term) | |
| 34 | + return list(dict.fromkeys(terms)) # dedupe, preserve order | |
| 35 | + | |
| 36 | + | |
| 37 | +class WikiIngester: | |
| 38 | + """ | |
| 39 | + Ingests wiki pages into the navegador graph. | |
| 40 | + | |
| 41 | + Usage: | |
| 42 | + ingester = WikiIngester(store) | |
| 43 | + | |
| 44 | + # From a local directory of markdown files | |
| 45 | + ingester.ingest_local("/path/to/wiki-clone") | |
| 46 | + | |
| 47 | + # From GitHub (clones the wiki repo) | |
| 48 | + ingester.ingest_github("owner/repo") | |
| 49 | + """ | |
| 50 | + | |
| 51 | + def __init__(self, store: GraphStore) -> None: | |
| 52 | + self.store = store | |
| 53 | + | |
| 54 | + # ── Entry points ────────────────────────────────────────────────────────── | |
| 55 | + | |
| 56 | + def ingest_local(self, wiki_dir: str | Path) -> dict[str, int]: | |
| 57 | + """Ingest all .md files in a local directory.""" | |
| 58 | + wiki_dir = Path(wiki_dir) | |
| 59 | + if not wiki_dir.exists raise FileNotFoundError(f"Wiki directory not found: {wiki_dir}") | |
| 60 | + | |
| 61 | + stats = {"pages": 0, "links": 0} | |
| 62 | + for md_file in sorted(wiki_dir.rglob("*.md")): | |
| 63 | + content = md_file.read_text(encoding="utf-8", errors="replace") | |
| 64 | + page_name = md_file.stem.replace("-", " ").replace("_", " ") | |
| 65 | + links = self._ingest_page( | |
| 66 | + name=page_name, | |
| 67 | + content=content, | |
| 68 | + source="local", | |
| 69 | + url=str(md_file), | |
| 70 | + ) | |
| 71 | + stats["pages"] += 1 | |
| 72 | + stats["links"] += links | |
| 73 | + | |
| 74 | + logger.info("Wiki (local): %d pages, %d links", stats["pages"], stats["links"]) | |
| 75 | + return stats | |
| 76 | + | |
| 77 | + def ingest_github( | |
| 78 | + self, | |
| 79 | + repo: str, | |
| 80 | + token: str = "", | |
| 81 | + clone_dir: str | Path | None = None, | |
| 82 | + ) -> dict[str, int]: | |
| 83 | + """ | |
| 84 | + Ingest a GitHub wiki by cloning it then processing locally. | |
| 85 | + | |
| 86 | + Args: | |
| 87 | + repo: "owner/repo" — the GitHub repository. | |
| 88 | + token: GitHub personal access token (needed for private repos). | |
| 89 | + clone_dir: Where to clone. Defaults to a temp directory. | |
| 90 | + """ | |
| 91 | + import subprocess | |
| 92 | + import tempfile | |
| 93 | + | |
| 94 | + wiki_url = f"https://github.com/{repo}.wiki.git" | |
| 95 | + if token: | |
| 96 | + wiki_url = f"https://{token}@github.com/{repo}.wiki.git" | |
| 97 | + | |
| 98 | + if clone_dir is None: | |
| 99 | + tmp = tempfile.mkdtemp(prefix="navegador-wiki-") | |
| 100 | + data = _json.loads(resp.read().decode()) | |
| 101 | + content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501 | |
| 102 | + page_name = Path(path).stem.replace("-", " ").replace("_", " ") | |
| 103 | + html_url = data.get("html_url", "") | |
| 104 | + links = self._ingest_page(page_name, content, source="github", url=html_url) | |
| 105 | + stats["pages"] += 1 | |
| 106 | + stats["links"] += links | |
| 107 | + except Exception as exc: | |
| 108 | + logger.warning("Skipping %s: %s", path, exc) | |
| 109 | + | |
| 110 | + logger.info("Wiki (GitHub API): %d pages, %d links", stats["pages"], stats["links"]) | |
| 111 | + return stats | |
| 112 | + | |
| 113 | + # ── Internal ────────────────────────────────────────────────────────────── | |
| 114 | + | |
| 115 | + def _ingest_page( | |
| 116 | + self, | |
| 117 | + name: str, | |
| 118 | + content: str, | |
| 119 | + source: str, | |
| 120 | + url: str, | |
| 121 | + ) -> int: | |
| 122 | + """Store one wiki page and return the number of DOCUMENTS links created.""" | |
| 123 | + NodeLabel.WikiPage, { "name": name, | |
| 124 | +url": url, | |
| 125 | + "so"" | |
| 126 | +WikiIngester — pulls pages fr──�G.md", "ARCHITECTURE.md", "docs/index.md"]: | |
| 127 | + url = f"https://api.github.com/repos/{owner}/{name}/contents/{path}" | |
| 128 | + try: | |
| 129 | + req = urllib.request.Request(url, headers=headers) | |
| 130 | + with urllib.request.urlopen(req, timeout=10) as resp: | |
| 131 | + import base64 | |
| 132 | + import json as _json | |
| 133 | + | |
| 134 | + data = _json.loads(resp.read().decode()) | |
| 135 | + content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501 | |
| 136 | + page_name = Path(path).stem.replace("-", " ").replace("_", " ") | |
| 137 | + html_url = data.get("html_url", "") | |
| 138 | + links = self._ingest_page(page_name, content, source="github", url=html_url) | |
| 139 | + stats["pages"] += 1 | |
| 140 | + stats["links"] += links | |
| 141 | + except Exception as exc: | |
| 142 | + logger.warning("Skipping %s: %s", path, exc) | |
| 143 | + | |
| 144 | + logger.info(ats["pages"], stats["links"]) | |
| 145 | + return stats | |
| 146 | + | |
| 147 | + # �debuInternal ───�──────────────────────────(ValueError, Exception): md_file.read_text(e0 |
| --- a/navegador/ingestion/wiki.py | |
| +++ b/navegador/ingestion/wiki.py | |
| @@ -0,0 +1,147 @@ | |
| --- a/navegador/ingestion/wiki.py | |
| +++ b/navegador/ingestion/wiki.py | |
| @@ -0,0 +1,147 @@ | |
| 1 | """ |
| 2 | WikiIngester — pulls pages from a project wiki into the knowledge graph. |
| 3 | |
| 4 | Supports: |
| 5 | - GitHub wiki (cloned as a git repo at <repo>.wiki.git) |
| 6 | - Local markdown directory (any folder of .md files) |
| 7 | |
| 8 | Each page becomes a WikiPage node. Headings and bold terms are scanned for |
| 9 | names that match existing Concept/Function/Class nodes — matches get a |
| 10 | DOCUMENTS edge so agents can traverse wiki ↔ code. |
| 11 | """ |
| 12 | |
| 13 | import logging |
| 14 | import re |
| 15 | import urllib.request |
| 16 | from pathlib import Path |
| 17 | |
| 18 | from navegador.graph.schema import EdgeType, NodeLabel |
| 19 | from navegador.graph.store import GraphStore |
| 20 | |
| 21 | logger = logging.getLogger(__name__) |
| 22 | |
| 23 | _HEADING_RE = re.compile(r"^#{1,6}\s+(.+)", re.MULTILINE) |
| 24 | _BOLD_RE = re.compile(r"\*\*(.+?)\*\*|__(.+?)__") |
| 25 | |
| 26 | |
| 27 | def _extract_terms(markdown: str) -> list[str]: |
| 28 | """Pull heading text and bold terms out of a markdown string.""" |
| 29 | terms = [m.group(1).strip() for m in _HEADING_RE.finditer(markdown)] |
| 30 | for m in _BOLD_RE.finditer(markdown): |
| 31 | term = (m.group(1) or m.group(2) or "").strip() |
| 32 | if term: |
| 33 | terms.append(term) |
| 34 | return list(dict.fromkeys(terms)) # dedupe, preserve order |
| 35 | |
| 36 | |
| 37 | class WikiIngester: |
| 38 | """ |
| 39 | Ingests wiki pages into the navegador graph. |
| 40 | |
| 41 | Usage: |
| 42 | ingester = WikiIngester(store) |
| 43 | |
| 44 | # From a local directory of markdown files |
| 45 | ingester.ingest_local("/path/to/wiki-clone") |
| 46 | |
| 47 | # From GitHub (clones the wiki repo) |
| 48 | ingester.ingest_github("owner/repo") |
| 49 | """ |
| 50 | |
| 51 | def __init__(self, store: GraphStore) -> None: |
| 52 | self.store = store |
| 53 | |
| 54 | # ── Entry points ────────────────────────────────────────────────────────── |
| 55 | |
| 56 | def ingest_local(self, wiki_dir: str | Path) -> dict[str, int]: |
| 57 | """Ingest all .md files in a local directory.""" |
| 58 | wiki_dir = Path(wiki_dir) |
| 59 | if not wiki_dir.exists raise FileNotFoundError(f"Wiki directory not found: {wiki_dir}") |
| 60 | |
| 61 | stats = {"pages": 0, "links": 0} |
| 62 | for md_file in sorted(wiki_dir.rglob("*.md")): |
| 63 | content = md_file.read_text(encoding="utf-8", errors="replace") |
| 64 | page_name = md_file.stem.replace("-", " ").replace("_", " ") |
| 65 | links = self._ingest_page( |
| 66 | name=page_name, |
| 67 | content=content, |
| 68 | source="local", |
| 69 | url=str(md_file), |
| 70 | ) |
| 71 | stats["pages"] += 1 |
| 72 | stats["links"] += links |
| 73 | |
| 74 | logger.info("Wiki (local): %d pages, %d links", stats["pages"], stats["links"]) |
| 75 | return stats |
| 76 | |
| 77 | def ingest_github( |
| 78 | self, |
| 79 | repo: str, |
| 80 | token: str = "", |
| 81 | clone_dir: str | Path | None = None, |
| 82 | ) -> dict[str, int]: |
| 83 | """ |
| 84 | Ingest a GitHub wiki by cloning it then processing locally. |
| 85 | |
| 86 | Args: |
| 87 | repo: "owner/repo" — the GitHub repository. |
| 88 | token: GitHub personal access token (needed for private repos). |
| 89 | clone_dir: Where to clone. Defaults to a temp directory. |
| 90 | """ |
| 91 | import subprocess |
| 92 | import tempfile |
| 93 | |
| 94 | wiki_url = f"https://github.com/{repo}.wiki.git" |
| 95 | if token: |
| 96 | wiki_url = f"https://{token}@github.com/{repo}.wiki.git" |
| 97 | |
| 98 | if clone_dir is None: |
| 99 | tmp = tempfile.mkdtemp(prefix="navegador-wiki-") |
| 100 | data = _json.loads(resp.read().decode()) |
| 101 | content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501 |
| 102 | page_name = Path(path).stem.replace("-", " ").replace("_", " ") |
| 103 | html_url = data.get("html_url", "") |
| 104 | links = self._ingest_page(page_name, content, source="github", url=html_url) |
| 105 | stats["pages"] += 1 |
| 106 | stats["links"] += links |
| 107 | except Exception as exc: |
| 108 | logger.warning("Skipping %s: %s", path, exc) |
| 109 | |
| 110 | logger.info("Wiki (GitHub API): %d pages, %d links", stats["pages"], stats["links"]) |
| 111 | return stats |
| 112 | |
| 113 | # ── Internal ────────────────────────────────────────────────────────────── |
| 114 | |
| 115 | def _ingest_page( |
| 116 | self, |
| 117 | name: str, |
| 118 | content: str, |
| 119 | source: str, |
| 120 | url: str, |
| 121 | ) -> int: |
| 122 | """Store one wiki page and return the number of DOCUMENTS links created.""" |
| 123 | NodeLabel.WikiPage, { "name": name, |
| 124 | url": url, |
| 125 | "so"" |
| 126 | WikiIngester — pulls pages fr──�G.md", "ARCHITECTURE.md", "docs/index.md"]: |
| 127 | url = f"https://api.github.com/repos/{owner}/{name}/contents/{path}" |
| 128 | try: |
| 129 | req = urllib.request.Request(url, headers=headers) |
| 130 | with urllib.request.urlopen(req, timeout=10) as resp: |
| 131 | import base64 |
| 132 | import json as _json |
| 133 | |
| 134 | data = _json.loads(resp.read().decode()) |
| 135 | content = base64.b64decode(data["content"]).decode("utf-8", errors="replace") # noqa: E501 |
| 136 | page_name = Path(path).stem.replace("-", " ").replace("_", " ") |
| 137 | html_url = data.get("html_url", "") |
| 138 | links = self._ingest_page(page_name, content, source="github", url=html_url) |
| 139 | stats["pages"] += 1 |
| 140 | stats["links"] += links |
| 141 | except Exception as exc: |
| 142 | logger.warning("Skipping %s: %s", path, exc) |
| 143 | |
| 144 | logger.info(ats["pages"], stats["links"]) |
| 145 | return stats |
| 146 | |
| 147 | # �debuInternal ───�──────────────────────────(ValueError, Exception): md_file.read_text(e0 |