diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 4df198bca..036fec858 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3554,6 +3554,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 @@ -4929,7 +4932,48 @@ def _extract_generic( 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 pathlib import Path 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 []; }); -$fn = fn($x) => $x + 1; -''' - php_file = tmp_path / 'dummy.php' +$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""" $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']) - - 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"]} + + # 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" +