diff --git a/CHANGELOG.md b/CHANGELOG.md index feb7cbfa..fae7b427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/__main__.py b/graphify/__main__.py index eaa13c59..ae185c22 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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) diff --git a/graphify/detect.py b/graphify/detect.py index 721c0d47..0555ce42 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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'} diff --git a/graphify/export.py b/graphify/export.py index 7ed922b7..f0ee66ba 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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("'", "\\'") diff --git a/graphify/extract.py b/graphify/extract.py index 24e1001a..52183c4e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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) diff --git a/graphify/serve.py b/graphify/serve.py index a0778343..24723717 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -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: diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md index dec6c7b1..d16d4986 100644 --- a/graphify/skill-codex.md +++ b/graphify/skill-codex.md @@ -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`. diff --git a/pyproject.toml b/pyproject.toml index f9d4e20a..110216f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/fixtures/sample_php_config.php b/tests/fixtures/sample_php_config.php new file mode 100644 index 00000000..48800c01 --- /dev/null +++ b/tests/fixtures/sample_php_config.php @@ -0,0 +1,22 @@ +app->bind(PaymentGateway::class, StripeGateway::class); + $this->app->singleton(CashierGateway::class, StripeGateway::class); + } +} diff --git a/tests/fixtures/sample_php_listen.php b/tests/fixtures/sample_php_listen.php new file mode 100644 index 00000000..fdb95ca0 --- /dev/null +++ b/tests/fixtures/sample_php_listen.php @@ -0,0 +1,22 @@ + [ + SendWelcomeEmail::class, + NotifyAdmins::class, + ], + OrderPlaced::class => [ + ShipOrder::class, + ], + ]; +} diff --git a/tests/fixtures/sample_php_static_prop.php b/tests/fixtures/sample_php_static_prop.php new file mode 100644 index 00000000..999b79ca --- /dev/null +++ b/tests/fixtures/sample_php_static_prop.php @@ -0,0 +1,22 @@ +