Skip to main content

fxrank_lang_python/detect/
mod.rs

1//! Per-function effect/risk detection and `Hotspot` assembly.
2//!
3//! `detect/mod.rs` owns the **own-body recursion driver** ([`walk_own_body`]) — the
4//! single place that decides, per the spec's wrapper/inner-call attribution rules,
5//! which sub-nodes are *evaluated in the enclosing body* (and so charged to this
6//! function) and which are *deferred* (a nested `def`/`lambda` body, or a lazy
7//! generator-expression element body — their own unit or simply uncounted).
8//!
9//! Detectors ([`calls`], later `mutation`/`risk`) stay pure: they receive the driver's
10//! callbacks and push `Effect`s. `analyze_unit` is the single owner of turning the
11//! collected effects/risks into a scored [`Hotspot`].
12
13pub mod calls;
14pub mod expr;
15pub mod mutation;
16pub mod risk;
17
18use crate::coverage;
19use crate::functions::{FnBody, FnUnit};
20use crate::imports::Imports;
21use crate::source::SpanIndex;
22use fxrank_core::confidence::function_confidence;
23use fxrank_core::effect::{RiskFeature, RiskKind, Tier};
24use fxrank_core::model::Hotspot;
25use fxrank_core::score::{
26    BoundaryCoverage, apply_boundary_discount, max_class, own_score, weight_for_class,
27};
28
29use libcst_native::{
30    Assert, AssignTargetExpression, Call, CompoundStatement, Decorator, Element, Expression,
31    FormattedStringContent, Parameters, Raise, SmallStatement, Statement, Suite,
32};
33
34/// A sink that receives the **eagerly-evaluated** effect sites of a function's own
35/// body, as decided by [`walk_own_body`]. Each method is a `classify_* → push` hook.
36pub trait EffectSink {
37    /// A function/method call evaluated in the enclosing body.
38    fn on_call(&mut self, call: &Call);
39    /// A bare `assert` statement (conditional abort; stripped under `-O`).
40    fn on_assert(&mut self, assert: &Assert);
41    /// A `raise` statement.
42    fn on_raise(&mut self, raise: &Raise);
43    /// An assignment target that may be an env write (`os.environ[...] = …`) or a
44    /// mutation. `is_aug` is true for an augmented assignment (`+=`, `|=`, …),
45    /// false for a plain `=`. A plain `=` to a **bare local name** is a *binding*,
46    /// not a mutation of pre-existing state (spec §"effect table": `local.mutation`
47    /// is `.append()` / `d[k] = …` / `+=` on a locally-created binding — not the
48    /// binding itself); subscript/attribute `=` targets still mutate.
49    fn on_assign_target(&mut self, target: &AssignTargetExpression, is_aug: bool);
50    /// An attribute read that may be an ambient-read signal (e.g. `sys.argv`).
51    /// Default: no-op (most sinks don't care).
52    fn on_attribute_read(&mut self, _attr: &Expression) {}
53}
54
55/// Walk a function-unit's **own body** and drive `sink` over every effect site that
56/// is *evaluated in the enclosing body*, per the spec's attribution rules.
57///
58/// Descends into: the body suite (or lambda body expr), `with`-items, **eager**
59/// list/set/dict-comprehension element + iterable expressions, f-string format
60/// expressions, and — for any **nested** `def`/`lambda` encountered while walking
61/// — that nested callable's **decorators** and **parameter default** expressions
62/// (they run when the nested `def`/`lambda` statement executes, i.e. in THIS
63/// function's body → charged here).
64///
65/// Does **not** descend into: a nested `def` body or a `Lambda` body (their own
66/// units), nor a **generator-expression** element/condition body (lazy — only its
67/// outermost iterable runs in the enclosing body, so only that is descended). It
68/// also never charges **annotation** expressions (lazy/stringized — Task 9 inspects
69/// them only syntactically).
70///
71/// Crucially it does **not** charge THIS unit's OWN decorators / parameter defaults
72/// to itself: those ran in the unit's *enclosing* scope (when its own `def`
73/// statement executed), not when the unit is called. They are own-body effects of
74/// the enclosing function (or, for a top-level def, of module scope → uncounted),
75/// and are charged there by the enclosing unit's own `walk_own_body` pass.
76pub fn walk_own_body<'a>(unit: &FnUnit<'a>, sink: &mut dyn EffectSink) {
77    match &unit.body {
78        FnBody::Suite(suite) => walk_suite(suite, sink),
79        FnBody::Expr(expr) => walk_expr(expr, sink),
80    }
81}
82
83/// Descend into a nested callable's **decorators** + **parameter default** value
84/// expressions (charged to the CURRENT function), without entering its body. Used
85/// for a nested `def` (decorators + defaults) and a nested `lambda` (defaults only;
86/// Python `lambda`s carry no decorators).
87fn walk_nested_def_header(def: &libcst_native::FunctionDef, sink: &mut dyn EffectSink) {
88    for dec in &def.decorators {
89        walk_decorator(dec, sink);
90    }
91    walk_param_defaults(&def.params, sink);
92}
93
94fn walk_decorator(dec: &Decorator, sink: &mut dyn EffectSink) {
95    walk_expr(&dec.decorator, sink);
96}
97
98fn walk_param_defaults(params: &Parameters, sink: &mut dyn EffectSink) {
99    let all = params
100        .posonly_params
101        .iter()
102        .chain(&params.params)
103        .chain(&params.kwonly_params);
104    for p in all {
105        if let Some(default) = &p.default {
106            walk_expr(default, sink);
107        }
108    }
109    // star_arg / star_kwarg may carry defaults too (rare), handle for completeness.
110    if let Some(libcst_native::StarArg::Param(p)) = &params.star_arg
111        && let Some(default) = &p.default
112    {
113        walk_expr(default, sink);
114    }
115    if let Some(p) = &params.star_kwarg
116        && let Some(default) = &p.default
117    {
118        walk_expr(default, sink);
119    }
120}
121
122// ─── statement traversal ──────────────────────────────────────────────────────
123
124fn walk_suite(suite: &Suite, sink: &mut dyn EffectSink) {
125    match suite {
126        Suite::IndentedBlock(b) => {
127            for stmt in &b.body {
128                walk_statement(stmt, sink);
129            }
130        }
131        Suite::SimpleStatementSuite(s) => {
132            for small in &s.body {
133                walk_small(small, sink);
134            }
135        }
136    }
137}
138
139fn walk_statement(stmt: &Statement, sink: &mut dyn EffectSink) {
140    match stmt {
141        Statement::Simple(line) => {
142            for small in &line.body {
143                walk_small(small, sink);
144            }
145        }
146        Statement::Compound(c) => walk_compound(c, sink),
147    }
148}
149
150fn walk_compound(compound: &CompoundStatement, sink: &mut dyn EffectSink) {
151    match compound {
152        // Nested `def` is its OWN unit — do NOT descend into its body. But its
153        // decorators + parameter defaults run when THIS `def` statement executes
154        // (in the enclosing body) → charge them to the CURRENT function.
155        CompoundStatement::FunctionDef(d) => walk_nested_def_header(d, sink),
156        // A nested class's methods are their own units; do not descend.
157        CompoundStatement::ClassDef(_) => {}
158        CompoundStatement::If(i) => {
159            walk_expr(&i.test, sink);
160            walk_suite(&i.body, sink);
161            if let Some(orelse) = &i.orelse {
162                walk_or_else(orelse, sink);
163            }
164        }
165        CompoundStatement::For(f) => {
166            walk_expr(&f.iter, sink);
167            walk_suite(&f.body, sink);
168            if let Some(orelse) = &f.orelse {
169                walk_suite(&orelse.body, sink);
170            }
171        }
172        CompoundStatement::While(w) => {
173            walk_expr(&w.test, sink);
174            walk_suite(&w.body, sink);
175            if let Some(orelse) = &w.orelse {
176                walk_suite(&orelse.body, sink);
177            }
178        }
179        CompoundStatement::Try(t) => {
180            walk_suite(&t.body, sink);
181            for handler in &t.handlers {
182                walk_suite(&handler.body, sink);
183            }
184            if let Some(orelse) = &t.orelse {
185                walk_suite(&orelse.body, sink);
186            }
187            if let Some(finalbody) = &t.finalbody {
188                walk_suite(&finalbody.body, sink);
189            }
190        }
191        CompoundStatement::TryStar(t) => {
192            walk_suite(&t.body, sink);
193            for handler in &t.handlers {
194                walk_suite(&handler.body, sink);
195            }
196            if let Some(orelse) = &t.orelse {
197                walk_suite(&orelse.body, sink);
198            }
199            if let Some(finalbody) = &t.finalbody {
200                walk_suite(&finalbody.body, sink);
201            }
202        }
203        CompoundStatement::With(w) => {
204            // `with open(...) as f:` — the with-items are evaluated in the enclosing
205            // body, so descend into them (wrapper attribution).
206            for item in &w.items {
207                walk_expr(&item.item, sink);
208            }
209            walk_suite(&w.body, sink);
210        }
211        CompoundStatement::Match(m) => {
212            walk_expr(&m.subject, sink);
213            for case in &m.cases {
214                walk_suite(&case.body, sink);
215            }
216        }
217    }
218}
219
220fn walk_or_else(orelse: &libcst_native::OrElse, sink: &mut dyn EffectSink) {
221    match orelse {
222        libcst_native::OrElse::Elif(elif) => {
223            walk_expr(&elif.test, sink);
224            walk_suite(&elif.body, sink);
225            if let Some(inner) = &elif.orelse {
226                walk_or_else(inner, sink);
227            }
228        }
229        libcst_native::OrElse::Else(e) => {
230            walk_suite(&e.body, sink);
231        }
232    }
233}
234
235fn walk_small(small: &SmallStatement, sink: &mut dyn EffectSink) {
236    match small {
237        SmallStatement::Expr(e) => walk_expr(&e.value, sink),
238        SmallStatement::Return(r) => {
239            if let Some(v) = &r.value {
240                walk_expr(v, sink);
241            }
242        }
243        SmallStatement::Assign(a) => {
244            for target in &a.targets {
245                sink.on_assign_target(&target.target, false);
246                walk_assign_target_subexprs(&target.target, sink);
247            }
248            walk_expr(&a.value, sink);
249        }
250        SmallStatement::AnnAssign(a) => {
251            // The annotation is NOT charged (lazy/stringized). The value IS.
252            sink.on_assign_target(&a.target, false);
253            walk_assign_target_subexprs(&a.target, sink);
254            if let Some(v) = &a.value {
255                walk_expr(v, sink);
256            }
257        }
258        SmallStatement::AugAssign(a) => {
259            sink.on_assign_target(&a.target, true);
260            walk_assign_target_subexprs(&a.target, sink);
261            walk_expr(&a.value, sink);
262        }
263        SmallStatement::Assert(a) => {
264            sink.on_assert(a);
265            walk_expr(&a.test, sink);
266            if let Some(msg) = &a.msg {
267                walk_expr(msg, sink);
268            }
269        }
270        SmallStatement::Raise(r) => {
271            sink.on_raise(r);
272            if let Some(exc) = &r.exc {
273                walk_expr(exc, sink);
274            }
275        }
276        // Pass / Break / Continue / Import / ImportFrom / Global / Nonlocal /
277        // Del / TypeAlias hold no eagerly-evaluated effect sites we charge.
278        _ => {}
279    }
280}
281
282/// Descend into an **assignment target's** eagerly-evaluated sub-expressions, so
283/// effects/risks/awaits *inside* the target are charged to the enclosing body.
284///
285/// Assignment targets evaluate some sub-expressions eagerly: `xs[f()] = v`
286/// evaluates `f()` (the subscript index) and `get_obj().attr = v` evaluates
287/// `get_obj()` (the attribute base). The mutation detector separately classifies
288/// the target's **root** (`xs` / `get_obj`) via `on_assign_target`; this walk only
289/// feeds the target's index/base sub-expressions to `walk_expr`, so it adds the
290/// `f()` / `get_obj()` effects **without** re-classifying (or double-counting) the
291/// target's mutation — `walk_expr` never calls `on_assign_target`, and the
292/// mutation sink's `on_call` only fires for mutating *methods* (an attribute-call
293/// like `requests.get(u)` is not one).
294fn walk_assign_target_subexprs(target: &AssignTargetExpression, sink: &mut dyn EffectSink) {
295    match target {
296        // A bare name target evaluates nothing — the root is the mutation, no sub-exprs.
297        AssignTargetExpression::Name(_) => {}
298        // `obj.attr = v` / `get_obj().attr = v` — the base expression is eagerly
299        // evaluated. Walk it (a bare `obj` Name yields nothing; a `get_obj()` Call
300        // surfaces its effect).
301        AssignTargetExpression::Attribute(a) => walk_expr(&a.value, sink),
302        // `xs[k] = v` / `get_dict()[k] = v` — both the base value AND the index/slice
303        // are eagerly evaluated. Walk both (the base may itself be an effectful call;
304        // the index expression may contain calls/awaits like `xs[f()]`).
305        AssignTargetExpression::Subscript(s) => {
306            walk_expr(&s.value, sink);
307            for element in &s.slice {
308                walk_base_slice(&element.slice, sink);
309            }
310        }
311        // Destructuring targets — recurse into each element's nested target sub-exprs.
312        AssignTargetExpression::Tuple(t) => {
313            for el in &t.elements {
314                walk_target_element(el, sink);
315            }
316        }
317        AssignTargetExpression::List(l) => {
318            for el in &l.elements {
319                walk_target_element(el, sink);
320            }
321        }
322        AssignTargetExpression::StarredElement(s) => walk_target_value(&s.value, sink),
323    }
324}
325
326/// Walk a destructuring-target element (`(a, b[f()]) = …`) for nested target
327/// sub-expressions.
328fn walk_target_element(el: &Element, sink: &mut dyn EffectSink) {
329    match el {
330        Element::Simple { value, .. } => walk_target_value(value, sink),
331        Element::Starred(s) => walk_target_value(&s.value, sink),
332    }
333}
334
335/// Walk a target-position **expression** (an element of a tuple/list target) for
336/// its eagerly-evaluated sub-expressions, mirroring `walk_assign_target_subexprs`
337/// but over an `Expression` (destructuring elements are typed as expressions).
338fn walk_target_value(expr: &Expression, sink: &mut dyn EffectSink) {
339    match expr {
340        Expression::Name(_) => {}
341        Expression::Attribute(a) => walk_expr(&a.value, sink),
342        Expression::Subscript(s) => {
343            walk_expr(&s.value, sink);
344            for element in &s.slice {
345                walk_base_slice(&element.slice, sink);
346            }
347        }
348        Expression::Tuple(t) => {
349            for el in &t.elements {
350                walk_target_element(el, sink);
351            }
352        }
353        Expression::List(l) => {
354            for el in &l.elements {
355                walk_target_element(el, sink);
356            }
357        }
358        Expression::StarredElement(s) => walk_target_value(&s.value, sink),
359        _ => {}
360    }
361}
362
363// ─── expression traversal ─────────────────────────────────────────────────────
364
365fn walk_expr(expr: &Expression, sink: &mut dyn EffectSink) {
366    match expr {
367        Expression::Call(c) => {
368            sink.on_call(c);
369            walk_expr(&c.func, sink);
370            for arg in &c.args {
371                walk_expr(&arg.value, sink);
372            }
373        }
374        // A nested `lambda` is its OWN unit — do NOT descend into its body. But its
375        // parameter defaults run when the `lambda` expression is evaluated (in the
376        // enclosing body) → charge them to the CURRENT function. (Lambdas carry no
377        // decorators in Python.)
378        Expression::Lambda(l) => walk_param_defaults(&l.params, sink),
379
380        Expression::Attribute(a) => {
381            sink.on_attribute_read(expr);
382            walk_expr(&a.value, sink);
383        }
384        Expression::Subscript(s) => {
385            // Fire on_attribute_read for `sys.argv[N]` — the subscript's value may be
386            // `sys.argv` (an Attribute), which the recursive walk_expr will also surface.
387            // The sink is responsible for deduplication if it tracks both forms.
388            walk_expr(&s.value, sink);
389            // The index/slice expression(s) are eagerly evaluated (`xs[f()]`,
390            // `xs[a:b]`) → descend into them too.
391            for element in &s.slice {
392                walk_base_slice(&element.slice, sink);
393            }
394        }
395        Expression::BinaryOperation(b) => {
396            walk_expr(&b.left, sink);
397            walk_expr(&b.right, sink);
398        }
399        Expression::BooleanOperation(b) => {
400            walk_expr(&b.left, sink);
401            walk_expr(&b.right, sink);
402        }
403        Expression::UnaryOperation(u) => walk_expr(&u.expression, sink),
404        Expression::Comparison(c) => {
405            walk_expr(&c.left, sink);
406            for comp in &c.comparisons {
407                walk_expr(&comp.comparator, sink);
408            }
409        }
410        Expression::IfExp(i) => {
411            walk_expr(&i.test, sink);
412            walk_expr(&i.body, sink);
413            walk_expr(&i.orelse, sink);
414        }
415        Expression::Tuple(t) => {
416            for el in &t.elements {
417                walk_element(el, sink);
418            }
419        }
420        Expression::List(l) => {
421            for el in &l.elements {
422                walk_element(el, sink);
423            }
424        }
425        Expression::Set(s) => {
426            for el in &s.elements {
427                walk_element(el, sink);
428            }
429        }
430        Expression::Dict(d) => {
431            for el in &d.elements {
432                match el {
433                    libcst_native::DictElement::Simple { key, value, .. } => {
434                        walk_expr(key, sink);
435                        walk_expr(value, sink);
436                    }
437                    libcst_native::DictElement::Starred(s) => walk_expr(&s.value, sink),
438                }
439            }
440        }
441        // EAGER comprehensions: descend into both the element and the iterable
442        // (both evaluated in the enclosing body).
443        Expression::ListComp(l) => {
444            walk_expr(&l.elt, sink);
445            walk_comp_for(&l.for_in, sink, true);
446        }
447        Expression::SetComp(s) => {
448            walk_expr(&s.elt, sink);
449            walk_comp_for(&s.for_in, sink, true);
450        }
451        Expression::DictComp(d) => {
452            walk_expr(&d.key, sink);
453            walk_expr(&d.value, sink);
454            walk_comp_for(&d.for_in, sink, true);
455        }
456        // LAZY generator expression: only its OUTERMOST iterable runs in the
457        // enclosing body. The element + condition bodies are deferred → NOT charged
458        // (no separate unit — simply uncounted). `eager = false` walks only iterables.
459        Expression::GeneratorExp(g) => {
460            walk_comp_for(&g.for_in, sink, false);
461        }
462        Expression::FormattedString(fs) => {
463            for part in &fs.parts {
464                if let FormattedStringContent::Expression(e) = part {
465                    walk_expr(&e.expression, sink);
466                    // `{x:{width()}}` — the format_spec is itself a sequence of
467                    // FormattedStringContent parts evaluated eagerly.
468                    if let Some(spec_parts) = &e.format_spec {
469                        for sp in spec_parts {
470                            if let FormattedStringContent::Expression(se) = sp {
471                                walk_expr(&se.expression, sink);
472                            }
473                        }
474                    }
475                }
476            }
477        }
478        Expression::Yield(y) => {
479            if let Some(v) = &y.value {
480                match &**v {
481                    libcst_native::YieldValue::Expression(e) => walk_expr(e, sink),
482                    libcst_native::YieldValue::From(f) => walk_expr(&f.item, sink),
483                }
484            }
485        }
486        Expression::Await(a) => walk_expr(&a.expression, sink),
487        Expression::NamedExpr(n) => walk_expr(&n.value, sink),
488        Expression::StarredElement(s) => walk_expr(&s.value, sink),
489
490        // Leaf / non-effectful expressions.
491        _ => {}
492    }
493}
494
495/// Walk a comprehension's `for … in …` clause(s).
496///
497/// `eager`: when `true` (list/set/dict comprehension) the element bodies were
498/// already walked by the caller and we descend into **every** iterable and `if`
499/// filter. When `false` (generator expression — lazy) we descend into **only the
500/// outermost iterable**, never the `if` filters or nested-`for` clauses, since
501/// those run on consumption, not in the enclosing body.
502fn walk_comp_for(comp: &libcst_native::CompFor, sink: &mut dyn EffectSink, eager: bool) {
503    // The outermost iterable always runs in the enclosing body (eager or lazy).
504    walk_expr(&comp.iter, sink);
505    if eager {
506        for cond in &comp.ifs {
507            walk_expr(&cond.test, sink);
508        }
509        if let Some(inner) = &comp.inner_for_in {
510            walk_comp_for(inner, sink, true);
511        }
512    }
513}
514
515fn walk_element(el: &Element, sink: &mut dyn EffectSink) {
516    match el {
517        Element::Simple { value, .. } => walk_expr(value, sink),
518        Element::Starred(s) => walk_expr(&s.value, sink),
519    }
520}
521
522/// Walk a subscript slice (`Index` value, or `Slice` lower/upper/step) for effects.
523fn walk_base_slice(slice: &libcst_native::BaseSlice, sink: &mut dyn EffectSink) {
524    match slice {
525        libcst_native::BaseSlice::Index(i) => walk_expr(&i.value, sink),
526        libcst_native::BaseSlice::Slice(s) => {
527            if let Some(lower) = &s.lower {
528                walk_expr(lower, sink);
529            }
530            if let Some(upper) = &s.upper {
531                walk_expr(upper, sink);
532            }
533            if let Some(step) = &s.step {
534                walk_expr(step, sink);
535            }
536        }
537    }
538}
539
540// ─── await counting ───────────────────────────────────────────────────────────
541
542/// Count `await` expressions in the unit's own body.
543///
544/// Uses a separate recursive pass rather than the `EffectSink` driver because the
545/// driver fires on call/assert/raise/assign, not on `await` as a distinct event.
546/// The attribution rules (no nested `def`/`lambda` bodies) are mirrored manually.
547fn count_awaits(unit: &FnUnit) -> usize {
548    fn count_in_body(body: &FnBody) -> usize {
549        match body {
550            FnBody::Suite(suite) => count_in_suite(suite),
551            FnBody::Expr(expr) => count_in_expr(expr),
552        }
553    }
554
555    fn count_in_suite(suite: &libcst_native::Suite) -> usize {
556        match suite {
557            libcst_native::Suite::IndentedBlock(b) => b.body.iter().map(count_in_stmt).sum(),
558            libcst_native::Suite::SimpleStatementSuite(s) => {
559                s.body.iter().map(count_in_small).sum()
560            }
561        }
562    }
563
564    fn count_in_stmt(stmt: &libcst_native::Statement) -> usize {
565        match stmt {
566            libcst_native::Statement::Simple(line) => line.body.iter().map(count_in_small).sum(),
567            libcst_native::Statement::Compound(c) => count_in_compound(c),
568        }
569    }
570
571    fn count_in_compound(c: &libcst_native::CompoundStatement) -> usize {
572        match c {
573            // Nested def — its body is NOT counted (own attribution), but its
574            // decorators + parameter defaults run in the enclosing body → count
575            // any `await` there.
576            libcst_native::CompoundStatement::FunctionDef(d) => count_in_def_header(d),
577            libcst_native::CompoundStatement::ClassDef(_) => 0,
578            libcst_native::CompoundStatement::If(i) => {
579                count_in_expr(&i.test)
580                    + count_in_suite(&i.body)
581                    + i.orelse.as_ref().map_or(0, |o| count_in_orelse(o))
582            }
583            libcst_native::CompoundStatement::For(f) => {
584                count_in_expr(&f.iter)
585                    + count_in_suite(&f.body)
586                    + f.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
587            }
588            libcst_native::CompoundStatement::While(w) => {
589                count_in_expr(&w.test)
590                    + count_in_suite(&w.body)
591                    + w.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
592            }
593            libcst_native::CompoundStatement::Try(t) => {
594                count_in_suite(&t.body)
595                    + t.handlers
596                        .iter()
597                        .map(|h| count_in_suite(&h.body))
598                        .sum::<usize>()
599                    + t.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
600                    + t.finalbody.as_ref().map_or(0, |e| count_in_suite(&e.body))
601            }
602            libcst_native::CompoundStatement::TryStar(t) => {
603                count_in_suite(&t.body)
604                    + t.handlers
605                        .iter()
606                        .map(|h| count_in_suite(&h.body))
607                        .sum::<usize>()
608                    + t.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
609                    + t.finalbody.as_ref().map_or(0, |e| count_in_suite(&e.body))
610            }
611            libcst_native::CompoundStatement::With(w) => {
612                w.items
613                    .iter()
614                    .map(|item| count_in_expr(&item.item))
615                    .sum::<usize>()
616                    + count_in_suite(&w.body)
617            }
618            libcst_native::CompoundStatement::Match(m) => {
619                count_in_expr(&m.subject)
620                    + m.cases
621                        .iter()
622                        .map(|case| count_in_suite(&case.body))
623                        .sum::<usize>()
624            }
625        }
626    }
627
628    fn count_in_orelse(orelse: &libcst_native::OrElse) -> usize {
629        match orelse {
630            libcst_native::OrElse::Elif(elif) => {
631                count_in_expr(&elif.test)
632                    + count_in_suite(&elif.body)
633                    + elif.orelse.as_ref().map_or(0, |o| count_in_orelse(o))
634            }
635            libcst_native::OrElse::Else(e) => count_in_suite(&e.body),
636        }
637    }
638
639    fn count_in_small(small: &libcst_native::SmallStatement) -> usize {
640        match small {
641            libcst_native::SmallStatement::Expr(e) => count_in_expr(&e.value),
642            libcst_native::SmallStatement::Return(r) => r.value.as_ref().map_or(0, count_in_expr),
643            libcst_native::SmallStatement::Assign(a) => {
644                a.targets
645                    .iter()
646                    .map(|t| count_in_assign_target(&t.target))
647                    .sum::<usize>()
648                    + count_in_expr(&a.value)
649            }
650            libcst_native::SmallStatement::AnnAssign(a) => {
651                count_in_assign_target(&a.target) + a.value.as_ref().map_or(0, count_in_expr)
652            }
653            libcst_native::SmallStatement::AugAssign(a) => {
654                count_in_assign_target(&a.target) + count_in_expr(&a.value)
655            }
656            libcst_native::SmallStatement::Assert(a) => {
657                count_in_expr(&a.test) + a.msg.as_ref().map_or(0, count_in_expr)
658            }
659            libcst_native::SmallStatement::Raise(r) => r.exc.as_ref().map_or(0, count_in_expr),
660            _ => 0,
661        }
662    }
663
664    fn count_in_expr(expr: &libcst_native::Expression) -> usize {
665        match expr {
666            libcst_native::Expression::Await(a) => {
667                // Count the await itself; descend into its inner expression too
668                // (nested awaits inside the awaited expression are possible in theory).
669                1 + count_in_expr(&a.expression)
670            }
671            // Nested lambda — its body is NOT counted (own attribution), but its
672            // parameter defaults run in the enclosing body → count awaits there.
673            libcst_native::Expression::Lambda(l) => count_in_params_defaults(&l.params),
674            libcst_native::Expression::Call(c) => {
675                count_in_expr(&c.func)
676                    + c.args
677                        .iter()
678                        .map(|a| count_in_expr(&a.value))
679                        .sum::<usize>()
680            }
681            libcst_native::Expression::Attribute(a) => count_in_expr(&a.value),
682            libcst_native::Expression::Subscript(s) => {
683                count_in_expr(&s.value)
684                    + s.slice
685                        .iter()
686                        .map(|e| count_in_base_slice(&e.slice))
687                        .sum::<usize>()
688            }
689            libcst_native::Expression::BinaryOperation(b) => {
690                count_in_expr(&b.left) + count_in_expr(&b.right)
691            }
692            libcst_native::Expression::BooleanOperation(b) => {
693                count_in_expr(&b.left) + count_in_expr(&b.right)
694            }
695            libcst_native::Expression::UnaryOperation(u) => count_in_expr(&u.expression),
696            libcst_native::Expression::Comparison(c) => {
697                count_in_expr(&c.left)
698                    + c.comparisons
699                        .iter()
700                        .map(|comp| count_in_expr(&comp.comparator))
701                        .sum::<usize>()
702            }
703            libcst_native::Expression::IfExp(i) => {
704                count_in_expr(&i.test) + count_in_expr(&i.body) + count_in_expr(&i.orelse)
705            }
706            libcst_native::Expression::Tuple(t) => t.elements.iter().map(count_in_element).sum(),
707            libcst_native::Expression::List(l) => l.elements.iter().map(count_in_element).sum(),
708            libcst_native::Expression::Set(s) => s.elements.iter().map(count_in_element).sum(),
709            libcst_native::Expression::Dict(d) => d
710                .elements
711                .iter()
712                .map(|el| match el {
713                    libcst_native::DictElement::Simple { key, value, .. } => {
714                        count_in_expr(key) + count_in_expr(value)
715                    }
716                    libcst_native::DictElement::Starred(s) => count_in_expr(&s.value),
717                })
718                .sum(),
719            libcst_native::Expression::ListComp(l) => {
720                count_in_expr(&l.elt) + count_in_comp_for(&l.for_in)
721            }
722            libcst_native::Expression::SetComp(s) => {
723                count_in_expr(&s.elt) + count_in_comp_for(&s.for_in)
724            }
725            libcst_native::Expression::DictComp(d) => {
726                count_in_expr(&d.key) + count_in_expr(&d.value) + count_in_comp_for(&d.for_in)
727            }
728            // LAZY generator expression: only the outermost iterable runs in the
729            // enclosing body. The element/condition bodies and nested-for clauses
730            // are deferred — awaits there do NOT count toward the enclosing
731            // function's await_count / async_boundary. Mirror walk_comp_for's
732            // `eager = false` branch: only descend into `comp.iter`.
733            libcst_native::Expression::GeneratorExp(g) => count_in_expr(&g.for_in.iter),
734            libcst_native::Expression::FormattedString(fs) => fs
735                .parts
736                .iter()
737                .map(|p| {
738                    if let libcst_native::FormattedStringContent::Expression(e) = p {
739                        let in_expr = count_in_expr(&e.expression);
740                        // `{x:{await w()}}` — format_spec parts are also eager.
741                        let in_spec = e
742                            .format_spec
743                            .as_deref()
744                            .unwrap_or(&[])
745                            .iter()
746                            .map(|sp| {
747                                if let libcst_native::FormattedStringContent::Expression(se) = sp {
748                                    count_in_expr(&se.expression)
749                                } else {
750                                    0
751                                }
752                            })
753                            .sum::<usize>();
754                        in_expr + in_spec
755                    } else {
756                        0
757                    }
758                })
759                .sum(),
760            libcst_native::Expression::Yield(y) => {
761                y.value.as_ref().map_or(0, |v| match v.as_ref() {
762                    libcst_native::YieldValue::Expression(e) => count_in_expr(e),
763                    libcst_native::YieldValue::From(f) => count_in_expr(&f.item),
764                })
765            }
766            libcst_native::Expression::NamedExpr(n) => count_in_expr(&n.value),
767            libcst_native::Expression::StarredElement(s) => count_in_expr(&s.value),
768            _ => 0,
769        }
770    }
771
772    /// Awaits in a nested `def`'s header (decorators + parameter defaults), which
773    /// run in the enclosing body. The def's BODY is not counted (own attribution).
774    fn count_in_def_header(def: &libcst_native::FunctionDef) -> usize {
775        def.decorators
776            .iter()
777            .map(|dec| count_in_expr(&dec.decorator))
778            .sum::<usize>()
779            + count_in_params_defaults(&def.params)
780    }
781
782    /// Awaits in a parameter list's default-value expressions (eager at def-time).
783    fn count_in_params_defaults(params: &libcst_native::Parameters) -> usize {
784        let mut n = 0;
785        let all = params
786            .posonly_params
787            .iter()
788            .chain(&params.params)
789            .chain(&params.kwonly_params);
790        for p in all {
791            if let Some(default) = &p.default {
792                n += count_in_expr(default);
793            }
794        }
795        if let Some(libcst_native::StarArg::Param(p)) = &params.star_arg
796            && let Some(default) = &p.default
797        {
798            n += count_in_expr(default);
799        }
800        if let Some(p) = &params.star_kwarg
801            && let Some(default) = &p.default
802        {
803            n += count_in_expr(default);
804        }
805        n
806    }
807
808    fn count_in_comp_for(comp: &libcst_native::CompFor) -> usize {
809        count_in_expr(&comp.iter)
810            + comp
811                .ifs
812                .iter()
813                .map(|c| count_in_expr(&c.test))
814                .sum::<usize>()
815            + comp
816                .inner_for_in
817                .as_ref()
818                .map_or(0, |inner| count_in_comp_for(inner))
819    }
820
821    /// Count awaits in an assignment **target's** eagerly-evaluated sub-expressions
822    /// (mirrors `walk_assign_target_subexprs`): a subscript target's base + index/
823    /// slice, an attribute target's base, recursing through destructuring elements.
824    fn count_in_assign_target(target: &libcst_native::AssignTargetExpression) -> usize {
825        use libcst_native::AssignTargetExpression as T;
826        match target {
827            T::Name(_) => 0,
828            T::Attribute(a) => count_in_expr(&a.value),
829            T::Subscript(s) => {
830                count_in_expr(&s.value)
831                    + s.slice
832                        .iter()
833                        .map(|e| count_in_base_slice(&e.slice))
834                        .sum::<usize>()
835            }
836            T::Tuple(t) => t.elements.iter().map(count_in_target_element).sum(),
837            T::List(l) => l.elements.iter().map(count_in_target_element).sum(),
838            T::StarredElement(s) => count_in_target_value(&s.value),
839        }
840    }
841
842    /// Count awaits in a destructuring-target element's nested sub-expressions.
843    fn count_in_target_element(el: &libcst_native::Element) -> usize {
844        match el {
845            libcst_native::Element::Simple { value, .. } => count_in_target_value(value),
846            libcst_native::Element::Starred(s) => count_in_target_value(&s.value),
847        }
848    }
849
850    /// Count awaits in a target-position expression (a tuple/list element).
851    fn count_in_target_value(expr: &libcst_native::Expression) -> usize {
852        match expr {
853            libcst_native::Expression::Name(_) => 0,
854            libcst_native::Expression::Attribute(a) => count_in_expr(&a.value),
855            libcst_native::Expression::Subscript(s) => {
856                count_in_expr(&s.value)
857                    + s.slice
858                        .iter()
859                        .map(|e| count_in_base_slice(&e.slice))
860                        .sum::<usize>()
861            }
862            libcst_native::Expression::Tuple(t) => {
863                t.elements.iter().map(count_in_target_element).sum()
864            }
865            libcst_native::Expression::List(l) => {
866                l.elements.iter().map(count_in_target_element).sum()
867            }
868            libcst_native::Expression::StarredElement(s) => count_in_target_value(&s.value),
869            _ => 0,
870        }
871    }
872
873    fn count_in_base_slice(slice: &libcst_native::BaseSlice) -> usize {
874        match slice {
875            libcst_native::BaseSlice::Index(i) => count_in_expr(&i.value),
876            libcst_native::BaseSlice::Slice(s) => {
877                s.lower.as_ref().map_or(0, count_in_expr)
878                    + s.upper.as_ref().map_or(0, count_in_expr)
879                    + s.step.as_ref().map_or(0, count_in_expr)
880            }
881        }
882    }
883
884    fn count_in_element(el: &libcst_native::Element) -> usize {
885        match el {
886            libcst_native::Element::Simple { value, .. } => count_in_expr(value),
887            libcst_native::Element::Starred(s) => count_in_expr(&s.value),
888        }
889    }
890
891    count_in_body(&unit.body)
892}
893
894// ─── unit assembly ────────────────────────────────────────────────────────────
895
896/// Analyze one function-unit into an owned [`Hotspot`].
897///
898/// # Gather → Fold
899/// 1. **gather**: drive each detector over the own body to collect `Vec<Effect>`.
900/// 2. **fold**: compute `own_score`, `max_class`, function-level `confidence`
901///    (weakest-link min over per-effect confidences, plus 0.8 synthetic when there
902///    are unresolved awaited calls), and `await_count` / `async_boundary`.
903///
904/// Adding a detector is a one-line addition to the gather step.
905pub fn analyze_unit(unit: &FnUnit, path: &str, imports: &Imports, span: &SpanIndex) -> Hotspot {
906    // ── gather ───────────────────────────────────────────────────────────────
907    let mut effects = calls::detect(unit, imports, span);
908
909    // Signature annotation coverage + `Any`/decorator signals (Task 9).
910    let cov = coverage::of(unit, imports);
911
912    // Task 8: mutation::detect — escape analysis + contained flag.
913    // Apply the boundary-containment discount per the `contained` flag: a contained
914    // (local-state) effect under an honest, typed boundary shifts down. Body `Any`
915    // re-opens the boundary, so it voids the discount (coverage forced to `None`).
916    // Escaping effects (`contained == false`) are never discounted.
917    let discount_coverage = if cov.any_in_body {
918        BoundaryCoverage::None
919    } else {
920        cov.boundary
921    };
922    let mut_pairs = mutation::detect(unit, span);
923    effects.extend(mut_pairs.into_iter().map(|(mut e, contained)| {
924        // Only record a discount when the boundary actually shifts the class —
925        // i.e. Partial/Full coverage. `None` (incl. a typed boundary voided by a
926        // body `Any`) produces no shift, so we leave `discounted_to`/`discount`
927        // unset rather than claim a no-op discount in the report (mirrors TS).
928        if contained && discount_coverage != BoundaryCoverage::None {
929            e.discounted_to = Some(apply_boundary_discount(e.class, discount_coverage, true));
930            e.discount = Some(
931                match discount_coverage {
932                    BoundaryCoverage::Full => "contained, Full-typed boundary",
933                    BoundaryCoverage::Partial => "contained, Partial-typed boundary",
934                    BoundaryCoverage::None => unreachable!("guarded above"),
935                }
936                .to_string(),
937            );
938            e.sync_weight();
939        }
940        e
941    }));
942    // ── risks ────────────────────────────────────────────────────────────────
943    // The coverage gate owns the `Any`-family `type.escape` risk (class 3, exact):
944    // an explicit `Any` in the signature or body is the `any ≈ unsafe` escape hatch.
945    // Task 10 adds dynamic.code etc. through this same Vec → fold.
946    let mut risks: Vec<RiskFeature> = Vec::new();
947
948    // Task 10: risk::detect — eval/exec/pickle/yaml/importlib/setattr/shell=True.
949    risks.extend(risk::detect(unit, imports, span, path));
950    if cov.any_in_signature || cov.any_in_body {
951        let class = RiskKind::TypeEscape.class();
952        risks.push(RiskFeature {
953            kind: RiskKind::TypeEscape,
954            class,
955            weight: weight_for_class(class),
956            path: path.into(),
957            line: unit.line,
958            evidence: "explicit Any (signature or body) — type-escape hatch".into(),
959            tier: Tier::Exact,
960        });
961    }
962
963    let await_count = count_awaits(unit);
964    let async_boundary = unit.is_async || await_count > 0;
965
966    // ── fold ─────────────────────────────────────────────────────────────────
967    let weights: Vec<u32> = effects.iter().map(|e| e.weight).collect();
968    let classes: Vec<u8> = effects.iter().map(|e| e.effective_class()).collect();
969
970    // Function confidence = weakest-link min of per-effect confidences.
971    // Per the spec: per-effect confidence is NOT serialized; it surfaces only here.
972    // When there are unresolved awaited calls, add a synthetic 0.8 entry —
973    // an async fn that awaits may hide IO effects we cannot see statically
974    // (mirrors the Rust and TS frontends). An unknown decorator may erase the
975    // signature to `Any`, so it lowers confidence (a 0.8 step) without touching
976    // coverage (the written annotations are still real signal).
977    let mut confidences: Vec<f64> = effects.iter().map(|e| e.confidence).collect();
978    if await_count > 0 {
979        confidences.push(0.8);
980    }
981    if cov.unknown_decorator {
982        confidences.push(0.8);
983    }
984
985    // Fold risks into scoring (generalized — Task 9 introduces the first real risk;
986    // Task 10 plugs more into the same Vec). risk_class = max class over features.
987    let risk_class = risks.iter().map(|r| r.class).max().unwrap_or(0);
988    let risk_weight = if risks.is_empty() {
989        0
990    } else {
991        weight_for_class(risk_class)
992    };
993
994    Hotspot {
995        id: format!("{}:{}:{}:{}", path, unit.line, unit.col, unit.symbol),
996        symbol: unit.symbol.clone(),
997        path: path.into(),
998        line: unit.line,
999        max_class: max_class(&classes, risk_class),
1000        own_score: own_score(&weights),
1001        risk_weight,
1002        confidence: function_confidence(&confidences),
1003        async_boundary,
1004        await_count,
1005        effects,
1006        risk_features: risks,
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use fxrank_core::model::Hotspot;
1014
1015    /// Parse `tests/fixtures/<name>.py`, run `analyze_unit` for every collected
1016    /// function-unit, and return the resulting `Vec<Hotspot>`.  Mirrors the
1017    /// `analyze_fixture` helper in `calls.rs` but returns full `Hotspot`s so
1018    /// scoring fields (`own_score`, `max_class`, `confidence`, …) can be asserted.
1019    fn scan_fixture_hotspots(name: &str) -> Vec<Hotspot> {
1020        let src = std::fs::read_to_string(format!("tests/fixtures/{name}.py")).unwrap();
1021        let module = libcst_native::parse_module(&src, None).unwrap();
1022        let imports = Imports::build(&module);
1023        let span = SpanIndex::new(&src);
1024        let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
1025        let (units, _) = crate::functions::collect(&module, &src, &span, &anchors);
1026        units
1027            .iter()
1028            .map(|unit| analyze_unit(unit, &format!("tests/fixtures/{name}.py"), &imports, &span))
1029            .collect()
1030    }
1031
1032    /// FIX 2: a nested `def`'s parameter-default expression runs when the ENCLOSING
1033    /// `def` statement executes → charged to the enclosing function, NOT the nested
1034    /// one. A top-level def's own default runs at module time → uncounted on itself.
1035    #[test]
1036    fn def_header_defaults_charge_to_enclosing_scope() {
1037        let h = scan_fixture_hotspots("attribution");
1038        let net = |sym: &str| {
1039            h.iter()
1040                .find(|x| x.symbol == sym)
1041                .unwrap_or_else(|| panic!("symbol {sym} not found"))
1042                .effects
1043                .iter()
1044                .any(|e| e.kind.wire() == "net.fs.db")
1045        };
1046        // `def inner(x=open(p))` inside `outer` → `open(p)` charged to OUTER.
1047        assert!(
1048            net("outer"),
1049            "open(p) default must be charged to enclosing outer"
1050        );
1051        assert!(
1052            !net("inner"),
1053            "open(p) must NOT be charged to nested inner (its default runs in outer)"
1054        );
1055        // Top-level `def top_default(x=open('f'))` → default runs at module time,
1056        // uncounted on top_default itself.
1057        assert!(
1058            !net("top_default"),
1059            "a top-level def's own param default is module-time → uncounted on itself"
1060        );
1061    }
1062
1063    /// FIX 3: a subscript index/slice expression is eagerly evaluated and must be
1064    /// traversed for effects (and awaits). `xs[requests.get(u)]` → net.fs.db.
1065    #[test]
1066    fn subscript_index_expression_is_traversed() {
1067        let h = scan_fixture_hotspots("attribution");
1068        let si = h.iter().find(|x| x.symbol == "subscript_index").unwrap();
1069        assert!(
1070            si.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
1071            "subscript index requests.get(u) must surface net.fs.db, got: {:?}",
1072            si.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1073        );
1074    }
1075
1076    /// Copilot FIX 1: an assignment TARGET's sub-expressions are eagerly evaluated
1077    /// and must be traversed for effects — a subscript target's index and an
1078    /// attribute target's base. CRITICALLY, the index/base walk must NOT
1079    /// double-count the target's own mutation: `xs[requests.get(u)] = 1` charges
1080    /// NetFsDb (from the index) AND exactly ONE param.mutation for `xs`.
1081    ///
1082    /// Pre-fix `walk_small`'s Assign/AnnAssign/AugAssign arms only called
1083    /// `on_assign_target` then walked the VALUE — never the target's sub-exprs — so
1084    /// the index/base call effects were silently dropped.
1085    #[test]
1086    fn assign_target_subexprs_are_traversed_without_double_counting() {
1087        let h = scan_fixture_hotspots("attribution");
1088
1089        // ── subscript-index arm: `xs[requests.get(u)] = 1` ──────────────────────
1090        let s = h
1091            .iter()
1092            .find(|x| x.symbol == "assign_target_subscript_index")
1093            .unwrap();
1094        let net_count = s
1095            .effects
1096            .iter()
1097            .filter(|e| e.kind.wire() == "net.fs.db")
1098            .count();
1099        assert_eq!(
1100            net_count,
1101            1,
1102            "subscript-target index requests.get(u) must surface exactly one net.fs.db, got: {:?}",
1103            s.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1104        );
1105        // Double-count guard: the param mutation of `xs` must be emitted EXACTLY once.
1106        let param_mut_count = s
1107            .effects
1108            .iter()
1109            .filter(|e| e.kind.wire() == "param.mutation")
1110            .count();
1111        assert_eq!(
1112            param_mut_count,
1113            1,
1114            "the subscript target `xs` must emit exactly ONE param.mutation (no double-count), got: {:?}",
1115            s.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1116        );
1117
1118        // ── attribute-base arm: `requests.get(u).attr = 1` ──────────────────────
1119        let a = h
1120            .iter()
1121            .find(|x| x.symbol == "assign_target_attr_base")
1122            .unwrap();
1123        assert!(
1124            a.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
1125            "attribute-target base requests.get(u) must surface net.fs.db, got: {:?}",
1126            a.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1127        );
1128    }
1129
1130    /// FIX 3 (await arm): an `await` in a subscript index counts toward await_count.
1131    #[test]
1132    fn subscript_index_await_counts() {
1133        let src = "async def f(xs):\n    return xs[await key()]\n";
1134        let module = libcst_native::parse_module(src, None).unwrap();
1135        let imports = Imports::build(&module);
1136        let span = SpanIndex::new(src);
1137        let anchors = crate::source::lambda_anchors(src).unwrap();
1138        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1139        let f = units.iter().find(|u| u.symbol == "f").unwrap();
1140        let h = analyze_unit(f, "x.py", &imports, &span);
1141        assert!(
1142            h.await_count >= 1,
1143            "await in subscript index must count, got await_count={}",
1144            h.await_count
1145        );
1146    }
1147
1148    /// Copilot FIX 2: an `await` in an assignment TARGET's sub-expression must be
1149    /// counted by `count_awaits`. `xs[await f()] = 1` — the await lives in the
1150    /// subscript-target index, which pre-fix `count_in_small` never visited (it
1151    /// only counted `a.value`), so await_count was 0.
1152    #[test]
1153    fn assign_target_subscript_index_await_counts() {
1154        let src = "async def f(xs):\n    xs[await key()] = 1\n";
1155        let module = libcst_native::parse_module(src, None).unwrap();
1156        let imports = Imports::build(&module);
1157        let span = SpanIndex::new(src);
1158        let anchors = crate::source::lambda_anchors(src).unwrap();
1159        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1160        let f = units.iter().find(|u| u.symbol == "f").unwrap();
1161        let h = analyze_unit(f, "x.py", &imports, &span);
1162        assert!(
1163            h.await_count >= 1,
1164            "await in an assignment-target subscript index must count, got await_count={}",
1165            h.await_count
1166        );
1167        assert!(
1168            h.async_boundary,
1169            "await in an assignment-target subscript index must set async_boundary"
1170        );
1171    }
1172
1173    /// FIX 5: a function-local `import subprocess` must be collected file-wide so
1174    /// `subprocess.run(c, shell=True)` resolves → process.control effect AND
1175    /// dynamic.code risk. Pre-fix, `Imports::build` scanned only top-level
1176    /// statements, so neither resolved.
1177    #[test]
1178    fn function_local_import_resolves_effect_and_risk() {
1179        let h = scan_fixture_hotspots("local_import");
1180        let f = h.iter().find(|x| x.symbol == "f").unwrap();
1181        assert!(
1182            f.effects.iter().any(|e| e.kind.wire() == "process.control"),
1183            "function-local import must resolve subprocess.run → process.control, got: {:?}",
1184            f.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1185        );
1186        assert!(
1187            f.risk_features
1188                .iter()
1189                .any(|r| r.kind.wire() == "dynamic.code"),
1190            "shell=True must emit dynamic.code once the local import resolves"
1191        );
1192    }
1193
1194    #[test]
1195    fn analyze_unit_scores_world_effects() {
1196        let h = scan_fixture_hotspots("calls");
1197        let io = h.iter().find(|x| x.symbol == "io_boundary").unwrap();
1198        // open(…) + requests.get(…) → NetFsDb class 7, weight 21 each.
1199        // logging.info(…)           → Logging class 4, weight 5.
1200        // weights = [21, 21, 5] → own_score = 21 + 0.5*(21+5) = 34.0
1201        assert_eq!(io.max_class, 7);
1202        assert!(
1203            io.own_score >= 21.0,
1204            "expected own_score >= 21.0, got {}",
1205            io.own_score
1206        );
1207    }
1208
1209    // ── Task 9: boundary discount + Any poison + decorator confidence ──────────
1210
1211    /// Parse a tiny `src` module and return `coverage::of` for the unit named `symbol`.
1212    fn coverage_of_symbol(src: &str, symbol: &str) -> crate::coverage::Coverage {
1213        let module = libcst_native::parse_module(src, None).unwrap();
1214        let imports = Imports::build(&module);
1215        let span = SpanIndex::new(src);
1216        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
1217        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1218        let unit = units
1219            .iter()
1220            .find(|u| u.symbol == symbol)
1221            .expect("unit not found");
1222        crate::coverage::of(unit, &imports)
1223    }
1224
1225    #[test]
1226    fn boundary_discount_zeros_contained_local_when_typed() {
1227        let h = scan_fixture_hotspots("coverage");
1228        let ft = h.iter().find(|x| x.symbol == "fully_typed").unwrap();
1229        assert_eq!(ft.own_score, 0.0); // local.mutation class 1 → 0 under Full coverage
1230    }
1231
1232    #[test]
1233    fn any_emits_type_escape_and_blocks_discount() {
1234        let h = scan_fixture_hotspots("coverage");
1235        // RiskFeature.kind is a RiskKind enum (effect.rs), not a String — compare via .wire().
1236        let has_type_escape = h
1237            .iter()
1238            .find(|x| x.symbol == "has_any")
1239            .unwrap()
1240            .risk_features
1241            .iter()
1242            .any(|r| r.kind.wire() == "type.escape");
1243        assert!(has_type_escape); // signature Any → type.escape
1244        let ba = h.iter().find(|x| x.symbol == "body_any").unwrap();
1245        assert!(
1246            ba.risk_features
1247                .iter()
1248                .any(|r| r.kind.wire() == "type.escape")
1249        ); // body Any → type.escape
1250        assert!(ba.own_score >= 1.0); // discount voided → local.mutation stays class 1
1251    }
1252
1253    /// FIX 4: body-`Any` detection must descend into list/tuple/dict literals,
1254    /// f-strings, and comprehensions. A fully-typed contained-mutation fn with a
1255    /// `cast(Any, …)` in such an eager context must emit `type.escape` AND have its
1256    /// boundary discount voided (local.mutation stays class 1, own_score >= 1.0).
1257    #[test]
1258    fn body_any_in_eager_containers_emits_escape_and_voids_discount() {
1259        let h = scan_fixture_hotspots("coverage");
1260        for sym in [
1261            "body_any_in_list",
1262            "body_any_in_fstring",
1263            "body_any_in_comprehension",
1264        ] {
1265            let f = h.iter().find(|x| x.symbol == sym).unwrap();
1266            assert!(
1267                f.risk_features
1268                    .iter()
1269                    .any(|r| r.kind.wire() == "type.escape"),
1270                "{sym}: body Any in an eager container must emit type.escape"
1271            );
1272            assert!(
1273                f.own_score >= 1.0,
1274                "{sym}: body Any must void the discount (local.mutation stays class 1), \
1275                 got own_score={}",
1276                f.own_score
1277            );
1278        }
1279    }
1280
1281    /// FIX 6: when the boundary discount fires the effect's human-readable
1282    /// `discount` rationale must be set (mirrors the TS frontend).
1283    #[test]
1284    fn discounted_effect_sets_rationale_string() {
1285        let h = scan_fixture_hotspots("coverage");
1286        let ft = h.iter().find(|x| x.symbol == "fully_typed").unwrap();
1287        let lm = ft
1288            .effects
1289            .iter()
1290            .find(|e| e.kind.wire() == "local.mutation")
1291            .expect("fully_typed must have a local.mutation effect");
1292        assert_eq!(
1293            lm.discount.as_deref(),
1294            Some("contained, Full-typed boundary"),
1295            "discounted effect must carry the Full-boundary rationale"
1296        );
1297    }
1298
1299    #[test]
1300    fn coverage_tiers_and_decorator_confidence() {
1301        let h = scan_fixture_hotspots("coverage");
1302        let score = |s: &str| h.iter().find(|x| x.symbol == s).unwrap().own_score;
1303        assert_eq!(score("untyped"), 1.0); // None coverage → local.mutation stays class 1
1304        assert_eq!(score("partial"), 0.0); // any coverage > None floors class-1 local to 0
1305        let dec = h.iter().find(|x| x.symbol == "decorated").unwrap();
1306        assert!(dec.confidence < 1.0); // unknown decorator reduces confidence
1307    }
1308
1309    #[test]
1310    fn coverage_excludes_self_and_degrades_untyped_star_args() {
1311        use fxrank_core::score::BoundaryCoverage;
1312        let src = "class C:\n    def m(self, x: int) -> int:\n        return x\ndef v(*args) -> int:\n    return 0\n";
1313        let cov_m = coverage_of_symbol(src, "m");
1314        assert_eq!(cov_m.boundary, BoundaryCoverage::Full); // self excluded → (x, return) both typed
1315        let cov_v = coverage_of_symbol(src, "v");
1316        assert_ne!(cov_v.boundary, BoundaryCoverage::Full); // untyped *args degrades coverage
1317    }
1318
1319    /// Regression for FIX B (Copilot round-2): `count_awaits` must NOT count
1320    /// `await` expressions that appear in a generator-expression `if` condition
1321    /// or a nested `for` clause's iterable — those are lazy and execute in the
1322    /// consumer's scope, not the enclosing function's body. Only the genexp's
1323    /// **outermost iterable** is eager and therefore counted.
1324    ///
1325    /// The concrete pre-fix bug: old code called `count_in_comp_for(&g.for_in)`
1326    /// which descended into `comp.ifs` (if conditions) and `inner_for_in` (nested
1327    /// for clauses) — both lazy in a genexp. The fix is `count_in_expr(&g.for_in.iter)`
1328    /// (outermost iterable only), mirroring `walk_comp_for`'s `eager = false` branch.
1329    ///
1330    /// Contrast with a list-comprehension: its `if` conditions ARE eager and
1331    /// their awaits DO count.
1332    ///
1333    /// Uses `tests/fixtures/genexp_await.py`.
1334    #[test]
1335    fn count_awaits_genexp_if_and_nested_for_are_lazy_outermost_iterable_is_eager() {
1336        let h = scan_fixture_hotspots("genexp_await");
1337        let find = |sym: &str| {
1338            h.iter()
1339                .find(|x| x.symbol == sym)
1340                .unwrap_or_else(|| panic!("symbol {sym} not found in hotspots"))
1341        };
1342
1343        // `genexp_await_in_if_condition`: `await predicate(x)` is in the genexp
1344        // IF condition — lazy. await_count must be 0.
1345        // (Pre-fix: old code used count_in_comp_for which visited comp.ifs, so
1346        // it would return await_count=1. This test fails on old code, passes with fix.)
1347        let lazy_if = find("genexp_await_in_if_condition");
1348        assert_eq!(
1349            lazy_if.await_count, 0,
1350            "genexp `if` condition await must NOT count toward enclosing await_count; \
1351             got await_count={} for genexp_await_in_if_condition",
1352            lazy_if.await_count
1353        );
1354
1355        // `listcomp_await_in_if_condition`: `await predicate(x)` is in a LIST-COMP
1356        // IF condition — eager. await_count must be >= 1.
1357        let eager_listcomp = find("listcomp_await_in_if_condition");
1358        assert!(
1359            eager_listcomp.await_count >= 1,
1360            "list-comp `if` condition await IS eager and MUST count toward await_count; \
1361             got await_count={} for listcomp_await_in_if_condition",
1362            eager_listcomp.await_count
1363        );
1364        assert!(
1365            eager_listcomp.async_boundary,
1366            "list-comp `if` condition await must set async_boundary; \
1367             got async_boundary={} for listcomp_await_in_if_condition",
1368            eager_listcomp.async_boundary
1369        );
1370
1371        // `genexp_await_in_nested_for_iterable`: `await get_items()` is in a
1372        // NESTED for clause's iterable inside a genexp — lazy. await_count must be 0.
1373        // (Pre-fix: old code used count_in_comp_for which recursed into inner_for_in.)
1374        let lazy_nested = find("genexp_await_in_nested_for_iterable");
1375        assert_eq!(
1376            lazy_nested.await_count, 0,
1377            "genexp nested-for iterable await must NOT count toward enclosing await_count; \
1378             got await_count={} for genexp_await_in_nested_for_iterable",
1379            lazy_nested.await_count
1380        );
1381
1382        // `genexp_await_in_outermost_iterable`: `await get_items()` is in the
1383        // OUTERMOST iterable — always eager. Must count.
1384        let eager_iterable = find("genexp_await_in_outermost_iterable");
1385        assert!(
1386            eager_iterable.await_count >= 1,
1387            "genexp outermost-iterable await IS eager and MUST count; \
1388             got await_count={} for genexp_await_in_outermost_iterable",
1389            eager_iterable.await_count
1390        );
1391    }
1392
1393    /// Copilot FIX 2: `walk_expr` must traverse f-string `format_spec` expression parts.
1394    /// `f"{x:{requests.get(u)}}"` — `requests.get(u)` is in the format-spec and is eager.
1395    #[test]
1396    fn fstring_format_spec_walk_expr_charges_effects() {
1397        let src = "import requests\ndef f(x, u):\n    return f\"{x:{requests.get(u)}}\"\n";
1398        let module = libcst_native::parse_module(src, None).unwrap();
1399        let imports = Imports::build(&module);
1400        let span = SpanIndex::new(src);
1401        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
1402        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1403        let unit = units.iter().find(|u| u.symbol == "f").unwrap();
1404        let h = analyze_unit(unit, "x.py", &imports, &span);
1405        assert!(
1406            h.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
1407            "requests.get(u) inside f-string format_spec must emit net.fs.db; got: {:?}",
1408            h.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1409        );
1410    }
1411
1412    /// Copilot FIX 3: `count_awaits` must count awaits in f-string `format_spec`.
1413    /// `f"{x:{await w()}}"` — the `await` is in the format-spec, which is eager.
1414    #[test]
1415    fn fstring_format_spec_await_counts() {
1416        let src = "async def f(x):\n    async def w(): ...\n    return f\"{x:{await w()}}\"\n";
1417        let module = libcst_native::parse_module(src, None).unwrap();
1418        let imports = Imports::build(&module);
1419        let span = SpanIndex::new(src);
1420        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
1421        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1422        let outer = units.iter().find(|u| u.symbol == "f").unwrap();
1423        let h = analyze_unit(outer, "x.py", &imports, &span);
1424        assert!(
1425            h.await_count >= 1,
1426            "await inside f-string format_spec must count; got await_count={}",
1427            h.await_count
1428        );
1429        assert!(
1430            h.async_boundary,
1431            "await inside f-string format_spec must set async_boundary"
1432        );
1433    }
1434
1435    /// Copilot FIX 4: body-`Any` detection must descend into f-string `format_spec`.
1436    /// `cast(Any, x)` inside a format-spec must emit `type.escape` and void the discount.
1437    #[test]
1438    fn fstring_format_spec_body_any_emits_type_escape() {
1439        let src = "from typing import Any, cast\ndef f(x: int, y: int) -> int:\n    acc: list[int] = []\n    _ = f\"{x:{cast(Any, y)}}\"\n    return x\n";
1440        let module = libcst_native::parse_module(src, None).unwrap();
1441        let imports = Imports::build(&module);
1442        let span = SpanIndex::new(src);
1443        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
1444        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1445        let unit = units.iter().find(|u| u.symbol == "f").unwrap();
1446        let h = analyze_unit(unit, "x.py", &imports, &span);
1447        assert!(
1448            h.risk_features
1449                .iter()
1450                .any(|r| r.kind.wire() == "type.escape"),
1451            "cast(Any, …) inside f-string format_spec must emit type.escape; got: {:?}",
1452            h.risk_features
1453                .iter()
1454                .map(|r| r.kind.wire())
1455                .collect::<Vec<_>>()
1456        );
1457    }
1458}