1use 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
55pub fn detect(
63 unit: &FnUnit,
64 imports: &Imports,
65 module_bindings: &HashSet<String>,
66 span: &SpanIndex,
67) -> Vec<(Effect, bool)> {
68 let params = collect_param_names(unit.params);
70
71 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 let is_init = unit.symbol == "__init__";
79 let mut sink = MutSink {
80 params: ¶ms,
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
94fn collect_param_names(params: &Parameters) -> HashSet<String> {
103 let mut out = HashSet::new();
104 let all = params
105 .posonly_params
106 .iter()
107 .chain(¶ms.params)
108 .chain(¶ms.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)) = ¶ms.star_arg {
113 out.insert(p.name.value.to_owned());
114 }
115 if let Some(p) = ¶ms.star_kwarg {
116 out.insert(p.name.value.to_owned());
117 }
118 out
119}
120
121fn 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(_) => {} 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 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 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 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 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 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 for target in &a.targets {
322 crate::imports::collect_target_names(&target.target, locals);
323 }
324 }
325 SmallStatement::AnnAssign(a) => {
328 crate::imports::collect_target_names(&a.target, locals);
329 }
330 SmallStatement::AugAssign(a) => {
333 if let AssignTargetExpression::Name(n) = &a.target {
334 locals.insert(n.value.to_owned());
335 }
336 }
337 _ => {}
338 }
339}
340
341struct MutSink<'a> {
344 params: &'a HashSet<String>,
345 globals: &'a HashSet<String>,
346 nonlocals: &'a HashSet<String>,
347 locals: &'a HashSet<String>,
350 imports: &'a Imports,
353 module_bindings: &'a HashSet<String>,
357 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 let Expression::Attribute(attr) = call.func.as_ref() else {
367 return;
368 };
369 if !is_mutating_method(attr.attr.value) {
370 return;
371 }
372 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 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 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 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 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 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 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 _ => {}
457 }
458 }
459}
460
461impl MutSink<'_> {
462 fn classify_and_push(&mut self, root: String, line: usize, col: usize, evidence: String) {
464 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 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 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 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 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 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 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 self.push_hidden(line, col, evidence, "captured-binding");
576 }
577
578 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
636fn 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
650fn 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
669fn 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
676fn 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
687fn name_line_col(name: &Name, span: &SpanIndex) -> (usize, usize) {
689 span.line_col(anchor_of_subslice(span.src(), name.value))
690}
691
692#[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 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 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 assert!(
751 m["uses_global"].contains(&(GlobalMutation, false)),
752 "uses_global should have GlobalMutation(contained=false), got: {:?}",
753 m["uses_global"]
754 );
755
756 assert!(
758 m["bump"].contains(&(ThisMutation, false)),
759 "bump should have ThisMutation(contained=false), got: {:?}",
760 m["bump"]
761 );
762
763 assert!(
765 m["mutates_param"].contains(&(ParamMutation, false)),
766 "mutates_param should have ParamMutation(contained=false), got: {:?}",
767 m["mutates_param"]
768 );
769
770 assert!(
772 m["builds_local"].contains(&(LocalMutation, true)),
773 "builds_local should have LocalMutation(contained=true), got: {:?}",
774 m["builds_local"]
775 );
776
777 assert!(
779 m["__init__"].contains(&(LocalMutation, true)),
780 "__init__ should have LocalMutation(contained=true), got: {:?}",
781 m["__init__"]
782 );
783 }
784
785 #[test]
790 fn plain_assign_to_global_nonlocal_names_escapes() {
791 let m = mutation_effects("mutation");
792
793 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 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 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 #[test]
821 fn self_method_and_subscript_mutations_escape_even_in_init() {
822 let m = mutation_effects("mutation");
823
824 assert!(
826 m["__init__"].contains(&(LocalMutation, true)),
827 "direct `self.attr = …` in __init__ stays LocalMutation(true), got: {:?}",
828 m["__init__"]
829 );
830
831 assert!(
834 m["__init__"].contains(&(ThisMutation, false)),
835 "`self.items.append(…)` in __init__ must be ThisMutation(false), got: {:?}",
836 m["__init__"]
837 );
838
839 assert!(
841 m["store"].contains(&(ThisMutation, false)),
842 "`self[i] = v` must be ThisMutation(false), got: {:?}",
843 m["store"]
844 );
845 }
846
847 #[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: ¶ms,
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 #[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 #[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 #[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 #[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 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 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 #[test]
1016 fn for_target_shadow_stays_local() {
1017 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 #[test]
1048 fn local_destructured_shadow_stays_local() {
1049 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}