Skip to main content

brokk_bifrost_python/
runtime_values.rs

1//! Bounded syntax evidence for Python process-input keyed reads.
2//!
3//! CPython publishes the process environment as `os.environ` and the launch
4//! argument vector as `sys.argv`. Both are module attributes, so the read's
5//! identity is the import binder plus the lexical scope, never the spelling of
6//! the root name. This module reports what the syntax proves; the reviewed
7//! runtime model in `brokk-bifrost-analysis` owns what the read *means*, and
8//! the executable semantics own the load it joins.
9//!
10//! Every pass is iterative and bounded. An exhausted budget reports an
11//! incomplete result rather than a clean one.
12
13use brokk_bifrost_core::analyzer::Range;
14use brokk_bifrost_core::analyzer::semantic_diagnostics::contains_node as covers;
15use brokk_bifrost_core::analyzer::structural::spec::StructuralSpec;
16use std::collections::{HashMap, HashSet};
17use tree_sitter::Node;
18
19use crate::bindings::{
20    PythonDirectScopeBindingKind, PythonLexicalNameResolution, PythonLexicalScopeInventory,
21    python_direct_scope_bindings_bounded, python_executable_scope_children,
22    python_name_resolution_at,
23};
24use crate::imports::python_import_bindings_from_tree;
25use crate::syntax::{python_parameter_name, python_plain_string_literal};
26
27/// The reviewed process-input containers this syntax pass looks for.
28///
29/// The pair bounds the traversal so an ordinary subscript never becomes a
30/// fact. It is not an identity claim: a read is reported only after the import
31/// binder proves its root names the standard-library module, and only the
32/// activated runtime model decides whether the container is attacker
33/// controlled.
34const PROCESS_INPUT_CONTAINERS: [(&str, &str); 2] = [("os", "environ"), ("sys", "argv")];
35
36/// A statically nameable Python access key.
37///
38/// Dynamic and unsupported forms are retained as facts so a caller preserves
39/// an incomplete answer instead of silently dropping the access.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub enum PythonRuntimeAccessKey {
42    /// A plain string-literal subscript, such as `os.environ["HOME"]`.
43    Property(String),
44    /// A non-negative integer-literal subscript, such as `sys.argv[1]`.
45    Index(u128),
46    /// The subscript is computed, so the read names no static key.
47    Dynamic,
48    /// The subscript is a slice, a tuple, or another shape whose value is not
49    /// one modeled element.
50    Unsupported,
51}
52
53/// How the root identifier of one access path resolves at its use site.
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum PythonRuntimeRootResolution {
56    /// The module scope binds the root exactly once, through an import of the
57    /// named standard-library module, and no enclosing function rebinds it.
58    ImportedModule,
59    /// A parameter, local, or other binding covers the root at the use site,
60    /// so this occurrence is not the module attribute.
61    LexicallyBound,
62    /// The binder evidence is insufficient to decide the root.
63    Unknown,
64}
65
66/// What this module proves about writes to the reviewed container.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum PythonRuntimeMutationEvidence {
69    NoKnownWrite,
70    KnownWrite,
71    KnownWriteAndUnknownEffects,
72    UnknownEffects,
73}
74
75/// Whether anything that can run before one read could replace the container
76/// or intercept the access.
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum PythonRuntimeAccessorCoverage {
79    NoKnownAccessorEffects,
80    UnknownAccessorEffects,
81}
82
83/// The execution context one read belongs to.
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum PythonRuntimeExecutionContext {
86    Module,
87    DeferredFunction(Range),
88}
89
90/// Why one occurrence of the reviewed path is not a plain keyed read.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum PythonRuntimeWriteTarget {
93    /// The container itself is assigned or deleted (`sys.argv = [...]`).
94    Container,
95    /// One key of the container is assigned or deleted.
96    Key,
97    /// The container value reaches a position this pass cannot follow: a
98    /// method receiver, a call argument, an alias, or a return.
99    ContainerEscape,
100    /// The module object itself reaches such a position, so an unseen writer
101    /// can replace any of its attributes.
102    ModuleEscape,
103}
104
105/// One occurrence of a reviewed path that is not a plain keyed read.
106#[derive(Clone, Debug, Eq, PartialEq)]
107pub struct PythonRuntimeWrite {
108    /// The absolute module the import binder proved for the root.
109    pub root_name: String,
110    /// The reviewed container this write can reach, when the occurrence names
111    /// one. A module escape reaches every container of its module.
112    pub container: Option<String>,
113    pub range: Range,
114    pub target: PythonRuntimeWriteTarget,
115}
116
117/// One keyed read of a reviewed process-input container.
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct PythonRuntimeRead {
120    /// The root identifier as written. Evidence, never an identity claim.
121    pub binding_name: String,
122    /// The absolute module the import binder proved for the root.
123    pub root_name: String,
124    pub container: String,
125    pub access: PythonRuntimeAccessKey,
126    /// The whole subscript expression, where the executable load sits.
127    pub range: Range,
128    pub root_range: Range,
129    /// The `os.environ` attribute node. This is both the container load and
130    /// the structural seed an RQL field-access query addresses, because a
131    /// Python subscript read is anchored on the base of its access chain.
132    pub container_range: Range,
133    pub candidate_anchor: Range,
134    pub key_range: Range,
135    pub lexical_resolution: PythonRuntimeRootResolution,
136    pub mutation: PythonRuntimeMutationEvidence,
137    pub execution: PythonRuntimeExecutionContext,
138    /// Accessor and effect coverage scoped to this read's own execution
139    /// context and ordered against it. A call that cannot run before the read
140    /// does not poison it.
141    pub accessor: PythonRuntimeAccessorCoverage,
142}
143
144/// Everything one bounded pass over one Python module proves.
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct PythonRuntimeReadFacts {
147    pub reads: Vec<PythonRuntimeRead>,
148    pub writes: Vec<PythonRuntimeWrite>,
149    /// Number of AST nodes inspected across the bounded passes.
150    pub visited_nodes: usize,
151    /// False only when a bounded traversal could not visit the full tree.
152    /// Dynamic keys, writes, and unknown effects stay represented in rows.
153    pub complete: bool,
154}
155
156/// Shared node budget for the bounded passes.
157#[derive(Debug)]
158struct PythonRuntimeBudget {
159    remaining: usize,
160    exhausted: bool,
161    visited_nodes: usize,
162}
163
164impl PythonRuntimeBudget {
165    /// The node bound keeps a large tree with few matching facts from making
166    /// extraction unbounded. The multiplier mirrors the JavaScript pass.
167    fn for_facts(max_facts: usize) -> Self {
168        Self {
169            remaining: max_facts.saturating_mul(64).max(4096),
170            exhausted: false,
171            visited_nodes: 0,
172        }
173    }
174
175    fn visit(&mut self) -> bool {
176        if self.remaining == 0 {
177            self.exhausted = true;
178            return false;
179        }
180        self.remaining -= 1;
181        self.visited_nodes += 1;
182        true
183    }
184}
185
186/// One import binding that covers a byte offset.
187struct ModuleBinding {
188    local_name: String,
189    /// The top-level package the local name binds.
190    root_module: String,
191    scope_start_byte: usize,
192    scope_end_byte: usize,
193    function_scoped: bool,
194}
195
196/// Report the reviewed process-input keyed reads and the writes that bound
197/// their pristine-input claim.
198///
199/// `max_facts` bounds emitted rows; the derived node bound stops the pass on a
200/// tree large enough to make either traversal unbounded.
201pub fn extract_python_runtime_reads(
202    root: Node<'_>,
203    source: &str,
204    max_facts: usize,
205) -> PythonRuntimeReadFacts {
206    let mut budget = PythonRuntimeBudget::for_facts(max_facts);
207    let bindings = module_bindings(root, source);
208    let mut reads = Vec::new();
209    let mut writes = Vec::new();
210    let mut stack = vec![root];
211    while let Some(node) = stack.pop() {
212        if !budget.visit() {
213            break;
214        }
215        if node.kind() == "attribute"
216            && reads.len() + writes.len() < max_facts
217            && let Some(path) = reviewed_attribute_path(node, source)
218        {
219            let resolution = root_resolution(
220                path.root,
221                &path.root_module,
222                &bindings,
223                root,
224                source,
225                &mut budget,
226            );
227            if resolution == PythonRuntimeRootResolution::ImportedModule {
228                classify_container_occurrence(
229                    node,
230                    &path,
231                    source,
232                    root,
233                    &mut budget,
234                    &mut reads,
235                    &mut writes,
236                );
237            }
238        }
239        if node.kind() == "identifier"
240            && writes.len() + reads.len() < max_facts
241            && let Some(module) =
242                escaping_module_identifier(node, &bindings, root, source, &mut budget)
243        {
244            writes.push(PythonRuntimeWrite {
245                root_name: module,
246                container: None,
247                range: node_source_range(node),
248                target: PythonRuntimeWriteTarget::ModuleEscape,
249            });
250        }
251        let mut cursor = node.walk();
252        stack.extend(node.named_children(&mut cursor));
253    }
254    // A write anywhere in this module refutes the pristine-input claim of
255    // every read of the same container, wherever the read is written.
256    for read in &mut reads {
257        let known_write = writes.iter().any(|write| {
258            write.root_name == read.root_name
259                && write
260                    .container
261                    .as_ref()
262                    .is_none_or(|container| *container == read.container)
263        });
264        read.mutation = match (known_write, read.accessor) {
265            (false, PythonRuntimeAccessorCoverage::NoKnownAccessorEffects) => {
266                PythonRuntimeMutationEvidence::NoKnownWrite
267            }
268            (false, PythonRuntimeAccessorCoverage::UnknownAccessorEffects) => {
269                PythonRuntimeMutationEvidence::UnknownEffects
270            }
271            (true, PythonRuntimeAccessorCoverage::NoKnownAccessorEffects) => {
272                PythonRuntimeMutationEvidence::KnownWrite
273            }
274            (true, PythonRuntimeAccessorCoverage::UnknownAccessorEffects) => {
275                PythonRuntimeMutationEvidence::KnownWriteAndUnknownEffects
276            }
277        };
278    }
279    reads.sort_by_key(|read| read.range.start_byte);
280    writes.sort_by_key(|write| write.range.start_byte);
281    PythonRuntimeReadFacts {
282        complete: !budget.exhausted && reads.len() + writes.len() < max_facts,
283        visited_nodes: budget.visited_nodes,
284        reads,
285        writes,
286    }
287}
288
289/// The structural node that represents one reviewed keyed read.
290///
291/// A Python subscript expression has no structural fact of its own, so the
292/// read is addressed by the base of its access chain: the
293/// `<module>.<container>` attribute. This is the same seed the extractor
294/// publishes as `candidate_anchor`, so a query and the executable load agree
295/// on one identity. A node that is not a reviewed keyed read has no seed.
296pub fn python_runtime_keyed_access_seed<'tree>(node: Node<'tree>) -> Option<Node<'tree>> {
297    if node.kind() != "subscript" {
298        return None;
299    }
300    let base = node.child_by_field_name("value")?;
301    if base.kind() != "attribute" {
302        return None;
303    }
304    let attribute = base.child_by_field_name("attribute")?;
305    let object = base.child_by_field_name("object")?;
306    if object.kind() != "identifier" || attribute.kind() != "identifier" {
307        return None;
308    }
309    Some(base)
310}
311
312/// One reviewed `<module>.<container>` attribute occurrence.
313struct ReviewedPath<'tree> {
314    root: Node<'tree>,
315    root_module: String,
316    container: String,
317}
318
319/// Recognize `<identifier>.<container>` for one reviewed pair. Deeper paths
320/// (`pkg.os.environ`) have a non-identifier object and are not this module.
321fn reviewed_attribute_path<'tree>(node: Node<'tree>, source: &str) -> Option<ReviewedPath<'tree>> {
322    let object = node.child_by_field_name("object")?;
323    let attribute = node.child_by_field_name("attribute")?;
324    if object.kind() != "identifier" || attribute.kind() != "identifier" {
325        return None;
326    }
327    let container = node_text(attribute, source);
328    let (root_module, _) = PROCESS_INPUT_CONTAINERS
329        .iter()
330        .find(|(_, reviewed)| *reviewed == container)?;
331    Some(ReviewedPath {
332        root: object,
333        root_module: (*root_module).to_owned(),
334        container: container.to_owned(),
335    })
336}
337
338/// Decide whether one reviewed container occurrence is a keyed read, a write,
339/// or an escape, and record the matching row.
340fn classify_container_occurrence<'tree>(
341    attribute: Node<'tree>,
342    path: &ReviewedPath<'tree>,
343    source: &str,
344    module_root: Node<'tree>,
345    budget: &mut PythonRuntimeBudget,
346    reads: &mut Vec<PythonRuntimeRead>,
347    writes: &mut Vec<PythonRuntimeWrite>,
348) {
349    let write = |target| PythonRuntimeWrite {
350        root_name: path.root_module.clone(),
351        container: Some(path.container.clone()),
352        range: node_source_range(attribute),
353        target,
354    };
355    let Some(parent) = attribute.parent() else {
356        writes.push(write(PythonRuntimeWriteTarget::ContainerEscape));
357        return;
358    };
359    if parent.kind() != "subscript"
360        || parent
361            .child_by_field_name("value")
362            .is_none_or(|value| value.id() != attribute.id())
363    {
364        // Every other position -- a method receiver such as
365        // `os.environ.update(...)`, an argument, an alias, an assignment
366        // target, a `del` -- can replace or observe the container, so the
367        // pristine-input claim of every read of it is refuted.
368        writes.push(write(if is_binding_target(attribute) {
369            PythonRuntimeWriteTarget::Container
370        } else {
371            PythonRuntimeWriteTarget::ContainerEscape
372        }));
373        return;
374    }
375    if is_binding_target(parent) {
376        writes.push(write(PythonRuntimeWriteTarget::Key));
377        return;
378    }
379    let Some(subscript) = parent.child_by_field_name("subscript") else {
380        writes.push(write(PythonRuntimeWriteTarget::ContainerEscape));
381        return;
382    };
383    // The grammar repeats the `subscript` field for comma-separated keys;
384    // even a trailing comma makes a tuple key rather than a scalar index.
385    let mut cursor = parent.walk();
386    let access = if parent
387        .children(&mut cursor)
388        .any(|child| child.kind() == ",")
389    {
390        PythonRuntimeAccessKey::Unsupported
391    } else {
392        access_key(subscript, source)
393    };
394    let read_range = node_source_range(parent);
395    let execution = match enclosing_execution_context(attribute) {
396        Some(function) => {
397            PythonRuntimeExecutionContext::DeferredFunction(node_source_range(function))
398        }
399        None => PythonRuntimeExecutionContext::Module,
400    };
401    let accessor = execution_context_accessor_coverage(attribute, module_root, read_range, budget);
402    reads.push(PythonRuntimeRead {
403        binding_name: node_text(path.root, source).to_owned(),
404        root_name: path.root_module.clone(),
405        container: path.container.clone(),
406        access,
407        range: read_range,
408        root_range: node_source_range(path.root),
409        container_range: node_source_range(attribute),
410        candidate_anchor: node_source_range(attribute),
411        key_range: node_source_range(subscript),
412        lexical_resolution: PythonRuntimeRootResolution::ImportedModule,
413        // Replaced by the module-wide join once every write is known.
414        mutation: PythonRuntimeMutationEvidence::UnknownEffects,
415        execution,
416        accessor,
417    });
418}
419
420/// The static key one subscript names.
421fn access_key(subscript: Node<'_>, source: &str) -> PythonRuntimeAccessKey {
422    if let Some(literal) = python_plain_string_literal(subscript, source) {
423        return PythonRuntimeAccessKey::Property(literal.to_owned());
424    }
425    match subscript.kind() {
426        "integer" => crate::structural::PYTHON_STRUCTURAL_SPEC
427            .integer_literal_value(subscript, source)
428            .and_then(|value| value.decimal)
429            .and_then(|decimal| decimal.parse::<u128>().ok())
430            .map_or(PythonRuntimeAccessKey::Unsupported, |index| {
431                PythonRuntimeAccessKey::Index(index)
432            }),
433        "slice" | "tuple" | "list_splat" => PythonRuntimeAccessKey::Unsupported,
434        "string" | "concatenated_string" => PythonRuntimeAccessKey::Unsupported,
435        _ => PythonRuntimeAccessKey::Dynamic,
436    }
437}
438
439/// Whether this node is written rather than read: an assignment target, an
440/// augmented-assignment target, a `del` operand, or a `for` target.
441fn is_binding_target(node: Node<'_>) -> bool {
442    let mut current = node;
443    while let Some(parent) = current.parent() {
444        match parent.kind() {
445            "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => {
446                return parent
447                    .child_by_field_name("left")
448                    .is_some_and(|left| left.id() == current.id());
449            }
450            "delete_statement" => return true,
451            "pattern_list" | "tuple_pattern" | "list_pattern" | "list_splat_pattern" => {
452                current = parent;
453            }
454            _ => return false,
455        }
456    }
457    false
458}
459
460/// The innermost function or lambda whose body executes this node, or `None`
461/// when the node is outside a function or lambda body.
462pub(crate) fn enclosing_execution_context<'tree>(node: Node<'tree>) -> Option<Node<'tree>> {
463    let mut current = node;
464    while let Some(parent) = current.parent() {
465        if matches!(parent.kind(), "function_definition" | "lambda")
466            && parent
467                .child_by_field_name("body")
468                .is_some_and(|body| covers(body, current))
469        {
470            return Some(parent);
471        }
472        current = parent;
473    }
474    None
475}
476
477/// Whether anything that can run before `read_range` inside this read's own
478/// execution context could replace the container or intercept the access.
479///
480/// Every call is treated as such a hazard. Python has no syntax that proves a
481/// callee cannot reach `os.environ`, so a call the read cannot be ordered
482/// after leaves the read typed incomplete rather than clean.
483fn execution_context_accessor_coverage(
484    attribute: Node<'_>,
485    module_root: Node<'_>,
486    read_range: Range,
487    budget: &mut PythonRuntimeBudget,
488) -> PythonRuntimeAccessorCoverage {
489    let scope = enclosing_execution_context(attribute)
490        .and_then(|function| function.child_by_field_name("body"))
491        .unwrap_or(module_root);
492    let mut stack = vec![scope];
493    while let Some(node) = stack.pop() {
494        if !budget.visit() {
495            return PythonRuntimeAccessorCoverage::UnknownAccessorEffects;
496        }
497        if matches!(node.kind(), "function_definition" | "lambda") {
498            // A nested body is a separate execution context. Its header still
499            // runs here, so only the body is skipped.
500            let mut cursor = node.walk();
501            stack.extend(node.named_children(&mut cursor).filter(|child| {
502                Some(child.id()) != node.child_by_field_name("body").map(|body| body.id())
503            }));
504            continue;
505        }
506        if node.kind() == "call"
507            && python_hazard_can_precede_bounded(node_source_range(node), read_range, node, || {
508                budget.visit()
509            }) != Some(false)
510        {
511            return PythonRuntimeAccessorCoverage::UnknownAccessorEffects;
512        }
513        let mut cursor = node.walk();
514        stack.extend(node.named_children(&mut cursor));
515    }
516    PythonRuntimeAccessorCoverage::NoKnownAccessorEffects
517}
518
519/// Whether `hazard` can execute before a read at `read_range`.
520///
521/// Source order decides within one straight-line context. A call that ends
522/// before the read starts has run before it. A call that encloses the read,
523/// such as `os.system(os.environ["K"])`, has not: its callee and arguments are
524/// evaluated first, and the call itself runs after the read's value exists.
525/// A hazard inside a loop that also contains the read can run before it on a
526/// later iteration, so the loop is checked structurally rather than by
527/// position alone.
528pub(crate) fn python_hazard_can_precede_bounded(
529    hazard: Range,
530    read_range: Range,
531    hazard_node: Node<'_>,
532    mut scope_step: impl FnMut() -> bool,
533) -> Option<bool> {
534    if !scope_step() {
535        return None;
536    }
537    if hazard.end_byte <= read_range.start_byte {
538        return Some(true);
539    }
540    let mut current = hazard_node;
541    while let Some(parent) = current.parent() {
542        if !scope_step() {
543            return None;
544        }
545        if matches!(parent.kind(), "for_statement" | "while_statement")
546            && parent.start_byte() <= read_range.start_byte
547            && read_range.end_byte <= parent.end_byte()
548        {
549            return Some(true);
550        }
551        current = parent;
552    }
553    Some(false)
554}
555
556/// The module whose object escapes at this identifier, when one does.
557///
558/// A module object is shared process-wide, so any position other than the
559/// object of an attribute access can carry it to a writer this pass cannot
560/// see. Its own import statement is the binder, not a use.
561fn escaping_module_identifier(
562    node: Node<'_>,
563    bindings: &[ModuleBinding],
564    module_root: Node<'_>,
565    source: &str,
566    budget: &mut PythonRuntimeBudget,
567) -> Option<String> {
568    let name = node_text(node, source);
569    let module = binding_root_module(node, name, bindings)?;
570    // A parameter or local that happens to spell the module name is not the
571    // module object, so it cannot carry it anywhere. The same three facts that
572    // decide a read decide this.
573    if root_resolution(node, &module, bindings, module_root, source, budget)
574        != PythonRuntimeRootResolution::ImportedModule
575    {
576        return None;
577    }
578    let parent = node.parent()?;
579    if parent.kind() == "attribute"
580        && parent
581            .child_by_field_name("object")
582            .is_some_and(|object| object.id() == node.id())
583    {
584        return None;
585    }
586    if matches!(
587        parent.kind(),
588        "import_statement" | "import_from_statement" | "aliased_import" | "dotted_name"
589    ) {
590        return None;
591    }
592    Some(module)
593}
594
595/// The top-level module one in-scope import binding gives `name`, when every
596/// in-scope binding of the name agrees on it.
597fn binding_root_module(
598    reference: Node<'_>,
599    name: &str,
600    bindings: &[ModuleBinding],
601) -> Option<String> {
602    let offset = reference.start_byte();
603    let mut module = None;
604    for binding in bindings {
605        if binding.local_name != name
606            || offset < binding.scope_start_byte
607            || offset > binding.scope_end_byte
608        {
609            continue;
610        }
611        match &module {
612            None => module = Some(binding.root_module.clone()),
613            Some(existing) if *existing == binding.root_module => {}
614            Some(_) => return None,
615        }
616    }
617    module.filter(|module| {
618        PROCESS_INPUT_CONTAINERS
619            .iter()
620            .any(|(reviewed, _)| reviewed == module)
621    })
622}
623
624/// Resolve the root identifier of one reviewed path.
625///
626/// Three independent structured facts have to agree before an occurrence is
627/// the module attribute: no enclosing function binds the name, the module
628/// scope binds it through imports alone, and every in-scope import binding
629/// names the same reviewed package.
630fn root_resolution(
631    root: Node<'_>,
632    expected_module: &str,
633    bindings: &[ModuleBinding],
634    module_root: Node<'_>,
635    source: &str,
636    budget: &mut PythonRuntimeBudget,
637) -> PythonRuntimeRootResolution {
638    let name = node_text(root, source);
639    match python_name_resolution_at(name, root, source) {
640        PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal => {
641            // A function-scoped import binds the name locally and still names
642            // the module. This pass does not carry that proof, so the root
643            // stays undecided rather than conclusively excluded.
644            return if bindings
645                .iter()
646                .any(|binding| binding.local_name == name && binding.function_scoped)
647            {
648                PythonRuntimeRootResolution::Unknown
649            } else {
650                PythonRuntimeRootResolution::LexicallyBound
651            };
652        }
653        PythonLexicalNameResolution::Global | PythonLexicalNameResolution::Unbound => {}
654    }
655    match module_scope_binds_name_through_imports(module_root, name, source, budget) {
656        Some(true) => {}
657        Some(false) => return PythonRuntimeRootResolution::LexicallyBound,
658        None => return PythonRuntimeRootResolution::Unknown,
659    }
660    match binding_root_module(root, name, bindings) {
661        Some(module) if module == expected_module => PythonRuntimeRootResolution::ImportedModule,
662        Some(_) => PythonRuntimeRootResolution::LexicallyBound,
663        None => PythonRuntimeRootResolution::Unknown,
664    }
665}
666
667/// Whether the module scope binds `name`, and binds it through imports alone.
668///
669/// `Some(false)` says something other than an import binds the name, so the
670/// occurrence is not the module object. `None` says the bounded walk could not
671/// decide, which is not an exclusion.
672fn module_scope_binds_name_through_imports(
673    module_root: Node<'_>,
674    name: &str,
675    source: &str,
676    budget: &mut PythonRuntimeBudget,
677) -> Option<bool> {
678    let mut imported = false;
679    let mut stack = vec![module_root];
680    while let Some(node) = stack.pop() {
681        if !budget.visit() {
682            return None;
683        }
684        if node.kind() == "wildcard_import" {
685            return None;
686        }
687        for binding in python_direct_scope_bindings_bounded(node, source, || budget.visit())? {
688            if node_text(binding.declaration, source) != name {
689                continue;
690            }
691            match binding.kind {
692                PythonDirectScopeBindingKind::Import => imported = true,
693                _ => return Some(false),
694            }
695        }
696        // A nested callable or class body is a separate scope; its header
697        // still executes here.
698        stack.extend(python_executable_scope_children(node));
699    }
700    Some(imported)
701}
702
703/// The import bindings of this module that can name a reviewed module, with
704/// every name the module scope binds more than once removed.
705///
706/// `import os` and `import os.path` both bind the package `os`, so they agree.
707/// A later `os = ...`, a `def os`, or a `from x import os` does not, and the
708/// name then names no proven module.
709fn module_bindings(root: Node<'_>, source: &str) -> Vec<ModuleBinding> {
710    let mut bindings = Vec::new();
711    for binding in python_import_bindings_from_tree(root, source) {
712        let Some(root_module) = binding.qualified_name.split('.').next() else {
713            continue;
714        };
715        if !PROCESS_INPUT_CONTAINERS
716            .iter()
717            .any(|(reviewed, _)| *reviewed == root_module)
718        {
719            continue;
720        }
721        // A `from os import environ` binds the member, not the module object.
722        if binding.qualified_name != root_module && binding.consumed_attributes == 0 {
723            continue;
724        }
725        bindings.push(ModuleBinding {
726            local_name: binding.local_name.clone(),
727            root_module: root_module.to_owned(),
728            scope_start_byte: binding.scope_start_byte,
729            scope_end_byte: binding.scope_end_byte,
730            function_scoped: binding.is_function_scoped(),
731        });
732    }
733    bindings
734}
735
736use brokk_bifrost_core::analyzer::usages::reference_site::node_range as node_source_range;
737
738fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
739    brokk_bifrost_core::analyzer::common::try_node_source_text(node, source)
740        .expect("a tree-sitter node range is valid UTF-8 source")
741}
742
743/// Why one Python module's member value may not be the module's own at a call.
744#[derive(Clone, Debug, Eq, PartialEq)]
745pub enum PythonModuleMemberMutation {
746    /// A write-shaped target names the member, or names it dynamically.
747    Write(Range),
748    /// The module object reaches a position this file-local walk cannot
749    /// follow, so an unseen writer can replace any of its members.
750    Escape(Range),
751    /// The bounded walk did not finish, so this file proves nothing.
752    Budget,
753}
754
755/// What in this file can replace `module.member`, or `None` when nothing can.
756///
757/// An imported module object is shared process-wide, and Python permits
758/// replacing one of its attributes (`os.system = fake`). A reviewed summary
759/// describes the standard-library implementation, not a value written over it,
760/// so a consumer that closes a call on that summary must first prove no
761/// workspace source can perform the replacement. Two evidence families refuse
762/// it, and both fail closed:
763///
764///  1. A write-shaped target -- an assignment, an augmented assignment, or a
765///     `del` -- whose attribute access resolves to the module. A statically
766///     named attribute refuses that member; `setattr` and every other dynamic
767///     form arrives through the escape rule below.
768///  2. Any use of a name bound to the module outside its own import statement
769///     and its attribute-object position. An alias, a call argument, a return,
770///     or a subscript can carry the shared module object to a writer this walk
771///     cannot see, so the proof refuses every member instead of following the
772///     value. A `sys.modules` read is classified by its structured key;
773///     dynamic-key results are possible aliases and use the same alias
774///     handling. A table write matters only when its stored value is or may
775///     be this module.
776///
777/// A file that never binds the module proves the absence vacuously. The walk
778/// is iterative and bounded; exhausting the bound refuses every member.
779pub fn python_module_member_mutation(
780    root: Node<'_>,
781    source: &str,
782    source_module: &str,
783    module: &str,
784    member: &str,
785    max_nodes: usize,
786) -> Option<PythonModuleMemberMutation> {
787    let mut budget = PythonRuntimeBudget {
788        remaining: max_nodes,
789        exhausted: false,
790        visited_nodes: 0,
791    };
792    let bindings = python_import_bindings_from_tree(root, source);
793    let Some(facts) =
794        python_sys_modules_aliases(root, source, source_module, module, &bindings, &mut budget)
795    else {
796        return Some(PythonModuleMemberMutation::Budget);
797    };
798    let table_aliases = &facts.aliases;
799    let locals: Vec<String> = bindings
800        .iter()
801        .filter(|binding| {
802            binding.qualified_name == module
803                || binding
804                    .qualified_name
805                    .split('.')
806                    .next()
807                    .is_some_and(|root_module| {
808                        root_module == module && binding.consumed_attributes > 0
809                    })
810        })
811        .map(|binding| binding.local_name.clone())
812        .collect();
813    let mut stack = vec![root];
814    while let Some(node) = stack.pop() {
815        if !budget.visit() {
816            return Some(PythonModuleMemberMutation::Budget);
817        }
818        if node.kind() == "call"
819            && python_mock_patch_may_replace_module_member(node, source, &bindings, module, member)
820        {
821            return Some(PythonModuleMemberMutation::Write(node_source_range(node)));
822        }
823        if node.kind() == "identifier" {
824            let name = node_text(node, source);
825            if let Some(table) = python_sys_modules_table(node, source) {
826                if python_imported_path(table, source, &bindings)
827                    .is_some_and(|path| path == ["sys", "modules"])
828                {
829                    match python_sys_modules_lookup(
830                        table,
831                        source,
832                        source_module,
833                        module,
834                        &bindings,
835                        &facts,
836                        &mut budget,
837                    ) {
838                        PythonSysModulesLookup::Unrelated => {}
839                        PythonSysModulesLookup::Escape(range) => {
840                            return Some(PythonModuleMemberMutation::Escape(range));
841                        }
842                        PythonSysModulesLookup::Target(read) => {
843                            if let Some(mutation) =
844                                python_sys_modules_read_use(read, source, member, table_aliases)
845                            {
846                                return Some(mutation);
847                            }
848                        }
849                    }
850                } else {
851                    return Some(PythonModuleMemberMutation::Escape(node_source_range(node)));
852                }
853            }
854            let table_alias = table_aliases.contains(name);
855            if table_alias && python_module_table_alias_binding_use(node, source, table_aliases) {
856                // A recognized table lookup binding is followed at its uses.
857            } else if (table_alias
858                || (locals.iter().any(|local| local == name)
859                    && !matches!(
860                        python_name_resolution_at(name, node, source),
861                        PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal
862                    )))
863                && let Some(mutation) =
864                    module_binding_use(node, source, &bindings, table_aliases, module, member)
865            {
866                return Some(mutation);
867            }
868        }
869        let mut cursor = node.walk();
870        stack.extend(node.named_children(&mut cursor));
871    }
872    None
873}
874
875enum PythonSysModulesLookup<'tree> {
876    Unrelated,
877    Target(Node<'tree>),
878    Escape(Range),
879}
880
881/// Return the `sys.modules` attribute when an identifier is its object.
882fn python_sys_modules_table<'tree>(node: Node<'tree>, source: &str) -> Option<Node<'tree>> {
883    let table = node.parent()?;
884    (table.kind() == "attribute"
885        && table
886            .child_by_field_name("object")
887            .is_some_and(|object| object.id() == node.id())
888        && table
889            .child_by_field_name("attribute")
890            .is_some_and(|attribute| node_text(attribute, source) == "modules"))
891    .then_some(table)
892}
893
894fn python_sys_modules_read_table<'tree>(read: Node<'tree>, source: &str) -> Option<Node<'tree>> {
895    if read.kind() == "subscript" {
896        let table = read.child_by_field_name("value")?;
897        return (table.kind() == "attribute"
898            && table
899                .child_by_field_name("attribute")
900                .is_some_and(|attribute| node_text(attribute, source) == "modules"))
901        .then_some(table);
902    }
903    if read.kind() == "call" {
904        let function = read.child_by_field_name("function")?;
905        if function.kind() != "attribute"
906            || function
907                .child_by_field_name("attribute")
908                .is_none_or(|attribute| {
909                    !matches!(node_text(attribute, source), "get" | "pop" | "setdefault")
910                })
911        {
912            return None;
913        }
914        let table = function.child_by_field_name("object")?;
915        return (table.kind() == "attribute"
916            && table
917                .child_by_field_name("attribute")
918                .is_some_and(|attribute| node_text(attribute, source) == "modules"))
919        .then_some(table);
920    }
921    None
922}
923
924fn python_sys_modules_lookup<'tree>(
925    table: Node<'tree>,
926    source: &str,
927    source_module: &str,
928    module: &str,
929    bindings: &[crate::imports::PythonImportBinding],
930    facts: &PythonSysModulesFacts<'tree>,
931    budget: &mut PythonRuntimeBudget,
932) -> PythonSysModulesLookup<'tree> {
933    // The registry maps names to module objects; changing one entry does not
934    // mutate the referenced module's globals. Only storing or producing this
935    // queried module as a value can create another write-through alias.
936    let escaped = || PythonSysModulesLookup::Escape(node_source_range(table));
937    if python_imported_path(table, source, bindings).is_none_or(|path| path != ["sys", "modules"]) {
938        return escaped();
939    }
940    let Some(parent) = table.parent() else {
941        return escaped();
942    };
943    let proof = PythonSysModulesProofContext {
944        source,
945        source_module,
946        module,
947        bindings,
948        facts,
949    };
950    if parent.kind() == "subscript"
951        && parent
952            .child_by_field_name("value")
953            .is_some_and(|value| value.id() == table.id())
954    {
955        if is_binding_target(parent) {
956            if parent
957                .parent()
958                .is_some_and(|parent| parent.kind() == "delete_statement")
959            {
960                return PythonSysModulesLookup::Unrelated;
961            }
962            let Some(value) = parent
963                .parent()
964                .filter(|assignment| assignment.kind() == "assignment")
965                .and_then(|assignment| assignment.child_by_field_name("right"))
966            else {
967                return escaped();
968            };
969            return match python_sys_modules_stored_value(
970                value,
971                &proof,
972                budget,
973                &mut HashSet::default(),
974            ) {
975                PythonSysModulesStoredValue::Unrelated => PythonSysModulesLookup::Unrelated,
976                PythonSysModulesStoredValue::Target | PythonSysModulesStoredValue::Unknown => {
977                    escaped()
978                }
979            };
980        }
981        return python_sys_modules_read_identity(
982            parent.child_by_field_name("subscript"),
983            parent,
984            &proof,
985            budget,
986        );
987    }
988    if parent.kind() == "attribute"
989        && parent
990            .child_by_field_name("object")
991            .is_some_and(|object| object.id() == table.id())
992    {
993        let Some(call) = parent.parent().filter(|call| {
994            call.kind() == "call"
995                && call
996                    .child_by_field_name("function")
997                    .is_some_and(|function| function.id() == parent.id())
998        }) else {
999            return escaped();
1000        };
1001        let method = parent
1002            .child_by_field_name("attribute")
1003            .map(|attribute| node_text(attribute, source));
1004        match method {
1005            Some("get" | "pop") => {
1006                let args = python_sys_modules_arguments(call);
1007                return python_sys_modules_read_identity(
1008                    args.first().copied(),
1009                    call,
1010                    &proof,
1011                    budget,
1012                );
1013            }
1014            Some("setdefault") => {
1015                let args = python_sys_modules_arguments(call);
1016                let default = args
1017                    .get(1)
1018                    .copied()
1019                    .or_else(|| python_sys_modules_keyword_argument(call, source, "default"));
1020                if let Some(default) = default
1021                    && python_sys_modules_stored_value(
1022                        default,
1023                        &proof,
1024                        budget,
1025                        &mut HashSet::default(),
1026                    ) != PythonSysModulesStoredValue::Unrelated
1027                {
1028                    return escaped();
1029                }
1030                return python_sys_modules_read_identity(
1031                    args.first().copied(),
1032                    call,
1033                    &proof,
1034                    budget,
1035                );
1036            }
1037            Some("update") => {
1038                let Some(arguments) = call.child_by_field_name("arguments") else {
1039                    return escaped();
1040                };
1041                let mut cursor = arguments.walk();
1042                for argument in arguments.named_children(&mut cursor) {
1043                    let identity = if argument.kind() == "keyword_argument" {
1044                        argument
1045                            .child_by_field_name("value")
1046                            .map(|value| {
1047                                python_sys_modules_stored_value(
1048                                    value,
1049                                    &proof,
1050                                    budget,
1051                                    &mut HashSet::default(),
1052                                )
1053                            })
1054                            .unwrap_or(PythonSysModulesStoredValue::Unknown)
1055                    } else if argument.kind() == "dictionary_splat" {
1056                        PythonSysModulesStoredValue::Unknown
1057                    } else {
1058                        python_sys_modules_update_values(
1059                            argument,
1060                            &proof,
1061                            budget,
1062                            &mut HashSet::default(),
1063                        )
1064                    };
1065                    if identity != PythonSysModulesStoredValue::Unrelated {
1066                        return escaped();
1067                    }
1068                }
1069                return PythonSysModulesLookup::Unrelated;
1070            }
1071            Some("clear" | "keys") => return PythonSysModulesLookup::Unrelated,
1072            _ => return escaped(),
1073        }
1074    }
1075    escaped()
1076}
1077
1078fn python_sys_modules_arguments(call: Node<'_>) -> Vec<Node<'_>> {
1079    let Some(arguments) = call.child_by_field_name("arguments") else {
1080        return Vec::new();
1081    };
1082    let mut cursor = arguments.walk();
1083    arguments
1084        .named_children(&mut cursor)
1085        .filter(|argument| argument.kind() != "keyword_argument")
1086        .collect()
1087}
1088
1089fn python_sys_modules_keyword_argument<'tree>(
1090    call: Node<'tree>,
1091    source: &str,
1092    name: &str,
1093) -> Option<Node<'tree>> {
1094    let arguments = call.child_by_field_name("arguments")?;
1095    let mut cursor = arguments.walk();
1096    arguments
1097        .named_children(&mut cursor)
1098        .find(|argument| {
1099            argument.kind() == "keyword_argument"
1100                && argument
1101                    .child_by_field_name("name")
1102                    .is_some_and(|key| node_text(key, source) == name)
1103        })
1104        .and_then(|argument| argument.child_by_field_name("value"))
1105}
1106
1107fn python_sys_modules_read_identity<'tree>(
1108    key: Option<Node<'tree>>,
1109    read: Node<'tree>,
1110    proof: &PythonSysModulesProofContext<'_, 'tree>,
1111    budget: &mut PythonRuntimeBudget,
1112) -> PythonSysModulesLookup<'tree> {
1113    let Some(key) = key else {
1114        return PythonSysModulesLookup::Escape(node_source_range(read));
1115    };
1116    if key.kind() == "identifier" && node_text(key, proof.source) == "__name__" {
1117        return if proof.source_module == proof.module {
1118            PythonSysModulesLookup::Target(read)
1119        } else {
1120            PythonSysModulesLookup::Unrelated
1121        };
1122    }
1123    let Some(key_value) = crate::decode_python_string_value(key, proof.source).ok() else {
1124        if python_sys_modules_guard_excludes_module(key, read, proof, budget) {
1125            return PythonSysModulesLookup::Unrelated;
1126        }
1127        return PythonSysModulesLookup::Target(read);
1128    };
1129    if key_value == proof.module {
1130        PythonSysModulesLookup::Target(read)
1131    } else {
1132        PythonSysModulesLookup::Unrelated
1133    }
1134}
1135
1136#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1137enum PythonGuardTruth {
1138    True,
1139    False,
1140    Unknown,
1141}
1142
1143/// Whether a structured dominating condition excludes this module name from
1144/// a dynamic `sys.modules` read. Unknown expressions retain the possible alias.
1145fn python_sys_modules_guard_excludes_module(
1146    key: Node<'_>,
1147    read: Node<'_>,
1148    proof: &PythonSysModulesProofContext<'_, '_>,
1149    budget: &mut PythonRuntimeBudget,
1150) -> bool {
1151    if key.kind() != "identifier" {
1152        return false;
1153    }
1154    let key_name = node_text(key, proof.source);
1155    let mut child = read;
1156    let mut ancestor = read.parent();
1157    while let Some(node) = ancestor {
1158        if !budget.visit() {
1159            return false;
1160        }
1161        if matches!(node.kind(), "if_statement" | "elif_clause")
1162            && let (Some(condition), Some(consequence)) = (
1163                node.child_by_field_name("condition"),
1164                node.child_by_field_name("consequence"),
1165            )
1166            && consequence.start_byte() <= read.start_byte()
1167            && read.end_byte() <= consequence.end_byte()
1168            && python_sys_modules_key_binding_unchanged(key, condition, read, proof, budget)
1169            && python_sys_modules_guard_truth(
1170                condition,
1171                key_name,
1172                proof.module,
1173                proof.source,
1174                budget,
1175            ) == PythonGuardTruth::False
1176        {
1177            return true;
1178        }
1179        if node.kind() == "block"
1180            && let Some(statement) = python_sys_modules_direct_child_containing(node, child)
1181            && python_sys_modules_prior_continue_guard_excludes(
1182                node, statement, key, read, key_name, proof, budget,
1183            )
1184        {
1185            return true;
1186        }
1187        child = node;
1188        ancestor = node.parent();
1189    }
1190    false
1191}
1192
1193/// Prove that the key has the same local binding in the guard and at the read.
1194/// The existing per-scope binding inventory used by module-alias propagation
1195/// counts every write in the lexical scope; this stricter source proof also
1196/// rejects any write or call on the path from the guard to the read.
1197fn python_sys_modules_key_binding_unchanged(
1198    key: Node<'_>,
1199    guard: Node<'_>,
1200    read: Node<'_>,
1201    proof: &PythonSysModulesProofContext<'_, '_>,
1202    budget: &mut PythonRuntimeBudget,
1203) -> bool {
1204    let name = node_text(key, proof.source);
1205    let guard_reference = python_sys_modules_guard_key_reference(guard, name, proof.source, budget);
1206    let Some(guard_reference) = guard_reference else {
1207        return false;
1208    };
1209    let scope = python_alias_scope(key);
1210    let key_resolution = python_name_resolution_at(name, key, proof.source);
1211    let guard_resolution = python_name_resolution_at(name, guard_reference, proof.source);
1212    let same_lexical_binding = key_resolution == guard_resolution
1213        && (key_resolution == PythonLexicalNameResolution::Local
1214            || (scope.kind() == "module"
1215                && key_resolution == PythonLexicalNameResolution::Unbound));
1216    if python_alias_scope(guard_reference).id() != scope.id()
1217        || python_alias_scope(read).id() != scope.id()
1218        || !same_lexical_binding
1219    {
1220        return false;
1221    }
1222    if !python_sys_modules_has_one_binding(proof, scope, name) {
1223        return false;
1224    }
1225    let start = guard.start_byte();
1226    let guard_end = guard.end_byte();
1227    let end = read.start_byte();
1228    let mut stack = vec![scope.child_by_field_name("body").unwrap_or(scope)];
1229    while let Some(node) = stack.pop() {
1230        if !budget.visit() {
1231            return false;
1232        }
1233        if node.start_byte() >= end || node.end_byte() <= start {
1234            continue;
1235        }
1236        if python_direct_scope_bindings_bounded(node, proof.source, || budget.visit()).is_none_or(
1237            |bindings| {
1238                bindings.into_iter().any(|binding| {
1239                    node_text(binding.declaration, proof.source) == name
1240                        && start <= binding.declaration.start_byte()
1241                        && binding.declaration.start_byte() < end
1242                })
1243            },
1244        ) {
1245            return false;
1246        }
1247        let mut cursor = node.walk();
1248        for child in node.named_children(&mut cursor) {
1249            if matches!(child.kind(), "call")
1250                && guard_end <= child.start_byte()
1251                && child.end_byte() < end
1252            {
1253                return false;
1254            }
1255        }
1256        stack.extend(python_executable_scope_children(node));
1257    }
1258    true
1259}
1260
1261fn python_sys_modules_guard_key_reference<'tree>(
1262    condition: Node<'tree>,
1263    name: &str,
1264    source: &str,
1265    budget: &mut PythonRuntimeBudget,
1266) -> Option<Node<'tree>> {
1267    let mut stack = vec![condition];
1268    while let Some(node) = stack.pop() {
1269        if !budget.visit() {
1270            return None;
1271        }
1272        if node.kind() == "identifier" && node_text(node, source) == name {
1273            return Some(node);
1274        }
1275        let mut cursor = node.walk();
1276        stack.extend(node.named_children(&mut cursor));
1277    }
1278    None
1279}
1280
1281fn python_sys_modules_direct_child_containing<'tree>(
1282    block: Node<'tree>,
1283    child: Node<'tree>,
1284) -> Option<Node<'tree>> {
1285    let mut direct = child;
1286    while direct
1287        .parent()
1288        .is_some_and(|parent| parent.id() != block.id())
1289    {
1290        direct = direct.parent()?;
1291    }
1292    direct
1293        .parent()
1294        .is_some_and(|parent| parent.id() == block.id())
1295        .then_some(direct)
1296}
1297
1298fn python_sys_modules_prior_continue_guard_excludes(
1299    block: Node<'_>,
1300    read_statement: Node<'_>,
1301    key: Node<'_>,
1302    read: Node<'_>,
1303    key_name: &str,
1304    proof: &PythonSysModulesProofContext<'_, '_>,
1305    budget: &mut PythonRuntimeBudget,
1306) -> bool {
1307    let mut cursor = block.walk();
1308    for statement in block.named_children(&mut cursor) {
1309        if statement.id() == read_statement.id() {
1310            break;
1311        }
1312        if statement.kind() != "if_statement" {
1313            continue;
1314        }
1315        let Some(condition) = statement.child_by_field_name("condition") else {
1316            continue;
1317        };
1318        if condition.kind() != "not_operator"
1319            || !python_sys_modules_guard_has_direct_abrupt_consequence(statement)
1320            || !python_sys_modules_key_binding_unchanged(key, condition, read, proof, budget)
1321        {
1322            continue;
1323        }
1324        let Some(guard_condition) = condition.named_child(0) else {
1325            continue;
1326        };
1327        if python_sys_modules_guard_truth(
1328            guard_condition,
1329            key_name,
1330            proof.module,
1331            proof.source,
1332            budget,
1333        ) == PythonGuardTruth::False
1334        {
1335            return true;
1336        }
1337    }
1338    false
1339}
1340
1341fn python_sys_modules_guard_has_direct_abrupt_consequence(statement: Node<'_>) -> bool {
1342    let Some(consequence) = statement.child_by_field_name("consequence") else {
1343        return false;
1344    };
1345    let mut cursor = consequence.walk();
1346    let mut statements = consequence.named_children(&mut cursor);
1347    let Some(only) = statements.next() else {
1348        return false;
1349    };
1350    statements.next().is_none()
1351        && matches!(
1352            only.kind(),
1353            "break_statement" | "continue_statement" | "return_statement" | "raise_statement"
1354        )
1355}
1356
1357fn python_sys_modules_guard_truth(
1358    condition: Node<'_>,
1359    key_name: &str,
1360    module: &str,
1361    source: &str,
1362    budget: &mut PythonRuntimeBudget,
1363) -> PythonGuardTruth {
1364    let mut pending = vec![(condition, false)];
1365    let mut results = HashMap::<usize, PythonGuardTruth>::default();
1366    while let Some((node, visited)) = pending.pop() {
1367        if !budget.visit() {
1368            return PythonGuardTruth::Unknown;
1369        }
1370        if !visited
1371            && matches!(
1372                node.kind(),
1373                "boolean_operator" | "not_operator" | "parenthesized_expression"
1374            )
1375        {
1376            pending.push((node, true));
1377            let mut cursor = node.walk();
1378            for child in node.named_children(&mut cursor) {
1379                pending.push((child, false));
1380            }
1381            continue;
1382        }
1383        let value = match node.kind() {
1384            "boolean_operator" => {
1385                let Some(left) = node.child_by_field_name("left") else {
1386                    return PythonGuardTruth::Unknown;
1387                };
1388                let Some(right) = node.child_by_field_name("right") else {
1389                    return PythonGuardTruth::Unknown;
1390                };
1391                let Some(operator) = node.child_by_field_name("operator") else {
1392                    return PythonGuardTruth::Unknown;
1393                };
1394                let (Some(left), Some(right)) = (results.get(&left.id()), results.get(&right.id()))
1395                else {
1396                    return PythonGuardTruth::Unknown;
1397                };
1398                match node_text(operator, source) {
1399                    "and" => python_guard_and(*left, *right),
1400                    "or" => python_guard_or(*left, *right),
1401                    _ => PythonGuardTruth::Unknown,
1402                }
1403            }
1404            "not_operator" => node
1405                .named_child(0)
1406                .and_then(|child| results.get(&child.id()).copied())
1407                .map(python_guard_not)
1408                .unwrap_or(PythonGuardTruth::Unknown),
1409            "parenthesized_expression" => node
1410                .named_child(0)
1411                .and_then(|child| results.get(&child.id()).copied())
1412                .unwrap_or(PythonGuardTruth::Unknown),
1413            _ => python_sys_modules_guard_atom(node, key_name, module, source),
1414        };
1415        results.insert(node.id(), value);
1416    }
1417    results
1418        .get(&condition.id())
1419        .copied()
1420        .unwrap_or(PythonGuardTruth::Unknown)
1421}
1422
1423fn python_guard_and(left: PythonGuardTruth, right: PythonGuardTruth) -> PythonGuardTruth {
1424    match (left, right) {
1425        (PythonGuardTruth::False, _) | (_, PythonGuardTruth::False) => PythonGuardTruth::False,
1426        (PythonGuardTruth::True, PythonGuardTruth::True) => PythonGuardTruth::True,
1427        _ => PythonGuardTruth::Unknown,
1428    }
1429}
1430
1431fn python_guard_or(left: PythonGuardTruth, right: PythonGuardTruth) -> PythonGuardTruth {
1432    match (left, right) {
1433        (PythonGuardTruth::True, _) | (_, PythonGuardTruth::True) => PythonGuardTruth::True,
1434        (PythonGuardTruth::False, PythonGuardTruth::False) => PythonGuardTruth::False,
1435        _ => PythonGuardTruth::Unknown,
1436    }
1437}
1438
1439fn python_guard_not(value: PythonGuardTruth) -> PythonGuardTruth {
1440    match value {
1441        PythonGuardTruth::True => PythonGuardTruth::False,
1442        PythonGuardTruth::False => PythonGuardTruth::True,
1443        PythonGuardTruth::Unknown => PythonGuardTruth::Unknown,
1444    }
1445}
1446
1447fn python_sys_modules_guard_atom(
1448    node: Node<'_>,
1449    key_name: &str,
1450    module: &str,
1451    source: &str,
1452) -> PythonGuardTruth {
1453    if node.kind() == "comparison_operator" {
1454        return python_sys_modules_guard_comparison(node, key_name, module, source);
1455    }
1456    if node.kind() == "call" {
1457        return python_sys_modules_guard_string_method(node, key_name, module, source);
1458    }
1459    PythonGuardTruth::Unknown
1460}
1461
1462fn python_sys_modules_guard_comparison(
1463    node: Node<'_>,
1464    key_name: &str,
1465    module: &str,
1466    source: &str,
1467) -> PythonGuardTruth {
1468    let mut cursor = node.walk();
1469    let operands = node.named_children(&mut cursor).collect::<Vec<_>>();
1470    let mut cursor = node.walk();
1471    let operators = node
1472        .children_by_field_name("operators", &mut cursor)
1473        .collect::<Vec<_>>();
1474    let ([left, right], [operator]) = (operands.as_slice(), operators.as_slice()) else {
1475        return PythonGuardTruth::Unknown;
1476    };
1477    let operator = node_text(*operator, source);
1478    let key_left = left.kind() == "identifier" && node_text(*left, source) == key_name;
1479    let key_right = right.kind() == "identifier" && node_text(*right, source) == key_name;
1480    if key_left || key_right {
1481        let literal = if key_left {
1482            crate::decode_python_string_value(*right, source)
1483        } else {
1484            crate::decode_python_string_value(*left, source)
1485        };
1486        if matches!(operator, "==" | "!=")
1487            && let Ok(literal) = literal
1488        {
1489            let matches = literal == module;
1490            return python_guard_bool(if operator == "==" { matches } else { !matches });
1491        }
1492        if operator == "in" && key_left {
1493            return python_sys_modules_literal_membership(*right, module, source);
1494        }
1495        if operator == "in"
1496            && key_right
1497            && let Ok(literal) = literal
1498        {
1499            return python_guard_bool(module.contains(&literal));
1500        }
1501    }
1502    PythonGuardTruth::Unknown
1503}
1504
1505fn python_sys_modules_literal_membership(
1506    collection: Node<'_>,
1507    module: &str,
1508    source: &str,
1509) -> PythonGuardTruth {
1510    if !matches!(collection.kind(), "tuple" | "list" | "set") {
1511        return PythonGuardTruth::Unknown;
1512    }
1513    let mut cursor = collection.walk();
1514    let mut contains = false;
1515    for value in collection.named_children(&mut cursor) {
1516        let Ok(literal) = crate::decode_python_string_value(value, source) else {
1517            return PythonGuardTruth::Unknown;
1518        };
1519        contains |= literal == module;
1520    }
1521    python_guard_bool(contains)
1522}
1523
1524fn python_sys_modules_guard_string_method(
1525    call: Node<'_>,
1526    key_name: &str,
1527    module: &str,
1528    source: &str,
1529) -> PythonGuardTruth {
1530    let Some(function) = call.child_by_field_name("function") else {
1531        return PythonGuardTruth::Unknown;
1532    };
1533    if function.kind() != "attribute"
1534        || !function
1535            .child_by_field_name("object")
1536            .is_some_and(|object| {
1537                object.kind() == "identifier" && node_text(object, source) == key_name
1538            })
1539    {
1540        return PythonGuardTruth::Unknown;
1541    }
1542    let Some(method) = function
1543        .child_by_field_name("attribute")
1544        .map(|name| node_text(name, source))
1545    else {
1546        return PythonGuardTruth::Unknown;
1547    };
1548    let Some(arguments) = call.child_by_field_name("arguments") else {
1549        return PythonGuardTruth::Unknown;
1550    };
1551    let mut cursor = arguments.walk();
1552    let mut values = arguments.named_children(&mut cursor);
1553    let Some(first) = values.next() else {
1554        return PythonGuardTruth::Unknown;
1555    };
1556    if values.next().is_some() || first.kind() == "keyword_argument" {
1557        return PythonGuardTruth::Unknown;
1558    }
1559    let matches = if let Ok(literal) = crate::decode_python_string_value(first, source) {
1560        match method {
1561            "startswith" => module.starts_with(&literal),
1562            "endswith" => module.ends_with(&literal),
1563            _ => return PythonGuardTruth::Unknown,
1564        }
1565    } else if matches!(first.kind(), "tuple" | "list" | "set") {
1566        let mut cursor = first.walk();
1567        let mut matched = false;
1568        for value in first.named_children(&mut cursor) {
1569            let Ok(literal) = crate::decode_python_string_value(value, source) else {
1570                return PythonGuardTruth::Unknown;
1571            };
1572            matched |= match method {
1573                "startswith" => module.starts_with(&literal),
1574                "endswith" => module.ends_with(&literal),
1575                _ => return PythonGuardTruth::Unknown,
1576            };
1577        }
1578        matched
1579    } else {
1580        return PythonGuardTruth::Unknown;
1581    };
1582    python_guard_bool(matches)
1583}
1584
1585fn python_guard_bool(value: bool) -> PythonGuardTruth {
1586    if value {
1587        PythonGuardTruth::True
1588    } else {
1589        PythonGuardTruth::False
1590    }
1591}
1592
1593#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1594enum PythonSysModulesStoredValue {
1595    Target,
1596    Unrelated,
1597    Unknown,
1598}
1599
1600struct PythonSysModulesFacts<'tree> {
1601    aliases: HashSet<String>,
1602    assignments: HashMap<(usize, usize, String), Vec<Node<'tree>>>,
1603    binding_counts: HashMap<(usize, usize, String), usize>,
1604    classes: Vec<PythonExternalClassAlias>,
1605}
1606
1607struct PythonSysModulesProofContext<'a, 'tree> {
1608    source: &'a str,
1609    source_module: &'a str,
1610    module: &'a str,
1611    bindings: &'a [crate::imports::PythonImportBinding],
1612    facts: &'a PythonSysModulesFacts<'tree>,
1613}
1614
1615fn python_sys_modules_has_one_binding(
1616    proof: &PythonSysModulesProofContext<'_, '_>,
1617    scope: Node<'_>,
1618    name: &str,
1619) -> bool {
1620    let count = proof
1621        .facts
1622        .binding_counts
1623        .get(&(scope.start_byte(), scope.end_byte(), name.to_owned()))
1624        .copied()
1625        .unwrap_or_default()
1626        + proof
1627            .bindings
1628            .iter()
1629            .filter(|binding| {
1630                binding.local_name == name
1631                    && binding.scope_start_byte == scope.start_byte()
1632                    && binding.scope_end_byte == scope.end_byte()
1633            })
1634            .count();
1635    count == 1
1636}
1637
1638fn python_sys_modules_value_scope<'tree>(node: Node<'tree>, source: &str) -> Node<'tree> {
1639    let nearest = python_alias_scope(node);
1640    let name = python_static_path(node, source)
1641        .and_then(|(root, _)| (root.kind() == "identifier").then_some(node_text(root, source)))
1642        .unwrap_or_default();
1643    match python_name_resolution_at(name, node, source) {
1644        PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal => nearest,
1645        PythonLexicalNameResolution::Global => {
1646            let mut scope = nearest;
1647            while scope.kind() != "module" {
1648                scope = python_alias_scope(scope.parent().expect("lexical scope has a parent"));
1649            }
1650            scope
1651        }
1652        PythonLexicalNameResolution::Unbound if nearest.kind() == "class_definition" => nearest,
1653        PythonLexicalNameResolution::Unbound => {
1654            let mut scope = nearest;
1655            while scope.kind() != "module" {
1656                scope = python_alias_scope(scope.parent().expect("lexical scope has a parent"));
1657            }
1658            scope
1659        }
1660    }
1661}
1662
1663fn python_sys_modules_stored_value<'tree>(
1664    value: Node<'tree>,
1665    context: &PythonSysModulesProofContext<'_, 'tree>,
1666    budget: &mut PythonRuntimeBudget,
1667    seen: &mut HashSet<(usize, usize, String)>,
1668) -> PythonSysModulesStoredValue {
1669    let source = context.source;
1670    let module = context.module;
1671    let bindings = context.bindings;
1672    let facts = context.facts;
1673    if !budget.visit() {
1674        return PythonSysModulesStoredValue::Unknown;
1675    }
1676    if python_sys_modules_table(value, source).is_some() {
1677        return PythonSysModulesStoredValue::Unknown;
1678    }
1679    if let Some(table) = python_sys_modules_read_table(value, source) {
1680        if python_imported_path(table, source, bindings)
1681            .is_none_or(|path| path != ["sys", "modules"])
1682        {
1683            return PythonSysModulesStoredValue::Unknown;
1684        }
1685        return match python_sys_modules_read_identity(
1686            if value.kind() == "subscript" {
1687                value.child_by_field_name("subscript")
1688            } else {
1689                python_sys_modules_arguments(value).first().copied()
1690            },
1691            value,
1692            context,
1693            budget,
1694        ) {
1695            PythonSysModulesLookup::Target(_) => PythonSysModulesStoredValue::Target,
1696            PythonSysModulesLookup::Unrelated => PythonSysModulesStoredValue::Unrelated,
1697            PythonSysModulesLookup::Escape(_) => PythonSysModulesStoredValue::Unknown,
1698        };
1699    }
1700    if matches!(
1701        value.kind(),
1702        "string"
1703            | "concatenated_string"
1704            | "integer"
1705            | "float"
1706            | "true"
1707            | "false"
1708            | "none"
1709            | "list"
1710            | "tuple"
1711            | "set"
1712            | "dictionary"
1713            | "list_comprehension"
1714            | "set_comprehension"
1715            | "dictionary_comprehension"
1716            | "generator_expression"
1717    ) {
1718        return PythonSysModulesStoredValue::Unrelated;
1719    }
1720    if let Some(path) = python_imported_path(value, source, bindings) {
1721        let root_name = python_static_path(value, source)
1722            .map(|(root, _)| node_text(root, source))
1723            .unwrap_or_default();
1724        let scope = python_sys_modules_value_scope(value, source);
1725        if facts.binding_counts.contains_key(&(
1726            scope.start_byte(),
1727            scope.end_byte(),
1728            root_name.to_owned(),
1729        )) {
1730            return PythonSysModulesStoredValue::Unknown;
1731        }
1732        return if path.join(".") == module {
1733            PythonSysModulesStoredValue::Target
1734        } else {
1735            PythonSysModulesStoredValue::Unrelated
1736        };
1737    }
1738    if value.kind() == "call" {
1739        let Some(function) = value.child_by_field_name("function") else {
1740            return PythonSysModulesStoredValue::Unknown;
1741        };
1742        if python_imported_path(function, source, bindings)
1743            .is_some_and(|path| path == ["types", "ModuleType"])
1744        {
1745            return PythonSysModulesStoredValue::Unrelated;
1746        }
1747        if let Some((root, path)) = python_static_path(function, source)
1748            && path.len() == 1
1749            && python_class_alias_visible_at(node_text(root, source), root, source, &facts.classes)
1750        {
1751            return PythonSysModulesStoredValue::Unrelated;
1752        }
1753        return PythonSysModulesStoredValue::Unknown;
1754    }
1755    let Some((root, path)) = python_static_path(value, source) else {
1756        return PythonSysModulesStoredValue::Unknown;
1757    };
1758    if path.len() == 1 && facts.aliases.contains(node_text(root, source)) {
1759        return PythonSysModulesStoredValue::Target;
1760    }
1761    if path.len() == 1
1762        && python_class_alias_visible_at(node_text(root, source), root, source, &facts.classes)
1763    {
1764        return PythonSysModulesStoredValue::Unrelated;
1765    }
1766    let scope = python_sys_modules_value_scope(value, source);
1767    if scope.kind() == "function_definition"
1768        && scope
1769            .child_by_field_name("parameters")
1770            .is_some_and(|parameters| {
1771                let mut cursor = parameters.walk();
1772                parameters
1773                    .named_children(&mut cursor)
1774                    .filter_map(|parameter| python_parameter_name(parameter, source))
1775                    .any(|parameter| parameter == node_text(root, source))
1776            })
1777    {
1778        return PythonSysModulesStoredValue::Unknown;
1779    }
1780    if !python_sys_modules_has_one_binding(context, scope, node_text(root, source)) {
1781        return PythonSysModulesStoredValue::Unknown;
1782    }
1783    let key = (scope.start_byte(), scope.end_byte(), path.join("."));
1784    if !seen.insert(key.clone()) {
1785        return PythonSysModulesStoredValue::Unknown;
1786    }
1787    let Some([assignment]) = facts.assignments.get(&key).map(Vec::as_slice) else {
1788        return PythonSysModulesStoredValue::Unknown;
1789    };
1790    python_sys_modules_stored_value(*assignment, context, budget, seen)
1791}
1792
1793fn python_sys_modules_update_values<'tree>(
1794    mapping: Node<'tree>,
1795    context: &PythonSysModulesProofContext<'_, 'tree>,
1796    budget: &mut PythonRuntimeBudget,
1797    seen: &mut HashSet<(usize, usize, String)>,
1798) -> PythonSysModulesStoredValue {
1799    let source = context.source;
1800    let facts = context.facts;
1801    if mapping.kind() == "dictionary" {
1802        let mut cursor = mapping.walk();
1803        for pair in mapping.named_children(&mut cursor) {
1804            let Some(value) = pair.child_by_field_name("value") else {
1805                return PythonSysModulesStoredValue::Unknown;
1806            };
1807            let identity = python_sys_modules_stored_value(value, context, budget, seen);
1808            if identity != PythonSysModulesStoredValue::Unrelated {
1809                return identity;
1810            }
1811        }
1812        return PythonSysModulesStoredValue::Unrelated;
1813    }
1814    let Some((root, path)) = python_static_path(mapping, source) else {
1815        return PythonSysModulesStoredValue::Unknown;
1816    };
1817    let scope = python_sys_modules_value_scope(mapping, source);
1818    let key = (scope.start_byte(), scope.end_byte(), path.join("."));
1819    if !seen.insert(key.clone()) {
1820        return PythonSysModulesStoredValue::Unknown;
1821    }
1822    if !python_sys_modules_has_one_binding(context, scope, node_text(root, source)) {
1823        return PythonSysModulesStoredValue::Unknown;
1824    }
1825    let Some([assigned_value]) = facts.assignments.get(&key).map(Vec::as_slice) else {
1826        return PythonSysModulesStoredValue::Unknown;
1827    };
1828    python_sys_modules_update_values(*assigned_value, context, budget, seen)
1829}
1830
1831/// Collect aliases of the queried module reached through proven
1832/// `sys.modules` reads in this file. Only names and alias edges survive the
1833/// bounded, per-file walk.
1834fn python_sys_modules_aliases<'tree>(
1835    root: Node<'tree>,
1836    source: &str,
1837    source_module: &str,
1838    module: &str,
1839    bindings: &[crate::imports::PythonImportBinding],
1840    budget: &mut PythonRuntimeBudget,
1841) -> Option<PythonSysModulesFacts<'tree>> {
1842    let mut aliases_from = HashMap::<String, Vec<String>>::default();
1843    let mut assignments = HashMap::<(usize, usize, String), Vec<Node<'_>>>::default();
1844    let mut binding_counts = HashMap::<(usize, usize, String), usize>::default();
1845    let mut classes = Vec::new();
1846    let mut pending_table_aliases = Vec::<(Vec<String>, Option<Node<'_>>, Node<'_>)>::new();
1847    let mut stack = vec![root];
1848    while let Some(node) = stack.pop() {
1849        if !budget.visit() {
1850            return None;
1851        }
1852        if node.kind() == "class_definition"
1853            && let Some(name) = node.child_by_field_name("name")
1854        {
1855            let scope = python_alias_scope(node);
1856            classes.push(PythonExternalClassAlias {
1857                name: node_text(name, source).to_owned(),
1858                scope_start_byte: scope.start_byte(),
1859                scope_end_byte: scope.end_byte(),
1860            });
1861        }
1862        for binding in python_direct_scope_bindings_bounded(node, source, || budget.visit())? {
1863            if binding.kind != PythonDirectScopeBindingKind::Import {
1864                let scope = python_alias_scope(binding.declaration);
1865                let key = (
1866                    scope.start_byte(),
1867                    scope.end_byte(),
1868                    node_text(binding.declaration, source).to_owned(),
1869                );
1870                *binding_counts.entry(key).or_default() += 1;
1871            }
1872        }
1873        if matches!(node.kind(), "function_definition" | "lambda")
1874            && let Some(parameters) = node.child_by_field_name("parameters")
1875        {
1876            let scope = node;
1877            let mut cursor = parameters.walk();
1878            for parameter in parameters.named_children(&mut cursor) {
1879                if let Some(name) = python_parameter_name(parameter, source) {
1880                    *binding_counts
1881                        .entry((scope.start_byte(), scope.end_byte(), name))
1882                        .or_default() += 1;
1883                }
1884            }
1885        }
1886        if matches!(node.kind(), "assignment" | "annotated_assignment") {
1887            let (targets, value) = if node.kind() == "assignment" {
1888                crate::imports::python_assignment_chain(node)
1889            } else {
1890                (
1891                    node.child_by_field_name("left").into_iter().collect(),
1892                    node.child_by_field_name("right"),
1893                )
1894            };
1895            if let Some(value) = value {
1896                for target in targets {
1897                    if let Some((_, path)) = python_static_path(target, source) {
1898                        let scope = python_alias_scope(target);
1899                        let key = (scope.start_byte(), scope.end_byte(), path.join("."));
1900                        assignments.entry(key).or_default().push(value);
1901                    }
1902                }
1903            }
1904        }
1905        if node.kind() == "assignment" {
1906            let (targets, value) = crate::imports::python_assignment_chain(node);
1907            if let Some(value) = value
1908                && !targets.is_empty()
1909                && targets.iter().all(|target| target.kind() == "identifier")
1910            {
1911                let target_names = targets
1912                    .iter()
1913                    .map(|target| node_text(*target, source).to_owned())
1914                    .collect::<Vec<_>>();
1915                if python_sys_modules_read_table(value, source).is_some_and(|table| {
1916                    python_imported_path(table, source, bindings)
1917                        .is_some_and(|path| path == ["sys", "modules"])
1918                }) {
1919                    pending_table_aliases.push((
1920                        target_names,
1921                        if value.kind() == "subscript" {
1922                            value.child_by_field_name("subscript")
1923                        } else {
1924                            python_sys_modules_arguments(value).first().copied()
1925                        },
1926                        value,
1927                    ));
1928                } else if value.kind() == "identifier" {
1929                    aliases_from
1930                        .entry(node_text(value, source).to_owned())
1931                        .or_default()
1932                        .extend(target_names);
1933                }
1934            }
1935        }
1936        let mut cursor = node.walk();
1937        stack.extend(node.named_children(&mut cursor));
1938    }
1939
1940    let mut facts = PythonSysModulesFacts {
1941        aliases: HashSet::default(),
1942        assignments,
1943        binding_counts,
1944        classes,
1945    };
1946    let mut target_aliases = Vec::new();
1947    {
1948        let proof = PythonSysModulesProofContext {
1949            source,
1950            source_module,
1951            module,
1952            bindings,
1953            facts: &facts,
1954        };
1955        for (targets, key, read) in pending_table_aliases {
1956            if matches!(
1957                python_sys_modules_read_identity(key, read, &proof, budget),
1958                PythonSysModulesLookup::Target(_)
1959            ) {
1960                target_aliases.extend(targets);
1961            }
1962        }
1963    }
1964    facts.aliases.extend(target_aliases);
1965
1966    let mut pending = facts.aliases.iter().cloned().collect::<Vec<_>>();
1967    while let Some(source_name) = pending.pop() {
1968        if let Some(targets) = aliases_from.get(&source_name) {
1969            for target in targets {
1970                if facts.aliases.insert(target.clone()) {
1971                    pending.push(target.clone());
1972                }
1973            }
1974        }
1975    }
1976    Some(facts)
1977}
1978
1979fn python_sys_modules_read_use(
1980    read: Node<'_>,
1981    source: &str,
1982    member: &str,
1983    aliases: &HashSet<String>,
1984) -> Option<PythonModuleMemberMutation> {
1985    let mut expression = read;
1986    let mut direct_attribute = None;
1987    while let Some(parent) = expression.parent() {
1988        if parent.kind() != "attribute"
1989            || parent
1990                .child_by_field_name("object")
1991                .is_none_or(|object| object.id() != expression.id())
1992        {
1993            break;
1994        }
1995        if direct_attribute.is_none() {
1996            direct_attribute = Some(parent);
1997        }
1998        expression = parent;
1999    }
2000    if let Some(attribute) = direct_attribute {
2001        let name = attribute
2002            .child_by_field_name("attribute")
2003            .map(|name| node_text(name, source));
2004        if name == Some("__dict__") {
2005            return Some(PythonModuleMemberMutation::Escape(node_source_range(
2006                attribute,
2007            )));
2008        }
2009        if name == Some(member) && is_binding_target(attribute) {
2010            return Some(PythonModuleMemberMutation::Write(node_source_range(
2011                attribute,
2012            )));
2013        }
2014        return None;
2015    }
2016    if python_builtin_module_read(expression, source) {
2017        return None;
2018    }
2019    if read.parent().is_some_and(|parent| {
2020        parent.kind() == "assignment"
2021            && parent
2022                .child_by_field_name("right")
2023                .is_some_and(|right| right.id() == read.id())
2024            && crate::imports::python_assignment_chain(parent)
2025                .0
2026                .iter()
2027                .all(|target| {
2028                    target.kind() == "identifier" && aliases.contains(node_text(*target, source))
2029                })
2030    }) || read.parent().is_some_and(|parent| {
2031        parent.kind() == "expression_statement"
2032            && parent
2033                .named_child(0)
2034                .is_some_and(|expression| expression.id() == read.id())
2035    }) {
2036        return None;
2037    }
2038    Some(PythonModuleMemberMutation::Escape(node_source_range(read)))
2039}
2040
2041fn python_builtin_module_read(expression: Node<'_>, source: &str) -> bool {
2042    let Some(arguments) = expression
2043        .parent()
2044        .filter(|node| node.kind() == "argument_list")
2045    else {
2046        return false;
2047    };
2048    let Some(call) = arguments.parent().filter(|node| node.kind() == "call") else {
2049        return false;
2050    };
2051    let Some(function) = call.child_by_field_name("function") else {
2052        return false;
2053    };
2054    if function.kind() != "identifier"
2055        || !matches!(node_text(function, source), "getattr" | "hasattr")
2056        || matches!(
2057            python_name_resolution_at(node_text(function, source), function, source),
2058            PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal
2059        )
2060    {
2061        return false;
2062    }
2063    let mut cursor = arguments.walk();
2064    arguments
2065        .named_children(&mut cursor)
2066        .find(|argument| argument.kind() != "keyword_argument")
2067        .is_some_and(|first| first.id() == expression.id())
2068}
2069
2070fn python_module_table_alias_binding_use(
2071    node: Node<'_>,
2072    source: &str,
2073    aliases: &HashSet<String>,
2074) -> bool {
2075    if node.kind() != "identifier" {
2076        return false;
2077    }
2078    let name = node_text(node, source);
2079    if !aliases.contains(name) {
2080        return false;
2081    }
2082    if is_binding_target(node) {
2083        return true;
2084    }
2085    node.parent().is_some_and(|assignment| {
2086        assignment.kind() == "assignment"
2087            && assignment
2088                .child_by_field_name("right")
2089                .is_some_and(|right| right.id() == node.id())
2090            && crate::imports::python_assignment_chain(assignment)
2091                .0
2092                .iter()
2093                .all(|target| {
2094                    target.kind() == "identifier" && aliases.contains(node_text(*target, source))
2095                })
2096    })
2097}
2098
2099/// Find writes to one imported external class member in this file.
2100///
2101/// The class identities are qualified names taken from one exact active model
2102/// record (including its exact aliases). Import binders turn those identities
2103/// into local AST paths; source spelling alone never establishes the class.
2104/// Dynamic `setattr` names are treated as possible replacement writes.
2105pub fn python_external_class_member_mutation(
2106    root: Node<'_>,
2107    source: &str,
2108    class_identities: &[&str],
2109    member: &str,
2110    max_nodes: usize,
2111) -> Option<PythonModuleMemberMutation> {
2112    let bindings = python_import_bindings_from_tree(root, source);
2113    let Some(aliases) =
2114        python_external_class_aliases(root, source, &bindings, class_identities, max_nodes)
2115    else {
2116        return Some(PythonModuleMemberMutation::Budget);
2117    };
2118    let mut budget = PythonRuntimeBudget {
2119        remaining: max_nodes,
2120        exhausted: false,
2121        visited_nodes: 0,
2122    };
2123    let mut stack = vec![root];
2124    while let Some(node) = stack.pop() {
2125        if !budget.visit() {
2126            return Some(PythonModuleMemberMutation::Budget);
2127        }
2128        if node.kind() == "attribute"
2129            && node
2130                .child_by_field_name("attribute")
2131                .is_some_and(|attribute| node_text(attribute, source) == member)
2132            && is_binding_target(node)
2133            && let Some(object) = node.child_by_field_name("object")
2134            && python_external_class_expression(
2135                object,
2136                source,
2137                &bindings,
2138                class_identities,
2139                &aliases,
2140            )
2141        {
2142            return Some(PythonModuleMemberMutation::Write(node_source_range(node)));
2143        }
2144        if node.kind() == "call"
2145            && python_setattr_may_replace_class_member(
2146                node,
2147                source,
2148                &bindings,
2149                class_identities,
2150                member,
2151                &aliases,
2152            )
2153        {
2154            return Some(PythonModuleMemberMutation::Write(node_source_range(node)));
2155        }
2156        if node.kind() == "call"
2157            && python_mock_patch_may_replace_class_member(
2158                node,
2159                source,
2160                &bindings,
2161                class_identities,
2162                member,
2163                &aliases,
2164            )
2165        {
2166            return Some(PythonModuleMemberMutation::Write(node_source_range(node)));
2167        }
2168        if node.kind() == "identifier"
2169            && python_class_alias_visible_at(node_text(node, source), node, source, &aliases)
2170            && python_class_alias_escapes_at(node, source, &aliases)
2171        {
2172            return Some(PythonModuleMemberMutation::Escape(node_source_range(node)));
2173        }
2174        let mut cursor = node.walk();
2175        stack.extend(node.named_children(&mut cursor));
2176    }
2177    None
2178}
2179
2180#[derive(Clone)]
2181struct PythonExternalClassAlias {
2182    name: String,
2183    scope_start_byte: usize,
2184    scope_end_byte: usize,
2185}
2186
2187fn python_external_class_aliases(
2188    root: Node<'_>,
2189    source: &str,
2190    bindings: &[crate::imports::PythonImportBinding],
2191    class_identities: &[&str],
2192    max_nodes: usize,
2193) -> Option<Vec<PythonExternalClassAlias>> {
2194    let mut budget = PythonRuntimeBudget {
2195        remaining: max_nodes,
2196        exhausted: false,
2197        visited_nodes: 0,
2198    };
2199    let mut assignments = Vec::new();
2200    let mut stack = vec![root];
2201    while let Some(node) = stack.pop() {
2202        if !budget.visit() {
2203            return None;
2204        }
2205        if matches!(node.kind(), "assignment" | "annotated_assignment")
2206            && let Some(target) = node.child_by_field_name("left")
2207            && target.kind() == "identifier"
2208            && let Some(value) = node.child_by_field_name("right")
2209        {
2210            let scope = python_alias_scope(node);
2211            assignments.push((
2212                node_text(target, source).to_owned(),
2213                value,
2214                scope.start_byte(),
2215                scope.end_byte(),
2216            ));
2217        }
2218        let mut cursor = node.walk();
2219        stack.extend(node.named_children(&mut cursor));
2220    }
2221
2222    let mut aliases = Vec::<PythonExternalClassAlias>::new();
2223    loop {
2224        let before = aliases.len();
2225        for (name, value, scope_start_byte, scope_end_byte) in &assignments {
2226            if python_external_class_expression(
2227                *value,
2228                source,
2229                bindings,
2230                class_identities,
2231                &aliases,
2232            ) && !aliases.iter().any(|alias| {
2233                alias.name == *name
2234                    && alias.scope_start_byte == *scope_start_byte
2235                    && alias.scope_end_byte == *scope_end_byte
2236            }) {
2237                aliases.push(PythonExternalClassAlias {
2238                    name: name.clone(),
2239                    scope_start_byte: *scope_start_byte,
2240                    scope_end_byte: *scope_end_byte,
2241                });
2242            }
2243        }
2244        if aliases.len() == before {
2245            return Some(aliases);
2246        }
2247    }
2248}
2249
2250fn python_alias_scope(mut node: Node<'_>) -> Node<'_> {
2251    while let Some(parent) = node.parent() {
2252        if matches!(
2253            parent.kind(),
2254            "module" | "class_definition" | "function_definition" | "lambda"
2255        ) {
2256            return parent;
2257        }
2258        node = parent;
2259    }
2260    node
2261}
2262
2263fn python_external_class_expression(
2264    expression: Node<'_>,
2265    source: &str,
2266    bindings: &[crate::imports::PythonImportBinding],
2267    class_identities: &[&str],
2268    aliases: &[PythonExternalClassAlias],
2269) -> bool {
2270    if python_imported_class_expression(expression, source, bindings, class_identities) {
2271        return true;
2272    }
2273    let Some((root, path)) = python_static_path(expression, source) else {
2274        return false;
2275    };
2276    path.len() == 1 && python_class_alias_visible_at(node_text(root, source), root, source, aliases)
2277}
2278
2279fn python_class_alias_visible_at(
2280    name: &str,
2281    reference: Node<'_>,
2282    source: &str,
2283    aliases: &[PythonExternalClassAlias],
2284) -> bool {
2285    if !aliases.iter().any(|alias| alias.name == name) {
2286        return false;
2287    }
2288    let mut current = reference;
2289    while let Some(scope) = nearest_scope(current) {
2290        if aliases.iter().any(|alias| {
2291            alias.name == name
2292                && alias.scope_start_byte == scope.start_byte()
2293                && alias.scope_end_byte == scope.end_byte()
2294        }) {
2295            return true;
2296        }
2297        match scope.kind() {
2298            "function_definition" | "lambda" => {
2299                let parameters = scope
2300                    .child_by_field_name("parameters")
2301                    .into_iter()
2302                    .flat_map(|parameters| {
2303                        let mut cursor = parameters.walk();
2304                        parameters
2305                            .named_children(&mut cursor)
2306                            .filter_map(|parameter| python_parameter_name(parameter, source))
2307                            .collect::<Vec<_>>()
2308                    });
2309                let Some(inventory) =
2310                    PythonLexicalScopeInventory::collect_bounded(scope, source, parameters, || {
2311                        true
2312                    })
2313                else {
2314                    return true;
2315                };
2316                match inventory.name_resolution_at(name, reference) {
2317                    PythonLexicalNameResolution::Local => return false,
2318                    PythonLexicalNameResolution::Global => break,
2319                    PythonLexicalNameResolution::Nonlocal
2320                    | PythonLexicalNameResolution::Unbound => {}
2321                }
2322            }
2323            "module" => return false,
2324            // Class names are not enclosing bindings for method bodies. A
2325            // class-body alias itself was handled above.
2326            "class_definition" => {}
2327            _ => unreachable!("nearest_scope returns only Python lexical scopes"),
2328        }
2329        current = scope;
2330    }
2331    aliases.iter().any(|alias| {
2332        alias.name == name
2333            && alias.scope_start_byte == 0
2334            && reference.start_byte() < alias.scope_end_byte
2335    })
2336}
2337
2338fn nearest_scope(mut node: Node<'_>) -> Option<Node<'_>> {
2339    while let Some(parent) = node.parent() {
2340        if matches!(
2341            parent.kind(),
2342            "module" | "class_definition" | "function_definition" | "lambda"
2343        ) {
2344            return Some(parent);
2345        }
2346        node = parent;
2347    }
2348    None
2349}
2350
2351fn python_class_alias_escapes_at(
2352    node: Node<'_>,
2353    source: &str,
2354    aliases: &[PythonExternalClassAlias],
2355) -> bool {
2356    let mut current = node;
2357    while let Some(parent) = current.parent() {
2358        if matches!(
2359            parent.kind(),
2360            "function_definition" | "lambda" | "class_definition" | "module"
2361        ) {
2362            return false;
2363        }
2364        if parent.kind() == "argument_list"
2365            && parent.parent().is_some_and(|call| call.kind() == "call")
2366        {
2367            return true;
2368        }
2369        if matches!(parent.kind(), "return_statement" | "yield") {
2370            return true;
2371        }
2372        if matches!(parent.kind(), "list" | "tuple" | "set" | "dictionary") {
2373            return true;
2374        }
2375        if matches!(parent.kind(), "assignment" | "annotated_assignment")
2376            && parent.child_by_field_name("right").is_some_and(|right| {
2377                right.start_byte() <= node.start_byte() && node.end_byte() <= right.end_byte()
2378            })
2379        {
2380            let Some(target) = parent.child_by_field_name("left") else {
2381                return true;
2382            };
2383            if target.kind() == "identifier"
2384                && python_class_alias_visible_at(node_text(target, source), target, source, aliases)
2385            {
2386                return false;
2387            }
2388            return true;
2389        }
2390        current = parent;
2391    }
2392    false
2393}
2394
2395fn python_setattr_may_replace_class_member(
2396    call: Node<'_>,
2397    source: &str,
2398    bindings: &[crate::imports::PythonImportBinding],
2399    class_identities: &[&str],
2400    member: &str,
2401    aliases: &[PythonExternalClassAlias],
2402) -> bool {
2403    let Some(function) = call.child_by_field_name("function") else {
2404        return false;
2405    };
2406    let imported_setattr = python_imported_path(function, source, bindings)
2407        .is_some_and(|path| path == ["builtins", "setattr"]);
2408    let builtin_setattr = python_static_path(function, source).is_some_and(|(root, path)| {
2409        path == ["setattr"]
2410            && !matches!(
2411                python_name_resolution_at(node_text(root, source), root, source),
2412                PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal
2413            )
2414    });
2415    if !imported_setattr && !builtin_setattr {
2416        return false;
2417    }
2418    let Some(arguments) = call.child_by_field_name("arguments") else {
2419        return false;
2420    };
2421    let mut cursor = arguments.walk();
2422    let positional = arguments
2423        .named_children(&mut cursor)
2424        .filter(|argument| argument.kind() != "keyword_argument")
2425        .collect::<Vec<_>>();
2426    let [object, name, ..] = positional.as_slice() else {
2427        return false;
2428    };
2429    if !python_external_class_expression(*object, source, bindings, class_identities, aliases) {
2430        return false;
2431    }
2432    python_plain_string_literal(*name, source).is_none_or(|name| name == member)
2433}
2434
2435fn python_mock_patch_may_replace_class_member(
2436    call: Node<'_>,
2437    source: &str,
2438    bindings: &[crate::imports::PythonImportBinding],
2439    class_identities: &[&str],
2440    member: &str,
2441    aliases: &[PythonExternalClassAlias],
2442) -> bool {
2443    let Some(function) = call.child_by_field_name("function") else {
2444        return false;
2445    };
2446    let Some(function_path) = python_imported_path(function, source, bindings) else {
2447        return false;
2448    };
2449    let patch_object = function_path == ["unittest", "mock", "patch", "object"];
2450    let patch_class_path = function_path == ["unittest", "mock", "patch"];
2451    if !patch_object && !patch_class_path {
2452        return false;
2453    }
2454    let Some(arguments) = call.child_by_field_name("arguments") else {
2455        return false;
2456    };
2457    let mut cursor = arguments.walk();
2458    let positional = arguments
2459        .named_children(&mut cursor)
2460        .filter(|argument| argument.kind() != "keyword_argument")
2461        .collect::<Vec<_>>();
2462    let Some(target) = positional.first().copied() else {
2463        return false;
2464    };
2465    if patch_object {
2466        let Some(name) = positional.get(1).copied() else {
2467            return false;
2468        };
2469        return python_external_class_expression(
2470            target,
2471            source,
2472            bindings,
2473            class_identities,
2474            aliases,
2475        ) && python_plain_string_literal(name, source).is_none_or(|name| name == member);
2476    }
2477    let Some(target_path) = python_plain_string_literal(target, source) else {
2478        return false;
2479    };
2480    class_identities
2481        .iter()
2482        .any(|class| target_path == *class || target_path == format!("{class}.{member}"))
2483}
2484
2485fn python_imported_path(
2486    expression: Node<'_>,
2487    source: &str,
2488    bindings: &[crate::imports::PythonImportBinding],
2489) -> Option<Vec<String>> {
2490    let (root, expression_path) = python_static_path(expression, source)?;
2491    let root_name = node_text(root, source);
2492    if matches!(
2493        python_name_resolution_at(root_name, root, source),
2494        PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal
2495    ) {
2496        return None;
2497    }
2498    let latest_import = bindings
2499        .iter()
2500        .filter(|binding| {
2501            binding.local_name == root_name
2502                && binding.start_byte <= root.start_byte()
2503                && binding.scope_start_byte <= root.start_byte()
2504                && root.end_byte() <= binding.scope_end_byte
2505        })
2506        .map(|binding| binding.start_byte)
2507        .max()?;
2508    let binding = bindings.iter().find(|binding| {
2509        binding.local_name == root_name
2510            && binding.start_byte == latest_import
2511            && binding.scope_start_byte <= root.start_byte()
2512            && root.end_byte() <= binding.scope_end_byte
2513    })?;
2514    let qualified_prefix = if binding.consumed_attributes > 0 {
2515        binding.qualified_name.split('.').next()?
2516    } else {
2517        binding.qualified_name.as_str()
2518    };
2519    let mut qualified_path = qualified_prefix
2520        .split('.')
2521        .map(str::to_owned)
2522        .collect::<Vec<_>>();
2523    qualified_path.extend(expression_path.into_iter().skip(1));
2524    Some(qualified_path)
2525}
2526
2527fn python_imported_class_expression(
2528    expression: Node<'_>,
2529    source: &str,
2530    bindings: &[crate::imports::PythonImportBinding],
2531    class_identities: &[&str],
2532) -> bool {
2533    let Some(path) = python_imported_path(expression, source, bindings) else {
2534        return false;
2535    };
2536    class_identities.iter().any(|identity| {
2537        identity
2538            .split('.')
2539            .map(str::to_owned)
2540            .eq(path.iter().cloned())
2541    })
2542}
2543
2544fn python_static_path<'tree>(
2545    node: Node<'tree>,
2546    source: &str,
2547) -> Option<(Node<'tree>, Vec<String>)> {
2548    let mut current = node;
2549    let mut attributes = Vec::new();
2550    loop {
2551        if current.kind() == "identifier" {
2552            attributes.reverse();
2553            let mut path = Vec::with_capacity(attributes.len() + 1);
2554            path.push(node_text(current, source).to_owned());
2555            path.extend(attributes);
2556            return Some((current, path));
2557        }
2558        if current.kind() != "attribute" {
2559            return None;
2560        }
2561        let attribute = current.child_by_field_name("attribute")?;
2562        attributes.push(node_text(attribute, source).to_owned());
2563        current = current.child_by_field_name("object")?;
2564    }
2565}
2566
2567/// Classify one occurrence of a name bound to the module object.
2568fn module_binding_use(
2569    node: Node<'_>,
2570    source: &str,
2571    bindings: &[crate::imports::PythonImportBinding],
2572    table_aliases: &HashSet<String>,
2573    module: &str,
2574    member: &str,
2575) -> Option<PythonModuleMemberMutation> {
2576    let Some(parent) = node.parent() else {
2577        return Some(PythonModuleMemberMutation::Escape(node_source_range(node)));
2578    };
2579    if matches!(
2580        parent.kind(),
2581        "import_statement" | "import_from_statement" | "aliased_import" | "dotted_name"
2582    ) {
2583        return None;
2584    }
2585    let mut expression = node;
2586    while let Some(parent) = expression.parent() {
2587        if parent.kind() != "attribute"
2588            || parent
2589                .child_by_field_name("object")
2590                .is_none_or(|object| object.id() != expression.id())
2591        {
2592            break;
2593        }
2594        expression = parent;
2595    }
2596    if python_builtin_module_read(expression, source) {
2597        return None;
2598    }
2599    let imported_path = python_imported_path(expression, source, bindings);
2600    let table_alias_path = python_static_path(expression, source).and_then(|(root, suffix)| {
2601        let root_name = node_text(root, source);
2602        table_aliases.contains(root_name).then(|| {
2603            let mut resolved = vec![module.to_owned()];
2604            resolved.extend(suffix.into_iter().skip(1));
2605            resolved
2606        })
2607    });
2608    let Some(path) = imported_path.or(table_alias_path) else {
2609        return Some(PythonModuleMemberMutation::Escape(node_source_range(node)));
2610    };
2611    let path = path.join(".");
2612    let target = format!("{module}.{member}");
2613    if path == target {
2614        return is_binding_target(expression)
2615            .then(|| PythonModuleMemberMutation::Write(node_source_range(expression)));
2616    }
2617    if path == format!("{module}.__dict__") || path == module {
2618        return Some(PythonModuleMemberMutation::Escape(node_source_range(
2619            expression,
2620        )));
2621    }
2622    if path.starts_with(&format!("{module}.")) {
2623        None
2624    } else {
2625        Some(PythonModuleMemberMutation::Escape(node_source_range(node)))
2626    }
2627}
2628
2629fn python_mock_patch_may_replace_module_member(
2630    call: Node<'_>,
2631    source: &str,
2632    bindings: &[crate::imports::PythonImportBinding],
2633    module: &str,
2634    member: &str,
2635) -> bool {
2636    let Some(function) = call.child_by_field_name("function") else {
2637        return false;
2638    };
2639    let Some(path) = python_imported_path(function, source, bindings) else {
2640        return false;
2641    };
2642    if path.len() < 2 || path[path.len() - 2] != "mock" || path[path.len() - 1] != "patch" {
2643        return false;
2644    }
2645    let Some(arguments) = call.child_by_field_name("arguments") else {
2646        return false;
2647    };
2648    let mut cursor = arguments.walk();
2649    let Some(target) = arguments.named_children(&mut cursor).next() else {
2650        return false;
2651    };
2652    let expected = format!("{module}.{member}");
2653    python_plain_string_literal(target, source) == Some(expected.as_str())
2654}
2655
2656#[cfg(test)]
2657mod tests {
2658    use super::*;
2659
2660    fn facts(source: &str) -> PythonRuntimeReadFacts {
2661        let mut parser = tree_sitter::Parser::new();
2662        parser
2663            .set_language(&tree_sitter_python::LANGUAGE.into())
2664            .expect("python grammar loads");
2665        let tree = parser.parse(source, None).expect("fixture parses");
2666        extract_python_runtime_reads(tree.root_node(), source, 64)
2667    }
2668
2669    #[test]
2670    #[brokk_bifrost_test_macros::hardwon(
2671        "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: module alias exhaustion certified mutation-free"
2672    )]
2673    fn qs6_module_mutation_budget_is_not_absence() {
2674        let source = "import os\nos.system = fake\n";
2675        let tree = crate::declarations::parse_python_tree(source).unwrap();
2676        for budget in [0, 1, 2] {
2677            assert_eq!(
2678                python_module_member_mutation(
2679                    tree.root_node(),
2680                    source,
2681                    "test",
2682                    "os",
2683                    "system",
2684                    budget
2685                ),
2686                Some(PythonModuleMemberMutation::Budget),
2687                "budget={budget}"
2688            );
2689        }
2690        assert!(
2691            python_module_member_mutation(tree.root_node(), source, "test", "os", "system", 4096)
2692                .is_some()
2693        );
2694    }
2695
2696    // Python's integer grammar admits radix prefixes and digit separators.
2697    // The u128 representation deliberately refuses larger literal magnitudes.
2698    #[test]
2699    #[brokk_bifrost_test_macros::hardwon(
2700        "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: runtime indices lost radix and separator literals"
2701    )]
2702    fn qs6_runtime_key_shapes() {
2703        use PythonRuntimeAccessKey::{Dynamic, Index, Property, Unsupported};
2704        for (key, expected) in [
2705            ("0", Index(0)),
2706            ("0x1", Index(1)),
2707            ("0b1", Index(1)),
2708            ("0o7", Index(7)),
2709            ("1_0", Index(10)),
2710            ("340282366920938463463374607431768211455", Index(u128::MAX)),
2711            ("340282366920938463463374607431768211456", Unsupported),
2712            ("1:3", Unsupported),
2713            ("1, 2", Unsupported),
2714            ("1,", Unsupported),
2715            ("(1, 2)", Unsupported),
2716            ("name", Dynamic),
2717            ("\"HOME\"", Property("HOME".into())),
2718        ] {
2719            let source = format!("import sys\nvalue = sys.argv[{key}]\n");
2720            let facts = facts(&source);
2721            assert!(facts.complete, "{facts:?}");
2722            assert_eq!(facts.reads.len(), 1, "{facts:?}");
2723            assert_eq!(facts.reads[0].access, expected, "{key}");
2724        }
2725    }
2726
2727    #[test]
2728    fn qs6_runtime_extraction_bounds() {
2729        let source = "pass\n".repeat(100);
2730        let tree = crate::declarations::parse_python_tree(&source).unwrap();
2731        let empty = extract_python_runtime_reads(tree.root_node(), &source, 1);
2732        assert!(empty.complete, "{empty:?}");
2733        assert!(empty.visited_nodes > 64, "{empty:?}");
2734        let source = "import sys\nx = sys.argv[1]\ny = sys.argv[2]\n";
2735        let tree = crate::declarations::parse_python_tree(source).unwrap();
2736        for cap in [0, 1, 2, 3] {
2737            let facts = extract_python_runtime_reads(tree.root_node(), source, cap);
2738            assert_eq!(facts.reads.len(), cap.min(2), "{facts:?}");
2739            assert_eq!(facts.complete, cap > 2, "{facts:?}");
2740        }
2741    }
2742
2743    /// The DataFlowBench template-1 positive shape.
2744    #[test]
2745    fn imported_module_environment_subscript_is_a_pristine_keyed_read() {
2746        let source = "import os\n\n\ndef run():\n    command = os.environ[\"DFB\"]\n    os.system(command)\n";
2747        let facts = facts(source);
2748        assert!(facts.complete, "{facts:#?}");
2749        let [read] = facts.reads.as_slice() else {
2750            panic!("exactly one keyed read: {facts:#?}");
2751        };
2752        assert_eq!(read.root_name, "os");
2753        assert_eq!(read.container, "environ");
2754        assert_eq!(
2755            read.access,
2756            PythonRuntimeAccessKey::Property("DFB".to_owned())
2757        );
2758        assert_eq!(
2759            read.lexical_resolution,
2760            PythonRuntimeRootResolution::ImportedModule
2761        );
2762        assert_eq!(read.mutation, PythonRuntimeMutationEvidence::NoKnownWrite);
2763        assert_eq!(
2764            read.accessor,
2765            PythonRuntimeAccessorCoverage::NoKnownAccessorEffects,
2766            "the only call runs after the read"
2767        );
2768        assert!(facts.writes.is_empty(), "{facts:#?}");
2769        // The structural seed is the container attribute, which is what an
2770        // RQL field-access query addresses.
2771        assert_eq!(
2772            &source[read.candidate_anchor.start_byte..read.candidate_anchor.end_byte],
2773            "os.environ"
2774        );
2775        assert_eq!(
2776            &source[read.range.start_byte..read.range.end_byte],
2777            "os.environ[\"DFB\"]"
2778        );
2779    }
2780
2781    #[test]
2782    fn imported_module_argv_index_is_a_keyed_read() {
2783        let facts = facts("import sys\n\n\ndef run():\n    return sys.argv[1]\n");
2784        let [read] = facts.reads.as_slice() else {
2785            panic!("exactly one keyed read: {facts:#?}");
2786        };
2787        assert_eq!(read.root_name, "sys");
2788        assert_eq!(read.container, "argv");
2789        assert_eq!(read.access, PythonRuntimeAccessKey::Index(1));
2790    }
2791
2792    /// A parameter named `os` is not the module, and the syntax proves it
2793    /// without comparing spellings anywhere else.
2794    #[test]
2795    fn a_shadowed_module_name_is_not_a_read() {
2796        let facts = facts("import os\n\n\ndef run(os):\n    return os.environ[\"DFB\"]\n");
2797        assert!(facts.reads.is_empty(), "{facts:#?}");
2798    }
2799
2800    #[test]
2801    fn a_local_container_name_is_not_a_read() {
2802        let facts =
2803            facts("def run():\n    environ = {\"DFB\": \"x\"}\n    return environ[\"DFB\"]\n");
2804        assert!(facts.reads.is_empty(), "{facts:#?}");
2805        assert!(facts.writes.is_empty(), "{facts:#?}");
2806    }
2807
2808    #[test]
2809    fn an_unrelated_module_spelled_os_is_not_a_read() {
2810        let facts = facts("from mypkg import os\n\n\ndef run():\n    return os.environ[\"DFB\"]\n");
2811        assert!(facts.reads.is_empty(), "{facts:#?}");
2812    }
2813
2814    #[test]
2815    fn a_dynamic_key_stays_a_read_with_no_static_key() {
2816        let facts = facts("import os\n\n\ndef run(name):\n    return os.environ[name]\n");
2817        let [read] = facts.reads.as_slice() else {
2818            panic!("one read: {facts:#?}");
2819        };
2820        assert_eq!(read.access, PythonRuntimeAccessKey::Dynamic);
2821    }
2822
2823    #[test]
2824    fn a_container_write_refutes_every_read_of_that_container() {
2825        let facts = facts(
2826            "import os\n\n\ndef run():\n    os.environ[\"A\"] = \"1\"\n    return os.environ[\"B\"]\n",
2827        );
2828        let [read] = facts.reads.as_slice() else {
2829            panic!("one read: {facts:#?}");
2830        };
2831        assert_eq!(read.mutation, PythonRuntimeMutationEvidence::KnownWrite);
2832        let [write] = facts.writes.as_slice() else {
2833            panic!("one write: {facts:#?}");
2834        };
2835        assert_eq!(write.target, PythonRuntimeWriteTarget::Key);
2836        assert_eq!(write.container.as_deref(), Some("environ"));
2837    }
2838
2839    #[test]
2840    fn a_container_method_receiver_refutes_the_pristine_claim() {
2841        let facts = facts(
2842            "import os\n\n\ndef run():\n    os.environ.update({})\n    return os.environ[\"B\"]\n",
2843        );
2844        let [read] = facts.reads.as_slice() else {
2845            panic!("one read: {facts:#?}");
2846        };
2847        assert_eq!(
2848            read.mutation,
2849            PythonRuntimeMutationEvidence::KnownWriteAndUnknownEffects
2850        );
2851    }
2852
2853    #[test]
2854    fn a_module_object_escape_refutes_every_container() {
2855        let facts =
2856            facts("import os\n\n\ndef run(sink):\n    sink(os)\n    return os.environ[\"B\"]\n");
2857        let [read] = facts.reads.as_slice() else {
2858            panic!("one read: {facts:#?}");
2859        };
2860        assert_eq!(
2861            read.mutation,
2862            PythonRuntimeMutationEvidence::KnownWriteAndUnknownEffects
2863        );
2864        assert!(
2865            facts
2866                .writes
2867                .iter()
2868                .any(|write| write.target == PythonRuntimeWriteTarget::ModuleEscape),
2869            "{facts:#?}"
2870        );
2871    }
2872
2873    #[test]
2874    fn a_call_before_the_read_leaves_accessor_coverage_open() {
2875        let facts = facts(
2876            "import os\n\n\ndef run(prepare):\n    prepare()\n    return os.environ[\"B\"]\n",
2877        );
2878        let [read] = facts.reads.as_slice() else {
2879            panic!("one read: {facts:#?}");
2880        };
2881        assert_eq!(
2882            read.accessor,
2883            PythonRuntimeAccessorCoverage::UnknownAccessorEffects
2884        );
2885    }
2886
2887    /// A call in a sibling function cannot run before this read, so it must
2888    /// not poison it: a reviewed sink call shares a module with its source.
2889    #[test]
2890    fn a_call_in_another_function_does_not_poison_the_read() {
2891        let source = "import os\n\n\ndef other(prepare):\n    prepare()\n\n\ndef run():\n    return os.environ[\"B\"]\n";
2892        let facts = facts(source);
2893        let [read] = facts.reads.as_slice() else {
2894            panic!("one read: {facts:#?}");
2895        };
2896        assert_eq!(
2897            read.accessor,
2898            PythonRuntimeAccessorCoverage::NoKnownAccessorEffects
2899        );
2900    }
2901
2902    #[test]
2903    fn a_call_later_in_the_same_loop_can_precede_the_read() {
2904        let source = "import os\n\n\ndef run(items, prepare):\n    for item in items:\n        value = os.environ[\"B\"]\n        prepare(value)\n";
2905        let facts = facts(source);
2906        let [read] = facts.reads.as_slice() else {
2907            panic!("one read: {facts:#?}");
2908        };
2909        assert_eq!(
2910            read.accessor,
2911            PythonRuntimeAccessorCoverage::UnknownAccessorEffects
2912        );
2913    }
2914
2915    #[test]
2916    fn an_aliased_import_keeps_the_absolute_module_identity() {
2917        let facts =
2918            facts("import os as operating\n\n\ndef run():\n    return operating.environ[\"B\"]\n");
2919        let [read] = facts.reads.as_slice() else {
2920            panic!("one read: {facts:#?}");
2921        };
2922        assert_eq!(read.binding_name, "operating");
2923        assert_eq!(read.root_name, "os");
2924    }
2925
2926    /// `import os` beside `import os.path` binds the same package, which the
2927    /// DataFlowBench propagator cell spells.
2928    #[test]
2929    fn a_submodule_import_agrees_with_its_package_binding() {
2930        let facts =
2931            facts("import os\nimport os.path\n\n\ndef run():\n    return os.environ[\"B\"]\n");
2932        let [read] = facts.reads.as_slice() else {
2933            panic!("one read: {facts:#?}");
2934        };
2935        assert_eq!(read.root_name, "os");
2936    }
2937
2938    #[test]
2939    fn a_module_scope_rebinding_of_the_import_name_is_not_a_read() {
2940        let facts = facts("import os\n\nos = None\n\n\ndef run():\n    return os.environ[\"B\"]\n");
2941        assert!(facts.reads.is_empty(), "{facts:#?}");
2942    }
2943}
2944
2945#[cfg(test)]
2946mod module_member_tests {
2947    use super::*;
2948
2949    fn mutation(source: &str, member: &str) -> Option<PythonModuleMemberMutation> {
2950        let mut parser = tree_sitter::Parser::new();
2951        parser
2952            .set_language(&tree_sitter_python::LANGUAGE.into())
2953            .expect("python grammar loads");
2954        let tree = parser.parse(source, None).expect("fixture parses");
2955        python_module_member_mutation(tree.root_node(), source, "test", "os", member, 4096)
2956    }
2957
2958    #[test]
2959    fn a_member_assignment_refuses_that_member() {
2960        let found = mutation(
2961            "import os\n\n\ndef patch(fake):\n    os.system = fake\n",
2962            "system",
2963        );
2964        assert!(
2965            matches!(found, Some(PythonModuleMemberMutation::Write(_))),
2966            "{found:?}"
2967        );
2968        // A write to another member leaves this one alone.
2969        assert_eq!(
2970            mutation(
2971                "import os\n\n\ndef patch(fake):\n    os.getcwd = fake\n",
2972                "system"
2973            ),
2974            None
2975        );
2976    }
2977
2978    #[test]
2979    fn a_module_object_escape_refuses_every_member() {
2980        for source in [
2981            "import os\n\n\ndef leak(sink):\n    sink(os)\n",
2982            "import os\n\n\nalias = os\n",
2983            "import os\nimport functools\n\n\ndef patch():\n    setattr(os, \"system\", None)\n",
2984        ] {
2985            let found = mutation(source, "system");
2986            assert!(
2987                matches!(found, Some(PythonModuleMemberMutation::Escape(_))),
2988                "{source}: {found:?}"
2989            );
2990        }
2991    }
2992
2993    #[test]
2994    fn a_matching_module_table_read_counts_its_attribute_write() {
2995        let found = mutation(
2996            "import sys\n\n\ndef patch(fake):\n    sys.modules[\"os\"].system = fake\n",
2997            "system",
2998        );
2999        assert!(
3000            matches!(found, Some(PythonModuleMemberMutation::Write(_))),
3001            "{found:?}"
3002        );
3003    }
3004
3005    #[test]
3006    fn a_file_that_never_binds_the_module_proves_the_absence() {
3007        assert_eq!(mutation("def run():\n    return 1\n", "system"), None);
3008    }
3009}
3010
3011#[cfg(test)]
3012mod shadowing_tests {
3013    use super::*;
3014
3015    fn facts(source: &str) -> PythonRuntimeReadFacts {
3016        let mut parser = tree_sitter::Parser::new();
3017        parser
3018            .set_language(&tree_sitter_python::LANGUAGE.into())
3019            .expect("python grammar loads");
3020        let tree = parser.parse(source, None).expect("fixture parses");
3021        extract_python_runtime_reads(tree.root_node(), source, 64)
3022    }
3023
3024    /// A parameter that spells the module name is not the module object, so it
3025    /// is not an escape and must not refute another function's pristine read.
3026    #[test]
3027    fn a_parameter_spelling_the_module_name_is_not_an_escape() {
3028        let source = "import os\n\n\ndef read_shadowed(os):\n    return os.environ[\"A\"]\n\n\ndef read_real():\n    return os.environ[\"B\"]\n";
3029        let facts = facts(source);
3030        assert!(facts.writes.is_empty(), "{facts:#?}");
3031        let [read] = facts.reads.as_slice() else {
3032            panic!("only the unshadowed read is a candidate: {facts:#?}");
3033        };
3034        assert_eq!(read.mutation, PythonRuntimeMutationEvidence::NoKnownWrite);
3035        assert_eq!(
3036            &source[read.key_range.start_byte..read.key_range.end_byte],
3037            "\"B\""
3038        );
3039    }
3040
3041    #[test]
3042    fn a_parameter_spelling_the_module_name_does_not_refuse_a_member() {
3043        let source =
3044            "import os\n\n\ndef helper(os):\n    return os\n\n\ndef run(c):\n    os.system(c)\n";
3045        let mut parser = tree_sitter::Parser::new();
3046        parser
3047            .set_language(&tree_sitter_python::LANGUAGE.into())
3048            .expect("python grammar loads");
3049        let tree = parser.parse(source, None).expect("fixture parses");
3050        assert_eq!(
3051            python_module_member_mutation(tree.root_node(), source, "test", "os", "system", 4096),
3052            None
3053        );
3054    }
3055}
3056
3057#[cfg(test)]
3058mod external_class_mutation_tests {
3059    use super::*;
3060    use brokk_bifrost_test_macros::hardwon;
3061
3062    fn mutation(source: &str) -> Option<PythonModuleMemberMutation> {
3063        let mut parser = tree_sitter::Parser::new();
3064        parser
3065            .set_language(&tree_sitter_python::LANGUAGE.into())
3066            .expect("python grammar loads");
3067        let tree = parser.parse(source, None).expect("fixture parses");
3068        python_external_class_member_mutation(
3069            tree.root_node(),
3070            source,
3071            &["paramiko.client.SSHClient"],
3072            "set_missing_host_key_policy",
3073            4096,
3074        )
3075    }
3076
3077    #[test]
3078    fn exact_imported_class_member_writes_are_found() {
3079        for source in [
3080            "from paramiko.client import SSHClient as Client\n\nClient.set_missing_host_key_policy = fake\n",
3081            "import paramiko.client as client\n\nclient.SSHClient.set_missing_host_key_policy = fake\n",
3082            "from paramiko.client import SSHClient as Client\n\nsetattr(Client, 'set_missing_host_key_policy', fake)\n",
3083            "from paramiko.client import SSHClient as Client\n\nsetattr(Client, name, fake)\n",
3084        ] {
3085            assert!(
3086                matches!(mutation(source), Some(PythonModuleMemberMutation::Write(_))),
3087                "{source}"
3088            );
3089        }
3090    }
3091
3092    #[test]
3093    #[hardwon("#4411: class aliases and mock patch APIs left external method dispatch exact")]
3094    fn class_aliases_and_mock_patch_apis_refute_method_closure() {
3095        for source in [
3096            "from paramiko.client import SSHClient as C\nAlias = C\nAlias.set_missing_host_key_policy = fake\n",
3097            "from paramiko.client import SSHClient as C\nAlias = C\nsetattr(Alias, 'set_missing_host_key_policy', fake)\n",
3098            "from unittest import mock\nfrom paramiko.client import SSHClient as C\nmock.patch.object(C, 'set_missing_host_key_policy', fake)\n",
3099            "from unittest.mock import patch\nfrom paramiko.client import SSHClient as C\npatch.object(C, 'set_missing_host_key_policy', fake)\n",
3100            "from unittest import mock\nmock.patch('paramiko.client.SSHClient.set_missing_host_key_policy', fake)\n",
3101        ] {
3102            assert!(
3103                matches!(mutation(source), Some(PythonModuleMemberMutation::Write(_))),
3104                "{source}"
3105            );
3106        }
3107    }
3108
3109    #[test]
3110    #[hardwon("#4411: an escaped class alias left external method dispatch exact")]
3111    fn an_escaped_class_alias_refutes_method_closure() {
3112        assert!(matches!(
3113            mutation("from paramiko.client import SSHClient as C\nAlias = C\nconsume(Alias)\n"),
3114            Some(PythonModuleMemberMutation::Escape(_))
3115        ));
3116    }
3117
3118    #[test]
3119    fn constructor_uses_and_same_named_workspace_classes_are_not_writes() {
3120        assert_eq!(
3121            mutation(
3122                "from paramiko.client import SSHClient as Client\n\n\ndef connect():\n    client = Client()\n    client.set_missing_host_key_policy(policy)\n"
3123            ),
3124            None
3125        );
3126        assert_eq!(
3127            mutation(
3128                "class SSHClient:\n    pass\n\nSSHClient.set_missing_host_key_policy = fake\n"
3129            ),
3130            None
3131        );
3132    }
3133}
3134
3135#[cfg(test)]
3136mod module_member_mutation_tests {
3137    use super::*;
3138    use brokk_bifrost_test_macros::hardwon;
3139
3140    fn mutation(source: &str) -> Option<PythonModuleMemberMutation> {
3141        mutation_in(source, "pkg.M")
3142    }
3143
3144    fn mutation_in(source: &str, source_module: &str) -> Option<PythonModuleMemberMutation> {
3145        mutation_for_module(source, source_module, "pkg.M")
3146    }
3147
3148    fn mutation_for_module(
3149        source: &str,
3150        source_module: &str,
3151        module: &str,
3152    ) -> Option<PythonModuleMemberMutation> {
3153        let mut parser = tree_sitter::Parser::new();
3154        parser
3155            .set_language(&tree_sitter_python::LANGUAGE.into())
3156            .expect("python grammar loads");
3157        let tree = parser.parse(source, None).expect("fixture parses");
3158        python_module_member_mutation(
3159            tree.root_node(),
3160            source,
3161            source_module,
3162            module,
3163            "NAME",
3164            4096,
3165        )
3166    }
3167
3168    #[test]
3169    #[hardwon(
3170        "#4622: writes through imported module aliases and mock patch APIs must keep a global class unknown"
3171    )]
3172    fn imported_module_member_mutation_covers_dynamic_write_forms() {
3173        for source in [
3174            "import pkg.M as target\ntarget.NAME = fake\n",
3175            "import pkg.M as target\ndel target.NAME\n",
3176            "import pkg.M as target\nsetattr(target, 'NAME', fake)\n",
3177            "import pkg.M as target\nsetattr(target, name, fake)\n",
3178            "import pkg.M as target\ntarget.__dict__['NAME'] = fake\n",
3179            "from unittest import mock\nmock.patch('pkg.M.NAME', fake)\n",
3180            "from unittest.mock import patch\npatch('pkg.M.NAME', fake)\n",
3181            "import pkg.M as target\nfrom unittest import mock\nmock.patch.object(target, 'NAME', fake)\n",
3182        ] {
3183            assert!(mutation(source).is_some(), "{source}");
3184        }
3185        assert_eq!(
3186            None,
3187            mutation("from unittest import mock\nmock.patch('pkg.Other.NAME', fake)\n")
3188        );
3189    }
3190
3191    #[test]
3192    #[hardwon("#4622: unrelated sys.modules reads blocked exact module-global class proofs")]
3193    fn sys_modules_reads_are_scoped_by_literal_module_identity() {
3194        assert_eq!(
3195            None,
3196            mutation("import sys\nother = sys.modules.get('torch')\n")
3197        );
3198        assert_eq!(None, mutation("import sys\nother = sys.modules['torch']\n"));
3199        assert!(matches!(
3200            mutation("import sys\nmodule = sys.modules.get('pkg.M')\nmodule.NAME = fake\n"),
3201            Some(PythonModuleMemberMutation::Write(_))
3202        ));
3203        assert!(matches!(
3204            mutation("import sys\nmodule = sys.modules['pkg.M']\nmodule.NAME = fake\n"),
3205            Some(PythonModuleMemberMutation::Write(_))
3206        ));
3207        assert_eq!(
3208            None,
3209            mutation("import sys\nmodule = sys.modules['pkg.M']\nconsume(module.NAME)\n")
3210        );
3211        assert!(matches!(
3212            mutation("import sys\nmodule = sys.modules[__name__]\nmodule.NAME = fake\n"),
3213            Some(PythonModuleMemberMutation::Write(_))
3214        ));
3215        assert_eq!(
3216            None,
3217            mutation_in("import sys\nmodule = sys.modules[__name__]\n", "pkg.Other")
3218        );
3219        for source in [
3220            "import sys\nsys.modules['pkg.M'] = fake\n",
3221            "import sys\nsys.modules.setdefault('pkg.M', fake)\n",
3222            "import sys\nsys.modules.update({'pkg.M': fake})\n",
3223            "import sys\nconsume(sys.modules)\n",
3224        ] {
3225            assert!(
3226                matches!(
3227                    mutation(source),
3228                    Some(PythonModuleMemberMutation::Escape(_))
3229                ),
3230                "{source}"
3231            );
3232        }
3233        for source in [
3234            "import sys\nmodule = sys.modules[module_name]\n",
3235            "import sys\ndel sys.modules['torch']\n",
3236            "import sys\nsys.modules.pop('torch')\n",
3237            "import sys\nsys.modules.clear()\n",
3238            "class Shim: pass\nimport sys\nsys.modules['shim'] = Shim()\n",
3239            "class Shim: pass\nimport sys\nsys.modules.update({'shim': Shim()})\n",
3240            "import sys\nimport types\nsys.modules['shim'] = types.ModuleType('shim')\n",
3241            "import sys\nsys.modules['literal'] = None\n",
3242            "import pkg.Other as shim\nimport sys\nsys.modules['shim'] = shim\n",
3243        ] {
3244            assert_eq!(mutation(source), None, "{source}");
3245        }
3246    }
3247
3248    #[test]
3249    #[hardwon("#4622: sys.modules values must be followed by module identity")]
3250    fn dynamic_module_table_reads_follow_only_writes_through_the_possible_alias() {
3251        assert!(matches!(
3252            mutation("import pkg.M as target\nimport sys\nsys.modules['shim'] = target\n"),
3253            Some(PythonModuleMemberMutation::Escape(_))
3254        ));
3255        assert_eq!(
3256            mutation("import sys\ntarget = sys.modules[module_name]\nconsume(target.NAME)\n"),
3257            None
3258        );
3259        assert_eq!(
3260            mutation(
3261                "import sys\ntarget = sys.modules[module_name]\nconsume(getattr(target, 'NAME'))\nconsume(hasattr(target, 'NAME'))\n"
3262            ),
3263            None
3264        );
3265        assert_eq!(
3266            mutation("import sys\nconsume(sys.modules[module_name].NAME)\n"),
3267            None
3268        );
3269        assert!(matches!(
3270            mutation("import sys\ntarget = sys.modules[module_name]\ntarget.NAME = fake\n"),
3271            Some(PythonModuleMemberMutation::Write(_))
3272        ));
3273    }
3274
3275    #[test]
3276    #[hardwon("#4622: a dominating string filter excludes unrelated dynamic sys.modules aliases")]
3277    fn dynamic_sys_modules_keys_respect_dominating_string_filters() {
3278        assert_eq!(
3279            mutation_for_module(
3280                "import sys\ndef patch():\n    for module_name in module_names:\n        if 'huggingface' in module_name or module_name.startswith('tqdm'):\n            target = sys.modules[module_name]\n            target.NAME = fake\n",
3281                "pkg.M",
3282                "backend.backends",
3283            ),
3284            None
3285        );
3286        assert!(matches!(
3287            mutation_for_module(
3288                "import sys\ndef patch():\n    for module_name in module_names:\n        if module_name.startswith('backend'):\n            target = sys.modules[module_name]\n            target.NAME = fake\n",
3289                "pkg.M",
3290                "backend.backends",
3291            ),
3292            Some(PythonModuleMemberMutation::Write(_))
3293        ));
3294        assert!(matches!(
3295            mutation_for_module(
3296                "import sys\ndef patch(allowed):\n    for module_name in module_names:\n        if module_name in allowed:\n            target = sys.modules[module_name]\n            target.NAME = fake\n",
3297                "pkg.M",
3298                "backend.backends",
3299            ),
3300            Some(PythonModuleMemberMutation::Write(_))
3301        ));
3302        assert!(matches!(
3303            mutation_for_module(
3304                "import sys\ndef patch():\n    for module_name in module_names:\n        if 'huggingface' in module_name:\n            module_name = 'backend.backends'\n            target = sys.modules[module_name]\n            target.NAME = fake\n",
3305                "pkg.M",
3306                "backend.backends",
3307            ),
3308            Some(PythonModuleMemberMutation::Write(_))
3309        ));
3310        assert_eq!(
3311            mutation_for_module(
3312                "import sys\ndef patch():\n    for module_name in module_names:\n        if not ('huggingface' in module_name):\n            continue\n        target = sys.modules[module_name]\n        target.NAME = fake\n",
3313                "pkg.M",
3314                "backend.backends",
3315            ),
3316            None
3317        );
3318    }
3319}