Skip to main content

brokk_bifrost_python/
runtime_values.rs

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