From 4630d12e273162ddd88c4fcea530c2219b3c6ab4 Mon Sep 17 00:00:00 2001 From: Minidoracat Date: Thu, 9 Apr 2026 15:29:20 +0800 Subject: [PATCH 1/2] fix: AST call edges use correct confidence and directed graph Two bugs in AST extraction: 1. Graph built as nx.Graph() (undirected), losing call edge direction. Utility functions like cached()/getTable() got inflated degree (~2x) from phantom reverse edges. Changed to nx.DiGraph() by default. Louvain/Leiden converts to undirected internally as required. 2. Tree-sitter-resolved call edges marked INFERRED/0.8 instead of EXTRACTED/1.0. Fixed across all language extractors (generic, Go, Rust, Zig, Obj-C, Java, Elixir). Cross-file class-level `uses` edges remain INFERRED as intended. --- graphify/analyze.py | 6 ++++-- graphify/build.py | 6 +++--- graphify/cluster.py | 35 ++++++++++++++++++----------------- graphify/extract.py | 18 +++++++++--------- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/graphify/analyze.py b/graphify/analyze.py index 8ca7ddac2..729ccc6d3 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -444,7 +444,7 @@ def suggest_questions( return questions[:top_n] -def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict: +def graph_diff(G_old: nx.Graph | nx.DiGraph, G_new: nx.Graph | nx.DiGraph) -> dict: """Compare two graph snapshots and return what changed. Returns: @@ -471,7 +471,9 @@ def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict: for n in removed_node_ids ] - def edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple: + def edge_key(G: nx.Graph | nx.DiGraph, u: str, v: str, data: dict) -> tuple: + if G.is_directed(): + return (u, v, data.get("relation", "")) return (min(u, v), max(u, v), data.get("relation", "")) old_edge_keys = { diff --git a/graphify/build.py b/graphify/build.py index 1bcb51b17..bdd5f98e5 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -26,13 +26,13 @@ from .validate import validate_extraction -def build_from_json(extraction: dict) -> nx.Graph: +def build_from_json(extraction: dict) -> nx.DiGraph: errors = validate_extraction(extraction) # Dangling edges (stdlib/external imports) are expected - only warn about real schema errors. real_errors = [e for e in errors if "does not match any node id" not in e] if real_errors: print(f"[graphify] Extraction warning ({len(real_errors)} issues): {real_errors[0]}", file=sys.stderr) - G = nx.Graph() + G = nx.DiGraph() for node in extraction.get("nodes", []): G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"}) node_set = set(G.nodes()) @@ -52,7 +52,7 @@ def build_from_json(extraction: dict) -> nx.Graph: return G -def build(extractions: list[dict]) -> nx.Graph: +def build(extractions: list[dict]) -> nx.DiGraph: """Merge multiple extraction results into one graph. Extractions are merged in order. For nodes with the same ID, the last diff --git a/graphify/cluster.py b/graphify/cluster.py index 09d070405..dfa6be09c 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -52,14 +52,9 @@ def _partition(G: nx.Graph) -> dict[str, int]: return {node: cid for cid, nodes in enumerate(communities) for node in nodes} -def build_graph(nodes: list[dict], edges: list[dict]) -> nx.Graph: - """Build a NetworkX graph from graphify node/edge dicts. - - Preserves original edge direction as _src/_tgt attributes so that - display functions can show relationships in the correct direction, - even though the graph is undirected for structural analysis. - """ - G = nx.Graph() +def build_graph(nodes: list[dict], edges: list[dict]) -> nx.DiGraph: + """Build a directed NetworkX graph from graphify node/edge dicts.""" + G = nx.DiGraph() for n in nodes: G.add_node(n["id"], **{k: v for k, v in n.items() if k != "id"}) for e in edges: @@ -73,22 +68,28 @@ def build_graph(nodes: list[dict], edges: list[dict]) -> nx.Graph: _MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes -def cluster(G: nx.Graph) -> dict[int, list[str]]: +def cluster(G: nx.Graph | nx.DiGraph) -> dict[int, list[str]]: """Run Leiden community detection. Returns {community_id: [node_ids]}. Community IDs are stable across runs: 0 = largest community after splitting. Oversized communities (> 25% of graph nodes, min 10) are split by running a second Leiden pass on the subgraph. + + Directed graphs are converted to undirected internally since + Louvain/Leiden require undirected input. """ if G.number_of_nodes() == 0: return {} if G.number_of_edges() == 0: return {i: [n] for i, n in enumerate(sorted(G.nodes))} + # Louvain/Leiden require undirected graphs + G_u = G.to_undirected() if G.is_directed() else G + # Leiden warns and drops isolates - handle them separately - isolates = [n for n in G.nodes() if G.degree(n) == 0] - connected_nodes = [n for n in G.nodes() if G.degree(n) > 0] - connected = G.subgraph(connected_nodes) + isolates = [n for n in G_u.nodes() if G_u.degree(n) == 0] + connected_nodes = [n for n in G_u.nodes() if G_u.degree(n) > 0] + connected = G_u.subgraph(connected_nodes) raw: dict[int, list[str]] = {} if connected.number_of_nodes() > 0: @@ -103,11 +104,11 @@ def cluster(G: nx.Graph) -> dict[int, list[str]]: next_cid += 1 # Split oversized communities - max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * _MAX_COMMUNITY_FRACTION)) + max_size = max(_MIN_SPLIT_SIZE, int(G_u.number_of_nodes() * _MAX_COMMUNITY_FRACTION)) final_communities: list[list[str]] = [] for nodes in raw.values(): if len(nodes) > max_size: - final_communities.extend(_split_community(G, nodes)) + final_communities.extend(_split_community(G_u, nodes)) else: final_communities.append(nodes) @@ -134,16 +135,16 @@ def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]: return [sorted(nodes)] -def cohesion_score(G: nx.Graph, community_nodes: list[str]) -> float: +def cohesion_score(G: nx.Graph | nx.DiGraph, community_nodes: list[str]) -> float: """Ratio of actual intra-community edges to maximum possible.""" n = len(community_nodes) if n <= 1: return 1.0 subgraph = G.subgraph(community_nodes) actual = subgraph.number_of_edges() - possible = n * (n - 1) / 2 + possible = n * (n - 1) if G.is_directed() else n * (n - 1) / 2 return round(actual / possible, 2) if possible > 0 else 0.0 -def score_all(G: nx.Graph, communities: dict[int, list[str]]) -> dict[int, float]: +def score_all(G: nx.Graph | nx.DiGraph, communities: dict[int, list[str]]) -> dict[int, float]: return {cid: cohesion_score(G, nodes) for cid, nodes in communities.items()} diff --git a/graphify/extract.py b/graphify/extract.py index 0ea50efbc..183d32c90 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -956,10 +956,10 @@ def walk_calls(node, caller_nid: str) -> None: "source": caller_nid, "target": tgt_nid, "relation": "calls", - "confidence": "INFERRED", + "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{line}", - "weight": 0.8, + "weight": 1.0, }) for child in node.children: @@ -1533,10 +1533,10 @@ def walk_calls(node, caller_nid: str) -> None: "source": caller_nid, "target": tgt_nid, "relation": "calls", - "confidence": "INFERRED", + "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{line}", - "weight": 0.8, + "weight": 1.0, }) for child in node.children: walk_calls(child, caller_nid) @@ -1702,10 +1702,10 @@ def walk_calls(node, caller_nid: str) -> None: "source": caller_nid, "target": tgt_nid, "relation": "calls", - "confidence": "INFERRED", + "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{line}", - "weight": 0.8, + "weight": 1.0, }) for child in node.children: walk_calls(child, caller_nid) @@ -1866,7 +1866,7 @@ def walk_calls(node, caller_nid: str) -> None: seen_call_pairs.add(pair) add_edge(caller_nid, tgt_nid, "calls", node.start_point[0] + 1, - confidence="INFERRED", weight=0.8) + confidence="EXTRACTED", weight=1.0) for child in node.children: walk_calls(child, caller_nid) @@ -2359,7 +2359,7 @@ def walk_calls(n) -> None: if pair not in seen_calls and caller_nid != candidate: seen_calls.add(pair) add_edge(caller_nid, candidate, "calls", body_node.start_point[0] + 1, - confidence="INFERRED", weight=0.8) + confidence="EXTRACTED", weight=1.0) for child in n.children: walk_calls(child) walk_calls(body_node) @@ -2532,7 +2532,7 @@ def walk_calls(node, caller_nid: str) -> None: if pair not in seen_call_pairs: seen_call_pairs.add(pair) add_edge(caller_nid, tgt_nid, "calls", - node.start_point[0] + 1, confidence="INFERRED", weight=0.8) + node.start_point[0] + 1, confidence="EXTRACTED", weight=1.0) for child in node.children: walk_calls(child, caller_nid) From e946fd7fae48c86c7ffe3f20abf7e3875347ab6b Mon Sep 17 00:00:00 2001 From: Minidoracat Date: Thu, 9 Apr 2026 16:06:08 +0800 Subject: [PATCH 2/2] test: update call edge assertions from INFERRED to EXTRACTED Tests now expect AST-resolved call edges to have confidence=EXTRACTED and weight=1.0, matching the fix in extract.py. --- tests/test_extract.py | 6 +++--- tests/test_languages.py | 4 ++-- tests/test_multilang.py | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_extract.py b/tests/test_extract.py index a852db987..3d9d727e4 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -115,12 +115,12 @@ def test_calls_edges_emitted(): assert len(calls) > 0, "Expected at least one calls edge" -def test_calls_edges_are_inferred(): +def test_calls_edges_are_extracted(): result = extract_python(FIXTURES / "sample_calls.py") for edge in result["edges"]: if edge["relation"] == "calls": - assert edge["confidence"] == "INFERRED" - assert edge["weight"] == 0.8 + assert edge["confidence"] == "EXTRACTED" + assert edge["weight"] == 1.0 def test_calls_no_self_loops(): diff --git a/tests/test_languages.py b/tests/test_languages.py index 9784dfa95..0cf93c0d5 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -76,11 +76,11 @@ def test_c_emits_calls(): r = extract_c(FIXTURES / "sample.c") assert any(e["relation"] == "calls" for e in r["edges"]) -def test_c_calls_are_inferred(): +def test_c_calls_are_extracted(): r = extract_c(FIXTURES / "sample.c") for e in r["edges"]: if e["relation"] == "calls": - assert e["confidence"] == "INFERRED" + assert e["confidence"] == "EXTRACTED" # ── C++ ─────────────────────────────────────────────────────────────────────── diff --git a/tests/test_multilang.py b/tests/test_multilang.py index e56fa0af8..0a67f50b3 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -47,11 +47,11 @@ def test_ts_emits_calls(): # .post() calls .get() assert any("post" in src and "get" in tgt for src, tgt in calls) -def test_ts_calls_are_inferred(): +def test_ts_calls_are_extracted(): r = extract_js(FIXTURES / "sample.ts") for e in r["edges"]: if e["relation"] == "calls": - assert e["confidence"] == "INFERRED" + assert e["confidence"] == "EXTRACTED" def test_ts_no_dangling_edges(): r = extract_js(FIXTURES / "sample.ts") @@ -83,9 +83,9 @@ def test_go_emits_calls(): # main() calls NewServer and Start assert len(_call_pairs(r)) > 0 -def test_go_has_inferred_calls(): +def test_go_has_extracted_calls(): r = extract_go(FIXTURES / "sample.go") - assert "INFERRED" in _confidences(r) + assert "EXTRACTED" in _confidences(r) def test_go_no_dangling_edges(): r = extract_go(FIXTURES / "sample.go") @@ -117,11 +117,11 @@ def test_rust_emits_calls(): calls = _call_pairs(r) assert any("build_graph" in src for src, _ in calls) -def test_rust_calls_are_inferred(): +def test_rust_calls_are_extracted(): r = extract_rust(FIXTURES / "sample.rs") for e in r["edges"]: if e["relation"] == "calls": - assert e["confidence"] == "INFERRED" + assert e["confidence"] == "EXTRACTED" def test_rust_no_dangling_edges(): r = extract_rust(FIXTURES / "sample.rs")