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)
|
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)
|
## 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)
|
- 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",
|
"skill_dst": Path(".trae-cn") / "skills" / "graphify" / "SKILL.md",
|
||||||
"claude_md": False,
|
"claude_md": False,
|
||||||
},
|
},
|
||||||
|
"hermes": {
|
||||||
|
"skill_file": "skill-claw.md",
|
||||||
|
"skill_dst": Path(".hermes") / "skills" / "graphify" / "SKILL.md",
|
||||||
|
"claude_md": False,
|
||||||
|
},
|
||||||
"antigravity": {
|
"antigravity": {
|
||||||
"skill_file": "skill.md",
|
"skill_file": "skill.md",
|
||||||
"skill_dst": Path(".agent") / "skills" / "graphify" / "SKILL.md",
|
"skill_dst": Path(".agent") / "skills" / "graphify" / "SKILL.md",
|
||||||
@@ -110,11 +115,11 @@ def install(platform: str = "claude") -> None:
|
|||||||
gemini_install()
|
gemini_install()
|
||||||
return
|
return
|
||||||
if platform == "cursor":
|
if platform == "cursor":
|
||||||
_cursor_install()
|
_cursor_install(Path("."))
|
||||||
return
|
return
|
||||||
if platform not in _PLATFORM_CONFIG:
|
if platform not in _PLATFORM_CONFIG:
|
||||||
print(
|
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,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -314,6 +319,7 @@ This project has a graphify knowledge graph at graphify-out/.
|
|||||||
Rules:
|
Rules:
|
||||||
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
|
- 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 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 `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
|
# 1. Copy skill file to ~/.agent/skills/graphify/SKILL.md
|
||||||
install(platform="antigravity")
|
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
|
# 2. Write .agent/rules/graphify.md
|
||||||
rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
|
rules_path = project_dir / _ANTIGRAVITY_RULES_PATH
|
||||||
rules_path.parent.mkdir(parents=True, exist_ok=True)
|
rules_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -355,6 +369,12 @@ def _antigravity_install(project_dir: Path) -> None:
|
|||||||
print()
|
print()
|
||||||
print("Antigravity will now check the knowledge graph before answering")
|
print("Antigravity will now check the knowledge graph before answering")
|
||||||
print("codebase questions. Run /graphify first to build the graph.")
|
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:
|
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.")
|
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."""
|
"""Remove the graphify section from the local AGENTS.md."""
|
||||||
target = (project_dir or Path(".")) / "AGENTS.md"
|
target = (project_dir or Path(".")) / "AGENTS.md"
|
||||||
|
|
||||||
@@ -620,7 +640,8 @@ def _agents_uninstall(project_dir: Path) -> None:
|
|||||||
target.unlink()
|
target.unlink()
|
||||||
print(f"AGENTS.md was empty after removal - deleted {target.resolve()}")
|
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:
|
def claude_install(project_dir: Path | None = None) -> None:
|
||||||
@@ -837,12 +858,12 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
print("Usage: graphify copilot [install|uninstall]", file=sys.stderr)
|
print("Usage: graphify copilot [install|uninstall]", file=sys.stderr)
|
||||||
sys.exit(1)
|
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 ""
|
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
if subcmd == "install":
|
if subcmd == "install":
|
||||||
_agents_install(Path("."), cmd)
|
_agents_install(Path("."), cmd)
|
||||||
elif subcmd == "uninstall":
|
elif subcmd == "uninstall":
|
||||||
_agents_uninstall(Path("."))
|
_agents_uninstall(Path("."), platform=cmd)
|
||||||
if cmd == "codex":
|
if cmd == "codex":
|
||||||
_uninstall_codex_hook(Path("."))
|
_uninstall_codex_hook(Path("."))
|
||||||
else:
|
else:
|
||||||
@@ -901,8 +922,6 @@ def main() -> None:
|
|||||||
graph_path = args[i + 1]; i += 2
|
graph_path = args[i + 1]; i += 2
|
||||||
else:
|
else:
|
||||||
i += 1
|
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()
|
gp = Path(graph_path).resolve()
|
||||||
if not gp.exists():
|
if not gp.exists():
|
||||||
print(f"error: graph file not found: {gp}", file=sys.stderr)
|
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"
|
_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'}
|
DOC_EXTENSIONS = {'.md', '.txt', '.rst'}
|
||||||
PAPER_EXTENSIONS = {'.pdf'}
|
PAPER_EXTENSIONS = {'.pdf'}
|
||||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
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.security import sanitize_label
|
||||||
from graphify.analyze import _node_community_map
|
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 = [
|
COMMUNITY_COLORS = [
|
||||||
"#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
|
"#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
|
||||||
"#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
|
"#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)
|
data = json_graph.node_link_data(G)
|
||||||
for node in data["nodes"]:
|
for node in data["nodes"]:
|
||||||
node["community"] = node_community.get(node["id"])
|
node["community"] = node_community.get(node["id"])
|
||||||
|
node["norm_label"] = _strip_diacritics(node.get("label", "")).lower()
|
||||||
for link in data["links"]:
|
for link in data["links"]:
|
||||||
if "confidence_score" not in link:
|
if "confidence_score" not in link:
|
||||||
conf = link.get("confidence", "EXTRACTED")
|
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)
|
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:
|
def _cypher_escape(s: str) -> str:
|
||||||
"""Escape a string for safe embedding in a Cypher single-quoted literal."""
|
"""Escape a string for safe embedding in a Cypher single-quoted literal."""
|
||||||
return s.replace("\\", "\\\\").replace("'", "\\'")
|
return s.replace("\\", "\\\\").replace("'", "\\'")
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class LanguageConfig:
|
|||||||
function_types: frozenset = frozenset()
|
function_types: frozenset = frozenset()
|
||||||
import_types: frozenset = frozenset()
|
import_types: frozenset = frozenset()
|
||||||
call_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 extraction
|
||||||
name_field: str = "name"
|
name_field: str = "name"
|
||||||
@@ -560,6 +564,10 @@ _PHP_CONFIG = LanguageConfig(
|
|||||||
function_types=frozenset({"function_definition", "method_declaration"}),
|
function_types=frozenset({"function_definition", "method_declaration"}),
|
||||||
import_types=frozenset({"namespace_use_clause"}),
|
import_types=frozenset({"namespace_use_clause"}),
|
||||||
call_types=frozenset({"function_call_expression", "member_call_expression"}),
|
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_function_field="function",
|
||||||
call_accessor_node_types=frozenset({"member_call_expression"}),
|
call_accessor_node_types=frozenset({"member_call_expression"}),
|
||||||
call_accessor_field="name",
|
call_accessor_field="name",
|
||||||
@@ -673,6 +681,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
|||||||
edges: list[dict] = []
|
edges: list[dict] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
function_bodies: list[tuple[str, object]] = []
|
function_bodies: list[tuple[str, object]] = []
|
||||||
|
pending_listen_edges: list[tuple[str, str, int]] = []
|
||||||
|
|
||||||
def add_node(nid: str, label: str, line: int) -> None:
|
def add_node(nid: str, label: str, line: int) -> None:
|
||||||
if nid not in seen_ids:
|
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)
|
walk(child, parent_class_nid=class_nid)
|
||||||
return
|
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
|
# Function types
|
||||||
if t in config.function_types:
|
if t in config.function_types:
|
||||||
# Swift deinit/subscript have no name field — resolve before generic fallback
|
# 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"]
|
label_to_nid[normalised.lower()] = n["id"]
|
||||||
|
|
||||||
seen_call_pairs: set[tuple[str, str]] = set()
|
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:
|
def walk_calls(node, caller_nid: str) -> None:
|
||||||
if node.type in config.function_boundary_types:
|
if node.type in config.function_boundary_types:
|
||||||
@@ -986,12 +1060,137 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
|||||||
"weight": 1.0,
|
"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:
|
for child in node.children:
|
||||||
walk_calls(child, caller_nid)
|
walk_calls(child, caller_nid)
|
||||||
|
|
||||||
for caller_nid, body_node in function_bodies:
|
for caller_nid, body_node in function_bodies:
|
||||||
walk_calls(body_node, caller_nid)
|
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 ───────────────────────────────────────────────────────────
|
# ── Clean edges ───────────────────────────────────────────────────────────
|
||||||
valid_ids = seen_ids
|
valid_ids = seen_ids
|
||||||
clean_edges = []
|
clean_edges = []
|
||||||
@@ -1212,6 +1411,59 @@ def extract_blade(path: Path) -> dict:
|
|||||||
return {"nodes": nodes, "edges": edges}
|
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:
|
def extract_lua(path: Path) -> dict:
|
||||||
"""Extract functions, methods, require() imports, and calls from a .lua file."""
|
"""Extract functions, methods, require() imports, and calls from a .lua file."""
|
||||||
return _extract_generic(path, _LUA_CONFIG)
|
return _extract_generic(path, _LUA_CONFIG)
|
||||||
@@ -2695,6 +2947,7 @@ def extract(paths: list[Path]) -> dict:
|
|||||||
".jl": extract_julia,
|
".jl": extract_julia,
|
||||||
".vue": extract_js,
|
".vue": extract_js,
|
||||||
".svelte": extract_js,
|
".svelte": extract_js,
|
||||||
|
".dart": extract_dart,
|
||||||
}
|
}
|
||||||
|
|
||||||
total = len(paths)
|
total = len(paths)
|
||||||
|
|||||||
+13
-5
@@ -39,12 +39,19 @@ def _communities_from_graph(G: nx.Graph) -> dict[int, list[str]]:
|
|||||||
return communities
|
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]]:
|
def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
|
||||||
scored = []
|
scored = []
|
||||||
|
norm_terms = [_strip_diacritics(t).lower() for t in terms]
|
||||||
for nid, data in G.nodes(data=True):
|
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()
|
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:
|
if score > 0:
|
||||||
scored.append((score, nid))
|
scored.append((score, nid))
|
||||||
return sorted(scored, reverse=True)
|
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]:
|
def _find_node(G: nx.Graph, label: str) -> list[str]:
|
||||||
"""Return node IDs whose label or ID matches the search term (case-insensitive)."""
|
"""Return node IDs whose label or ID matches the search term (diacritic-insensitive)."""
|
||||||
term = label.lower()
|
term = _strip_diacritics(label).lower()
|
||||||
return [nid for nid, d in G.nodes(data=True)
|
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:
|
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)**
|
**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`.
|
> 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.
|
> 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:
|
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`.
|
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]
|
[project]
|
||||||
name = "graphifyy"
|
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"
|
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"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
@@ -45,10 +45,11 @@ mcp = ["mcp"]
|
|||||||
neo4j = ["neo4j"]
|
neo4j = ["neo4j"]
|
||||||
pdf = ["pypdf", "html2text"]
|
pdf = ["pypdf", "html2text"]
|
||||||
watch = ["watchdog"]
|
watch = ["watchdog"]
|
||||||
leiden = ["graspologic"]
|
svg = ["matplotlib"]
|
||||||
|
leiden = ["graspologic; python_version < '3.13'"]
|
||||||
office = ["python-docx", "openpyxl"]
|
office = ["python-docx", "openpyxl"]
|
||||||
video = ["faster-whisper", "yt-dlp"]
|
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]
|
[project.scripts]
|
||||||
graphify = "graphify.__main__:main"
|
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."""
|
"""Tests for hooks.py - git hook install/uninstall."""
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
@@ -23,7 +24,10 @@ def test_install_is_executable(tmp_path):
|
|||||||
repo = _make_git_repo(tmp_path)
|
repo = _make_git_repo(tmp_path)
|
||||||
install(repo)
|
install(repo)
|
||||||
hook = repo / ".git" / "hooks" / "post-commit"
|
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):
|
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)
|
repo = _make_git_repo(tmp_path)
|
||||||
install(repo)
|
install(repo)
|
||||||
hook = repo / ".git" / "hooks" / "post-checkout"
|
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):
|
def test_uninstall_removes_post_checkout_hook(tmp_path):
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ def _agents_install(tmp_path, platform):
|
|||||||
_install_fn(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
|
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):
|
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."""
|
"""opencode uninstall removes the plugin file and deregisters from opencode.json."""
|
||||||
import json as _json
|
import json as _json
|
||||||
_agents_install(tmp_path, "opencode")
|
_agents_install(tmp_path, "opencode")
|
||||||
_agents_uninstall(tmp_path)
|
_agents_uninstall(tmp_path, platform="opencode")
|
||||||
plugin = tmp_path / ".opencode" / "plugins" / "graphify.js"
|
plugin = tmp_path / ".opencode" / "plugins" / "graphify.js"
|
||||||
assert not plugin.exists()
|
assert not plugin.exists()
|
||||||
config_file = tmp_path / "opencode.json"
|
config_file = tmp_path / "opencode.json"
|
||||||
|
|||||||
@@ -234,6 +234,58 @@ def test_php_finds_imports():
|
|||||||
r = extract_php(FIXTURES / "sample.php")
|
r = extract_php(FIXTURES / "sample.php")
|
||||||
assert "imports" in _relations(r)
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Swift ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user