diff --git a/graphify/build.py b/graphify/build.py index 2c2c773b..37366271 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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 diff --git a/graphify/cluster.py b/graphify/cluster.py index 9227c0d1..b5555a85 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -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)} diff --git a/tests/test_build.py b/tests/test_build.py index 2b47bc86..342ebbce 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -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}