From d9b2928da151e690ac299bdfef1c78d3d9e32815 Mon Sep 17 00:00:00 2001 From: Safi Date: Wed, 22 Apr 2026 09:04:38 +0100 Subject: [PATCH] fix: deterministic GRAPH_REPORT on large graphs, stable edge node IDs, correct common-root inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analyze.py: add seed=42 to betweenness_centrality() — eliminates non-deterministic GRAPH_REPORT.md diffs on graphs >1000 nodes (#499) - extract.py: fix common-root inference to stop at first diverging segment not sum of all matches (#502) - extract.py: resolve root to absolute path; post-process file node IDs to project-relative after extraction so graph.json edge endpoints are stable across machines (#502) Co-Authored-By: Claude Sonnet 4.6 --- graphify/analyze.py | 2 +- graphify/extract.py | 35 ++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/graphify/analyze.py b/graphify/analyze.py index a47d82aa..4f480c5b 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -363,7 +363,7 @@ def suggest_questions( # 2. Bridge nodes (high betweenness) → cross-cutting concern questions if G.number_of_edges() > 0: k = min(100, G.number_of_nodes()) if G.number_of_nodes() > 1000 else None - betweenness = nx.betweenness_centrality(G, k=k) + betweenness = nx.betweenness_centrality(G, k=k, seed=42) # Top bridge nodes that are NOT file-level hubs bridges = sorted( [(n, s) for n, s in betweenness.items() diff --git a/graphify/extract.py b/graphify/extract.py index f1c02f31..e12ff80a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3085,20 +3085,24 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: _check_tree_sitter_version() per_file: list[dict] = [] - # Infer a common root for cache keys + # Infer a common root for cache keys (use first diverging segment, not sum of all matches) try: if not paths: root = Path(".") elif len(paths) == 1: root = paths[0].parent else: - common_len = sum( - 1 for i in range(min(len(p.parts) for p in paths)) - if len({p.parts[i] for p in paths}) == 1 - ) + min_parts = min(len(p.parts) for p in paths) + common_len = 0 + for i in range(min_parts): + if len({p.parts[i] for p in paths}) == 1: + common_len += 1 + else: + break root = Path(*paths[0].parts[:common_len]) if common_len else Path(".") except Exception: root = Path(".") + root = root.resolve() _DISPATCH: dict[str, Any] = { ".py": extract_python, @@ -3168,6 +3172,27 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: all_nodes.extend(result.get("nodes", [])) all_edges.extend(result.get("edges", [])) + # Remap file node IDs from absolute-path-derived to project-relative so + # graph.json edge endpoints are stable across machines (#502) + id_remap: dict[str, str] = {} + for path in paths: + old_id = _make_id(str(path)) + try: + new_id = _make_id(str(path.relative_to(root))) + except ValueError: + continue + if old_id != new_id: + id_remap[old_id] = new_id + if id_remap: + for n in all_nodes: + if n.get("id") in id_remap: + n["id"] = id_remap[n["id"]] + for e in all_edges: + if e.get("source") in id_remap: + e["source"] = id_remap[e["source"]] + if e.get("target") in id_remap: + e["target"] = id_remap[e["target"]] + # Add cross-file class-level edges (Python only - uses Python parser internally) py_paths = [p for p in paths if p.suffix == ".py"] if py_paths: diff --git a/pyproject.toml b/pyproject.toml index 5ffee074..1b7ab967 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.4.26" +version = "0.4.27" description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = { file = "LICENSE" }