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