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 refs;
17pub mod risk;
18
19use std::collections::HashSet;
20
21use crate::coverage;
22use crate::functions::{FnBody, FnUnit};
23use crate::imports::Imports;
24use crate::source::SpanIndex;
25use fxrank_core::confidence::function_confidence;
26use fxrank_core::effect::{RiskFeature, RiskKind, Tier};
27use fxrank_core::model::Hotspot;
28use fxrank_core::score::{
29    BoundaryCoverage, apply_boundary_discount, max_class, own_score, weight_for_class,
30};
31
32use libcst_native::{
33    Assert, AssignTargetExpression, Call, CompoundStatement, Decorator, Element, Expression,
34    FormattedStringContent, Parameters, Raise, SmallStatement, Statement, Suite,
35};
36
37/// A sink that receives the **eagerly-evaluated** effect sites of a function's own
38/// body, as decided by [`walk_own_body`]. Each method is a `classify_* → push` hook.
39pub trait EffectSink {
40    /// A function/method call evaluated in the enclosing body.
41    fn on_call(&mut self, call: &Call);
42    /// A bare `assert` statement (conditional abort; stripped under `-O`).
43    fn on_assert(&mut self, assert: &Assert);
44    /// A `raise` statement.
45    fn on_raise(&mut self, raise: &Raise);
46    /// An assignment target that may be an env write (`os.environ[...] = …`) or a
47    /// mutation. `is_aug` is true for an augmented assignment (`+=`, `|=`, …),
48    /// false for a plain `=`. A plain `=` to a **bare local name** is a *binding*,
49    /// not a mutation of pre-existing state (spec §"effect table": `local.mutation`
50    /// is `.append()` / `d[k] = …` / `+=` on a locally-created binding — not the
51    /// binding itself); subscript/attribute `=` targets still mutate.
52    fn on_assign_target(&mut self, target: &AssignTargetExpression, is_aug: bool);
53    /// An attribute read that may be an ambient-read signal (e.g. `sys.argv`).
54    /// Default: no-op (most sinks don't care).
55    fn on_attribute_read(&mut self, _attr: &Expression) {}
56}
57
58/// Walk a function-unit's **own body** and drive `sink` over every effect site that
59/// is *evaluated in the enclosing body*, per the spec's attribution rules.
60///
61/// Descends into: the body suite (or lambda body expr), `with`-items, **eager**
62/// list/set/dict-comprehension element + iterable expressions, f-string format
63/// expressions, and — for any **nested** `def`/`lambda` encountered while walking
64/// — that nested callable's **decorators** and **parameter default** expressions
65/// (they run when the nested `def`/`lambda` statement executes, i.e. in THIS
66/// function's body → charged here).
67///
68/// Does **not** descend into: a nested `def` body or a `Lambda` body (their own
69/// units), nor a **generator-expression** element/condition body (lazy — only its
70/// outermost iterable runs in the enclosing body, so only that is descended). It
71/// also never charges **annotation** expressions (lazy/stringized — Task 9 inspects
72/// them only syntactically).
73///
74/// Crucially it does **not** charge THIS unit's OWN decorators / parameter defaults
75/// to itself: those ran in the unit's *enclosing* scope (when its own `def`
76/// statement executed), not when the unit is called. They are own-body effects of
77/// the enclosing function (or, for a top-level def, of module scope → uncounted),
78/// and are charged there by the enclosing unit's own `walk_own_body` pass.
79pub fn walk_own_body<'a>(unit: &FnUnit<'a>, sink: &mut dyn EffectSink) {
80    match &unit.body {
81        FnBody::Suite(suite) => walk_suite(suite, sink),
82        FnBody::Expr(expr) => walk_expr(expr, sink),
83        // The synthetic `<module>` unit: walk each top-level statement.
84        // Special handling for top-level `ClassDef`: Python class bodies run at
85        // class-definition / import time, so class-level statements ARE import-time
86        // effects of `<module>`.  Walk the class body suite directly so that
87        // `class C: DATA = open("y")` charges `open("y")` to `<module>`.
88        // Nested `def` / method bodies inside the class are still their own units;
89        // `walk_suite` → `walk_compound` → `FunctionDef` → `walk_nested_def_header`
90        // already handles that (decorators + defaults only, NOT the method body).
91        // For all other statements (including top-level `FunctionDef`), the existing
92        // `walk_statement` → `walk_compound` boundary is correct:
93        //   top-level FunctionDef → `walk_nested_def_header` (decorators + defaults, NOT body)
94        //   everything else       → descend normally
95        FnBody::Module(stmts) => {
96            for stmt in *stmts {
97                match stmt {
98                    Statement::Compound(CompoundStatement::ClassDef(class_def)) => {
99                        // Walk the class body to capture class-level import-time effects.
100                        // `walk_suite` already makes nested `def` (methods) charge only
101                        // their header (decorators/defaults), never their bodies.
102                        walk_suite(&class_def.body, sink);
103                        // Also walk the class HEADER expressions, which run at
104                        // class-definition / import time (per guideline):
105                        //   @register(handler)          — decorator expression
106                        //   class C(make_base(), metaclass=M())  — base/keyword args
107                        // These are import-time effects charged to `<module>`, mirroring
108                        // how `walk_nested_def_header` handles a function's decorators and
109                        // parameter defaults (run in the enclosing scope, not inside).
110                        walk_class_header(class_def, sink);
111                    }
112                    other => walk_statement(other, sink),
113                }
114            }
115        }
116    }
117}
118
119/// Descend into a nested callable's **decorators** + **parameter default** value
120/// expressions (charged to the CURRENT function), without entering its body. Used
121/// for a nested `def` (decorators + defaults) and a nested `lambda` (defaults only;
122/// Python `lambda`s carry no decorators).
123fn walk_nested_def_header(def: &libcst_native::FunctionDef, sink: &mut dyn EffectSink) {
124    for dec in &def.decorators {
125        walk_decorator(dec, sink);
126    }
127    walk_param_defaults(&def.params, sink);
128}
129
130/// Walk a top-level `ClassDef`'s **header expressions** — decorators and
131/// base-class / keyword-argument expressions — which run at class-definition /
132/// import time and are therefore import-time effects of `<module>`.
133///
134/// Mirrors `walk_nested_def_header` (function decorators + param defaults).
135/// Does NOT descend into the class body (handled separately by the caller).
136fn walk_class_header(class_def: &libcst_native::ClassDef, sink: &mut dyn EffectSink) {
137    for dec in &class_def.decorators {
138        walk_decorator(dec, sink);
139    }
140    // `bases` covers positional base-class args: `class C(Base(), ...)`.
141    // `keywords` covers keyword args: `class C(metaclass=M())`.
142    // Both are `Vec<Arg>` with a `value: Expression` field.
143    for arg in class_def.bases.iter().chain(class_def.keywords.iter()) {
144        walk_expr(&arg.value, sink);
145    }
146}
147
148fn walk_decorator(dec: &Decorator, sink: &mut dyn EffectSink) {
149    walk_expr(&dec.decorator, sink);
150}
151
152fn walk_param_defaults(params: &Parameters, sink: &mut dyn EffectSink) {
153    let all = params
154        .posonly_params
155        .iter()
156        .chain(&params.params)
157        .chain(&params.kwonly_params);
158    for p in all {
159        if let Some(default) = &p.default {
160            walk_expr(default, sink);
161        }
162    }
163    // star_arg / star_kwarg may carry defaults too (rare), handle for completeness.
164    if let Some(libcst_native::StarArg::Param(p)) = &params.star_arg
165        && let Some(default) = &p.default
166    {
167        walk_expr(default, sink);
168    }
169    if let Some(p) = &params.star_kwarg
170        && let Some(default) = &p.default
171    {
172        walk_expr(default, sink);
173    }
174}
175
176// ─── statement traversal ──────────────────────────────────────────────────────
177
178fn walk_suite(suite: &Suite, sink: &mut dyn EffectSink) {
179    match suite {
180        Suite::IndentedBlock(b) => {
181            for stmt in &b.body {
182                walk_statement(stmt, sink);
183            }
184        }
185        Suite::SimpleStatementSuite(s) => {
186            for small in &s.body {
187                walk_small(small, sink);
188            }
189        }
190    }
191}
192
193fn walk_statement(stmt: &Statement, sink: &mut dyn EffectSink) {
194    match stmt {
195        Statement::Simple(line) => {
196            for small in &line.body {
197                walk_small(small, sink);
198            }
199        }
200        Statement::Compound(c) => walk_compound(c, sink),
201    }
202}
203
204fn walk_compound(compound: &CompoundStatement, sink: &mut dyn EffectSink) {
205    match compound {
206        // Nested `def` is its OWN unit — do NOT descend into its body. But its
207        // decorators + parameter defaults run when THIS `def` statement executes
208        // (in the enclosing body) → charge them to the CURRENT function.
209        CompoundStatement::FunctionDef(d) => walk_nested_def_header(d, sink),
210        // A nested class's methods are their own units; do not descend.
211        CompoundStatement::ClassDef(_) => {}
212        CompoundStatement::If(i) => {
213            walk_expr(&i.test, sink);
214            walk_suite(&i.body, sink);
215            if let Some(orelse) = &i.orelse {
216                walk_or_else(orelse, sink);
217            }
218        }
219        CompoundStatement::For(f) => {
220            walk_expr(&f.iter, sink);
221            walk_suite(&f.body, sink);
222            if let Some(orelse) = &f.orelse {
223                walk_suite(&orelse.body, sink);
224            }
225        }
226        CompoundStatement::While(w) => {
227            walk_expr(&w.test, sink);
228            walk_suite(&w.body, sink);
229            if let Some(orelse) = &w.orelse {
230                walk_suite(&orelse.body, sink);
231            }
232        }
233        CompoundStatement::Try(t) => {
234            walk_suite(&t.body, sink);
235            for handler in &t.handlers {
236                walk_suite(&handler.body, sink);
237            }
238            if let Some(orelse) = &t.orelse {
239                walk_suite(&orelse.body, sink);
240            }
241            if let Some(finalbody) = &t.finalbody {
242                walk_suite(&finalbody.body, sink);
243            }
244        }
245        CompoundStatement::TryStar(t) => {
246            walk_suite(&t.body, sink);
247            for handler in &t.handlers {
248                walk_suite(&handler.body, sink);
249            }
250            if let Some(orelse) = &t.orelse {
251                walk_suite(&orelse.body, sink);
252            }
253            if let Some(finalbody) = &t.finalbody {
254                walk_suite(&finalbody.body, sink);
255            }
256        }
257        CompoundStatement::With(w) => {
258            // `with open(...) as f:` — the with-items are evaluated in the enclosing
259            // body, so descend into them (wrapper attribution).
260            for item in &w.items {
261                walk_expr(&item.item, sink);
262            }
263            walk_suite(&w.body, sink);
264        }
265        CompoundStatement::Match(m) => {
266            walk_expr(&m.subject, sink);
267            for case in &m.cases {
268                walk_suite(&case.body, sink);
269            }
270        }
271    }
272}
273
274fn walk_or_else(orelse: &libcst_native::OrElse, sink: &mut dyn EffectSink) {
275    match orelse {
276        libcst_native::OrElse::Elif(elif) => {
277            walk_expr(&elif.test, sink);
278            walk_suite(&elif.body, sink);
279            if let Some(inner) = &elif.orelse {
280                walk_or_else(inner, sink);
281            }
282        }
283        libcst_native::OrElse::Else(e) => {
284            walk_suite(&e.body, sink);
285        }
286    }
287}
288
289fn walk_small(small: &SmallStatement, sink: &mut dyn EffectSink) {
290    match small {
291        SmallStatement::Expr(e) => walk_expr(&e.value, sink),
292        SmallStatement::Return(r) => {
293            if let Some(v) = &r.value {
294                walk_expr(v, sink);
295            }
296        }
297        SmallStatement::Assign(a) => {
298            for target in &a.targets {
299                sink.on_assign_target(&target.target, false);
300                walk_assign_target_subexprs(&target.target, sink);
301            }
302            walk_expr(&a.value, sink);
303        }
304        SmallStatement::AnnAssign(a) => {
305            // The annotation is NOT charged (lazy/stringized). The value IS.
306            sink.on_assign_target(&a.target, false);
307            walk_assign_target_subexprs(&a.target, sink);
308            if let Some(v) = &a.value {
309                walk_expr(v, sink);
310            }
311        }
312        SmallStatement::AugAssign(a) => {
313            sink.on_assign_target(&a.target, true);
314            walk_assign_target_subexprs(&a.target, sink);
315            walk_expr(&a.value, sink);
316        }
317        SmallStatement::Assert(a) => {
318            sink.on_assert(a);
319            walk_expr(&a.test, sink);
320            if let Some(msg) = &a.msg {
321                walk_expr(msg, sink);
322            }
323        }
324        SmallStatement::Raise(r) => {
325            sink.on_raise(r);
326            if let Some(exc) = &r.exc {
327                walk_expr(exc, sink);
328            }
329        }
330        // Pass / Break / Continue / Import / ImportFrom / Global / Nonlocal /
331        // Del / TypeAlias hold no eagerly-evaluated effect sites we charge.
332        _ => {}
333    }
334}
335
336/// Descend into an **assignment target's** eagerly-evaluated sub-expressions, so
337/// effects/risks/awaits *inside* the target are charged to the enclosing body.
338///
339/// Assignment targets evaluate some sub-expressions eagerly: `xs[f()] = v`
340/// evaluates `f()` (the subscript index) and `get_obj().attr = v` evaluates
341/// `get_obj()` (the attribute base). The mutation detector separately classifies
342/// the target's **root** (`xs` / `get_obj`) via `on_assign_target`; this walk only
343/// feeds the target's index/base sub-expressions to `walk_expr`, so it adds the
344/// `f()` / `get_obj()` effects **without** re-classifying (or double-counting) the
345/// target's mutation — `walk_expr` never calls `on_assign_target`, and the
346/// mutation sink's `on_call` only fires for mutating *methods* (an attribute-call
347/// like `requests.get(u)` is not one).
348fn walk_assign_target_subexprs(target: &AssignTargetExpression, sink: &mut dyn EffectSink) {
349    match target {
350        // A bare name target evaluates nothing — the root is the mutation, no sub-exprs.
351        AssignTargetExpression::Name(_) => {}
352        // `obj.attr = v` / `get_obj().attr = v` — the base expression is eagerly
353        // evaluated. Walk it (a bare `obj` Name yields nothing; a `get_obj()` Call
354        // surfaces its effect).
355        AssignTargetExpression::Attribute(a) => walk_expr(&a.value, sink),
356        // `xs[k] = v` / `get_dict()[k] = v` — both the base value AND the index/slice
357        // are eagerly evaluated. Walk both (the base may itself be an effectful call;
358        // the index expression may contain calls/awaits like `xs[f()]`).
359        AssignTargetExpression::Subscript(s) => {
360            walk_expr(&s.value, sink);
361            for element in &s.slice {
362                walk_base_slice(&element.slice, sink);
363            }
364        }
365        // Destructuring targets — recurse into each element's nested target sub-exprs.
366        AssignTargetExpression::Tuple(t) => {
367            for el in &t.elements {
368                walk_target_element(el, sink);
369            }
370        }
371        AssignTargetExpression::List(l) => {
372            for el in &l.elements {
373                walk_target_element(el, sink);
374            }
375        }
376        AssignTargetExpression::StarredElement(s) => walk_target_value(&s.value, sink),
377    }
378}
379
380/// Walk a destructuring-target element (`(a, b[f()]) = …`) for nested target
381/// sub-expressions.
382fn walk_target_element(el: &Element, sink: &mut dyn EffectSink) {
383    match el {
384        Element::Simple { value, .. } => walk_target_value(value, sink),
385        Element::Starred(s) => walk_target_value(&s.value, sink),
386    }
387}
388
389/// Walk a target-position **expression** (an element of a tuple/list target) for
390/// its eagerly-evaluated sub-expressions, mirroring `walk_assign_target_subexprs`
391/// but over an `Expression` (destructuring elements are typed as expressions).
392fn walk_target_value(expr: &Expression, sink: &mut dyn EffectSink) {
393    match expr {
394        Expression::Name(_) => {}
395        Expression::Attribute(a) => walk_expr(&a.value, sink),
396        Expression::Subscript(s) => {
397            walk_expr(&s.value, sink);
398            for element in &s.slice {
399                walk_base_slice(&element.slice, sink);
400            }
401        }
402        Expression::Tuple(t) => {
403            for el in &t.elements {
404                walk_target_element(el, sink);
405            }
406        }
407        Expression::List(l) => {
408            for el in &l.elements {
409                walk_target_element(el, sink);
410            }
411        }
412        Expression::StarredElement(s) => walk_target_value(&s.value, sink),
413        _ => {}
414    }
415}
416
417// ─── expression traversal ─────────────────────────────────────────────────────
418
419fn walk_expr(expr: &Expression, sink: &mut dyn EffectSink) {
420    match expr {
421        Expression::Call(c) => {
422            sink.on_call(c);
423            walk_expr(&c.func, sink);
424            for arg in &c.args {
425                walk_expr(&arg.value, sink);
426            }
427        }
428        // A nested `lambda` is its OWN unit — do NOT descend into its body. But its
429        // parameter defaults run when the `lambda` expression is evaluated (in the
430        // enclosing body) → charge them to the CURRENT function. (Lambdas carry no
431        // decorators in Python.)
432        Expression::Lambda(l) => walk_param_defaults(&l.params, sink),
433
434        Expression::Attribute(a) => {
435            sink.on_attribute_read(expr);
436            walk_expr(&a.value, sink);
437        }
438        Expression::Subscript(s) => {
439            // Fire on_attribute_read for `sys.argv[N]` — the subscript's value may be
440            // `sys.argv` (an Attribute), which the recursive walk_expr will also surface.
441            // The sink is responsible for deduplication if it tracks both forms.
442            walk_expr(&s.value, sink);
443            // The index/slice expression(s) are eagerly evaluated (`xs[f()]`,
444            // `xs[a:b]`) → descend into them too.
445            for element in &s.slice {
446                walk_base_slice(&element.slice, sink);
447            }
448        }
449        Expression::BinaryOperation(b) => {
450            walk_expr(&b.left, sink);
451            walk_expr(&b.right, sink);
452        }
453        Expression::BooleanOperation(b) => {
454            walk_expr(&b.left, sink);
455            walk_expr(&b.right, sink);
456        }
457        Expression::UnaryOperation(u) => walk_expr(&u.expression, sink),
458        Expression::Comparison(c) => {
459            walk_expr(&c.left, sink);
460            for comp in &c.comparisons {
461                walk_expr(&comp.comparator, sink);
462            }
463        }
464        Expression::IfExp(i) => {
465            walk_expr(&i.test, sink);
466            walk_expr(&i.body, sink);
467            walk_expr(&i.orelse, sink);
468        }
469        Expression::Tuple(t) => {
470            for el in &t.elements {
471                walk_element(el, sink);
472            }
473        }
474        Expression::List(l) => {
475            for el in &l.elements {
476                walk_element(el, sink);
477            }
478        }
479        Expression::Set(s) => {
480            for el in &s.elements {
481                walk_element(el, sink);
482            }
483        }
484        Expression::Dict(d) => {
485            for el in &d.elements {
486                match el {
487                    libcst_native::DictElement::Simple { key, value, .. } => {
488                        walk_expr(key, sink);
489                        walk_expr(value, sink);
490                    }
491                    libcst_native::DictElement::Starred(s) => walk_expr(&s.value, sink),
492                }
493            }
494        }
495        // EAGER comprehensions: descend into both the element and the iterable
496        // (both evaluated in the enclosing body).
497        Expression::ListComp(l) => {
498            walk_expr(&l.elt, sink);
499            walk_comp_for(&l.for_in, sink, true);
500        }
501        Expression::SetComp(s) => {
502            walk_expr(&s.elt, sink);
503            walk_comp_for(&s.for_in, sink, true);
504        }
505        Expression::DictComp(d) => {
506            walk_expr(&d.key, sink);
507            walk_expr(&d.value, sink);
508            walk_comp_for(&d.for_in, sink, true);
509        }
510        // LAZY generator expression: only its OUTERMOST iterable runs in the
511        // enclosing body. The element + condition bodies are deferred → NOT charged
512        // (no separate unit — simply uncounted). `eager = false` walks only iterables.
513        Expression::GeneratorExp(g) => {
514            walk_comp_for(&g.for_in, sink, false);
515        }
516        Expression::FormattedString(fs) => {
517            for part in &fs.parts {
518                if let FormattedStringContent::Expression(e) = part {
519                    walk_expr(&e.expression, sink);
520                    // `{x:{width()}}` — the format_spec is itself a sequence of
521                    // FormattedStringContent parts evaluated eagerly.
522                    if let Some(spec_parts) = &e.format_spec {
523                        for sp in spec_parts {
524                            if let FormattedStringContent::Expression(se) = sp {
525                                walk_expr(&se.expression, sink);
526                            }
527                        }
528                    }
529                }
530            }
531        }
532        Expression::Yield(y) => {
533            if let Some(v) = &y.value {
534                match &**v {
535                    libcst_native::YieldValue::Expression(e) => walk_expr(e, sink),
536                    libcst_native::YieldValue::From(f) => walk_expr(&f.item, sink),
537                }
538            }
539        }
540        Expression::Await(a) => walk_expr(&a.expression, sink),
541        Expression::NamedExpr(n) => walk_expr(&n.value, sink),
542        Expression::StarredElement(s) => walk_expr(&s.value, sink),
543
544        // Leaf / non-effectful expressions.
545        _ => {}
546    }
547}
548
549/// Walk a comprehension's `for … in …` clause(s).
550///
551/// `eager`: when `true` (list/set/dict comprehension) the element bodies were
552/// already walked by the caller and we descend into **every** iterable and `if`
553/// filter. When `false` (generator expression — lazy) we descend into **only the
554/// outermost iterable**, never the `if` filters or nested-`for` clauses, since
555/// those run on consumption, not in the enclosing body.
556fn walk_comp_for(comp: &libcst_native::CompFor, sink: &mut dyn EffectSink, eager: bool) {
557    // The outermost iterable always runs in the enclosing body (eager or lazy).
558    walk_expr(&comp.iter, sink);
559    if eager {
560        for cond in &comp.ifs {
561            walk_expr(&cond.test, sink);
562        }
563        if let Some(inner) = &comp.inner_for_in {
564            walk_comp_for(inner, sink, true);
565        }
566    }
567}
568
569fn walk_element(el: &Element, sink: &mut dyn EffectSink) {
570    match el {
571        Element::Simple { value, .. } => walk_expr(value, sink),
572        Element::Starred(s) => walk_expr(&s.value, sink),
573    }
574}
575
576/// Walk a subscript slice (`Index` value, or `Slice` lower/upper/step) for effects.
577fn walk_base_slice(slice: &libcst_native::BaseSlice, sink: &mut dyn EffectSink) {
578    match slice {
579        libcst_native::BaseSlice::Index(i) => walk_expr(&i.value, sink),
580        libcst_native::BaseSlice::Slice(s) => {
581            if let Some(lower) = &s.lower {
582                walk_expr(lower, sink);
583            }
584            if let Some(upper) = &s.upper {
585                walk_expr(upper, sink);
586            }
587            if let Some(step) = &s.step {
588                walk_expr(step, sink);
589            }
590        }
591    }
592}
593
594// ─── await counting ───────────────────────────────────────────────────────────
595
596/// Count `await` expressions in the unit's own body.
597///
598/// Uses a separate recursive pass rather than the `EffectSink` driver because the
599/// driver fires on call/assert/raise/assign, not on `await` as a distinct event.
600/// The attribution rules (no nested `def`/`lambda` bodies) are mirrored manually.
601fn count_awaits(unit: &FnUnit) -> usize {
602    fn count_in_body(body: &FnBody) -> usize {
603        match body {
604            FnBody::Suite(suite) => count_in_suite(suite),
605            FnBody::Expr(expr) => count_in_expr(expr),
606            // The `<module>` unit's body is a flat list of top-level statements.
607            // Top-level ClassDef: count awaits in both the class body (same as
608            // walk_own_body) and the class header expressions (decorators + base/
609            // keyword args, which run at import time — mirrors walk_class_header).
610            FnBody::Module(stmts) => stmts
611                .iter()
612                .map(|stmt| {
613                    if let libcst_native::Statement::Compound(
614                        libcst_native::CompoundStatement::ClassDef(c),
615                    ) = stmt
616                    {
617                        count_in_stmt(stmt)
618                            + c.decorators
619                                .iter()
620                                .map(|dec| count_in_expr(&dec.decorator))
621                                .sum::<usize>()
622                            + c.bases
623                                .iter()
624                                .chain(c.keywords.iter())
625                                .map(|arg| count_in_expr(&arg.value))
626                                .sum::<usize>()
627                    } else {
628                        count_in_stmt(stmt)
629                    }
630                })
631                .sum(),
632        }
633    }
634
635    fn count_in_suite(suite: &libcst_native::Suite) -> usize {
636        match suite {
637            libcst_native::Suite::IndentedBlock(b) => b.body.iter().map(count_in_stmt).sum(),
638            libcst_native::Suite::SimpleStatementSuite(s) => {
639                s.body.iter().map(count_in_small).sum()
640            }
641        }
642    }
643
644    fn count_in_stmt(stmt: &libcst_native::Statement) -> usize {
645        match stmt {
646            libcst_native::Statement::Simple(line) => line.body.iter().map(count_in_small).sum(),
647            libcst_native::Statement::Compound(c) => count_in_compound(c),
648        }
649    }
650
651    fn count_in_compound(c: &libcst_native::CompoundStatement) -> usize {
652        match c {
653            // Nested def — its body is NOT counted (own attribution), but its
654            // decorators + parameter defaults run in the enclosing body → count
655            // any `await` there.
656            libcst_native::CompoundStatement::FunctionDef(d) => count_in_def_header(d),
657            libcst_native::CompoundStatement::ClassDef(_) => 0,
658            libcst_native::CompoundStatement::If(i) => {
659                count_in_expr(&i.test)
660                    + count_in_suite(&i.body)
661                    + i.orelse.as_ref().map_or(0, |o| count_in_orelse(o))
662            }
663            libcst_native::CompoundStatement::For(f) => {
664                count_in_expr(&f.iter)
665                    + count_in_suite(&f.body)
666                    + f.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
667            }
668            libcst_native::CompoundStatement::While(w) => {
669                count_in_expr(&w.test)
670                    + count_in_suite(&w.body)
671                    + w.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
672            }
673            libcst_native::CompoundStatement::Try(t) => {
674                count_in_suite(&t.body)
675                    + t.handlers
676                        .iter()
677                        .map(|h| count_in_suite(&h.body))
678                        .sum::<usize>()
679                    + t.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
680                    + t.finalbody.as_ref().map_or(0, |e| count_in_suite(&e.body))
681            }
682            libcst_native::CompoundStatement::TryStar(t) => {
683                count_in_suite(&t.body)
684                    + t.handlers
685                        .iter()
686                        .map(|h| count_in_suite(&h.body))
687                        .sum::<usize>()
688                    + t.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
689                    + t.finalbody.as_ref().map_or(0, |e| count_in_suite(&e.body))
690            }
691            libcst_native::CompoundStatement::With(w) => {
692                w.items
693                    .iter()
694                    .map(|item| count_in_expr(&item.item))
695                    .sum::<usize>()
696                    + count_in_suite(&w.body)
697            }
698            libcst_native::CompoundStatement::Match(m) => {
699                count_in_expr(&m.subject)
700                    + m.cases
701                        .iter()
702                        .map(|case| count_in_suite(&case.body))
703                        .sum::<usize>()
704            }
705        }
706    }
707
708    fn count_in_orelse(orelse: &libcst_native::OrElse) -> usize {
709        match orelse {
710            libcst_native::OrElse::Elif(elif) => {
711                count_in_expr(&elif.test)
712                    + count_in_suite(&elif.body)
713                    + elif.orelse.as_ref().map_or(0, |o| count_in_orelse(o))
714            }
715            libcst_native::OrElse::Else(e) => count_in_suite(&e.body),
716        }
717    }
718
719    fn count_in_small(small: &libcst_native::SmallStatement) -> usize {
720        match small {
721            libcst_native::SmallStatement::Expr(e) => count_in_expr(&e.value),
722            libcst_native::SmallStatement::Return(r) => r.value.as_ref().map_or(0, count_in_expr),
723            libcst_native::SmallStatement::Assign(a) => {
724                a.targets
725                    .iter()
726                    .map(|t| count_in_assign_target(&t.target))
727                    .sum::<usize>()
728                    + count_in_expr(&a.value)
729            }
730            libcst_native::SmallStatement::AnnAssign(a) => {
731                count_in_assign_target(&a.target) + a.value.as_ref().map_or(0, count_in_expr)
732            }
733            libcst_native::SmallStatement::AugAssign(a) => {
734                count_in_assign_target(&a.target) + count_in_expr(&a.value)
735            }
736            libcst_native::SmallStatement::Assert(a) => {
737                count_in_expr(&a.test) + a.msg.as_ref().map_or(0, count_in_expr)
738            }
739            libcst_native::SmallStatement::Raise(r) => r.exc.as_ref().map_or(0, count_in_expr),
740            _ => 0,
741        }
742    }
743
744    fn count_in_expr(expr: &libcst_native::Expression) -> usize {
745        match expr {
746            libcst_native::Expression::Await(a) => {
747                // Count the await itself; descend into its inner expression too
748                // (nested awaits inside the awaited expression are possible in theory).
749                1 + count_in_expr(&a.expression)
750            }
751            // Nested lambda — its body is NOT counted (own attribution), but its
752            // parameter defaults run in the enclosing body → count awaits there.
753            libcst_native::Expression::Lambda(l) => count_in_params_defaults(&l.params),
754            libcst_native::Expression::Call(c) => {
755                count_in_expr(&c.func)
756                    + c.args
757                        .iter()
758                        .map(|a| count_in_expr(&a.value))
759                        .sum::<usize>()
760            }
761            libcst_native::Expression::Attribute(a) => count_in_expr(&a.value),
762            libcst_native::Expression::Subscript(s) => {
763                count_in_expr(&s.value)
764                    + s.slice
765                        .iter()
766                        .map(|e| count_in_base_slice(&e.slice))
767                        .sum::<usize>()
768            }
769            libcst_native::Expression::BinaryOperation(b) => {
770                count_in_expr(&b.left) + count_in_expr(&b.right)
771            }
772            libcst_native::Expression::BooleanOperation(b) => {
773                count_in_expr(&b.left) + count_in_expr(&b.right)
774            }
775            libcst_native::Expression::UnaryOperation(u) => count_in_expr(&u.expression),
776            libcst_native::Expression::Comparison(c) => {
777                count_in_expr(&c.left)
778                    + c.comparisons
779                        .iter()
780                        .map(|comp| count_in_expr(&comp.comparator))
781                        .sum::<usize>()
782            }
783            libcst_native::Expression::IfExp(i) => {
784                count_in_expr(&i.test) + count_in_expr(&i.body) + count_in_expr(&i.orelse)
785            }
786            libcst_native::Expression::Tuple(t) => t.elements.iter().map(count_in_element).sum(),
787            libcst_native::Expression::List(l) => l.elements.iter().map(count_in_element).sum(),
788            libcst_native::Expression::Set(s) => s.elements.iter().map(count_in_element).sum(),
789            libcst_native::Expression::Dict(d) => d
790                .elements
791                .iter()
792                .map(|el| match el {
793                    libcst_native::DictElement::Simple { key, value, .. } => {
794                        count_in_expr(key) + count_in_expr(value)
795                    }
796                    libcst_native::DictElement::Starred(s) => count_in_expr(&s.value),
797                })
798                .sum(),
799            libcst_native::Expression::ListComp(l) => {
800                count_in_expr(&l.elt) + count_in_comp_for(&l.for_in)
801            }
802            libcst_native::Expression::SetComp(s) => {
803                count_in_expr(&s.elt) + count_in_comp_for(&s.for_in)
804            }
805            libcst_native::Expression::DictComp(d) => {
806                count_in_expr(&d.key) + count_in_expr(&d.value) + count_in_comp_for(&d.for_in)
807            }
808            // LAZY generator expression: only the outermost iterable runs in the
809            // enclosing body. The element/condition bodies and nested-for clauses
810            // are deferred — awaits there do NOT count toward the enclosing
811            // function's await_count / async_boundary. Mirror walk_comp_for's
812            // `eager = false` branch: only descend into `comp.iter`.
813            libcst_native::Expression::GeneratorExp(g) => count_in_expr(&g.for_in.iter),
814            libcst_native::Expression::FormattedString(fs) => fs
815                .parts
816                .iter()
817                .map(|p| {
818                    if let libcst_native::FormattedStringContent::Expression(e) = p {
819                        let in_expr = count_in_expr(&e.expression);
820                        // `{x:{await w()}}` — format_spec parts are also eager.
821                        let in_spec = e
822                            .format_spec
823                            .as_deref()
824                            .unwrap_or(&[])
825                            .iter()
826                            .map(|sp| {
827                                if let libcst_native::FormattedStringContent::Expression(se) = sp {
828                                    count_in_expr(&se.expression)
829                                } else {
830                                    0
831                                }
832                            })
833                            .sum::<usize>();
834                        in_expr + in_spec
835                    } else {
836                        0
837                    }
838                })
839                .sum(),
840            libcst_native::Expression::Yield(y) => {
841                y.value.as_ref().map_or(0, |v| match v.as_ref() {
842                    libcst_native::YieldValue::Expression(e) => count_in_expr(e),
843                    libcst_native::YieldValue::From(f) => count_in_expr(&f.item),
844                })
845            }
846            libcst_native::Expression::NamedExpr(n) => count_in_expr(&n.value),
847            libcst_native::Expression::StarredElement(s) => count_in_expr(&s.value),
848            _ => 0,
849        }
850    }
851
852    /// Awaits in a nested `def`'s header (decorators + parameter defaults), which
853    /// run in the enclosing body. The def's BODY is not counted (own attribution).
854    fn count_in_def_header(def: &libcst_native::FunctionDef) -> usize {
855        def.decorators
856            .iter()
857            .map(|dec| count_in_expr(&dec.decorator))
858            .sum::<usize>()
859            + count_in_params_defaults(&def.params)
860    }
861
862    /// Awaits in a parameter list's default-value expressions (eager at def-time).
863    fn count_in_params_defaults(params: &libcst_native::Parameters) -> usize {
864        let mut n = 0;
865        let all = params
866            .posonly_params
867            .iter()
868            .chain(&params.params)
869            .chain(&params.kwonly_params);
870        for p in all {
871            if let Some(default) = &p.default {
872                n += count_in_expr(default);
873            }
874        }
875        if let Some(libcst_native::StarArg::Param(p)) = &params.star_arg
876            && let Some(default) = &p.default
877        {
878            n += count_in_expr(default);
879        }
880        if let Some(p) = &params.star_kwarg
881            && let Some(default) = &p.default
882        {
883            n += count_in_expr(default);
884        }
885        n
886    }
887
888    fn count_in_comp_for(comp: &libcst_native::CompFor) -> usize {
889        count_in_expr(&comp.iter)
890            + comp
891                .ifs
892                .iter()
893                .map(|c| count_in_expr(&c.test))
894                .sum::<usize>()
895            + comp
896                .inner_for_in
897                .as_ref()
898                .map_or(0, |inner| count_in_comp_for(inner))
899    }
900
901    /// Count awaits in an assignment **target's** eagerly-evaluated sub-expressions
902    /// (mirrors `walk_assign_target_subexprs`): a subscript target's base + index/
903    /// slice, an attribute target's base, recursing through destructuring elements.
904    fn count_in_assign_target(target: &libcst_native::AssignTargetExpression) -> usize {
905        use libcst_native::AssignTargetExpression as T;
906        match target {
907            T::Name(_) => 0,
908            T::Attribute(a) => count_in_expr(&a.value),
909            T::Subscript(s) => {
910                count_in_expr(&s.value)
911                    + s.slice
912                        .iter()
913                        .map(|e| count_in_base_slice(&e.slice))
914                        .sum::<usize>()
915            }
916            T::Tuple(t) => t.elements.iter().map(count_in_target_element).sum(),
917            T::List(l) => l.elements.iter().map(count_in_target_element).sum(),
918            T::StarredElement(s) => count_in_target_value(&s.value),
919        }
920    }
921
922    /// Count awaits in a destructuring-target element's nested sub-expressions.
923    fn count_in_target_element(el: &libcst_native::Element) -> usize {
924        match el {
925            libcst_native::Element::Simple { value, .. } => count_in_target_value(value),
926            libcst_native::Element::Starred(s) => count_in_target_value(&s.value),
927        }
928    }
929
930    /// Count awaits in a target-position expression (a tuple/list element).
931    fn count_in_target_value(expr: &libcst_native::Expression) -> usize {
932        match expr {
933            libcst_native::Expression::Name(_) => 0,
934            libcst_native::Expression::Attribute(a) => count_in_expr(&a.value),
935            libcst_native::Expression::Subscript(s) => {
936                count_in_expr(&s.value)
937                    + s.slice
938                        .iter()
939                        .map(|e| count_in_base_slice(&e.slice))
940                        .sum::<usize>()
941            }
942            libcst_native::Expression::Tuple(t) => {
943                t.elements.iter().map(count_in_target_element).sum()
944            }
945            libcst_native::Expression::List(l) => {
946                l.elements.iter().map(count_in_target_element).sum()
947            }
948            libcst_native::Expression::StarredElement(s) => count_in_target_value(&s.value),
949            _ => 0,
950        }
951    }
952
953    fn count_in_base_slice(slice: &libcst_native::BaseSlice) -> usize {
954        match slice {
955            libcst_native::BaseSlice::Index(i) => count_in_expr(&i.value),
956            libcst_native::BaseSlice::Slice(s) => {
957                s.lower.as_ref().map_or(0, count_in_expr)
958                    + s.upper.as_ref().map_or(0, count_in_expr)
959                    + s.step.as_ref().map_or(0, count_in_expr)
960            }
961        }
962    }
963
964    fn count_in_element(el: &libcst_native::Element) -> usize {
965        match el {
966            libcst_native::Element::Simple { value, .. } => count_in_expr(value),
967            libcst_native::Element::Starred(s) => count_in_expr(&s.value),
968        }
969    }
970
971    count_in_body(&unit.body)
972}
973
974// ─── unit assembly ────────────────────────────────────────────────────────────
975
976/// Run every detector over `unit`'s own body and return the gathered
977/// `(effects, risks, await_count, async_boundary)` tuple.
978///
979/// Both [`analyze_unit`] and [`build_record`] call this helper so the two
980/// callers can never silently diverge — any future detector addition touches
981/// one place. Mirrors the private `gather` in `fxrank-lang-rust/src/detect/mod.rs`.
982/// Return type of [`gather`]: `(effects, risks, await_count, async_boundary, unknown_decorator)`.
983///
984/// `unknown_decorator` is forwarded to `analyze_unit` only — it lowers confidence
985/// without touching coverage.  `build_record` discards it (records carry no
986/// confidence field).
987type GatherOutput = (
988    Vec<fxrank_core::effect::Effect>,
989    Vec<RiskFeature>,
990    usize,
991    bool,
992    bool,
993);
994
995fn gather(
996    unit: &FnUnit,
997    path: &str,
998    imports: &Imports,
999    module_bindings: &HashSet<String>,
1000    span: &SpanIndex,
1001) -> GatherOutput {
1002    let mut effects = calls::detect(unit, imports, span);
1003
1004    // Signature annotation coverage + `Any`/decorator signals.
1005    let cov = coverage::of(unit, imports);
1006
1007    // Apply the boundary-containment discount per the `contained` flag: a contained
1008    // (local-state) effect under an honest, typed boundary shifts down. Body `Any`
1009    // re-opens the boundary, so it voids the discount (coverage forced to `None`).
1010    // Escaping effects (`contained == false`) are never discounted.
1011    let discount_coverage = if cov.any_in_body {
1012        BoundaryCoverage::None
1013    } else {
1014        cov.boundary
1015    };
1016    let mut_pairs = mutation::detect(unit, imports, module_bindings, span);
1017    effects.extend(mut_pairs.into_iter().map(|(mut e, contained)| {
1018        // Wire the tuple's containment bool onto the Effect so propagation logic
1019        // (`Effect::escapes()`) sees the real value (not the default `false` stub).
1020        e.contained = contained;
1021        // Only record a discount when the boundary actually shifts the class —
1022        // i.e. Partial/Full coverage. `None` (incl. a typed boundary voided by a
1023        // body `Any`) produces no shift, so we leave `discounted_to`/`discount`
1024        // unset rather than claim a no-op discount in the report (mirrors TS).
1025        if contained && discount_coverage != BoundaryCoverage::None {
1026            e.discounted_to = Some(apply_boundary_discount(e.class, discount_coverage, true));
1027            e.discount = Some(
1028                match discount_coverage {
1029                    BoundaryCoverage::Full => "contained, Full-typed boundary",
1030                    BoundaryCoverage::Partial => "contained, Partial-typed boundary",
1031                    BoundaryCoverage::None => unreachable!("guarded above"),
1032                }
1033                .to_string(),
1034            );
1035            e.sync_weight();
1036        }
1037        e
1038    }));
1039
1040    // The coverage gate owns the `Any`-family `type.escape` risk (class 3, exact):
1041    // an explicit `Any` in the signature or body is the `any ≈ unsafe` escape hatch.
1042    // risk::detect adds eval/exec/pickle/yaml/importlib/setattr/shell=True.
1043    let mut risks: Vec<RiskFeature> = risk::detect(unit, imports, span, path);
1044    if cov.any_in_signature || cov.any_in_body {
1045        let class = RiskKind::TypeEscape.class();
1046        risks.push(RiskFeature {
1047            kind: RiskKind::TypeEscape,
1048            class,
1049            weight: weight_for_class(class),
1050            path: path.into(),
1051            line: unit.line,
1052            col: unit.col,
1053            evidence: "explicit Any (signature or body) — type-escape hatch".into(),
1054            tier: Tier::Exact,
1055        });
1056    }
1057
1058    let await_count = count_awaits(unit);
1059    let async_boundary = unit.is_async || await_count > 0;
1060
1061    (
1062        effects,
1063        risks,
1064        await_count,
1065        async_boundary,
1066        cov.unknown_decorator,
1067    )
1068}
1069
1070/// Analyze one function-unit into an owned [`Hotspot`].
1071///
1072/// # Gather → Fold
1073/// 1. **gather**: drive each detector over the own body to collect `Vec<Effect>`.
1074/// 2. **fold**: compute `own_score`, `max_class`, function-level `confidence`
1075///    (weakest-link min over per-effect confidences, plus 0.8 synthetic when there
1076///    are unresolved awaited calls), and `await_count` / `async_boundary`.
1077///
1078/// Adding a detector is a one-line addition to the gather step (in [`gather`]).
1079pub fn analyze_unit(
1080    unit: &FnUnit,
1081    path: &str,
1082    imports: &Imports,
1083    module_bindings: &HashSet<String>,
1084    span: &SpanIndex,
1085) -> Hotspot {
1086    // ── gather ───────────────────────────────────────────────────────────────
1087    let (effects, risks, await_count, async_boundary, unknown_decorator) =
1088        gather(unit, path, imports, module_bindings, span);
1089
1090    // ── fold ─────────────────────────────────────────────────────────────────
1091    let weights: Vec<u32> = effects.iter().map(|e| e.weight).collect();
1092    let classes: Vec<u8> = effects.iter().map(|e| e.effective_class()).collect();
1093
1094    // Function confidence = weakest-link min of per-effect confidences.
1095    // Per the spec: per-effect confidence is NOT serialized; it surfaces only here.
1096    // When there are unresolved awaited calls, add a synthetic 0.8 entry —
1097    // an async fn that awaits may hide IO effects we cannot see statically
1098    // (mirrors the Rust and TS frontends). An unknown decorator may erase the
1099    // signature to `Any`, so it lowers confidence (a 0.8 step) without touching
1100    // coverage (the written annotations are still real signal).
1101    let mut confidences: Vec<f64> = effects.iter().map(|e| e.confidence).collect();
1102    if await_count > 0 {
1103        confidences.push(0.8);
1104    }
1105    if unknown_decorator {
1106        confidences.push(0.8);
1107    }
1108
1109    // Fold risks into scoring (generalized — Task 9 introduces the first real risk;
1110    // Task 10 plugs more into the same Vec). risk_class = max class over features.
1111    let risk_class = risks.iter().map(|r| r.class).max().unwrap_or(0);
1112    let risk_weight = if risks.is_empty() {
1113        0
1114    } else {
1115        weight_for_class(risk_class)
1116    };
1117
1118    let mc = max_class(&classes, risk_class);
1119    let os = own_score(&weights);
1120    Hotspot {
1121        id: format!("{}:{}:{}:{}", path, unit.line, unit.col, unit.symbol),
1122        symbol: unit.symbol.clone(),
1123        path: path.into(),
1124        line: unit.line,
1125        risk_weight,
1126        confidence: function_confidence(&confidences),
1127        async_boundary,
1128        await_count,
1129        effects,
1130        risk_features: risks,
1131        // Propagated fields default to own (cross-file folding overwrites them).
1132        ..Hotspot::own_seed(os, mc)
1133    }
1134}
1135
1136// ─── build_record ─────────────────────────────────────────────────────────────
1137
1138/// Path-meaningful segments for a Python symbol. Synthetic `<module>`/`<lambda@…>`
1139/// symbols (identified by leading `<`) are not importable → `None`. Real names
1140/// return `Some(vec![symbol])` (a single segment; Python symbols carry no dots).
1141/// Module-level-vs-method filtering is done by `is_module_level` at the call
1142/// site in `build_record`; this only guards the synthetic forms.
1143fn symbol_segments(symbol: &str) -> Option<Vec<String>> {
1144    if symbol.starts_with('<') {
1145        None
1146    } else {
1147        Some(vec![symbol.to_string()])
1148    }
1149}
1150
1151/// Build a language-neutral [`fxrank_core::record::UnitRecord`] for `unit`.
1152///
1153/// Calls the shared [`gather`] helper (same as [`analyze_unit`]) so the record's
1154/// `effects`/`risks`/`async_boundary`/`await_count` are byte-identical to the
1155/// Hotspot. Also extracts outgoing call references via [`refs::extract`] for the
1156/// cross-file resolver.
1157///
1158/// INVARIANT: this recomputes own-body via the same `gather` as `analyze_unit`.
1159/// This stays correct only while `analyze_unit` does NO post-`gather` mutation of
1160/// effects/risks (unlike TS, which absorbs React signals and so must copy from the
1161/// final Hotspot). If you add a post-gather step here, switch to copying from the
1162/// Hotspot or the record's own-body will drift from it.
1163pub fn build_record(
1164    unit: &FnUnit,
1165    path: &str,
1166    imports: &Imports,
1167    module_bindings: &HashSet<String>,
1168    span: &SpanIndex,
1169    module_map: &crate::module_map::PyModuleMap,
1170) -> fxrank_core::record::UnitRecord {
1171    // ── gather ─────────────────────────────────────────────────────────────
1172    // `unknown_decorator` is a confidence-only signal; records carry no
1173    // confidence field, so we discard it here.
1174    let (effects, risks, await_count, async_boundary, _unknown_decorator) =
1175        gather(unit, path, imports, module_bindings, span);
1176
1177    // ── canonical_path ─────────────────────────────────────────────────────
1178    // Only a module-level def is importable as `module.<name>`. Methods/nested
1179    // defs/lambdas/<module> get an empty canonical_path so they cannot be a
1180    // false-resolve target (Python symbols are bare — a method `write` would
1181    // otherwise collide with module-level `write`). (P2-1)
1182    let canonical_path = if !unit.is_module_level {
1183        vec![]
1184    } else {
1185        match (module_map.module_of(path), symbol_segments(&unit.symbol)) {
1186            (Some(mut m), Some(seg)) => {
1187                m.extend(seg);
1188                m
1189            }
1190            _ => vec![], // no module in scope, OR a synthetic symbol
1191        }
1192    };
1193
1194    // ── refs ───────────────────────────────────────────────────────────────
1195    let referencing_module = module_map.module_of(path).unwrap_or_default();
1196    let referencing_is_package = module_map.is_package(path);
1197    let call_refs = refs::extract(
1198        unit,
1199        imports,
1200        span,
1201        &referencing_module,
1202        referencing_is_package,
1203        module_map,
1204    );
1205
1206    fxrank_core::record::UnitRecord {
1207        unit_id: format!("{}:{}:{}:{}", path, unit.line, unit.col, unit.symbol),
1208        path: path.into(),
1209        line: unit.line,
1210        col: unit.col,
1211        symbol: unit.symbol.clone(),
1212        is_root: false,
1213        canonical_path,
1214        aliases: vec![],
1215        effects,
1216        risks,
1217        refs: call_refs,
1218        async_boundary,
1219        await_count,
1220        language: fxrank_core::frontend::Language::Python,
1221    }
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226    use super::*;
1227    use fxrank_core::model::Hotspot;
1228
1229    /// Parse `tests/fixtures/<name>.py`, run `analyze_unit` for every collected
1230    /// function-unit, and return the resulting `Vec<Hotspot>`.  Mirrors the
1231    /// `analyze_fixture` helper in `calls.rs` but returns full `Hotspot`s so
1232    /// scoring fields (`own_score`, `max_class`, `confidence`, …) can be asserted.
1233    fn scan_fixture_hotspots(name: &str) -> Vec<Hotspot> {
1234        let src = std::fs::read_to_string(format!("tests/fixtures/{name}.py")).unwrap();
1235        let module = libcst_native::parse_module(&src, None).unwrap();
1236        let imports = Imports::build(&module);
1237        let module_bindings = crate::imports::module_bindings(&module);
1238        let span = SpanIndex::new(&src);
1239        let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
1240        let (units, _) = crate::functions::collect(&module, &src, &span, &anchors);
1241        units
1242            .iter()
1243            .map(|unit| {
1244                analyze_unit(
1245                    unit,
1246                    &format!("tests/fixtures/{name}.py"),
1247                    &imports,
1248                    &module_bindings,
1249                    &span,
1250                )
1251            })
1252            .collect()
1253    }
1254
1255    /// FIX 2: a nested `def`'s parameter-default expression runs when the ENCLOSING
1256    /// `def` statement executes → charged to the enclosing function, NOT the nested
1257    /// one. A top-level def's own default runs at module time → uncounted on itself.
1258    #[test]
1259    fn def_header_defaults_charge_to_enclosing_scope() {
1260        let h = scan_fixture_hotspots("attribution");
1261        let net = |sym: &str| {
1262            h.iter()
1263                .find(|x| x.symbol == sym)
1264                .unwrap_or_else(|| panic!("symbol {sym} not found"))
1265                .effects
1266                .iter()
1267                .any(|e| e.kind.wire() == "net.fs.db")
1268        };
1269        // `def inner(x=open(p))` inside `outer` → `open(p)` charged to OUTER.
1270        assert!(
1271            net("outer"),
1272            "open(p) default must be charged to enclosing outer"
1273        );
1274        assert!(
1275            !net("inner"),
1276            "open(p) must NOT be charged to nested inner (its default runs in outer)"
1277        );
1278        // Top-level `def top_default(x=open('f'))` → default runs at module time,
1279        // uncounted on top_default itself.
1280        assert!(
1281            !net("top_default"),
1282            "a top-level def's own param default is module-time → uncounted on itself"
1283        );
1284    }
1285
1286    /// FIX 3: a subscript index/slice expression is eagerly evaluated and must be
1287    /// traversed for effects (and awaits). `xs[requests.get(u)]` → net.fs.db.
1288    #[test]
1289    fn subscript_index_expression_is_traversed() {
1290        let h = scan_fixture_hotspots("attribution");
1291        let si = h.iter().find(|x| x.symbol == "subscript_index").unwrap();
1292        assert!(
1293            si.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
1294            "subscript index requests.get(u) must surface net.fs.db, got: {:?}",
1295            si.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1296        );
1297    }
1298
1299    /// Copilot FIX 1: an assignment TARGET's sub-expressions are eagerly evaluated
1300    /// and must be traversed for effects — a subscript target's index and an
1301    /// attribute target's base. CRITICALLY, the index/base walk must NOT
1302    /// double-count the target's own mutation: `xs[requests.get(u)] = 1` charges
1303    /// NetFsDb (from the index) AND exactly ONE param.mutation for `xs`.
1304    ///
1305    /// Pre-fix `walk_small`'s Assign/AnnAssign/AugAssign arms only called
1306    /// `on_assign_target` then walked the VALUE — never the target's sub-exprs — so
1307    /// the index/base call effects were silently dropped.
1308    #[test]
1309    fn assign_target_subexprs_are_traversed_without_double_counting() {
1310        let h = scan_fixture_hotspots("attribution");
1311
1312        // ── subscript-index arm: `xs[requests.get(u)] = 1` ──────────────────────
1313        let s = h
1314            .iter()
1315            .find(|x| x.symbol == "assign_target_subscript_index")
1316            .unwrap();
1317        let net_count = s
1318            .effects
1319            .iter()
1320            .filter(|e| e.kind.wire() == "net.fs.db")
1321            .count();
1322        assert_eq!(
1323            net_count,
1324            1,
1325            "subscript-target index requests.get(u) must surface exactly one net.fs.db, got: {:?}",
1326            s.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1327        );
1328        // Double-count guard: the param mutation of `xs` must be emitted EXACTLY once.
1329        let param_mut_count = s
1330            .effects
1331            .iter()
1332            .filter(|e| e.kind.wire() == "param.mutation")
1333            .count();
1334        assert_eq!(
1335            param_mut_count,
1336            1,
1337            "the subscript target `xs` must emit exactly ONE param.mutation (no double-count), got: {:?}",
1338            s.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1339        );
1340
1341        // ── attribute-base arm: `requests.get(u).attr = 1` ──────────────────────
1342        let a = h
1343            .iter()
1344            .find(|x| x.symbol == "assign_target_attr_base")
1345            .unwrap();
1346        assert!(
1347            a.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
1348            "attribute-target base requests.get(u) must surface net.fs.db, got: {:?}",
1349            a.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1350        );
1351    }
1352
1353    /// FIX 3 (await arm): an `await` in a subscript index counts toward await_count.
1354    #[test]
1355    fn subscript_index_await_counts() {
1356        let src = "async def f(xs):\n    return xs[await key()]\n";
1357        let module = libcst_native::parse_module(src, None).unwrap();
1358        let imports = Imports::build(&module);
1359        let module_bindings = crate::imports::module_bindings(&module);
1360        let span = SpanIndex::new(src);
1361        let anchors = crate::source::lambda_anchors(src).unwrap();
1362        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1363        let f = units.iter().find(|u| u.symbol == "f").unwrap();
1364        let h = analyze_unit(f, "x.py", &imports, &module_bindings, &span);
1365        assert!(
1366            h.await_count >= 1,
1367            "await in subscript index must count, got await_count={}",
1368            h.await_count
1369        );
1370    }
1371
1372    /// Copilot FIX 2: an `await` in an assignment TARGET's sub-expression must be
1373    /// counted by `count_awaits`. `xs[await f()] = 1` — the await lives in the
1374    /// subscript-target index, which pre-fix `count_in_small` never visited (it
1375    /// only counted `a.value`), so await_count was 0.
1376    #[test]
1377    fn assign_target_subscript_index_await_counts() {
1378        let src = "async def f(xs):\n    xs[await key()] = 1\n";
1379        let module = libcst_native::parse_module(src, None).unwrap();
1380        let imports = Imports::build(&module);
1381        let module_bindings = crate::imports::module_bindings(&module);
1382        let span = SpanIndex::new(src);
1383        let anchors = crate::source::lambda_anchors(src).unwrap();
1384        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1385        let f = units.iter().find(|u| u.symbol == "f").unwrap();
1386        let h = analyze_unit(f, "x.py", &imports, &module_bindings, &span);
1387        assert!(
1388            h.await_count >= 1,
1389            "await in an assignment-target subscript index must count, got await_count={}",
1390            h.await_count
1391        );
1392        assert!(
1393            h.async_boundary,
1394            "await in an assignment-target subscript index must set async_boundary"
1395        );
1396    }
1397
1398    /// FIX 5: a function-local `import subprocess` must be collected file-wide so
1399    /// `subprocess.run(c, shell=True)` resolves → process.control effect AND
1400    /// dynamic.code risk. Pre-fix, `Imports::build` scanned only top-level
1401    /// statements, so neither resolved.
1402    #[test]
1403    fn function_local_import_resolves_effect_and_risk() {
1404        let h = scan_fixture_hotspots("local_import");
1405        let f = h.iter().find(|x| x.symbol == "f").unwrap();
1406        assert!(
1407            f.effects.iter().any(|e| e.kind.wire() == "process.control"),
1408            "function-local import must resolve subprocess.run → process.control, got: {:?}",
1409            f.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1410        );
1411        assert!(
1412            f.risk_features
1413                .iter()
1414                .any(|r| r.kind.wire() == "dynamic.code"),
1415            "shell=True must emit dynamic.code once the local import resolves"
1416        );
1417    }
1418
1419    #[test]
1420    fn analyze_unit_scores_world_effects() {
1421        let h = scan_fixture_hotspots("calls");
1422        let io = h.iter().find(|x| x.symbol == "io_boundary").unwrap();
1423        // open(…) + requests.get(…) → NetFsDb class 7, weight 21 each.
1424        // logging.info(…)           → Logging class 2, weight 2.
1425        // weights = [21, 21, 2] → own_score = 21 + 0.5*(21+2) = 32.5
1426        assert_eq!(io.max_class, 7);
1427        assert!(
1428            io.own_score >= 21.0,
1429            "expected own_score >= 21.0, got {}",
1430            io.own_score
1431        );
1432    }
1433
1434    // ── Task 9: boundary discount + Any poison + decorator confidence ──────────
1435
1436    /// Parse a tiny `src` module and return `coverage::of` for the unit named `symbol`.
1437    fn coverage_of_symbol(src: &str, symbol: &str) -> crate::coverage::Coverage {
1438        let module = libcst_native::parse_module(src, None).unwrap();
1439        let imports = Imports::build(&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 unit = units
1444            .iter()
1445            .find(|u| u.symbol == symbol)
1446            .expect("unit not found");
1447        crate::coverage::of(unit, &imports)
1448    }
1449
1450    #[test]
1451    fn boundary_discount_zeros_contained_local_when_typed() {
1452        let h = scan_fixture_hotspots("coverage");
1453        let ft = h.iter().find(|x| x.symbol == "fully_typed").unwrap();
1454        assert_eq!(ft.own_score, 0.0); // local.mutation class 1 → 0 under Full coverage
1455    }
1456
1457    #[test]
1458    fn any_emits_type_escape_and_blocks_discount() {
1459        let h = scan_fixture_hotspots("coverage");
1460        // RiskFeature.kind is a RiskKind enum (effect.rs), not a String — compare via .wire().
1461        let has_type_escape = h
1462            .iter()
1463            .find(|x| x.symbol == "has_any")
1464            .unwrap()
1465            .risk_features
1466            .iter()
1467            .any(|r| r.kind.wire() == "type.escape");
1468        assert!(has_type_escape); // signature Any → type.escape
1469        let ba = h.iter().find(|x| x.symbol == "body_any").unwrap();
1470        assert!(
1471            ba.risk_features
1472                .iter()
1473                .any(|r| r.kind.wire() == "type.escape")
1474        ); // body Any → type.escape
1475        assert!(ba.own_score >= 1.0); // discount voided → local.mutation stays class 1
1476    }
1477
1478    /// FIX 4: body-`Any` detection must descend into list/tuple/dict literals,
1479    /// f-strings, and comprehensions. A fully-typed contained-mutation fn with a
1480    /// `cast(Any, …)` in such an eager context must emit `type.escape` AND have its
1481    /// boundary discount voided (local.mutation stays class 1, own_score >= 1.0).
1482    #[test]
1483    fn body_any_in_eager_containers_emits_escape_and_voids_discount() {
1484        let h = scan_fixture_hotspots("coverage");
1485        for sym in [
1486            "body_any_in_list",
1487            "body_any_in_fstring",
1488            "body_any_in_comprehension",
1489        ] {
1490            let f = h.iter().find(|x| x.symbol == sym).unwrap();
1491            assert!(
1492                f.risk_features
1493                    .iter()
1494                    .any(|r| r.kind.wire() == "type.escape"),
1495                "{sym}: body Any in an eager container must emit type.escape"
1496            );
1497            assert!(
1498                f.own_score >= 1.0,
1499                "{sym}: body Any must void the discount (local.mutation stays class 1), \
1500                 got own_score={}",
1501                f.own_score
1502            );
1503        }
1504    }
1505
1506    /// FIX 6: when the boundary discount fires the effect's human-readable
1507    /// `discount` rationale must be set (mirrors the TS frontend).
1508    #[test]
1509    fn discounted_effect_sets_rationale_string() {
1510        let h = scan_fixture_hotspots("coverage");
1511        let ft = h.iter().find(|x| x.symbol == "fully_typed").unwrap();
1512        let lm = ft
1513            .effects
1514            .iter()
1515            .find(|e| e.kind.wire() == "local.mutation")
1516            .expect("fully_typed must have a local.mutation effect");
1517        assert_eq!(
1518            lm.discount.as_deref(),
1519            Some("contained, Full-typed boundary"),
1520            "discounted effect must carry the Full-boundary rationale"
1521        );
1522    }
1523
1524    #[test]
1525    fn coverage_tiers_and_decorator_confidence() {
1526        let h = scan_fixture_hotspots("coverage");
1527        let score = |s: &str| h.iter().find(|x| x.symbol == s).unwrap().own_score;
1528        assert_eq!(score("untyped"), 1.0); // None coverage → local.mutation stays class 1
1529        assert_eq!(score("partial"), 0.0); // any coverage > None floors class-1 local to 0
1530        let dec = h.iter().find(|x| x.symbol == "decorated").unwrap();
1531        assert!(dec.confidence < 1.0); // unknown decorator reduces confidence
1532    }
1533
1534    #[test]
1535    fn coverage_excludes_self_and_degrades_untyped_star_args() {
1536        use fxrank_core::score::BoundaryCoverage;
1537        let src = "class C:\n    def m(self, x: int) -> int:\n        return x\ndef v(*args) -> int:\n    return 0\n";
1538        let cov_m = coverage_of_symbol(src, "m");
1539        assert_eq!(cov_m.boundary, BoundaryCoverage::Full); // self excluded → (x, return) both typed
1540        let cov_v = coverage_of_symbol(src, "v");
1541        assert_ne!(cov_v.boundary, BoundaryCoverage::Full); // untyped *args degrades coverage
1542    }
1543
1544    /// Regression for FIX B (Copilot round-2): `count_awaits` must NOT count
1545    /// `await` expressions that appear in a generator-expression `if` condition
1546    /// or a nested `for` clause's iterable — those are lazy and execute in the
1547    /// consumer's scope, not the enclosing function's body. Only the genexp's
1548    /// **outermost iterable** is eager and therefore counted.
1549    ///
1550    /// The concrete pre-fix bug: old code called `count_in_comp_for(&g.for_in)`
1551    /// which descended into `comp.ifs` (if conditions) and `inner_for_in` (nested
1552    /// for clauses) — both lazy in a genexp. The fix is `count_in_expr(&g.for_in.iter)`
1553    /// (outermost iterable only), mirroring `walk_comp_for`'s `eager = false` branch.
1554    ///
1555    /// Contrast with a list-comprehension: its `if` conditions ARE eager and
1556    /// their awaits DO count.
1557    ///
1558    /// Uses `tests/fixtures/genexp_await.py`.
1559    #[test]
1560    fn count_awaits_genexp_if_and_nested_for_are_lazy_outermost_iterable_is_eager() {
1561        let h = scan_fixture_hotspots("genexp_await");
1562        let find = |sym: &str| {
1563            h.iter()
1564                .find(|x| x.symbol == sym)
1565                .unwrap_or_else(|| panic!("symbol {sym} not found in hotspots"))
1566        };
1567
1568        // `genexp_await_in_if_condition`: `await predicate(x)` is in the genexp
1569        // IF condition — lazy. await_count must be 0.
1570        // (Pre-fix: old code used count_in_comp_for which visited comp.ifs, so
1571        // it would return await_count=1. This test fails on old code, passes with fix.)
1572        let lazy_if = find("genexp_await_in_if_condition");
1573        assert_eq!(
1574            lazy_if.await_count, 0,
1575            "genexp `if` condition await must NOT count toward enclosing await_count; \
1576             got await_count={} for genexp_await_in_if_condition",
1577            lazy_if.await_count
1578        );
1579
1580        // `listcomp_await_in_if_condition`: `await predicate(x)` is in a LIST-COMP
1581        // IF condition — eager. await_count must be >= 1.
1582        let eager_listcomp = find("listcomp_await_in_if_condition");
1583        assert!(
1584            eager_listcomp.await_count >= 1,
1585            "list-comp `if` condition await IS eager and MUST count toward await_count; \
1586             got await_count={} for listcomp_await_in_if_condition",
1587            eager_listcomp.await_count
1588        );
1589        assert!(
1590            eager_listcomp.async_boundary,
1591            "list-comp `if` condition await must set async_boundary; \
1592             got async_boundary={} for listcomp_await_in_if_condition",
1593            eager_listcomp.async_boundary
1594        );
1595
1596        // `genexp_await_in_nested_for_iterable`: `await get_items()` is in a
1597        // NESTED for clause's iterable inside a genexp — lazy. await_count must be 0.
1598        // (Pre-fix: old code used count_in_comp_for which recursed into inner_for_in.)
1599        let lazy_nested = find("genexp_await_in_nested_for_iterable");
1600        assert_eq!(
1601            lazy_nested.await_count, 0,
1602            "genexp nested-for iterable await must NOT count toward enclosing await_count; \
1603             got await_count={} for genexp_await_in_nested_for_iterable",
1604            lazy_nested.await_count
1605        );
1606
1607        // `genexp_await_in_outermost_iterable`: `await get_items()` is in the
1608        // OUTERMOST iterable — always eager. Must count.
1609        let eager_iterable = find("genexp_await_in_outermost_iterable");
1610        assert!(
1611            eager_iterable.await_count >= 1,
1612            "genexp outermost-iterable await IS eager and MUST count; \
1613             got await_count={} for genexp_await_in_outermost_iterable",
1614            eager_iterable.await_count
1615        );
1616    }
1617
1618    /// Copilot FIX 2: `walk_expr` must traverse f-string `format_spec` expression parts.
1619    /// `f"{x:{requests.get(u)}}"` — `requests.get(u)` is in the format-spec and is eager.
1620    #[test]
1621    fn fstring_format_spec_walk_expr_charges_effects() {
1622        let src = "import requests\ndef f(x, u):\n    return f\"{x:{requests.get(u)}}\"\n";
1623        let module = libcst_native::parse_module(src, None).unwrap();
1624        let imports = Imports::build(&module);
1625        let module_bindings = crate::imports::module_bindings(&module);
1626        let span = SpanIndex::new(src);
1627        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
1628        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1629        let unit = units.iter().find(|u| u.symbol == "f").unwrap();
1630        let h = analyze_unit(unit, "x.py", &imports, &module_bindings, &span);
1631        assert!(
1632            h.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
1633            "requests.get(u) inside f-string format_spec must emit net.fs.db; got: {:?}",
1634            h.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1635        );
1636    }
1637
1638    /// Copilot FIX 3: `count_awaits` must count awaits in f-string `format_spec`.
1639    /// `f"{x:{await w()}}"` — the `await` is in the format-spec, which is eager.
1640    #[test]
1641    fn fstring_format_spec_await_counts() {
1642        let src = "async def f(x):\n    async def w(): ...\n    return f\"{x:{await w()}}\"\n";
1643        let module = libcst_native::parse_module(src, None).unwrap();
1644        let imports = Imports::build(&module);
1645        let module_bindings = crate::imports::module_bindings(&module);
1646        let span = SpanIndex::new(src);
1647        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
1648        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1649        let outer = units.iter().find(|u| u.symbol == "f").unwrap();
1650        let h = analyze_unit(outer, "x.py", &imports, &module_bindings, &span);
1651        assert!(
1652            h.await_count >= 1,
1653            "await inside f-string format_spec must count; got await_count={}",
1654            h.await_count
1655        );
1656        assert!(
1657            h.async_boundary,
1658            "await inside f-string format_spec must set async_boundary"
1659        );
1660    }
1661
1662    /// Copilot FIX 4: body-`Any` detection must descend into f-string `format_spec`.
1663    /// `cast(Any, x)` inside a format-spec must emit `type.escape` and void the discount.
1664    #[test]
1665    fn fstring_format_spec_body_any_emits_type_escape() {
1666        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";
1667        let module = libcst_native::parse_module(src, None).unwrap();
1668        let imports = Imports::build(&module);
1669        let module_bindings = crate::imports::module_bindings(&module);
1670        let span = SpanIndex::new(src);
1671        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
1672        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1673        let unit = units.iter().find(|u| u.symbol == "f").unwrap();
1674        let h = analyze_unit(unit, "x.py", &imports, &module_bindings, &span);
1675        assert!(
1676            h.risk_features
1677                .iter()
1678                .any(|r| r.kind.wire() == "type.escape"),
1679            "cast(Any, …) inside f-string format_spec must emit type.escape; got: {:?}",
1680            h.risk_features
1681                .iter()
1682                .map(|r| r.kind.wire())
1683                .collect::<Vec<_>>()
1684        );
1685    }
1686
1687    /// Task 2 (Phase-3a): the mutation tuple's `contained` bool must flow through
1688    /// to `Effect.contained` after `gather` assembles the effects list.
1689    ///
1690    /// - A body-local mutation (`acc.append(1)` where `acc` is a local) is
1691    ///   `LocalMutation, contained=true` from the detector; `Effect.contained`
1692    ///   must be `true` and `escapes()` must be `false`.
1693    /// - An escaping mutation (`global g; g = 1` → `GlobalMutation`) is
1694    ///   `contained=false`; `Effect.contained` must be `false` and `escapes()` must
1695    ///   be `true`.
1696    #[test]
1697    fn gather_sets_effect_contained_from_mutation_tuple() {
1698        // --- contained: body-local list build → LocalMutation, contained=true ---
1699        let contained_src =
1700            "def builds_local():\n    acc = []\n    acc.append(1)\n    return acc\n";
1701        let module = libcst_native::parse_module(contained_src, None).unwrap();
1702        let imports = Imports::build(&module);
1703        let module_bindings = crate::imports::module_bindings(&module);
1704        let span = SpanIndex::new(contained_src);
1705        let anchors = crate::source::lambda_anchors(contained_src).expect("tokenize");
1706        let (units, _) = crate::functions::collect(&module, contained_src, &span, &anchors);
1707        let unit = units
1708            .iter()
1709            .find(|u| u.symbol == "builds_local")
1710            .expect("builds_local not found");
1711        let h = analyze_unit(unit, "test.py", &imports, &module_bindings, &span);
1712        let local_mut = h
1713            .effects
1714            .iter()
1715            .find(|e| e.kind.wire() == "local.mutation")
1716            .expect("expected a local.mutation effect");
1717        assert!(
1718            local_mut.contained,
1719            "local.mutation from a body-local build must have contained=true, got: {:?}",
1720            local_mut
1721        );
1722        assert!(
1723            !local_mut.escapes(),
1724            "a contained local.mutation must not escape, got: {:?}",
1725            local_mut
1726        );
1727
1728        // --- escaping: global mutation → GlobalMutation, contained=false ---
1729        let escaping_src = "_g = 0\ndef uses_global():\n    global _g\n    _g += 1\n";
1730        let module2 = libcst_native::parse_module(escaping_src, None).unwrap();
1731        let imports2 = Imports::build(&module2);
1732        let module_bindings2 = crate::imports::module_bindings(&module2);
1733        let span2 = SpanIndex::new(escaping_src);
1734        let anchors2 = crate::source::lambda_anchors(escaping_src).expect("tokenize");
1735        let (units2, _) = crate::functions::collect(&module2, escaping_src, &span2, &anchors2);
1736        let unit2 = units2
1737            .iter()
1738            .find(|u| u.symbol == "uses_global")
1739            .expect("uses_global not found");
1740        let h2 = analyze_unit(unit2, "test.py", &imports2, &module_bindings2, &span2);
1741        let global_mut = h2
1742            .effects
1743            .iter()
1744            .find(|e| e.kind.wire() == "global.mutation")
1745            .expect("expected a global.mutation effect");
1746        assert!(
1747            !global_mut.contained,
1748            "global.mutation must have contained=false, got: {:?}",
1749            global_mut
1750        );
1751        assert!(
1752            global_mut.escapes(),
1753            "a non-contained global.mutation must escape, got: {:?}",
1754            global_mut
1755        );
1756    }
1757
1758    /// Task 4: `build_record` emits a `UnitRecord` 1:1 with the Hotspot.
1759    /// Parse `import os\ndef writer():\n    os.getcwd()`; build the record;
1760    /// assert: symbol "writer", a ref `base "os.getcwd"` with `qualified true`,
1761    /// `unit_id` ends `:writer`, `language == Python`.
1762    /// The frontend always emits `is_root: false`; the CLI sets the real value.
1763    #[test]
1764    fn build_record_emits_record_for_python_unit() {
1765        use crate::module_map::PyModuleMap;
1766        use fxrank_core::frontend::SourceFile;
1767        let src = "import os\ndef writer():\n    os.getcwd()\n";
1768        let module = libcst_native::parse_module(src, None).unwrap();
1769        let imports = Imports::build(&module);
1770        let module_bindings = crate::imports::module_bindings(&module);
1771        let span = SpanIndex::new(src);
1772        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
1773        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
1774        let unit = units
1775            .iter()
1776            .find(|u| u.symbol == "writer")
1777            .expect("writer unit not found");
1778        let mmap = PyModuleMap::build(&[SourceFile {
1779            path: "test.py".into(),
1780            text: String::new(),
1781        }]);
1782        let rec = build_record(unit, "test.py", &imports, &module_bindings, &span, &mmap);
1783
1784        assert_eq!(rec.symbol, "writer");
1785        assert!(
1786            rec.unit_id.ends_with(":writer"),
1787            "unit_id must end with ':writer', got: {}",
1788            rec.unit_id
1789        );
1790        assert_eq!(
1791            rec.language,
1792            fxrank_core::frontend::Language::Python,
1793            "language must be Python"
1794        );
1795        // Frontend always emits is_root: false; CLI sets the real value.
1796        assert!(
1797            !rec.is_root,
1798            "frontend build_record must emit is_root=false (CLI sets the real value)"
1799        );
1800        let os_ref = rec
1801            .refs
1802            .iter()
1803            .find(|r| r.base == "os.getcwd")
1804            .expect("expected a ref with base 'os.getcwd'");
1805        assert!(
1806            os_ref.qualified,
1807            "os.getcwd ref must have qualified=true (os is imported)"
1808        );
1809    }
1810
1811    // ── Task 2 (Phase-3d): module-init unit ───────────────────────────────────
1812
1813    /// Helper: parse an inline Python `src`, build a synthetic `<module>` unit
1814    /// (if any), run `analyze_unit` on it, and return the `Hotspot`.
1815    fn module_init_hotspot(src: &str) -> Option<Hotspot> {
1816        let module = libcst_native::parse_module(src, None).unwrap();
1817        let imports = Imports::build(&module);
1818        let module_bindings = crate::imports::module_bindings(&module);
1819        let span = SpanIndex::new(src);
1820        let unit = crate::functions::module_init_unit(&module)?;
1821        Some(analyze_unit(
1822            &unit,
1823            "test.py",
1824            &imports,
1825            &module_bindings,
1826            &span,
1827        ))
1828    }
1829
1830    /// A module with import-time effects MUST emit a `<module>` hotspot whose
1831    /// own-body effects capture only the TOP-LEVEL statements — NOT effects from
1832    /// inside nested `def` bodies (own-body isolation assertion).
1833    ///
1834    /// Source:
1835    /// ```python
1836    /// import os
1837    /// CONFIG = os.environ["X"]   # ← top-level subscript read of os.environ
1838    /// print("loading")           # ← top-level call (logging effect)
1839    /// def impure():
1840    ///     open("f")              # ← inside def body — must NOT appear on <module>
1841    /// def pure():
1842    ///     return 1
1843    /// ```
1844    #[test]
1845    fn module_init_captures_top_level_effects_not_nested_def_body() {
1846        let src = concat!(
1847            "import os\n",
1848            "CONFIG = os.environ[\"X\"]\n",
1849            "print(\"loading\")\n",
1850            "def impure():\n",
1851            "    open(\"f\")\n",
1852            "def pure():\n",
1853            "    return 1\n",
1854        );
1855        let h = module_init_hotspot(src).expect("<module> hotspot must exist for impure module");
1856
1857        // The `<module>` symbol is correct.
1858        assert_eq!(
1859            h.symbol, "<module>",
1860            "synthetic unit must have symbol '<module>'"
1861        );
1862
1863        // The `<module>` hotspot must have at least one effect (the top-level call
1864        // to `print` or the `os.environ` subscript read).
1865        assert!(
1866            !h.effects.is_empty(),
1867            "<module> must have ≥1 effect from top-level statements; got: {:?}",
1868            h.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1869        );
1870
1871        // ISOLATION ASSERTION (the load-bearing correctness check):
1872        // `impure`'s `open("f")` is inside a `def` body — it MUST NOT appear on
1873        // the `<module>` unit's effects (own-body semantics: nested def bodies are
1874        // separate units, never charged to the enclosing module scope).
1875        let module_effect_kinds: Vec<&str> = h.effects.iter().map(|e| e.kind.wire()).collect();
1876        assert!(
1877            !module_effect_kinds.contains(&"net.fs.db"),
1878            "impure()'s open('f') is inside a def body and must NOT surface on <module>; \
1879             got module effects: {:?}",
1880            module_effect_kinds
1881        );
1882
1883        // `impure` and `pure` are their own separate units (scored via scan_fixture).
1884        // Verify they can be found by the regular collect path.
1885        let module2 = libcst_native::parse_module(src, None).unwrap();
1886        let span = SpanIndex::new(src);
1887        let anchors = crate::source::lambda_anchors(src).expect("tokenize");
1888        let imports = Imports::build(&module2);
1889        let module_bindings = crate::imports::module_bindings(&module2);
1890        let (units, _) = crate::functions::collect(&module2, src, &span, &anchors);
1891        let impure_h = analyze_unit(
1892            units
1893                .iter()
1894                .find(|u| u.symbol == "impure")
1895                .expect("impure unit"),
1896            "test.py",
1897            &imports,
1898            &module_bindings,
1899            &span,
1900        );
1901        assert!(
1902            impure_h
1903                .effects
1904                .iter()
1905                .any(|e| e.kind.wire() == "net.fs.db"),
1906            "impure must have its own net.fs.db from open('f'); got: {:?}",
1907            impure_h
1908                .effects
1909                .iter()
1910                .map(|e| e.kind.wire())
1911                .collect::<Vec<_>>()
1912        );
1913    }
1914
1915    /// A pure module (only `import` declarations and `def`/`class` definitions
1916    /// with no executable top-level statements) must produce NO `<module>` hotspot.
1917    #[test]
1918    fn pure_module_emits_no_module_init_hotspot() {
1919        let src = "import os\ndef f():\n    return 1\n";
1920        assert!(
1921            module_init_hotspot(src).is_none(),
1922            "a pure module (import + def, no top-level effects) must not emit a <module> hotspot"
1923        );
1924    }
1925
1926    // ── Feature 025: module-init captures class decorators + base-class exprs ──
1927
1928    /// A top-level class decorator that is an effectful call must be captured on
1929    /// `<module>`. `@open("y")` on a top-level class runs at import time.
1930    ///
1931    /// Uses `open(...)` which resolves to `net.fs.db` (class 7) — a concrete scored
1932    /// effect rather than an unscored call reference.
1933    #[test]
1934    fn module_init_captures_class_decorator_effect() {
1935        let src = concat!("@open(\"y\")\n", "class C:\n", "    pass\n",);
1936        let h = module_init_hotspot(src)
1937            .expect("<module> hotspot must exist when a class has an effectful decorator");
1938        assert!(
1939            h.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
1940            "class decorator open(\"y\") must charge net.fs.db to <module>; got: {:?}",
1941            h.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1942        );
1943    }
1944
1945    /// A top-level class base-class expression that is an effectful call must be
1946    /// captured on `<module>`. `class C(open("y")):` runs `open("y")` at import time.
1947    #[test]
1948    fn module_init_captures_class_base_expr_effect() {
1949        let src = concat!("class C(open(\"y\")):\n", "    pass\n",);
1950        let h = module_init_hotspot(src)
1951            .expect("<module> hotspot must exist when a class has an effectful base expression");
1952        assert!(
1953            h.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
1954            "class base open(\"y\") must charge net.fs.db to <module>; got: {:?}",
1955            h.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1956        );
1957    }
1958
1959    /// A method body's call MUST NOT appear on `<module>` — isolation is preserved
1960    /// even when the class has an effectful base expression that IS captured.
1961    ///
1962    /// `class C(open("y")): def m(self): open("z")`
1963    ///   → `open("y")` (base)  → captured on `<module>` (net.fs.db, exactly 1)
1964    ///   → `open("z")` (method body) → NOT on `<module>` (remains on `m`)
1965    #[test]
1966    fn module_init_class_header_captured_method_body_not() {
1967        let src = concat!(
1968            "class C(open(\"y\")):\n",
1969            "    def m(self):\n",
1970            "        open(\"z\")\n",
1971        );
1972        let h = module_init_hotspot(src)
1973            .expect("<module> hotspot must exist when a class has an effectful base expression");
1974
1975        // The base-class `open("y")` must appear on `<module>`.
1976        assert!(
1977            h.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
1978            "class base open(\"y\") must charge net.fs.db to <module>; got: {:?}",
1979            h.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
1980        );
1981
1982        // ISOLATION GUARD: the method body contributes a SECOND `open` call which
1983        // must NOT appear on `<module>`. The count must be exactly 1 (only the base).
1984        let net_count = h
1985            .effects
1986            .iter()
1987            .filter(|e| e.kind.wire() == "net.fs.db")
1988            .count();
1989        assert_eq!(
1990            net_count, 1,
1991            "method body open(\"z\") must NOT be double-counted on <module>; \
1992             expected exactly 1 net.fs.db effect, got {net_count}"
1993        );
1994    }
1995
1996    // ── Task 2 (Phase-3e): canonical_path in build_record ─────────────────────
1997
1998    /// A module-level `write` function in `pkg/util.py` gets canonical_path
1999    /// `["pkg", "util", "write"]` (the module key + the function name).
2000    #[test]
2001    fn build_record_sets_canonical_path() {
2002        use crate::module_map::PyModuleMap;
2003        use fxrank_core::frontend::SourceFile;
2004        // Module pkg.util with a top-level `write`.
2005        let mmap = PyModuleMap::build(&[
2006            SourceFile {
2007                path: "pkg/__init__.py".into(),
2008                text: String::new(),
2009            },
2010            SourceFile {
2011                path: "pkg/util.py".into(),
2012                text: String::new(),
2013            },
2014        ]);
2015        let src = "def write():\n    pass\n";
2016        let module = libcst_native::parse_module(src, None).unwrap();
2017        let imports = Imports::build(&module);
2018        let module_bindings = crate::imports::module_bindings(&module);
2019        let span = SpanIndex::new(src);
2020        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
2021        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
2022        let unit = units
2023            .iter()
2024            .find(|u| u.symbol == "write")
2025            .expect("write unit not found");
2026        let rec = build_record(
2027            unit,
2028            "pkg/util.py",
2029            &imports,
2030            &module_bindings,
2031            &span,
2032            &mmap,
2033        );
2034        assert_eq!(
2035            rec.canonical_path,
2036            vec!["pkg".to_string(), "util".into(), "write".into()],
2037            "module-level write in pkg/util.py must get canonical_path [pkg, util, write]"
2038        );
2039    }
2040
2041    /// A CLASS method `write` inside `pkg/util.py` is NOT module-level and must get
2042    /// an empty canonical_path (so it can never false-resolve for
2043    /// `from pkg.util import write`).
2044    #[test]
2045    fn method_unit_gets_empty_canonical_path() {
2046        use crate::module_map::PyModuleMap;
2047        use fxrank_core::frontend::SourceFile;
2048        let mmap = PyModuleMap::build(&[
2049            SourceFile {
2050                path: "pkg/__init__.py".into(),
2051                text: String::new(),
2052            },
2053            SourceFile {
2054                path: "pkg/util.py".into(),
2055                text: String::new(),
2056            },
2057        ]);
2058        let src = "class C:\n    def write(self):\n        pass\n";
2059        let module = libcst_native::parse_module(src, None).unwrap();
2060        let imports = Imports::build(&module);
2061        let module_bindings = crate::imports::module_bindings(&module);
2062        let span = SpanIndex::new(src);
2063        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
2064        let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
2065        let method_unit = units
2066            .iter()
2067            .find(|u| u.symbol == "write")
2068            .expect("write method unit not found");
2069        assert!(
2070            !method_unit.is_module_level,
2071            "a class method must have is_module_level=false, got true"
2072        );
2073        let rec = build_record(
2074            method_unit,
2075            "pkg/util.py",
2076            &imports,
2077            &module_bindings,
2078            &span,
2079            &mmap,
2080        );
2081        assert!(
2082            rec.canonical_path.is_empty(),
2083            "a method must not get an importable canonical_path; got: {:?}",
2084            rec.canonical_path
2085        );
2086    }
2087}