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| {
637 matches!(name, "update" | "setdefault" | "pop" | "popitem" | "clear")
638 })
639 }) && object.is_some_and(|object| is_module_mapping_call(object, source))
640 {
641 return Some(true);
642 }
643 }
644 if !is_module_mapping_call(call, source) {
645 return Some(false);
646 }
647 let mut current = call;
648 while let Some(parent) = current.parent() {
649 if !scope_step() {
650 return None;
651 }
652 match parent.kind() {
653 "assignment" | "augmented_assignment" | "annotated_assignment" => {
654 return Some(parent.child_by_field_name("left").is_some_and(|left| {
655 left.start_byte() <= call.start_byte() && call.end_byte() <= left.end_byte()
656 }));
657 }
658 "delete_statement" => return Some(true),
659 "subscript" | "attribute" => current = parent,
660 _ => return Some(false),
661 }
662 }
663 Some(false)
664}
665
666fn is_module_mapping_call(node: Node<'_>, source: &str) -> bool {
667 if node.kind() != "call"
668 || !node
669 .child_by_field_name("arguments")
670 .is_some_and(|arguments| {
671 let mut cursor = arguments.walk();
672 arguments.named_children(&mut cursor).next().is_none()
673 })
674 {
675 return false;
676 }
677 node.child_by_field_name("function")
678 .is_some_and(|function| {
679 function.kind() == "identifier"
680 && function
681 .utf8_text(source.as_bytes())
682 .ok()
683 .is_some_and(|name| matches!(name, "globals" | "vars"))
684 })
685}
686
687#[derive(Debug, Clone)]
688pub struct PythonFileSourceFacts {
689 pub occurrences: SourceFactRows,
690 pub facts: PythonSourceFacts,
691}
692
693impl PythonFileSourceFacts {
694 pub fn callable_return_for_range(
696 &self,
697 start: usize,
698 end: usize,
699 keep_going: &dyn Fn() -> bool,
700 ) -> Option<&brokk_bifrost_core::analyzer::python_facts::PythonCallableReturnFact> {
701 for fact in &self.facts.callable_returns {
702 if !keep_going() {
703 return None;
704 }
705 let declaration = self.occurrences.declaration(fact.declaration);
706 let range = self.occurrences.occurrence(declaration.occurrence).range;
707 if range.start_byte == start && range.end_byte == end {
708 return Some(fact);
709 }
710 }
711 None
712 }
713
714 pub fn estimated_retained_bytes(&self) -> usize {
715 self.occurrences
716 .estimated_bytes()
717 .saturating_add(self.facts.estimated_retained_bytes())
718 }
719}
720
721pub trait PythonSourceFactProvider: Send + Sync {
722 fn python_source_facts(
725 &self,
726 token: QueryToken<'_>,
727 file: &ProjectFile,
728 keep_going: &dyn Fn() -> bool,
729 ) -> Option<Arc<PythonFileSourceFacts>>;
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735 use brokk_bifrost_test_macros::hardwon;
736
737 fn module_facts(source: &str) -> PythonModuleGlobalSourceFacts {
738 let mut parser = tree_sitter::Parser::new();
739 parser
740 .set_language(&tree_sitter_python::LANGUAGE.into())
741 .expect("Python grammar");
742 let tree = parser.parse(source, None).expect("parse fixture");
743 python_module_global_source_facts(tree.root_node(), source, 100_000, || true)
744 .expect("complete Python module facts")
745 }
746
747 fn initializer<'a>(
748 facts: &PythonModuleGlobalSourceFacts,
749 source: &'a str,
750 name: &str,
751 ) -> Option<&'a str> {
752 let range = facts.initializer_range(name)?;
753 source.get(range.start_byte..range.end_byte)
754 }
755
756 #[test]
757 fn qs6_initializer_range_grammar_boundary() {
758 for source in [
759 "value = 1\n",
760 "value = (\n 1,\n 2\n)\n",
761 "value = '''a\nb'''\n",
762 "# \u{e9}\r\nvalue = (\r\n 1\r\n)\r\n",
763 ] {
764 let tree = crate::declarations::parse_python_tree(source).unwrap();
765 let root = tree.root_node();
766 let mut cursor = root.walk();
767 let statement = root
768 .named_children(&mut cursor)
769 .find(|n| n.kind() == "expression_statement")
770 .unwrap();
771 let rhs = statement
772 .named_child(0)
773 .unwrap()
774 .child_by_field_name("right")
775 .unwrap();
776 let range = module_facts(source).initializer_range("value").unwrap();
777 assert_eq!(
778 (
779 range.start_byte,
780 range.end_byte,
781 range.start_line,
782 range.end_line
783 ),
784 (
785 rhs.start_byte(),
786 rhs.end_byte(),
787 rhs.start_position().row + 1,
788 rhs.end_position().row + 1
789 ),
790 "{source}"
791 );
792 }
793 }
794
795 #[test]
796 fn qs6_module_binding_and_mutation_shapes() {
797 for target in [
799 "item",
800 "(item, other)",
801 "container.attr",
802 "container[index]",
803 ] {
804 let source =
805 format!("container = factory()\nindex = 0\nwith manager() as {target}:\n pass\n");
806 let facts = module_facts(&source);
807 assert_eq!(
808 initializer(&facts, &source, "container"),
809 Some("factory()"),
810 "{target}"
811 );
812 assert_eq!(initializer(&facts, &source, "index"), Some("0"), "{target}");
813 assert_eq!(
814 facts.module_binds_name("item"),
815 target == "item" || target == "(item, other)"
816 );
817 }
818 for mapping in ["globals", "vars"] {
819 for (call, mutates) in [
820 ("update(value=2)", true),
821 ("setdefault('value', 2)", true),
822 ("pop('value')", true),
823 ("clear()", true),
824 ("get('value')", false),
825 ] {
826 let source = format!("value = 1\n{mapping}().{call}\n");
827 assert_eq!(
828 module_facts(&source).initializer_range("value").is_none(),
829 mutates,
830 "{source}"
831 );
832 }
833 }
834 }
835
836 #[test]
837 #[hardwon(
838 "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: popitem can delete a module binding"
839 )]
840 fn qs6_popitem_invalidates_initializer() {
841 let source = "value = 1\nglobals().popitem()\n";
842 assert!(module_facts(source).initializer_range("value").is_none());
843 }
844
845 #[test]
846 fn qs6_final_cancellation_boundary() {
847 for source in [
848 "value = 1\nvalue\n",
849 "value = 1\ndef change():\n global value\n value = 2\nvalue\n",
850 ] {
851 let tree = crate::declarations::parse_python_tree(source).unwrap();
852 let root = tree.root_node();
853 let reference = root
854 .named_child(root.named_child_count() - 1)
855 .unwrap()
856 .named_child(0)
857 .unwrap();
858 let facts = PythonModuleMutationFacts::collect_bounded(root, source, || true).unwrap();
859 let mut parent_count = 0;
860 let mut current = reference;
861 while let Some(parent) = current.parent() {
862 parent_count += 1;
863 current = parent;
864 }
865 let mut remaining: usize = 1 + parent_count;
868 assert_eq!(
869 facts.binding_unmutated_at_bounded(root, "value", reference, || {
870 let admitted = remaining > 0;
871 remaining = remaining.saturating_sub(1);
872 admitted
873 }),
874 None
875 );
876 }
877 }
878
879 #[test]
880 #[hardwon(
881 "#4622+#5745: module mutation and stopped scans cannot prove unchanged initializer classes"
882 )]
883 fn module_global_source_fact_requires_a_single_safe_assignment() {
884 let source = "class Target: pass\n\
885safe = Target()\n\
886items = [safe for safe in ()]\n\
887if True:\n nested_safe = Target()\n";
888 let facts = module_facts(source);
889 assert_eq!(
890 Some("Target()"),
891 initializer(&facts, source, "safe"),
892 "{facts:#?}"
893 );
894 assert_eq!(Some("Target()"), initializer(&facts, source, "nested_safe"));
895
896 let source = "class Target: pass\n\
897global_write = Target()\n\
898def change():\n global global_write\n global_write = None\n";
899 assert_eq!(
900 None,
901 initializer(&module_facts(source), source, "global_write")
902 );
903
904 let source = "class Target: pass\n\
905twice = Target()\n\
906twice = Target()\n";
907 assert_eq!(None, initializer(&module_facts(source), source, "twice"));
908
909 let source = "class Target: pass\n\
910dynamic = Target()\n\
911globals()[\"dynamic\"] = None\n";
912 assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
913
914 let source = "class Target: pass\n\
915dynamic = Target()\n\
916vars().update(dynamic=None)\n";
917 assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
918
919 let source = "class Target: pass\n\
920dynamic = Target()\n\
921exec(\"dynamic = None\")\n";
922 assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
923
924 let deferred_exec =
925 "class Target: pass\nvalue = Target()\ndef later():\n exec('value = None')\n";
926 assert_eq!(
927 None,
928 initializer(&module_facts(deferred_exec), deferred_exec, "value")
929 );
930
931 let bounded_source =
934 "class Base: pass\nclass D(Base): pass\ndef later():\n exec('Base = object')\n";
935 let mut parser = tree_sitter::Parser::new();
936 parser
937 .set_language(&tree_sitter_python::LANGUAGE.into())
938 .expect("Python grammar");
939 let tree = parser.parse(bounded_source, None).expect("parse fixture");
940 let root = tree.root_node();
941 let reference = root
942 .named_child(1)
943 .unwrap()
944 .child_by_field_name("superclasses")
945 .unwrap()
946 .named_child(0)
947 .unwrap();
948 let mut visits = 0_usize;
949 assert_eq!(
950 Some(true),
951 python_module_binding_unmutated_at_bounded(
952 root,
953 bounded_source,
954 "Base",
955 reference,
956 || {
957 visits += 1;
958 true
959 },
960 )
961 );
962 for allowance in 0..visits {
963 let mut remaining = allowance;
964 assert_eq!(
965 None,
966 python_module_binding_unmutated_at_bounded(
967 root,
968 bounded_source,
969 "Base",
970 reference,
971 || {
972 let allowed = remaining > 0;
973 remaining = remaining.saturating_sub(1);
974 allowed
975 },
976 ),
977 "stopping after {allowance} of {visits} visits cannot admit Base"
978 );
979 }
980 assert_eq!(
981 Some(false),
982 python_module_export_unmutated_bounded(root, bounded_source, "Base", || true)
983 );
984
985 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";
988 let tree = parser
989 .parse(multi_source, None)
990 .expect("parse all-name events");
991 let root = tree.root_node();
992 assert!(!root.has_error());
993 let arguments = root
994 .named_child(5)
995 .unwrap()
996 .child_by_field_name("superclasses")
997 .unwrap();
998 let mut collection_visits = 0_usize;
999 let mutation = PythonModuleMutationFacts::collect_bounded(root, multi_source, || {
1000 collection_visits += 1;
1001 true
1002 })
1003 .expect("complete mutation inventory");
1004 for allowance in 0..collection_visits {
1005 let mut remaining = allowance;
1006 assert!(
1007 PythonModuleMutationFacts::collect_bounded(root, multi_source, || {
1008 let allowed = remaining > 0;
1009 remaining = remaining.saturating_sub(1);
1010 allowed
1011 })
1012 .is_none(),
1013 "partial collection at {allowance} must not publish facts"
1014 );
1015 }
1016 for (index, name, expected) in [
1017 (0, "A", false),
1018 (1, "B", false),
1019 (2, "C", false),
1020 (3, "ReadOnly", true),
1021 (4, "Sibling", false),
1022 ] {
1023 let reference = arguments.named_child(index).unwrap();
1024 let mut read_visits = 0_usize;
1025 assert_eq!(
1026 Some(expected),
1027 mutation.binding_unmutated_at_bounded(root, name, reference, || {
1028 read_visits += 1;
1029 true
1030 }),
1031 "all-name inventory: {name}"
1032 );
1033 assert!(
1034 read_visits < collection_visits,
1035 "a reused name read must not recollect the module"
1036 );
1037 assert_eq!(
1038 None,
1039 mutation.binding_unmutated_at_bounded(root, name, reference, || false),
1040 "completed facts cannot override a stopped reader"
1041 );
1042 for allowance in 0..read_visits {
1043 let mut remaining = allowance;
1044 assert_eq!(
1045 None,
1046 mutation.binding_unmutated_at_bounded(root, name, reference, || {
1047 let allowed = remaining > 0;
1048 remaining = remaining.saturating_sub(1);
1049 allowed
1050 }),
1051 "{name}: stopped read including final check at {allowance}"
1052 );
1053 }
1054 let mut export_visits = 0_usize;
1055 assert_eq!(
1056 Some(expected),
1057 mutation.export_unmutated_bounded(name, || {
1058 export_visits += 1;
1059 true
1060 })
1061 );
1062 for allowance in 0..export_visits {
1063 let mut remaining = allowance;
1064 assert_eq!(
1065 None,
1066 mutation.export_unmutated_bounded(name, || {
1067 let allowed = remaining > 0;
1068 remaining = remaining.saturating_sub(1);
1069 allowed
1070 }),
1071 "{name}: stopped export including final check at {allowance}"
1072 );
1073 }
1074 }
1075
1076 for (label, source, container_index, expected, export_expected) in [
1077 (
1078 "empty inventory still checks the final stop",
1079 "class Base: pass\nclass D(Base): pass\n",
1080 1,
1081 true,
1082 true,
1083 ),
1084 (
1085 "later exec is irrelevant to immediate read",
1086 bounded_source,
1087 1,
1088 true,
1089 false,
1090 ),
1091 (
1092 "deferred read includes later exec",
1093 "class Base: pass\ndef load():\n class D(Base): pass\ndef later():\n exec('Base = object')\n",
1094 1,
1095 false,
1096 false,
1097 ),
1098 (
1099 "later loop hazard can precede next read",
1100 "class Base: pass\nfor item in (1, 2):\n class D(Base): pass\n exec('Base = object')\n",
1101 1,
1102 false,
1103 false,
1104 ),
1105 (
1106 "earlier callable remains a possible writer",
1107 "class Base: pass\ndef earlier():\n exec('Base = object')\nclass D(Base): pass\n",
1108 2,
1109 false,
1110 false,
1111 ),
1112 (
1113 "later reflective call is irrelevant to immediate read",
1114 "class Base: pass\nclass D(Base): pass\nglobals().update(Base=object)\n",
1115 1,
1116 true,
1117 false,
1118 ),
1119 ] {
1120 let tree = parser.parse(source, None).expect("parse hazard ordering");
1121 let root = tree.root_node();
1122 assert!(!root.has_error(), "{label}");
1123 let container = root.named_child(container_index).unwrap();
1124 let class = if container.kind() == "class_definition" {
1125 container
1126 } else {
1127 container
1128 .child_by_field_name("body")
1129 .unwrap()
1130 .named_child(0)
1131 .unwrap()
1132 };
1133 assert_eq!(class.kind(), "class_definition");
1134 let reference = class
1135 .child_by_field_name("superclasses")
1136 .unwrap()
1137 .named_child(0)
1138 .unwrap();
1139 let mutation =
1140 PythonModuleMutationFacts::collect_bounded(root, source, || true).unwrap();
1141 let mut read_visits = 0_usize;
1142 assert_eq!(
1143 Some(expected),
1144 mutation.binding_unmutated_at_bounded(root, "Base", reference, || {
1145 read_visits += 1;
1146 true
1147 }),
1148 "{label}"
1149 );
1150 assert_eq!(
1151 Some(export_expected),
1152 mutation.export_unmutated_bounded("Base", || true),
1153 "{label}: completed exports include all hazards"
1154 );
1155 for allowance in 0..read_visits {
1156 let mut remaining = allowance;
1157 assert_eq!(
1158 None,
1159 mutation.binding_unmutated_at_bounded(root, "Base", reference, || {
1160 let allowed = remaining > 0;
1161 remaining = remaining.saturating_sub(1);
1162 allowed
1163 }),
1164 "{label}: recovery/ancestry/final stop at {allowance}"
1165 );
1166 }
1167 }
1168
1169 let original_tree = parser.parse(bounded_source, None).unwrap();
1173 let mutation = PythonModuleMutationFacts::collect_bounded(
1174 original_tree.root_node(),
1175 bounded_source,
1176 || true,
1177 )
1178 .unwrap();
1179 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";
1180 let changed_tree = parser.parse(changed_source, None).unwrap();
1181 let changed_root = changed_tree.root_node();
1182 let changed_reference = changed_root
1183 .named_child(1)
1184 .unwrap()
1185 .child_by_field_name("superclasses")
1186 .unwrap()
1187 .named_child(0)
1188 .unwrap();
1189 assert_eq!(
1190 None,
1191 mutation
1192 .binding_unmutated_at_bounded(changed_root, "Base", changed_reference, || true,)
1193 );
1194
1195 let read_only_initializer = "class Target: pass\nvalue = Target()\ndef read_only():\n global value\n return value\n";
1198 assert_eq!(
1199 None,
1200 initializer(
1201 &module_facts(read_only_initializer),
1202 read_only_initializer,
1203 "value"
1204 )
1205 );
1206 for (label, suffix, expected) in [
1207 (
1208 "read-only directives and unrelated local writer",
1209 "def read_only():\n global Base\n global Base\n return Base\ndef local():\n Base = 0\n",
1210 true,
1211 ),
1212 ("module directive is inert", "", true),
1213 (
1214 "sibling global assignment",
1215 "def read_only():\n global Base\n return Base\ndef change():\n global Base\n Base = 0\n",
1216 false,
1217 ),
1218 (
1219 "pending global class binding",
1220 "class Outer:\n global Base\n class Base(Base):\n pass\n",
1221 false,
1222 ),
1223 (
1224 "global function declaration",
1225 "def change():\n global Base\n def Base():\n pass\n",
1226 false,
1227 ),
1228 (
1229 "global import binding",
1230 "def change():\n global Base\n from elsewhere import Base\n",
1231 false,
1232 ),
1233 (
1234 "global delete binding",
1235 "def change():\n global Base\n del Base\n",
1236 false,
1237 ),
1238 (
1239 "nested directive uses nearest class owner",
1240 "def outer():\n Base = 0\n class ReadOnly:\n global Base\n value = Base\n",
1241 true,
1242 ),
1243 ] {
1244 let source = format!("global Base\nclass Base: pass\nclass D(Base): pass\n{suffix}");
1247 let tree = parser
1248 .parse(&source, None)
1249 .expect("parse global event fixture");
1250 let root = tree.root_node();
1251 assert!(!root.has_error(), "{label}: {source}");
1252 let reference = root
1253 .named_child(2)
1254 .unwrap()
1255 .child_by_field_name("superclasses")
1256 .unwrap()
1257 .named_child(0)
1258 .unwrap();
1259 let mut visits = 0_usize;
1260 assert_eq!(
1261 Some(expected),
1262 python_module_binding_unmutated_at_bounded(
1263 root,
1264 &source,
1265 "Base",
1266 reference,
1267 || {
1268 visits += 1;
1269 true
1270 }
1271 ),
1272 "{label}"
1273 );
1274 assert_eq!(
1275 Some(expected),
1276 python_module_export_unmutated_bounded(root, &source, "Base", || true),
1277 "{label}: complete-module export uses the same event proof"
1278 );
1279 for allowance in 0..visits {
1280 let mut remaining = allowance;
1281 assert_eq!(
1282 None,
1283 python_module_binding_unmutated_at_bounded(
1284 root,
1285 &source,
1286 "Base",
1287 reference,
1288 || {
1289 let allowed = remaining > 0;
1290 remaining = remaining.saturating_sub(1);
1291 allowed
1292 },
1293 ),
1294 "{label}: stop after {allowance} of {visits} visits cannot prove no writer"
1295 );
1296 }
1297 }
1298
1299 let source = "class Target: pass\n\
1300wild = Target()\n\
1301from elsewhere import *\n";
1302 assert_eq!(None, initializer(&module_facts(source), source, "wild"));
1303
1304 let source = "class Target: pass\n\
1305annotated: object = Target()\n\
1306augmented = Target()\n\
1307augmented += Target()\n\
1308deleted = Target()\n\
1309del deleted\n\
1310for loop_target in ():\n pass\n\
1311with manager as with_target:\n pass\n\
1312walrus = None\n\
1313(walrus := Target())\n\
1314import pathlib as imported\n\
1315from os import path as from_imported\n\
1316def declared():\n pass\n\
1317class Declared: pass\n";
1318 let facts = module_facts(source);
1319 for name in [
1320 "annotated",
1321 "augmented",
1322 "deleted",
1323 "loop_target",
1324 "with_target",
1325 "walrus",
1326 "imported",
1327 "from_imported",
1328 "declared",
1329 "Declared",
1330 ] {
1331 assert_eq!(
1332 None,
1333 initializer(&facts, source, name),
1334 "{name}: {facts:#?}"
1335 );
1336 }
1337 }
1338}