Skip to main content

fxrank_lang_python/detect/
mutation.rs

1//! Mutation detection with escape analysis for Python — the `fxrank-lang-python`
2//! analog of `fxrank-lang-ts`'s `detect/mutation.rs`.
3//!
4//! Python's mutation story is simpler than Rust's (no `&mut` / ownership) but
5//! more nuanced than JS's: `global` and `nonlocal` declarations are function-wide
6//! (not position-dependent), and `self.attr = …` inside `__init__` is construction
7//! (contained), not escaping state mutation.
8//!
9//! ## Escape classification table
10//!
11//! | write site                                   | kind             | class | contained |
12//! |----------------------------------------------|------------------|-------|-----------|
13//! | `global x` declared, then `x = …` or `x += …` | `global.mutation` | 6  | false     |
14//! | `nonlocal x` declared, then `x = …` or `x += …` | `this.mutation` | 3 | false     |
15//! | `self.attr = …` in `__init__`               | `local.mutation` | 1     | **true**  |
16//! | `self.attr = …` in a non-`__init__` method  | `this.mutation`  | 3     | false     |
17//! | `self.x.append(…)` / `self[i] = …` (any method, incl. `__init__`) | `this.mutation` | 3 | false |
18//! | write where root is a **param name**        | `param.mutation` | 3     | false     |
19//! | write where root is a **local binding**     | `local.mutation` | 1     | **true**  |
20//! | module top-level binding, content-mutated (no `global`) | `global.mutation` | 6 | false |
21//!
22//! ## Strategy
23//!
24//! 1. **Pre-scan** the function body for `global`/`nonlocal` declarations and
25//!    local bindings — `Assign`/`AnnAssign` targets (incl. tuple/list/starred
26//!    destructuring), `AugAssign` bare-`Name` targets, `for`/`async for` loop
27//!    targets, `with … as`/`async with … as` names, and `except … as` /
28//!    `except* … as` names — building the `globals`, `nonlocals`, and `locals`
29//!    sets. (Python scoping: any binding-form in a body makes the name
30//!    function-local for the whole function.)
31//! 2. **Extract** parameter names from `unit.params`.
32//! 3. **Walk** the body classifying write targets: `Assign`/`AnnAssign`/`AugAssign`
33//!    targets, and mutating method calls (`.append`, `.update`, `.add`) via
34//!    `on_call` in the EffectSink.
35//!
36//! The `contained` bool returned alongside each `Effect` is the
37//! boundary-containment signal that Task 9's discount consumes.
38
39use std::collections::HashSet;
40
41use fxrank_core::confidence::detection_confidence;
42use fxrank_core::effect::{Effect, EffectKind, Tier};
43use fxrank_core::score::weight_for_class;
44use libcst_native::{
45    Assert, AssignTargetExpression, Call, Expression, Name, Parameters, Raise, SmallStatement,
46    Statement, Suite,
47};
48
49use super::expr::render_expr;
50use super::{EffectSink, walk_own_body};
51use crate::functions::{FnBody, FnUnit};
52use crate::imports::Imports;
53use crate::source::{SpanIndex, anchor_of_subslice};
54
55/// Detect mutation effects in `unit`'s own body, with escape analysis.
56///
57/// Returns `(Effect, contained)` pairs. The `bool` is the containment flag —
58/// `true` means the write is bounded to this function's scope (local init or
59/// constructor init); `false` means it escapes.
60///
61/// Task 9 consumes the `contained` flags to apply boundary-containment discounts.
62pub fn detect(
63    unit: &FnUnit,
64    imports: &Imports,
65    module_bindings: &HashSet<String>,
66    span: &SpanIndex,
67) -> Vec<(Effect, bool)> {
68    // ── Step 1: collect param names from the unit's signature ────────────────
69    let params = collect_param_names(unit.params);
70
71    // ── Step 2: pre-scan body for global/nonlocal declarations + local assigns ─
72    let mut globals: HashSet<String> = HashSet::new();
73    let mut nonlocals: HashSet<String> = HashSet::new();
74    let mut locals: HashSet<String> = HashSet::new();
75    prescan_body(&unit.body, &mut globals, &mut nonlocals, &mut locals);
76
77    // ── Step 3: classify writes via the EffectSink driver ────────────────────
78    let is_init = unit.symbol == "__init__";
79    let mut sink = MutSink {
80        params: &params,
81        globals: &globals,
82        nonlocals: &nonlocals,
83        locals: &locals,
84        imports,
85        module_bindings,
86        is_init,
87        span,
88        effects: Vec::new(),
89    };
90    walk_own_body(unit, &mut sink);
91    sink.effects
92}
93
94// ─── parameter name extraction ────────────────────────────────────────────────
95
96/// Extract all parameter name strings from `params`.
97///
98/// Covers positional-only, regular, keyword-only, and `**kwargs` params;
99/// skips the `*` bare separator. The `self`/`cls` first-param convention is
100/// included — callers that want to exclude it do so by not treating `self` as
101/// a mutation target (it is handled specially in the write-site classifier).
102fn collect_param_names(params: &Parameters) -> HashSet<String> {
103    let mut out = HashSet::new();
104    let all = params
105        .posonly_params
106        .iter()
107        .chain(&params.params)
108        .chain(&params.kwonly_params);
109    for p in all {
110        out.insert(p.name.value.to_owned());
111    }
112    if let Some(libcst_native::StarArg::Param(p)) = &params.star_arg {
113        out.insert(p.name.value.to_owned());
114    }
115    if let Some(p) = &params.star_kwarg {
116        out.insert(p.name.value.to_owned());
117    }
118    out
119}
120
121// ─── pre-scan: global/nonlocal declarations + local bindings ─────────────────
122
123/// Walk the body suite or lambda body expression to collect:
124/// - `globals`: names declared with `global`.
125/// - `nonlocals`: names declared with `nonlocal`.
126/// - `locals`: names introduced by assignment in the function body — including
127///   `Assign`/`AnnAssign` targets (incl. tuple/list/starred destructuring),
128///   `AugAssign` bare-`Name` targets, `for`/`async for` loop targets,
129///   `with … as`/`async with … as` names, and `except … as` / `except* … as`
130///   names.  (Python scoping: any binding-form in a body makes the name
131///   function-local for the whole function.)
132///
133/// Residual accepted limits (not collected here): `match` pattern captures,
134/// comprehension-scope targets (Python 3 gives them their own scope), and
135/// walrus (`:=`) operator targets.
136///
137/// Only scans the **own** body (does not descend into nested `def`/`lambda`).
138fn prescan_body(
139    body: &FnBody,
140    globals: &mut HashSet<String>,
141    nonlocals: &mut HashSet<String>,
142    locals: &mut HashSet<String>,
143) {
144    match body {
145        FnBody::Suite(suite) => prescan_suite(suite, globals, nonlocals, locals),
146        FnBody::Expr(_) => {} // lambdas have no statements
147    }
148}
149
150fn prescan_suite(
151    suite: &Suite,
152    globals: &mut HashSet<String>,
153    nonlocals: &mut HashSet<String>,
154    locals: &mut HashSet<String>,
155) {
156    match suite {
157        Suite::IndentedBlock(b) => {
158            for stmt in &b.body {
159                prescan_stmt(stmt, globals, nonlocals, locals);
160            }
161        }
162        Suite::SimpleStatementSuite(s) => {
163            for small in &s.body {
164                prescan_small(small, globals, nonlocals, locals);
165            }
166        }
167    }
168}
169
170fn prescan_stmt(
171    stmt: &Statement,
172    globals: &mut HashSet<String>,
173    nonlocals: &mut HashSet<String>,
174    locals: &mut HashSet<String>,
175) {
176    match stmt {
177        Statement::Simple(line) => {
178            for small in &line.body {
179                prescan_small(small, globals, nonlocals, locals);
180            }
181        }
182        Statement::Compound(c) => prescan_compound(c, globals, nonlocals, locals),
183    }
184}
185
186fn prescan_compound(
187    compound: &libcst_native::CompoundStatement,
188    globals: &mut HashSet<String>,
189    nonlocals: &mut HashSet<String>,
190    locals: &mut HashSet<String>,
191) {
192    use libcst_native::CompoundStatement;
193    match compound {
194        // Nested def/lambda: do NOT descend (own-body attribution).
195        CompoundStatement::FunctionDef(_) | CompoundStatement::ClassDef(_) => {}
196        CompoundStatement::If(i) => {
197            prescan_suite(&i.body, globals, nonlocals, locals);
198            if let Some(orelse) = &i.orelse {
199                prescan_orelse(orelse, globals, nonlocals, locals);
200            }
201        }
202        CompoundStatement::For(f) => {
203            // `for <target> in …` — the target is a Python local for the whole function
204            // (PEP 3104), whether the loop is `for` or `async for` (same node, just an
205            // `asynchronous` flag). Collect target names before recursing into the body.
206            crate::imports::collect_target_names(&f.target, locals);
207            prescan_suite(&f.body, globals, nonlocals, locals);
208            if let Some(orelse) = &f.orelse {
209                prescan_suite(&orelse.body, globals, nonlocals, locals);
210            }
211        }
212        CompoundStatement::While(w) => {
213            prescan_suite(&w.body, globals, nonlocals, locals);
214            if let Some(orelse) = &w.orelse {
215                prescan_suite(&orelse.body, globals, nonlocals, locals);
216            }
217        }
218        CompoundStatement::Try(t) => {
219            prescan_suite(&t.body, globals, nonlocals, locals);
220            for h in &t.handlers {
221                // `except SomeError as e:` — `e` is a Python local for the whole
222                // function (unlike in Python 2, it is deleted after the block, but it
223                // IS in scope inside the handler and binds the name function-locally).
224                if let Some(asname) = &h.name {
225                    crate::imports::collect_target_names(&asname.name, locals);
226                }
227                prescan_suite(&h.body, globals, nonlocals, locals);
228            }
229            if let Some(orelse) = &t.orelse {
230                prescan_suite(&orelse.body, globals, nonlocals, locals);
231            }
232            if let Some(fin) = &t.finalbody {
233                prescan_suite(&fin.body, globals, nonlocals, locals);
234            }
235        }
236        CompoundStatement::TryStar(t) => {
237            prescan_suite(&t.body, globals, nonlocals, locals);
238            for h in &t.handlers {
239                // `except* SomeError as e:` — same binding semantics as `except … as`.
240                if let Some(asname) = &h.name {
241                    crate::imports::collect_target_names(&asname.name, locals);
242                }
243                prescan_suite(&h.body, globals, nonlocals, locals);
244            }
245            if let Some(orelse) = &t.orelse {
246                prescan_suite(&orelse.body, globals, nonlocals, locals);
247            }
248            if let Some(fin) = &t.finalbody {
249                prescan_suite(&fin.body, globals, nonlocals, locals);
250            }
251        }
252        CompoundStatement::With(w) => {
253            // `with expr as <target>:` (and `async with`) — `target` is a Python local
254            // for the whole function. Both are the same `With` node with an
255            // `asynchronous` flag. Collect asname targets before recursing into the body.
256            for item in &w.items {
257                if let Some(asname) = &item.asname {
258                    crate::imports::collect_target_names(&asname.name, locals);
259                }
260            }
261            prescan_suite(&w.body, globals, nonlocals, locals);
262        }
263        CompoundStatement::Match(m) => {
264            for case in &m.cases {
265                prescan_suite(&case.body, globals, nonlocals, locals);
266            }
267        }
268    }
269}
270
271fn prescan_orelse(
272    orelse: &libcst_native::OrElse,
273    globals: &mut HashSet<String>,
274    nonlocals: &mut HashSet<String>,
275    locals: &mut HashSet<String>,
276) {
277    match orelse {
278        libcst_native::OrElse::Elif(elif) => {
279            prescan_suite(&elif.body, globals, nonlocals, locals);
280            if let Some(inner) = &elif.orelse {
281                prescan_orelse(inner, globals, nonlocals, locals);
282            }
283        }
284        libcst_native::OrElse::Else(e) => {
285            prescan_suite(&e.body, globals, nonlocals, locals);
286        }
287    }
288}
289
290fn prescan_small(
291    small: &SmallStatement,
292    globals: &mut HashSet<String>,
293    nonlocals: &mut HashSet<String>,
294    locals: &mut HashSet<String>,
295) {
296    match small {
297        SmallStatement::Global(g) => {
298            for item in &g.names {
299                globals.insert(item.name.value.to_owned());
300            }
301        }
302        SmallStatement::Nonlocal(n) => {
303            for item in &n.names {
304                nonlocals.insert(item.name.value.to_owned());
305            }
306        }
307        SmallStatement::Assign(a) => {
308            // Collect ALL bound names recursively through tuple/list/starred
309            // destructuring (not just bare `Name`). Python's scoping rule: any
310            // binding-assignment in a function body — including `(a, b) = …` and
311            // `[x, *rest] = …` — makes the bound names local to the whole function.
312            for target in &a.targets {
313                crate::imports::collect_target_names(&target.target, locals);
314            }
315        }
316        // AnnAssign `x: T = …` also introduces a local (recurse for safety,
317        // though `x: T` is always a bare Name in practice).
318        SmallStatement::AnnAssign(a) => {
319            crate::imports::collect_target_names(&a.target, locals);
320        }
321        // AugAssign `x += …` binds the name locally when the target is a bare Name.
322        // Only a Name target introduces a binding; `x.attr += 1` / `x[i] += 1` do not.
323        SmallStatement::AugAssign(a) => {
324            if let AssignTargetExpression::Name(n) = &a.target {
325                locals.insert(n.value.to_owned());
326            }
327        }
328        _ => {}
329    }
330}
331
332// ─── write-site classifier (EffectSink) ──────────────────────────────────────
333
334struct MutSink<'a> {
335    params: &'a HashSet<String>,
336    globals: &'a HashSet<String>,
337    nonlocals: &'a HashSet<String>,
338    /// Locally-assigned names (global/nonlocal names are removed from this set
339    /// in the classification logic).
340    locals: &'a HashSet<String>,
341    /// File-wide import table — lets the cascade resolve an import-rooted write (F5)
342    /// and distinguish a captured opaque binding (F1) from a known module.
343    imports: &'a Imports,
344    /// Module top-level binding names (assign targets + def/class). A write whose
345    /// root is one of these — and is not a local/param/global-decl/import — is
346    /// module-shared state, escalated to `global.mutation` (the #29 analog).
347    module_bindings: &'a HashSet<String>,
348    /// True when analyzing `__init__` (so `self.attr = …` is local init).
349    is_init: bool,
350    span: &'a SpanIndex<'a>,
351    effects: Vec<(Effect, bool)>,
352}
353
354impl EffectSink for MutSink<'_> {
355    fn on_call(&mut self, call: &Call) {
356        // Detect mutating method calls: `receiver.append(…)`, `.update(…)`, `.add(…)`.
357        let Expression::Attribute(attr) = call.func.as_ref() else {
358            return;
359        };
360        if !is_mutating_method(attr.attr.value) {
361            return;
362        }
363        // The receiver's root name is the write target.
364        let Some(root) = root_name_of_expr(&attr.value) else {
365            return;
366        };
367        let line = name_line_expr(&attr.value, self.span);
368        // Render the full receiver expression (the attribute chain) for evidence,
369        // e.g. `self.items.append(…)` — not the misleading root-only `self.append(…)`.
370        // Fall back to the root name for shapes `render_expr` doesn't model.
371        let receiver = render_expr(&attr.value).unwrap_or_else(|| root.clone());
372        let evidence = format!("{receiver}.{}(…)", attr.attr.value);
373        self.classify_and_push(root, line, evidence);
374    }
375
376    fn on_assert(&mut self, _assert: &Assert) {}
377    fn on_raise(&mut self, _raise: &Raise) {}
378
379    fn on_assign_target(&mut self, target: &AssignTargetExpression, is_aug: bool) {
380        match target {
381            // `self.attr = …` → check is_init for LocalMutation vs ThisMutation.
382            AssignTargetExpression::Attribute(attr) => {
383                if let Expression::Name(n) = attr.value.as_ref()
384                    && n.value == "self"
385                {
386                    let line = name_line(n, self.span);
387                    if self.is_init {
388                        self.push(
389                            EffectKind::LocalMutation,
390                            Tier::Heuristic,
391                            line,
392                            "self.x = … (constructor init, contained)".to_string(),
393                            true,
394                        );
395                    } else {
396                        self.push(
397                            EffectKind::ThisMutation,
398                            Tier::Heuristic,
399                            line,
400                            format!("self.{} = … (instance state)", attr.attr.value),
401                            false,
402                        );
403                    }
404                    return;
405                }
406                // Non-self attribute write: `obj.attr = …` — root is `obj`.
407                if let Some(root) = root_name_of_expr(&attr.value) {
408                    let line = name_line_expr(&attr.value, self.span);
409                    let evidence = format!("{root}.{} = …", attr.attr.value);
410                    self.classify_and_push(root, line, evidence);
411                }
412            }
413            // `x = …` bare name. A plain `=` to a bare name is a *binding*, not a
414            // mutation of pre-existing state (spec: `local.mutation` is `.append()` /
415            // `d[k] = …` / `+=` on a locally-created binding — never the binding
416            // itself) — UNLESS the name is declared `global`/`nonlocal`, in which case
417            // a plain `g = …` rebinds the enclosing/global binding and IS an escaping
418            // mutation. An augmented `x += …` is always a mutation.
419            AssignTargetExpression::Name(n) if is_aug => {
420                let name = n.value.to_owned();
421                let line = name_line(n, self.span);
422                let evidence = format!("{name} += …");
423                self.classify_and_push(name, line, evidence);
424            }
425            // Plain `=` to a bare name declared `global`/`nonlocal` is an escaping
426            // rebind, not a local binding — emit. A true local binding emits nothing.
427            AssignTargetExpression::Name(n)
428                if self.globals.contains(n.value) || self.nonlocals.contains(n.value) =>
429            {
430                let name = n.value.to_owned();
431                let line = name_line(n, self.span);
432                let evidence = format!("{name} = …");
433                self.classify_and_push(name, line, evidence);
434            }
435            AssignTargetExpression::Name(_) => {}
436            // `d[k] = …` subscript write — root is `d`.
437            AssignTargetExpression::Subscript(sub) => {
438                if let Some(root) = root_name_of_expr(&sub.value) {
439                    let line = name_line_expr(&sub.value, self.span);
440                    let evidence = format!("{root}[…] = …");
441                    self.classify_and_push(root, line, evidence);
442                }
443            }
444            // Starred / Tuple / List destructuring — skip (no single root).
445            _ => {}
446        }
447    }
448}
449
450impl MutSink<'_> {
451    /// Classify a write by root name and push an `(Effect, contained)` pair.
452    fn classify_and_push(&mut self, root: String, line: usize, evidence: String) {
453        // Root-`self` method/subscript/aug writes (`self.items.append(…)`,
454        // `self[i] = v`, `self += …`) mutate already-existing instance state — they
455        // are escaping `ThisMutation`, contained=false, *even in `__init__`*. The
456        // contained build-then-expose case is only the *direct* `self.attr = …`
457        // assignment, which `on_assign_target` handles before reaching here.
458        // Preserve the actual write-site evidence passed in.
459        if root == "self" {
460            self.push(
461                EffectKind::ThisMutation,
462                Tier::Heuristic,
463                line,
464                evidence,
465                false,
466            );
467            return;
468        }
469
470        // Global declaration: `global x` → `GlobalMutation`. Include the write
471        // expression in evidence so the report is traceable to the actual write site.
472        if self.globals.contains(&root) {
473            self.push(
474                EffectKind::GlobalMutation,
475                Tier::Exact,
476                line,
477                format!("global {root} ({evidence})"),
478                false,
479            );
480            return;
481        }
482
483        // Nonlocal declaration: `nonlocal x` → `ThisMutation` (escaping outer scope).
484        // Include the write expression for the same traceability reason.
485        if self.nonlocals.contains(&root) {
486            self.push(
487                EffectKind::ThisMutation,
488                Tier::Exact,
489                line,
490                format!("nonlocal {root} ({evidence})"),
491                false,
492            );
493            return;
494        }
495
496        // Parameter mutation: root is a param name (but NOT `self`, handled above).
497        if self.params.contains(&root) {
498            self.push(
499                EffectKind::ParamMutation,
500                Tier::Heuristic,
501                line,
502                evidence,
503                false,
504            );
505            return;
506        }
507
508        // Local binding mutation: root is locally assigned (and not global/nonlocal).
509        if self.locals.contains(&root) {
510            self.push(EffectKind::LocalMutation, Tier::Exact, line, evidence, true);
511            return;
512        }
513
514        // F5: root resolves through the ImportTable → module-level state (the imported
515        // module/name) escaping the function. global.mutation (class 6, Heuristic),
516        // contained=false. A same-named LOCAL already won above.
517        if self.imports.resolve(&root).is_some() {
518            self.push(
519                EffectKind::GlobalMutation,
520                Tier::Heuristic,
521                line,
522                format!("{evidence} (imported `{root}`)"),
523                false,
524            );
525            return;
526        }
527
528        // F2 analog (Python #29): root is a MODULE top-level binding (a
529        // module-level name / def / class) whose contents are mutated
530        // (subscript/attr/method) — module-shared state used for cross-function /
531        // cross-module communication → global.mutation (class 6, Heuristic). A
532        // bare rebind without `global` is a LOCAL (Python semantics) and already
533        // won above; an explicit `global x` rebind already hit the globals arm.
534        // So this catches exactly the content-mutation-of-module-container case.
535        if self.module_bindings.contains(&root) {
536            self.push(
537                EffectKind::GlobalMutation,
538                Tier::Heuristic,
539                line,
540                format!("{evidence} (module-level `{root}`)"),
541                false,
542            );
543            return;
544        }
545
546        // F1: the root resolves to NONE of self/global/nonlocal/param/local/import/
547        // module-binding — a captured outer (enclosing-function) binding we cannot
548        // bound syntactically → hidden.mutation (class 3, hidden:true,
549        // contained:false), subreason "captured-binding". Mirrors the TS `captured`
550        // hidden case (Milestone A left it un-emitted).
551        self.push_hidden(line, evidence, "captured-binding");
552    }
553
554    /// Push a `HiddenMutation` (`hidden:true` + a `subreason`), always escaping
555    /// (`contained:false`). Used for writes whose root is an opaque captured /
556    /// unresolved binding — the fallback after all named-binding cases are handled.
557    fn push_hidden(&mut self, line: usize, evidence: String, subreason: &str) {
558        let kind = EffectKind::HiddenMutation;
559        let tier = Tier::Heuristic;
560        let class = kind.base_class();
561        self.effects.push((
562            Effect {
563                kind,
564                class,
565                discounted_to: None,
566                weight: weight_for_class(class),
567                line,
568                tier,
569                hidden: true,
570                evidence,
571                discount: None,
572                subreason: Some(subreason.to_owned()),
573                confidence: detection_confidence(tier, false, false),
574            },
575            false,
576        ));
577    }
578
579    fn push(
580        &mut self,
581        kind: EffectKind,
582        tier: Tier,
583        line: usize,
584        evidence: String,
585        contained: bool,
586    ) {
587        let class = kind.base_class();
588        self.effects.push((
589            Effect {
590                kind,
591                class,
592                discounted_to: None,
593                weight: weight_for_class(class),
594                line,
595                tier,
596                hidden: false,
597                evidence,
598                discount: None,
599                subreason: None,
600                confidence: detection_confidence(tier, false, false),
601            },
602            contained,
603        ));
604    }
605}
606
607// ─── helpers ─────────────────────────────────────────────────────────────────
608
609/// Return the root `Name.value` of an expression chain (`a.b.c` → `"a"`,
610/// `a[k]` → `"a"`, `Name("x")` → `"x"`).
611fn root_name_of_expr(expr: &Expression) -> Option<String> {
612    match expr {
613        Expression::Name(n) => Some(n.value.to_owned()),
614        Expression::Attribute(a) => root_name_of_expr(&a.value),
615        Expression::Subscript(s) => root_name_of_expr(&s.value),
616        Expression::Call(c) => root_name_of_expr(&c.func),
617        _ => None,
618    }
619}
620
621/// True for method names that mutate their receiver.
622fn is_mutating_method(name: &str) -> bool {
623    matches!(
624        name,
625        "append"
626            | "extend"
627            | "insert"
628            | "remove"
629            | "pop"
630            | "clear"
631            | "sort"
632            | "reverse"
633            | "update"
634            | "add"
635            | "discard"
636            | "setdefault"
637    )
638}
639
640/// 1-based line of the leftmost `Name` in an expression.
641fn name_line_expr(expr: &Expression, span: &SpanIndex) -> usize {
642    leftmost_name(expr).map(|n| name_line(n, span)).unwrap_or(0)
643}
644
645/// The leftmost `Name` in an expression chain.
646fn leftmost_name<'a>(expr: &'a Expression<'a>) -> Option<&'a Name<'a>> {
647    match expr {
648        Expression::Name(n) => Some(n),
649        Expression::Attribute(a) => leftmost_name(&a.value),
650        Expression::Subscript(s) => leftmost_name(&s.value),
651        Expression::Call(c) => leftmost_name(&c.func),
652        _ => None,
653    }
654}
655
656/// 1-based line of a `Name` node (pointer-arithmetic on its borrowed &str).
657fn name_line(name: &Name, span: &SpanIndex) -> usize {
658    span.line_col(anchor_of_subslice(span.src(), name.value)).0
659}
660
661// ─── tests ───────────────────────────────────────────────────────────────────
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use crate::functions;
667    use fxrank_core::effect::EffectKind::{self, *};
668    use std::collections::HashMap;
669
670    /// Parse `tests/fixtures/<name>.py`, collect units, run `detect` per unit, and
671    /// return `symbol → Vec<(EffectKind, bool)>` (kind + contained flag).
672    fn mutation_effects(name: &str) -> HashMap<String, Vec<(EffectKind, bool)>> {
673        let src = std::fs::read_to_string(format!("tests/fixtures/{name}.py")).unwrap();
674        let module = libcst_native::parse_module(&src, None).unwrap();
675        let imports = crate::imports::Imports::build(&module);
676        let module_bindings = crate::imports::module_bindings(&module);
677        let span = crate::source::SpanIndex::new(&src);
678        let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
679        let (units, _) = functions::collect(&module, &src, &span, &anchors);
680        let mut out: HashMap<String, Vec<(EffectKind, bool)>> = HashMap::new();
681        for unit in &units {
682            let pairs = detect(unit, &imports, &module_bindings, &span);
683            out.insert(
684                unit.symbol.clone(),
685                pairs.iter().map(|(e, c)| (e.kind, *c)).collect(),
686            );
687        }
688        out
689    }
690
691    /// Like `mutation_effects` but retains each effect's evidence string.
692    fn mutation_evidence(name: &str) -> HashMap<String, Vec<(EffectKind, bool, String)>> {
693        let src = std::fs::read_to_string(format!("tests/fixtures/{name}.py")).unwrap();
694        let module = libcst_native::parse_module(&src, None).unwrap();
695        let imports = crate::imports::Imports::build(&module);
696        let module_bindings = crate::imports::module_bindings(&module);
697        let span = crate::source::SpanIndex::new(&src);
698        let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
699        let (units, _) = functions::collect(&module, &src, &span, &anchors);
700        let mut out: HashMap<String, Vec<(EffectKind, bool, String)>> = HashMap::new();
701        for unit in &units {
702            let pairs = detect(unit, &imports, &module_bindings, &span);
703            out.insert(
704                unit.symbol.clone(),
705                pairs
706                    .iter()
707                    .map(|(e, c)| (e.kind, *c, e.evidence.clone()))
708                    .collect(),
709            );
710        }
711        out
712    }
713
714    #[test]
715    fn classifies_mutation_by_escape() {
716        let m = mutation_effects("mutation");
717
718        // `global _counter` then `_counter += 1` → GlobalMutation, not contained.
719        assert!(
720            m["uses_global"].contains(&(GlobalMutation, false)),
721            "uses_global should have GlobalMutation(contained=false), got: {:?}",
722            m["uses_global"]
723        );
724
725        // `self.n += 1` in a non-__init__ method → ThisMutation, not contained.
726        assert!(
727            m["bump"].contains(&(ThisMutation, false)),
728            "bump should have ThisMutation(contained=false), got: {:?}",
729            m["bump"]
730        );
731
732        // `lst.append(1)` where lst is a param → ParamMutation, not contained.
733        assert!(
734            m["mutates_param"].contains(&(ParamMutation, false)),
735            "mutates_param should have ParamMutation(contained=false), got: {:?}",
736            m["mutates_param"]
737        );
738
739        // `acc.append(1)` where acc is a local → LocalMutation, contained.
740        assert!(
741            m["builds_local"].contains(&(LocalMutation, true)),
742            "builds_local should have LocalMutation(contained=true), got: {:?}",
743            m["builds_local"]
744        );
745
746        // `self.n = n` inside `__init__` → LocalMutation, contained (constructor init).
747        assert!(
748            m["__init__"].contains(&(LocalMutation, true)),
749            "__init__ should have LocalMutation(contained=true), got: {:?}",
750            m["__init__"]
751        );
752    }
753
754    /// FIX 1: a plain `=` to a name declared `global`/`nonlocal` is an escaping
755    /// rebind and MUST emit — only a TRUE local binding (`y = 1`, no declaration)
756    /// is a no-emit binding. Pre-fix the classifier only ran for bare names when
757    /// `is_aug`, so `global g; g = 1` and `nonlocal x; x = 1` were silently dropped.
758    #[test]
759    fn plain_assign_to_global_nonlocal_names_escapes() {
760        let m = mutation_effects("mutation");
761
762        // `global _counter; _counter = 1` → GlobalMutation, not contained.
763        assert!(
764            m["plain_global_rebind"].contains(&(GlobalMutation, false)),
765            "plain `=` to a global name must emit GlobalMutation(false), got: {:?}",
766            m["plain_global_rebind"]
767        );
768
769        // `nonlocal x; x = 1` (inside the nested def) → ThisMutation, not contained.
770        assert!(
771            m["plain_nonlocal_rebind"].contains(&(ThisMutation, false)),
772            "plain `=` to a nonlocal name must emit ThisMutation(false), got: {:?}",
773            m["plain_nonlocal_rebind"]
774        );
775
776        // `y = 1` with no global/nonlocal declaration → NO mutation effect (binding).
777        assert!(
778            m["plain_local_binding"].is_empty(),
779            "plain `=` to a true local must emit NO mutation, got: {:?}",
780            m["plain_local_binding"]
781        );
782    }
783
784    /// FIX 1: root-`self` method/subscript mutations are escaping instance-state
785    /// (`ThisMutation`, contained=false) even inside `__init__` — they mutate
786    /// already-existing instance state, NOT the contained build-then-expose
787    /// `self.attr = …` case. The direct `self.attr = …`-in-init case stays
788    /// `LocalMutation` contained.
789    #[test]
790    fn self_method_and_subscript_mutations_escape_even_in_init() {
791        let m = mutation_effects("mutation");
792
793        // `self.items = []` in __init__ → LocalMutation contained (unchanged).
794        assert!(
795            m["__init__"].contains(&(LocalMutation, true)),
796            "direct `self.attr = …` in __init__ stays LocalMutation(true), got: {:?}",
797            m["__init__"]
798        );
799
800        // `self.items.append(1)` in __init__ → ThisMutation, contained=false
801        // (escaping — was wrongly contained before the fix).
802        assert!(
803            m["__init__"].contains(&(ThisMutation, false)),
804            "`self.items.append(…)` in __init__ must be ThisMutation(false), got: {:?}",
805            m["__init__"]
806        );
807
808        // `self[i] = v` in a non-init method → ThisMutation, contained=false.
809        assert!(
810            m["store"].contains(&(ThisMutation, false)),
811            "`self[i] = v` must be ThisMutation(false), got: {:?}",
812            m["store"]
813        );
814    }
815
816    /// PREREQ 1: MutSink can emit a HiddenMutation (hidden:true + subreason) —
817    /// the channel Python has never used. `push` stays the honest hidden:false path.
818    #[test]
819    fn push_hidden_emits_hidden_mutation_with_subreason() {
820        let params = std::collections::HashSet::new();
821        let globals = std::collections::HashSet::new();
822        let nonlocals = std::collections::HashSet::new();
823        let locals = std::collections::HashSet::new();
824        let src = "x\n";
825        let module = libcst_native::parse_module(src, None).unwrap();
826        let imports = crate::imports::Imports::build(&module);
827        let span = crate::source::SpanIndex::new(src);
828        let mut sink = MutSink {
829            params: &params,
830            globals: &globals,
831            nonlocals: &nonlocals,
832            locals: &locals,
833            imports: &imports,
834            module_bindings: &HashSet::new(),
835            is_init: false,
836            span: &span,
837            effects: Vec::new(),
838        };
839        sink.push_hidden(1, "outer_acc.append(…)".to_string(), "captured-binding");
840
841        assert_eq!(sink.effects.len(), 1);
842        let (effect, contained) = &sink.effects[0];
843        assert_eq!(effect.kind, EffectKind::HiddenMutation);
844        assert_eq!(effect.class, 3);
845        assert!(effect.hidden, "push_hidden must set hidden:true");
846        assert_eq!(effect.subreason.as_deref(), Some("captured-binding"));
847        assert!(!contained, "hidden writes escape — contained=false");
848    }
849
850    /// PREREQ 2: mutation::detect accepts the ImportTable so F5 + F1 can resolve roots.
851    #[test]
852    fn detect_accepts_imports_param() {
853        let src = "def f(lst):\n    lst.append(1)\n";
854        let module = libcst_native::parse_module(src, None).unwrap();
855        let imports = crate::imports::Imports::build(&module);
856        let module_bindings = crate::imports::module_bindings(&module);
857        let span = crate::source::SpanIndex::new(src);
858        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
859        let (units, _) = functions::collect(&module, src, &span, &anchors);
860        let f = units.iter().find(|u| u.symbol == "f").unwrap();
861        let pairs = detect(f, &imports, &module_bindings, &span);
862        assert!(
863            pairs.iter().any(|(e, _)| e.kind == ParamMutation),
864            "lst.append where lst is a param → ParamMutation, got: {:?}",
865            pairs.iter().map(|(e, _)| e.kind).collect::<Vec<_>>()
866        );
867    }
868
869    /// F5: a write whose root resolves through the ImportTable is module-level state
870    /// escaping the function → global.mutation/6, contained=false. Inserted AFTER the
871    /// `locals` arm (a same-named local shadows the import) and BEFORE the F1 fallback.
872    #[test]
873    fn import_rooted_write_is_global_mutation() {
874        let m = mutation_effects("mutation");
875        assert!(
876            m["mutates_imported_module"].contains(&(GlobalMutation, false)),
877            "config.settings.append(…) where `config` is imported must be GlobalMutation(false), got: {:?}",
878            m["mutates_imported_module"]
879        );
880    }
881
882    /// F1/F3: a write whose root resolves to NONE of {self, globals, nonlocals, params,
883    /// locals, import} is a captured outer/opaque binding. Pre-fix the cascade fell off
884    /// silently; now it emits hidden.mutation/3, hidden=true, contained=false, subreason
885    /// "captured-binding" (the Python analog of the TS `captured` hidden case).
886    #[test]
887    fn captured_binding_subreason_is_set() {
888        let src = std::fs::read_to_string("tests/fixtures/mutation.py").unwrap();
889        let module = libcst_native::parse_module(&src, None).unwrap();
890        let imports = crate::imports::Imports::build(&module);
891        let module_bindings = crate::imports::module_bindings(&module);
892        let span = crate::source::SpanIndex::new(&src);
893        let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
894        let (units, _) = functions::collect(&module, &src, &span, &anchors);
895        let inner = units.iter().find(|u| u.symbol == "inner").unwrap();
896        let pairs = detect(inner, &imports, &module_bindings, &span);
897        let hidden = pairs
898            .iter()
899            .find(|(e, _)| e.kind == HiddenMutation)
900            .map(|(e, _)| e)
901            .expect("inner must emit a HiddenMutation");
902        assert_eq!(hidden.class, 3);
903        assert!(
904            hidden.hidden,
905            "captured-binding HiddenMutation must be hidden:true"
906        );
907        assert_eq!(hidden.subreason.as_deref(), Some("captured-binding"));
908        assert!(
909            pairs.iter().any(|(e, c)| e.kind == HiddenMutation && !*c),
910            "captured-binding write escapes — contained=false"
911        );
912    }
913
914    /// FIX 2: mutating-method evidence renders the full receiver expression
915    /// (the attribute chain) — `self.items.append(…)`, not the misleading
916    /// `self.append(…)` built from just the root name.
917    #[test]
918    fn mutating_method_evidence_uses_full_receiver() {
919        let m = mutation_evidence("mutation");
920        let init = &m["__init__"];
921        let append = init
922            .iter()
923            .find(|(k, _, _)| *k == ThisMutation)
924            .unwrap_or_else(|| panic!("expected a ThisMutation in __init__, got: {init:?}"));
925        assert!(
926            append.2.contains("self.items"),
927            "evidence must name the full receiver `self.items`, got: {:?}",
928            append.2
929        );
930    }
931
932    fn detect_src(src: &str, fn_name: &str) -> Vec<(Effect, bool)> {
933        let module = libcst_native::parse_module(src, None).unwrap();
934        let imports = crate::imports::Imports::build(&module);
935        let module_bindings = crate::imports::module_bindings(&module);
936        let span = crate::source::SpanIndex::new(src);
937        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
938        let (units, _) = functions::collect(&module, src, &span, &anchors);
939        let unit = units
940            .iter()
941            .find(|u| u.symbol == fn_name)
942            .expect("unit not found");
943        detect(unit, &imports, &module_bindings, &span)
944    }
945
946    #[test]
947    fn module_level_content_mutation_is_global() {
948        // A module-level dict mutated by content (no `global` decl) is module-shared
949        // state -> global.mutation (class 6), not the hidden captured-binding fallback.
950        let src = "_cache = {}\ndef f():\n    _cache['k'] = 1\n";
951        let pairs = detect_src(src, "f");
952        assert!(
953            pairs.iter().any(|(e, c)| e.kind == GlobalMutation && !*c),
954            "module-level `_cache['k']=1` (no `global`) must be GlobalMutation(false), got: {:?}",
955            pairs.iter().map(|(e, _)| e.kind).collect::<Vec<_>>()
956        );
957        assert!(
958            !pairs.iter().any(|(e, _)| e.kind == HiddenMutation),
959            "module-level content mutation must not be hidden.mutation"
960        );
961    }
962
963    #[test]
964    fn local_shadowing_module_binding_is_local() {
965        // A bare local rebind shadows the module name (Python creates a local) ->
966        // local.mutation; the shadow wins because locals are checked before the
967        // module-binding arm.
968        let src = "_cache = {}\ndef f():\n    _cache = {}\n    _cache['k'] = 1\n";
969        let pairs = detect_src(src, "f");
970        assert!(
971            pairs.iter().any(|(e, c)| e.kind == LocalMutation && *c),
972            "shadowing local `_cache` must be LocalMutation(true), got: {:?}",
973            pairs.iter().map(|(e, _)| e.kind).collect::<Vec<_>>()
974        );
975        assert!(
976            !pairs.iter().any(|(e, _)| e.kind == GlobalMutation),
977            "shadowing local must not escalate to GlobalMutation"
978        );
979    }
980
981    /// Prescan fix (for/with-as/except-as): a name introduced by a `for` loop target
982    /// in a function body is a Python local for the WHOLE function (PEP 3104). A
983    /// module binding of the same name must be shadowed by the for-target local.
984    #[test]
985    fn for_target_shadow_stays_local() {
986        // `_cache` is a module-level binding; `for _cache in []` shadows it locally.
987        // The write `_cache['k'] = 1` inside the loop must be LocalMutation (contained),
988        // not GlobalMutation. Before the prescan fix this was GlobalMutation because
989        // the prescan did not collect for-loop targets.
990        let src = "_cache = {}\ndef f():\n    for _cache in []:\n        _cache['k'] = 1\n";
991        let pairs = detect_src(src, "f");
992        let writes: Vec<_> = pairs
993            .iter()
994            .filter(|(e, _)| {
995                matches!(
996                    e.kind,
997                    LocalMutation | GlobalMutation | HiddenMutation | ThisMutation
998                )
999            })
1000            .collect();
1001        assert!(
1002            writes.iter().any(|(e, c)| e.kind == LocalMutation && *c),
1003            "expected LocalMutation(contained=true) for for-target shadow, got: {:?}",
1004            writes.iter().map(|(e, c)| (e.kind, *c)).collect::<Vec<_>>()
1005        );
1006        assert!(
1007            !writes.iter().any(|(e, _)| e.kind == GlobalMutation),
1008            "expected NO GlobalMutation for for-target shadow, got: {:?}",
1009            writes.iter().map(|(e, c)| (e.kind, *c)).collect::<Vec<_>>()
1010        );
1011    }
1012
1013    /// Prescan fix: a name introduced by DESTRUCTURING assignment in a function body
1014    /// is a Python local (Python scoping: any binding-assignment makes the name local
1015    /// for the whole function). A module binding of the same name must be shadowed.
1016    #[test]
1017    fn local_destructured_shadow_stays_local() {
1018        // `_cache` is a module-level binding. `f` rebinds it via tuple destructuring
1019        // `(_cache,) = ({},)` — Python considers `_cache` local to `f` for the whole
1020        // function. The subsequent `_cache['k'] = 1` must be LocalMutation (contained),
1021        // not GlobalMutation. Before the prescan fix this was GlobalMutation because
1022        // the prescan only collected bare-Name targets.
1023        let src = "_cache = {}\ndef f():\n    (_cache,) = ({},)\n    _cache['k'] = 1\n";
1024        let pairs = detect_src(src, "f");
1025        assert!(
1026            pairs.iter().any(|(e, c)| e.kind == LocalMutation && *c),
1027            "destructuring-local `_cache` must be LocalMutation(true), got: {:?}",
1028            pairs.iter().map(|(e, _)| e.kind).collect::<Vec<_>>()
1029        );
1030        assert!(
1031            !pairs.iter().any(|(e, _)| e.kind == GlobalMutation),
1032            "destructured local must not escalate to GlobalMutation"
1033        );
1034    }
1035}