diff --git a/libs/openant-core/parsers/php/function_extractor.py b/libs/openant-core/parsers/php/function_extractor.py index 7000f874..1e29b17b 100644 --- a/libs/openant-core/parsers/php/function_extractor.py +++ b/libs/openant-core/parsers/php/function_extractor.py @@ -131,6 +131,21 @@ def _get_parameters(self, node, source: bytes) -> List[str]: return params + def _get_attributes(self, node, source: bytes) -> List[str]: + """Extract PHP 8 attributes (`#[Route(...)]`, `#[Get]`, ...) as strings. + + Attributes decorate a method/function via one or more `attribute_list` + children (`#[...]`). They carry routing/framework semantics (Symfony / + API-Platform `#[Route]`, `#[Get]`, `#[Post]`), so they are stored under + `decorators` — mirroring the Python/JS extractors — for the entry-point + detector to classify routed methods regardless of the class name. + """ + attributes: List[str] = [] + for child in node.children: + if child.type == 'attribute_list': + attributes.append(self._node_text(child, source)) + return attributes + def _is_static_method(self, node, source: bytes) -> bool: """Check if a method_declaration has a static modifier.""" for child in node.children: @@ -670,6 +685,7 @@ def _process_function_node(self, node, source: bytes, relative_path: str, 'parameters': parameters, 'is_static': is_static, 'unit_type': unit_type, + 'decorators': self._get_attributes(node, source), } self._store_function(func_id, func_data) diff --git a/libs/openant-core/parsers/php/test_pipeline.py b/libs/openant-core/parsers/php/test_pipeline.py index 9d64e4e5..e3db259d 100644 --- a/libs/openant-core/parsers/php/test_pipeline.py +++ b/libs/openant-core/parsers/php/test_pipeline.py @@ -263,6 +263,12 @@ def apply_reachability_filter(self) -> bool: 'endLine': func_data.get('endLine', func_data.get('end_line', 0)), 'isExported': func_data.get('isExported', True), 'isSingleton': func_data.get('isSingleton', func_data.get('is_singleton', False)), + # Carry decorators/attributes so EntryPointDetector Check-1c + # (PHP 8 #[Route] attribute routing) and Check-2 (decorator + # patterns) fire on this per-parser reachable path — the + # whitelist previously dropped them, silently disabling + # attribute-based entry-point detection here. + 'decorators': func_data.get('decorators', []), } # Build call graph from dataset unit metadata diff --git a/libs/openant-core/parsers/php/unit_generator.py b/libs/openant-core/parsers/php/unit_generator.py index c787f71b..383acc1d 100644 --- a/libs/openant-core/parsers/php/unit_generator.py +++ b/libs/openant-core/parsers/php/unit_generator.py @@ -316,6 +316,9 @@ def generate_analyzer_output(self) -> Dict: 'isStatic': func_data.get('is_static', False), 'parameters': func_data.get('parameters', []), 'className': func_data.get('class_name'), + # Carry PHP 8 attributes (#[Route], ...) so the reachable pipeline's + # EntryPointDetector can seed attribute-routed handlers. + 'decorators': func_data.get('decorators', []), } return { diff --git a/libs/openant-core/tests/parsers/php/test_php_extractor.py b/libs/openant-core/tests/parsers/php/test_php_extractor.py index 0d4e9d71..6bb69a44 100644 --- a/libs/openant-core/tests/parsers/php/test_php_extractor.py +++ b/libs/openant-core/tests/parsers/php/test_php_extractor.py @@ -262,6 +262,83 @@ def test_closure_units_capture_their_body(tmp_path): # --------------------------------------------------------------------------- +# PHP 8 #[Route] attribute handlers on a NON-*Controller class must seed (B11) +# --------------------------------------------------------------------------- + +# Symfony/API-Platform style: the handler lives on a class that is NOT named +# *Controller and the file path contains no 'controller' segment, so the +# extractor's name/path-based route_handler classifier misses it. The routing +# attribute (#[Route]) is what makes it an endpoint. The body reads user input +# via the Symfony request bag ($request->query->get(...)). +ROUTE_ATTRIBUTE_SOURCE = """query->get("x"); + return $q; + } +} +""" + + +def test_php_route_attribute_captured_by_extractor(tmp_path): + """The extractor must capture #[Route] into func_data['decorators'].""" + result = _extract(tmp_path, "ProductApi.php", ROUTE_ATTRIBUTE_SOURCE) + show = next(fd for fd in result["functions"].values() if fd["name"] == "show") + decorators = show.get("decorators", []) + assert any("Route" in d for d in decorators), ( + "extractor must capture the #[Route] PHP8 attribute into " + f"func_data['decorators']; got decorators={decorators!r}" + ) + + +def test_php_route_attribute_handler_is_entry_point(tmp_path): + """A #[Route] method on a non-*Controller class must seed as an entry point.""" + result = _extract(tmp_path, "ProductApi.php", ROUTE_ATTRIBUTE_SOURCE) + + # Sanity: on the buggy path this method is classified as a plain 'method' + # (class 'ProductApi' has no 'controller', path has no 'controller'). + show = next(fd for fd in result["functions"].values() if fd["name"] == "show") + assert show["class_name"] == "ProductApi" + + detector = EntryPointDetector(result["functions"], {}) + detector.detect_entry_points() + show_id = next( + fid for fid, fd in result["functions"].items() if fd["name"] == "show" + ) + assert detector.is_entry_point(show_id), ( + "ProductApi::show carries a #[Route] attribute and must be seeded as an " + "entry point INDEPENDENT of the class name; " + f"reason={detector.get_entry_point_reason(show_id)!r}" + ) + + +def test_symfony_request_query_get_is_input_pattern(tmp_path): + """$request->query->get( / $request->request->get( must count as user input.""" + src = """query->get("q"); + $y = $request->request->get("p"); + return $x . $y; + } +} +""" + result = _extract(tmp_path, "Widget.php", src) + detector = EntryPointDetector(result["functions"], {}) + detector.detect_entry_points() + build_id = next( + fid for fid, fd in result["functions"].items() if fd["name"] == "build" + ) + assert detector.is_entry_point(build_id), ( + "Widget::build reads Symfony request bags " + "($request->query->get / $request->request->get) and must be flagged as " + f"an entry point; reason={detector.get_entry_point_reason(build_id)!r}" + ) + + # A named function nested in a method body is GLOBAL in PHP (bug B7) # --------------------------------------------------------------------------- diff --git a/libs/openant-core/tests/parsers/php/test_php_route_attribute_pipeline.py b/libs/openant-core/tests/parsers/php/test_php_route_attribute_pipeline.py new file mode 100644 index 00000000..8efa6c81 --- /dev/null +++ b/libs/openant-core/tests/parsers/php/test_php_route_attribute_pipeline.py @@ -0,0 +1,60 @@ +"""End-to-end regression for PHP 8 #[Route] attribute entry-point seeding +through the php *reachable* pipeline. + +The per-parser reachable path (parsers/php/test_pipeline.py) rebuilds every unit +through a field whitelist before running EntryPointDetector. That whitelist +dropped 'decorators', so a #[Route] handler on a class NOT named *Controller +seeded ZERO entry points on this path and was pruned by the reachability filter +(Units: 1 -> 0) — while the raw-detector unit tests stayed green. This drives the +whole pipeline (parse_repository -> normalization -> detector -> reachability +filter) so the fix is exercised on the shipped path. +""" +import json +import sys +from pathlib import Path + +CORE = Path(__file__).resolve().parents[3] +if str(CORE) not in sys.path: + sys.path.insert(0, str(CORE)) + +from core.parser_adapter import parse_repository # noqa: E402 + +ROUTE_PHP = ( + " it was not " + "seeded (decorators dropped by the pipeline normalization whitelist)" + ) diff --git a/libs/openant-core/utilities/agentic_enhancer/entry_point_detector.py b/libs/openant-core/utilities/agentic_enhancer/entry_point_detector.py index 41dbd6c9..20e3f481 100644 --- a/libs/openant-core/utilities/agentic_enhancer/entry_point_detector.py +++ b/libs/openant-core/utilities/agentic_enhancer/entry_point_detector.py @@ -88,6 +88,19 @@ def _unit_type(func_data: Dict) -> str: r'@WebSocketGateway', ] +# PHP 8 routing attributes (Symfony / API-Platform): `#[Route(...)]`, `#[Get]`, +# `#[Post]`, ... A method carrying one of these IS a route handler regardless of +# the class name, so a handler on a class NOT named *Controller (which the PHP +# extractor's name/path-based classifier leaves as a plain `method`) is still +# seeded as an entry point. +ROUTE_ATTRIBUTE_PATTERNS = [ + # A PHP 8 routing attribute anywhere in the attribute list — not only right + # after `#[`. Allows a namespace prefix (#[Routing\Route], #[\Symfony\...\Route]), + # grouped attributes (#[Foo, Route(...)]), and (with IGNORECASE at compile) + # case-insensitive class names, since PHP class names are case-insensitive. + r'#\[[^\]]*\b(Route|Get|Post|Put|Delete|Patch|Options|Head)\b', +] + # Code patterns indicating direct user input sources USER_INPUT_PATTERNS = [ # Flask @@ -140,6 +153,14 @@ def _unit_type(func_data: Dict) -> str: r'php://input', r'\bfile_get_contents\s*\(\s*["\']php://input', r'\bfilter_input\s*\(', + # Symfony request reads, anchored to a $request / $req / $this->request + # receiver so they read HTTP input, not an unrelated ->query->all() on an + # ORM builder or a ->headers->get() on the app's own response object. + # - request bags: $request->query->get(...) / ->request-> / ->cookies-> / ... + # - direct methods: $request->get(...) / ->getPayload() / ->getContent() / + # ->toArray() / ->input(...) / ->all() + r'(\$(request|req)\b|\$this\s*->\s*request\b)\s*->\s*(query|request|cookies|attributes|headers|files)\s*->\s*(get|all)\s*\(', + r'(\$(request|req)\b|\$this\s*->\s*request\b)\s*->\s*(get|getPayload|getContent|toArray|input|all)\s*\(', ] # Patterns that indicate module-level scripts with user input @@ -198,6 +219,9 @@ def __init__(self, functions: Dict, call_graph: Dict): self._decorator_patterns = [ re.compile(p, re.IGNORECASE) for p in ENTRY_POINT_DECORATORS ] + self._route_attribute_patterns = [ + re.compile(p, re.IGNORECASE) for p in ROUTE_ATTRIBUTE_PATTERNS + ] self._input_patterns = [ re.compile(p) for p in USER_INPUT_PATTERNS ] @@ -249,9 +273,19 @@ def _get_entry_point_reasons(self, func_data: Dict) -> List[str]: elif func_data.get('name') == 'main': reasons.append('name:main') - # Check 2: Decorators indicate entry point + # Check 1c: A PHP 8 routing attribute (#[Route]/#[Get]/#[Post]/...) marks + # the method as a route handler INDEPENDENT of the class name. Symfony / + # API-Platform endpoints live on classes not named *Controller, which the + # PHP extractor's name/path-based classifier leaves as a plain `method`; + # the attribute is the authoritative signal. decorators = func_data.get('decorators', []) decorators_str = ' '.join(decorators) + for pattern in self._route_attribute_patterns: + if pattern.search(decorators_str): + reasons.append('unit_type:route_handler') + break + + # Check 2: Decorators indicate entry point for pattern in self._decorator_patterns: if pattern.search(decorators_str): reasons.append(f'decorator:{pattern.pattern}')