mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-20 22:31:48 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
f61bb27d9c
commit
6df69dce6c
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user