| import ast |
| import json |
| import re |
| from pathlib import Path |
|
|
| from backend.core.constants import GRAPH_DIR |
| from backend.database.schemas import Chunk |
|
|
| IMPORT_RE = re.compile(r"^\s*import\s+(.+)|^\s*from\s+([.\w]+)\s+import\s+(.+)") |
|
|
|
|
| class LocalNetworkXGraphStore: |
| """Build dependency graphs with NetworkX and persist them as local JSON files.""" |
|
|
| def __init__(self) -> None: |
| try: |
| import networkx as nx |
| from networkx.readwrite import json_graph |
| except ImportError as exc: |
| raise ImportError("networkx is required for local graph storage.") from exc |
|
|
| self.nx = nx |
| self.json_graph = json_graph |
| self.graph_dir = GRAPH_DIR |
| self.graph_dir.mkdir(parents=True, exist_ok=True) |
| self._graphs: dict[str, self.nx.DiGraph] = {} |
|
|
| def build(self, repo_id: str, repo_path: Path, chunks: list[Chunk]) -> None: |
| graph = self.nx.DiGraph(repo_id=repo_id, repo_path=str(repo_path)) |
| files = sorted({chunk.path for chunk in chunks}) |
|
|
| for file_path in files: |
| graph.add_node( |
| self._file_node(file_path), |
| kind="file", |
| path=file_path, |
| label=file_path, |
| ) |
|
|
| for chunk in chunks: |
| file_node = self._file_node(chunk.path) |
| if chunk.kind != "imports": |
| chunk_node = self._chunk_node(chunk.id) |
| graph.add_node( |
| chunk_node, |
| kind=chunk.kind, |
| chunk_id=chunk.id, |
| path=chunk.path, |
| language=chunk.language, |
| symbol=chunk.symbol, |
| start_line=chunk.start_line, |
| end_line=chunk.end_line, |
| label=chunk.symbol, |
| ) |
| graph.add_edge(file_node, chunk_node, relation="contains") |
|
|
| for imported in self._extract_imports(chunk): |
| import_node = self._import_node(imported) |
| graph.add_node( |
| import_node, |
| kind="import", |
| name=imported, |
| label=imported, |
| ) |
| graph.add_edge(file_node, import_node, relation="imports") |
|
|
| self._graphs[repo_id] = graph |
| self._save(repo_id, graph) |
|
|
| def neighbors_for_terms(self, repo_id: str, terms: list[str]) -> set[str]: |
| if not terms: |
| return set() |
|
|
| graph = self._graphs.get(repo_id) or self._load(repo_id) |
| if not graph: |
| return set() |
|
|
| lowered_terms = [term.lower() for term in terms] |
| matches = { |
| node |
| for node, attrs in graph.nodes(data=True) |
| if self._node_matches(attrs, lowered_terms) |
| } |
|
|
| related: set[str] = set() |
| for node in matches: |
| self._add_search_values(related, graph.nodes[node]) |
| for neighbor in set(graph.predecessors(node)) | set(graph.successors(node)): |
| self._add_search_values(related, graph.nodes[neighbor]) |
|
|
| return related |
|
|
| def _save(self, repo_id: str, graph) -> None: |
| payload = self.json_graph.node_link_data(graph) |
| self._graph_path(repo_id).write_text(json.dumps(payload, indent=2), encoding="utf-8") |
|
|
| def _load(self, repo_id: str): |
| graph_path = self._graph_path(repo_id) |
| if not graph_path.exists(): |
| return None |
|
|
| payload = json.loads(graph_path.read_text(encoding="utf-8")) |
| graph = self.json_graph.node_link_graph(payload, directed=True) |
| self._graphs[repo_id] = graph |
| return graph |
|
|
| def _graph_path(self, repo_id: str) -> Path: |
| safe_repo_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", repo_id) |
| return self.graph_dir / f"{safe_repo_id}.json" |
|
|
| def _node_matches(self, attrs: dict, lowered_terms: list[str]) -> bool: |
| searchable_values = [ |
| attrs.get("path", ""), |
| attrs.get("symbol", ""), |
| attrs.get("kind", ""), |
| attrs.get("language", ""), |
| attrs.get("name", ""), |
| attrs.get("label", ""), |
| ] |
| haystack = " ".join(str(value).lower() for value in searchable_values if value) |
| return any(term in haystack for term in lowered_terms) |
|
|
| def _add_search_values(self, related: set[str], attrs: dict) -> None: |
| for key in ("path", "symbol", "name", "label"): |
| value = attrs.get(key) |
| if value: |
| related.add(str(value)) |
|
|
| def _extract_imports(self, chunk: Chunk) -> list[str]: |
| if chunk.language == "python": |
| try: |
| tree = ast.parse(chunk.content) |
| except SyntaxError: |
| return [] |
| imports: list[str] = [] |
| for node in ast.walk(tree): |
| if isinstance(node, ast.Import): |
| imports.extend(alias.name for alias in node.names) |
| elif isinstance(node, ast.ImportFrom) and node.module: |
| imports.append(node.module) |
| return imports |
|
|
| imports = [] |
| for line in chunk.content.splitlines(): |
| match = IMPORT_RE.match(line) |
| if match: |
| imports.append(next(group for group in match.groups() if group)) |
| return imports |
|
|
| def _file_node(self, path: str) -> str: |
| return f"file:{path}" |
|
|
| def _chunk_node(self, chunk_id: str) -> str: |
| return f"chunk:{chunk_id}" |
|
|
| def _import_node(self, name: str) -> str: |
| return f"import:{name}" |
|
|
|
|
| |
| |
| |
| dependency_graph_store = LocalNetworkXGraphStore() |
|
|