Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions graphify/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,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:
Expand All @@ -468,7 +468,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 = {
Expand Down
6 changes: 3 additions & 3 deletions graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand Down
24 changes: 15 additions & 9 deletions graphify/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,28 @@ def _partition(G: nx.Graph) -> dict[str, int]:
_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:
Expand All @@ -86,11 +92,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)

Expand All @@ -117,16 +123,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()}
18 changes: 9 additions & 9 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
4 changes: 2 additions & 2 deletions tests/test_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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++ ───────────────────────────────────────────────────────────────────────
Expand Down
12 changes: 6 additions & 6 deletions tests/test_multilang.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading