Files
tirth8205--code-review-graph/code_review_graph/communities.py
T
2026-07-13 12:42:18 +08:00

875 lines
29 KiB
Python

"""Community/cluster detection for the code knowledge graph.
Detects communities of related code nodes using the Leiden algorithm (via igraph,
optional) with a file-based grouping fallback when igraph is not installed.
"""
from __future__ import annotations
import logging
import random
import re
from collections import Counter, defaultdict
from typing import Any
from .graph import GraphEdge, GraphNode, GraphStore, _sanitize_name
# Fixed seed for igraph's RNG so Leiden community detection is reproducible
# across runs. Without this, two builds of the same graph produce different
# community IDs / sizes, breaking benchmark comparability. Override with
# CRG_LEIDEN_SEED env var if you need a different seed.
_LEIDEN_SEED = 42
logger = logging.getLogger(__name__)
# Stay well under SQLite's default 999-variable limit per statement.
_SQL_BATCH = 450
# ---------------------------------------------------------------------------
# Optional igraph import
# ---------------------------------------------------------------------------
try:
import igraph as ig # type: ignore[import-untyped]
IGRAPH_AVAILABLE = True
except ImportError:
ig = None # type: ignore[assignment]
IGRAPH_AVAILABLE = False
# ---------------------------------------------------------------------------
# Edge weight mapping
# ---------------------------------------------------------------------------
EDGE_WEIGHTS: dict[str, float] = {
"CALLS": 1.0,
"IMPORTS_FROM": 0.5,
"INHERITS": 0.8,
"IMPLEMENTS": 0.7,
"CONTAINS": 0.3,
"TESTED_BY": 0.4,
"DEPENDS_ON": 0.6,
}
# Common words to filter when generating community names
_COMMON_WORDS = frozenset({
"get", "set", "self", "init", "new", "create", "update", "delete",
"add", "remove", "make", "build", "from", "to", "for", "with",
"the", "and", "test", "main", "run", "do", "is", "has", "on",
"of", "in", "at", "by", "my", "this", "that", "all", "none",
})
# ---------------------------------------------------------------------------
# Community naming
# ---------------------------------------------------------------------------
def _generate_community_name(members: list[GraphNode]) -> str:
"""Generate a meaningful name for a community of nodes.
Algorithm:
1. Find most common module/file prefix among members
2. If a dominant class exists (>40% of nodes), use its name
3. Fallback: most frequent keyword in function/class names
4. Format: "{prefix}-{keyword}"
"""
if not members:
return "empty"
# 1. Find common file prefix
file_paths = [m.file_path for m in members]
prefix = _extract_file_prefix(file_paths)
# 2. Check for dominant class
class_names = [m.name for m in members if m.kind == "Class"]
if class_names:
class_counts = Counter(class_names)
top_class, top_count = class_counts.most_common(1)[0]
if top_count > len(members) * 0.4:
if prefix:
return f"{prefix}-{_to_slug(top_class)}"
return _to_slug(top_class)
# 3. Most frequent keyword from function/class names
keywords = _extract_keywords(members)
keyword = keywords[0] if keywords else ""
if prefix and keyword:
return f"{prefix}-{keyword}"
if prefix:
return prefix
if keyword:
return keyword
return "cluster"
def _extract_file_prefix(file_paths: list[str]) -> str:
"""Find the most common short directory or module name from file paths."""
if not file_paths:
return ""
# Extract the parent directory or file stem
parts: list[str] = []
for fp in file_paths:
# Use the last directory component or file stem
segments = fp.replace("\\", "/").split("/")
# Take the parent dir if it exists, otherwise the file stem
if len(segments) >= 2:
parts.append(segments[-2])
else:
stem = segments[-1].rsplit(".", 1)[0]
parts.append(stem)
counts = Counter(parts)
top_part, _ = counts.most_common(1)[0]
return _to_slug(top_part)
def _extract_keywords(members: list[GraphNode]) -> list[str]:
"""Extract the most frequent meaningful keywords from member names."""
word_counts: Counter[str] = Counter()
for m in members:
if m.kind in ("Function", "Class", "Test", "Type"):
words = _split_name(m.name)
for w in words:
wl = w.lower()
if wl not in _COMMON_WORDS and len(wl) > 1:
word_counts[wl] += 1
if not word_counts:
return []
return [w for w, _ in word_counts.most_common(5)]
def _split_name(name: str) -> list[str]:
"""Split a camelCase or snake_case name into words."""
# Insert boundary before uppercase letters for camelCase
s = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
# Split on underscores, hyphens, dots
return [p for p in re.split(r"[_\-.\s]+", s) if p]
def _to_slug(s: str) -> str:
"""Convert a string to a short lowercase slug."""
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")[:30]
# ---------------------------------------------------------------------------
# Cohesion calculation
# ---------------------------------------------------------------------------
def _compute_cohesion_batch(
community_member_qns: list[set[str]],
all_edges: list[GraphEdge],
) -> list[float]:
"""Compute cohesion for multiple communities in a single O(edges) pass.
Builds a ``qualified_name -> community_index`` reverse map (each node
appears in at most one community since all callers produce partitions),
then walks every edge exactly once, bucketing it into internal/external
counters per community.
Total work: O(edges + sum(|members|)) instead of
O(edges * communities) for naive per-community cohesion.
Returns a list of cohesion scores aligned with ``community_member_qns``.
"""
qn_to_idx: dict[str, int] = {}
for idx, members in enumerate(community_member_qns):
for qn in members:
qn_to_idx[qn] = idx
n = len(community_member_qns)
internal = [0] * n
external = [0] * n
for e in all_edges:
sc = qn_to_idx.get(e.source_qualified)
tc = qn_to_idx.get(e.target_qualified)
if sc is None and tc is None:
continue
if sc == tc:
# Safe: sc is not None here (sc == tc and not both None).
assert sc is not None
internal[sc] += 1
else:
if sc is not None:
external[sc] += 1
if tc is not None:
external[tc] += 1
results: list[float] = []
for i in range(n):
total = internal[i] + external[i]
results.append(internal[i] / total if total > 0 else 0.0)
return results
def _build_adjacency(edges: list[GraphEdge]) -> dict[str, list[str]]:
"""Build adjacency list from edges (one pass over all edges)."""
adj: dict[str, list[str]] = defaultdict(list)
for e in edges:
adj[e.source_qualified].append(e.target_qualified)
adj[e.target_qualified].append(e.source_qualified)
return adj
def _compute_cohesion(
member_qns: set[str],
all_edges: list[GraphEdge],
adj: dict[str, list[str]] | None = None,
) -> float:
"""Compute cohesion: internal_edges / (internal_edges + external_edges).
For multiple communities, prefer :func:`_compute_cohesion_batch`, which
runs in O(edges) total instead of O(edges) per community.
"""
return _compute_cohesion_batch([member_qns], all_edges)[0]
# ---------------------------------------------------------------------------
# Leiden-based community detection (igraph)
# ---------------------------------------------------------------------------
def _detect_leiden(
nodes: list[GraphNode],
edges: list[GraphEdge],
min_size: int,
adj: dict[str, list[str]] | None = None,
) -> list[dict[str, Any]]:
"""Detect communities using Leiden algorithm via igraph.
Caps Leiden at ``n_iterations=2`` (sufficient for code dependency graphs)
and skips the recursive sub-community splitting pass that caused
exponential blow-up on large repos (>100k nodes).
"""
if ig is None:
return []
qn_to_idx: dict[str, int] = {}
idx_to_node: dict[int, GraphNode] = {}
for i, node in enumerate(nodes):
qn_to_idx[node.qualified_name] = i
idx_to_node[i] = node
if not qn_to_idx:
return []
logger.info("Building igraph with %d nodes...", len(qn_to_idx))
g = ig.Graph(n=len(qn_to_idx), directed=False)
edge_list: list[tuple[int, int]] = []
weights: list[float] = []
seen_edges: set[tuple[int, int]] = set()
for e in edges:
src_idx = qn_to_idx.get(e.source_qualified)
tgt_idx = qn_to_idx.get(e.target_qualified)
if src_idx is not None and tgt_idx is not None and src_idx != tgt_idx:
pair = (min(src_idx, tgt_idx), max(src_idx, tgt_idx))
if pair not in seen_edges:
seen_edges.add(pair)
edge_list.append(pair)
weights.append(EDGE_WEIGHTS.get(e.kind, 0.5))
if not edge_list:
return _detect_file_based(nodes, edges, min_size, adj=adj)
g.add_edges(edge_list)
g.es["weight"] = weights
# Run Leiden -- scale resolution inversely with graph size to get
# coarser clusters on large repos. Default resolution=1.0 produces
# thousands of tiny communities for 30k+ node graphs.
import math
n_nodes = g.vcount()
resolution = max(0.05, 1.0 / math.log10(max(n_nodes, 10)))
logger.info(
"Running Leiden on %d nodes, %d edges...",
g.vcount(), g.ecount(),
)
import os
seed = int(os.environ.get("CRG_LEIDEN_SEED", _LEIDEN_SEED))
# Deterministic seeding for benchmark reproducibility — community
# detection is not a security-sensitive context. nosec B311.
ig.set_random_number_generator(random.Random(seed)) # nosec B311
partition = g.community_leiden(
objective_function="modularity",
weights="weight",
resolution=resolution,
n_iterations=2,
)
logger.info(
"Leiden complete, found %d partitions. Computing cohesion...",
len(partition),
)
pending: list[tuple[list[GraphNode], set[str]]] = []
for cluster_ids in partition:
if len(cluster_ids) < min_size:
continue
members = [idx_to_node[i] for i in cluster_ids if i in idx_to_node]
if len(members) < min_size:
continue
member_qns = {m.qualified_name for m in members}
pending.append((members, member_qns))
cohesions = _compute_cohesion_batch([p[1] for p in pending], edges)
communities: list[dict[str, Any]] = []
for (members, member_qns), cohesion in zip(pending, cohesions):
lang_counts = Counter(m.language for m in members if m.language)
dominant_lang = lang_counts.most_common(1)[0][0] if lang_counts else ""
name = _generate_community_name(members)
communities.append({
"name": name,
"level": 0,
"size": len(members),
"cohesion": round(cohesion, 4),
"dominant_language": dominant_lang,
"description": f"Community of {len(members)} nodes",
"members": [m.qualified_name for m in members],
"member_qns": member_qns,
})
logger.info("Community detection complete: %d communities", len(communities))
return communities
# ---------------------------------------------------------------------------
# File-based fallback community detection
# ---------------------------------------------------------------------------
def _detect_file_based(
nodes: list[GraphNode],
edges: list[GraphEdge],
min_size: int,
adj: dict[str, list[str]] | None = None,
) -> list[dict[str, Any]]:
"""Group nodes by directory when Leiden is unavailable or over-fragments.
Strips the longest common directory prefix from all file paths, then
adaptively picks a grouping depth that yields 10-200 communities.
"""
# Collect all directory paths (normalized, without filename)
all_dir_parts: list[list[str]] = []
for n in nodes:
parts = n.file_path.replace("\\", "/").split("/")
all_dir_parts.append([p for p in parts[:-1] if p])
# Find the longest common prefix among directory parts
prefix_len = 0
if all_dir_parts:
shortest = min(len(p) for p in all_dir_parts)
for i in range(shortest):
seg = all_dir_parts[0][i]
if all(p[i] == seg for p in all_dir_parts):
prefix_len = i + 1
else:
break
def _group_at_depth(depth: int) -> dict[str, list[GraphNode]]:
groups: dict[str, list[GraphNode]] = defaultdict(list)
for n in nodes:
parts = n.file_path.replace("\\", "/").split("/")
dir_parts = [p for p in parts[:-1] if p]
remainder = dir_parts[prefix_len:]
if remainder:
key = "/".join(remainder[:depth])
else:
key = parts[-1].rsplit(".", 1)[0] if parts else "root"
groups[key].append(n)
return groups
# Try increasing depths until we get 10-200 qualifying groups
max_depth = max((len(p) - prefix_len for p in all_dir_parts), default=0)
best_groups = _group_at_depth(1) # depth=1 always works (file stem fallback)
for depth in range(1, max_depth + 1):
groups = _group_at_depth(depth)
qualifying = sum(1 for v in groups.values() if len(v) >= min_size)
best_groups = groups
if qualifying >= 10:
break
by_dir = best_groups
# Pre-filter to communities meeting min_size and collect their member
# sets so we can batch-compute all cohesions in a single O(edges) pass.
# Without this, per-community cohesion is O(edges * files), which makes
# community detection effectively hang on large repos.
pending: list[tuple[str, list[GraphNode], set[str]]] = []
for dir_path, members in by_dir.items():
if len(members) < min_size:
continue
member_qns = {m.qualified_name for m in members}
pending.append((dir_path, members, member_qns))
cohesions = _compute_cohesion_batch([p[2] for p in pending], edges)
communities: list[dict[str, Any]] = []
for (dir_path, members, member_qns), cohesion in zip(pending, cohesions):
lang_counts = Counter(m.language for m in members if m.language)
dominant_lang = lang_counts.most_common(1)[0][0] if lang_counts else ""
name = _generate_community_name(members)
communities.append({
"name": name,
"level": 0,
"size": len(members),
"cohesion": round(cohesion, 4),
"dominant_language": dominant_lang,
"description": f"Directory-based community: {dir_path}",
"members": [m.qualified_name for m in members],
"member_qns": member_qns,
})
return communities
# ---------------------------------------------------------------------------
# Oversized community splitting
# ---------------------------------------------------------------------------
def _split_oversized(
communities: list[dict],
nodes: list[GraphNode],
edges: list[GraphEdge],
threshold_pct: float = 0.25,
min_split_size: int = 10,
) -> list[dict]:
"""Recursively split communities that exceed threshold_pct of total.
Uses Leiden on the subgraph of oversized communities. If igraph is
not available, returns communities unchanged.
"""
if not IGRAPH_AVAILABLE:
return communities
total = sum(
c.get("size", len(c.get("members", [])))
for c in communities
)
if total == 0:
return communities
threshold = max(int(total * threshold_pct), min_split_size)
result: list[dict] = []
next_id = max(
(c.get("id", 0) for c in communities), default=0
) + 1
for comm in communities:
members = set(comm.get("members", []))
if len(members) <= threshold:
result.append(comm)
continue
# Build subgraph for this community
member_nodes = [
n for n in nodes
if n.qualified_name in members
]
member_edges = [
e for e in edges
if (
e.source_qualified in members
and e.target_qualified in members
)
]
if len(member_nodes) < min_split_size:
result.append(comm)
continue
# Run Leiden on subgraph
qn_to_idx = {
n.qualified_name: i
for i, n in enumerate(member_nodes)
}
ig_edges: list[tuple[int, int]] = []
ig_weights: list[float] = []
for e in member_edges:
si = qn_to_idx.get(e.source_qualified)
ti = qn_to_idx.get(e.target_qualified)
if si is not None and ti is not None and si != ti:
ig_edges.append((si, ti))
ig_weights.append(
EDGE_WEIGHTS.get(e.kind, 0.5)
)
if not ig_edges:
result.append(comm)
continue
try:
g = ig.Graph(
n=len(member_nodes),
edges=ig_edges,
directed=False,
)
g.es["weight"] = ig_weights
import os
seed = int(os.environ.get("CRG_LEIDEN_SEED", _LEIDEN_SEED))
# Deterministic seeding for benchmark reproducibility — community
# detection is not a security-sensitive context. nosec B311.
ig.set_random_number_generator(random.Random(seed)) # nosec B311
partition = g.community_leiden(
objective_function="modularity",
weights="weight",
resolution=0.5,
)
sub_communities: dict[int, list[str]] = {}
for idx, cid in enumerate(partition.membership):
sub_communities.setdefault(cid, []).append(
member_nodes[idx].qualified_name
)
if len(sub_communities) <= 1:
result.append(comm)
continue
parent_id = comm.get("id", 0)
comm_name = comm.get("name", "")
for sub_members in sub_communities.values():
sub_comm = {
"id": next_id,
"name": comm_name + f"-sub{next_id}",
"level": comm.get("level", 0) + 1,
"parent_id": parent_id,
"members": sub_members,
"size": len(sub_members),
"cohesion": 0.0,
"dominant_language": comm.get(
"dominant_language"
),
"description": (
f"Split from {comm_name}"
),
}
result.append(sub_comm)
next_id += 1
logger.info(
"Split oversized community '%s' "
"(%d members) into %d",
comm_name,
len(members),
len(sub_communities),
)
except Exception:
logger.warning(
"Failed to split community '%s', "
"keeping as-is",
comm.get("name", ""),
exc_info=True,
)
result.append(comm)
return result
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def detect_communities(
store: GraphStore, min_size: int = 2
) -> list[dict[str, Any]]:
"""Detect communities in the code graph.
Uses the Leiden algorithm via igraph if available, otherwise falls back to
file-based grouping.
Args:
store: The GraphStore instance.
min_size: Minimum number of nodes for a community to be included.
Returns:
List of community dicts with keys: name, level, size, cohesion,
dominant_language, description, members, member_qns.
"""
# Gather all nodes (exclude File nodes to focus on code entities)
all_edges = store.get_all_edges()
unique_nodes = store.get_all_nodes(exclude_files=True)
# Build adjacency index once for fast cohesion computation
adj = _build_adjacency(all_edges)
logger.info(
"Loaded %d unique nodes, %d edges",
len(unique_nodes), len(all_edges),
)
if IGRAPH_AVAILABLE:
logger.info("Detecting communities with Leiden algorithm (igraph)")
results = _detect_leiden(unique_nodes, all_edges, min_size, adj=adj)
else:
logger.info("igraph not available, using file-based community detection")
results = _detect_file_based(unique_nodes, all_edges, min_size, adj=adj)
# Split oversized communities
results = _split_oversized(
results, unique_nodes, all_edges,
)
# Convert member_qns (internal set) to a list for serialization safety,
# then strip it from the returned dicts to avoid leaking internal state.
for comm in results:
if "member_qns" in comm:
comm["member_qns"] = list(comm["member_qns"])
del comm["member_qns"]
return results
def incremental_detect_communities(
store: GraphStore,
changed_files: list[str],
min_size: int = 2,
) -> int:
"""Re-detect communities only if changed files affect existing communities.
If no existing communities contain nodes from changed files, skips
re-detection entirely (the common case for small changes). Otherwise
re-runs full community detection.
Args:
store: The GraphStore instance.
changed_files: List of file paths that have changed.
min_size: Minimum number of nodes for a community to be included.
Returns:
Number of communities detected, or 0 if skipped.
"""
if not changed_files:
return 0
conn = store._conn
# Check if any communities are affected (batch to stay under SQLite limit)
affected_count = 0
for i in range(0, len(changed_files), _SQL_BATCH):
batch = changed_files[i:i + _SQL_BATCH]
placeholders = ",".join("?" * len(batch))
row = conn.execute(
f"SELECT COUNT(DISTINCT community_id) FROM nodes " # nosec B608
f"WHERE community_id IS NOT NULL AND file_path IN ({placeholders})",
batch,
).fetchone()
if row:
affected_count += row[0]
affected = (affected_count,) if affected_count else None
if not affected or affected[0] == 0:
return 0 # No communities affected, skip
# Re-run full community detection (correct and fast enough)
communities = detect_communities(store, min_size=min_size)
return store_communities(store, communities)
def store_communities(
store: GraphStore, communities: list[dict[str, Any]]
) -> int:
"""Store detected communities in the database.
Clears existing communities and community_id assignments, then inserts
the new communities and updates node community_id references.
Args:
store: The GraphStore instance.
communities: List of community dicts from detect_communities().
Returns:
Number of communities stored.
"""
# NOTE: store_communities uses _conn directly because it performs
# multi-statement batch writes (DELETE + INSERT loop + UPDATE loop)
# that are tightly coupled to the DB transaction lifecycle.
conn = store._conn
if conn.in_transaction:
logger.warning("Rolling back uncommitted transaction before BEGIN IMMEDIATE")
conn.rollback()
# Wrap in explicit transaction so the DELETE + INSERT + UPDATE
# sequence is atomic — no partial community data on crash.
conn.execute("BEGIN IMMEDIATE")
try:
conn.execute("DELETE FROM communities")
conn.execute("UPDATE nodes SET community_id = NULL")
count = 0
for comm in communities:
cursor = conn.execute(
"""INSERT INTO communities
(name, level, cohesion, size, dominant_language, description)
VALUES (?, ?, ?, ?, ?, ?)""",
(
comm["name"],
comm.get("level", 0),
comm.get("cohesion", 0.0),
comm["size"],
comm.get("dominant_language", ""),
comm.get("description", ""),
),
)
community_id = cursor.lastrowid
# Batch update community_id on member nodes
member_qns = comm.get("members", [])
for j in range(0, len(member_qns), _SQL_BATCH):
batch = member_qns[j:j + _SQL_BATCH]
placeholders = ",".join("?" * len(batch))
conn.execute(
f"UPDATE nodes SET community_id = ? WHERE qualified_name IN ({placeholders})", # nosec B608
[community_id] + batch,
)
count += 1
conn.commit()
except BaseException:
conn.rollback()
raise
return count
def get_communities(
store: GraphStore, sort_by: str = "size", min_size: int = 0
) -> list[dict[str, Any]]:
"""Retrieve stored communities from the database.
Args:
store: The GraphStore instance.
sort_by: Column to sort by ("size", "cohesion", "name").
min_size: Minimum community size to include.
Returns:
List of community dicts.
"""
valid_sorts = {"size", "cohesion", "name"}
if sort_by not in valid_sorts:
sort_by = "size"
order = "DESC" if sort_by in ("size", "cohesion") else "ASC"
# NOTE: get_communities reads the communities table which has no
# dedicated GraphStore method (it's a domain-specific table managed
# entirely by the communities module). We use _conn for this query.
rows = store._conn.execute(
f"SELECT * FROM communities WHERE size >= ? ORDER BY {sort_by} {order}", # nosec B608
(min_size,),
).fetchall()
communities: list[dict[str, Any]] = []
for row in rows:
# Fetch member qualified names for this community
member_qns = [
_sanitize_name(qn)
for qn in store.get_community_member_qns(row["id"])
]
communities.append({
"id": row["id"],
"name": _sanitize_name(row["name"]),
"level": row["level"],
"cohesion": row["cohesion"],
"size": row["size"],
"dominant_language": row["dominant_language"] or "",
"description": _sanitize_name(row["description"] or ""),
"members": member_qns,
})
return communities
_TEST_COMMUNITY_RE = re.compile(
r"(^test[-/]|[-/]test([:/]|$)|it:should|describe:|spec[-/]|[-/]spec$)",
re.IGNORECASE,
)
def _is_test_community(name: str) -> bool:
"""Return True if a community name indicates it is test-dominated."""
return bool(_TEST_COMMUNITY_RE.search(name))
def get_architecture_overview(store: GraphStore) -> dict[str, Any]:
"""Generate an architecture overview based on community structure.
Builds a node-to-community mapping, counts cross-community edges,
and generates warnings for high coupling.
Args:
store: The GraphStore instance.
Returns:
Dict with keys: communities, cross_community_edges, warnings.
"""
communities = get_communities(store)
# Build node -> community_id mapping
node_to_community: dict[str, int] = {}
for comm in communities:
comm_id = comm.get("id", 0)
for qn in comm.get("members", []):
node_to_community[qn] = comm_id
# Count cross-community edges
all_edges = store.get_all_edges()
cross_edges: list[dict[str, Any]] = []
cross_counts: Counter[tuple[int, int]] = Counter()
for e in all_edges:
# TESTED_BY edges are expected cross-community coupling (test → code),
# not an architectural smell.
if e.kind == "TESTED_BY":
continue
src_comm = node_to_community.get(e.source_qualified)
tgt_comm = node_to_community.get(e.target_qualified)
if (
src_comm is not None
and tgt_comm is not None
and src_comm != tgt_comm
):
pair = (min(src_comm, tgt_comm), max(src_comm, tgt_comm))
cross_counts[pair] += 1
cross_edges.append({
"source_community": src_comm,
"target_community": tgt_comm,
"edge_kind": e.kind,
"source": _sanitize_name(e.source_qualified),
"target": _sanitize_name(e.target_qualified),
})
# Generate warnings for high coupling, skipping test-dominated pairs.
warnings: list[str] = []
comm_name_map = {c.get("id", 0): c["name"] for c in communities}
for (c1, c2), count in cross_counts.most_common():
if count > 10:
name1 = comm_name_map.get(c1, f"community-{c1}")
name2 = comm_name_map.get(c2, f"community-{c2}")
# Skip pairs where either community is test-dominated — coupling
# between test and production code is expected, not architectural.
if _is_test_community(name1) or _is_test_community(name2):
continue
warnings.append(
f"High coupling ({count} edges) between "
f"'{name1}' and '{name2}'"
)
return {
"communities": communities,
"cross_community_edges": cross_edges,
"warnings": warnings,
}