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