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        // The synthetic `<module>` unit has no signature, so coverage/Any
227        // are not meaningful. Return false (no type-escape risk from module-level
228        // Any; the `<module>` unit has no parameters to annotate with Any).
229        FnBody::Module(_) => false,
230    }
231}
232
233fn suite_has_any(suite: &Suite, imports: &Imports) -> bool {
234    match suite {
235        Suite::IndentedBlock(b) => b.body.iter().any(|s| stmt_has_any(s, imports)),
236        Suite::SimpleStatementSuite(s) => s.body.iter().any(|s| small_has_any(s, imports)),
237    }
238}
239
240fn stmt_has_any(stmt: &Statement, imports: &Imports) -> bool {
241    match stmt {
242        Statement::Simple(line) => line.body.iter().any(|s| small_has_any(s, imports)),
243        Statement::Compound(c) => compound_has_any(c, imports),
244    }
245}
246
247fn compound_has_any(c: &CompoundStatement, imports: &Imports) -> bool {
248    match c {
249        // Nested def/lambda/class — their own units; do not descend.
250        CompoundStatement::FunctionDef(_) | CompoundStatement::ClassDef(_) => false,
251        CompoundStatement::If(i) => {
252            expr_has_any(&i.test, imports)
253                || suite_has_any(&i.body, imports)
254                || i.orelse
255                    .as_ref()
256                    .is_some_and(|o| orelse_has_any(o, imports))
257        }
258        CompoundStatement::For(f) => {
259            expr_has_any(&f.iter, imports)
260                || suite_has_any(&f.body, imports)
261                || f.orelse
262                    .as_ref()
263                    .is_some_and(|e| suite_has_any(&e.body, imports))
264        }
265        CompoundStatement::While(w) => {
266            expr_has_any(&w.test, imports)
267                || suite_has_any(&w.body, imports)
268                || w.orelse
269                    .as_ref()
270                    .is_some_and(|e| suite_has_any(&e.body, imports))
271        }
272        CompoundStatement::Try(t) => {
273            suite_has_any(&t.body, imports)
274                || t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
275                || t.orelse
276                    .as_ref()
277                    .is_some_and(|e| suite_has_any(&e.body, imports))
278                || t.finalbody
279                    .as_ref()
280                    .is_some_and(|e| suite_has_any(&e.body, imports))
281        }
282        CompoundStatement::TryStar(t) => {
283            suite_has_any(&t.body, imports)
284                || t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
285                || t.orelse
286                    .as_ref()
287                    .is_some_and(|e| suite_has_any(&e.body, imports))
288                || t.finalbody
289                    .as_ref()
290                    .is_some_and(|e| suite_has_any(&e.body, imports))
291        }
292        CompoundStatement::With(w) => {
293            w.items.iter().any(|item| expr_has_any(&item.item, imports))
294                || suite_has_any(&w.body, imports)
295        }
296        CompoundStatement::Match(m) => {
297            expr_has_any(&m.subject, imports)
298                || m.cases
299                    .iter()
300                    .any(|case| suite_has_any(&case.body, imports))
301        }
302    }
303}
304
305fn orelse_has_any(orelse: &OrElse, imports: &Imports) -> bool {
306    match orelse {
307        OrElse::Elif(elif) => {
308            expr_has_any(&elif.test, imports)
309                || suite_has_any(&elif.body, imports)
310                || elif
311                    .orelse
312                    .as_ref()
313                    .is_some_and(|o| orelse_has_any(o, imports))
314        }
315        OrElse::Else(e) => suite_has_any(&e.body, imports),
316    }
317}
318
319fn small_has_any(small: &SmallStatement, imports: &Imports) -> bool {
320    match small {
321        // `x: Any = …` / `x: Any` — an `Any`-annotated local.
322        SmallStatement::AnnAssign(a) => {
323            if is_any_type(&a.annotation.annotation, imports) {
324                return true;
325            }
326            a.value.as_ref().is_some_and(|v| expr_has_any(v, imports))
327        }
328        SmallStatement::Assign(a) => expr_has_any(&a.value, imports),
329        SmallStatement::AugAssign(a) => expr_has_any(&a.value, imports),
330        SmallStatement::Expr(e) => expr_has_any(&e.value, imports),
331        SmallStatement::Return(r) => r.value.as_ref().is_some_and(|v| expr_has_any(v, imports)),
332        SmallStatement::Raise(r) => r.exc.as_ref().is_some_and(|e| expr_has_any(e, imports)),
333        _ => false,
334    }
335}
336
337/// Walk an expression for a `cast(Any, …)` call.
338fn expr_has_any(expr: &Expression, imports: &Imports) -> bool {
339    match expr {
340        Expression::Call(c) => {
341            if is_cast_any(c, imports) {
342                return true;
343            }
344            expr_has_any(&c.func, imports) || c.args.iter().any(|a| expr_has_any(&a.value, imports))
345        }
346        Expression::Attribute(a) => expr_has_any(&a.value, imports),
347        Expression::Subscript(s) => {
348            expr_has_any(&s.value, imports)
349                || s.slice
350                    .iter()
351                    .any(|el| base_slice_has_any(&el.slice, imports))
352        }
353        Expression::BinaryOperation(b) => {
354            expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
355        }
356        Expression::BooleanOperation(b) => {
357            expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
358        }
359        Expression::UnaryOperation(u) => expr_has_any(&u.expression, imports),
360        Expression::Comparison(c) => {
361            expr_has_any(&c.left, imports)
362                || c.comparisons
363                    .iter()
364                    .any(|cmp| expr_has_any(&cmp.comparator, imports))
365        }
366        Expression::IfExp(i) => {
367            expr_has_any(&i.test, imports)
368                || expr_has_any(&i.body, imports)
369                || expr_has_any(&i.orelse, imports)
370        }
371        // List/set/tuple literals.
372        Expression::List(l) => l.elements.iter().any(|e| element_has_any(e, imports)),
373        Expression::Set(s) => s.elements.iter().any(|e| element_has_any(e, imports)),
374        Expression::Tuple(t) => t.elements.iter().any(|e| element_has_any(e, imports)),
375        Expression::Dict(d) => d.elements.iter().any(|el| match el {
376            libcst_native::DictElement::Simple { key, value, .. } => {
377                expr_has_any(key, imports) || expr_has_any(value, imports)
378            }
379            libcst_native::DictElement::Starred(s) => expr_has_any(&s.value, imports),
380        }),
381        // Comprehensions (eager and lazy alike — a `cast(Any, …)` anywhere in the
382        // body re-opens the boundary regardless of execution timing).
383        Expression::ListComp(l) => {
384            expr_has_any(&l.elt, imports) || comp_for_has_any(&l.for_in, imports)
385        }
386        Expression::SetComp(s) => {
387            expr_has_any(&s.elt, imports) || comp_for_has_any(&s.for_in, imports)
388        }
389        Expression::DictComp(d) => {
390            expr_has_any(&d.key, imports)
391                || expr_has_any(&d.value, imports)
392                || comp_for_has_any(&d.for_in, imports)
393        }
394        Expression::GeneratorExp(g) => {
395            expr_has_any(&g.elt, imports) || comp_for_has_any(&g.for_in, imports)
396        }
397        Expression::FormattedString(fs) => fs.parts.iter().any(|p| {
398            if let libcst_native::FormattedStringContent::Expression(e) = p {
399                if expr_has_any(&e.expression, imports) {
400                    return true;
401                }
402                // `{x:{cast(Any, y)}}` — format_spec parts are eager.
403                if let Some(spec_parts) = &e.format_spec {
404                    return spec_parts.iter().any(|sp| {
405                        matches!(sp, libcst_native::FormattedStringContent::Expression(se) if expr_has_any(&se.expression, imports))
406                    });
407                }
408            }
409            false
410        }),
411        // Nested def/lambda are their own units — do not descend into a lambda body.
412        Expression::Lambda(_) => false,
413        Expression::Await(a) => expr_has_any(&a.expression, imports),
414        Expression::Yield(y) => y.value.as_ref().is_some_and(|v| match v.as_ref() {
415            libcst_native::YieldValue::Expression(e) => expr_has_any(e, imports),
416            libcst_native::YieldValue::From(f) => expr_has_any(&f.item, imports),
417        }),
418        Expression::NamedExpr(n) => expr_has_any(&n.value, imports),
419        Expression::StarredElement(s) => expr_has_any(&s.value, imports),
420        _ => false,
421    }
422}
423
424/// Does an `Element` (list/set/tuple member) contain a `cast(Any, …)`?
425fn element_has_any(el: &libcst_native::Element, imports: &Imports) -> bool {
426    match el {
427        libcst_native::Element::Simple { value, .. } => expr_has_any(value, imports),
428        libcst_native::Element::Starred(s) => expr_has_any(&s.value, imports),
429    }
430}
431
432/// Does a comprehension `for … in …` clause contain a `cast(Any, …)`?
433fn comp_for_has_any(comp: &libcst_native::CompFor, imports: &Imports) -> bool {
434    expr_has_any(&comp.iter, imports)
435        || comp.ifs.iter().any(|c| expr_has_any(&c.test, imports))
436        || comp
437            .inner_for_in
438            .as_ref()
439            .is_some_and(|i| comp_for_has_any(i, imports))
440}
441
442/// Does a subscript slice (`Index`/`Slice`) contain a `cast(Any, …)`?
443fn base_slice_has_any(slice: &libcst_native::BaseSlice, imports: &Imports) -> bool {
444    match slice {
445        libcst_native::BaseSlice::Index(i) => expr_has_any(&i.value, imports),
446        libcst_native::BaseSlice::Slice(s) => {
447            s.lower.as_ref().is_some_and(|e| expr_has_any(e, imports))
448                || s.upper.as_ref().is_some_and(|e| expr_has_any(e, imports))
449                || s.step.as_ref().is_some_and(|e| expr_has_any(e, imports))
450        }
451    }
452}
453
454/// Is `call` a `cast(Any, …)` (the first type argument is the bare `Any` type)?
455/// Matches a bare `cast(...)` (`from typing import cast`) and a `typing.cast(...)`
456/// whose base **resolves to `typing`** through the import table (so an aliased
457/// `t.cast(...)` matches, but an unrelated `obj.cast(...)` does not). The first
458/// positional argument must be the bare `Any` type.
459fn is_cast_any(call: &libcst_native::Call, imports: &Imports) -> bool {
460    let is_cast = match call.func.as_ref() {
461        Expression::Name(n) => n.value == "cast",
462        Expression::Attribute(a) => a.attr.value == "cast" && base_is_typing(&a.value, imports),
463        _ => false,
464    };
465    if !is_cast {
466        return false;
467    }
468    call.args
469        .first()
470        .is_some_and(|arg| is_any_type(&arg.value, imports))
471}
472
473// ─── tests ────────────────────────────────────────────────────────────────────
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::source::SpanIndex;
479
480    /// Parse `src`, build imports, and return `Coverage` for the unit named `symbol`.
481    fn coverage_of(src: &str, symbol: &str) -> Coverage {
482        let module = libcst_native::parse_module(src, None).unwrap();
483        let imports = Imports::build(&module);
484        let span = SpanIndex::new(src);
485        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
486        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
487        let unit = units
488            .iter()
489            .find(|u| u.symbol == symbol)
490            .expect("unit not found");
491        of(unit, &imports)
492    }
493
494    /// Copilot FIX 3: a signature `typing.Any` (with `import typing`) must poison
495    /// (resolves through the import table), but an unrelated `mymod.Any` must NOT.
496    ///
497    /// Pre-fix `is_any_type` matched ANY attribute whose final component is `Any`,
498    /// so `mymod.Any` falsely poisoned.
499    #[test]
500    fn signature_typing_any_poisons_but_unrelated_attr_any_does_not() {
501        let typing_any = coverage_of(
502            "import typing\ndef f(x: typing.Any) -> int:\n    return 0\n",
503            "f",
504        );
505        assert!(
506            typing_any.any_in_signature,
507            "typing.Any (import typing) must set any_in_signature"
508        );
509
510        let aliased = coverage_of(
511            "import typing as t\ndef f(x: t.Any) -> int:\n    return 0\n",
512            "f",
513        );
514        assert!(
515            aliased.any_in_signature,
516            "aliased t.Any (import typing as t) must set any_in_signature"
517        );
518
519        let unrelated = coverage_of(
520            "import mymod\ndef f(x: mymod.Any) -> int:\n    return 0\n",
521            "f",
522        );
523        assert!(
524            !unrelated.any_in_signature,
525            "unrelated mymod.Any must NOT set any_in_signature (does not resolve to typing)"
526        );
527    }
528
529    /// Copilot FIX 4: `typing.cast(Any, x)` in the body (with `import typing` and a
530    /// bare `Any` in scope) must be detected as body-Any, but `obj.cast(Any, x)`
531    /// where `obj` is not `typing` must NOT.
532    ///
533    /// Pre-fix `is_cast_any` matched any attribute call whose final component is
534    /// `cast`, so `obj.cast(...)` falsely fired.
535    #[test]
536    fn body_typing_cast_any_detected_but_unrelated_cast_not() {
537        // `import typing` for the base; `from typing import Any` so the bare `Any`
538        // arg resolves through the Name arm.
539        let typing_cast = coverage_of(
540            "import typing\nfrom typing import Any\ndef f(x: int) -> int:\n    y = typing.cast(Any, x)\n    return y\n",
541            "f",
542        );
543        assert!(
544            typing_cast.any_in_body,
545            "typing.cast(Any, x) must set any_in_body"
546        );
547
548        let unrelated_cast = coverage_of(
549            "import obj\nfrom typing import Any\ndef f(x: int) -> int:\n    y = obj.cast(Any, x)\n    return y\n",
550            "f",
551        );
552        assert!(
553            !unrelated_cast.any_in_body,
554            "obj.cast(Any, x) (obj not typing) must NOT set any_in_body"
555        );
556    }
557}