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