Fix #683: normalize source_file path separators + two-phase cohesion re-clustering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-03 17:26:13 +01:00
co-authored by Claude Sonnet 4.6
parent f61bb27d9c
commit 6df69dce6c
3 changed files with 38 additions and 0 deletions
+10
View File
@@ -39,6 +39,12 @@ def _normalize_id(s: str) -> str:
return cleaned.strip("_").lower()
def _norm_source_file(p: str | None) -> str | None:
"""Normalize path separators to forward slashes so Windows backslash paths
and POSIX paths from semantic subagents resolve to the same node identity."""
return p.replace("\\", "/") if p else p
def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
"""Build a NetworkX graph from an extraction dict.
@@ -73,6 +79,8 @@ def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
print(f"[graphify] Extraction warning ({len(real_errors)} issues): {real_errors[0]}", file=sys.stderr)
G: nx.Graph = nx.DiGraph() if directed else nx.Graph()
for node in extraction.get("nodes", []):
if "source_file" in node:
node["source_file"] = _norm_source_file(node["source_file"])
G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
node_set = set(G.nodes())
# Normalized ID map: lets edges survive when the LLM generates IDs with
@@ -95,6 +103,8 @@ def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
if src not in node_set or tgt not in node_set:
continue # skip edges to external/stdlib nodes - expected, not an error
attrs = {k: v for k, v in edge.items() if k not in ("source", "target")}
if "source_file" in attrs:
attrs["source_file"] = _norm_source_file(attrs["source_file"])
# Preserve original edge direction - undirected graphs lose it otherwise,
# causing display functions to show edges backwards.
attrs["_src"] = src
+13
View File
@@ -54,6 +54,8 @@ def _partition(G: nx.Graph) -> dict[str, int]:
_MAX_COMMUNITY_FRACTION = 0.25 # communities larger than 25% of graph get split
_MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes
_COHESION_SPLIT_THRESHOLD = 0.05 # re-split communities with cohesion below this
_COHESION_SPLIT_MIN_SIZE = 50 # only cohesion-split if community has at least this many nodes
def cluster(G: nx.Graph) -> dict[int, list[str]]:
@@ -99,6 +101,17 @@ def cluster(G: nx.Graph) -> dict[int, list[str]]:
else:
final_communities.append(nodes)
# Second pass: re-split low-cohesion communities caused by doc-hub nodes
# that bridge otherwise-unrelated subsystems (e.g. CLAUDE.md connected to everything).
second_pass: list[list[str]] = []
for nodes in final_communities:
if len(nodes) >= _COHESION_SPLIT_MIN_SIZE and cohesion_score(G, nodes) < _COHESION_SPLIT_THRESHOLD:
splits = _split_community(G, nodes)
second_pass.extend(splits if len(splits) > 1 else [nodes])
else:
second_pass.append(nodes)
final_communities = second_pass
# Re-index by size descending for deterministic ordering
final_communities.sort(key=len, reverse=True)
return {i: sorted(nodes) for i, nodes in enumerate(final_communities)}
+15
View File
@@ -50,6 +50,21 @@ def test_legacy_edge_from_to_canonicalized():
assert G.number_of_edges() == 1
def test_source_file_backslash_normalized():
"""Windows backslash paths and POSIX paths for the same file must produce one node."""
extraction = {
"nodes": [
{"id": "n1", "label": "A", "file_type": "code", "source_file": "src\\middleware\\auth.py"},
{"id": "n2", "label": "B", "file_type": "code", "source_file": "src/middleware/auth.py"},
],
"edges": [],
"input_tokens": 0, "output_tokens": 0,
}
G = build_from_json(extraction)
sources = {G.nodes[n]["source_file"] for n in G.nodes()}
assert sources == {"src/middleware/auth.py"}
def test_build_merges_multiple_extractions():
ext1 = {"nodes": [{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"}],
"edges": [], "input_tokens": 0, "output_tokens": 0}