CoolFace
Apppublic

salim0986/graph-bug-ai

sourceHugging Facemitupdated 4mo agoView on Hugging Face
0likes
graph_builder.py885 linesDownload Raw Back to src
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