1use crate::bindings::PythonLexicalScopeInventory;
4use brokk_bifrost_core::analyzer::ProjectFile;
5use brokk_bifrost_core::analyzer::Range;
6use brokk_bifrost_core::analyzer::python_facts::PythonSourceFacts;
7use brokk_bifrost_core::analyzer::query_token::QueryToken;
8use brokk_bifrost_core::analyzer::source_facts::SourceFactRows;
9use std::collections::{HashMap, HashSet};
10use std::mem::size_of;
11use std::sync::Arc;
12use tree_sitter::Node;
13
14#[derive(Debug, Clone, Default)]
22pub struct PythonModuleGlobalSourceFacts {
23 bindings: HashMap<Box<str>, PythonModuleGlobalBindingFact>,
24 global_declarations: HashSet<Box<str>>,
25 wildcard_import_offsets: Vec<usize>,
26 has_dynamic_write: bool,
27 has_exec: bool,
28}
29
30#[derive(Debug, Clone, Default)]
31struct PythonModuleGlobalBindingFact {
32 statements: u32,
33 initializer: Option<Range>,
34 initializer_statement_end: usize,
35}
36
37impl PythonModuleGlobalSourceFacts {
38 pub fn module_binds_name(&self, name: &str) -> bool {
40 self.bindings.contains_key(name)
41 }
42
43 pub fn initializer_range(&self, name: &str) -> Option<Range> {
45 if self.has_dynamic_write || self.has_exec || self.global_declarations.contains(name) {
46 return None;
47 }
48 let binding = self.bindings.get(name)?;
49 if binding.statements != 1 {
50 return None;
51 }
52 if self
53 .wildcard_import_offsets
54 .iter()
55 .any(|offset| *offset > binding.initializer_statement_end)
56 {
57 return None;
58 }
59 binding.initializer
60 }
61
62 pub fn estimated_retained_bytes(&self) -> usize {
64 size_of::<Self>()
65 + self
66 .bindings
67 .keys()
68 .map(|name| name.len() + size_of::<PythonModuleGlobalBindingFact>())
69 .sum::<usize>()
70 + self
71 .global_declarations
72 .iter()
73 .map(|name| name.len() + size_of::<Box<str>>())
74 .sum::<usize>()
75 + self.wildcard_import_offsets.len() * size_of::<usize>()
76 }
77}
78
79#[derive(Debug)]
80enum PythonModuleMutationHazard {
81 Global(Vec<Box<str>>),
82 Exec,
83 ReflectiveWrite,
84}
85
86fn python_module_mutation_hazard_bounded(
89 node: Node<'_>,
90 source: &str,
91 mut scope_step: impl FnMut() -> bool,
92) -> Option<Option<PythonModuleMutationHazard>> {
93 if !scope_step() {
94 return None;
95 }
96 if node.kind() == "global_statement" {
97 let mut names = Vec::new();
98 let mut cursor = node.walk();
99 for child in node.named_children(&mut cursor) {
100 if !scope_step() {
101 return None;
102 }
103 if child.kind() == "identifier" {
104 names.push(child.utf8_text(source.as_bytes()).ok()?.into());
105 }
106 }
107 return Some(Some(PythonModuleMutationHazard::Global(names)));
108 }
109 if node.kind() != "call" {
110 return Some(None);
111 }
112 if node
113 .child_by_field_name("function")
114 .is_some_and(|function| {
115 function.kind() == "identifier"
116 && function.utf8_text(source.as_bytes()).ok() == Some("exec")
117 })
118 {
119 return Some(Some(PythonModuleMutationHazard::Exec));
120 }
121 Some(
122 module_globals_write_call_bounded(node, source, &mut scope_step)?
123 .then_some(PythonModuleMutationHazard::ReflectiveWrite),
124 )
125}
126
127pub(crate) fn python_module_dynamic_write_candidate(node: Node<'_>, source: &str) -> bool {
131 matches!(
132 python_module_mutation_hazard_bounded(node, source, || true),
133 Some(Some(
134 PythonModuleMutationHazard::Exec | PythonModuleMutationHazard::ReflectiveWrite
135 ))
136 )
137}
138
139#[derive(Debug)]
145pub struct PythonModuleMutationFacts {
146 global_binding_events: HashSet<Box<str>>,
147 dynamic_hazards: Vec<Range>,
148}
149
150impl PythonModuleMutationFacts {
151 pub fn collect_bounded(
154 root: Node<'_>,
155 source: &str,
156 mut scope_step: impl FnMut() -> bool,
157 ) -> Option<Self> {
158 use brokk_bifrost_core::analyzer::tree_walk::{
159 WalkControl, node_range, try_walk_named_tree_preorder,
160 };
161 if !scope_step() || root.kind() != "module" || root.has_error() {
162 return None;
163 }
164 let mut facts = Self {
165 global_binding_events: HashSet::new(),
166 dynamic_hazards: Vec::new(),
167 };
168 let mut checked_global_scopes = HashSet::new();
169 try_walk_named_tree_preorder(root, true, |node| {
170 if !scope_step() {
171 return Err(());
172 }
173 match python_module_mutation_hazard_bounded(node, source, &mut scope_step).ok_or(())? {
174 Some(PythonModuleMutationHazard::Global(_)) => {
175 let mut current = node.parent();
178 while let Some(scope) = current {
179 if !scope_step() {
180 return Err(());
181 }
182 if scope.kind() == "module" {
183 break;
186 }
187 if matches!(
188 scope.kind(),
189 "function_definition" | "lambda" | "class_definition"
190 ) {
191 if !checked_global_scopes.contains(&scope.id()) {
192 let bindings = PythonLexicalScopeInventory::collect_bounded(
195 scope,
196 source,
197 std::iter::empty::<String>(),
198 &mut scope_step,
199 )
200 .ok_or(())?
201 .into_bindings();
202 for name in bindings.global_binding_events() {
205 if !scope_step() {
206 return Err(());
207 }
208 facts.global_binding_events.insert(name.into());
209 }
210 checked_global_scopes.insert(scope.id());
211 }
212 break;
213 }
214 current = scope.parent();
215 }
216 }
217 Some(
218 PythonModuleMutationHazard::Exec | PythonModuleMutationHazard::ReflectiveWrite,
219 ) => {
220 facts.dynamic_hazards.push(node_range(node));
221 }
222 None => {}
223 }
224 Ok(WalkControl::Continue)
225 })
226 .ok()?;
227 scope_step().then_some(facts)
228 }
229
230 pub fn binding_unmutated_at_bounded(
236 &self,
237 root: Node<'_>,
238 name: &str,
239 reference: Node<'_>,
240 mut scope_step: impl FnMut() -> bool,
241 ) -> Option<bool> {
242 use brokk_bifrost_core::analyzer::tree_walk::node_range;
243 use brokk_bifrost_core::analyzer::usages::reference_site::smallest_named_node_covering_with_scope;
244 if !scope_step() {
245 return None;
246 }
247 if root.kind() != "module" || root.has_error() {
248 return scope_step().then_some(false);
249 }
250 let deferred = crate::syntax::python_reference_is_deferred_function_body_bounded(
251 reference,
252 &mut scope_step,
253 )?;
254 let read_range = node_range(reference);
255 let mut unmutated = !self.global_binding_events.contains(name);
256 if unmutated {
257 for hazard in &self.dynamic_hazards {
258 if !scope_step() {
259 return None;
260 }
261 let node = smallest_named_node_covering_with_scope(
262 root,
263 hazard.start_byte,
264 hazard.end_byte,
265 &mut scope_step,
266 )?;
267 if node.start_byte() != hazard.start_byte || node.end_byte() != hazard.end_byte {
268 return None;
269 }
270 if deferred
273 || crate::runtime_values::python_hazard_can_precede_bounded(
274 *hazard,
275 read_range,
276 node,
277 &mut scope_step,
278 )?
279 {
280 unmutated = false;
281 break;
282 }
283 }
284 }
285 scope_step().then_some(unmutated)
288 }
289
290 pub fn export_unmutated_bounded(
293 &self,
294 name: &str,
295 mut scope_step: impl FnMut() -> bool,
296 ) -> Option<bool> {
297 if !scope_step() {
298 return None;
299 }
300 let unmutated =
301 !self.global_binding_events.contains(name) && self.dynamic_hazards.is_empty();
302 scope_step().then_some(unmutated)
303 }
304}
305
306pub fn python_module_binding_unmutated_at_bounded(
308 root: Node<'_>,
309 source: &str,
310 name: &str,
311 reference: Node<'_>,
312 mut scope_step: impl FnMut() -> bool,
313) -> Option<bool> {
314 PythonModuleMutationFacts::collect_bounded(root, source, &mut scope_step)?
315 .binding_unmutated_at_bounded(root, name, reference, scope_step)
316}
317
318pub fn python_module_export_unmutated_bounded(
320 root: Node<'_>,
321 source: &str,
322 name: &str,
323 mut scope_step: impl FnMut() -> bool,
324) -> Option<bool> {
325 PythonModuleMutationFacts::collect_bounded(root, source, &mut scope_step)?
326 .export_unmutated_bounded(name, scope_step)
327}
328
329pub fn python_module_global_source_facts(
336 root: Node<'_>,
337 source: &str,
338 max_nodes: usize,
339 mut keep_going: impl FnMut() -> bool,
340) -> Option<PythonModuleGlobalSourceFacts> {
341 if root.kind() != "module" || root.has_error() {
342 return None;
343 }
344 let mut remaining = max_nodes;
345 let mut facts = PythonModuleGlobalSourceFacts::default();
346 let mut stack = vec![(root, true)];
347 while let Some((node, initializer_path)) = stack.pop() {
348 if remaining == 0 || !keep_going() {
349 return None;
350 }
351 remaining -= 1;
352
353 if node.kind() == "wildcard_import" {
354 facts.wildcard_import_offsets.push(node.start_byte());
355 }
356
357 let mut bound_names = HashSet::<String>::new();
358 if matches!(
359 node.kind(),
360 "function_definition"
361 | "class_definition"
362 | "import_statement"
363 | "import_from_statement"
364 | "assignment"
365 | "augmented_assignment"
366 | "annotated_assignment"
367 | "named_expression"
368 | "for_statement"
369 | "delete_statement"
370 | "as_pattern"
371 | "except_clause"
372 | "case_clause"
373 ) {
374 let mut steps = || {
375 if remaining == 0 || !keep_going() {
376 false
377 } else {
378 remaining -= 1;
379 true
380 }
381 };
382 for binding in
383 crate::bindings::python_direct_scope_bindings_bounded(node, source, &mut steps)?
384 {
385 if binding.declaration.kind() == "identifier" {
386 bound_names.insert(
387 binding
388 .declaration
389 .utf8_text(source.as_bytes())
390 .ok()?
391 .to_owned(),
392 );
393 }
394 }
395 }
396 if node.kind() == "annotated_assignment"
397 && let Some(target) = node.child_by_field_name("left")
398 {
399 module_target_names(
400 target,
401 source,
402 &mut bound_names,
403 &mut remaining,
404 &mut keep_going,
405 )?;
406 }
407 if node.kind() == "with_statement" {
408 let mut cursor = node.walk();
409 for child in node.named_children(&mut cursor) {
410 if child.kind() == "with_clause" {
411 let mut with_stack = vec![child];
412 while let Some(item) = with_stack.pop() {
413 if remaining == 0 || !keep_going() {
414 return None;
415 }
416 remaining -= 1;
417 if item.kind() == "as_pattern"
418 && let Some(alias) = item.child_by_field_name("alias")
419 {
420 module_target_names(
421 alias,
422 source,
423 &mut bound_names,
424 &mut remaining,
425 &mut keep_going,
426 )?;
427 }
428 with_stack.extend(item.named_children(&mut item.walk()));
429 }
430 }
431 }
432 }
433
434 if !bound_names.is_empty() {
435 let assignment_initializer = (node.kind() == "assignment"
436 && initializer_path
437 && node.parent().is_some_and(|parent| {
438 matches!(parent.kind(), "module" | "block" | "expression_statement")
439 })
440 && node.child_by_field_name("type").is_none()
441 && node.child_by_field_name("annotation").is_none()
442 && node
443 .child_by_field_name("left")
444 .is_some_and(|left| left.kind() == "identifier")
445 && bound_names.len() == 1)
446 .then(|| node.child_by_field_name("right"))
447 .flatten();
448 for name in bound_names {
449 let binding = facts.bindings.entry(name.into_boxed_str()).or_default();
450 binding.statements = binding.statements.saturating_add(1);
451 if let Some(value) = assignment_initializer {
452 binding.initializer = Some(node_range(value, source));
453 binding.initializer_statement_end = node.end_byte();
454 } else {
455 binding.initializer = None;
456 binding.initializer_statement_end = node.end_byte();
457 }
458 }
459 }
460
461 let allowed_block = matches!(
462 node.kind(),
463 "if_statement"
464 | "try_statement"
465 | "with_statement"
466 | "for_statement"
467 | "block"
468 | "except_clause"
469 | "else_clause"
470 | "finally_clause"
471 | "expression_statement"
472 );
473 let scope_body = matches!(
474 node.kind(),
475 "function_definition" | "class_definition" | "lambda"
476 )
477 .then(|| node.child_by_field_name("body").map(|body| body.id()))
478 .flatten();
479 let name_node = matches!(node.kind(), "function_definition" | "class_definition")
480 .then(|| node.child_by_field_name("name").map(|name| name.id()))
481 .flatten();
482 let mut cursor = node.walk();
483 let children = node.named_children(&mut cursor).collect::<Vec<_>>();
484 for child in children.into_iter().rev() {
485 if Some(child.id()) == scope_body || Some(child.id()) == name_node {
486 continue;
487 }
488 let child_initializer_path =
489 if matches!(node.kind(), "module" | "block" | "expression_statement") {
490 initializer_path
491 } else if matches!(
492 node.kind(),
493 "except_clause" | "else_clause" | "finally_clause"
494 ) {
495 initializer_path && child.kind() == "block"
496 } else if allowed_block {
497 if node.kind() == "try_statement" {
498 initializer_path
499 && matches!(
500 child.kind(),
501 "block" | "except_clause" | "else_clause" | "finally_clause"
502 )
503 } else if matches!(node.kind(), "if_statement" | "for_statement")
504 && child.kind() == "else_clause"
505 {
506 initializer_path
507 } else {
508 initializer_path
509 && child.kind() == "block"
510 && (node
511 .child_by_field_name("body")
512 .is_some_and(|body| body.id() == child.id())
513 || node
514 .child_by_field_name("consequence")
515 .is_some_and(|body| body.id() == child.id())
516 || node
517 .child_by_field_name("alternative")
518 .is_some_and(|body| body.id() == child.id()))
519 }
520 } else {
521 false
522 };
523 if matches!(
524 node.kind(),
525 "list_comprehension"
526 | "set_comprehension"
527 | "dictionary_comprehension"
528 | "generator_expression"
529 ) && child.kind() == "for_in_clause"
530 {
531 let target = child.child_by_field_name("left");
532 let mut clause_cursor = child.walk();
533 for clause_child in child.named_children(&mut clause_cursor) {
534 if target.is_some_and(|target| target.id() == clause_child.id()) {
535 continue;
536 }
537 stack.push((clause_child, false));
538 }
539 continue;
540 }
541 stack.push((child, child_initializer_path));
542 }
543 }
544
545 remaining = max_nodes;
546 let mut stack = vec![root];
547 while let Some(node) = stack.pop() {
548 if remaining == 0 || !keep_going() {
549 return None;
550 }
551 remaining -= 1;
552 match python_module_mutation_hazard_bounded(node, source, || true)? {
555 Some(PythonModuleMutationHazard::Global(names)) => {
556 facts.global_declarations.extend(names);
557 }
558 Some(PythonModuleMutationHazard::Exec) => facts.has_exec = true,
559 Some(PythonModuleMutationHazard::ReflectiveWrite) => facts.has_dynamic_write = true,
560 None => {}
561 }
562 let mut cursor = node.walk();
563 stack.extend(node.named_children(&mut cursor));
564 }
565 facts.wildcard_import_offsets.sort_unstable();
566 Some(facts)
567}
568
569fn node_range(node: Node<'_>, source: &str) -> Range {
570 let start_byte = node.start_byte();
571 let end_byte = node.end_byte();
572 let start_line = source.as_bytes()[..start_byte]
573 .iter()
574 .filter(|byte| **byte == b'\n')
575 .count()
576 + 1;
577 let end_line = source.as_bytes()[..end_byte]
578 .iter()
579 .filter(|byte| **byte == b'\n')
580 .count()
581 + 1;
582 Range {
583 start_byte,
584 end_byte,
585 start_line,
586 end_line,
587 }
588}
589
590fn module_target_names(
591 root: Node<'_>,
592 source: &str,
593 names: &mut HashSet<String>,
594 remaining: &mut usize,
595 keep_going: &mut impl FnMut() -> bool,
596) -> Option<()> {
597 let mut stack = vec![root];
598 while let Some(node) = stack.pop() {
599 if *remaining == 0 || !keep_going() {
600 return None;
601 }
602 *remaining -= 1;
603 match node.kind() {
604 "identifier" => {
605 names.insert(node.utf8_text(source.as_bytes()).ok()?.to_owned());
606 }
607 "attribute" | "subscript" | "call" | "string" | "integer" => {}
608 _ => {
609 let mut cursor = node.walk();
610 stack.extend(node.named_children(&mut cursor));
611 }
612 }
613 }
614 Some(())
615}
616
617fn module_globals_write_call_bounded(
618 call: Node<'_>,
619 source: &str,
620 mut scope_step: impl FnMut() -> bool,
621) -> Option<bool> {
622 if !scope_step() {
623 return None;
624 }
625 let function = call.child_by_field_name("function");
626 let Some(function) = function else {
627 return Some(false);
628 };
629 if function.kind() == "attribute" {
630 let method = function.child_by_field_name("attribute");
631 let object = function.child_by_field_name("object");
632 if method.is_some_and(|method| {
633 method
634 .utf8_text(source.as_bytes())
635 .ok()
636 .is_some_and(|name| matches!(name, "update" | "setdefault" | "pop" | "clear"))
637 }) && object.is_some_and(|object| is_module_mapping_call(object, source))
638 {
639 return Some(true);
640 }
641 }
642 if !is_module_mapping_call(call, source) {
643 return Some(false);
644 }
645 let mut current = call;
646 while let Some(parent) = current.parent() {
647 if !scope_step() {
648 return None;
649 }
650 match parent.kind() {
651 "assignment" | "augmented_assignment" | "annotated_assignment" => {
652 return Some(parent.child_by_field_name("left").is_some_and(|left| {
653 left.start_byte() <= call.start_byte() && call.end_byte() <= left.end_byte()
654 }));
655 }
656 "delete_statement" => return Some(true),
657 "subscript" | "attribute" => current = parent,
658 _ => return Some(false),
659 }
660 }
661 Some(false)
662}
663
664fn is_module_mapping_call(node: Node<'_>, source: &str) -> bool {
665 if node.kind() != "call"
666 || !node
667 .child_by_field_name("arguments")
668 .is_some_and(|arguments| {
669 let mut cursor = arguments.walk();
670 arguments.named_children(&mut cursor).next().is_none()
671 })
672 {
673 return false;
674 }
675 node.child_by_field_name("function")
676 .is_some_and(|function| {
677 function.kind() == "identifier"
678 && function
679 .utf8_text(source.as_bytes())
680 .ok()
681 .is_some_and(|name| matches!(name, "globals" | "vars"))
682 })
683}
684
685#[derive(Debug, Clone)]
686pub struct PythonFileSourceFacts {
687 pub occurrences: SourceFactRows,
688 pub facts: PythonSourceFacts,
689}
690
691impl PythonFileSourceFacts {
692 pub fn callable_return_for_range(
694 &self,
695 start: usize,
696 end: usize,
697 keep_going: &dyn Fn() -> bool,
698 ) -> Option<&brokk_bifrost_core::analyzer::python_facts::PythonCallableReturnFact> {
699 for fact in &self.facts.callable_returns {
700 if !keep_going() {
701 return None;
702 }
703 let declaration = self.occurrences.declaration(fact.declaration);
704 let range = self.occurrences.occurrence(declaration.occurrence).range;
705 if range.start_byte == start && range.end_byte == end {
706 return Some(fact);
707 }
708 }
709 None
710 }
711
712 pub fn estimated_retained_bytes(&self) -> usize {
713 self.occurrences
714 .estimated_bytes()
715 .saturating_add(self.facts.estimated_retained_bytes())
716 }
717}
718
719pub trait PythonSourceFactProvider: Send + Sync {
720 fn python_source_facts(
723 &self,
724 token: QueryToken<'_>,
725 file: &ProjectFile,
726 keep_going: &dyn Fn() -> bool,
727 ) -> Option<Arc<PythonFileSourceFacts>>;
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use brokk_bifrost_test_macros::hardwon;
734
735 fn module_facts(source: &str) -> PythonModuleGlobalSourceFacts {
736 let mut parser = tree_sitter::Parser::new();
737 parser
738 .set_language(&tree_sitter_python::LANGUAGE.into())
739 .expect("Python grammar");
740 let tree = parser.parse(source, None).expect("parse fixture");
741 python_module_global_source_facts(tree.root_node(), source, 100_000, || true)
742 .expect("complete Python module facts")
743 }
744
745 fn initializer<'a>(
746 facts: &PythonModuleGlobalSourceFacts,
747 source: &'a str,
748 name: &str,
749 ) -> Option<&'a str> {
750 let range = facts.initializer_range(name)?;
751 source.get(range.start_byte..range.end_byte)
752 }
753
754 #[test]
755 #[hardwon(
756 "#4622+#5745: module mutation and stopped scans cannot prove unchanged initializer classes"
757 )]
758 fn module_global_source_fact_requires_a_single_safe_assignment() {
759 let source = "class Target: pass\n\
760safe = Target()\n\
761items = [safe for safe in ()]\n\
762if True:\n nested_safe = Target()\n";
763 let facts = module_facts(source);
764 assert_eq!(
765 Some("Target()"),
766 initializer(&facts, source, "safe"),
767 "{facts:#?}"
768 );
769 assert_eq!(Some("Target()"), initializer(&facts, source, "nested_safe"));
770
771 let source = "class Target: pass\n\
772global_write = Target()\n\
773def change():\n global global_write\n global_write = None\n";
774 assert_eq!(
775 None,
776 initializer(&module_facts(source), source, "global_write")
777 );
778
779 let source = "class Target: pass\n\
780twice = Target()\n\
781twice = Target()\n";
782 assert_eq!(None, initializer(&module_facts(source), source, "twice"));
783
784 let source = "class Target: pass\n\
785dynamic = Target()\n\
786globals()[\"dynamic\"] = None\n";
787 assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
788
789 let source = "class Target: pass\n\
790dynamic = Target()\n\
791vars().update(dynamic=None)\n";
792 assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
793
794 let source = "class Target: pass\n\
795dynamic = Target()\n\
796exec(\"dynamic = None\")\n";
797 assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
798
799 let deferred_exec =
800 "class Target: pass\nvalue = Target()\ndef later():\n exec('value = None')\n";
801 assert_eq!(
802 None,
803 initializer(&module_facts(deferred_exec), deferred_exec, "value")
804 );
805
806 let bounded_source =
809 "class Base: pass\nclass D(Base): pass\ndef later():\n exec('Base = object')\n";
810 let mut parser = tree_sitter::Parser::new();
811 parser
812 .set_language(&tree_sitter_python::LANGUAGE.into())
813 .expect("Python grammar");
814 let tree = parser.parse(bounded_source, None).expect("parse fixture");
815 let root = tree.root_node();
816 let reference = root
817 .named_child(1)
818 .unwrap()
819 .child_by_field_name("superclasses")
820 .unwrap()
821 .named_child(0)
822 .unwrap();
823 let mut visits = 0_usize;
824 assert_eq!(
825 Some(true),
826 python_module_binding_unmutated_at_bounded(
827 root,
828 bounded_source,
829 "Base",
830 reference,
831 || {
832 visits += 1;
833 true
834 },
835 )
836 );
837 for allowance in 0..visits {
838 let mut remaining = allowance;
839 assert_eq!(
840 None,
841 python_module_binding_unmutated_at_bounded(
842 root,
843 bounded_source,
844 "Base",
845 reference,
846 || {
847 let allowed = remaining > 0;
848 remaining = remaining.saturating_sub(1);
849 allowed
850 },
851 ),
852 "stopping after {allowance} of {visits} visits cannot admit Base"
853 );
854 }
855 assert_eq!(
856 Some(false),
857 python_module_export_unmutated_bounded(root, bounded_source, "Base", || true)
858 );
859
860 let multi_source = "class A: pass\nclass B: pass\nclass C: pass\nclass ReadOnly: pass\nclass Sibling: pass\nclass D(A, B, C, ReadOnly, Sibling): pass\ndef writer():\n global A\n global B, C\n global ReadOnly\n A = None\n class B(B): pass\n del C\n return ReadOnly\ndef sibling():\n global Sibling\n from elsewhere import Sibling\n";
863 let tree = parser
864 .parse(multi_source, None)
865 .expect("parse all-name events");
866 let root = tree.root_node();
867 assert!(!root.has_error());
868 let arguments = root
869 .named_child(5)
870 .unwrap()
871 .child_by_field_name("superclasses")
872 .unwrap();
873 let mut collection_visits = 0_usize;
874 let mutation = PythonModuleMutationFacts::collect_bounded(root, multi_source, || {
875 collection_visits += 1;
876 true
877 })
878 .expect("complete mutation inventory");
879 for allowance in 0..collection_visits {
880 let mut remaining = allowance;
881 assert!(
882 PythonModuleMutationFacts::collect_bounded(root, multi_source, || {
883 let allowed = remaining > 0;
884 remaining = remaining.saturating_sub(1);
885 allowed
886 })
887 .is_none(),
888 "partial collection at {allowance} must not publish facts"
889 );
890 }
891 for (index, name, expected) in [
892 (0, "A", false),
893 (1, "B", false),
894 (2, "C", false),
895 (3, "ReadOnly", true),
896 (4, "Sibling", false),
897 ] {
898 let reference = arguments.named_child(index).unwrap();
899 let mut read_visits = 0_usize;
900 assert_eq!(
901 Some(expected),
902 mutation.binding_unmutated_at_bounded(root, name, reference, || {
903 read_visits += 1;
904 true
905 }),
906 "all-name inventory: {name}"
907 );
908 assert!(
909 read_visits < collection_visits,
910 "a reused name read must not recollect the module"
911 );
912 assert_eq!(
913 None,
914 mutation.binding_unmutated_at_bounded(root, name, reference, || false),
915 "completed facts cannot override a stopped reader"
916 );
917 for allowance in 0..read_visits {
918 let mut remaining = allowance;
919 assert_eq!(
920 None,
921 mutation.binding_unmutated_at_bounded(root, name, reference, || {
922 let allowed = remaining > 0;
923 remaining = remaining.saturating_sub(1);
924 allowed
925 }),
926 "{name}: stopped read including final check at {allowance}"
927 );
928 }
929 let mut export_visits = 0_usize;
930 assert_eq!(
931 Some(expected),
932 mutation.export_unmutated_bounded(name, || {
933 export_visits += 1;
934 true
935 })
936 );
937 for allowance in 0..export_visits {
938 let mut remaining = allowance;
939 assert_eq!(
940 None,
941 mutation.export_unmutated_bounded(name, || {
942 let allowed = remaining > 0;
943 remaining = remaining.saturating_sub(1);
944 allowed
945 }),
946 "{name}: stopped export including final check at {allowance}"
947 );
948 }
949 }
950
951 for (label, source, container_index, expected, export_expected) in [
952 (
953 "empty inventory still checks the final stop",
954 "class Base: pass\nclass D(Base): pass\n",
955 1,
956 true,
957 true,
958 ),
959 (
960 "later exec is irrelevant to immediate read",
961 bounded_source,
962 1,
963 true,
964 false,
965 ),
966 (
967 "deferred read includes later exec",
968 "class Base: pass\ndef load():\n class D(Base): pass\ndef later():\n exec('Base = object')\n",
969 1,
970 false,
971 false,
972 ),
973 (
974 "later loop hazard can precede next read",
975 "class Base: pass\nfor item in (1, 2):\n class D(Base): pass\n exec('Base = object')\n",
976 1,
977 false,
978 false,
979 ),
980 (
981 "earlier callable remains a possible writer",
982 "class Base: pass\ndef earlier():\n exec('Base = object')\nclass D(Base): pass\n",
983 2,
984 false,
985 false,
986 ),
987 (
988 "later reflective call is irrelevant to immediate read",
989 "class Base: pass\nclass D(Base): pass\nglobals().update(Base=object)\n",
990 1,
991 true,
992 false,
993 ),
994 ] {
995 let tree = parser.parse(source, None).expect("parse hazard ordering");
996 let root = tree.root_node();
997 assert!(!root.has_error(), "{label}");
998 let container = root.named_child(container_index).unwrap();
999 let class = if container.kind() == "class_definition" {
1000 container
1001 } else {
1002 container
1003 .child_by_field_name("body")
1004 .unwrap()
1005 .named_child(0)
1006 .unwrap()
1007 };
1008 assert_eq!(class.kind(), "class_definition");
1009 let reference = class
1010 .child_by_field_name("superclasses")
1011 .unwrap()
1012 .named_child(0)
1013 .unwrap();
1014 let mutation =
1015 PythonModuleMutationFacts::collect_bounded(root, source, || true).unwrap();
1016 let mut read_visits = 0_usize;
1017 assert_eq!(
1018 Some(expected),
1019 mutation.binding_unmutated_at_bounded(root, "Base", reference, || {
1020 read_visits += 1;
1021 true
1022 }),
1023 "{label}"
1024 );
1025 assert_eq!(
1026 Some(export_expected),
1027 mutation.export_unmutated_bounded("Base", || true),
1028 "{label}: completed exports include all hazards"
1029 );
1030 for allowance in 0..read_visits {
1031 let mut remaining = allowance;
1032 assert_eq!(
1033 None,
1034 mutation.binding_unmutated_at_bounded(root, "Base", reference, || {
1035 let allowed = remaining > 0;
1036 remaining = remaining.saturating_sub(1);
1037 allowed
1038 }),
1039 "{label}: recovery/ancestry/final stop at {allowance}"
1040 );
1041 }
1042 }
1043
1044 let original_tree = parser.parse(bounded_source, None).unwrap();
1048 let mutation = PythonModuleMutationFacts::collect_bounded(
1049 original_tree.root_node(),
1050 bounded_source,
1051 || true,
1052 )
1053 .unwrap();
1054 let changed_source = "class Base: pass\nclass D(Base): pass\ndef later():\n longer_call('a longer structured expression at the previous hazard site')\n";
1055 let changed_tree = parser.parse(changed_source, None).unwrap();
1056 let changed_root = changed_tree.root_node();
1057 let changed_reference = changed_root
1058 .named_child(1)
1059 .unwrap()
1060 .child_by_field_name("superclasses")
1061 .unwrap()
1062 .named_child(0)
1063 .unwrap();
1064 assert_eq!(
1065 None,
1066 mutation
1067 .binding_unmutated_at_bounded(changed_root, "Base", changed_reference, || true,)
1068 );
1069
1070 let read_only_initializer = "class Target: pass\nvalue = Target()\ndef read_only():\n global value\n return value\n";
1073 assert_eq!(
1074 None,
1075 initializer(
1076 &module_facts(read_only_initializer),
1077 read_only_initializer,
1078 "value"
1079 )
1080 );
1081 for (label, suffix, expected) in [
1082 (
1083 "read-only directives and unrelated local writer",
1084 "def read_only():\n global Base\n global Base\n return Base\ndef local():\n Base = 0\n",
1085 true,
1086 ),
1087 ("module directive is inert", "", true),
1088 (
1089 "sibling global assignment",
1090 "def read_only():\n global Base\n return Base\ndef change():\n global Base\n Base = 0\n",
1091 false,
1092 ),
1093 (
1094 "pending global class binding",
1095 "class Outer:\n global Base\n class Base(Base):\n pass\n",
1096 false,
1097 ),
1098 (
1099 "global function declaration",
1100 "def change():\n global Base\n def Base():\n pass\n",
1101 false,
1102 ),
1103 (
1104 "global import binding",
1105 "def change():\n global Base\n from elsewhere import Base\n",
1106 false,
1107 ),
1108 (
1109 "global delete binding",
1110 "def change():\n global Base\n del Base\n",
1111 false,
1112 ),
1113 (
1114 "nested directive uses nearest class owner",
1115 "def outer():\n Base = 0\n class ReadOnly:\n global Base\n value = Base\n",
1116 true,
1117 ),
1118 ] {
1119 let source = format!("global Base\nclass Base: pass\nclass D(Base): pass\n{suffix}");
1122 let tree = parser
1123 .parse(&source, None)
1124 .expect("parse global event fixture");
1125 let root = tree.root_node();
1126 assert!(!root.has_error(), "{label}: {source}");
1127 let reference = root
1128 .named_child(2)
1129 .unwrap()
1130 .child_by_field_name("superclasses")
1131 .unwrap()
1132 .named_child(0)
1133 .unwrap();
1134 let mut visits = 0_usize;
1135 assert_eq!(
1136 Some(expected),
1137 python_module_binding_unmutated_at_bounded(
1138 root,
1139 &source,
1140 "Base",
1141 reference,
1142 || {
1143 visits += 1;
1144 true
1145 }
1146 ),
1147 "{label}"
1148 );
1149 assert_eq!(
1150 Some(expected),
1151 python_module_export_unmutated_bounded(root, &source, "Base", || true),
1152 "{label}: complete-module export uses the same event proof"
1153 );
1154 for allowance in 0..visits {
1155 let mut remaining = allowance;
1156 assert_eq!(
1157 None,
1158 python_module_binding_unmutated_at_bounded(
1159 root,
1160 &source,
1161 "Base",
1162 reference,
1163 || {
1164 let allowed = remaining > 0;
1165 remaining = remaining.saturating_sub(1);
1166 allowed
1167 },
1168 ),
1169 "{label}: stop after {allowance} of {visits} visits cannot prove no writer"
1170 );
1171 }
1172 }
1173
1174 let source = "class Target: pass\n\
1175wild = Target()\n\
1176from elsewhere import *\n";
1177 assert_eq!(None, initializer(&module_facts(source), source, "wild"));
1178
1179 let source = "class Target: pass\n\
1180annotated: object = Target()\n\
1181augmented = Target()\n\
1182augmented += Target()\n\
1183deleted = Target()\n\
1184del deleted\n\
1185for loop_target in ():\n pass\n\
1186with manager as with_target:\n pass\n\
1187walrus = None\n\
1188(walrus := Target())\n\
1189import pathlib as imported\n\
1190from os import path as from_imported\n\
1191def declared():\n pass\n\
1192class Declared: pass\n";
1193 let facts = module_facts(source);
1194 for name in [
1195 "annotated",
1196 "augmented",
1197 "deleted",
1198 "loop_target",
1199 "with_target",
1200 "walrus",
1201 "imported",
1202 "from_imported",
1203 "declared",
1204 "Declared",
1205 ] {
1206 assert_eq!(
1207 None,
1208 initializer(&facts, source, name),
1209 "{name}: {facts:#?}"
1210 );
1211 }
1212 }
1213}