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