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/// Native producers reuse the same selected-node hazard classification. This
128/// retains unknown lexical binders during parsing rather than reparsing a file
129/// to certify a later builtin lookup. Global directives have binding events.
130pub(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/// Complete syntax-only module mutation facts for one request or operation.
140///
141/// These facts retain no tree nodes, selected reads, source owners or resolved
142/// answers. Callers must pair them with the exact prepared-source bytes used
143/// for collection, and validate each actual owner's source independently.
144#[derive(Debug)]
145pub struct PythonModuleMutationFacts {
146    global_binding_events: HashSet<Box<str>>,
147    dynamic_hazards: Vec<Range>,
148}
149
150impl PythonModuleMutationFacts {
151    /// Collect all hazards before publishing facts. A stopped collection has
152    /// no usable partial result, even if an earlier hazard already refused.
153    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                    // Directives belong to their nearest lexical body. Keep
176                    // this outer scan file-wide to include sibling writers.
177                    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                            // Module directives are inert; ordinary module
184                            // writes remain the module binder's obligation.
185                            break;
186                        }
187                        if matches!(
188                            scope.kind(),
189                            "function_definition" | "lambda" | "class_definition"
190                        ) {
191                            if !checked_global_scopes.contains(&scope.id()) {
192                                // Unfiltered inventory preserves pending
193                                // class/declaration/import/delete events.
194                                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                                // Merge ALL names before deduplicating this
203                                // owner, not just the first directive's name.
204                                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    /// Admit an actual read against the current prepared root and reference.
231    /// The reference must belong to this root from the same prepared source.
232    /// Earlier callable bodies can write; a later body cannot affect an
233    /// immediate read except on a loop back-edge. Actual named global events
234    /// remain file-wide barriers; read-only directives are not mutations.
235    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                // A deeper named node with the identical span is sufficient:
271                // order depends on this range and enclosing loops, not kind.
272                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        // A completed inventory does not erase a current caller's late stop,
286        // including empty-hazard and known-global-membership return paths.
287        scope_step().then_some(unmutated)
288    }
289
290    /// Imports consume the actual owner's completed export. Declaration-time
291    /// or importer timing cannot dismiss any recorded dynamic hazard.
292    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
306/// Stateless callers share the same complete collector and read evaluator.
307pub 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
318/// Each source module/facade supplies its own completed export inventory.
319pub 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
329/// Collect bounded, per-file module-binding syntax facts.
330///
331/// The module binding walk skips function, lambda and class bodies, but still
332/// visits their headers because decorators and defaults execute in the
333/// enclosing scope. A second iterative walk records `global` declarations and
334/// reflective writes wherever they occur in the module.
335pub 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        // Preserve the file-wide/deferred contract, including its historical
553        // node budget. The reference-time consumer meters classifier internals.
554        match python_module_mutation_hazard_bounded(node, source, || true)? {
555            Some(PythonModuleMutationHazard::Global(names)) => {
556                facts.global_declarations.extend(names);
557            }
558            Some(PythonModuleMutationHazard::Exec) => facts.has_exec = true,
559            Some(PythonModuleMutationHazard::ReflectiveWrite) => facts.has_dynamic_write = true,
560            None => {}
561        }
562        let mut cursor = node.walk();
563        stack.extend(node.named_children(&mut cursor));
564    }
565    facts.wildcard_import_offsets.sort_unstable();
566    Some(facts)
567}
568
569fn node_range(node: Node<'_>, source: &str) -> Range {
570    let start_byte = node.start_byte();
571    let end_byte = node.end_byte();
572    let start_line = source.as_bytes()[..start_byte]
573        .iter()
574        .filter(|byte| **byte == b'\n')
575        .count()
576        + 1;
577    let end_line = source.as_bytes()[..end_byte]
578        .iter()
579        .filter(|byte| **byte == b'\n')
580        .count()
581        + 1;
582    Range {
583        start_byte,
584        end_byte,
585        start_line,
586        end_line,
587    }
588}
589
590fn module_target_names(
591    root: Node<'_>,
592    source: &str,
593    names: &mut HashSet<String>,
594    remaining: &mut usize,
595    keep_going: &mut impl FnMut() -> bool,
596) -> Option<()> {
597    let mut stack = vec![root];
598    while let Some(node) = stack.pop() {
599        if *remaining == 0 || !keep_going() {
600            return None;
601        }
602        *remaining -= 1;
603        match node.kind() {
604            "identifier" => {
605                names.insert(node.utf8_text(source.as_bytes()).ok()?.to_owned());
606            }
607            "attribute" | "subscript" | "call" | "string" | "integer" => {}
608            _ => {
609                let mut cursor = node.walk();
610                stack.extend(node.named_children(&mut cursor));
611            }
612        }
613    }
614    Some(())
615}
616
617fn module_globals_write_call_bounded(
618    call: Node<'_>,
619    source: &str,
620    mut scope_step: impl FnMut() -> bool,
621) -> Option<bool> {
622    if !scope_step() {
623        return None;
624    }
625    let function = call.child_by_field_name("function");
626    let Some(function) = function else {
627        return Some(false);
628    };
629    if function.kind() == "attribute" {
630        let method = function.child_by_field_name("attribute");
631        let object = function.child_by_field_name("object");
632        if method.is_some_and(|method| {
633            method
634                .utf8_text(source.as_bytes())
635                .ok()
636                .is_some_and(|name| matches!(name, "update" | "setdefault" | "pop" | "clear"))
637        }) && object.is_some_and(|object| is_module_mapping_call(object, source))
638        {
639            return Some(true);
640        }
641    }
642    if !is_module_mapping_call(call, source) {
643        return Some(false);
644    }
645    let mut current = call;
646    while let Some(parent) = current.parent() {
647        if !scope_step() {
648            return None;
649        }
650        match parent.kind() {
651            "assignment" | "augmented_assignment" | "annotated_assignment" => {
652                return Some(parent.child_by_field_name("left").is_some_and(|left| {
653                    left.start_byte() <= call.start_byte() && call.end_byte() <= left.end_byte()
654                }));
655            }
656            "delete_statement" => return Some(true),
657            "subscript" | "attribute" => current = parent,
658            _ => return Some(false),
659        }
660    }
661    Some(false)
662}
663
664fn is_module_mapping_call(node: Node<'_>, source: &str) -> bool {
665    if node.kind() != "call"
666        || !node
667            .child_by_field_name("arguments")
668            .is_some_and(|arguments| {
669                let mut cursor = arguments.walk();
670                arguments.named_children(&mut cursor).next().is_none()
671            })
672    {
673        return false;
674    }
675    node.child_by_field_name("function")
676        .is_some_and(|function| {
677            function.kind() == "identifier"
678                && function
679                    .utf8_text(source.as_bytes())
680                    .ok()
681                    .is_some_and(|name| matches!(name, "globals" | "vars"))
682        })
683}
684
685#[derive(Debug, Clone)]
686pub struct PythonFileSourceFacts {
687    pub occurrences: SourceFactRows,
688    pub facts: PythonSourceFacts,
689}
690
691impl PythonFileSourceFacts {
692    /// Select the canonical callable for one exact current-syntax focus.
693    pub fn callable_return_for_range(
694        &self,
695        start: usize,
696        end: usize,
697        keep_going: &dyn Fn() -> bool,
698    ) -> Option<&brokk_bifrost_core::analyzer::python_facts::PythonCallableReturnFact> {
699        for fact in &self.facts.callable_returns {
700            if !keep_going() {
701                return None;
702            }
703            let declaration = self.occurrences.declaration(fact.declaration);
704            let range = self.occurrences.occurrence(declaration.occurrence).range;
705            if range.start_byte == start && range.end_byte == end {
706                return Some(fact);
707            }
708        }
709        None
710    }
711
712    pub fn estimated_retained_bytes(&self) -> usize {
713        self.occurrences
714            .estimated_bytes()
715            .saturating_add(self.facts.estimated_retained_bytes())
716    }
717}
718
719pub trait PythonSourceFactProvider: Send + Sync {
720    /// Missing publication is unavailable; a published empty file returns Some.
721    /// `keep_going` is the caller's admission and traversal continuation check.
722    fn python_source_facts(
723        &self,
724        token: QueryToken<'_>,
725        file: &ProjectFile,
726        keep_going: &dyn Fn() -> bool,
727    ) -> Option<Arc<PythonFileSourceFacts>>;
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use brokk_bifrost_test_macros::hardwon;
734
735    fn module_facts(source: &str) -> PythonModuleGlobalSourceFacts {
736        let mut parser = tree_sitter::Parser::new();
737        parser
738            .set_language(&tree_sitter_python::LANGUAGE.into())
739            .expect("Python grammar");
740        let tree = parser.parse(source, None).expect("parse fixture");
741        python_module_global_source_facts(tree.root_node(), source, 100_000, || true)
742            .expect("complete Python module facts")
743    }
744
745    fn initializer<'a>(
746        facts: &PythonModuleGlobalSourceFacts,
747        source: &'a str,
748        name: &str,
749    ) -> Option<&'a str> {
750        let range = facts.initializer_range(name)?;
751        source.get(range.start_byte..range.end_byte)
752    }
753
754    #[test]
755    #[hardwon(
756        "#4622+#5745: module mutation and stopped scans cannot prove unchanged initializer classes"
757    )]
758    fn module_global_source_fact_requires_a_single_safe_assignment() {
759        let source = "class Target: pass\n\
760safe = Target()\n\
761items = [safe for safe in ()]\n\
762if True:\n    nested_safe = Target()\n";
763        let facts = module_facts(source);
764        assert_eq!(
765            Some("Target()"),
766            initializer(&facts, source, "safe"),
767            "{facts:#?}"
768        );
769        assert_eq!(Some("Target()"), initializer(&facts, source, "nested_safe"));
770
771        let source = "class Target: pass\n\
772global_write = Target()\n\
773def change():\n    global global_write\n    global_write = None\n";
774        assert_eq!(
775            None,
776            initializer(&module_facts(source), source, "global_write")
777        );
778
779        let source = "class Target: pass\n\
780twice = Target()\n\
781twice = Target()\n";
782        assert_eq!(None, initializer(&module_facts(source), source, "twice"));
783
784        let source = "class Target: pass\n\
785dynamic = Target()\n\
786globals()[\"dynamic\"] = None\n";
787        assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
788
789        let source = "class Target: pass\n\
790dynamic = Target()\n\
791vars().update(dynamic=None)\n";
792        assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
793
794        let source = "class Target: pass\n\
795dynamic = Target()\n\
796exec(\"dynamic = None\")\n";
797        assert_eq!(None, initializer(&module_facts(source), source, "dynamic"));
798
799        let deferred_exec =
800            "class Target: pass\nvalue = Target()\ndef later():\n    exec('value = None')\n";
801        assert_eq!(
802            None,
803            initializer(&module_facts(deferred_exec), deferred_exec, "value")
804        );
805
806        // A stopped reference-time scan is not an absence proof. The later
807        // exec stays irrelevant to this immediate read, but not to exports.
808        let bounded_source =
809            "class Base: pass\nclass D(Base): pass\ndef later():\n    exec('Base = object')\n";
810        let mut parser = tree_sitter::Parser::new();
811        parser
812            .set_language(&tree_sitter_python::LANGUAGE.into())
813            .expect("Python grammar");
814        let tree = parser.parse(bounded_source, None).expect("parse fixture");
815        let root = tree.root_node();
816        let reference = root
817            .named_child(1)
818            .unwrap()
819            .child_by_field_name("superclasses")
820            .unwrap()
821            .named_child(0)
822            .unwrap();
823        let mut visits = 0_usize;
824        assert_eq!(
825            Some(true),
826            python_module_binding_unmutated_at_bounded(
827                root,
828                bounded_source,
829                "Base",
830                reference,
831                || {
832                    visits += 1;
833                    true
834                },
835            )
836        );
837        for allowance in 0..visits {
838            let mut remaining = allowance;
839            assert_eq!(
840                None,
841                python_module_binding_unmutated_at_bounded(
842                    root,
843                    bounded_source,
844                    "Base",
845                    reference,
846                    || {
847                        let allowed = remaining > 0;
848                        remaining = remaining.saturating_sub(1);
849                        allowed
850                    },
851                ),
852                "stopping after {allowance} of {visits} visits cannot admit Base"
853            );
854        }
855        assert_eq!(
856            Some(false),
857            python_module_export_unmutated_bounded(root, bounded_source, "Base", || true)
858        );
859
860        // One complete inventory serves all selected names. In particular,
861        // the first directive's owner must contribute its later names too.
862        let multi_source = "class A: pass\nclass B: pass\nclass C: pass\nclass ReadOnly: pass\nclass Sibling: pass\nclass D(A, B, C, ReadOnly, Sibling): pass\ndef writer():\n    global A\n    global B, C\n    global ReadOnly\n    A = None\n    class B(B): pass\n    del C\n    return ReadOnly\ndef sibling():\n    global Sibling\n    from elsewhere import Sibling\n";
863        let tree = parser
864            .parse(multi_source, None)
865            .expect("parse all-name events");
866        let root = tree.root_node();
867        assert!(!root.has_error());
868        let arguments = root
869            .named_child(5)
870            .unwrap()
871            .child_by_field_name("superclasses")
872            .unwrap();
873        let mut collection_visits = 0_usize;
874        let mutation = PythonModuleMutationFacts::collect_bounded(root, multi_source, || {
875            collection_visits += 1;
876            true
877        })
878        .expect("complete mutation inventory");
879        for allowance in 0..collection_visits {
880            let mut remaining = allowance;
881            assert!(
882                PythonModuleMutationFacts::collect_bounded(root, multi_source, || {
883                    let allowed = remaining > 0;
884                    remaining = remaining.saturating_sub(1);
885                    allowed
886                })
887                .is_none(),
888                "partial collection at {allowance} must not publish facts"
889            );
890        }
891        for (index, name, expected) in [
892            (0, "A", false),
893            (1, "B", false),
894            (2, "C", false),
895            (3, "ReadOnly", true),
896            (4, "Sibling", false),
897        ] {
898            let reference = arguments.named_child(index).unwrap();
899            let mut read_visits = 0_usize;
900            assert_eq!(
901                Some(expected),
902                mutation.binding_unmutated_at_bounded(root, name, reference, || {
903                    read_visits += 1;
904                    true
905                }),
906                "all-name inventory: {name}"
907            );
908            assert!(
909                read_visits < collection_visits,
910                "a reused name read must not recollect the module"
911            );
912            assert_eq!(
913                None,
914                mutation.binding_unmutated_at_bounded(root, name, reference, || false),
915                "completed facts cannot override a stopped reader"
916            );
917            for allowance in 0..read_visits {
918                let mut remaining = allowance;
919                assert_eq!(
920                    None,
921                    mutation.binding_unmutated_at_bounded(root, name, reference, || {
922                        let allowed = remaining > 0;
923                        remaining = remaining.saturating_sub(1);
924                        allowed
925                    }),
926                    "{name}: stopped read including final check at {allowance}"
927                );
928            }
929            let mut export_visits = 0_usize;
930            assert_eq!(
931                Some(expected),
932                mutation.export_unmutated_bounded(name, || {
933                    export_visits += 1;
934                    true
935                })
936            );
937            for allowance in 0..export_visits {
938                let mut remaining = allowance;
939                assert_eq!(
940                    None,
941                    mutation.export_unmutated_bounded(name, || {
942                        let allowed = remaining > 0;
943                        remaining = remaining.saturating_sub(1);
944                        allowed
945                    }),
946                    "{name}: stopped export including final check at {allowance}"
947                );
948            }
949        }
950
951        for (label, source, container_index, expected, export_expected) in [
952            (
953                "empty inventory still checks the final stop",
954                "class Base: pass\nclass D(Base): pass\n",
955                1,
956                true,
957                true,
958            ),
959            (
960                "later exec is irrelevant to immediate read",
961                bounded_source,
962                1,
963                true,
964                false,
965            ),
966            (
967                "deferred read includes later exec",
968                "class Base: pass\ndef load():\n    class D(Base): pass\ndef later():\n    exec('Base = object')\n",
969                1,
970                false,
971                false,
972            ),
973            (
974                "later loop hazard can precede next read",
975                "class Base: pass\nfor item in (1, 2):\n    class D(Base): pass\n    exec('Base = object')\n",
976                1,
977                false,
978                false,
979            ),
980            (
981                "earlier callable remains a possible writer",
982                "class Base: pass\ndef earlier():\n    exec('Base = object')\nclass D(Base): pass\n",
983                2,
984                false,
985                false,
986            ),
987            (
988                "later reflective call is irrelevant to immediate read",
989                "class Base: pass\nclass D(Base): pass\nglobals().update(Base=object)\n",
990                1,
991                true,
992                false,
993            ),
994        ] {
995            let tree = parser.parse(source, None).expect("parse hazard ordering");
996            let root = tree.root_node();
997            assert!(!root.has_error(), "{label}");
998            let container = root.named_child(container_index).unwrap();
999            let class = if container.kind() == "class_definition" {
1000                container
1001            } else {
1002                container
1003                    .child_by_field_name("body")
1004                    .unwrap()
1005                    .named_child(0)
1006                    .unwrap()
1007            };
1008            assert_eq!(class.kind(), "class_definition");
1009            let reference = class
1010                .child_by_field_name("superclasses")
1011                .unwrap()
1012                .named_child(0)
1013                .unwrap();
1014            let mutation =
1015                PythonModuleMutationFacts::collect_bounded(root, source, || true).unwrap();
1016            let mut read_visits = 0_usize;
1017            assert_eq!(
1018                Some(expected),
1019                mutation.binding_unmutated_at_bounded(root, "Base", reference, || {
1020                    read_visits += 1;
1021                    true
1022                }),
1023                "{label}"
1024            );
1025            assert_eq!(
1026                Some(export_expected),
1027                mutation.export_unmutated_bounded("Base", || true),
1028                "{label}: completed exports include all hazards"
1029            );
1030            for allowance in 0..read_visits {
1031                let mut remaining = allowance;
1032                assert_eq!(
1033                    None,
1034                    mutation.binding_unmutated_at_bounded(root, "Base", reference, || {
1035                        let allowed = remaining > 0;
1036                        remaining = remaining.saturating_sub(1);
1037                        allowed
1038                    }),
1039                    "{label}: recovery/ancestry/final stop at {allowance}"
1040                );
1041            }
1042        }
1043
1044        // A covering-only recovery cannot stand in for the exact hazard.
1045        // Callers must prevent changed-source reuse; the evaluator still
1046        // refuses rather than treating a missing old site as harmless.
1047        let original_tree = parser.parse(bounded_source, None).unwrap();
1048        let mutation = PythonModuleMutationFacts::collect_bounded(
1049            original_tree.root_node(),
1050            bounded_source,
1051            || true,
1052        )
1053        .unwrap();
1054        let changed_source = "class Base: pass\nclass D(Base): pass\ndef later():\n    longer_call('a longer structured expression at the previous hazard site')\n";
1055        let changed_tree = parser.parse(changed_source, None).unwrap();
1056        let changed_root = changed_tree.root_node();
1057        let changed_reference = changed_root
1058            .named_child(1)
1059            .unwrap()
1060            .child_by_field_name("superclasses")
1061            .unwrap()
1062            .named_child(0)
1063            .unwrap();
1064        assert_eq!(
1065            None,
1066            mutation
1067                .binding_unmutated_at_bounded(changed_root, "Base", changed_reference, || true,)
1068        );
1069
1070        // Runtime namespace mutation requires an event in the directive's
1071        // own scope. Legacy initializer facts intentionally remain stricter.
1072        let read_only_initializer = "class Target: pass\nvalue = Target()\ndef read_only():\n    global value\n    return value\n";
1073        assert_eq!(
1074            None,
1075            initializer(
1076                &module_facts(read_only_initializer),
1077                read_only_initializer,
1078                "value"
1079            )
1080        );
1081        for (label, suffix, expected) in [
1082            (
1083                "read-only directives and unrelated local writer",
1084                "def read_only():\n    global Base\n    global Base\n    return Base\ndef local():\n    Base = 0\n",
1085                true,
1086            ),
1087            ("module directive is inert", "", true),
1088            (
1089                "sibling global assignment",
1090                "def read_only():\n    global Base\n    return Base\ndef change():\n    global Base\n    Base = 0\n",
1091                false,
1092            ),
1093            (
1094                "pending global class binding",
1095                "class Outer:\n    global Base\n    class Base(Base):\n        pass\n",
1096                false,
1097            ),
1098            (
1099                "global function declaration",
1100                "def change():\n    global Base\n    def Base():\n        pass\n",
1101                false,
1102            ),
1103            (
1104                "global import binding",
1105                "def change():\n    global Base\n    from elsewhere import Base\n",
1106                false,
1107            ),
1108            (
1109                "global delete binding",
1110                "def change():\n    global Base\n    del Base\n",
1111                false,
1112            ),
1113            (
1114                "nested directive uses nearest class owner",
1115                "def outer():\n    Base = 0\n    class ReadOnly:\n        global Base\n        value = Base\n",
1116                true,
1117            ),
1118        ] {
1119            // Place the inert module directive before any Base use, so these
1120            // controls also respect Python's directive ordering constraint.
1121            let source = format!("global Base\nclass Base: pass\nclass D(Base): pass\n{suffix}");
1122            let tree = parser
1123                .parse(&source, None)
1124                .expect("parse global event fixture");
1125            let root = tree.root_node();
1126            assert!(!root.has_error(), "{label}: {source}");
1127            let reference = root
1128                .named_child(2)
1129                .unwrap()
1130                .child_by_field_name("superclasses")
1131                .unwrap()
1132                .named_child(0)
1133                .unwrap();
1134            let mut visits = 0_usize;
1135            assert_eq!(
1136                Some(expected),
1137                python_module_binding_unmutated_at_bounded(
1138                    root,
1139                    &source,
1140                    "Base",
1141                    reference,
1142                    || {
1143                        visits += 1;
1144                        true
1145                    }
1146                ),
1147                "{label}"
1148            );
1149            assert_eq!(
1150                Some(expected),
1151                python_module_export_unmutated_bounded(root, &source, "Base", || true),
1152                "{label}: complete-module export uses the same event proof"
1153            );
1154            for allowance in 0..visits {
1155                let mut remaining = allowance;
1156                assert_eq!(
1157                    None,
1158                    python_module_binding_unmutated_at_bounded(
1159                        root,
1160                        &source,
1161                        "Base",
1162                        reference,
1163                        || {
1164                            let allowed = remaining > 0;
1165                            remaining = remaining.saturating_sub(1);
1166                            allowed
1167                        },
1168                    ),
1169                    "{label}: stop after {allowance} of {visits} visits cannot prove no writer"
1170                );
1171            }
1172        }
1173
1174        let source = "class Target: pass\n\
1175wild = Target()\n\
1176from elsewhere import *\n";
1177        assert_eq!(None, initializer(&module_facts(source), source, "wild"));
1178
1179        let source = "class Target: pass\n\
1180annotated: object = Target()\n\
1181augmented = Target()\n\
1182augmented += Target()\n\
1183deleted = Target()\n\
1184del deleted\n\
1185for loop_target in ():\n    pass\n\
1186with manager as with_target:\n    pass\n\
1187walrus = None\n\
1188(walrus := Target())\n\
1189import pathlib as imported\n\
1190from os import path as from_imported\n\
1191def declared():\n    pass\n\
1192class Declared: pass\n";
1193        let facts = module_facts(source);
1194        for name in [
1195            "annotated",
1196            "augmented",
1197            "deleted",
1198            "loop_target",
1199            "with_target",
1200            "walrus",
1201            "imported",
1202            "from_imported",
1203            "declared",
1204            "Declared",
1205        ] {
1206            assert_eq!(
1207                None,
1208                initializer(&facts, source, name),
1209                "{name}: {facts:#?}"
1210            );
1211        }
1212    }
1213}