|
| 1 | +"""Stage 3: match rules against decorators and base classes (#27). |
| 2 | +
|
| 3 | +Matching is on ``PyDecorator.qualified_name`` -- never the written spelling -- |
| 4 | +so ``@route`` under ``from flask import route`` hits the same rule as |
| 5 | +``@app.route``. An unresolved decorator (``qualified_name is None``) never |
| 6 | +matches: under-approximate rather than guess. |
| 7 | +""" |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import ast |
| 11 | +import re |
| 12 | +from typing import Any, Dict, Iterable, List, Optional |
| 13 | + |
| 14 | +from codeanalyzer.entrypoints.rules import DecoratorRule |
| 15 | +from codeanalyzer.schema.py_schema import PyEntrypoint |
| 16 | + |
| 17 | +# Dispatch names that are HTTP verbs. DRF's ViewSet dispatch names |
| 18 | +# (list, retrieve, create, ...) are NOT verbs and must not be emitted as such. |
| 19 | +_HTTP_VERBS = {"get", "post", "put", "patch", "delete", "head", "options"} |
| 20 | + |
| 21 | + |
| 22 | +def match_pattern(pattern: str, qualified_name: Optional[str]) -> bool: |
| 23 | + """``{a,b}`` alternation and trailing ``*``; everything else is literal.""" |
| 24 | + if not qualified_name: |
| 25 | + return False |
| 26 | + return re.fullmatch(_compile(pattern), qualified_name) is not None |
| 27 | + |
| 28 | + |
| 29 | +def _compile(pattern: str) -> str: |
| 30 | + out, i = [], 0 |
| 31 | + while i < len(pattern): |
| 32 | + ch = pattern[i] |
| 33 | + if ch == "{": |
| 34 | + j = pattern.index("}", i) |
| 35 | + alts = pattern[i + 1 : j].split(",") |
| 36 | + out.append("(?:" + "|".join(re.escape(a.strip()) for a in alts) + ")") |
| 37 | + i = j + 1 |
| 38 | + elif ch == "*": |
| 39 | + out.append(r"[^\s]*") |
| 40 | + i += 1 |
| 41 | + else: |
| 42 | + out.append(re.escape(ch)) |
| 43 | + i += 1 |
| 44 | + return "".join(out) |
| 45 | + |
| 46 | + |
| 47 | +def _literal(text: Optional[str]) -> Any: |
| 48 | + """Best-effort: decorator arguments are unparsed source fragments.""" |
| 49 | + if text is None: |
| 50 | + return None |
| 51 | + try: |
| 52 | + return ast.literal_eval(text) |
| 53 | + except (ValueError, SyntaxError): |
| 54 | + return None |
| 55 | + |
| 56 | + |
| 57 | +def _route_of(dec, spec: Optional[Dict[str, Any]]) -> Optional[str]: |
| 58 | + if not spec or spec.get("from") != "positional": |
| 59 | + return None |
| 60 | + args = dec.positional_arguments or [] |
| 61 | + idx = int(spec.get("index", 0)) |
| 62 | + if idx >= len(args): |
| 63 | + return None |
| 64 | + value = _literal(args[idx]) |
| 65 | + return value if isinstance(value, str) else None |
| 66 | + |
| 67 | + |
| 68 | +def _methods_of(dec, rule: DecoratorRule, spec: Optional[Dict[str, Any]]) -> List[str]: |
| 69 | + if not spec: |
| 70 | + return [] |
| 71 | + source = spec.get("from") |
| 72 | + if source == "match_suffix": |
| 73 | + verb = (dec.qualified_name or "").rsplit(".", 1)[-1] |
| 74 | + return [verb.upper()] |
| 75 | + if source == "keyword": |
| 76 | + raw = (dec.keyword_arguments or {}).get(spec.get("name", "")) |
| 77 | + value = _literal(raw) |
| 78 | + if isinstance(value, (list, tuple)): |
| 79 | + return [str(v).upper() for v in value] |
| 80 | + return [str(v).upper() for v in (spec.get("default") or [])] |
| 81 | + return [] |
| 82 | + |
| 83 | + |
| 84 | +def entrypoints_from_decorators( |
| 85 | + node, framework: str, rules: Iterable[DecoratorRule], ruleset: str |
| 86 | +) -> List[PyEntrypoint]: |
| 87 | + out: List[PyEntrypoint] = [] |
| 88 | + for dec in getattr(node, "decorators", []) or []: |
| 89 | + for rule in rules: |
| 90 | + if not match_pattern(rule.match, dec.qualified_name): |
| 91 | + continue |
| 92 | + out.append( |
| 93 | + PyEntrypoint( |
| 94 | + framework=framework, |
| 95 | + confidence=rule.confidence, |
| 96 | + rule=rule.id, |
| 97 | + ruleset=ruleset, |
| 98 | + evidence=dec.qualified_name, |
| 99 | + route=_route_of(dec, rule.route), |
| 100 | + http_methods=_methods_of(dec, rule, rule.methods), |
| 101 | + ) |
| 102 | + ) |
| 103 | + return out |
0 commit comments