release: v0.4.9 — PHP extractor improvements, Dart, diacritics, Hermes, fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-04-13 08:39:28 +01:00
co-authored by Claude Sonnet 4.6
parent 1dd4c41413
commit 1fbcaf840f
15 changed files with 487 additions and 24 deletions
+19
View File
@@ -2,6 +2,25 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## 0.4.9 (2026-04-13)
- Fix: `graphify install --platform cursor` no longer crashes — passes `Path(".")` to `_cursor_install` (#281)
- Fix: `_agents_uninstall` now only removes the OpenCode plugin when uninstalling the `opencode` platform — other platforms were incorrectly having their OpenCode plugin stripped (#276)
- Fix: misleading comment in query `--graph` path handler removed (#278)
- Fix: `skill-codex.md``wait``wait_agent` (correct Codex tool name) (#273)
- Add: `svg = ["matplotlib"]` optional extra in pyproject.toml; `matplotlib` added to `[all]` extra (#288)
- Fix: `graspologic` dependency now has `python_version < '3.13'` env marker in `leiden` and `all` extras — prevents install failures on Python 3.13+ (#290)
- Add: Dart/Flutter support — `.dart` files extracted via regex (classes, mixins, functions, imports); added to `CODE_EXTENSIONS` (#292)
- Add: `norm_label` field written at build time in `to_json()` for diacritic-insensitive search; `_score_nodes` and `_find_node` in `serve.py` use `norm_label` with Unicode NFKD normalization fallback (#293)
- Add: Hermes Agent platform support — `graphify hermes install` writes skill to `~/.hermes/skills/graphify/SKILL.md` and AGENTS.md (#251)
- Add: PHP extractor now captures static property access (`Foo::$bar`) as `uses_static_prop` edges (#234)
- Add: PHP extractor now captures `config()` helper calls as `uses_config` edges pointing to the first config key segment (#236)
- Add: PHP extractor now captures service container bindings (`bind`, `singleton`, `scoped`, `instance`) as `bound_to` edges (#238)
- Add: PHP extractor now captures `$listen` / `$subscribe` event listener arrays as `listened_by` edges (#240)
- Add: `prune_dangling_edges()` utility in `export.py` — removes edges whose source/target is not in the node set (#294)
- Fix: Antigravity install injects YAML frontmatter into skill file for native tool discovery; rules now include MCP navigation hint; prints MCP config snippet (#268)
- Fix: Windows hook tests now use platform-aware assertions instead of POSIX executable bit checks (#279)
## 0.4.8 (2026-04-12)
- Fix: platform skill files (aider, codex, opencode, claw, droid, copilot, windows) no longer contain Claude-specific language — references to "Claude" as the AI model replaced with platform-agnostic wording (#272)
+27 -8
View File
@@ -92,6 +92,11 @@ _PLATFORM_CONFIG: dict[str, dict] = {
"skill_dst": Path(".trae-cn") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
},
"hermes": {
"skill_file": "skill-claw.md",
"skill_dst": Path(".hermes") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
},
"antigravity": {
"skill_file": "skill.md",
"skill_dst": Path(".agent") / "skills" / "graphify" / "SKILL.md",
@@ -110,11 +115,11 @@ def install(platform: str = "claude") -> None:
gemini_install()
return
if platform == "cursor":
_cursor_install()
_cursor_install(Path("."))
return
if platform not in _PLATFORM_CONFIG:
print(
f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}, gemini, cursor, antigravity",
f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}, gemini, cursor",
file=sys.stderr,
)
sys.exit(1)
@@ -314,6 +319,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
- 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
"""
@@ -334,6 +340,14 @@ def _antigravity_install(project_dir: Path) -> None:
# 1. Copy skill file to ~/.agent/skills/graphify/SKILL.md
install(platform="antigravity")
# 1.5. Inject YAML frontmatter for native Antigravity tool discovery
skill_dst = Path.home() / _PLATFORM_CONFIG["antigravity"]["skill_dst"]
if skill_dst.exists():
content = skill_dst.read_text(encoding="utf-8")
if not content.startswith("---\n"):
frontmatter = "---\nname: graphify-manager\ndescription: Rebuild the code graph or perform manual CLI queries when MCP server is offline.\n---\n\n"
skill_dst.write_text(frontmatter + content, encoding="utf-8")
# 2. Write .agent/rules/graphify.md
rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
rules_path.parent.mkdir(parents=True, exist_ok=True)
@@ -355,6 +369,12 @@ def _antigravity_install(project_dir: Path) -> None:
print()
print("Antigravity will now check the knowledge graph before answering")
print("codebase questions. Run /graphify first to build the graph.")
print()
print("To enable full MCP architecture navigation, add this to ~/.gemini/antigravity/mcp_config.json:")
print(' "graphify": {')
print(' "command": "uv",')
print(' "args": ["run", "--with", "graphifyy", "--with", "mcp", "-m", "graphify.serve", "${workspace.path}/graphify-out/graph.json"]')
print(' }')
def _antigravity_uninstall(project_dir: Path) -> None:
@@ -594,7 +614,7 @@ def _agents_install(project_dir: Path, platform: str) -> None:
print(f"{platform.capitalize()} — the AGENTS.md rules are the always-on mechanism.")
def _agents_uninstall(project_dir: Path) -> None:
def _agents_uninstall(project_dir: Path, platform: str = "") -> None:
"""Remove the graphify section from the local AGENTS.md."""
target = (project_dir or Path(".")) / "AGENTS.md"
@@ -620,7 +640,8 @@ def _agents_uninstall(project_dir: Path) -> None:
target.unlink()
print(f"AGENTS.md was empty after removal - deleted {target.resolve()}")
_uninstall_opencode_plugin(project_dir or Path("."))
if platform == "opencode":
_uninstall_opencode_plugin(project_dir or Path("."))
def claude_install(project_dir: Path | None = None) -> None:
@@ -837,12 +858,12 @@ def main() -> None:
else:
print("Usage: graphify copilot [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn"):
elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
_agents_install(Path("."), cmd)
elif subcmd == "uninstall":
_agents_uninstall(Path("."))
_agents_uninstall(Path("."), platform=cmd)
if cmd == "codex":
_uninstall_codex_hook(Path("."))
else:
@@ -901,8 +922,6 @@ def main() -> None:
graph_path = args[i + 1]; i += 2
else:
i += 1
# Load graph directly — validate_graph_path restricts to graphify-out/
# so for custom --graph paths we resolve and load directly after existence check
gp = Path(graph_path).resolve()
if not gp.exists():
print(f"error: graph file not found: {gp}", file=sys.stderr)
+1 -1
View File
@@ -18,7 +18,7 @@ class FileType(str, Enum):
_MANIFEST_PATH = "graphify-out/manifest.json"
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte'}
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.dart'}
DOC_EXTENSIONS = {'.md', '.txt', '.rst'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
+22
View File
@@ -11,6 +11,12 @@ from networkx.readwrite import json_graph
from graphify.security import sanitize_label
from graphify.analyze import _node_community_map
def _strip_diacritics(text: str) -> str:
import unicodedata
nfkd = unicodedata.normalize("NFKD", text)
return "".join(c for c in nfkd if not unicodedata.combining(c))
COMMUNITY_COLORS = [
"#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
"#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
@@ -290,6 +296,7 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) ->
data = json_graph.node_link_data(G)
for node in data["nodes"]:
node["community"] = node_community.get(node["id"])
node["norm_label"] = _strip_diacritics(node.get("label", "")).lower()
for link in data["links"]:
if "confidence_score" not in link:
conf = link.get("confidence", "EXTRACTED")
@@ -299,6 +306,21 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) ->
json.dump(data, f, indent=2)
def prune_dangling_edges(graph_data: dict) -> tuple[dict, int]:
"""Remove edges whose source or target node is not in the node set.
Returns the cleaned graph_data dict and the number of pruned edges.
"""
node_ids = {n["id"] for n in graph_data["nodes"]}
links_key = "links" if "links" in graph_data else "edges"
before = len(graph_data[links_key])
graph_data[links_key] = [
e for e in graph_data[links_key]
if e["source"] in node_ids and e["target"] in node_ids
]
return graph_data, before - len(graph_data[links_key])
def _cypher_escape(s: str) -> str:
"""Escape a string for safe embedding in a Cypher single-quoted literal."""
return s.replace("\\", "\\\\").replace("'", "\\'")
+253
View File
@@ -29,6 +29,10 @@ class LanguageConfig:
function_types: frozenset = frozenset()
import_types: frozenset = frozenset()
call_types: frozenset = frozenset()
static_prop_types: frozenset = frozenset()
helper_fn_names: frozenset = frozenset()
container_bind_methods: frozenset = frozenset()
event_listener_properties: frozenset = frozenset()
# Name extraction
name_field: str = "name"
@@ -560,6 +564,10 @@ _PHP_CONFIG = LanguageConfig(
function_types=frozenset({"function_definition", "method_declaration"}),
import_types=frozenset({"namespace_use_clause"}),
call_types=frozenset({"function_call_expression", "member_call_expression"}),
static_prop_types=frozenset({"scoped_property_access_expression"}),
helper_fn_names=frozenset({"config"}),
container_bind_methods=frozenset({"bind", "singleton", "scoped", "instance"}),
event_listener_properties=frozenset({"listen", "subscribe"}),
call_function_field="function",
call_accessor_node_types=frozenset({"member_call_expression"}),
call_accessor_field="name",
@@ -673,6 +681,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, object]] = []
pending_listen_edges: list[tuple[str, str, int]] = []
def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
@@ -800,6 +809,57 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
walk(child, parent_class_nid=class_nid)
return
# Event listener property arrays: $listen = [Event::class => [Listener::class]]
if (t == "property_declaration"
and parent_class_nid
and config.event_listener_properties):
for element in node.children:
if element.type != "property_element":
continue
prop_name: str | None = None
array_node = None
for c in element.children:
if c.type == "variable_name":
for sc in c.children:
if sc.type == "name":
prop_name = _read_text(sc, source)
break
elif c.type == "array_creation_expression":
array_node = c
if (prop_name is None
or prop_name not in config.event_listener_properties
or array_node is None):
continue
for entry in array_node.children:
if entry.type != "array_element_initializer":
continue
event_cls: str | None = None
listener_arr = None
for sub in entry.children:
if sub.type == "class_constant_access_expression" and event_cls is None:
for sc in sub.children:
if sc.is_named and sc.type in ("name", "qualified_name"):
event_cls = _read_text(sc, source)
break
elif sub.type == "array_creation_expression":
listener_arr = sub
if not event_cls or listener_arr is None:
continue
for listener_entry in listener_arr.children:
if listener_entry.type != "array_element_initializer":
continue
for item in listener_entry.children:
if item.type != "class_constant_access_expression":
continue
for sc in item.children:
if sc.is_named and sc.type in ("name", "qualified_name"):
listener_cls = _read_text(sc, source)
line_no = item.start_point[0] + 1
pending_listen_edges.append((event_cls, listener_cls, line_no))
break
break
return
# Function types
if t in config.function_types:
# Swift deinit/subscript have no name field — resolve before generic fallback
@@ -873,6 +933,20 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
label_to_nid[normalised.lower()] = n["id"]
seen_call_pairs: set[tuple[str, str]] = set()
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()
def _php_class_const_scope(n) -> str | None:
scope = n.child_by_field_name("scope")
if scope is None:
for c in n.children:
if c.is_named and c.type in ("name", "qualified_name", "identifier"):
scope = c
break
if scope is None:
return None
return _read_text(scope, source)
def walk_calls(node, caller_nid: str) -> None:
if node.type in config.function_boundary_types:
@@ -986,12 +1060,137 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
"weight": 1.0,
})
# Helper function calls: config('foo.bar') → uses_config edge to "foo"
if (callee_name and callee_name in config.helper_fn_names):
args_node = node.child_by_field_name("arguments")
first_key: str | None = None
if args_node:
for arg in args_node.children:
if arg.type != "argument":
continue
for inner in arg.children:
if inner.type == "string":
for sc in inner.children:
if sc.type == "string_content":
first_key = _read_text(sc, source)
break
break
if first_key:
break
if first_key:
segment = first_key.split(".")[0]
tgt_nid = (label_to_nid.get(segment.lower())
or label_to_nid.get(f"{segment}.php".lower()))
if tgt_nid and tgt_nid != caller_nid:
relation = f"uses_{callee_name}"
pair3 = (caller_nid, tgt_nid, relation)
if pair3 not in seen_helper_ref_pairs:
seen_helper_ref_pairs.add(pair3)
line = node.start_point[0] + 1
edges.append({
"source": caller_nid,
"target": tgt_nid,
"relation": relation,
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
# Service container bindings: $this->app->bind(Foo::class, Bar::class)
if (node.type == "member_call_expression"
and callee_name
and callee_name in config.container_bind_methods):
args_node = node.child_by_field_name("arguments")
class_args: list[str] = []
if args_node:
for arg in args_node.children:
if arg.type != "argument":
continue
for inner in arg.children:
if inner.type == "class_constant_access_expression":
cls = _php_class_const_scope(inner)
if cls:
class_args.append(cls)
break
if len(class_args) >= 2:
break
if len(class_args) == 2:
contract_name, impl_name = class_args
contract_nid = label_to_nid.get(contract_name.lower())
impl_nid = label_to_nid.get(impl_name.lower())
if contract_nid and impl_nid and contract_nid != impl_nid:
pair3 = (contract_nid, impl_nid, "bound_to")
if pair3 not in seen_bind_pairs:
seen_bind_pairs.add(pair3)
line = node.start_point[0] + 1
edges.append({
"source": contract_nid,
"target": impl_nid,
"relation": "bound_to",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
# Static property access: Foo::$bar → uses_static_prop edge
if node.type in config.static_prop_types:
scope_node = node.child_by_field_name("scope")
if scope_node is None:
for child in node.children:
if child.is_named and child.type in ("name", "qualified_name", "identifier"):
scope_node = child
break
if scope_node is not None:
class_name = _read_text(scope_node, source)
tgt_nid = label_to_nid.get(class_name.lower())
if tgt_nid and tgt_nid != caller_nid:
pair3 = (caller_nid, tgt_nid, "uses_static_prop")
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": "uses_static_prop",
"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)
for caller_nid, body_node in function_bodies:
walk_calls(body_node, caller_nid)
# ── Event listener pass ───────────────────────────────────────────────────
seen_listen_pairs: set[tuple[str, str]] = set()
for event_name, listener_name, line in pending_listen_edges:
event_nid = label_to_nid.get(event_name.lower())
listener_nid = label_to_nid.get(listener_name.lower())
if not event_nid or not listener_nid or event_nid == listener_nid:
continue
pair2 = (event_nid, listener_nid)
if pair2 in seen_listen_pairs:
continue
seen_listen_pairs.add(pair2)
edges.append({
"source": event_nid,
"target": listener_nid,
"relation": "listened_by",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
# ── Clean edges ───────────────────────────────────────────────────────────
valid_ids = seen_ids
clean_edges = []
@@ -1212,6 +1411,59 @@ def extract_blade(path: Path) -> dict:
return {"nodes": nodes, "edges": edges}
def extract_dart(path: Path) -> dict:
"""Extract classes, mixins, functions, imports, and calls from a .dart file using regex."""
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"error": f"cannot read {path}"}
file_nid = _make_id(str(path))
nodes = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str(path), "source_location": None}]
edges = []
defined: set[str] = set()
# Classes and mixins
for m in re.finditer(r"^\s*(?:abstract\s+)?(?:class|mixin)\s+(\w+)", src, re.MULTILINE):
nid = _make_id(str(path), m.group(1))
if nid not in defined:
nodes.append({"id": nid, "label": m.group(1), "file_type": "code",
"source_file": str(path), "source_location": None})
edges.append({"source": file_nid, "target": nid, "relation": "defines",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
defined.add(nid)
# Top-level and member functions/methods
for m in re.finditer(r"^\s*(?:static\s+|async\s+)?(?:\w+\s+)+(\w+)\s*\(", src, re.MULTILINE):
name = m.group(1)
if name in {"if", "for", "while", "switch", "catch", "return"}:
continue
nid = _make_id(str(path), name)
if nid not in defined:
nodes.append({"id": nid, "label": name, "file_type": "code",
"source_file": str(path), "source_location": None})
edges.append({"source": file_nid, "target": nid, "relation": "defines",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
defined.add(nid)
# import 'package:...' or import '...'
for m in re.finditer(r"""^import\s+['"]([^'"]+)['"]""", src, re.MULTILINE):
pkg = m.group(1)
tgt_nid = _make_id(pkg)
if tgt_nid not in defined:
nodes.append({"id": tgt_nid, "label": pkg, "file_type": "code",
"source_file": str(path), "source_location": None})
defined.add(tgt_nid)
edges.append({"source": file_nid, "target": tgt_nid, "relation": "imports",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
return {"nodes": nodes, "edges": edges}
def extract_lua(path: Path) -> dict:
"""Extract functions, methods, require() imports, and calls from a .lua file."""
return _extract_generic(path, _LUA_CONFIG)
@@ -2695,6 +2947,7 @@ def extract(paths: list[Path]) -> dict:
".jl": extract_julia,
".vue": extract_js,
".svelte": extract_js,
".dart": extract_dart,
}
total = len(paths)
+13 -5
View File
@@ -39,12 +39,19 @@ def _communities_from_graph(G: nx.Graph) -> dict[int, list[str]]:
return communities
def _strip_diacritics(text: str) -> str:
import unicodedata
nfkd = unicodedata.normalize("NFKD", text)
return "".join(c for c in nfkd if not unicodedata.combining(c))
def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
scored = []
norm_terms = [_strip_diacritics(t).lower() for t in terms]
for nid, data in G.nodes(data=True):
label = data.get("label", "").lower()
norm_label = data.get("norm_label") or _strip_diacritics(data.get("label", "")).lower()
source = data.get("source_file", "").lower()
score = sum(1 for t in terms if t in label) + sum(0.5 for t in terms if t in source)
score = sum(1 for t in norm_terms if t in norm_label) + sum(0.5 for t in norm_terms if t in source)
if score > 0:
scored.append((score, nid))
return sorted(scored, reverse=True)
@@ -102,10 +109,11 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu
def _find_node(G: nx.Graph, label: str) -> list[str]:
"""Return node IDs whose label or ID matches the search term (case-insensitive)."""
term = label.lower()
"""Return node IDs whose label or ID matches the search term (diacritic-insensitive)."""
term = _strip_diacritics(label).lower()
return [nid for nid, d in G.nodes(data=True)
if term in d.get("label", "").lower() or term == nid.lower()]
if term in (d.get("norm_label") or _strip_diacritics(d.get("label", "")).lower())
or term == nid.lower()]
def _filter_blank_stdin() -> None:
+2 -2
View File
@@ -230,7 +230,7 @@ Load files from `.graphify_uncached.txt`. Split into chunks of 20-25 files each.
**Step B2 - Dispatch ALL subagents in a single message (Codex)**
> **Codex platform:** Uses `spawn_agent` + `wait` + `close_agent` instead of the Agent tool.
> **Codex platform:** Uses `spawn_agent` + `wait_agent` + `close_agent` instead of the Agent tool.
> Requires `multi_agent = true` under `[features]` in `~/.codex/config.toml`.
> If `spawn_agent` is unavailable, tell the user to add that config and restart Codex.
@@ -242,7 +242,7 @@ spawn_agent(agent_type="worker", message="Your task is to perform the following.
After all agents are dispatched, collect results sequentially:
```
result = wait(handle); close_agent(handle) # repeat per handle
result = wait_agent(handle); close_agent(handle) # repeat per handle
```
Parse each result as JSON. Accumulate nodes/edges/hyperedges across all results and write to `.graphify_semantic_new.json`.
+4 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
version = "0.4.8"
version = "0.4.9"
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, OpenClaw, Factory Droid, Trae) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
@@ -45,10 +45,11 @@ mcp = ["mcp"]
neo4j = ["neo4j"]
pdf = ["pypdf", "html2text"]
watch = ["watchdog"]
leiden = ["graspologic"]
svg = ["matplotlib"]
leiden = ["graspologic; python_version < '3.13'"]
office = ["python-docx", "openpyxl"]
video = ["faster-whisper", "yt-dlp"]
all = ["mcp", "neo4j", "pypdf", "html2text", "watchdog", "graspologic", "python-docx", "openpyxl", "faster-whisper", "yt-dlp"]
all = ["mcp", "neo4j", "pypdf", "html2text", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib"]
[project.scripts]
graphify = "graphify.__main__:main"
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Support;
class Throttle
{
public const int DEFAULT_PER_SECOND = 60;
public const int DEFAULT_PER_DAY = 10000;
}
class RateLimiter
{
public function perSecond(): int
{
return (int) config('throttle.api.per_second', 60);
}
public function perDay(): int
{
return (int) config('throttle.api.per_day', 10000);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Providers;
class PaymentGateway {}
class StripeGateway {}
class CashierGateway {}
class AppServiceProvider
{
public function register(): void
{
$this->app->bind(PaymentGateway::class, StripeGateway::class);
$this->app->singleton(CashierGateway::class, StripeGateway::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Providers;
class UserRegistered {}
class OrderPlaced {}
class SendWelcomeEmail {}
class NotifyAdmins {}
class ShipOrder {}
class EventServiceProvider
{
protected $listen = [
UserRegistered::class => [
SendWelcomeEmail::class,
NotifyAdmins::class,
],
OrderPlaced::class => [
ShipOrder::class,
],
];
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Theme;
class DefaultPalette
{
public static string $primary = '#3366ff';
public static string $accent = '#ff6633';
}
class ColorResolver
{
public function primary(): string
{
return DefaultPalette::$primary;
}
public function accent(): string
{
return DefaultPalette::$accent;
}
}
+9 -2
View File
@@ -1,4 +1,5 @@
"""Tests for hooks.py - git hook install/uninstall."""
import os
import subprocess
from pathlib import Path
import pytest
@@ -23,7 +24,10 @@ def test_install_is_executable(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
hook = repo / ".git" / "hooks" / "post-commit"
assert hook.stat().st_mode & 0o111 # executable bit set
if os.name == "nt":
assert hook.read_text(encoding="utf-8").startswith("#!/bin/sh\n")
else:
assert hook.stat().st_mode & 0o111 # executable bit set
def test_install_idempotent(tmp_path):
@@ -92,7 +96,10 @@ def test_install_post_checkout_is_executable(tmp_path):
repo = _make_git_repo(tmp_path)
install(repo)
hook = repo / ".git" / "hooks" / "post-checkout"
assert hook.stat().st_mode & 0o111
if os.name == "nt":
assert hook.read_text(encoding="utf-8").startswith("#!/bin/sh\n")
else:
assert hook.stat().st_mode & 0o111
def test_uninstall_removes_post_checkout_hook(tmp_path):
+3 -3
View File
@@ -116,9 +116,9 @@ def _agents_install(tmp_path, platform):
_install_fn(tmp_path, platform)
def _agents_uninstall(tmp_path):
def _agents_uninstall(tmp_path, platform=""):
from graphify.__main__ import _agents_uninstall as _uninstall_fn
_uninstall_fn(tmp_path)
_uninstall_fn(tmp_path, platform=platform)
def test_codex_agents_install_writes_agents_md(tmp_path):
@@ -218,7 +218,7 @@ def test_opencode_agents_uninstall_removes_plugin(tmp_path):
"""opencode uninstall removes the plugin file and deregisters from opencode.json."""
import json as _json
_agents_install(tmp_path, "opencode")
_agents_uninstall(tmp_path)
_agents_uninstall(tmp_path, platform="opencode")
plugin = tmp_path / ".opencode" / "plugins" / "graphify.js"
assert not plugin.exists()
config_file = tmp_path / "opencode.json"
+52
View File
@@ -234,6 +234,58 @@ def test_php_finds_imports():
r = extract_php(FIXTURES / "sample.php")
assert "imports" in _relations(r)
def test_php_finds_static_property_access():
r = extract_php(FIXTURES / "sample_php_static_prop.php")
assert "uses_static_prop" in _relations(r)
def test_php_static_prop_target_is_holding_class():
r = extract_php(FIXTURES / "sample_php_static_prop.php")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
uses_prop = [
(node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
for e in r["edges"] if e["relation"] == "uses_static_prop"
]
assert any("DefaultPalette" in tgt for _, tgt in uses_prop)
def test_php_finds_config_helper_call():
r = extract_php(FIXTURES / "sample_php_config.php")
assert "uses_config" in _relations(r)
def test_php_config_helper_target_matches_first_segment():
r = extract_php(FIXTURES / "sample_php_config.php")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
uses_cfg = [
(node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
for e in r["edges"] if e["relation"] == "uses_config"
]
assert any("Throttle" in tgt for _, tgt in uses_cfg)
def test_php_finds_container_bind():
r = extract_php(FIXTURES / "sample_php_container.php")
assert "bound_to" in _relations(r)
def test_php_container_bind_links_contract_to_implementation():
r = extract_php(FIXTURES / "sample_php_container.php")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
bound = [
(node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
for e in r["edges"] if e["relation"] == "bound_to"
]
assert any("PaymentGateway" in src and "StripeGateway" in tgt for src, tgt in bound)
def test_php_finds_event_listeners():
r = extract_php(FIXTURES / "sample_php_listen.php")
assert "listened_by" in _relations(r)
def test_php_event_listener_links_event_to_listener():
r = extract_php(FIXTURES / "sample_php_listen.php")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
listened = [
(node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
for e in r["edges"] if e["relation"] == "listened_by"
]
assert any("UserRegistered" in src and "SendWelcomeEmail" in tgt for src, tgt in listened)
# ── Swift ────────────────────────────────────────────────────────────────────