mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 02:17:04 +00:00
Fix 9 issues: kiro package data, betweenness perf, wiki step, opencode plugin, cache root, PHP missing edges, Windows stability, cross-file calls
- #352: add skill-kiro.md to pyproject.toml package-data - #341: guard edge_betweenness at >5000 nodes; use approximate k=100 for suggest_questions on large graphs - #354/#229: add Step 6b in skill.md to call to_wiki() when --wiki given (before Step 9 cleanup) - #356: call _install_opencode_plugin() from install --platform opencode path - #350: add cache_root param to extract() so subdirectory runs keep cache at ./graphify-out/cache/ - #230: PHP class_constant_access_expression emits references_constant edges - #232: PHP scoped_call_expression (static method calls) emits calls edges - #287: os.replace fallback for Windows WinError 5; graphify update exits 1 on failure; templates use graphify update . instead of python3 -c - #348: cross-file call resolution for all languages via raw_calls + global label map pass in extract() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -156,6 +156,9 @@ def install(platform: str = "claude") -> None:
|
||||
claude_md.write_text(_SKILL_REGISTRATION.lstrip(), encoding="utf-8")
|
||||
print(f" CLAUDE.md -> created at {claude_md}")
|
||||
|
||||
if platform == "opencode":
|
||||
_install_opencode_plugin(Path("."))
|
||||
|
||||
print()
|
||||
print("Done. Open your AI coding assistant and type:")
|
||||
print()
|
||||
@@ -171,7 +174,7 @@ This project has a graphify knowledge graph at graphify-out/.
|
||||
Rules:
|
||||
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
|
||||
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
||||
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
|
||||
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
|
||||
"""
|
||||
|
||||
_CLAUDE_MD_MARKER = "## graphify"
|
||||
@@ -325,7 +328,7 @@ Rules:
|
||||
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
|
||||
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
||||
- If the graphify MCP server is active, utilize tools like `query_graph`, `get_node`, and `shortest_path` for precise architecture navigation instead of falling back to `grep`
|
||||
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
|
||||
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
|
||||
"""
|
||||
|
||||
_ANTIGRAVITY_WORKFLOW = """\
|
||||
@@ -487,7 +490,7 @@ This project has a graphify knowledge graph at graphify-out/.
|
||||
|
||||
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
|
||||
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
||||
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
|
||||
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
|
||||
"""
|
||||
|
||||
|
||||
@@ -1235,7 +1238,8 @@ def main() -> None:
|
||||
if ok:
|
||||
print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.")
|
||||
else:
|
||||
print("Nothing to update or rebuild failed — check output above.")
|
||||
print("Nothing to update or rebuild failed — check output above.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif cmd == "benchmark":
|
||||
from graphify.benchmark import run_benchmark, print_benchmark
|
||||
|
||||
+4
-1
@@ -262,6 +262,8 @@ def _cross_community_surprises(
|
||||
# No community info - use edge betweenness centrality
|
||||
if G.number_of_edges() == 0:
|
||||
return []
|
||||
if G.number_of_nodes() > 5000:
|
||||
return []
|
||||
betweenness = nx.edge_betweenness_centrality(G)
|
||||
top_edges = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:top_n]
|
||||
result = []
|
||||
@@ -360,7 +362,8 @@ def suggest_questions(
|
||||
|
||||
# 2. Bridge nodes (high betweenness) → cross-cutting concern questions
|
||||
if G.number_of_edges() > 0:
|
||||
betweenness = nx.betweenness_centrality(G)
|
||||
k = min(100, G.number_of_nodes()) if G.number_of_nodes() > 1000 else None
|
||||
betweenness = nx.betweenness_centrality(G, k=k)
|
||||
# Top bridge nodes that are NOT file-level hubs
|
||||
bridges = sorted(
|
||||
[(n, s) for n, s in betweenness.items()
|
||||
|
||||
+8
-1
@@ -79,7 +79,14 @@ def save_cached(path: Path, result: dict, root: Path = Path(".")) -> None:
|
||||
tmp = entry.with_suffix(".tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(result), encoding="utf-8")
|
||||
os.replace(tmp, entry)
|
||||
try:
|
||||
os.replace(tmp, entry)
|
||||
except PermissionError:
|
||||
# Windows: os.replace can fail with WinError 5 if the target is
|
||||
# briefly locked. Fall back to copy-then-delete.
|
||||
import shutil
|
||||
shutil.copy2(tmp, entry)
|
||||
tmp.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
+78
-6
@@ -563,7 +563,7 @@ _PHP_CONFIG = LanguageConfig(
|
||||
class_types=frozenset({"class_declaration"}),
|
||||
function_types=frozenset({"function_definition", "method_declaration"}),
|
||||
import_types=frozenset({"namespace_use_clause"}),
|
||||
call_types=frozenset({"function_call_expression", "member_call_expression"}),
|
||||
call_types=frozenset({"function_call_expression", "member_call_expression", "scoped_call_expression", "class_constant_access_expression"}),
|
||||
static_prop_types=frozenset({"scoped_property_access_expression"}),
|
||||
helper_fn_names=frozenset({"config"}),
|
||||
container_bind_methods=frozenset({"bind", "singleton", "scoped", "instance"}),
|
||||
@@ -936,6 +936,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
||||
seen_static_ref_pairs: set[tuple[str, str, str]] = set()
|
||||
seen_helper_ref_pairs: set[tuple[str, str, str]] = set()
|
||||
seen_bind_pairs: set[tuple[str, str, str]] = set()
|
||||
raw_calls: list[dict] = [] # unresolved calls for cross-file resolution in extract()
|
||||
|
||||
def _php_class_const_scope(n) -> str | None:
|
||||
scope = n.child_by_field_name("scope")
|
||||
@@ -1009,11 +1010,16 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
||||
callee_name = raw
|
||||
break
|
||||
elif config.ts_module == "tree_sitter_php":
|
||||
# PHP: distinguish function_call_expression vs member_call_expression
|
||||
# PHP: distinguish call expression subtypes
|
||||
if node.type == "function_call_expression":
|
||||
func_node = node.child_by_field_name("function")
|
||||
if func_node:
|
||||
callee_name = _read_text(func_node, source)
|
||||
elif node.type == "scoped_call_expression":
|
||||
# Static method call: Helper::format() → callee = "Helper"
|
||||
scope_node = node.child_by_field_name("scope")
|
||||
if scope_node:
|
||||
callee_name = _read_text(scope_node, source)
|
||||
else:
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node:
|
||||
@@ -1059,6 +1065,14 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
elif callee_name and not tgt_nid:
|
||||
# Callee not in this file — save for cross-file resolution in extract()
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": callee_name,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
|
||||
# Helper function calls: config('foo.bar') → uses_config edge to "foo"
|
||||
if (callee_name and callee_name in config.helper_fn_names):
|
||||
@@ -1163,6 +1177,27 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
# PHP class constant access: Foo::BAR → references_constant edge
|
||||
if config.ts_module == "tree_sitter_php" and node.type == "class_constant_access_expression":
|
||||
class_name = _php_class_const_scope(node)
|
||||
if class_name:
|
||||
tgt_nid = label_to_nid.get(class_name.lower())
|
||||
if tgt_nid and tgt_nid != caller_nid:
|
||||
pair3 = (caller_nid, tgt_nid, "references_constant")
|
||||
if pair3 not in seen_static_ref_pairs:
|
||||
seen_static_ref_pairs.add(pair3)
|
||||
line = node.start_point[0] + 1
|
||||
edges.append({
|
||||
"source": caller_nid,
|
||||
"target": tgt_nid,
|
||||
"relation": "references_constant",
|
||||
"confidence": "EXTRACTED",
|
||||
"confidence_score": 1.0,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
@@ -1199,7 +1234,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
||||
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
|
||||
clean_edges.append(edge)
|
||||
|
||||
return {"nodes": nodes, "edges": clean_edges}
|
||||
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|
||||
|
||||
|
||||
# ── Python rationale extraction ───────────────────────────────────────────────
|
||||
@@ -2992,13 +3027,19 @@ def _check_tree_sitter_version() -> None:
|
||||
)
|
||||
|
||||
|
||||
def extract(paths: list[Path]) -> dict:
|
||||
def extract(paths: list[Path], cache_root: Path | None = None) -> dict:
|
||||
"""Extract AST nodes and edges from a list of code files.
|
||||
|
||||
Two-pass process:
|
||||
1. Per-file structural extraction (classes, functions, imports)
|
||||
2. Cross-file import resolution: turns file-level imports into
|
||||
class-level INFERRED edges (DigestAuth --uses--> Response)
|
||||
|
||||
Args:
|
||||
paths: files to extract from
|
||||
cache_root: explicit root for graphify-out/cache/ (overrides the
|
||||
inferred common path prefix). Pass Path('.') when running on a
|
||||
subdirectory so the cache stays at ./graphify-out/cache/.
|
||||
"""
|
||||
_check_tree_sitter_version()
|
||||
per_file: list[dict] = []
|
||||
@@ -3068,13 +3109,13 @@ def extract(paths: list[Path]) -> dict:
|
||||
extractor = _DISPATCH.get(path.suffix)
|
||||
if extractor is None:
|
||||
continue
|
||||
cached = load_cached(path, root)
|
||||
cached = load_cached(path, cache_root or root)
|
||||
if cached is not None:
|
||||
per_file.append(cached)
|
||||
continue
|
||||
result = extractor(path)
|
||||
if "error" not in result:
|
||||
save_cached(path, result, root)
|
||||
save_cached(path, result, cache_root or root)
|
||||
per_file.append(result)
|
||||
if total >= _PROGRESS_INTERVAL:
|
||||
print(f" AST extraction: {total}/{total} files (100%)", flush=True)
|
||||
@@ -3096,6 +3137,37 @@ def extract(paths: list[Path]) -> dict:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Cross-file import resolution failed, skipping: %s", exc)
|
||||
|
||||
# Cross-file call resolution for all languages
|
||||
# Each extractor saved unresolved calls in raw_calls. Now that we have all
|
||||
# nodes from all files, resolve any callee that exists in another file.
|
||||
global_label_to_nid: dict[str, str] = {}
|
||||
for n in all_nodes:
|
||||
raw = n.get("label", "")
|
||||
normalised = raw.strip("()").lstrip(".")
|
||||
if normalised:
|
||||
global_label_to_nid[normalised.lower()] = n["id"]
|
||||
|
||||
existing_pairs = {(e["source"], e["target"]) for e in all_edges}
|
||||
for result in per_file:
|
||||
for rc in result.get("raw_calls", []):
|
||||
callee = rc.get("callee", "")
|
||||
if not callee:
|
||||
continue
|
||||
tgt = global_label_to_nid.get(callee.lower())
|
||||
caller = rc["caller_nid"]
|
||||
if tgt and tgt != caller and (caller, tgt) not in existing_pairs:
|
||||
existing_pairs.add((caller, tgt))
|
||||
all_edges.append({
|
||||
"source": caller,
|
||||
"target": tgt,
|
||||
"relation": "calls",
|
||||
"confidence": "INFERRED",
|
||||
"confidence_score": 0.8,
|
||||
"source_file": rc.get("source_file", ""),
|
||||
"source_location": rc.get("source_location"),
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
return {
|
||||
"nodes": all_nodes,
|
||||
"edges": all_edges,
|
||||
|
||||
@@ -548,6 +548,36 @@ else:
|
||||
"
|
||||
```
|
||||
|
||||
### Step 6b - Wiki (only if --wiki flag)
|
||||
|
||||
**Only run this step if `--wiki` was explicitly given in the original command.**
|
||||
|
||||
Run this before Step 9 (cleanup) so `.graphify_labels.json` is still available.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.build import build_from_json
|
||||
from graphify.wiki import to_wiki
|
||||
from graphify.analyze import god_nodes
|
||||
from pathlib import Path
|
||||
|
||||
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text())
|
||||
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text())
|
||||
labels_raw = json.loads(Path('graphify-out/.graphify_labels.json').read_text()) if Path('graphify-out/.graphify_labels.json').exists() else {}
|
||||
|
||||
G = build_from_json(extraction)
|
||||
communities = {int(k): v for k, v in analysis['communities'].items()}
|
||||
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
|
||||
labels = {int(k): v for k, v in labels_raw.items()}
|
||||
gods = god_nodes(G)
|
||||
|
||||
n = to_wiki(G, communities, 'graphify-out/wiki', community_labels=labels or None, cohesion=cohesion, god_nodes_data=gods)
|
||||
print(f'Wiki: {n} articles written to graphify-out/wiki/')
|
||||
print(' graphify-out/wiki/index.md -> agent entry point')
|
||||
"
|
||||
```
|
||||
|
||||
### Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag)
|
||||
|
||||
**If `--neo4j`** - generate a Cypher file for manual import:
|
||||
|
||||
+1
-1
@@ -60,4 +60,4 @@ where = ["."]
|
||||
include = ["graphify*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md"]
|
||||
graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md"]
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_install_contains_expected_rules(tmp_path):
|
||||
content = (tmp_path / "CLAUDE.md").read_text()
|
||||
assert "GRAPH_REPORT.md" in content
|
||||
assert "wiki/index.md" in content
|
||||
assert "_rebuild_code" in content
|
||||
assert "graphify update" in content
|
||||
|
||||
|
||||
def test_install_appends_to_existing_claude_md(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user