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