mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-21 13:05:41 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
1dd4c41413
commit
1fbcaf840f
@@ -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
@@ -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
@@ -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'}
|
||||
|
||||
@@ -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("'", "\\'")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Vendored
+22
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+22
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user