Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,7 @@ def _ruby_sanitize_method_name(name: str) -> str:
"enum_declaration",
"trait_declaration",
}),
function_types=frozenset({"function_definition", "method_declaration"}),
function_types=frozenset({"function_definition", "method_declaration", "anonymous_function_creation_expression", "arrow_function"}),
import_types=frozenset({"namespace_use_clause"}),
# object_creation_expression joins the dispatch set so `new Foo(...)` links
# the constructing method to Foo (engine has a dedicated PHP branch: the
Expand All @@ -1041,7 +1041,7 @@ def _ruby_sanitize_method_name(name: str) -> str:
# enums wrap their members in an enum_declaration_list rather than a
# declaration_list, so the body walk needs it to reach enum methods/cases.
body_fallback_child_types=("declaration_list", "compound_statement", "enum_declaration_list"),
function_boundary_types=frozenset({"function_definition", "method_declaration"}),
function_boundary_types=frozenset({"function_definition", "method_declaration", "anonymous_function_creation_expression", "arrow_function"}),
import_handler=_import_php,
)

Expand Down
77 changes: 76 additions & 1 deletion graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3072,6 +3072,67 @@ def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: st
del ruby_namespace[-len(const_segments):]
return True

_PHP_ROUTING_VERBS = frozenset({"get", "post", "put", "patch", "delete", "options", "any", "match", "map"})

def _php_get_route_name(closure_node, src: bytes) -> str | None:
"""Walk up the AST to extract grouped routing prefixes (#3409)."""
prefixes = []
verb = None

curr = closure_node.parent
while curr is not None:
if curr.type in ("function_definition", "method_declaration", "class_declaration"):
break

if curr.type == "argument":
arg_list = curr.parent
if arg_list is not None and arg_list.type == "argument_list":
call = arg_list.parent
if call is not None and call.type in ("member_call_expression", "function_call_expression", "scoped_call_expression"):
name_node = call.child_by_field_name("name")
if name_node is None:
name_node = call.child_by_field_name("function")

raw_method = (_read_text(name_node, src) if name_node else "").lower()
path_text = None

for sibling in arg_list.children:
if sibling is curr:
break
if sibling.type in ("string", "encapsed_string"):
path_text = _read_text(sibling, src).strip("'\"")
break

if verb is None:
# The innermost call must be a routing verb with a path starting with '/'
if path_text is not None and path_text.startswith("/") and raw_method in _PHP_ROUTING_VERBS:
verb = raw_method.upper()
prefixes.append(path_text)
else:
return None # Not a valid route closure
else:
# Outer calls (e.g. group(), prefix()) just contribute their prefix if it starts with '/'
if path_text is not None and path_text.startswith("/"):
prefixes.append(path_text)

curr = call.parent
continue

elif curr.type in ("anonymous_function_creation_expression", "arrow_function"):
# Jump across the closure boundary to its containing argument
curr = curr.parent
continue

curr = curr.parent

if verb and prefixes:
# prefixes are inside-out (innermost path is first)
# e.g. ['/users/{id}', '/api/v1'] -> '/api/v1/users/{id}'
full_path = "/" + "/".join(p.strip("/") for p in reversed(prefixes) if p.strip("/"))
return f"{verb} {full_path}"

return None

