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