Skip to main content

brokk_bifrost_python/
source_facts.rs

1//! Mounted read-side input for Python declaration consumers.
2
3use 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/// Syntax-only proof candidates for module-level initializer values.
15///
16/// This is deliberately separate from [`PythonFileSourceFacts`]: that type is
17/// a read projection over persisted rows, while this value is derived from the
18/// current parse tree and belongs in the analyzer's generation/blob/file
19/// cache. A returned range identifies only the RHS of a single, plain module
20/// assignment; callers still classify that expression before using it.
21#[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    /// Whether any module-scope syntax binds this name.
39    pub fn module_binds_name(&self, name: &str) -> bool {
40        self.bindings.contains_key(name)
41    }
42
43    /// The initializer's RHS range only when every local syntactic proof holds.
44    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    /// Approximate retained bytes for the analyzer's weighted per-file cache.
63    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
86/// The same grammar classification feeds both file-wide initializer facts and
87/// reference-time class binding. None is a stopped classifier, not no hazard.
88fn 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/// Complete syntax-only module mutation facts for one request or operation.
128///
129/// These facts retain no tree nodes, selected reads, source owners or resolved
130/// answers. Callers must pair them with the exact prepared-source bytes used
131/// for collection, and validate each actual owner's source independently.
132#[derive(Debug)]
133pub struct PythonModuleMutationFacts {
134    global_binding_events: HashSet<Box<str>>,
135    dynamic_hazards: Vec<Range>,
136}
137
138impl PythonModuleMutationFacts {
139    /// Collect all hazards before publishing facts. A stopped collection has
140    /// no usable partial result, even if an earlier hazard already refused.
141    pub fn collect_bounded(
142        root: Node<'_>,
143        source: &str,
144        mut scope_step: impl FnMut() -> bool,
145    ) -> Option<Self> {
146        use brokk_bifrost_core::analyzer::tree_walk::{
147            WalkControl, node_range, try_walk_named_tree_preorder,
148        };
149        if !scope_step() || root.kind() != "module" || root.has_error() {
150            return None;
151        }
152        let mut facts = Self {
153            global_binding_events: HashSet::new(),
154            dynamic_hazards: Vec::new(),
155        };
156        let mut checked_global_scopes = HashSet::new();
157        try_walk_named_tree_preorder(root, true, |node| {
158            if !scope_step() {
159                return Err(());
160            }
161            match python_module_mutation_hazard_bounded(node, source, &mut scope_step).ok_or(())? {
162                Some(PythonModuleMutationHazard::Global(_)) => {
163                    // Directives belong to their nearest lexical body. Keep
164                    // this outer scan file-wide to include sibling writers.
165                    let mut current = node.parent();
166                    while let Some(scope) = current {
167                        if !scope_step() {
168                            return Err(());
169                        }
170                        if scope.kind() == "module" {
171                            // Module directives are inert; ordinary module
172                            // writes remain the module binder's obligation.
173                            break;
174                        }
175                        if matches!(
176                            scope.kind(),
177                            "function_definition" | "lambda" | "class_definition"
178                        ) {
179                            if !checked_global_scopes.contains(&scope.id()) {
180                                // Unfiltered inventory preserves pending
181                                // class/declaration/import/delete events.
182                                let bindings = PythonLexicalScopeInventory::collect_bounded(
183                                    scope,
184                                    source,
185                                    std::iter::empty::<String>(),
186                                    &mut scope_step,
187                                )
188                                .ok_or(())?
189                                .into_bindings();
190                                // Merge ALL names before deduplicating this
191                                // owner, not just the first directive's name.
192                                for name in bindings.global_binding_events() {
193                                    if !scope_step() {
194                                        return Err(());
195                                    }
196                                    facts.global_binding_events.insert(name.into());
197                                }
198                                checked_global_scopes.insert(scope.id());
199                            }
200                            break;
201                        }
202                        current = scope.parent();
203                    }
204                }
205                Some(
206                    PythonModuleMutationHazard::Exec | PythonModuleMutationHazard::ReflectiveWrite,
207                ) => {
208                    facts.dynamic_hazards.push(node_range(node));
209                }
210                None => {}
211            }
212            Ok(WalkControl::Continue)
213        })
214        .ok()?;
215        scope_step().then_some(facts)
216    }
217
218    /// Admit an actual read against the current prepared root and reference.
219    /// The reference must belong to this root from the same prepared source.
220    /// Earlier callable bodies can write; a later body cannot affect an
221    /// immediate read except on a loop back-edge. Actual named global events
222    /// remain file-wide barriers; read-only directives are not mutations.
223    pub fn binding_unmutated_at_bounded(
224        &self,
225        root: Node<'_>,
226        name: &str,
227        reference: Node<'_>,
228        mut scope_step: impl FnMut() -> bool,
229    ) -> Option<bool> {
230        use brokk_bifrost_core::analyzer::tree_walk::node_range;
231        use brokk_bifrost_core::analyzer::usages::reference_site::smallest_named_node_covering_with_scope;
232        if !scope_step() {
233            return None;
234        }
235        if root.kind() != "module" || root.has_error() {
236            return scope_step().then_some(false);
237        }
238        let deferred = crate::syntax::python_reference_is_deferred_function_body_bounded(
239            reference,
240            &mut scope_step,
241        )?;
242        let read_range = node_range(reference);
243        let mut unmutated = !self.global_binding_events.contains(name);
244        if unmutated {
245            for hazard in &self.dynamic_hazards {
246                if !scope_step() {
247                    return None;
248                }
249                let node = smallest_named_node_covering_with_scope(
250                    root,
251                    hazard.start_byte,
252                    hazard.end_byte,
253                    &mut scope_step,
254                )?;
255                if node.start_byte() != hazard.start_byte || node.end_byte() != hazard.end_byte {
256                    return None;
257                }
258                // A deeper named node with the identical span is sufficient:
259                // order depends on this range and enclosing loops, not kind.
260                if deferred
261                    || crate::runtime_values::python_hazard_can_precede_bounded(
262                        *hazard,
263                        read_range,
264                        node,
265                        &mut scope_step,
266                    )?
267                {
268                    unmutated = false;
269                    break;
270                }
271            }
272        }
273        // A completed inventory does not erase a current caller's late stop,
274        // including empty-hazard and known-global-membership return paths.
275        scope_step().then_some(unmutated)
276    }
277
278    /// Imports consume the actual owner's completed export. Declaration-time
279    /// or importer timing cannot dismiss any recorded dynamic hazard.
280    pub fn export_unmutated_bounded(
281        &self,
282        name: &str,
283        mut scope_step: impl FnMut() -> bool,
284    ) -> Option<bool> {
285        if !scope_step() {
286            return None;
287        }
288        let unmutated =
289            !self.global_binding_events.contains(name) && self.dynamic_hazards.is_empty();
290        scope_step().then_some(unmutated)
291    }
292}
293
294/// Stateless callers share the same complete collector and read evaluator.
295pub fn python_module_binding_unmutated_at_bounded(
296    root: Node<'_>,
297    source: &str,
298    name: &str,
299    reference: Node<'_>,
300    mut scope_step: impl FnMut() -> bool,
301) -> Option<bool> {
302    PythonModuleMutationFacts::collect_bounded(root, source, &mut scope_step)?
303        .binding_unmutated_at_bounded(root, name, reference, scope_step)
304}
305
306/// Each source module/facade supplies its own completed export inventory.
307pub fn python_module_export_unmutated_bounded(
308    root: Node<'_>,
309    source: &str,
310    name: &str,
311    mut scope_step: impl FnMut() -> bool,
312) -> Option<bool> {
313    PythonModuleMutationFacts::collect_bounded(root, source, &mut scope_step)?
314        .export_unmutated_bounded(name, scope_step)
315}
316
317/// Collect bounded, per-file module-binding syntax facts.
318///
319/// The module binding walk skips function, lambda and class bodies, but still
320/// visits their headers because decorators and defaults execute in the
321/// enclosing scope. A second iterative walk records `global` declarations and
322/// reflective writes wherever they occur in the module.
323pub fn python_module_global_source_facts(
324    root: Node<'_>,
325    source: &str,
326    max_nodes: usize,
327    mut keep_going: impl FnMut() -> bool,
328) -> Option<PythonModuleGlobalSourceFacts> {
329    if root.kind() != "module" || root.has_error() {
330        return None;
331    }
332    let mut remaining = max_nodes;
333    let mut facts = PythonModuleGlobalSourceFacts::default();
334    let mut stack = vec![(root, true)];
335    while let Some((node, initializer_path)) = stack.pop() {
336        if remaining == 0 || !keep_going() {
337            return None;
338        }
339        remaining -= 1;
340
341        if node.kind() == "wildcard_import" {
342            facts.wildcard_import_offsets.push(node.start_byte());
343        }
344
345        let mut bound_names = HashSet::<String>::new();
346        if matches!(
347            node.kind(),
348            "function_definition"
349                | "class_definition"
350                | "import_statement"
351                | "import_from_statement"
352                | "assignment"
353                | "augmented_assignment"
354                | "annotated_assignment"
355                | "named_expression"
356                | "for_statement"
357                | "delete_statement"
358                | "as_pattern"
359                | "except_clause"
360                | "case_clause"
361        ) {
362            let mut steps = || {
363                if remaining == 0 || !keep_going() {
364                    false
365                } else {
366                    remaining -= 1;
367                    true
368                }
369            };
370            for binding in
371                crate::bindings::python_direct_scope_bindings_bounded(node, source, &mut steps)?
372            {
373                if binding.declaration.kind() == "identifier" {
374                    bound_names.insert(
375                        binding
376                            .declaration
377                            .utf8_text(source.as_bytes())
378                            .ok()?
379                            .to_owned(),
380                    );
381                }
382            }
383        }
384        if node.kind() == "annotated_assignment"
385            && let Some(target) = node.child_by_field_name("left")
386        {
387            module_target_names(
388                target,
389                source,
390                &mut bound_names,
391                &mut remaining,
392                &mut keep_going,
393            )?;
394        }
395        if node.kind() == "with_statement" {
396            let mut cursor = node.walk();
397            for child in node.named_children(&mut cursor) {
398                if child.kind() == "with_clause" {
399                    let mut with_stack = vec![child];
400                    while let Some(item) = with_stack.pop() {
401                        if remaining == 0 || !keep_going() {
402                            return None;
403                        }
404                        remaining -= 1;
405                        if item.kind() == "as_pattern"
406                            && let Some(alias) = item.child_by_field_name("alias")
407                        {
408                            module_target_names(
409                                alias,
410                                source,
411                                &mut bound_names,
412                                &mut remaining,
413                                &mut keep_going,
414                            )?;
415                        }
416                        with_stack.extend(item.named_children(&mut item.walk()));
417                    }
418                }
419            }
420        }
421
422        if !bound_names.is_empty() {
423            let assignment_initializer = (node.kind() == "assignment"
424                && initializer_path
425                && node.parent().is_some_and(|parent| {
426                    matches!(parent.kind(), "module" | "block" | "expression_statement")
427                })
428                && node.child_by_field_name("type").is_none()
429                && node.child_by_field_name("annotation").is_none()
430                && node
431                    .child_by_field_name("left")
432                    .is_some_and(|left| left.kind() == "identifier")
433                && bound_names.len() == 1)
434                .then(|| node.child_by_field_name("right"))
435                .flatten();
436            for name in bound_names {
437                let binding = facts.bindings.entry(name.into_boxed_str()).or_default();
438                binding.statements = binding.statements.saturating_add(1);
439                if let Some(value) = assignment_initializer {
440                    binding.initializer = Some(node_range(value, source));
441                    binding.initializer_statement_end = node.end_byte();
442                } else {
443                    binding.initializer = None;
444                    binding.initializer_statement_end = node.end_byte();
445                }
446            }
447        }
448
449        let allowed_block = matches!(
450            node.kind(),
451            "if_statement"
452                | "try_statement"
453                | "with_statement"
454                | "for_statement"
455                | "block"
456                | "except_clause"
457                | "else_clause"
458                | "finally_clause"
459                | "expression_statement"
460        );
461        let scope_body = matches!(
462            node.kind(),
463            "function_definition" | "class_definition" | "lambda"
464        )
465        .then(|| node.child_by_field_name("body").map(|body| body.id()))
466        .flatten();
467        let name_node = matches!(node.kind(), "function_definition" | "class_definition")
468            .then(|| node.child_by_field_name("name").map(|name| name.id()))
469            .flatten();
470        let mut cursor = node.walk();
471        let children = node.named_children(&mut cursor).collect::<Vec<_>>();
472        for child in children.into_iter().rev() {
473            if Some(child.id()) == scope_body || Some(child.id()) == name_node {
474                continue;
475            }
476            let child_initializer_path =
477                if matches!(node.kind(), "module" | "block" | "expression_statement") {
478                    initializer_path
479                } else if matches!(
480                    node.kind(),
481                    "except_clause" | "else_clause" | "finally_clause"
482                ) {
483                    initializer_path && child.kind() == "block"
484                } else if allowed_block {
485                    if node.kind() == "try_statement" {
486                        initializer_path
487                            && matches!(
488                                child.kind(),
489                                "block" | "except_clause" | "else_clause" | "finally_clause"
490                            )
491                    } else if matches!(node.kind(), "if_statement" | "for_statement")
492                        && child.kind() == "else_clause"
493                    {
494                        initializer_path
495                    } else {
496                        initializer_path
497                            && child.kind() == "block"
498                            && (node
499                                .child_by_field_name("body")
500                                .is_some_and(|body| body.id() == child.id())
501                                || node
502                                    .child_by_field_name("consequence")
503                                    .is_some_and(|body| body.id() == child.id())
504                                || node
505                                    .child_by_field_name("alternative")
506                                    .is_some_and(|body| body.id() == child.id()))
507                    }
508                } else {
509                    false
510                };
511            if matches!(
512                node.kind(),
513                "list_comprehension"
514                    | "set_comprehension"
515                    | "dictionary_comprehension"
516                    | "generator_expression"
517            ) && child.kind() == "for_in_clause"
518            {
519                let target = child.child_by_field_name("left");
520                let mut clause_cursor = child.walk();
521                for clause_child in child.named_children(&mut clause_cursor) {
522                    if target.is_some_and(|target| target.id() == clause_child.id()) {
523                        continue;
524                    }
525                    stack.push((clause_child, false));
526                }
527                continue;
528            }
529            stack.push((child, child_initializer_path));
530        }
531    }
532
533    remaining = max_nodes;
534    let mut stack = vec![root];
535    while let Some(node) = stack.pop() {
536        if remaining == 0 || !keep_going() {
537            return None;
538        }
539        remaining -= 1;
540        // Preserve the file-wide/deferred contract, including its historical
541        // node budget. The reference-time consumer meters classifier internals.
542        match python_module_mutation_hazard_bounded(node, source, || true)? {
543            Some(PythonModuleMutationHazard::Global(names)) => {
544                facts.global_declarations.extend(names);
545            }
546            Some(PythonModuleMutationHazard::Exec) => facts.has_exec = true,
547            Some(PythonModuleMutationHazard::ReflectiveWrite) => facts.has_dynamic_write = true,
548            None => {}
549        }
550        let mut cursor = node.walk();
551        stack.extend(node.named_children(&mut cursor));
552    }
553    facts.wildcard_import_offsets.sort_unstable();
554    Some(facts)
555}
556
557fn node_range(node: Node<'_>, source: &str) -> Range {
558    let start_byte = node.start_byte();
559    let end_byte = node.end_byte();
560    let start_line = source.as_bytes()[..start_byte]
561        .iter()
562        .filter(|byte| **byte == b'\n')
563        .count()
564        + 1;
565    let end_line = source.as_bytes()[..end_byte]
566        .iter()
567        .filter(|byte| **byte == b'\n')
568        .count()
569        + 1;
570    Range {
571        start_byte,
572        end_byte,
573        start_line,
574        end_line,
575    }
576}
577
578fn module_target_names(
579    root: Node<'_>,
580    source: &str,
581    names: &mut HashSet<String>,
582    remaining: &mut usize,
583    keep_going: &mut impl FnMut() -> bool,
584) -> Option<()> {
585    let mut stack = vec![root];
586    while let Some(node) = stack.pop() {
587        if *remaining == 0 || !keep_going() {
588            return None;
589        }
590        *remaining -= 1;
591        match node.kind() {
592            "identifier" => {
593                names.insert(node.utf8_text(source.as_bytes()).ok()?.to_owned());
594            }
595            "attribute" | "subscript" | "call" | "string" | "integer" => {}
596            _ => {
597                let mut cursor = node.walk();
598                stack.extend(node.named_children(&mut cursor));
599            }
600        }
601    }
602    Some(())
603}
604
605fn module_globals_write_call_bounded(
606    call: Node<'_>,
607    source: &str,
608    mut scope_step: impl FnMut() -> bool,
609) -> Option<bool> {
610    if !scope_step() {
611        return None;
612    }
613    let function = call.child_by_field_name("function");
614    let Some(function) = function else {
615        return Some(false);
616    };
617    if function.kind() == "attribute" {
618        let method = function.child_by_field_name("attribute");
619        let object = function.child_by_field_name("object");
620        if method.is_some_and(|method| {
621            method
622                .utf8_text(source.as_bytes())
623                .ok()
624                .is_some_and(|name| matches!(name, "update" | "setdefault" | "pop" | "clear"))
625        }) && object.is_some_and(|object| is_module_mapping_call(object, source))
626        {
627            return Some(true);
628        }
629    }
630    if !is_module_mapping_call(call, source) {
631        return Some(false);
632    }
633    let mut current = call;
634    while let Some(parent) = current.parent() {
635        if !scope_step() {
636            return None;
637        }
638        match parent.kind() {
639            "assignment" | "augmented_assignment" | "annotated_assignment" => {
640                return Some(parent.child_by_field_name("left").is_some_and(|left| {
641                    left.start_byte() <= call.start_byte() && call.end_byte() <= left.end_byte()
642                }));
643            }
644            "delete_statement" => return Some(true),
645            "subscript" | "attribute" => current = parent,
646            _ => return Some(false),
647        }
648    }
649    Some(false)
650}
651
652fn is_module_mapping_call(node: Node<'_>, source: &str) -> bool {
653    if node.kind() != "call"
654        || !node
655            .child_by_field_name("arguments")
656            .is_some_and(|arguments| {
657                let mut cursor = arguments.walk();
658                arguments.named_children(&mut cursor).next().is_none()
659            })
660    {
661        return false;
662    }
663    node.child_by_field_name("function")
664        .is_some_and(|function| {
665            function.kind() == "identifier"
666                && function
667                    .utf8_text(source.as_bytes())
668                    .ok()
669                    .is_some_and(|name| matches!(name, "globals" | "vars"))
670        })
671}
672
673#[derive(Debug, Clone)]
674pub struct PythonFileSourceFacts {
675    pub occurrences: SourceFactRows,
676    pub facts: PythonSourceFacts,
677}
678
679impl PythonFileSourceFacts {
680    /// Select the canonical callable for one exact current-syntax focus.
681    pub fn callable_return_for_range(
682        &self,
683        start: usize,
684        end: usize,
685        keep_going: &dyn Fn() -> bool,
686    ) -> Option<&brokk_bifrost_core::analyzer::python_facts::PythonCallableReturnFact> {
687        for fact in &self.facts.callable_returns {
688            if !keep_going() {
689                return None;
690            }
691            let declaration = self.occurrences.declaration(fact.declaration);
692            let range = self.occurrences.occurrence(declaration.occurrence).range;
693            if range.start_byte == start && range.end_byte == end {
694                return Some(fact);
695            }
696        }
697        None
698    }
699
700    pub fn estimated_retained_bytes(&self) -> usize {
701        self.occurrences
702            .estimated_bytes()
703            .saturating_add(self.facts.estimated_retained_bytes())
704    }
705}
706
707pub trait PythonSourceFactProvider: Send + Sync {
708    /// Missing publication is unavailable; a published empty file returns Some.
709    /// `keep_going` is the caller's admission and traversal continuation check.
710    fn python_source_facts(
711        &self,
712        token: QueryToken<'_>,
713        file: &ProjectFile,
714        keep_going: &dyn Fn() -> bool,
715    ) -> Option<Arc<PythonFileSourceFacts>>;
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use brokk_bifrost_test_macros::hardwon;
722
723    fn module_facts(source: &str) -> PythonModuleGlobalSourceFacts {
724        let mut parser = tree_sitter::Parser::new();
725        parser
726            .set_language(&tree_sitter_python::LANGUAGE.into())
727            .expect("Python grammar");
728        let tree = parser.parse(source, None).expect("parse fixture");
729        python_module_global_source_facts(tree.root_node(), source, 100_000, || true)
730            .expect("complete Python module facts")
731    }
732
733    fn initializer<'a>(
734        facts: &PythonModuleGlobalSourceFacts,
735        source: &'a str,
736        name: &str,
737    ) -> Option<&'a str> {
738        let range = facts.initializer_range(name)?;
739        source.get(range.start_byte..range.end_byte)
740    }
741
742    #[test]
743    #[hardwon(
744        "#4622+#5745: module mutation and stopped scans cannot prove unchanged initializer classes"
745    )]
746    fn module_global_source_fact_requires_a_single_safe_assignment() {
747        let source = "class Target: pass\n\
748safe = Target()\n\
749items = [safe for safe in ()]\n\
750if True:\n    nested_safe = Target()\n";
751        let facts = module_facts(source);
752        assert_eq!(
753            Some("Target()"),
754            initializer(&facts, source, "safe"),
755            "{facts:#?}"
756        );
757        assert_eq!(Some("Target()"), initializer(&facts, source, "nested_safe"));
758
759        let source = "class Target: pass\n\
760global_write = Target()\n\
761def change():\n    global global_write\n    global_write = None\n";
762        assert_eq!(
763            None,
764            initializer(&module_facts(source), source, "global_write")
765        );
766
767        let source = "class Target: pass\n\
768twice = Target()\n\
769twice = Target()\n";
770        assert_eq!(None, initializer(&module_facts(source), source, "twice"));
771
772        let source = "class Target: pass\n\
773dynamic = Target()\n\
774globals()[\"dynamic\"] = None\n";
775        assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
776
777        let source = "class Target: pass\n\
778dynamic = Target()\n\
779vars().update(dynamic=None)\n";
780        assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
781
782        let source = "class Target: pass\n\
783dynamic = Target()\n\
784exec(\"dynamic = None\")\n";
785        assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
786
787        let deferred_exec =
788            "class Target: pass\nvalue = Target()\ndef later():\n    exec('value = None')\n";
789        assert_eq!(
790            None,
791            initializer(&module_facts(deferred_exec), deferred_exec, "value")
792        );
793
794        // A stopped reference-time scan is not an absence proof. The later
795        // exec stays irrelevant to this immediate read, but not to exports.
796        let bounded_source =
797            "class Base: pass\nclass D(Base): pass\ndef later():\n    exec('Base = object')\n";
798        let mut parser = tree_sitter::Parser::new();
799        parser
800            .set_language(&tree_sitter_python::LANGUAGE.into())
801            .expect("Python grammar");
802        let tree = parser.parse(bounded_source, None).expect("parse fixture");
803        let root = tree.root_node();
804        let reference = root
805            .named_child(1)
806            .unwrap()
807            .child_by_field_name("superclasses")
808            .unwrap()
809            .named_child(0)
810            .unwrap();
811        let mut visits = 0_usize;
812        assert_eq!(
813            Some(true),
814            python_module_binding_unmutated_at_bounded(
815                root,
816                bounded_source,
817                "Base",
818                reference,
819                || {
820                    visits += 1;
821                    true
822                },
823            )
824        );
825        for allowance in 0..visits {
826            let mut remaining = allowance;
827            assert_eq!(
828                None,
829                python_module_binding_unmutated_at_bounded(
830                    root,
831                    bounded_source,
832                    "Base",
833                    reference,
834                    || {
835                        let allowed = remaining > 0;
836                        remaining = remaining.saturating_sub(1);
837                        allowed
838                    },
839                ),
840                "stopping after {allowance} of {visits} visits cannot admit Base"
841            );
842        }
843        assert_eq!(
844            Some(false),
845            python_module_export_unmutated_bounded(root, bounded_source, "Base", || true)
846        );
847
848        // One complete inventory serves all selected names. In particular,
849        // the first directive's owner must contribute its later names too.
850        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";
851        let tree = parser
852            .parse(multi_source, None)
853            .expect("parse all-name events");
854        let root = tree.root_node();
855        assert!(!root.has_error());
856        let arguments = root
857            .named_child(5)
858            .unwrap()
859            .child_by_field_name("superclasses")
860            .unwrap();
861        let mut collection_visits = 0_usize;
862        let mutation = PythonModuleMutationFacts::collect_bounded(root, multi_source, || {
863            collection_visits += 1;
864            true
865        })
866        .expect("complete mutation inventory");
867        for allowance in 0..collection_visits {
868            let mut remaining = allowance;
869            assert!(
870                PythonModuleMutationFacts::collect_bounded(root, multi_source, || {
871                    let allowed = remaining > 0;
872                    remaining = remaining.saturating_sub(1);
873                    allowed
874                })
875                .is_none(),
876                "partial collection at {allowance} must not publish facts"
877            );
878        }
879        for (index, name, expected) in [
880            (0, "A", false),
881            (1, "B", false),
882            (2, "C", false),
883            (3, "ReadOnly", true),
884            (4, "Sibling", false),
885        ] {
886            let reference = arguments.named_child(index).unwrap();
887            let mut read_visits = 0_usize;
888            assert_eq!(
889                Some(expected),
890                mutation.binding_unmutated_at_bounded(root, name, reference, || {
891                    read_visits += 1;
892                    true
893                }),
894                "all-name inventory: {name}"
895            );
896            assert!(
897                read_visits < collection_visits,
898                "a reused name read must not recollect the module"
899            );
900            assert_eq!(
901                None,
902                mutation.binding_unmutated_at_bounded(root, name, reference, || false),
903                "completed facts cannot override a stopped reader"
904            );
905            for allowance in 0..read_visits {
906                let mut remaining = allowance;
907                assert_eq!(
908                    None,
909                    mutation.binding_unmutated_at_bounded(root, name, reference, || {
910                        let allowed = remaining > 0;
911                        remaining = remaining.saturating_sub(1);
912                        allowed
913                    }),
914                    "{name}: stopped read including final check at {allowance}"
915                );
916            }
917            let mut export_visits = 0_usize;
918            assert_eq!(
919                Some(expected),
920                mutation.export_unmutated_bounded(name, || {
921                    export_visits += 1;
922                    true
923                })
924            );
925            for allowance in 0..export_visits {
926                let mut remaining = allowance;
927                assert_eq!(
928                    None,
929                    mutation.export_unmutated_bounded(name, || {
930                        let allowed = remaining > 0;
931                        remaining = remaining.saturating_sub(1);
932                        allowed
933                    }),
934                    "{name}: stopped export including final check at {allowance}"
935                );
936            }
937        }
938
939        for (label, source, container_index, expected, export_expected) in [
940            (
941                "empty inventory still checks the final stop",
942                "class Base: pass\nclass D(Base): pass\n",
943                1,
944                true,
945                true,
946            ),
947            (
948                "later exec is irrelevant to immediate read",
949                bounded_source,
950                1,
951                true,
952                false,
953            ),
954            (
955                "deferred read includes later exec",
956                "class Base: pass\ndef load():\n    class D(Base): pass\ndef later():\n    exec('Base = object')\n",
957                1,
958                false,
959                false,
960            ),
961            (
962                "later loop hazard can precede next read",
963                "class Base: pass\nfor item in (1, 2):\n    class D(Base): pass\n    exec('Base = object')\n",
964                1,
965                false,
966                false,
967            ),
968            (
969                "earlier callable remains a possible writer",
970                "class Base: pass\ndef earlier():\n    exec('Base = object')\nclass D(Base): pass\n",
971                2,
972                false,
973                false,
974            ),
975            (
976                "later reflective call is irrelevant to immediate read",
977                "class Base: pass\nclass D(Base): pass\nglobals().update(Base=object)\n",
978                1,
979                true,
980                false,
981            ),
982        ] {
983            let tree = parser.parse(source, None).expect("parse hazard ordering");
984            let root = tree.root_node();
985            assert!(!root.has_error(), "{label}");
986            let container = root.named_child(container_index).unwrap();
987            let class = if container.kind() == "class_definition" {
988                container
989            } else {
990                container
991                    .child_by_field_name("body")
992                    .unwrap()
993                    .named_child(0)
994                    .unwrap()
995            };
996            assert_eq!(class.kind(), "class_definition");
997            let reference = class
998                .child_by_field_name("superclasses")
999                .unwrap()
1000                .named_child(0)
1001                .unwrap();
1002            let mutation =
1003                PythonModuleMutationFacts::collect_bounded(root, source, || true).unwrap();
1004            let mut read_visits = 0_usize;
1005            assert_eq!(
1006                Some(expected),
1007                mutation.binding_unmutated_at_bounded(root, "Base", reference, || {
1008                    read_visits += 1;
1009                    true
1010                }),
1011                "{label}"
1012            );
1013            assert_eq!(
1014                Some(export_expected),
1015                mutation.export_unmutated_bounded("Base", || true),
1016                "{label}: completed exports include all hazards"
1017            );
1018            for allowance in 0..read_visits {
1019                let mut remaining = allowance;
1020                assert_eq!(
1021                    None,
1022                    mutation.binding_unmutated_at_bounded(root, "Base", reference, || {
1023                        let allowed = remaining > 0;
1024                        remaining = remaining.saturating_sub(1);
1025                        allowed
1026                    }),
1027                    "{label}: recovery/ancestry/final stop at {allowance}"
1028                );
1029            }
1030        }
1031
1032        // A covering-only recovery cannot stand in for the exact hazard.
1033        // Callers must prevent changed-source reuse; the evaluator still
1034        // refuses rather than treating a missing old site as harmless.
1035        let original_tree = parser.parse(bounded_source, None).unwrap();
1036        let mutation = PythonModuleMutationFacts::collect_bounded(
1037            original_tree.root_node(),
1038            bounded_source,
1039            || true,
1040        )
1041        .unwrap();
1042        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";
1043        let changed_tree = parser.parse(changed_source, None).unwrap();
1044        let changed_root = changed_tree.root_node();
1045        let changed_reference = changed_root
1046            .named_child(1)
1047            .unwrap()
1048            .child_by_field_name("superclasses")
1049            .unwrap()
1050            .named_child(0)
1051            .unwrap();
1052        assert_eq!(
1053            None,
1054            mutation
1055                .binding_unmutated_at_bounded(changed_root, "Base", changed_reference, || true,)
1056        );
1057
1058        // Runtime namespace mutation requires an event in the directive's
1059        // own scope. Legacy initializer facts intentionally remain stricter.
1060        let read_only_initializer = "class Target: pass\nvalue = Target()\ndef read_only():\n    global value\n    return value\n";
1061        assert_eq!(
1062            None,
1063            initializer(
1064                &module_facts(read_only_initializer),
1065                read_only_initializer,
1066                "value"
1067            )
1068        );
1069        for (label, suffix, expected) in [
1070            (
1071                "read-only directives and unrelated local writer",
1072                "def read_only():\n    global Base\n    global Base\n    return Base\ndef local():\n    Base = 0\n",
1073                true,
1074            ),
1075            ("module directive is inert", "", true),
1076            (
1077                "sibling global assignment",
1078                "def read_only():\n    global Base\n    return Base\ndef change():\n    global Base\n    Base = 0\n",
1079                false,
1080            ),
1081            (
1082                "pending global class binding",
1083                "class Outer:\n    global Base\n    class Base(Base):\n        pass\n",
1084                false,
1085            ),
1086            (
1087                "global function declaration",
1088                "def change():\n    global Base\n    def Base():\n        pass\n",
1089                false,
1090            ),
1091            (
1092                "global import binding",
1093                "def change():\n    global Base\n    from elsewhere import Base\n",
1094                false,
1095            ),
1096            (
1097                "global delete binding",
1098                "def change():\n    global Base\n    del Base\n",
1099                false,
1100            ),
1101            (
1102                "nested directive uses nearest class owner",
1103                "def outer():\n    Base = 0\n    class ReadOnly:\n        global Base\n        value = Base\n",
1104                true,
1105            ),
1106        ] {
1107            // Place the inert module directive before any Base use, so these
1108            // controls also respect Python's directive ordering constraint.
1109            let source = format!("global Base\nclass Base: pass\nclass D(Base): pass\n{suffix}");
1110            let tree = parser
1111                .parse(&source, None)
1112                .expect("parse global event fixture");
1113            let root = tree.root_node();
1114            assert!(!root.has_error(), "{label}: {source}");
1115            let reference = root
1116                .named_child(2)
1117                .unwrap()
1118                .child_by_field_name("superclasses")
1119                .unwrap()
1120                .named_child(0)
1121                .unwrap();
1122            let mut visits = 0_usize;
1123            assert_eq!(
1124                Some(expected),
1125                python_module_binding_unmutated_at_bounded(
1126                    root,
1127                    &source,
1128                    "Base",
1129                    reference,
1130                    || {
1131                        visits += 1;
1132                        true
1133                    }
1134                ),
1135                "{label}"
1136            );
1137            assert_eq!(
1138                Some(expected),
1139                python_module_export_unmutated_bounded(root, &source, "Base", || true),
1140                "{label}: complete-module export uses the same event proof"
1141            );
1142            for allowance in 0..visits {
1143                let mut remaining = allowance;
1144                assert_eq!(
1145                    None,
1146                    python_module_binding_unmutated_at_bounded(
1147                        root,
1148                        &source,
1149                        "Base",
1150                        reference,
1151                        || {
1152                            let allowed = remaining > 0;
1153                            remaining = remaining.saturating_sub(1);
1154                            allowed
1155                        },
1156                    ),
1157                    "{label}: stop after {allowance} of {visits} visits cannot prove no writer"
1158                );
1159            }
1160        }
1161
1162        let source = "class Target: pass\n\
1163wild = Target()\n\
1164from elsewhere import *\n";
1165        assert_eq!(None, initializer(&module_facts(source), source, "wild"));
1166
1167        let source = "class Target: pass\n\
1168annotated: object = Target()\n\
1169augmented = Target()\n\
1170augmented += Target()\n\
1171deleted = Target()\n\
1172del deleted\n\
1173for loop_target in ():\n    pass\n\
1174with manager as with_target:\n    pass\n\
1175walrus = None\n\
1176(walrus := Target())\n\
1177import pathlib as imported\n\
1178from os import path as from_imported\n\
1179def declared():\n    pass\n\
1180class Declared: pass\n";
1181        let facts = module_facts(source);
1182        for name in [
1183            "annotated",
1184            "augmented",
1185            "deleted",
1186            "loop_target",
1187            "with_target",
1188            "walrus",
1189            "imported",
1190            "from_imported",
1191            "declared",
1192            "Declared",
1193        ] {
1194            assert_eq!(
1195                None,
1196                initializer(&facts, source, name),
1197                "{name}: {facts:#?}"
1198            );
1199        }
1200    }
1201}