From cd3fd9436e8d0e9c11d9e99453446dd5f91e8d70 Mon Sep 17 00:00:00 2001 From: nikhil2004 Date: Thu, 10 Sep 2026 03:56:57 +0530 Subject: [PATCH] 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. (cherry picked from commit c8c3af1960f55b261321d4fa6ba29552c56088bf) --- 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 45617905c..a37298f07 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1459,7 +1459,7 @@ _PHP_CONFIG = LanguageConfig( "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 @@ -1477,7 +1477,7 @@ _PHP_CONFIG = LanguageConfig( # 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 c016961ab..4df198bca 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4928,7 +4928,10 @@ def _extract_generic( 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" +