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(_) => {} }
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 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 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 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 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 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 for target in &a.targets {
313 crate::imports::collect_target_names(&target.target, locals);
314 }
315 }
316 SmallStatement::AnnAssign(a) => {
319 crate::imports::collect_target_names(&a.target, locals);
320 }
321 SmallStatement::AugAssign(a) => {
324 if let AssignTargetExpression::Name(n) = &a.target {
325 locals.insert(n.value.to_owned());
326 }
327 }
328 _ => {}
329 }
330}
331
332struct MutSink<'a> {
335 params: &'a HashSet<String>,
336 globals: &'a HashSet<String>,
337 nonlocals: &'a HashSet<String>,
338 locals: &'a HashSet<String>,
341 imports: &'a Imports,
344 module_bindings: &'a HashSet<String>,
348 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 let Expression::Attribute(attr) = call.func.as_ref() else {
358 return;
359 };
360 if !is_mutating_method(attr.attr.value) {
361 return;
362 }
363 let Some(root) = root_name_of_expr(&attr.value) else {
365 return;
366 };
367 let line = name_line_expr(&attr.value, self.span);
368 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 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 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 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 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 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 _ => {}
446 }
447 }
448}
449
450impl MutSink<'_> {
451 fn classify_and_push(&mut self, root: String, line: usize, evidence: String) {
453 if root == "self" {
460 self.push(
461 EffectKind::ThisMutation,
462 Tier::Heuristic,
463 line,
464 evidence,
465 false,
466 );
467 return;
468 }
469
470 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 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 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 if self.locals.contains(&root) {
510 self.push(EffectKind::LocalMutation, Tier::Exact, line, evidence, true);
511 return;
512 }
513
514 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 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 self.push_hidden(line, evidence, "captured-binding");
552 }
553
554 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
607fn 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
621fn 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
640fn name_line_expr(expr: &Expression, span: &SpanIndex) -> usize {
642 leftmost_name(expr).map(|n| name_line(n, span)).unwrap_or(0)
643}
644
645fn 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
656fn name_line(name: &Name, span: &SpanIndex) -> usize {
658 span.line_col(anchor_of_subslice(span.src(), name.value)).0
659}
660
661#[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 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 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 assert!(
720 m["uses_global"].contains(&(GlobalMutation, false)),
721 "uses_global should have GlobalMutation(contained=false), got: {:?}",
722 m["uses_global"]
723 );
724
725 assert!(
727 m["bump"].contains(&(ThisMutation, false)),
728 "bump should have ThisMutation(contained=false), got: {:?}",
729 m["bump"]
730 );
731
732 assert!(
734 m["mutates_param"].contains(&(ParamMutation, false)),
735 "mutates_param should have ParamMutation(contained=false), got: {:?}",
736 m["mutates_param"]
737 );
738
739 assert!(
741 m["builds_local"].contains(&(LocalMutation, true)),
742 "builds_local should have LocalMutation(contained=true), got: {:?}",
743 m["builds_local"]
744 );
745
746 assert!(
748 m["__init__"].contains(&(LocalMutation, true)),
749 "__init__ should have LocalMutation(contained=true), got: {:?}",
750 m["__init__"]
751 );
752 }
753
754 #[test]
759 fn plain_assign_to_global_nonlocal_names_escapes() {
760 let m = mutation_effects("mutation");
761
762 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 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 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 #[test]
790 fn self_method_and_subscript_mutations_escape_even_in_init() {
791 let m = mutation_effects("mutation");
792
793 assert!(
795 m["__init__"].contains(&(LocalMutation, true)),
796 "direct `self.attr = …` in __init__ stays LocalMutation(true), got: {:?}",
797 m["__init__"]
798 );
799
800 assert!(
803 m["__init__"].contains(&(ThisMutation, false)),
804 "`self.items.append(…)` in __init__ must be ThisMutation(false), got: {:?}",
805 m["__init__"]
806 );
807
808 assert!(
810 m["store"].contains(&(ThisMutation, false)),
811 "`self[i] = v` must be ThisMutation(false), got: {:?}",
812 m["store"]
813 );
814 }
815
816 #[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: ¶ms,
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 #[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 #[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 #[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 #[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 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 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 #[test]
985 fn for_target_shadow_stays_local() {
986 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 #[test]
1017 fn local_destructured_shadow_stays_local() {
1018 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}