"""Build a knowledge graph (Graph-RAG style) of code / document structure. Two builders: - ``build_from_directory`` — stdlib only. Parses Python files with ``ast`` (files, classes, functions, methods, imports), Markdown/text files by heading hierarchy, and JSON files by their own key/array structure. No external dependency. - ``build_from_codebase_memory`` — best-effort enrichment using the codebase-memory-mcp knowledge graph when available; falls back to the local builder on any problem. Both return a :class:`StructureGraph` of nodes + edges that the UI lays out and renders. A simple layered layout is provided. """ from __future__ import annotations import ast import json import os from collections import defaultdict, deque from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Tuple EXCLUDE_DIRS = {".venv", "__pycache__", ".git", "node_modules", ".pytest_cache", ".mypy_cache", "dist", "build", ".idea", ".vscode"} CODE_SUFFIXES = {".py"} DOC_SUFFIXES = {".md", ".markdown", ".txt", ".rst"} JSON_SUFFIXES = {".json"} DEFAULT_MAX_NODES = 400 MAX_IMPORTS_PER_FILE = 8 MAX_JSON_DEPTH = 20 # how many nested levels of keys/arrays to expand MAX_JSON_KEYS_PER_LEVEL = 200 # cap per object, so one huge JSON can't flood the graph @dataclass class GNode: id: str label: str kind: str # dir | file | class | function | method | module | section detail: str = "" path: str = "" # absolute file/folder this node maps to (for "open folder") @dataclass class GEdge: source: str target: str type: str = "" # contains | defines | method | imports | subsection @dataclass class StructureGraph: nodes: List[GNode] = field(default_factory=list) edges: List[GEdge] = field(default_factory=list) truncated: bool = False max_nodes: int = 0 # 0 = unlimited max_edges: int = 0 # 0 = unlimited def __post_init__(self): self._ids = {n.id for n in self.nodes} def add_node(self, node: GNode) -> bool: if node.id in self._ids: return False if self.max_nodes and len(self.nodes) >= self.max_nodes: self.truncated = True return False self.nodes.append(node) self._ids.add(node.id) return True def add_edge(self, source: str, target: str, type_: str = "") -> None: if source in self._ids and target in self._ids: if self.max_edges and len(self.edges) >= self.max_edges: self.truncated = True return self.edges.append(GEdge(source, target, type_)) def has(self, node_id: str) -> bool: return node_id in self._ids # -------------------------------------------------------------------------- # Local (stdlib) builder # -------------------------------------------------------------------------- def build_from_directory(root, mode: str = "all", max_nodes: int = 0, max_edges: int = 0) -> StructureGraph: """Build the structure graph for a folder. mode: - 'files' — the WHOLE folder/file tree: Python and docs parsed deeply, and every other file type shown as a plain file node (structure only). - 'all' — Code & Docs: Python + Markdown/text parsed (no other file types). - 'code' — Python files only. - 'doc' — Markdown/text docs only. ``max_nodes=0`` (default) means no cap — every node/edge is kept. """ root_path = Path(root).expanduser().resolve() graph = StructureGraph(max_nodes=max_nodes, max_edges=max_edges) root_id = f"dir:{root_path}" graph.add_node(GNode(root_id, root_path.name or str(root_path), "dir", str(root_path), str(root_path))) want_code = mode in ("files", "all", "code") want_doc = mode in ("files", "all", "doc") want_json = mode == "files" # deep-parse JSON only in the "All" (files) view want_other = mode == "files" # show every remaining file type as a file node for dirpath, dirnames, filenames in os.walk(root_path): dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".")] if graph.truncated: break dpath = Path(dirpath) dir_id = f"dir:{dpath}" if dpath != root_path: graph.add_node(GNode(dir_id, dpath.name, "dir", str(dpath), str(dpath))) parent_id = f"dir:{dpath.parent}" graph.add_edge(parent_id, dir_id, "contains") for fname in sorted(filenames): suffix = Path(fname).suffix.lower() fpath = dpath / fname if want_code and suffix in CODE_SUFFIXES: _add_python_file(graph, dir_id, fpath, root_path) elif want_json and suffix in JSON_SUFFIXES: _add_json_file(graph, dir_id, fpath, root_path) elif want_doc and suffix in DOC_SUFFIXES: _add_doc_file(graph, dir_id, fpath, root_path) elif want_other: _add_generic_file(graph, dir_id, fpath, root_path) if graph.truncated: break return graph def _add_generic_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None: """Add a plain file node (no inner parsing) so the full folder/file tree is shown — used for file types beyond Python/docs in 'files' mode.""" file_id = f"file:{fpath}" if graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), str(fpath))): graph.add_edge(dir_id, file_id, "contains") def _rel(path: Path, root: Path) -> str: try: return str(path.relative_to(root)) except ValueError: return str(path) def _add_python_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None: file_id = f"file:{fpath}" if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), str(fpath))): return graph.add_edge(dir_id, file_id, "contains") fp = str(fpath) try: tree = ast.parse(fpath.read_text(encoding="utf-8", errors="replace")) except (OSError, SyntaxError, ValueError): return for node in tree.body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): fn_id = f"{file_id}::{node.name}" graph.add_node(GNode(fn_id, node.name + "()", "function", _rel(fpath, root), fp)) graph.add_edge(file_id, fn_id, "defines") elif isinstance(node, ast.ClassDef): cls_id = f"{file_id}::{node.name}" graph.add_node(GNode(cls_id, node.name, "class", _rel(fpath, root), fp)) graph.add_edge(file_id, cls_id, "defines") for item in node.body: if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): m_id = f"{cls_id}.{item.name}" graph.add_node(GNode(m_id, item.name + "()", "method", f"{node.name}.{item.name}", fp)) graph.add_edge(cls_id, m_id, "method") imports = _module_imports(tree)[:MAX_IMPORTS_PER_FILE] for mod in imports: mod_id = f"mod:{mod}" graph.add_node(GNode(mod_id, mod, "module", "import")) graph.add_edge(file_id, mod_id, "imports") def _module_imports(tree: ast.AST) -> List[str]: mods: List[str] = [] for node in ast.walk(tree): if isinstance(node, ast.Import): mods += [a.name.split(".")[0] for a in node.names] elif isinstance(node, ast.ImportFrom) and node.module: mods.append(node.module.split(".")[0]) seen, out = set(), [] for m in mods: if m and m not in seen: seen.add(m) out.append(m) return out def _add_doc_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None: file_id = f"file:{fpath}" fp = str(fpath) if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), fp)): return graph.add_edge(dir_id, file_id, "contains") try: lines = fpath.read_text(encoding="utf-8", errors="replace").splitlines() except OSError: return # Track the most recent heading id at each level to build hierarchy. last_at_level: Dict[int, str] = {0: file_id} counter = 0 for line in lines: stripped = line.lstrip() if not stripped.startswith("#"): continue level = len(stripped) - len(stripped.lstrip("#")) title = stripped[level:].strip() if not title or level > 6: continue counter += 1 sec_id = f"{file_id}#sec{counter}" if not graph.add_node(GNode(sec_id, title[:48], "section", title, fp)): break parent = next((last_at_level[lv] for lv in range(level - 1, -1, -1) if lv in last_at_level), file_id) graph.add_edge(parent, sec_id, "subsection") last_at_level[level] = sec_id # invalidate deeper levels for lv in list(last_at_level): if lv > level: del last_at_level[lv] def _add_json_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None: """Parse a JSON file's OWN structure (object keys / array shape) into nodes — the same way Python files become classes/functions and Markdown becomes heading sections — instead of showing up as just a flat file node with nothing inside it.""" file_id = f"file:{fpath}" fp = str(fpath) if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), fp)): return graph.add_edge(dir_id, file_id, "contains") try: data = json.loads(fpath.read_text(encoding="utf-8", errors="replace")) except (OSError, ValueError, RecursionError): # RecursionError: a syntactically valid but very deeply nested document # (e.g. 1000+ levels of arrays) blows Python's json parser's recursion # budget — skip just this file rather than aborting the whole scan. return _add_json_value(graph, file_id, fp, data, depth=0) def _json_scalar_preview(value) -> str: if isinstance(value, dict): return f"{{…}} ({len(value)} keys)" if isinstance(value, list): return f"[…] ({len(value)} items)" return str(value)[:40] def _add_json_value(graph: StructureGraph, parent_id: str, fp: str, value, depth: int) -> None: if depth >= MAX_JSON_DEPTH: return if isinstance(value, dict): # Child ids are the parent's id plus this key's POSITION, never the key # text itself — a key that happens to contain the "#" join character # (or matches another key's text at a different depth) can otherwise # collide with a deeper node's id and silently delete it from the graph. for i, (key, val) in enumerate(list(value.items())[:MAX_JSON_KEYS_PER_LEVEL]): key_id = f"{parent_id}#{i}" # Only dicts and arrays-of-objects get expanded further; a scalar # array (e.g. ["a","b","c"]) has nothing more to show, so it's # labelled with its preview and left as a leaf like any scalar. needs_recursion = isinstance(val, dict) or ( isinstance(val, list) and val and isinstance(val[0], dict)) # Always show key: value (even for nested objects/arrays show a preview) label = f"{key}: {_json_scalar_preview(val)}" if not graph.add_node(GNode(key_id, label[:120], "json_key", str(key), fp)): continue graph.add_edge(parent_id, key_id, "contains") if needs_recursion: _add_json_value(graph, key_id, fp, val, depth + 1) elif isinstance(value, list) and value and isinstance(value[0], dict): # Arrays of objects: show the FIRST element's shape as a representative # sample rather than exploding every item (a 500-row JSON array would # otherwise flood the graph with near-identical nodes). sample_id = f"{parent_id}#0" if graph.add_node(GNode(sample_id, f"[0] of {len(value)} (sample)", "json_key", "array item sample", fp)): graph.add_edge(parent_id, sample_id, "contains") _add_json_value(graph, sample_id, fp, value[0], depth + 1) # -------------------------------------------------------------------------- # Codebase-memory builder (best effort, falls back to local) # -------------------------------------------------------------------------- def build_from_codebase_memory(mem, repo_path, mode: str = "all", max_nodes: int = 0, max_edges: int = 0) -> StructureGraph: """Try to build from the codebase-memory knowledge graph; fall back local.""" try: graph = StructureGraph(max_nodes=max_nodes, max_edges=max_edges) produced = 0 for label, kind in (("Class", "class"), ("Function", "function")): res = mem.call("search_graph", {"label": label, "limit": 150}) for item in _iter_results(res): name = item.get("name") or item.get("label") if not name: continue file = item.get("file") or item.get("path") or "" nid = f"{label}:{file}:{name}" if graph.add_node(GNode(nid, name, kind, file)): produced += 1 if file: fid = f"file:{file}" graph.add_node(GNode(fid, Path(file).name, "file", file)) graph.add_edge(fid, nid, "defines") if produced >= 3: return graph except Exception: pass return build_from_directory(repo_path, mode=mode) def _iter_results(res): if isinstance(res, dict): for key in ("results", "nodes", "items", "data"): val = res.get(key) if isinstance(val, list): return [x for x in val if isinstance(x, dict)] if isinstance(res, list): return [x for x in res if isinstance(x, dict)] return [] # -------------------------------------------------------------------------- # Layout (layered by distance from roots) # -------------------------------------------------------------------------- def layered_layout(graph: StructureGraph, col_w: int = 280, row_h: int = 64) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, int]]: indeg = {n.id: 0 for n in graph.nodes} adj = defaultdict(list) for e in graph.edges: if e.target in indeg: indeg[e.target] += 1 adj[e.source].append(e.target) roots = [n.id for n in graph.nodes if indeg.get(n.id, 0) == 0] if not roots and graph.nodes: roots = [graph.nodes[0].id] level: Dict[str, int] = {} dq = deque() for r in roots: level[r] = 0 dq.append(r) while dq: cur = dq.popleft() for nxt in adj[cur]: if nxt not in level: level[nxt] = level[cur] + 1 dq.append(nxt) for n in graph.nodes: level.setdefault(n.id, 0) by_level: Dict[int, List[str]] = defaultdict(list) for n in graph.nodes: by_level[level[n.id]].append(n.id) pos: Dict[str, Tuple[int, int]] = {} for lv in sorted(by_level): for i, nid in enumerate(by_level[lv]): pos[nid] = (lv * col_w, i * row_h) return pos, level def _networkx_layout(graph: StructureGraph, width: int, height: int): """Use networkx spring layout when available (better for heavy graphs).""" try: import networkx as nx except ImportError: return None try: g = nx.Graph() g.add_nodes_from(n.id for n in graph.nodes) g.add_edges_from((e.source, e.target) for e in graph.edges) if g.number_of_nodes() == 0: return None pos = nx.spring_layout(g, seed=42) # deterministic scale = min(width, height) * 0.45 out = {} for nid, (x, y) in pos.items(): out[nid] = (int(width / 2 + x * scale), int(height / 2 + y * scale)) return out except Exception: return None def force_layout(graph: StructureGraph, width: int = 1600, height: int = 1000) -> Dict[str, Tuple[int, int]]: """Layout positions. Prefers networkx spring layout when installed (faster/nicer for heavy graphs); otherwise a deterministic pure-Python Fruchterman-Reingold fallback (no extra dependency). """ import math nx_pos = _networkx_layout(graph, width, height) if nx_pos is not None: return nx_pos ids = [n.id for n in graph.nodes] n = len(ids) or 1 k = math.sqrt((width * height) / n) # deterministic initial placement on a circle pos = {} for i, nid in enumerate(ids): ang = 2 * math.pi * i / n pos[nid] = [width / 2 + (width / 3) * math.cos(ang), height / 2 + (height / 3) * math.sin(ang)] edges = [(e.source, e.target) for e in graph.edges if e.source in pos and e.target in pos] iterations = min(220, max(20, 7000 // n)) t = width / 10.0 for _ in range(iterations): disp = {nid: [0.0, 0.0] for nid in ids} for i in range(n): a = ids[i] ax, ay = pos[a] for j in range(i + 1, n): b = ids[j] dx = ax - pos[b][0] dy = ay - pos[b][1] dist = math.hypot(dx, dy) or 0.01 force = k * k / dist ux, uy = dx / dist, dy / dist disp[a][0] += ux * force disp[a][1] += uy * force disp[b][0] -= ux * force disp[b][1] -= uy * force for a, b in edges: dx = pos[a][0] - pos[b][0] dy = pos[a][1] - pos[b][1] dist = math.hypot(dx, dy) or 0.01 force = dist * dist / k ux, uy = dx / dist, dy / dist disp[a][0] -= ux * force disp[a][1] -= uy * force disp[b][0] += ux * force disp[b][1] += uy * force for nid in ids: dx, dy = disp[nid] d = math.hypot(dx, dy) or 0.01 pos[nid][0] += dx / d * min(d, t) pos[nid][1] += dy / d * min(d, t) t *= 0.95 return {nid: (int(p[0]), int(p[1])) for nid, p in pos.items()} NODE_KIND_COLORS = { "dir": "#64748b", "file": "#3b82f6", "class": "#f37021", "function": "#22a06b", "method": "#8b5cf6", "module": "#eab308", "section": "#ec4899", "json_key": "#06b6d4", } # The relationship (edge) types the builders emit, each with a distinct colour # so the graph doesn't just show anonymous lines — every edge now carries a # defined, colour-coded meaning (shown as a label on the edge + in the legend). # contains — a folder/file holds another folder/file (tree structure) # defines — a file/module defines a class or top-level function # method — a class owns a method # imports — a module imports another module # subsection — a doc/JSON section nests a subsection/key EDGE_KIND_COLORS = { "contains": "#7c8aa0", "defines": "#22a06b", "method": "#8b5cf6", "imports": "#eab308", "subsection": "#ec4899", }