Skip to main content

fxrank_lang_python/
coverage.rs

1//! Signature annotation-slot coverage + `Any`/decorator analysis — the Python
2//! analog of `fxrank-lang-ts`'s `coverage.rs`. The project thesis ("types lower
3//! the score") made operational for gradual-typed Python: we measure how much of
4//! a function's signature is *explicitly annotated* (its boundary), so the
5//! boundary-containment discount in `analyze_unit` can shift **contained** effects
6//! down when the boundary is honest, and void the discount when `Any` poisons it.
7//!
8//! # Slots (spec §"Signature coverage")
9//! A "slot" is one declared boundary position: each parameter (**excluding**
10//! `self`/`cls` — convention never annotates them), plus one return slot.
11//! `*args` / `**kwargs` are **one slot each** (a typed star-param counts; an
12//! untyped one degrades coverage — the escape-hatch rule). A slot is **typed**
13//! iff it carries an *explicit* annotation whose top-level type is **not** `Any`.
14//! `t` = typed slots, `S` = total slots → `None` (`t = 0`), `Partial`
15//! (`0 < t < S`), `Full` (`t = S`).
16//!
17//! # `Any` poison (two cases, spec §"Poison & confidence rules")
18//! - **Signature** (`x: Any`, `-> Any`) → that slot is untyped *and*
19//!   `any_in_signature` is set (an `Any`-typed boundary is a non-boundary).
20//! - **Body** (`cast(Any, …)`, an `Any`-annotated local) → `any_in_body` is set;
21//!   `analyze_unit` forces the boundary shift to 0 (the discount is voided).
22//!
23//! Both cases drive a `type.escape` risk in `analyze_unit`.
24//!
25//! # Decorators (spec §"Poison & confidence rules")
26//! An unknown / dynamic decorator (outside a known-pure allowlist) does **not**
27//! degrade coverage — the written annotations are real signal — but lowers the
28//! function's confidence ("typed, but a wrapper may be lying").
29//!
30//! **Top-level only.** `Any` detection is shallow: a parameter typed `list[Any]`
31//! or `dict[str, Any]` counts as *typed* (its top-level type is `list`/`dict`,
32//! not the bare `Any` name). Matching the Milestone-A scope, we do not descend
33//! into subscript type arguments.
34
35use fxrank_core::score::BoundaryCoverage;
36use libcst_native::{
37    CompoundStatement, Decorator, Expression, OrElse, Param, SmallStatement, StarArg, Statement,
38    Suite,
39};
40
41use crate::detect::expr::render_expr;
42use crate::functions::{FnBody, FnUnit};
43use crate::imports::Imports;
44
45/// Annotation-slot coverage + `Any`/decorator signals for one function unit.
46pub struct Coverage {
47    /// The boundary tier fed to `apply_boundary_discount`.
48    pub boundary: BoundaryCoverage,
49    /// A signature slot's explicit annotation top-level type IS `Any`.
50    pub any_in_signature: bool,
51    /// The body contains `cast(Any, …)` or an `Any`-annotated local (`AnnAssign`).
52    pub any_in_body: bool,
53    /// A decorator is outside the known-pure allowlist.
54    pub unknown_decorator: bool,
55}
56
57/// Compute the annotation-slot coverage + `Any`/decorator signals of `unit`.
58///
59/// `imports` lets `typing.Any` / `typing.cast` be recognized by resolving the
60/// attribute base through the file's import table (so an aliased `import typing as
61/// t` matches, while an unrelated `mymod.Any` / `obj.cast(...)` does not).
62pub fn of(unit: &FnUnit, imports: &Imports) -> Coverage {
63    let mut typed = 0usize;
64    let mut total = 0usize;
65    let mut any_in_signature = false;
66
67    // ── parameter slots (excluding self/cls) ─────────────────────────────────
68    let mut visit_param = |p: &Param, total: &mut usize, typed: &mut usize| {
69        if is_receiver(p.name.value) {
70            return;
71        }
72        *total += 1;
73        match classify_annotation(p.annotation.as_ref().map(|a| &a.annotation), imports) {
74            SlotKind::Typed => *typed += 1,
75            SlotKind::Any => any_in_signature = true,
76            SlotKind::Untyped => {}
77        }
78    };
79
80    for p in unit
81        .params
82        .posonly_params
83        .iter()
84        .chain(&unit.params.params)
85        .chain(&unit.params.kwonly_params)
86    {
87        visit_param(p, &mut total, &mut typed);
88    }
89    // `*args` / `**kwargs`: one slot each (a bare `*` separator is NOT a slot).
90    if let Some(StarArg::Param(p)) = &unit.params.star_arg {
91        visit_param(p, &mut total, &mut typed);
92    }
93    if let Some(p) = &unit.params.star_kwarg {
94        visit_param(p, &mut total, &mut typed);
95    }
96
97    // ── return slot ──────────────────────────────────────────────────────────
98    total += 1;
99    match classify_annotation(unit.returns.map(|a| &a.annotation), imports) {
100        SlotKind::Typed => typed += 1,
101        SlotKind::Any => any_in_signature = true,
102        SlotKind::Untyped => {}
103    }
104
105    let boundary = if total > 0 && typed == total {
106        BoundaryCoverage::Full
107    } else if typed > 0 {
108        BoundaryCoverage::Partial
109    } else {
110        BoundaryCoverage::None
111    };
112
113    Coverage {
114        boundary,
115        any_in_signature,
116        any_in_body: body_has_any(&unit.body, imports),
117        unknown_decorator: unit.decorators.iter().any(|d| !is_pure_decorator(d)),
118    }
119}
120
121/// First-param receiver names excluded from the slot count.
122fn is_receiver(name: &str) -> bool {
123    name == "self" || name == "cls"
124}
125
126/// Per-slot annotation classification.
127enum SlotKind {
128    /// Explicit annotation, top-level type is not `Any` → typed slot.
129    Typed,
130    /// Explicit annotation, top-level type IS `Any` → untyped + signature poison.
131    Any,
132    /// No explicit annotation → untyped slot.
133    Untyped,
134}
135
136/// Classify an optional annotation expression by its top-level type.
137fn classify_annotation(ann: Option<&Expression>, imports: &Imports) -> SlotKind {
138    match ann {
139        None => SlotKind::Untyped,
140        Some(expr) if is_any_type(expr, imports) => SlotKind::Any,
141        Some(_) => SlotKind::Typed,
142    }
143}
144
145/// Is `expr` the bare `Any` type (top-level only)? Accepts a `Name("Any")`
146/// (`from typing import Any`) or an **attribute whose base resolves to `typing`**
147/// (`typing.Any`, or an aliased `import typing as t; t.Any`).
148///
149/// Resolving the base through the import table (rather than matching any final
150/// `.Any` component) avoids falsely poisoning on an unrelated `mymod.Any`.
151fn is_any_type(expr: &Expression, imports: &Imports) -> bool {
152    match expr {
153        Expression::Name(n) => n.value == "Any",
154        Expression::Attribute(a) => a.attr.value == "Any" && base_is_typing(&a.value, imports),
155        _ => false,
156    }
157}
158
159/// Does an attribute's base expression resolve to the `typing` module through the
160/// import table? Renders the base to a dotted string and resolves its root, so a
161/// bare `typing.X` (`import typing`) and an aliased `t.X` (`import typing as t`)
162/// both match, while an unrelated `mymod.X` does not.
163fn base_is_typing(base: &Expression, imports: &Imports) -> bool {
164    let Some(rendered) = render_expr(base) else {
165        return false;
166    };
167    let root = rendered.split('.').next().unwrap_or(&rendered);
168    imports.resolve(root) == Some("typing")
169}
170
171// ─── decorator allowlist ──────────────────────────────────────────────────────
172
173/// Is `dec` a known-pure decorator that does not erase the signature?
174///
175/// Allowlist (spec): `property`, `staticmethod`, `classmethod`, `dataclass`,
176/// `abstractmethod`, `functools.wraps`, `functools.cached_property`,
177/// `abc.abstractmethod`, and framework route decorators (`app.route`, `app.get`,
178/// `app.post`, … — any `*.route`/`*.get`/`*.post`/`*.put`/`*.delete`/`*.patch`).
179fn is_pure_decorator(dec: &Decorator) -> bool {
180    // A decorator may be a bare name, an attribute, or a call (`@app.route("/")`).
181    // Unwrap a call to its callee, then match the name/attribute form.
182    let callee = match &dec.decorator {
183        Expression::Call(c) => c.func.as_ref(),
184        other => other,
185    };
186    match callee {
187        Expression::Name(n) => matches!(
188            n.value,
189            "property" | "staticmethod" | "classmethod" | "dataclass" | "abstractmethod"
190        ),
191        Expression::Attribute(a) => {
192            // `functools.wraps`, `functools.cached_property`.
193            if matches!(a.attr.value, "wraps" | "cached_property") {
194                return true;
195            }
196            // `dataclass` imported via attribute (`dataclasses.dataclass`).
197            if a.attr.value == "dataclass" {
198                return true;
199            }
200            // `abc.abstractmethod` and similar bare `abstractmethod` attributes.
201            if a.attr.value == "abstractmethod" {
202                return true;
203            }
204            // Framework route decorators: `app.route`, `router.get`, `bp.post`, …
205            is_route_method(a.attr.value)
206        }
207        _ => false,
208    }
209}
210
211/// HTTP-style framework route decorator method names.
212fn is_route_method(name: &str) -> bool {
213    matches!(
214        name,
215        "route" | "get" | "post" | "put" | "delete" | "patch" | "head" | "options"
216    )
217}
218
219// ─── body `Any` detection (`cast(Any, …)` / `Any`-annotated local) ────────────
220
221fn body_has_any(body: &FnBody, imports: &Imports) -> bool {
222    match body {
223        FnBody::Suite(suite) => suite_has_any(suite, imports),
224        // A lambda body is a single expression; only a `cast(Any, …)` could appear.
225        FnBody::Expr(e) => expr_has_any(e, imports),
226    }
227}
228
229fn suite_has_any(suite: &Suite, imports: &Imports) -> bool {
230    match suite {
231        Suite::IndentedBlock(b) => b.body.iter().any(|s| stmt_has_any(s, imports)),
232        Suite::SimpleStatementSuite(s) => s.body.iter().any(|s| small_has_any(s, imports)),
233    }
234}
235
236fn stmt_has_any(stmt: &Statement, imports: &Imports) -> bool {
237    match stmt {
238        Statement::Simple(line) => line.body.iter().any(|s| small_has_any(s, imports)),
239        Statement::Compound(c) => compound_has_any(c, imports),
240    }
241}
242
243fn compound_has_any(c: &CompoundStatement, imports: &Imports) -> bool {
244    match c {
245        // Nested def/lambda/class — their own units; do not descend.
246        CompoundStatement::FunctionDef(_) | CompoundStatement::ClassDef(_) => false,
247        CompoundStatement::If(i) => {
248            expr_has_any(&i.test, imports)
249                || suite_has_any(&i.body, imports)
250                || i.orelse
251                    .as_ref()
252                    .is_some_and(|o| orelse_has_any(o, imports))
253        }
254        CompoundStatement::For(f) => {
255            expr_has_any(&f.iter, imports)
256                || suite_has_any(&f.body, imports)
257                || f.orelse
258                    .as_ref()
259                    .is_some_and(|e| suite_has_any(&e.body, imports))
260        }
261        CompoundStatement::While(w) => {
262            expr_has_any(&w.test, imports)
263                || suite_has_any(&w.body, imports)
264                || w.orelse
265                    .as_ref()
266                    .is_some_and(|e| suite_has_any(&e.body, imports))
267        }
268        CompoundStatement::Try(t) => {
269            suite_has_any(&t.body, imports)
270                || t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
271                || t.orelse
272                    .as_ref()
273                    .is_some_and(|e| suite_has_any(&e.body, imports))
274                || t.finalbody
275                    .as_ref()
276                    .is_some_and(|e| suite_has_any(&e.body, imports))
277        }
278        CompoundStatement::TryStar(t) => {
279            suite_has_any(&t.body, imports)
280                || t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
281                || t.orelse
282                    .as_ref()
283                    .is_some_and(|e| suite_has_any(&e.body, imports))
284                || t.finalbody
285                    .as_ref()
286                    .is_some_and(|e| suite_has_any(&e.body, imports))
287        }
288        CompoundStatement::With(w) => {
289            w.items.iter().any(|item| expr_has_any(&item.item, imports))
290                || suite_has_any(&w.body, imports)
291        }
292        CompoundStatement::Match(m) => {
293            expr_has_any(&m.subject, imports)
294                || m.cases
295                    .iter()
296                    .any(|case| suite_has_any(&case.body, imports))
297        }
298    }
299}
300
301fn orelse_has_any(orelse: &OrElse, imports: &Imports) -> bool {
302    match orelse {
303        OrElse::Elif(elif) => {
304            expr_has_any(&elif.test, imports)
305                || suite_has_any(&elif.body, imports)
306                || elif
307                    .orelse
308                    .as_ref()
309                    .is_some_and(|o| orelse_has_any(o, imports))
310        }
311        OrElse::Else(e) => suite_has_any(&e.body, imports),
312    }
313}
314
315fn small_has_any(small: &SmallStatement, imports: &Imports) -> bool {
316    match small {
317        // `x: Any = …` / `x: Any` — an `Any`-annotated local.
318        SmallStatement::AnnAssign(a) => {
319            if is_any_type(&a.annotation.annotation, imports) {
320                return true;
321            }
322            a.value.as_ref().is_some_and(|v| expr_has_any(v, imports))
323        }
324        SmallStatement::Assign(a) => expr_has_any(&a.value, imports),
325        SmallStatement::AugAssign(a) => expr_has_any(&a.value, imports),
326        SmallStatement::Expr(e) => expr_has_any(&e.value, imports),
327        SmallStatement::Return(r) => r.value.as_ref().is_some_and(|v| expr_has_any(v, imports)),
328        SmallStatement::Raise(r) => r.exc.as_ref().is_some_and(|e| expr_has_any(e, imports)),
329        _ => false,
330    }
331}
332
333/// Walk an expression for a `cast(Any, …)` call.
334fn expr_has_any(expr: &Expression, imports: &Imports) -> bool {
335    match expr {
336        Expression::Call(c) => {
337            if is_cast_any(c, imports) {
338                return true;
339            }
340            expr_has_any(&c.func, imports) || c.args.iter().any(|a| expr_has_any(&a.value, imports))
341        }
342        Expression::Attribute(a) => expr_has_any(&a.value, imports),
343        Expression::Subscript(s) => {
344            expr_has_any(&s.value, imports)
345                || s.slice
346                    .iter()
347                    .any(|el| base_slice_has_any(&el.slice, imports))
348        }
349        Expression::BinaryOperation(b) => {
350            expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
351        }
352        Expression::BooleanOperation(b) => {
353            expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
354        }
355        Expression::UnaryOperation(u) => expr_has_any(&u.expression, imports),
356        Expression::Comparison(c) => {
357            expr_has_any(&c.left, imports)
358                || c.comparisons
359                    .iter()
360                    .any(|cmp| expr_has_any(&cmp.comparator, imports))
361        }
362        Expression::IfExp(i) => {
363            expr_has_any(&i.test, imports)
364                || expr_has_any(&i.body, imports)
365                || expr_has_any(&i.orelse, imports)
366        }
367        // List/set/tuple literals.
368        Expression::List(l) => l.elements.iter().any(|e| element_has_any(e, imports)),
369        Expression::Set(s) => s.elements.iter().any(|e| element_has_any(e, imports)),
370        Expression::Tuple(t) => t.elements.iter().any(|e| element_has_any(e, imports)),
371        Expression::Dict(d) => d.elements.iter().any(|el| match el {
372            libcst_native::DictElement::Simple { key, value, .. } => {
373                expr_has_any(key, imports) || expr_has_any(value, imports)
374            }
375            libcst_native::DictElement::Starred(s) => expr_has_any(&s.value, imports),
376        }),
377        // Comprehensions (eager and lazy alike — a `cast(Any, …)` anywhere in the
378        // body re-opens the boundary regardless of execution timing).
379        Expression::ListComp(l) => {
380            expr_has_any(&l.elt, imports) || comp_for_has_any(&l.for_in, imports)
381        }
382        Expression::SetComp(s) => {
383            expr_has_any(&s.elt, imports) || comp_for_has_any(&s.for_in, imports)
384        }
385        Expression::DictComp(d) => {
386            expr_has_any(&d.key, imports)
387                || expr_has_any(&d.value, imports)
388                || comp_for_has_any(&d.for_in, imports)
389        }
390        Expression::GeneratorExp(g) => {
391            expr_has_any(&g.elt, imports) || comp_for_has_any(&g.for_in, imports)
392        }
393        Expression::FormattedString(fs) => fs.parts.iter().any(|p| {
394            if let libcst_native::FormattedStringContent::Expression(e) = p {
395                if expr_has_any(&e.expression, imports) {
396                    return true;
397                }
398                // `{x:{cast(Any, y)}}` — format_spec parts are eager.
399                if let Some(spec_parts) = &e.format_spec {
400                    return spec_parts.iter().any(|sp| {
401                        matches!(sp, libcst_native::FormattedStringContent::Expression(se) if expr_has_any(&se.expression, imports))
402                    });
403                }
404            }
405            false
406        }),
407        // Nested def/lambda are their own units — do not descend into a lambda body.
408        Expression::Lambda(_) => false,
409        Expression::Await(a) => expr_has_any(&a.expression, imports),
410        Expression::Yield(y) => y.value.as_ref().is_some_and(|v| match v.as_ref() {
411            libcst_native::YieldValue::Expression(e) => expr_has_any(e, imports),
412            libcst_native::YieldValue::From(f) => expr_has_any(&f.item, imports),
413        }),
414        Expression::NamedExpr(n) => expr_has_any(&n.value, imports),
415        Expression::StarredElement(s) => expr_has_any(&s.value, imports),
416        _ => false,
417    }
418}
419
420/// Does an `Element` (list/set/tuple member) contain a `cast(Any, …)`?
421fn element_has_any(el: &libcst_native::Element, imports: &Imports) -> bool {
422    match el {
423        libcst_native::Element::Simple { value, .. } => expr_has_any(value, imports),
424        libcst_native::Element::Starred(s) => expr_has_any(&s.value, imports),
425    }
426}
427
428/// Does a comprehension `for … in …` clause contain a `cast(Any, …)`?
429fn comp_for_has_any(comp: &libcst_native::CompFor, imports: &Imports) -> bool {
430    expr_has_any(&comp.iter, imports)
431        || comp.ifs.iter().any(|c| expr_has_any(&c.test, imports))
432        || comp
433            .inner_for_in
434            .as_ref()
435            .is_some_and(|i| comp_for_has_any(i, imports))
436}
437
438/// Does a subscript slice (`Index`/`Slice`) contain a `cast(Any, …)`?
439fn base_slice_has_any(slice: &libcst_native::BaseSlice, imports: &Imports) -> bool {
440    match slice {
441        libcst_native::BaseSlice::Index(i) => expr_has_any(&i.value, imports),
442        libcst_native::BaseSlice::Slice(s) => {
443            s.lower.as_ref().is_some_and(|e| expr_has_any(e, imports))
444                || s.upper.as_ref().is_some_and(|e| expr_has_any(e, imports))
445                || s.step.as_ref().is_some_and(|e| expr_has_any(e, imports))
446        }
447    }
448}
449
450/// Is `call` a `cast(Any, …)` (the first type argument is the bare `Any` type)?
451/// Matches a bare `cast(...)` (`from typing import cast`) and a `typing.cast(...)`
452/// whose base **resolves to `typing`** through the import table (so an aliased
453/// `t.cast(...)` matches, but an unrelated `obj.cast(...)` does not). The first
454/// positional argument must be the bare `Any` type.
455fn is_cast_any(call: &libcst_native::Call, imports: &Imports) -> bool {
456    let is_cast = match call.func.as_ref() {
457        Expression::Name(n) => n.value == "cast",
458        Expression::Attribute(a) => a.attr.value == "cast" && base_is_typing(&a.value, imports),
459        _ => false,
460    };
461    if !is_cast {
462        return false;
463    }
464    call.args
465        .first()
466        .is_some_and(|arg| is_any_type(&arg.value, imports))
467}
468
469// ─── tests ────────────────────────────────────────────────────────────────────
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::source::SpanIndex;
475
476    /// Parse `src`, build imports, and return `Coverage` for the unit named `symbol`.
477    fn coverage_of(src: &str, symbol: &str) -> Coverage {
478        let module = libcst_native::parse_module(src, None).unwrap();
479        let imports = Imports::build(&module);
480        let span = SpanIndex::new(src);
481        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
482        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
483        let unit = units
484            .iter()
485            .find(|u| u.symbol == symbol)
486            .expect("unit not found");
487        of(unit, &imports)
488    }
489
490    /// Copilot FIX 3: a signature `typing.Any` (with `import typing`) must poison
491    /// (resolves through the import table), but an unrelated `mymod.Any` must NOT.
492    ///
493    /// Pre-fix `is_any_type` matched ANY attribute whose final component is `Any`,
494    /// so `mymod.Any` falsely poisoned.
495    #[test]
496    fn signature_typing_any_poisons_but_unrelated_attr_any_does_not() {
497        let typing_any = coverage_of(
498            "import typing\ndef f(x: typing.Any) -> int:\n    return 0\n",
499            "f",
500        );
501        assert!(
502            typing_any.any_in_signature,
503            "typing.Any (import typing) must set any_in_signature"
504        );
505
506        let aliased = coverage_of(
507            "import typing as t\ndef f(x: t.Any) -> int:\n    return 0\n",
508            "f",
509        );
510        assert!(
511            aliased.any_in_signature,
512            "aliased t.Any (import typing as t) must set any_in_signature"
513        );
514
515        let unrelated = coverage_of(
516            "import mymod\ndef f(x: mymod.Any) -> int:\n    return 0\n",
517            "f",
518        );
519        assert!(
520            !unrelated.any_in_signature,
521            "unrelated mymod.Any must NOT set any_in_signature (does not resolve to typing)"
522        );
523    }
524
525    /// Copilot FIX 4: `typing.cast(Any, x)` in the body (with `import typing` and a
526    /// bare `Any` in scope) must be detected as body-Any, but `obj.cast(Any, x)`
527    /// where `obj` is not `typing` must NOT.
528    ///
529    /// Pre-fix `is_cast_any` matched any attribute call whose final component is
530    /// `cast`, so `obj.cast(...)` falsely fired.
531    #[test]
532    fn body_typing_cast_any_detected_but_unrelated_cast_not() {
533        // `import typing` for the base; `from typing import Any` so the bare `Any`
534        // arg resolves through the Name arm.
535        let typing_cast = coverage_of(
536            "import typing\nfrom typing import Any\ndef f(x: int) -> int:\n    y = typing.cast(Any, x)\n    return y\n",
537            "f",
538        );
539        assert!(
540            typing_cast.any_in_body,
541            "typing.cast(Any, x) must set any_in_body"
542        );
543
544        let unrelated_cast = coverage_of(
545            "import obj\nfrom typing import Any\ndef f(x: int) -> int:\n    y = obj.cast(Any, x)\n    return y\n",
546            "f",
547        );
548        assert!(
549            !unrelated_cast.any_in_body,
550            "obj.cast(Any, x) (obj not typing) must NOT set any_in_body"
551        );
552    }
553}