From c8c3af1960f55b261321d4fa6ba29552c56088bf Mon Sep 17 00:00:00 2001 From: nikhil2004 Date: Thu, 10 Sep 2026 03:56:57 +0530 Subject: [PATCH 1/4] feat(extract): support PHP closures (#3409) The bug: PHP closures (`anonymous_function_creation_expression` and `arrow_function`) were invisible to the graph, disconnecting API routes and callbacks from the backend code. The fix: - Added closure syntax node types to _PHP_CONFIG's `function_types`. - Updated engine.py to synthesize line-bound names (e.g., `{closure@42}`) for unnamed closures matching these AST node types, enabling them to be extracted as linkable nodes in the graph without ID collisions. --- graphify/extract.py | 4 ++-- graphify/extractors/engine.py | 5 ++++- tests/test_extract_php_closures.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 tests/test_extract_php_closures.py diff --git a/graphify/extract.py b/graphify/extract.py index 32c59484d..d179b9b86 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 @@ -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, ) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 6b0dbf87d..676239857 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4415,7 +4415,10 @@ 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"): + func_name = f"{{closure@{node.start_point[0] + 1}}}" + else: + return sanitized_name = ( config.sanitize_symbol_name_fn(func_name) if config.sanitize_symbol_name_fn is not None diff --git a/tests/test_extract_php_closures.py b/tests/test_extract_php_closures.py new file mode 100644 index 000000000..7b9350f49 --- /dev/null +++ b/tests/test_extract_php_closures.py @@ -0,0 +1,33 @@ +from pathlib import Path +from graphify.extract import extract_php +import pytest + +def test_php_closures(tmp_path): + src = b'''get('/api', function() { + return 1; +}); +$fn = fn($x) => $x + 1; +''' + php_file = tmp_path / 'dummy.php' + php_file.write_bytes(src) + + res = extract_php(php_file) + if res.get('error'): + pytest.skip(res['error']) + + nodes = {n['id']: n for n in res['nodes']} + labels = {n['label']: n for n in res['nodes']} + + # Assert closures are correctly generated + assert '{closure@2}()' in labels, "Missing anonymous_function_creation_expression node" + assert '{closure@5}()' in labels, "Missing arrow_function node" + + closure_1 = labels['{closure@2}()'] + closure_2 = labels['{closure@5}()'] + + # Check edges + edges = res['edges'] + assert any(e['source'] == 'dummy.php' and e['target'] == closure_1['id'] and e['relation'] == 'contains' for e in edges), "Missing contains edge for closure_1" + assert any(e['source'] == 'dummy.php' and e['target'] == closure_2['id'] and e['relation'] == 'contains' for e in edges), "Missing contains edge for closure_2" + From 5400b9cbd7c18b9e0325a7028adc80be4c0da168 Mon Sep 17 00:00:00 2001 From: nikhil2004 Date: Thu, 10 Sep 2026 12:32:04 +0530 Subject: [PATCH 2/4] fix(extract): stable closure naming for PHP (#3409) Address reviewer feedback: - Replace line-based {closure@N} names with stable ordinal {closure#N} names scoped per enclosing class or file. Ordinals are unaffected by line insertions/deletions above a closure; only reordering closures or inserting one earlier in the same scope shifts later ordinals, which is a much rarer edit. - Route closures passed to routing verbs (get/post/put/patch/delete/ options/any/match/map) receive a semantic 'VERB /path' label that is both stable and directly meaningful to graph queries (the primary use-case from the issue). Note: adding anonymous_function_creation_expression and arrow_function to function_boundary_types (introduced in the first commit) means calls inside a closure now attribute to the closure rather than the enclosing named function. This is the correct behaviour; existing codebases with closures inside named functions will see those edges move on next incremental update. --- graphify/extractors/engine.py | 46 +++++++++++++++++++- tests/test_extract_php_closures.py | 67 +++++++++++++++++++----------- 2 files changed, 87 insertions(+), 26 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 676239857..5351243d5 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3155,6 +3155,9 @@ 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 @@ -4416,7 +4419,48 @@ def scala_base_name(type_node) -> str | None: if not func_name: if t in ("anonymous_function_creation_expression", "arrow_function"): - func_name = f"{{closure@{node.start_point[0] + 1}}}" + # Prefer a route-derived name when the closure is passed + # directly to a routing method (`$app->get('/api', fn)`): + # walk up to find a sibling string argument and the method name. + route_name: str | None = None + _PHP_ROUTING_VERBS = frozenset({ + "get", "post", "put", "patch", "delete", + "options", "any", "match", "map", + }) + parent = node.parent # argument node + if parent is not None and parent.type == "argument": + arg_list = parent.parent # argument_list + if arg_list is not None and arg_list.type == "argument_list": + call_node = arg_list.parent + # extract method/function name from the call + if call_node is not None and call_node.type in ( + "member_call_expression", "function_call_expression" + ): + method_name_node = call_node.child_by_field_name("name") + if method_name_node is None: + method_name_node = call_node.child_by_field_name("function") + raw_method = ( + _read_text(method_name_node, source) + if method_name_node else "" + ).lower() + if raw_method in _PHP_ROUTING_VERBS: + # look for the first string argument (the path) + for sibling in arg_list.children: + if sibling is parent: + break + if sibling.type in ( + "string", "encapsed_string" + ): + path_text = _read_text(sibling, source).strip("'\"") + route_name = f"{raw_method.upper()} {path_text}" + break + 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 = ( diff --git a/tests/test_extract_php_closures.py b/tests/test_extract_php_closures.py index 7b9350f49..d94b018dc 100644 --- a/tests/test_extract_php_closures.py +++ b/tests/test_extract_php_closures.py @@ -2,32 +2,49 @@ from graphify.extract import extract_php import pytest -def test_php_closures(tmp_path): - src = b'''get('/api', function() { - return 1; + +def test_php_route_closure_gets_semantic_name(tmp_path): + """A closure passed to a routing method gets a 'VERB /path' label.""" + src = b"""get('/api/users', function() { + return []; +}); +$app->post('/api/users', function() { + return 'created'; }); -$fn = fn($x) => $x + 1; -''' - php_file = tmp_path / 'dummy.php' +""" + php_file = tmp_path / "routes.php" php_file.write_bytes(src) - + res = extract_php(php_file) - if res.get('error'): - pytest.skip(res['error']) - - nodes = {n['id']: n for n in res['nodes']} - labels = {n['label']: n for n in res['nodes']} - - # Assert closures are correctly generated - assert '{closure@2}()' in labels, "Missing anonymous_function_creation_expression node" - assert '{closure@5}()' in labels, "Missing arrow_function node" - - closure_1 = labels['{closure@2}()'] - closure_2 = labels['{closure@5}()'] - - # Check edges - edges = res['edges'] - assert any(e['source'] == 'dummy.php' and e['target'] == closure_1['id'] and e['relation'] == 'contains' for e in edges), "Missing contains edge for closure_1" - assert any(e['source'] == 'dummy.php' and e['target'] == closure_2['id'] and e['relation'] == 'contains' for e in edges), "Missing contains edge for closure_2" + 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""" $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" + From 38861aa1c4fedde3134a60da51c9d5bba542bdc6 Mon Sep 17 00:00:00 2001 From: nikhil2004 Date: Thu, 10 Sep 2026 13:17:40 +0530 Subject: [PATCH 3/4] fix(extract): robust PHP nested routing closure extraction (#3409) Address edge cases identified in review: - Nested route closures (e.g., inside group() or prefix()) now correctly compose their full path by walking up the AST across closure boundaries. - Non-routing closures passed to methods that share verbs (like ->get('user:42')) are no longer misidentified as routes. A route path must begin with '/' to qualify, otherwise it correctly falls back to an ordinal name. --- graphify/extractors/engine.py | 97 +++++++++++++++++++----------- tests/test_extract_php_closures.py | 36 +++++++++++ 2 files changed, 98 insertions(+), 35 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 5351243d5..7a871e2f1 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3159,6 +3159,67 @@ def _extract_generic( # (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]] = [] + + def _php_get_route_name(closure_node, src: bytes) -> str | None: + """Walk up the AST to extract grouped routing prefixes (#3409).""" + _routing_verbs = frozenset({"get", "post", "put", "patch", "delete", "options", "any", "match", "map"}) + 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 _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 + # 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 @@ -4419,41 +4480,7 @@ def scala_base_name(type_node) -> str | None: if not func_name: if t in ("anonymous_function_creation_expression", "arrow_function"): - # Prefer a route-derived name when the closure is passed - # directly to a routing method (`$app->get('/api', fn)`): - # walk up to find a sibling string argument and the method name. - route_name: str | None = None - _PHP_ROUTING_VERBS = frozenset({ - "get", "post", "put", "patch", "delete", - "options", "any", "match", "map", - }) - parent = node.parent # argument node - if parent is not None and parent.type == "argument": - arg_list = parent.parent # argument_list - if arg_list is not None and arg_list.type == "argument_list": - call_node = arg_list.parent - # extract method/function name from the call - if call_node is not None and call_node.type in ( - "member_call_expression", "function_call_expression" - ): - method_name_node = call_node.child_by_field_name("name") - if method_name_node is None: - method_name_node = call_node.child_by_field_name("function") - raw_method = ( - _read_text(method_name_node, source) - if method_name_node else "" - ).lower() - if raw_method in _PHP_ROUTING_VERBS: - # look for the first string argument (the path) - for sibling in arg_list.children: - if sibling is parent: - break - if sibling.type in ( - "string", "encapsed_string" - ): - path_text = _read_text(sibling, source).strip("'\"") - route_name = f"{raw_method.upper()} {path_text}" - break + route_name = _php_get_route_name(node, source) if route_name: func_name = route_name else: diff --git a/tests/test_extract_php_closures.py b/tests/test_extract_php_closures.py index d94b018dc..1d8f5a2d3 100644 --- a/tests/test_extract_php_closures.py +++ b/tests/test_extract_php_closures.py @@ -48,3 +48,39 @@ def test_php_generic_closure_gets_ordinal_name(tmp_path): 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"""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"""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" + + From e845f15b688056832c245ed8dbccc2e0e132c936 Mon Sep 17 00:00:00 2001 From: nikhil2004 Date: Thu, 10 Sep 2026 17:34:05 +0530 Subject: [PATCH 4/4] refactor(extract): hoist PHP route helper to module level (#3409) Address maintainer nits by moving _php_get_route_name and the _PHP_ROUTING_VERBS frozenset out of the _extract_generic body to the module level, preventing them from being repeatedly re-initialized per file on large codebases. --- graphify/extractors/engine.py | 121 +++++++++++++++++----------------- 1 file changed, 61 insertions(+), 60 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 7a871e2f1..146b7bd18 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -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( path: Path, config: LanguageConfig, *, source_override: bytes | None = None ) -> dict: @@ -3160,66 +3221,6 @@ def _extract_generic( php_closure_counts: dict[str, int] = {} pending_listen_edges: list[tuple[str, str, int]] = [] - def _php_get_route_name(closure_node, src: bytes) -> str | None: - """Walk up the AST to extract grouped routing prefixes (#3409).""" - _routing_verbs = frozenset({"get", "post", "put", "patch", "delete", "options", "any", "match", "map"}) - 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 _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 - # 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