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