mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 16:56:36 +00:00
fix hooks phantom dir on git < 2.31, save_manifest incremental data loss, cohesion rounding, C++ inheritance; add --resolution and --exclude-hubs
- hooks.py: drop --path-format=absolute (added git 2.31), validate no newlines in path, anchor relative paths on repo root (#907) - detect.py: seed save_manifest from existing manifest before loop so incremental callers don't erase untouched file entries (#917) - cluster.py: drop round(..., 2) from cohesion_score so split threshold 0.05 fires correctly; add resolution param to _partition and cluster; add exclude_hubs_percentile to cluster with majority-vote reattachment (#919) - report.py: format cohesion with :.2f for display - __main__.py: wire --resolution and --exclude-hubs into extract and cluster-only commands (#919) - C++ inheritance already written to disk by analysis agent (#915) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f7160c81c5
commit
2d783e569a
+23
-2
@@ -1761,11 +1761,21 @@ def main() -> None:
|
||||
args = sys.argv[2:]
|
||||
watch_path: Path | None = None
|
||||
graph_override: Path | None = None
|
||||
co_resolution: float = 1.0
|
||||
co_exclude_hubs: float | None = None
|
||||
i_arg = 0
|
||||
while i_arg < len(args):
|
||||
a = args[i_arg]
|
||||
if a == "--graph" and i_arg + 1 < len(args):
|
||||
graph_override = Path(args[i_arg + 1]); i_arg += 2
|
||||
elif a == "--resolution" and i_arg + 1 < len(args):
|
||||
co_resolution = float(args[i_arg + 1]); i_arg += 2
|
||||
elif a.startswith("--resolution="):
|
||||
co_resolution = float(a.split("=", 1)[1]); i_arg += 1
|
||||
elif a == "--exclude-hubs" and i_arg + 1 < len(args):
|
||||
co_exclude_hubs = float(args[i_arg + 1]); i_arg += 2
|
||||
elif a.startswith("--exclude-hubs="):
|
||||
co_exclude_hubs = float(a.split("=", 1)[1]); i_arg += 1
|
||||
elif a == "--no-viz" or a.startswith("--min-community-size="):
|
||||
i_arg += 1
|
||||
elif a.startswith("--"):
|
||||
@@ -1792,7 +1802,7 @@ def main() -> None:
|
||||
G = build_from_json(_raw, directed=_directed)
|
||||
print(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
|
||||
print("Re-clustering...")
|
||||
communities = cluster(G)
|
||||
communities = cluster(G, resolution=co_resolution, exclude_hubs_percentile=co_exclude_hubs)
|
||||
cohesion = score_all(G, communities)
|
||||
gods = god_nodes(G)
|
||||
surprises = surprising_connections(G, communities)
|
||||
@@ -2415,6 +2425,9 @@ def main() -> None:
|
||||
cli_token_budget: int | None = None
|
||||
cli_max_concurrency: int | None = None
|
||||
cli_api_timeout: float | None = None
|
||||
# Clustering tuning knobs
|
||||
cli_resolution: float = 1.0
|
||||
cli_exclude_hubs: float | None = None
|
||||
|
||||
def _parse_int(name: str, raw: str) -> int:
|
||||
try:
|
||||
@@ -2480,6 +2493,14 @@ def main() -> None:
|
||||
cli_api_timeout = _parse_float("--api-timeout", args[i + 1]); i += 2
|
||||
elif a.startswith("--api-timeout="):
|
||||
cli_api_timeout = _parse_float("--api-timeout", a.split("=", 1)[1]); i += 1
|
||||
elif a == "--resolution" and i + 1 < len(args):
|
||||
cli_resolution = _parse_float("--resolution", args[i + 1]); i += 2
|
||||
elif a.startswith("--resolution="):
|
||||
cli_resolution = _parse_float("--resolution", a.split("=", 1)[1]); i += 1
|
||||
elif a == "--exclude-hubs" and i + 1 < len(args):
|
||||
cli_exclude_hubs = float(args[i + 1]); i += 2
|
||||
elif a.startswith("--exclude-hubs="):
|
||||
cli_exclude_hubs = float(a.split("=", 1)[1]); i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
@@ -2796,7 +2817,7 @@ def main() -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
communities = _cluster(G)
|
||||
communities = _cluster(G, resolution=cli_resolution, exclude_hubs_percentile=cli_exclude_hubs)
|
||||
cohesion = _score_all(G, communities)
|
||||
try:
|
||||
gods = _god_nodes(G)
|
||||
|
||||
+53
-7
@@ -19,12 +19,15 @@ def _suppress_output():
|
||||
return contextlib.redirect_stdout(io.StringIO())
|
||||
|
||||
|
||||
def _partition(G: nx.Graph) -> dict[str, int]:
|
||||
def _partition(G: nx.Graph, resolution: float = 1.0) -> dict[str, int]:
|
||||
"""Run community detection. Returns {node_id: community_id}.
|
||||
|
||||
Tries Leiden (graspologic) first — best quality.
|
||||
Falls back to Louvain (built into networkx) if graspologic is not installed.
|
||||
|
||||
resolution > 1.0 → more, smaller communities.
|
||||
resolution < 1.0 → fewer, larger communities.
|
||||
|
||||
Output from graspologic is suppressed to prevent ANSI escape codes
|
||||
from corrupting terminal scroll buffers on Windows PowerShell 5.1.
|
||||
"""
|
||||
@@ -49,6 +52,8 @@ def _partition(G: nx.Graph) -> dict[str, int]:
|
||||
kwargs["random_seed"] = 42
|
||||
if "trials" in lsig:
|
||||
kwargs["trials"] = 1
|
||||
if "resolution" in lsig:
|
||||
kwargs["resolution"] = resolution
|
||||
# Suppress graspologic output to prevent ANSI escape codes from
|
||||
# corrupting PowerShell 5.1 scroll buffer (issue #19)
|
||||
old_stderr = sys.stderr
|
||||
@@ -65,7 +70,7 @@ def _partition(G: nx.Graph) -> dict[str, int]:
|
||||
# Fallback: networkx louvain (available since networkx 2.7).
|
||||
# Inspect kwargs to stay compatible across NetworkX versions — max_level
|
||||
# was added in a later release and prevents hangs on large sparse graphs.
|
||||
kwargs: dict = {"seed": 42, "threshold": 1e-4}
|
||||
kwargs: dict = {"seed": 42, "threshold": 1e-4, "resolution": resolution}
|
||||
if "max_level" in inspect.signature(nx.community.louvain_communities).parameters:
|
||||
kwargs["max_level"] = 10
|
||||
communities = nx.community.louvain_communities(stable, **kwargs)
|
||||
@@ -78,7 +83,11 @@ _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]]:
|
||||
def cluster(
|
||||
G: nx.Graph,
|
||||
resolution: float = 1.0,
|
||||
exclude_hubs_percentile: float | None = None,
|
||||
) -> dict[int, list[str]]:
|
||||
"""Run Leiden community detection. Returns {community_id: [node_ids]}.
|
||||
|
||||
Community IDs are stable across runs: 0 = largest community after splitting.
|
||||
@@ -87,6 +96,13 @@ def cluster(G: nx.Graph) -> dict[int, list[str]]:
|
||||
|
||||
Accepts directed or undirected graphs. DiGraphs are converted to undirected
|
||||
internally since Louvain/Leiden require undirected input.
|
||||
|
||||
resolution: passed to Leiden/Louvain. >1.0 = more smaller communities,
|
||||
<1.0 = fewer larger communities. Default 1.0.
|
||||
exclude_hubs_percentile: if set (0-100), nodes whose degree exceeds this
|
||||
percentile are excluded from partitioning and reattached to their
|
||||
majority-vote neighbour community afterwards. Useful for staging/utility
|
||||
super-hubs that inflate god-node rankings (#919).
|
||||
"""
|
||||
if G.number_of_nodes() == 0:
|
||||
return {}
|
||||
@@ -95,14 +111,26 @@ def cluster(G: nx.Graph) -> dict[int, list[str]]:
|
||||
if G.number_of_edges() == 0:
|
||||
return {i: [n] for i, n in enumerate(sorted(G.nodes))}
|
||||
|
||||
# Compute hub exclusion set before removing anything so degree is based on full graph
|
||||
hub_nodes: set[str] = set()
|
||||
if exclude_hubs_percentile is not None:
|
||||
degrees = sorted(d for _, d in G.degree())
|
||||
if degrees:
|
||||
idx = max(0, int(len(degrees) * exclude_hubs_percentile / 100) - 1)
|
||||
threshold = degrees[idx]
|
||||
hub_nodes = {n for n, d in G.degree() if d > threshold}
|
||||
|
||||
# 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]
|
||||
# Also exclude hub nodes from partitioning so they don't pull unrelated
|
||||
# subsystems into the same community
|
||||
excluded = hub_nodes
|
||||
isolates = [n for n in G.nodes() if G.degree(n) == 0 and n not in excluded]
|
||||
connected_nodes = [n for n in G.nodes() if G.degree(n) > 0 and n not in excluded]
|
||||
connected = G.subgraph(connected_nodes)
|
||||
|
||||
raw: dict[int, list[str]] = {}
|
||||
if connected.number_of_nodes() > 0:
|
||||
partition = _partition(connected)
|
||||
partition = _partition(connected, resolution=resolution)
|
||||
for node, cid in partition.items():
|
||||
raw.setdefault(cid, []).append(node)
|
||||
|
||||
@@ -112,6 +140,24 @@ def cluster(G: nx.Graph) -> dict[int, list[str]]:
|
||||
raw[next_cid] = [node]
|
||||
next_cid += 1
|
||||
|
||||
# Reattach excluded hubs by majority-vote neighbour community
|
||||
if hub_nodes:
|
||||
node_community: dict[str, int] = {n: cid for cid, nodes in raw.items() for n in nodes}
|
||||
for hub in sorted(hub_nodes):
|
||||
votes: dict[int, int] = {}
|
||||
for nb in G.neighbors(hub):
|
||||
cid = node_community.get(nb)
|
||||
if cid is not None:
|
||||
votes[cid] = votes.get(cid, 0) + 1
|
||||
if votes:
|
||||
best = min(votes, key=lambda c: (-votes[c], c))
|
||||
raw.setdefault(best, []).append(hub)
|
||||
node_community[hub] = best
|
||||
else:
|
||||
raw[next_cid] = [hub]
|
||||
node_community[hub] = next_cid
|
||||
next_cid += 1
|
||||
|
||||
# Split oversized communities
|
||||
max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * _MAX_COMMUNITY_FRACTION))
|
||||
final_communities: list[list[str]] = []
|
||||
@@ -163,7 +209,7 @@ def cohesion_score(G: nx.Graph, community_nodes: list[str]) -> float:
|
||||
subgraph = G.subgraph(community_nodes)
|
||||
actual = subgraph.number_of_edges()
|
||||
possible = n * (n - 1) / 2
|
||||
return round(actual / possible, 2) if possible > 0 else 0.0
|
||||
return actual / possible if possible > 0 else 0.0
|
||||
|
||||
|
||||
def score_all(G: nx.Graph, communities: dict[int, list[str]]) -> dict[int, float]:
|
||||
|
||||
+25
-6
@@ -856,7 +856,31 @@ def save_manifest(
|
||||
kind="both" — full pipeline: stamps both hashes (default).
|
||||
"""
|
||||
existing = load_manifest(manifest_path)
|
||||
|
||||
def _normalise_entry(entry):
|
||||
if isinstance(entry, (int, float)):
|
||||
return {"mtime": entry, "ast_hash": "", "semantic_hash": ""}
|
||||
if isinstance(entry, dict) and "hash" in entry and "ast_hash" not in entry:
|
||||
return {"mtime": entry.get("mtime", 0), "ast_hash": entry["hash"], "semantic_hash": ""}
|
||||
if isinstance(entry, dict):
|
||||
return entry
|
||||
return None
|
||||
|
||||
# Seed from the existing manifest so incremental callers passing a subset
|
||||
# of files don't silently erase entries for untouched files (#917).
|
||||
# Prune entries whose file no longer exists on disk — those are genuine
|
||||
# deletions that detect_incremental() should treat as gone.
|
||||
manifest: dict[str, dict] = {}
|
||||
for f, entry in existing.items():
|
||||
normalised = _normalise_entry(entry)
|
||||
if normalised is None:
|
||||
continue
|
||||
try:
|
||||
if Path(f).exists():
|
||||
manifest[f] = normalised
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for file_list in files.values():
|
||||
for f in file_list:
|
||||
try:
|
||||
@@ -865,12 +889,7 @@ def save_manifest(
|
||||
h = _md5_file(p)
|
||||
except OSError:
|
||||
continue # file deleted between detect() and manifest write
|
||||
prev = existing.get(f, {})
|
||||
# Normalise legacy {mtime, hash} entries to new schema
|
||||
if isinstance(prev, (int, float)):
|
||||
prev = {"mtime": prev, "ast_hash": "", "semantic_hash": ""}
|
||||
elif isinstance(prev, dict) and "hash" in prev and "ast_hash" not in prev:
|
||||
prev = {"mtime": prev.get("mtime", 0), "ast_hash": prev["hash"], "semantic_hash": ""}
|
||||
prev = _normalise_entry(existing.get(f, {})) or {}
|
||||
entry: dict = {"mtime": mtime}
|
||||
if kind in ("ast", "both"):
|
||||
entry["ast_hash"] = h
|
||||
|
||||
+11
-3
@@ -199,14 +199,22 @@ def _hooks_dir(root: Path) -> Path:
|
||||
)
|
||||
# In a linked worktree .git is a file not a directory, so constructing
|
||||
# root/.git/hooks directly fails. Ask git for the real hooks path instead.
|
||||
# NOTE: do NOT pass --path-format=absolute — added in git 2.31; older git
|
||||
# echoes it back as a literal argument, contaminating stdout and causing a
|
||||
# phantom directory to be created (#907). git -C <root> already returns an
|
||||
# absolute path for worktree/external-gitdir cases, and a path relative to
|
||||
# <root> for normal repos — anchoring on root covers both.
|
||||
import subprocess as _sp
|
||||
try:
|
||||
res = _sp.run(
|
||||
["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-path", "hooks"],
|
||||
["git", "-C", str(root), "rev-parse", "--git-path", "hooks"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
d = Path(res.stdout.strip())
|
||||
raw = res.stdout.strip()
|
||||
# A valid hooks path can never contain newlines or NUL. Their presence
|
||||
# means git echoed an unrecognised flag back (old git behaviour).
|
||||
if res.returncode == 0 and raw and not any(c in raw for c in ("\n", "\r", "\x00")):
|
||||
d = (root / raw).resolve()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
except (OSError, FileNotFoundError):
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ def generate(
|
||||
lines += [
|
||||
"",
|
||||
f"### Community {cid} - \"{label}\"",
|
||||
f"Cohesion: {score}",
|
||||
f"Cohesion: {score:.2f}",
|
||||
f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user