def _extract_generic(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_extract_generic()

fans out to 26 callees (efferent coupling); 18 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

path: Path, config: LanguageConfig, *, source_override: bytes | None = None
) -> dict:
Expand Down Expand Up @@ -3155,7 +3216,11 @@ def _extract_generic(
# walk_calls as extra_locals, so each closure sees only its own
# params/locals instead of a shared union that over-suppresses siblings.
closure_locals_by_body: dict[int, set[str]] = {}
# PHP only: ordinal counter for anonymous closures, keyed by scope id
# (parent_class_nid or stem). Stable across line-only edits (#3409).
php_closure_counts: dict[str, int] = {}
pending_listen_edges: list[tuple[str, str, int]] = []

# tree-sitter-swift parses both `class Foo` and `extension Foo` as
# `class_declaration`. Same-file pairs collapse via seen_ids, but cross-file
# extensions don't (file stem is part of the id), so they're collected here
Expand Down Expand Up @@ -4415,7 +4480,17 @@ def scala_base_name(type_node) -> str | None:
func_name = _read_text(name_node, source) if name_node else None

if not func_name:
return
if t in ("anonymous_function_creation_expression", "arrow_function"):
route_name = _php_get_route_name(node, source)
if route_name:
func_name = route_name
else:
# Stable ordinal scoped to the enclosing class/file (#3409)
_scope_key = parent_class_nid or stem
php_closure_counts[_scope_key] = php_closure_counts.get(_scope_key, 0) + 1
func_name = "{closure#" + str(php_closure_counts[_scope_key]) + "}"
else:
return
sanitized_name = (
config.sanitize_symbol_name_fn(func_name)
if config.sanitize_symbol_name_fn is not None
Expand Down
86 changes: 86 additions & 0 deletions tests/test_extract_php_closures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from pathlib import Path
from graphify.extract import extract_php
import pytest


def test_php_route_closure_gets_semantic_name(tmp_path):
"""A closure passed to a routing method gets a 'VERB /path' label."""
src = b"""<?php
$app->get('/api/users', function() {
return [];
});
$app->post('/api/users', function() {
return 'created';
});
"""
php_file = tmp_path / "routes.php"
php_file.write_bytes(src)

res = extract_php(php_file)
if res.get("error"):
pytest.skip(res["error"])

labels = {n["label"] for n in res["nodes"]}

assert "GET /api/users()" in labels, "Expected route closure to have semantic label 'GET /api/users()'"
assert "POST /api/users()" in labels, "Expected route closure to have semantic label 'POST /api/users()'"


def test_php_generic_closure_gets_ordinal_name(tmp_path):
"""Non-routing closures get stable ordinal names {closure#N}."""
src = b"""<?php
$fn1 = fn($x) => $x + 1;
$fn2 = function() { return 'hello'; };
"""
php_file = tmp_path / "closures.php"
php_file.write_bytes(src)

res = extract_php(php_file)
if res.get("error"):
pytest.skip(res["error"])

labels = {n["label"] for n in res["nodes"]}

# Ordinals, not line numbers
assert "{closure#1}()" in labels, "Expected first generic closure to be '{closure#1}()'"
assert "{closure#2}()" in labels, "Expected second generic closure to be '{closure#2}()'"
# Ensure no old line-based names leak through
assert not any("closure@" in l for l in labels), "Line-based closure names should not appear"


def test_php_nested_route_closure_composes_prefix(tmp_path):
"""A closure passed to a routing method inside a group() composes the path."""
src = b"""<?php
$app->group('/api/v1', function ($group) {
$group->get('/users/{id}', function ($req, $res) { return 1; });
});
"""
php_file = tmp_path / "nested_routes.php"
php_file.write_bytes(src)

res = extract_php(php_file)
if res.get("error"):
pytest.skip(res["error"])

labels = {n["label"] for n in res["nodes"]}
assert "GET /api/v1/users/{id}()" in labels, "Expected nested route closure to compose prefix"
assert "{closure#1}()" in labels, "Expected outer group closure to fallback to ordinal"


def test_php_cache_get_avoids_route_false_positive(tmp_path):
"""A get() call without a '/' path is treated as a generic closure, not a route."""
src = b"""<?php
$value = $cache->get('user:42', function () { return 2; });
"""
php_file = tmp_path / "cache.php"
php_file.write_bytes(src)

res = extract_php(php_file)
if res.get("error"):
pytest.skip(res["error"])

labels = {n["label"] for n in res["nodes"]}
assert "{closure#1}()" in labels, "Expected non-routing get() to fallback to ordinal"
assert not any(l.startswith("GET ") for l in labels), "Expected no route label for cache method"