salim0986/graph-bug-ai
0
1from neo4j import GraphDatabase2from .logger import setup_logger3from typing import TYPE_CHECKING4if TYPE_CHECKING:5 from .parser import ParseReport6 7logger = setup_logger(__name__)8 9# HYBRID MAP: Covers Zed Node Types, Standard Tags, AND Ops Tags10HYBRID_MAP = {11 # --- ZED STYLE (Node Types) ---12 # These match the actual grammar structure, regardless of the query file13 "function_definition": "Function", # Python14 "function_declaration": "Function", # JS/TS/Go15 "method_definition": "Function", # JS/TS16 "method_declaration": "Function", # Go/Java17 "class_definition": "Class", # Python18 "class_declaration": "Class", # JS/TS/Java19 "interface_declaration": "Interface", # TS/Java20 "internal_module": "Module", # JS/TS (namespace/module declarations)21 "struct_item": "Struct", # Rust22 "function_item": "Function", # Rust23 "impl_item": "Class", # Rust24 25 # --- STANDARD TREE-SITTER TAGS ---26 # These match the capture names in .scm files27 "definition.function": "Function",28 "definition.method": "Function",29 "definition.macro": "Function",30 "definition.entrypoint": "Function",31 "definition.class": "Class",32 "definition.interface": "Interface",33 "definition.module": "Module",34 "definition.import": "Import",35 "definition.implementation": "Class",36 37 # --- OPS & INFRASTRUCTURE TAGS ---38 "definition.resource": "Resource", # Terraform/HCL39 "definition.variable": "Variable", # Bash/Terraform/Docker/Env40 "definition.section": "Section", # TOML/INI Tables41 "definition.key": "ConfigKey", # TOML/YAML Keys42 "definition.config": "Config", # General Configuration43 "definition.base_image": "BaseImage", # Dockerfile44 "definition.instruction": "Instruction", # Dockerfile45 "definition.stage": "Stage", # Dockerfile46 "definition.target": "Target", # Makefile47 "definition.script": "Script", # Vue48 "definition.style": "Style", # Vue49}50 51class GraphBuilder:52 def __init__(self, uri, auth, max_retries=3, retry_delay=2.0):53 """Initialize GraphBuilder with connection retry logic54 55 Args:56 uri: Neo4j connection URI57 auth: (username, password) tuple58 max_retries: Maximum connection retry attempts59 retry_delay: Initial delay between retries (exponential backoff)60 """61 self.uri = uri62 self.auth = auth63 self.max_retries = max_retries64 self.retry_delay = retry_delay65 self.driver = self._connect_with_retry()66 67 def _connect_with_retry(self):68 """Connect to Neo4j with exponential backoff retry"""69 import time70 71 for attempt in range(self.max_retries):72 try:73 logger.info(f"Connecting to Neo4j at {self.uri} (attempt {attempt + 1}/{self.max_retries})")74 driver = GraphDatabase.driver(self.uri, auth=self.auth)75 76 # Verify connectivity77 with driver.session() as session:78 session.run("RETURN 1")79 80 logger.info(f"✅ Neo4j connection established successfully")81 82 # Create indexes for performance83 self._ensure_indexes(driver)84 85 return driver86 87 except Exception as e:88 logger.warning(f"Neo4j connection attempt {attempt + 1} failed: {e}")89 90 if attempt < self.max_retries - 1:91 wait_time = self.retry_delay * (2 ** attempt) # Exponential backoff92 logger.info(f"Retrying in {wait_time}s...")93 time.sleep(wait_time)94 else:95 logger.error(f"❌ Failed to connect to Neo4j after {self.max_retries} attempts")96 logger.error(f" URI: {self.uri}")97 logger.error(f" Error: {e}")98 raise ConnectionError(f"Cannot connect to Neo4j: {e}")99 100 def _ensure_indexes(self, driver):101 """Create Neo4j indexes for performance on large repositories"""102 indexes = [103 "CREATE INDEX entity_repo_id IF NOT EXISTS FOR (e:Entity) ON (e.repo_id)",104 "CREATE INDEX entity_uid IF NOT EXISTS FOR (e:Entity) ON (e.uid)",105 "CREATE INDEX file_repo_id IF NOT EXISTS FOR (f:File) ON (f.repo_id)",106 "CREATE INDEX file_uid IF NOT EXISTS FOR (f:File) ON (f.uid)",107 # M4: indexes needed for fast name-based call/inherit resolution108 "CREATE INDEX entity_name_repo IF NOT EXISTS FOR (e:Entity) ON (e.repo_id, e.name)",109 "CREATE INDEX entity_name_file IF NOT EXISTS FOR (e:Entity) ON (e.file, e.name)",110 "CREATE INDEX external_uid IF NOT EXISTS FOR (e:External) ON (e.uid)",111 "CREATE INDEX module_uid IF NOT EXISTS FOR (m:Module) ON (m.uid)",112 ]113 114 try:115 with driver.session() as session:116 for index_query in indexes:117 session.run(index_query)118 logger.info("✓ Neo4j indexes ensured")119 except Exception as e:120 logger.warning(f"Index creation warning (may already exist): {e}")121 122 def process_file_nodes(self, repo_id, file_path, captures, code_bytes):123 nodes_to_create = []124 file_uid = f"{repo_id}::{file_path}"125 126 for node, capture_name in captures:127 clean_tag = capture_name.strip("@")128 node_type = node.type129 130 # 1. TRY NODE TYPE (Most reliable for Zed/Outline queries)131 node_label = HYBRID_MAP.get(node_type)132 133 # 2. TRY TAG NAME (Reliable for Standard/Manual queries)134 if not node_label:135 node_label = HYBRID_MAP.get(clean_tag)136 137 # 3. FALLBACK HEURISTICS138 if not node_label:139 if "function" in node_type or "method" in node_type:140 node_label = "Function"141 elif "class" in node_type or "struct" in node_type:142 node_label = "Class"143 else:144 # Skip unrelated captures (like comments or noise)145 continue146 147 # 4. ROBUST NAME EXTRACTION148 # Sometimes the capture is the whole function body (Zed), 149 # sometimes it is just the name (Standard).150 151 # First, try to find a child field explicitly named "name"152 name_node = node.child_by_field_name("name")153 154 if name_node:155 # We found a specific name node, use it156 name_text = code_bytes[name_node.start_byte : name_node.end_byte].decode("utf8", errors="ignore")157 else:158 # If no "name" field, assume the captured node IS the name 159 # OR fallback to finding the first identifier160 name_text = code_bytes[node.start_byte : node.end_byte].decode("utf8", errors="ignore")161 162 # Cleanup: If name captures multiple lines, it's likely the whole body. 163 # Fallback to "anon" or first line to prevent DB errors.164 if "\n" in name_text:165 # Try to find an identifier child166 found_id = False167 for child in node.children:168 if "identifier" in child.type or "string_lit" in child.type:169 name_text = code_bytes[child.start_byte : child.end_byte].decode("utf8", errors="ignore")170 found_id = True171 break172 if not found_id:173 # Final safety net174 name_text = f"anon_{node.start_point[0]}"175 176 # Clean up quotes if it's a string literal (common in JSON/YAML/TF)177 name_text = name_text.strip('"').strip("'")178 179 # Extract Raw Code (Always the full node)180 raw_code = code_bytes[node.start_byte : node.end_byte].decode("utf8", errors="ignore")181 182 entity_uid = f"{file_uid}::{name_text}"183 184 nodes_to_create.append({185 "label": node_label,186 "name": name_text,187 "uid": entity_uid,188 "raw_code": raw_code,189 "start_line": node.start_point[0],190 "end_line": node.end_point[0]191 })192 193 if nodes_to_create:194 self._batch_insert(repo_id, file_uid, file_path, nodes_to_create)195 196 def _batch_insert(self, repo_id, file_uid, file_path, node_list):197 query = """198 MERGE (f:File {uid: $file_uid})199 SET f.path = $file_path, f.repo_id = $repo_id200 WITH f201 UNWIND $batch AS item202 MERGE (e:Entity {uid: item.uid})203 SET e.name = item.name, e.repo_id = $repo_id, e.file = $file_path,204 e.type = item.label, e.start_line = item.start_line,205 e.end_line = item.end_line, e.raw_code = item.raw_code206 MERGE (f)-[:DEFINES]->(e)207 """208 try:209 with self.driver.session() as session:210 session.run(query, repo_id=repo_id, file_uid=file_uid, file_path=file_path, batch=node_list)211 logger.debug(f"Inserted {len(node_list)} nodes for {file_path}")212 except Exception as e:213 logger.error(f"Graph insert error for {file_path}: {e}")214 215 def build_dependencies(self, repo_id):216 logger.info(f"Linking dependencies for repo {repo_id}...")217 query = """218 MATCH (source:Entity {repo_id: $repo_id}), (target:Entity {repo_id: $repo_id})219 WHERE source.uid <> target.uid AND source.file <> target.file220 AND source.type = 'Function' 221 AND target.type IN ['Function', 'Class', 'Struct', 'Interface', 'Resource', 'Variable']222 AND size(target.name) > 3223 AND source.raw_code CONTAINS target.name224 MERGE (source)-[:MAY_CALL]->(target)225 """226 try:227 with self.driver.session() as session:228 result = session.run(query, repo_id=repo_id)229 summary = result.consume()230 logger.info(f"Created {summary.counters.relationships_created} dependency links")231 except Exception as e:232 logger.error(f"Graph dependency error: {e}")233 234 # ========================================================================235 # M4 — AST-driven relationship edges236 # ========================================================================237 238 def process_relationships(self, repo_id: str, file_path: str, report) -> dict:239 """240 Ingest typed relationship records from a ParseReport into Neo4j.241 Creates CALLS, IMPORTS, and INHERITS edges driven by M1 AST output.242 243 Args:244 repo_id: Repository identifier.245 file_path: Relative file path (used to locate Entity nodes by file).246 report: ParseReport from UniversalParser.extract_relationships().247 248 Returns:249 Stats dict: calls_linked, calls_external, imports_linked,250 inheritances_linked, inheritances_external.251 """252 stats = {253 "calls_linked": 0,254 "calls_external": 0,255 "imports_linked": 0,256 "inheritances_linked": 0,257 "inheritances_external": 0,258 }259 file_uid = f"{repo_id}::{file_path}"260 try:261 if report.calls:262 c = self._link_calls(repo_id, file_path, report.calls)263 stats["calls_linked"] += c["calls_linked"]264 stats["calls_external"] += c["calls_external"]265 if report.imports:266 stats["imports_linked"] += self._link_imports(repo_id, file_uid, report.imports)267 if report.inheritances:268 c = self._link_inheritances(repo_id, report.inheritances)269 stats["inheritances_linked"] += c["inheritances_linked"]270 stats["inheritances_external"] += c["inheritances_external"]271 except Exception as e:272 logger.error(f"[M4] process_relationships error for {file_path}: {e}")273 logger.debug(274 f"[M4] {file_path}: calls={stats['calls_linked']}+{stats['calls_external']}ext "275 f"imports={stats['imports_linked']} inherit={stats['inheritances_linked']}+{stats['inheritances_external']}ext"276 )277 return stats278 279 # Maximum rows per UNWIND statement to avoid Neo4j OOM on large files.280 _CYPHER_BATCH = 500281 282 def _run_batched(self, session, query: str, fixed_params: dict, batch: list) -> int:283 """Run a Cypher query in sub-batches of _CYPHER_BATCH and sum the 'n' counter."""284 total = 0285 for i in range(0, len(batch), self._CYPHER_BATCH):286 sub = batch[i : i + self._CYPHER_BATCH]287 rec = session.run(query, **fixed_params, batch=sub).single()288 total += rec["n"] if rec else 0289 return total290 291 def _link_calls(self, repo_id: str, file_path: str, calls) -> dict:292 """293 Batch-create CALLS edges with three prioritised passes:294 295 Pass 1 — same-file resolution: callee defined in the same file as caller.296 Most precise; avoids name-collision false positives entirely.297 Pass 2 — cross-file unambiguous resolution: callee exists in the repo,298 but ONLY if exactly one Entity carries that name (to prevent299 O(N) false edges for common identifiers like 'process', 'init').300 Pass 3 — external stub: callee not found anywhere in this repo →301 create an :External node so the edge is not silently dropped.302 303 Calls whose caller is "module_level" are excluded: no Entity node named304 "module_level" exists in Neo4j, so they would always produce 0 matches.305 """306 batch = [307 {"caller_name": c.caller, "callee_name": c.callee, "line": c.line}308 for c in calls309 # module_level is a synthetic sentinel, not a real Entity in the graph310 if c.caller and c.callee and c.caller != "module_level"311 ]312 if not batch:313 return {"calls_linked": 0, "calls_external": 0}314 315 # Pass 1: same-file callee (precise, no name-collision risk)316 q_same_file = """317 UNWIND $batch AS c318 MATCH (caller:Entity {repo_id: $repo_id, file: $file_path, name: c.caller_name})319 MATCH (callee:Entity {repo_id: $repo_id, file: $file_path, name: c.callee_name})320 WHERE caller.uid <> callee.uid321 MERGE (caller)-[r:CALLS]->(callee)322 ON CREATE SET r.line = c.line, r.file = $file_path323 RETURN count(r) AS n324 """325 326 # Pass 2: cross-file, but only when the name is unambiguous (exactly 1 match)327 q_cross_file = """328 UNWIND $batch AS c329 MATCH (caller:Entity {repo_id: $repo_id, file: $file_path, name: c.caller_name})330 WHERE NOT EXISTS {331 MATCH (:Entity {repo_id: $repo_id, file: $file_path, name: c.callee_name})332 }333 OPTIONAL MATCH (callee:Entity {repo_id: $repo_id, name: c.callee_name})334 WHERE caller.uid <> callee.uid335 WITH caller, c, collect(callee) AS callees336 WHERE size(callees) = 1337 WITH caller, c, callees[0] AS callee338 MERGE (caller)-[r:CALLS]->(callee)339 ON CREATE SET r.line = c.line, r.file = $file_path340 RETURN count(r) AS n341 """342 343 # Pass 3: external stub for callees not found anywhere in this repo344 q_external = """345 UNWIND $batch AS c346 MATCH (caller:Entity {repo_id: $repo_id, file: $file_path, name: c.caller_name})347 WHERE NOT EXISTS {348 MATCH (:Entity {repo_id: $repo_id, name: c.callee_name})349 }350 MERGE (ext:External {uid: $repo_id + '::ext::' + c.callee_name})351 ON CREATE SET ext.name = c.callee_name, ext.repo_id = $repo_id352 MERGE (caller)-[r:CALLS]->(ext)353 ON CREATE SET r.line = c.line, r.file = $file_path, r.external = true354 RETURN count(r) AS n355 """356 357 linked = 0358 external = 0359 params = {"repo_id": repo_id, "file_path": file_path}360 try:361 with self.driver.session() as session:362 linked += self._run_batched(session, q_same_file, params, batch)363 linked += self._run_batched(session, q_cross_file, params, batch)364 external = self._run_batched(session, q_external, params, batch)365 except Exception as e:366 logger.error(f"[M4] _link_calls error for {file_path}: {e}")367 return {"calls_linked": linked, "calls_external": external}368 369 def _link_imports(self, repo_id: str, file_uid: str, imports) -> int:370 """Create (File)-[:IMPORTS]->(Module) edges. Module nodes keyed by repo+name."""371 batch = [372 {"module": imp.module, "alias": imp.alias or "", "line": imp.line}373 for imp in imports374 if imp.module375 ]376 if not batch:377 return 0378 379 q = """380 MATCH (f:File {uid: $file_uid})381 UNWIND $batch AS imp382 MERGE (m:Module {uid: $repo_id + '::mod::' + imp.module})383 ON CREATE SET m.name = imp.module, m.repo_id = $repo_id384 MERGE (f)-[r:IMPORTS]->(m)385 ON CREATE SET r.line = imp.line, r.alias = imp.alias386 RETURN count(r) AS n387 """388 try:389 with self.driver.session() as session:390 return self._run_batched(session, q, {"file_uid": file_uid, "repo_id": repo_id}, batch)391 except Exception as e:392 logger.error(f"[M4] _link_imports error: {e}")393 return 0394 395 def _link_inheritances(self, repo_id: str, inheritances) -> dict:396 """397 Batch-create INHERITS edges.398 Pass 1: parent resolves to existing Entity in repo.399 Pass 2: parent unknown — stub as :External node.400 """401 batch = [402 {"child_name": inh.child, "parent_name": inh.parent, "line": inh.line}403 for inh in inheritances404 if inh.child and inh.parent405 ]406 if not batch:407 return {"inheritances_linked": 0, "inheritances_external": 0}408 409 q_resolved = """410 UNWIND $batch AS inh411 MATCH (child:Entity {repo_id: $repo_id, name: inh.child_name})412 MATCH (parent:Entity {repo_id: $repo_id, name: inh.parent_name})413 WHERE child.uid <> parent.uid414 MERGE (child)-[r:INHERITS]->(parent)415 ON CREATE SET r.line = inh.line416 RETURN count(r) AS n417 """418 419 q_external = """420 UNWIND $batch AS inh421 MATCH (child:Entity {repo_id: $repo_id, name: inh.child_name})422 WHERE NOT EXISTS {423 MATCH (:Entity {repo_id: $repo_id, name: inh.parent_name})424 }425 MERGE (ext:External {uid: $repo_id + '::ext::' + inh.parent_name})426 ON CREATE SET ext.name = inh.parent_name, ext.repo_id = $repo_id427 MERGE (child)-[r:INHERITS]->(ext)428 ON CREATE SET r.line = inh.line, r.external = true429 RETURN count(r) AS n430 """431 432 linked = 0433 external = 0434 params = {"repo_id": repo_id}435 try:436 with self.driver.session() as session:437 linked = self._run_batched(session, q_resolved, params, batch)438 external = self._run_batched(session, q_external, params, batch)439 except Exception as e:440 logger.error(f"[M4] _link_inheritances error: {e}")441 return {"inheritances_linked": linked, "inheritances_external": external}442 443 def get_dependencies(self, repo_id, file_path, start_line):444 """445 Robust Lookup: Uses File + Line Number to find the exact node 446 in the graph, then fetches what it calls.447 """448 # We use a small range (start_line +/- 1) to handle minor parser discrepancies449 query = """450 MATCH (source:Entity {repo_id: $repo_id, file: $file_path})451 WHERE abs(source.start_line - $start_line) <= 1452 MATCH (source)-[:CALLS|MAY_CALL]->(target)453 RETURN DISTINCT target.name AS name, target.file AS file, target.raw_code AS code, target.start_line AS start_line454 LIMIT 5455 """456 try:457 with self.driver.session() as session:458 # Pass start_line as integer459 result = session.run(query, repo_id=repo_id, file_path=file_path, start_line=int(start_line))460 dependencies = [461 {462 "name": record["name"],463 "file": record["file"],464 "code": record["code"],465 "line": record["start_line"]466 }467 for record in result468 ]469 return dependencies470 except Exception as e:471 logger.error(f"Graph retrieval error: {e}")472 return []473 474 def delete_repo(self, repo_id: str):475 """Deletes all nodes and relationships for a specific repo_id."""476 query = "MATCH (n {repo_id: $repo_id}) DETACH DELETE n"477 try:478 with self.driver.session() as session:479 result = session.run(query, repo_id=repo_id)480 summary = result.consume()481 deleted_count = summary.counters.nodes_deleted482 logger.info(f"🗑️ Deleted {deleted_count} graph nodes for repo {repo_id}")483 return deleted_count484 except Exception as e:485 logger.error(f"⚠️ Graph delete failed: {e}")486 return 0487 488 def get_repo_node_count(self, repo_id: str) -> int:489 """Get count of nodes for a given repo_id."""490 try:491 with self.driver.session() as session:492 result = session.run("MATCH (n {repo_id: $repo_id}) RETURN count(n) as count", repo_id=repo_id)493 record = result.single()494 return record["count"] if record else 0495 except Exception as e:496 logger.error(f"Error counting nodes: {e}")497 return 0498 499 def delete_file(self, file_uid: str):500 """501 Delete all entities for a specific file (for incremental updates)502 503 Args:504 file_uid: File unique identifier (format: repo_id::file_path)505 """506 query = """507 MATCH (f:File {uid: $file_uid})-[:DEFINES]->(e:Entity)508 DETACH DELETE e, f509 """510 try:511 with self.driver.session() as session:512 session.run(query, file_uid=file_uid)513 logger.debug(f"Deleted graph nodes for file {file_uid}")514 except Exception as e:515 logger.error(f"Graph file delete failed: {e}")516 517 # ========================================================================518 # ADVANCED GRAPH QUERIES FOR CODE REVIEW (Phase 3.2)519 # ========================================================================520 521 # ========================================================================522 # M5 — Multi-hop graph queries & blast-radius analysis523 # ========================================================================524 525 def find_transitive_callers(526 self,527 repo_id: str,528 entity_name: str,529 max_depth: int = 4,530 ) -> list:531 """532 Multi-hop backward reachability: every Entity that transitively calls533 `entity_name`, up to `max_depth` CALLS hops away.534 535 Results are ordered by hop-depth (direct callers first) then file.536 Capped at 50 results to keep query time bounded on large graphs.537 max_depth is interpolated into the query string (it is always an int538 from internal code — no injection risk).539 """540 if not entity_name or max_depth < 1:541 return []542 query = f"""543 MATCH path = (caller:Entity {{repo_id: $repo_id}})544 -[:CALLS|MAY_CALL*1..{max_depth}]->545 (target:Entity {{repo_id: $repo_id, name: $entity_name}})546 RETURN DISTINCT547 caller.name AS name,548 caller.file AS file,549 caller.type AS type,550 caller.start_line AS line,551 min(length(path)) AS depth552 ORDER BY depth, caller.file553 LIMIT 50554 """555 try:556 with self.driver.session() as session:557 result = session.run(query, repo_id=repo_id, entity_name=entity_name)558 return [559 {560 "name": r["name"],561 "file": r["file"],562 "type": r["type"],563 "line": r["line"],564 "depth": r["depth"],565 }566 for r in result567 ]568 except Exception as e:569 logger.error(f"[M5] find_transitive_callers error for {entity_name}: {e}")570 return []571 572 def find_blast_radius(self, repo_id: str, entity_name: str) -> dict:573 """574 Compute the blast radius for a changed entity.575 576 Returns:577 affected_functions — transitive callers (list of dicts)578 affected_files — unique files containing a caller (sorted)579 untested_callers — callers whose file path contains no "test" marker580 total_affected_functions — count581 total_affected_files — count582 """583 callers = self.find_transitive_callers(repo_id, entity_name, max_depth=4)584 affected_files = sorted({c["file"] for c in callers if c.get("file")})585 # Heuristic: a caller file is "untested" when its path does not contain586 # "test_" or "/test/" — i.e. there is no obvious co-located test file.587 untested = [588 c for c in callers589 if c.get("file") and "test" not in c["file"].lower()590 ]591 return {592 "affected_functions": callers,593 "affected_files": affected_files,594 "untested_callers": untested,595 "total_affected_functions": len(callers),596 "total_affected_files": len(affected_files),597 }598 599 def find_cycles(600 self,601 repo_id: str,602 file_path: str,603 max_depth: int = 6,604 ) -> list:605 """606 Detect CALLS-edge cycles that involve at least one function in607 `file_path`. Returns up to 10 unique cycles; each cycle is a list of608 entity names in canonical form (rotated so the lexicographically609 smallest name appears first, making [A,B,C] and [B,C,A] identical).610 """611 if not file_path:612 return []613 query = f"""614 MATCH (start:Entity {{repo_id: $repo_id, file: $file_path}})615 MATCH path = (start)-[:CALLS*2..{max_depth}]->(start)616 RETURN [n IN nodes(path) | n.name] AS cycle617 LIMIT 20618 """619 try:620 with self.driver.session() as session:621 result = session.run(query, repo_id=repo_id, file_path=file_path)622 seen: set = set()623 cycles: list = []624 for r in result:625 raw = r["cycle"]626 if not raw:627 continue628 min_idx = raw.index(min(raw))629 canonical = tuple(raw[min_idx:] + raw[:min_idx])630 if canonical not in seen:631 seen.add(canonical)632 cycles.append(list(canonical))633 if len(cycles) >= 10:634 break635 return cycles636 except Exception as e:637 logger.error(f"[M5] find_cycles error for {file_path}: {e}")638 return []639 640 def find_related_by_file(self, repo_id: str, file_path: str, limit: int = 10):641 """642 Find all entities defined in a specific file643 Returns functions, classes, and other entities644 """645 logger.debug(f"[GraphDB] find_related_by_file: repo_id={repo_id}, file={file_path}, limit={limit}")646 647 query = """648 MATCH (e:Entity {repo_id: $repo_id, file: $file_path})649 RETURN e.name AS name, e.type AS type, e.start_line AS line, 650 e.end_line AS end_line, e.raw_code AS code651 ORDER BY e.start_line652 LIMIT $limit653 """654 try:655 with self.driver.session() as session:656 result = session.run(query, repo_id=repo_id, file_path=file_path, limit=limit)657 entities = [658 {659 "name": record["name"],660 "type": record["type"],661 "line": record["line"],662 "end_line": record["end_line"],663 "code": record["code"]664 }665 for record in result666 ]667 logger.debug(f"[GraphDB] find_related_by_file returned {len(entities)} entities")668 if len(entities) == 0:669 logger.warning(f"[GraphDB] No entities found for repo_id={repo_id}, file={file_path}")670 return entities671 except Exception as e:672 logger.error(f"Error finding entities by file: {e}")673 return []674 675 def find_callers(self, repo_id: str, function_name: str, limit: int = 10):676 """677 Find all functions that call a specific function (reverse dependency)678 Useful for impact analysis679 """680 query = """681 MATCH (caller:Entity {repo_id: $repo_id})-[:CALLS|MAY_CALL]->(target:Entity {repo_id: $repo_id, name: $function_name})682 RETURN DISTINCT caller.name AS name, caller.file AS file, caller.type AS type,683 caller.start_line AS line684 LIMIT $limit685 """686 try:687 with self.driver.session() as session:688 result = session.run(query, repo_id=repo_id, function_name=function_name, limit=limit)689 return [690 {691 "name": record["name"],692 "file": record["file"],693 "type": record["type"],694 "line": record["line"],695 "relationship": "calls"696 }697 for record in result698 ]699 except Exception as e:700 logger.error(f"Error finding callers: {e}")701 return []702 703 def find_call_chain(self, repo_id: str, function_name: str, max_depth: int = 3):704 """705 Find the call chain for a function (what it calls, recursively)706 Useful for understanding execution flow707 """708 query = """709 MATCH path = (source:Entity {repo_id: $repo_id, name: $function_name})-[:CALLS|MAY_CALL*1..$max_depth]->(target)710 WITH path, length(path) AS depth711 ORDER BY depth712 RETURN [node IN nodes(path) | {713 name: node.name,714 type: node.type,715 file: node.file,716 line: node.start_line717 }] AS chain718 LIMIT 10719 """720 try:721 with self.driver.session() as session:722 result = session.run(query, repo_id=repo_id, function_name=function_name, max_depth=max_depth)723 return [record["chain"] for record in result]724 except Exception as e:725 logger.error(f"Error finding call chain: {e}")726 return []727 728 def find_file_dependencies(self, repo_id: str, file_path: str):729 """730 Find all files that this file depends on (via function calls)731 Useful for understanding file-level coupling732 """733 logger.debug(f"[GraphDB] find_file_dependencies: repo_id={repo_id}, file={file_path}")734 735 query = """736 MATCH (source:Entity {repo_id: $repo_id, file: $file_path})-[:CALLS|MAY_CALL]->(target:Entity {repo_id: $repo_id})737 WHERE target.file <> $file_path738 RETURN DISTINCT target.file AS file, COUNT(*) AS call_count739 ORDER BY call_count DESC740 """741 try:742 with self.driver.session() as session:743 result = session.run(query, repo_id=repo_id, file_path=file_path)744 dependencies = [745 {746 "file": record["file"],747 "call_count": record["call_count"],748 "relationship": "depends_on"749 }750 for record in result751 ]752 logger.debug(f"[GraphDB] find_file_dependencies returned {len(dependencies)} dependencies")753 return dependencies754 except Exception as e:755 logger.error(f"Error finding file dependencies: {e}")756 return []757 758 def find_similar_functions(self, repo_id: str, function_name: str, limit: int = 5):759 """760 Find functions with similar names (potential duplicates or related functionality)761 Uses fuzzy name matching762 """763 query = """764 MATCH (e:Entity {repo_id: $repo_id, type: 'Function'})765 WHERE e.name CONTAINS $search_term OR $search_term CONTAINS e.name766 AND e.name <> $function_name767 RETURN e.name AS name, e.file AS file, e.start_line AS line, e.raw_code AS code768 LIMIT $limit769 """770 # Extract base name (remove prefixes/suffixes for better matching)771 search_term = function_name.replace("get", "").replace("set", "").replace("_", "")772 773 try:774 with self.driver.session() as session:775 result = session.run(776 query, 777 repo_id=repo_id, 778 function_name=function_name,779 search_term=search_term,780 limit=limit781 )782 return [783 {784 "name": record["name"],785 "file": record["file"],786 "line": record["line"],787 "code": record["code"]788 }789 for record in result790 ]791 except Exception as e:792 logger.error(f"Error finding similar functions: {e}")793 return []794 795 def get_complexity_hotspots(self, repo_id: str, min_calls: int = 5, limit: int = 10):796 """797 Find functions with high number of outgoing calls (complexity hotspots)798 These functions are doing too much and may need refactoring799 """800 query = """801 MATCH (source:Entity {repo_id: $repo_id})-[:MAY_CALL]->(target)802 WITH source, COUNT(target) AS call_count803 WHERE call_count >= $min_calls804 RETURN source.name AS name, source.file AS file, source.type AS type,805 source.start_line AS line, call_count806 ORDER BY call_count DESC807 LIMIT $limit808 """809 try:810 with self.driver.session() as session:811 result = session.run(query, repo_id=repo_id, min_calls=min_calls, limit=limit)812 return [813 {814 "name": record["name"],815 "file": record["file"],816 "type": record["type"],817 "line": record["line"],818 "call_count": record["call_count"],819 "issue": "High complexity - too many dependencies"820 }821 for record in result822 ]823 except Exception as e:824 logger.error(f"Error finding complexity hotspots: {e}")825 return []826 827 def get_highly_coupled_files(self, repo_id: str, min_connections: int = 5, limit: int = 10):828 """829 Find files that are highly coupled (many cross-file dependencies)830 Useful for identifying architectural issues831 """832 query = """833 MATCH (source:Entity {repo_id: $repo_id})-[:MAY_CALL]->(target:Entity {repo_id: $repo_id})834 WHERE source.file <> target.file835 WITH source.file AS source_file, target.file AS target_file, COUNT(*) AS connections836 WHERE connections >= $min_connections837 RETURN source_file, target_file, connections838 ORDER BY connections DESC839 LIMIT $limit840 """841 try:842 with self.driver.session() as session:843 result = session.run(query, repo_id=repo_id, min_connections=min_connections, limit=limit)844 return [845 {846 "source_file": record["source_file"],847 "target_file": record["target_file"],848 "connections": record["connections"],849 "issue": "High coupling between files"850 }851 for record in result852 ]853 except Exception as e:854 logger.error(f"Error finding coupled files: {e}")855 return []856 857 def find_unused_functions(self, repo_id: str, limit: int = 20):858 """859 Find functions that are never called by other code860 Potential dead code candidates861 """862 query = """863 MATCH (e:Entity {repo_id: $repo_id, type: 'Function'})864 WHERE NOT (()-[:MAY_CALL]->(e))865 AND NOT e.name IN ['main', 'index', '__init__', 'handler', 'default']866 RETURN e.name AS name, e.file AS file, e.start_line AS line867 ORDER BY e.file, e.start_line868 LIMIT $limit869 """870 try:871 with self.driver.session() as session:872 result = session.run(query, repo_id=repo_id, limit=limit)873 return [874 {875 "name": record["name"],876 "file": record["file"],877 "line": record["line"],878 "issue": "Potentially unused function (no callers found)"879 }880 for record in result881 ]882 except Exception as e:883 logger.error(f"Error finding unused functions: {e}")884 return []885 