Skip to main content

brokk_bifrost_python/graph/
extractor.rs

1//! Python's forward, per-target usage scan: the scoped import closure, the
2//! per-file walk that proves a reference, and the receiver-type facts both this
3//! and the inverted walk resolve through.
4
5use crate::bindings::{
6    PythonLexicalNameResolution, python_comprehension_binds_name_at,
7    python_is_type_parameter_binder, python_name_resolution_at,
8    python_type_parameter_binds_name_at,
9};
10use crate::graph::PythonGraphSource;
11use crate::graph::hits::{
12    record_hit, record_import_hit, record_self_receiver_hit, record_unproven_hit,
13};
14use crate::graph::resolver::{
15    annotation_class_qualifier_site, annotation_reference_candidates, member_name,
16    normalized_receiver_type, receiver_annotation_matches_target,
17    resolve_callable_parameter_default_types, resolve_constructor_types, resolve_receiver_type,
18    resolve_visible_named_import_candidates, resolved_member_declarations, target_owner_code_unit,
19    top_level_identifier,
20};
21use crate::graph_support::{PythonSource, PythonUsageSource};
22use crate::imports::{
23    PythonImportBinding, PythonImportDetails, imported_module_assignment_at,
24    python_function_scoped_import_binding, python_import_bindings_from_imports,
25    python_import_details, python_imports_by_statement, resolve_fqn_candidates,
26    resolve_python_relative_module,
27};
28use crate::syntax::{
29    attribute_chain, leftmost_identifier, python_plain_string_literal,
30    python_reference_is_deferred_function_body,
31};
32use crate::usage_index::{
33    ModuleBindingEvent, ModuleBindingEventKind, ModuleBindingTimeline, PythonScopeFacts,
34    usage_matching_edges, usage_module_binding_timeline, usage_resolve_module_files,
35    usage_scope_facts,
36};
37pub use brokk_bifrost_core::analyzer::common::node_source_text as slice;
38use brokk_bifrost_core::analyzer::common::nonempty_node_source_text as non_empty_node_text;
39use brokk_bifrost_core::analyzer::model::ImportInfo;
40use brokk_bifrost_core::analyzer::symbol_path::parse_symbol_path;
41use brokk_bifrost_core::analyzer::tree_walk::parent_if_field;
42use brokk_bifrost_core::analyzer::usages::local_inference::{
43    LocalBindingsSnapshot, LocalInferenceConfig, LocalInferenceEngine, SymbolResolution,
44};
45use brokk_bifrost_core::analyzer::usages::model::{ImportKind, UsageHit};
46use brokk_bifrost_core::analyzer::usages::{ImportEdge, ImportEdgeKind};
47use brokk_bifrost_core::analyzer::{CodeUnit, CodeUnitIndex, Language, ProjectFile, Range};
48use brokk_bifrost_core::cancellation::CancellationToken;
49use brokk_bifrost_core::hash::{HashMap, HashSet};
50use brokk_bifrost_core::text_utils::compute_line_starts;
51use rayon::prelude::*;
52use std::collections::BTreeSet;
53use std::sync::Arc;
54use tree_sitter::{Node, Parser, Tree};
55
56pub struct ParsedFile {
57    pub source: Arc<String>,
58    pub tree: Tree,
59}
60
61pub struct PythonProjectGraph {
62    parsed: HashMap<ProjectFile, ParsedFile>,
63}
64
65impl PythonProjectGraph {
66    pub fn scan_files(
67        &self,
68        candidate_files: &HashSet<ProjectFile>,
69        target_file: &ProjectFile,
70    ) -> HashSet<ProjectFile> {
71        candidate_files
72            .iter()
73            .cloned()
74            .chain(std::iter::once(target_file.clone()))
75            .collect()
76    }
77}
78
79pub fn build_python_graph(
80    candidate_files: &HashSet<ProjectFile>,
81    target_file: &ProjectFile,
82    cancellation: Option<&CancellationToken>,
83) -> PythonProjectGraph {
84    let parser_language = tree_sitter_python::LANGUAGE.into();
85    let files: HashSet<ProjectFile> = candidate_files
86        .iter()
87        .cloned()
88        .chain(std::iter::once(target_file.clone()))
89        .collect();
90    let mut parsed = HashMap::default();
91
92    for file in files {
93        if cancellation.is_some_and(CancellationToken::is_cancelled) {
94            break;
95        }
96        let Ok(source) = file.read_to_string() else {
97            continue;
98        };
99        if cancellation.is_some_and(CancellationToken::is_cancelled) {
100            break;
101        }
102        if source.is_empty() {
103            continue;
104        }
105        let mut parser = Parser::new();
106        if parser.set_language(&parser_language).is_err() {
107            continue;
108        }
109        let Some(tree) = parser.parse(source.as_str(), None) else {
110            continue;
111        };
112        if cancellation.is_some_and(CancellationToken::is_cancelled) {
113            break;
114        }
115        parsed.insert(
116            file,
117            ParsedFile {
118                source: Arc::new(source),
119                tree,
120            },
121        );
122    }
123
124    PythonProjectGraph { parsed }
125}
126
127/// Immutable target facts shared by every file in one forward usage scan.
128pub struct PythonScanTarget<'a> {
129    target: &'a CodeUnit,
130    target_short: String,
131    target_member: Option<String>,
132    target_owner: Option<CodeUnit>,
133    member_unique_in_target_file: bool,
134}
135
136impl<'a> PythonScanTarget<'a> {
137    /// Derive all target facts once before the per-file frontier evaluates.
138    pub fn new(index: &dyn CodeUnitIndex, target: &'a CodeUnit) -> Self {
139        let target_member = member_name(index, target);
140        // A same-file best-effort for unresolvable receivers is only safe when
141        // the member name is unambiguous in the target's file (exactly one
142        // class there declares it), so `recv.member` can only mean the target.
143        let member_unique_in_target_file = target_member.as_deref().is_some_and(|member| {
144            let owners: HashSet<CodeUnit> = index
145                .declarations(target.source())
146                .into_iter()
147                .filter(|decl| {
148                    decl.identifier() == member && target_owner_code_unit(index, decl).is_some()
149                })
150                .filter_map(|decl| target_owner_code_unit(index, &decl))
151                .collect();
152            owners.len() == 1
153        });
154        Self {
155            target,
156            target_short: top_level_identifier(index, target),
157            target_member,
158            target_owner: target_owner_code_unit(index, target),
159            member_unique_in_target_file,
160        }
161    }
162}
163
164/// Scan a set of files, retaining the historical whole-set API for callers
165/// that do not need per-file frontier replay.
166pub fn scan_files_for_seeds(
167    graph: &PythonGraphSource<'_>,
168    python: &dyn PythonUsageSource,
169    project_graph: &PythonProjectGraph,
170    files: &HashSet<ProjectFile>,
171    target: &CodeUnit,
172    seeds: &BTreeSet<(ProjectFile, String)>,
173    cancellation: Option<&CancellationToken>,
174) -> Result<ScanResult, HashSet<ProjectFile>> {
175    let scan_target = PythonScanTarget::new(graph.index, target);
176    let results = files
177        .par_iter()
178        .map(|file| {
179            scan_file_for_seeds(
180                graph,
181                python,
182                project_graph,
183                file,
184                &scan_target,
185                seeds,
186                cancellation,
187            )
188        })
189        .collect::<Vec<_>>();
190    let mut collected = ScanResult::default();
191    let mut missing = HashSet::default();
192    for result in results {
193        match result {
194            Ok(result) => {
195                collected.hits.extend(result.hits);
196                collected.unproven_hits.extend(result.unproven_hits);
197            }
198            Err(file) => {
199                missing.insert(file);
200            }
201        }
202    }
203    if missing.is_empty() {
204        Ok(collected)
205    } else {
206        Err(missing)
207    }
208}
209
210/// Scan one file against one target. The owned result is suitable for replay
211/// by a relational item frontier: no provisional result is published through
212/// shared state.
213pub fn scan_file_for_seeds(
214    graph: &PythonGraphSource<'_>,
215    python: &dyn PythonUsageSource,
216    project_graph: &PythonProjectGraph,
217    file: &ProjectFile,
218    scan_target: &PythonScanTarget<'_>,
219    seeds: &BTreeSet<(ProjectFile, String)>,
220    cancellation: Option<&CancellationToken>,
221) -> Result<ScanResult, ProjectFile> {
222    let parser_language = tree_sitter_python::LANGUAGE.into();
223    let target = scan_target.target;
224    let target_short = scan_target.target_short.as_str();
225    let target_member = scan_target.target_member.as_deref();
226    let target_owner = scan_target.target_owner.as_ref();
227
228    if cancellation.is_some_and(CancellationToken::is_cancelled) {
229        return Ok(ScanResult::default());
230    }
231    let owned_source: Option<Arc<String>>;
232    let owned_tree: Option<Tree>;
233    let (source_str, tree_ref) = {
234        let _scope = brokk_bifrost_core::profiling::scope("python_graph::read_or_parse_candidate");
235        if let Some(parsed) = project_graph.parsed.get(file) {
236            (parsed.source.as_str(), &parsed.tree)
237        } else {
238            let Ok(source) = file.read_to_string() else {
239                return Ok(ScanResult::default());
240            };
241            if source.is_empty() {
242                return Ok(ScanResult::default());
243            }
244            let mut parser = Parser::new();
245            if parser.set_language(&parser_language).is_err() {
246                return Ok(ScanResult::default());
247            }
248            let Some(tree) = parser.parse(source.as_str(), None) else {
249                return Ok(ScanResult::default());
250            };
251            owned_source = Some(Arc::new(source));
252            owned_tree = Some(tree);
253            (
254                owned_source.as_deref().unwrap().as_str(),
255                owned_tree.as_ref().unwrap(),
256            )
257        }
258    };
259    if cancellation.is_some_and(CancellationToken::is_cancelled) {
260        return Ok(ScanResult::default());
261    }
262
263    let edges = {
264        let _scope = brokk_bifrost_core::profiling::scope("python_graph::matching_edges");
265        usage_matching_edges(python, file, seeds)
266    };
267    // This is an AST-name gate, not a source-text resolver. Every usage
268    // accepted by the later structural walk has a matching identifier or
269    // can occur inside an annotation string. Skip files that lack both
270    // before building their scope facts and performing that expensive walk.
271    let may_reference_target = {
272        let _scope = brokk_bifrost_core::profiling::scope("python_graph::target_ast_gate");
273        file_may_reference_target(
274            tree_ref.root_node(),
275            source_str,
276            target,
277            target_short,
278            target_member,
279            &edges,
280        )
281    };
282    if !may_reference_target {
283        return Ok(ScanResult::default());
284    }
285    let imports = python.import_info_of(graph.token, file);
286    let raw_module_bindings = {
287        let _scope = brokk_bifrost_core::profiling::scope("python_graph::module_binding_timeline");
288        usage_module_binding_timeline(python, file, || {
289            collect_module_binding_timeline(tree_ref.root_node(), source_str, &imports)
290        })
291    };
292    let module_bindings =
293        classify_module_binding_timeline(python, file, raw_module_bindings.as_ref(), seeds, &edges);
294    let scoped_import_bindings =
295        python_import_bindings_from_imports(&imports, tree_ref.root_node(), source_str.len());
296    let target_self_file = file == target.source();
297    let scope_facts = {
298        let _scope = brokk_bifrost_core::profiling::scope("python_graph::scope_facts");
299        usage_scope_facts(python, file, || {
300            collect_scope_facts_from_parsed_source(
301                graph,
302                python,
303                file,
304                source_str,
305                tree_ref.root_node(),
306            )
307        })
308    };
309    let Some(scope_facts) = scope_facts else {
310        return Err(file.clone());
311    };
312    let scope_range_index = {
313        let _scope = brokk_bifrost_core::profiling::scope("python_graph::scope_range_index");
314        build_scope_range_index(graph, scope_facts.as_ref())
315    };
316
317    let mut local_hits = BTreeSet::new();
318    let mut local_unproven_hits = BTreeSet::new();
319    let line_starts = compute_line_starts(source_str);
320
321    let mut scan_ctx = ScanCtx {
322        python,
323        file,
324        source: source_str,
325        line_starts: &line_starts,
326        graph,
327        target,
328        target_short,
329        target_member,
330        target_owner: target_owner.cloned(),
331        target_is_module: target.is_module(),
332        target_source: target.source(),
333        seeds,
334        edges: &edges,
335        target_self_file,
336        member_best_effort_unique: target_self_file && scan_target.member_unique_in_target_file,
337        raw_module_bindings: raw_module_bindings.as_ref(),
338        module_bindings: &module_bindings,
339        scoped_import_bindings: &scoped_import_bindings,
340        scope_facts: scope_facts.as_ref(),
341        scope_range_index: &scope_range_index,
342        hits: &mut local_hits,
343        unproven_hits: &mut local_unproven_hits,
344    };
345
346    {
347        let _scope = brokk_bifrost_core::profiling::scope("python_graph::scan_tree");
348        scan_node(tree_ref.root_node(), &mut scan_ctx);
349    }
350
351    Ok(ScanResult {
352        hits: local_hits,
353        unproven_hits: local_unproven_hits,
354    })
355}
356
357fn file_may_reference_target(
358    root: Node<'_>,
359    source: &str,
360    target: &CodeUnit,
361    target_short: &str,
362    target_member: Option<&str>,
363    edges: &[ImportEdge],
364) -> bool {
365    if target.is_module() {
366        return true;
367    }
368
369    let mut stack = vec![root];
370    while let Some(node) = stack.pop() {
371        if node.kind() == "string" {
372            // Annotation strings can use an FQN or a re-export alias that is
373            // not an exact identifier node. Keep them for the structured
374            // annotation resolver below.
375            return true;
376        }
377        if node.kind() == "identifier" {
378            let name = slice(node, source);
379            if name == target_short
380                || target_member.is_some_and(|member| name == member)
381                || edges.iter().any(|edge| edge.local_name == name)
382            {
383                return true;
384            }
385        }
386
387        let mut cursor = node.walk();
388        stack.extend(node.named_children(&mut cursor));
389    }
390    false
391}
392
393#[derive(Default)]
394pub struct ScanResult {
395    pub hits: BTreeSet<UsageHit>,
396    pub unproven_hits: BTreeSet<UsageHit>,
397}
398
399pub struct ScanCtx<'a> {
400    python: &'a dyn PythonUsageSource,
401    pub file: &'a ProjectFile,
402    pub source: &'a str,
403    pub line_starts: &'a [usize],
404    pub graph: &'a PythonGraphSource<'a>,
405    target: &'a CodeUnit,
406    target_short: &'a str,
407    target_member: Option<&'a str>,
408    target_owner: Option<CodeUnit>,
409    target_is_module: bool,
410    target_source: &'a ProjectFile,
411    seeds: &'a BTreeSet<(ProjectFile, String)>,
412    edges: &'a [ImportEdge],
413    target_self_file: bool,
414    /// True when a same-file best-effort is justified for an unresolvable
415    /// receiver: the target is a member, its owner is in this file, and exactly
416    /// one class in this file declares that member name (so `recv.member` with
417    /// an un-inferrable `recv` unambiguously means the target). Cross-file
418    /// untyped receivers stay conservative.
419    member_best_effort_unique: bool,
420    raw_module_bindings: &'a ModuleBindingTimeline,
421    module_bindings: &'a HashMap<String, Vec<ClassifiedModuleBindingEvent>>,
422    scoped_import_bindings: &'a [PythonImportBinding],
423    scope_facts: &'a HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
424    scope_range_index: &'a [ScopeRangeEntry],
425    pub hits: &'a mut BTreeSet<UsageHit>,
426    pub unproven_hits: &'a mut BTreeSet<UsageHit>,
427}
428
429struct ScopeRangeEntry {
430    range: Range,
431    scope: CodeUnit,
432    prefix_max_end: usize,
433}
434
435fn build_scope_range_index(
436    graph: &PythonGraphSource<'_>,
437    scope_facts: &HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
438) -> Vec<ScopeRangeEntry> {
439    let mut entries = scope_facts
440        .keys()
441        .flat_map(|scope| {
442            graph
443                .index
444                .ranges(scope)
445                .into_iter()
446                .map(|range| ScopeRangeEntry {
447                    range,
448                    scope: scope.clone(),
449                    prefix_max_end: 0,
450                })
451        })
452        .collect::<Vec<_>>();
453    entries.sort_by(|left, right| {
454        left.range
455            .start_byte
456            .cmp(&right.range.start_byte)
457            .then_with(|| right.range.end_byte.cmp(&left.range.end_byte))
458            .then_with(|| left.scope.cmp(&right.scope))
459    });
460    let mut max_end = 0;
461    for entry in &mut entries {
462        max_end = max_end.max(entry.range.end_byte);
463        entry.prefix_max_end = max_end;
464    }
465    entries
466}
467
468fn indexed_scope_entry<'entry, 'facts>(
469    scope_range_index: &'entry [ScopeRangeEntry],
470    scope_facts: &'facts HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
471    node: Node<'_>,
472    mut skip_innermost: usize,
473) -> Option<(&'entry CodeUnit, &'facts LocalBindingsSnapshot<String>)> {
474    let mut cursor =
475        scope_range_index.partition_point(|entry| entry.range.start_byte <= node.start_byte());
476    while cursor > 0 {
477        cursor -= 1;
478        let entry = &scope_range_index[cursor];
479        if entry.prefix_max_end < node.end_byte() {
480            return None;
481        }
482        if entry.range.end_byte >= node.end_byte() {
483            if skip_innermost > 0 {
484                skip_innermost -= 1;
485                continue;
486            }
487            return scope_facts
488                .get(&entry.scope)
489                .map(|facts| (&entry.scope, facts));
490        }
491    }
492    None
493}
494
495/// The per-function receiver-type facts enclosing `node`, if any. Shared by the
496/// forward scan ([`ScanCtx`]) and the inverted builder (`PyScan`) so the two
497/// paths resolve a receiver's scope through one place.
498pub fn enclosing_scope_facts<'a>(
499    index: &dyn CodeUnitIndex,
500    file: &ProjectFile,
501    scope_facts: &'a HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
502    node: Node<'_>,
503) -> Option<&'a LocalBindingsSnapshot<String>> {
504    let range = Range {
505        start_byte: node.start_byte(),
506        end_byte: node.end_byte(),
507        start_line: 0,
508        end_line: 0,
509    };
510    let enclosing = index.enclosing_code_unit(file, &range)?;
511    scope_facts.get(&enclosing)
512}
513
514impl ScanCtx<'_> {
515    fn scope_entry_for_node(
516        &self,
517        node: Node<'_>,
518    ) -> Option<(&CodeUnit, &LocalBindingsSnapshot<String>)> {
519        indexed_scope_entry(
520            self.scope_range_index,
521            self.scope_facts,
522            node,
523            usize::from(function_declaration_expression_is_outer_scoped(node)),
524        )
525    }
526
527    fn scope_facts_for_node(&self, node: Node<'_>) -> Option<&LocalBindingsSnapshot<String>> {
528        self.scope_entry_for_node(node).map(|(_, facts)| facts)
529    }
530
531    fn binds_target(&self, ident: &str, node: Node<'_>) -> bool {
532        if python_comprehension_binds_name_at(ident, node, self.source)
533            || python_type_parameter_binds_name_at(ident, node, self.source)
534        {
535            return false;
536        }
537        if self.target_self_file && ident == self.target_short {
538            match python_name_resolution_at(ident, node, self.source) {
539                PythonLexicalNameResolution::Global => return true,
540                PythonLexicalNameResolution::Local | PythonLexicalNameResolution::Nonlocal => {
541                    return false;
542                }
543                PythonLexicalNameResolution::Unbound => {}
544            }
545        }
546        let scope_entry = self.scope_entry_for_node(node);
547        if self.target_self_file
548            && ident == self.target_short
549            && scope_entry
550                .is_none_or(|(scope, facts)| scope.is_module() || !facts.is_shadowed(ident))
551        {
552            return true;
553        }
554        if scope_entry.is_some_and(|(scope, facts)| !scope.is_module() && facts.is_shadowed(ident))
555        {
556            return false;
557        }
558        self.module_binding_targets_query(ident, node)
559    }
560
561    fn receiver_binds_target(&self, expr: &str, node: Node<'_>) -> bool {
562        if self.binds_target(expr, node) {
563            return true;
564        }
565
566        if self.target_member.is_some() && self.import_edge_visible_for(expr, node) {
567            return true;
568        }
569
570        // `self`/`cls` is implicitly typed as the enclosing class, so a same-file
571        // `self.member` access is a usage of that class's member even though the
572        // receiver is never assigned a type the way a local or parameter is.
573        if matches!(expr, "self" | "cls") && self.self_receiver_matches_target(node) {
574            return true;
575        }
576
577        match enclosing_runtime_parameter_type(expr, node, self.source) {
578            EnclosingParameterType::Typed(raw_type) => {
579                return self.receiver_type_matches_target(&raw_type);
580            }
581            EnclosingParameterType::Untyped => return false,
582            EnclosingParameterType::NotDeclared => {}
583        }
584
585        let Some(scope_facts) = self.scope_facts_for_node(node) else {
586            return false;
587        };
588        let resolution = scope_facts.resolution_for(expr);
589        let Some(raw_type) = resolution
590            .as_precise()
591            .and_then(|targets| targets.iter().next())
592        else {
593            return false;
594        };
595        self.receiver_type_matches_target(raw_type)
596    }
597
598    fn importlib_assignment_targets_query(&self, receiver: Node<'_>, node: Node<'_>) -> bool {
599        if receiver.kind() != "identifier" {
600            return false;
601        }
602        let local = slice(receiver, self.source);
603        let Some(module) =
604            imported_module_assignment_at(node, local, self.source, |importlib_local| {
605                self.importlib_binding_visible(importlib_local, node)
606            })
607        else {
608            return false;
609        };
610        self.graph
611            .index
612            .parent_of(self.target)
613            .filter(CodeUnit::is_module)
614            .is_some_and(|owner| {
615                owner.fq_name() == module
616                    || resolve_fqn_candidates(self.python, Some(self.file), &module, |name| {
617                        self.graph.index.definitions(name).collect()
618                    })
619                    .into_iter()
620                    .any(|candidate| candidate.is_module() && candidate == owner)
621            })
622            || module_contains_seed(self.python, self.file, &module, self.seeds)
623    }
624
625    fn importlib_binding_visible(&self, local: &str, node: Node<'_>) -> bool {
626        if let Some(binding) = self.scoped_import_bindings.iter().rev().find(|binding| {
627            binding.start_byte <= node.start_byte()
628                && binding.scope_start_byte <= node.start_byte()
629                && node.end_byte() <= binding.scope_end_byte
630                && binding.local_name == local
631        }) {
632            if binding.is_function_scoped() {
633                return binding.qualified_name == "importlib";
634            }
635            if self
636                .scope_facts_for_node(node)
637                .is_some_and(|facts| facts.is_shadowed(local))
638            {
639                return false;
640            }
641            return binding.qualified_name == "importlib";
642        }
643        if self
644            .scope_facts_for_node(node)
645            .is_some_and(|facts| facts.is_shadowed(local))
646        {
647            return false;
648        }
649        self.python
650            .import_binder_of(self.file)
651            .bindings
652            .get(local)
653            .is_some_and(|binding| {
654                binding.kind == ImportKind::Namespace
655                    && binding
656                        .namespace_imported_module
657                        .as_deref()
658                        .unwrap_or(&binding.module_specifier)
659                        == "importlib"
660            })
661    }
662
663    /// Whether `node` is evaluated in the target member owner's class namespace.
664    /// This includes class-level field initializers and the decorators,
665    /// annotations, and defaults of a method declaration. The method body itself
666    /// executes later with ordinary function scoping, where a bare member name
667    /// does not reach the class namespace.
668    fn node_directly_in_owner_class_body(&self, node: Node<'_>) -> bool {
669        let Some(target_owner) = self.target_owner.as_ref() else {
670            return false;
671        };
672        let range = Range {
673            start_byte: node.start_byte(),
674            end_byte: node.end_byte(),
675            start_line: 0,
676            end_line: 0,
677        };
678        let Some(enclosing) = self.graph.index.enclosing_code_unit(self.file, &range) else {
679            return false;
680        };
681        if &enclosing == target_owner {
682            return true;
683        }
684        if enclosing.is_function() {
685            return target_owner_code_unit(self.graph.index, &enclosing).as_ref()
686                == Some(target_owner)
687                && function_declaration_expression_is_outer_scoped(node);
688        }
689        target_owner_code_unit(self.graph.index, &enclosing).as_ref() == Some(target_owner)
690    }
691
692    /// Whether `expr`'s type is genuinely un-inferrable in `node`'s scope (an
693    /// unseeded receiver such as an unannotated parameter), as opposed to a
694    /// receiver we resolved to some specific — possibly different — type.
695    fn receiver_type_is_unknown(&self, expr: &str, node: Node<'_>) -> bool {
696        match enclosing_runtime_parameter_type(expr, node, self.source) {
697            EnclosingParameterType::Typed(_) => return false,
698            EnclosingParameterType::Untyped => return true,
699            EnclosingParameterType::NotDeclared => {}
700        }
701        match self.scope_facts_for_node(node) {
702            Some(facts) => facts.resolution_for(expr).is_unknown(),
703            None => true,
704        }
705    }
706
707    fn import_edge_visible_for(&self, ident: &str, node: Node<'_>) -> bool {
708        if let Some(scope_facts) = self.scope_facts_for_node(node)
709            && scope_facts.is_shadowed(ident)
710        {
711            return false;
712        }
713        self.module_binding_targets_query(ident, node)
714    }
715
716    fn module_binding_targets_query(&self, ident: &str, node: Node<'_>) -> bool {
717        // A member receiver must bind the owner symbol itself. A namespace
718        // import only binds the module that contains the owner; treating that
719        // module as the owner conflates `from pkg import child` (the
720        // `pkg.child` module) with a same-named `child` class exported by that
721        // module. Top-level targets still accept either binding kind below.
722        if self.target_member.is_some() {
723            return self.module_binding_targets_symbol(ident, node);
724        }
725        if self.target_is_module
726            && imported_module_bindings(self, node, node)
727                .into_iter()
728                .flat_map(|binding| {
729                    usage_resolve_module_files(self.python, self.file, &binding.module)
730                })
731                .any(|resolved| &resolved == self.target_source)
732        {
733            // Unaliased dotted imports are cumulative: `import pkg`, followed
734            // by `import pkg.a`, still leaves the bare `pkg` binding naming the
735            // root module. The classified single-binding timeline cannot keep
736            // that fact once a later dotted import shares the same local.
737            return true;
738        }
739        if let Some(matches) = self.function_import_binding_targets_query(ident, node) {
740            return matches;
741        }
742        self.module_binding_matches_query(ident, node, true, |kind| {
743            kind != ModuleBindingKind::Other
744        })
745    }
746
747    fn module_binding_targets_symbol(&self, ident: &str, node: Node<'_>) -> bool {
748        if let Some(matches) = self.function_import_binding_targets_query(ident, node) {
749            return matches;
750        }
751        let unclassified_named_import = self.edges.iter().any(|edge| {
752            edge.local_name == ident && !matches!(edge.kind, ImportEdgeKind::Namespace)
753        });
754        self.module_binding_matches_query(ident, node, unclassified_named_import, |kind| {
755            kind == ModuleBindingKind::TargetSymbolImport
756        })
757    }
758
759    /// Resolve the nearest function-local import before the module timeline.
760    ///
761    /// Candidate discovery retains all imports. A local import with the same
762    /// binder must still override the module binding only inside its function.
763    fn function_import_binding_targets_query(&self, ident: &str, node: Node<'_>) -> Option<bool> {
764        let binding =
765            python_function_scoped_import_binding(self.scoped_import_bindings, ident, node)?;
766        let qualified_name = resolve_python_relative_module(
767            self.python.project(),
768            self.file,
769            &binding.qualified_name,
770        )
771        .unwrap_or_else(|| binding.qualified_name.clone());
772        let candidates =
773            resolve_fqn_candidates(self.python, Some(self.file), &qualified_name, |name| {
774                self.graph.index.definitions(name).collect()
775            });
776        let imported_target = self.target_owner.as_ref().unwrap_or(self.target);
777        Some(
778            candidates
779                .iter()
780                .any(|candidate| candidate == imported_target),
781        )
782    }
783
784    fn module_binding_matches_query(
785        &self,
786        ident: &str,
787        node: Node<'_>,
788        unclassified: bool,
789        matches: impl Fn(ModuleBindingKind) -> bool,
790    ) -> bool {
791        if !self.edges.iter().any(|edge| edge.local_name == ident) {
792            return false;
793        }
794        let Some(events) = self.module_bindings.get(ident) else {
795            return unclassified;
796        };
797        let cutoff = if python_reference_is_deferred_function_body(node) {
798            usize::MAX
799        } else {
800            node.start_byte()
801        };
802        let visible: Vec<_> = events
803            .iter()
804            .filter(|event| event.visible_from <= cutoff)
805            .collect();
806        let start = visible
807            .iter()
808            .rposition(|event| !event.conditional)
809            .unwrap_or(0);
810        visible[start..].iter().any(|event| matches(event.kind))
811    }
812
813    fn module_binding_is_local_at(&self, ident: &str, node: Node<'_>) -> bool {
814        self.module_binding_matches_query(ident, node, false, |kind| {
815            kind == ModuleBindingKind::Other
816        })
817    }
818
819    fn module_binding_targets_module(&self, ident: &str, node: Node<'_>) -> bool {
820        self.module_binding_matches_query(ident, node, false, |kind| {
821            kind == ModuleBindingKind::TargetModuleImport
822        })
823    }
824
825    /// Whether a bare read of `ident` at `node` reaches a non-module target
826    /// that an import edge brings into this file under that name.
827    ///
828    /// An import of the target's own name reaches it, and so does a later
829    /// local rebinding of that name (`f = wrap(f)`), which still carries the
830    /// imported value. A module binding never does: `from pkg import models`,
831    /// and `from pkg import reexport` where `reexport.py` says
832    /// `from base import *`, both bind the module object. Only the attribute
833    /// path `models.Target` / `reexport.target` reaches the target, and
834    /// `handle_attribute_candidate` records that (#3294). A guarded import
835    /// whose `except` arm rebinds the local (`reexport = None`) leaves an
836    /// `Other` event beside the module import, which is why the module
837    /// question decides after the local-rebinding one.
838    fn bare_read_binds_target(&self, ident: &str, node: Node<'_>) -> bool {
839        if self.module_binding_targets_symbol(ident, node) {
840            return true;
841        }
842        self.module_binding_is_local_at(ident, node)
843            && !self.module_binding_targets_module(ident, node)
844    }
845
846    /// Whether the class enclosing `node` is the target member's owner (or a
847    /// subclass of it, for inherited members). Used to resolve `self`/`cls`
848    /// receivers, whose type is the lexically enclosing class.
849    fn self_receiver_matches_target(&self, node: Node<'_>) -> bool {
850        let Some(target_owner) = self.target_owner.as_ref() else {
851            return false;
852        };
853        let range = Range {
854            start_byte: node.start_byte(),
855            end_byte: node.end_byte(),
856            start_line: 0,
857            end_line: 0,
858        };
859        let Some(enclosing) = self.graph.index.enclosing_code_unit(self.file, &range) else {
860            return false;
861        };
862        let enclosing_class = if enclosing.is_class() {
863            enclosing
864        } else {
865            match target_owner_code_unit(self.graph.index, &enclosing) {
866                Some(class) => class,
867                None => return false,
868            }
869        };
870        self.receiver_unit_matches_target(&enclosing_class, target_owner)
871    }
872
873    fn receiver_type_matches_target(&self, raw_type: &str) -> bool {
874        let Some(target_owner) = self.target_owner.as_ref() else {
875            return false;
876        };
877        if let Some(receiver_type) = resolve_receiver_type(
878            self.graph,
879            self.python,
880            self.file,
881            raw_type,
882            self.target_self_file,
883        ) {
884            return self.receiver_unit_matches_target(&receiver_type, target_owner);
885        }
886
887        // Preserve the annotation-edge/name fallback only when structured
888        // resolution has no answer. When it identifies a concrete owner, even
889        // a negative answer is authoritative: otherwise identically named
890        // vendored package copies widen into one another.
891        receiver_annotation_matches_target(
892            raw_type,
893            self.edges,
894            self.target_short,
895            self.target_self_file,
896        )
897    }
898
899    fn receiver_unit_matches_target(
900        &self,
901        receiver_type: &CodeUnit,
902        target_owner: &CodeUnit,
903    ) -> bool {
904        if let Some(member) = self.target_member {
905            return resolved_member_declarations(self.graph, receiver_type, member)
906                .iter()
907                .any(|candidate| candidate == self.target);
908        }
909        receiver_type == target_owner
910            || self
911                .graph
912                .hierarchy
913                .map(|provider| provider.get_ancestors(receiver_type))
914                .unwrap_or_default()
915                .into_iter()
916                .any(|ancestor| ancestor == *target_owner)
917    }
918}
919
920pub(crate) fn function_declaration_expression_is_outer_scoped(node: Node<'_>) -> bool {
921    let site_start = node.start_byte();
922    let site_end = node.end_byte();
923    let mut current = node;
924    while let Some(parent) = current.parent() {
925        if parent.kind() == "function_definition" {
926            if parent
927                .child_by_field_name("body")
928                .is_some_and(|body| body.start_byte() <= site_start && site_end <= body.end_byte())
929            {
930                return false;
931            }
932            if parent
933                .child_by_field_name("name")
934                .is_some_and(|name| name.id() == node.id())
935            {
936                return false;
937            }
938            if let Some(parameters) = parent.child_by_field_name("parameters")
939                && parameters.start_byte() <= site_start
940                && site_end <= parameters.end_byte()
941            {
942                let mut parameter = node;
943                while parameter.parent() != Some(parameters) {
944                    let Some(next) = parameter.parent() else {
945                        return false;
946                    };
947                    parameter = next;
948                }
949                let binder = if parameter.kind() == "identifier" {
950                    Some(parameter)
951                } else {
952                    parameter.child_by_field_name("name").or_else(|| {
953                        parameter
954                            .named_child(0)
955                            .filter(|child| child.kind() == "identifier")
956                    })
957                };
958                return binder.is_none_or(|binder| binder.id() != node.id());
959            }
960            return true;
961        }
962        if parent.kind() == "decorated_definition" {
963            return current.kind() == "decorator";
964        }
965        if parent.kind() == "class_definition" {
966            break;
967        }
968        current = parent;
969    }
970    false
971}
972
973fn scan_node(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
974    let mut stack = vec![node];
975    while let Some(node) = stack.pop() {
976        match node.kind() {
977            "import_statement" | "import_from_statement" => {
978                handle_import_candidate(node, ctx);
979                continue;
980            }
981            "identifier" => {
982                if handle_annotation_reference_candidate(node, ctx) {
983                    continue;
984                }
985                handle_identifier_candidate(node, ctx);
986            }
987            "attribute" => {
988                if handle_annotation_reference_candidate(node, ctx) {
989                    continue;
990                }
991                handle_attribute_candidate(node, ctx);
992            }
993            "string_content" => {
994                handle_annotation_reference_candidate(node, ctx);
995            }
996            "keyword_argument" => {
997                handle_keyword_argument_candidate(node, ctx);
998                if let Some(value) = node.child_by_field_name("value") {
999                    stack.push(value);
1000                }
1001                continue;
1002            }
1003            _ => {}
1004        }
1005
1006        let mut cursor = node.walk();
1007        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
1008        children.reverse();
1009        stack.extend(children);
1010    }
1011}
1012
1013fn handle_annotation_reference_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) -> bool {
1014    let Some(candidates) = annotation_reference_candidates(
1015        ctx.graph,
1016        ctx.python,
1017        ctx.file,
1018        ctx.source,
1019        node,
1020        ctx.target_self_file,
1021    ) else {
1022        return false;
1023    };
1024
1025    // Method annotations are part of the surrounding class declaration. A
1026    // qualifier such as `types.Handler` therefore uses the class field
1027    // `types` when that field was defined earlier in the class body, even
1028    // though resolving the complete annotation yields `Handler`.
1029    if node.kind() == "attribute"
1030        && ctx.target.is_field()
1031        && let Some(root) = leftmost_identifier(node)
1032        && ctx.target_member == Some(slice(root, ctx.source))
1033        && ctx.node_directly_in_owner_class_body(root)
1034    {
1035        record_hit(root, ctx);
1036    }
1037
1038    if (ctx.target.is_class() || ctx.target.is_field() || ctx.target_member.is_none())
1039        && candidates.iter().all(|candidate| *candidate == *ctx.target)
1040        && candidates.iter().any(|candidate| *candidate == *ctx.target)
1041    {
1042        let site = if node.kind() == "attribute" {
1043            node.child_by_field_name("attribute").unwrap_or(node)
1044        } else {
1045            node
1046        };
1047        record_hit(site, ctx);
1048    }
1049
1050    if let Some(site) = annotation_class_qualifier_site(
1051        ctx.graph, ctx.python, ctx.file, ctx.source, node, ctx.target,
1052    ) {
1053        record_hit(site, ctx);
1054    }
1055
1056    // An attribute annotation the resolver could not turn into a candidate is
1057    // not consumed here: the namespace-attribute path still has to see it, and
1058    // so do the node's children. A module target also needs that path because
1059    // annotation resolution names the terminal declaration, not its qualifier.
1060    // `inverted.rs` always falls through for these module qualifier references.
1061    if node.kind() == "attribute" && (candidates.is_empty() || ctx.target_is_module) {
1062        return false;
1063    }
1064
1065    true
1066}
1067
1068fn handle_keyword_argument_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
1069    let (Some(target_member), Some(name), Some(arguments)) = (
1070        ctx.target_member,
1071        node.child_by_field_name("name"),
1072        node.parent(),
1073    ) else {
1074        return;
1075    };
1076    if name.kind() != "identifier"
1077        || slice(name, ctx.source) != target_member
1078        || arguments.kind() != "argument_list"
1079    {
1080        return;
1081    }
1082    let Some(call) = arguments.parent().filter(|parent| parent.kind() == "call") else {
1083        return;
1084    };
1085    let Some(function) = call.child_by_field_name("function") else {
1086        return;
1087    };
1088    if function.kind() == "identifier" && slice(function, ctx.source) == "cls" {
1089        if ctx.self_receiver_matches_target(function) {
1090            record_hit(name, ctx);
1091        }
1092        return;
1093    }
1094    let Some(target_owner) = ctx.target_owner.as_ref() else {
1095        return;
1096    };
1097    let scoped_callee_matches = if function.kind() == "identifier" {
1098        ctx.scope_facts_for_node(function)
1099            .and_then(|facts| {
1100                facts
1101                    .resolution_for(slice(function, ctx.source))
1102                    .as_precise()
1103                    .and_then(|targets| targets.iter().next().cloned())
1104            })
1105            .is_some_and(|raw_type| ctx.receiver_type_matches_target(&raw_type))
1106    } else {
1107        false
1108    };
1109    let default_callee_matches = if function.kind() == "identifier" {
1110        resolve_callable_parameter_default_types(
1111            ctx.graph,
1112            ctx.python,
1113            ctx.file,
1114            ctx.source,
1115            function,
1116            slice(function, ctx.source),
1117        )
1118        .into_iter()
1119        .any(|class| constructed_class_matches_target_owner(ctx, &class, target_owner))
1120    } else {
1121        false
1122    };
1123    let root_shadowed = leftmost_identifier(function).is_some_and(|root| {
1124        ctx.scope_facts_for_node(function)
1125            .is_some_and(|facts| facts.is_shadowed(slice(root, ctx.source)))
1126    });
1127    let named_import_callee_matches = if !root_shadowed && function.kind() == "identifier" {
1128        let cutoff = if python_reference_is_deferred_function_body(function) {
1129            usize::MAX
1130        } else {
1131            function.start_byte()
1132        };
1133        resolve_visible_named_import_candidates(
1134            ctx.graph,
1135            ctx.python,
1136            ctx.file,
1137            ctx.raw_module_bindings,
1138            slice(function, ctx.source),
1139            cutoff,
1140        )
1141        .into_iter()
1142        .any(|class| constructed_class_matches_target_owner(ctx, &class, target_owner))
1143    } else {
1144        false
1145    };
1146    if root_shadowed
1147        && !scoped_callee_matches
1148        && !default_callee_matches
1149        && !named_import_callee_matches
1150    {
1151        return;
1152    }
1153    let matches = scoped_callee_matches
1154        || default_callee_matches
1155        || named_import_callee_matches
1156        || (!root_shadowed
1157            && resolve_constructor_types(ctx.graph, ctx.python, ctx.file, ctx.source, function)
1158                .into_iter()
1159                .any(|class| constructed_class_matches_target_owner(ctx, &class, target_owner)));
1160    if matches {
1161        record_hit(name, ctx);
1162    }
1163}
1164
1165/// Emit an `Import`-binding hit for `from <mod> import <target>` (the token that
1166/// brings the target into this file). Gated on there being an import edge whose
1167/// local name is the target — so a same-named symbol imported from a different
1168/// module is not falsely counted. Only top-level symbols (not members) are
1169/// imported by their own name.
1170fn handle_import_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
1171    if ctx.target_member.is_some() {
1172        return;
1173    }
1174    if !ctx
1175        .edges
1176        .iter()
1177        .any(|edge| edge.local_name == ctx.target_short)
1178    {
1179        return;
1180    }
1181    let mut stack = vec![node];
1182    while let Some(node) = stack.pop() {
1183        if node.kind() == "identifier" && slice(node, ctx.source) == ctx.target_short {
1184            record_import_hit(node, ctx);
1185            return;
1186        }
1187        let mut cursor = node.walk();
1188        for child in node.named_children(&mut cursor) {
1189            stack.push(child);
1190        }
1191    }
1192}
1193
1194fn handle_identifier_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
1195    if node
1196        .parent()
1197        .is_some_and(|parent| parent.kind() == "attribute")
1198    {
1199        return;
1200    }
1201    let text = slice(node, ctx.source);
1202    if text.is_empty() || is_declaration_identifier(node) || decorates_the_target(node, ctx) {
1203        return;
1204    }
1205    if let Some(member) = ctx.target_member {
1206        // A constructor call `Owner(...)` invokes the class's `__init__`, so it
1207        // is a usage of `__init__` even though `__init__` never appears.
1208        if member == "__init__" && parent_if_field(node, "call", "function").is_some() {
1209            let Some(target_owner) = ctx.target_owner.as_ref() else {
1210                return;
1211            };
1212            if resolve_constructor_types(ctx.graph, ctx.python, ctx.file, ctx.source, node)
1213                .into_iter()
1214                .any(|class| constructed_class_matches_target_owner(ctx, &class, target_owner))
1215            {
1216                record_hit(node, ctx);
1217            }
1218            return;
1219        }
1220        // For a member target, a bare identifier is a usage only when it names
1221        // the member directly in the owner class body (the class namespace).
1222        if text == member && ctx.node_directly_in_owner_class_body(node) {
1223            record_hit(node, ctx);
1224        }
1225        return;
1226    }
1227    if !ctx.binds_target(text, node) {
1228        return;
1229    }
1230    let dynamic_getattr = dynamic_getattr_receiver(node, ctx);
1231    // A named submodule import (`from pkg import models`) binds the module,
1232    // not every top-level declaration exported by that module. The same
1233    // binding is valid as the structured receiver in `models.Model`, but a
1234    // bare `models` read is a reference to the module itself.
1235    if !ctx.target_is_module && imported_root_targets_module(ctx, node, node) && !dynamic_getattr {
1236        return;
1237    }
1238    if !ctx.target_is_module
1239        && ctx.edges.iter().any(|edge| edge.local_name == text)
1240        && !ctx.bare_read_binds_target(text, node)
1241        && !dynamic_getattr
1242    {
1243        return;
1244    }
1245    if dynamic_getattr && !(ctx.target_is_module && imported_root_targets_module(ctx, node, node)) {
1246        record_unproven_hit(node, ctx);
1247    } else {
1248        record_hit(node, ctx);
1249    }
1250}
1251
1252fn constructed_class_matches_target_owner(
1253    ctx: &ScanCtx<'_>,
1254    constructed: &CodeUnit,
1255    target_owner: &CodeUnit,
1256) -> bool {
1257    constructed == target_owner
1258        || ctx
1259            .graph
1260            .hierarchy
1261            .map(|provider| provider.get_ancestors(constructed))
1262            .unwrap_or_default()
1263            .into_iter()
1264            .any(|ancestor| &ancestor == target_owner)
1265}
1266
1267/// Whether this identifier is the receiver of a built-in `getattr` call whose
1268/// member name is not a plain string literal. Its module or symbol binding is
1269/// a candidate at that site, but the call does not select one particular name.
1270fn dynamic_getattr_receiver(node: Node<'_>, ctx: &ScanCtx<'_>) -> bool {
1271    let Some(arguments) = node
1272        .parent()
1273        .filter(|parent| parent.kind() == "argument_list")
1274    else {
1275        return false;
1276    };
1277    let Some(call) = arguments.parent().filter(|parent| parent.kind() == "call") else {
1278        return false;
1279    };
1280    let Some(function) = call.child_by_field_name("function") else {
1281        return false;
1282    };
1283    if function.kind() != "identifier"
1284        || slice(function, ctx.source) != "getattr"
1285        || !ctx
1286            .scope_facts_for_node(function)
1287            .is_some_and(|facts| !facts.is_shadowed("getattr"))
1288    {
1289        return false;
1290    }
1291
1292    let mut object = None;
1293    let mut name = None;
1294    let mut positional = 0;
1295    let mut cursor = arguments.walk();
1296    for argument in arguments.named_children(&mut cursor) {
1297        if argument.kind() == "keyword_argument" {
1298            let Some(label) = argument.child_by_field_name("name") else {
1299                return false;
1300            };
1301            let Some(value) = argument.child_by_field_name("value") else {
1302                return false;
1303            };
1304            match slice(label, ctx.source) {
1305                "object" => object = Some(value),
1306                "name" => name = Some(value),
1307                _ => {}
1308            }
1309        } else if matches!(argument.kind(), "list_splat" | "dictionary_splat") {
1310            return false;
1311        } else {
1312            match positional {
1313                0 => object = Some(argument),
1314                1 => name = Some(argument),
1315                _ => {}
1316            }
1317            positional += 1;
1318        }
1319    }
1320    let Some(object) = object else {
1321        return false;
1322    };
1323    let Some(name) = name else {
1324        return false;
1325    };
1326    object.start_byte() == node.start_byte()
1327        && object.end_byte() == node.end_byte()
1328        && python_plain_string_literal(name, ctx.source).is_none()
1329}
1330
1331/// Whether `node` sits in a decorator of the very definition the scan is
1332/// looking for.
1333///
1334/// Python evaluates a decorator expression before the decorated name is bound,
1335/// so `@foo` above `def foo()` names an *outer* `foo` and never the definition
1336/// it decorates. Without this the decorated definition reads as a user of
1337/// itself, which the enclosing-equals-target drop used to hide.
1338fn decorates_the_target(node: Node<'_>, ctx: &ScanCtx<'_>) -> bool {
1339    if ctx.file != ctx.target_source {
1340        return false;
1341    }
1342    let mut current = node;
1343    while let Some(parent) = current.parent() {
1344        if parent.kind() == "decorated_definition" && current.kind() == "decorator" {
1345            let Some(definition) = parent.child_by_field_name("definition") else {
1346                return false;
1347            };
1348            return ctx.graph.index.ranges(ctx.target).iter().any(|range| {
1349                range.start_byte <= definition.start_byte()
1350                    && definition.end_byte() <= range.end_byte
1351            });
1352        }
1353        current = parent;
1354    }
1355    false
1356}
1357
1358fn handle_attribute_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
1359    let Some(object) = node.child_by_field_name("object") else {
1360        return;
1361    };
1362    let Some(attribute) = node.child_by_field_name("attribute") else {
1363        return;
1364    };
1365    let object_text = slice(object, ctx.source);
1366    let attribute_text = slice(attribute, ctx.source);
1367    if ctx.target_member.is_none()
1368        && !ctx.target_is_module
1369        && attribute_text == ctx.target_short
1370        && ctx.importlib_assignment_targets_query(object, node)
1371    {
1372        record_hit(attribute, ctx);
1373    }
1374    if let Some(member) = ctx.target_member
1375        && attribute_text == member
1376    {
1377        // A `self.member` / `cls.member` receiver is the current instance / own
1378        // class — a same-owner site, excluded from external usage counts (#1014
1379        // facet B). A typed local of the same type is a different instance and
1380        // stays an external hit.
1381        let is_same_owner_receiver =
1382            matches!(object_text, "self" | "cls") && ctx.self_receiver_matches_target(node);
1383        if is_same_owner_receiver {
1384            record_self_receiver_hit(attribute, ctx);
1385        } else if ctx.receiver_binds_target(object_text, node)
1386            || (object.kind() == "call" && call_result_matches_target(object, ctx))
1387        {
1388            record_hit(attribute, ctx);
1389        } else if member_receiver_match_is_unproven(object, object_text, node, ctx) {
1390            record_unproven_hit(attribute, ctx);
1391        }
1392    }
1393
1394    let object_binds_target = if ctx.target_is_module {
1395        imported_root_targets_module(ctx, object, node)
1396    } else {
1397        ctx.binds_target(object_text, node)
1398    };
1399    if object.kind() == "identifier"
1400        && object_binds_target
1401        && (ctx.target_is_module
1402            || (ctx.target_member.is_none()
1403                && !ctx.edges.iter().any(|edge| {
1404                    matches!(edge.kind, ImportEdgeKind::Namespace) && edge.local_name == object_text
1405                })))
1406    {
1407        record_hit(object, ctx);
1408    }
1409
1410    if ctx.target_is_module
1411        && let Some(module_qualifier) = module_attribute_target_hit(node, ctx)
1412    {
1413        record_hit(module_qualifier, ctx);
1414    }
1415
1416    // A bare member name used as the *object* of an attribute access in the
1417    // owner class body — e.g. the `x` in `@x.setter`/`@x.deleter` decorating a
1418    // property `x` — is a usage of that member.
1419    if let Some(member) = ctx.target_member
1420        && object.kind() == "identifier"
1421        && object_text == member
1422        && ctx.node_directly_in_owner_class_body(object)
1423    {
1424        record_hit(object, ctx);
1425    }
1426
1427    // Best-effort for an un-inferrable receiver: `recv.member` where `recv`'s
1428    // type cannot be resolved is attributed to the target when the target's
1429    // owner is in this file and the member name is unique among local classes
1430    // (so `recv.member` can only mean the target). `self`/`cls` are handled
1431    // structurally above; cross-file untyped receivers stay conservative.
1432    if ctx.member_best_effort_unique
1433        && let Some(member) = ctx.target_member
1434        && attribute_text == member
1435        && object.kind() == "identifier"
1436        && !matches!(object_text, "self" | "cls")
1437        && !ctx.receiver_binds_target(object_text, node)
1438        && ctx.receiver_type_is_unknown(object_text, node)
1439    {
1440        record_hit(attribute, ctx);
1441    }
1442
1443    if let Some(module_binding_target) = module_binding_attribute_target_hit(node, ctx) {
1444        record_hit(module_binding_target, ctx);
1445    }
1446}
1447
1448/// Resolve a top-level symbol written through an imported module binding and
1449/// any number of intermediate modules (`K.feature.DISK`,
1450/// `pkg.core.ops.eye_like`). This includes both `import pkg as K` and
1451/// `from pkg import submodule`; the latter is a named import syntactically but
1452/// still introduces a module binding when the structured module index proves
1453/// that `pkg.submodule` is a workspace module.
1454///
1455/// The written path is assembled only from tree-sitter's `attribute` fields.
1456/// The analyzer's canonical export resolver then proves that the whole path
1457/// names the exact physical target, so re-export aliases remain supported
1458/// without comparing rendered source paths or broadening same-name candidates.
1459fn module_binding_attribute_target_hit<'a>(node: Node<'a>, ctx: &ScanCtx<'_>) -> Option<Node<'a>> {
1460    if ctx.target_member.is_some() {
1461        return None;
1462    }
1463    let (root, attributes) = attribute_chain(node)?;
1464    let terminal = *attributes.last()?;
1465    let terminal_name = slice(terminal, ctx.source);
1466    if terminal_name.is_empty()
1467        || !ctx
1468            .seeds
1469            .iter()
1470            .any(|(_, seed_name)| seed_name == terminal_name)
1471    {
1472        return None;
1473    }
1474
1475    for binding in imported_module_bindings(ctx, root, node) {
1476        let mut written_module = binding.module.clone();
1477        for attribute in attributes
1478            [binding.consumed_attributes.min(attributes.len() - 1)..attributes.len() - 1]
1479            .iter()
1480        {
1481            let segment = slice(*attribute, ctx.source);
1482            if segment.is_empty() {
1483                return None;
1484            }
1485            written_module.push('.');
1486            written_module.push_str(segment);
1487        }
1488        if usage_resolve_module_files(ctx.python, ctx.file, &written_module)
1489            .iter()
1490            .any(|resolved| {
1491                ctx.seeds
1492                    .contains(&(resolved.clone(), terminal_name.to_string()))
1493            })
1494        {
1495            return Some(terminal);
1496        }
1497
1498        let mut written_fqn = binding.module;
1499        for attribute in attributes.iter().skip(binding.consumed_attributes) {
1500            let segment = slice(*attribute, ctx.source);
1501            if segment.is_empty() {
1502                return None;
1503            }
1504            written_fqn.push('.');
1505            written_fqn.push_str(segment);
1506        }
1507        if resolve_fqn_candidates(ctx.python, Some(ctx.file), &written_fqn, |name| {
1508            ctx.graph.index.definitions(name).collect()
1509        })
1510        .into_iter()
1511        .any(|candidate| &candidate == ctx.target)
1512        {
1513            return Some(terminal);
1514        }
1515    }
1516    None
1517}
1518
1519fn imported_root_targets_module(ctx: &ScanCtx<'_>, root: Node<'_>, reference: Node<'_>) -> bool {
1520    imported_module_bindings(ctx, root, reference)
1521        .into_iter()
1522        .any(|binding| {
1523            usage_resolve_module_files(ctx.python, ctx.file, &binding.module)
1524                .into_iter()
1525                .any(|resolved_file| &resolved_file == ctx.target_source)
1526        })
1527}
1528
1529fn module_attribute_target_hit<'a>(node: Node<'a>, ctx: &ScanCtx<'_>) -> Option<Node<'a>> {
1530    let (root, attributes) = attribute_chain(node)?;
1531    if attributes.is_empty() {
1532        return None;
1533    }
1534    for binding in imported_module_bindings(ctx, root, node) {
1535        let mut module_fqn = binding.module;
1536        for attribute in attributes.iter().skip(binding.consumed_attributes) {
1537            let segment = slice(*attribute, ctx.source);
1538            if segment.is_empty() {
1539                return None;
1540            }
1541            if module_fqn.ends_with('.') {
1542                module_fqn.push_str(segment);
1543            } else {
1544                module_fqn.push('.');
1545                module_fqn.push_str(segment);
1546            }
1547            let resolved = usage_resolve_module_files(ctx.python, ctx.file, &module_fqn);
1548            if resolved.is_empty() {
1549                break;
1550            }
1551            if resolved
1552                .iter()
1553                .any(|resolved_file| resolved_file == ctx.target_source)
1554            {
1555                return Some(*attribute);
1556            }
1557        }
1558    }
1559    None
1560}
1561
1562fn call_result_matches_target(call: Node<'_>, ctx: &ScanCtx<'_>) -> bool {
1563    let Some(target_owner) = ctx.target_owner.as_ref() else {
1564        return false;
1565    };
1566    let scope_facts = ctx.scope_facts_for_node(call);
1567    call_result_types(
1568        ctx.graph,
1569        ctx.python,
1570        ctx.file,
1571        ctx.source,
1572        call,
1573        scope_facts,
1574    )
1575    .into_iter()
1576    .any(|class| {
1577        &class == target_owner
1578            || ctx
1579                .graph
1580                .hierarchy
1581                .map(|provider| provider.get_ancestors(&class))
1582                .unwrap_or_default()
1583                .into_iter()
1584                .any(|ancestor| &ancestor == target_owner)
1585    })
1586}
1587
1588pub fn call_result_types(
1589    graph: &PythonGraphSource<'_>,
1590    python: &dyn PythonUsageSource,
1591    file: &ProjectFile,
1592    source: &str,
1593    call: Node<'_>,
1594    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1595) -> Vec<CodeUnit> {
1596    let Some(function) = call.child_by_field_name("function") else {
1597        return Vec::new();
1598    };
1599    let constructed = resolve_constructor_types(graph, python, file, source, function);
1600    if !constructed.is_empty() {
1601        return constructed;
1602    }
1603    let callable_fqns = resolve_callable_fqns(graph, python, file, source, function, scope_facts);
1604    if callable_fqns.is_empty() {
1605        return Vec::new();
1606    }
1607    let callables = callable_fqns
1608        .into_iter()
1609        .flat_map(|callable_fqn| {
1610            resolve_fqn_candidates(python, Some(file), &callable_fqn, |name| {
1611                graph.index.definitions(name).collect()
1612            })
1613        })
1614        .collect::<Vec<_>>();
1615    let mut classes = Vec::new();
1616    for callable in callables.into_iter().filter(CodeUnit::is_function) {
1617        let raw_type = callable_return_type_name(graph, python, &callable).or_else(|| {
1618            if callable.source() != file {
1619                return None;
1620            }
1621            let prepared = python.prepared_syntax(graph.token, file)?;
1622            let key = factory_function_key(graph, &callable);
1623            let facts = python.python_source_facts(graph.token, file, &|| true)?;
1624            collect_factory_return_types_from_root(
1625                prepared.tree().root_node(),
1626                prepared.source(),
1627                &facts,
1628            )?
1629            .remove(&key)
1630        });
1631        let Some(raw_type) = raw_type else {
1632            continue;
1633        };
1634        if let Some(class) =
1635            resolve_receiver_type(graph, python, callable.source(), &raw_type, true)
1636        {
1637            classes.push(class);
1638        }
1639    }
1640    classes.sort();
1641    classes.dedup();
1642    classes
1643}
1644
1645fn resolve_callable_fqns(
1646    graph: &PythonGraphSource<'_>,
1647    python: &dyn PythonUsageSource,
1648    file: &ProjectFile,
1649    source: &str,
1650    function: Node<'_>,
1651    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1652) -> Vec<String> {
1653    match function.kind() {
1654        "identifier" => {
1655            resolve_identifier_callable_fqns(graph, python, file, source, function, scope_facts)
1656        }
1657        "attribute" => {
1658            resolve_attribute_callable_fqns(graph, python, file, source, function, scope_facts)
1659        }
1660        _ => Vec::new(),
1661    }
1662}
1663
1664fn resolve_identifier_callable_fqns(
1665    graph: &PythonGraphSource<'_>,
1666    python: &dyn PythonUsageSource,
1667    file: &ProjectFile,
1668    source: &str,
1669    function: Node<'_>,
1670    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1671) -> Vec<String> {
1672    let local = slice(function, source);
1673    if local.is_empty() || scope_facts.is_some_and(|facts| facts.is_shadowed(local)) {
1674        return Vec::new();
1675    }
1676    let binder = python.import_binder_of(file);
1677    match binder.bindings.get(local) {
1678        Some(binding) if binding.kind == ImportKind::Named => binding
1679            .imported_name
1680            .as_ref()
1681            .map(|imported| vec![format!("{}.{}", binding.module_specifier, imported)])
1682            .unwrap_or_default(),
1683        _ => graph
1684            .index
1685            .declarations(file)
1686            .into_iter()
1687            .find(|unit| unit.is_function() && unit.identifier() == local)
1688            .map(|unit| vec![unit.fq_name()])
1689            .unwrap_or_default(),
1690    }
1691}
1692
1693fn resolve_attribute_callable_fqns(
1694    graph: &PythonGraphSource<'_>,
1695    python: &dyn PythonUsageSource,
1696    file: &ProjectFile,
1697    source: &str,
1698    function: Node<'_>,
1699    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1700) -> Vec<String> {
1701    let Some(receiver) = function.child_by_field_name("object") else {
1702        return Vec::new();
1703    };
1704    let Some(method) = function.child_by_field_name("attribute") else {
1705        return Vec::new();
1706    };
1707    let method = slice(method, source);
1708    if method.is_empty() {
1709        return Vec::new();
1710    }
1711    let mut fqns = attribute_receiver_classes(graph, python, file, source, receiver, scope_facts)
1712        .into_iter()
1713        .map(|class| format!("{}.{}", class.fq_name(), method))
1714        .collect::<Vec<_>>();
1715    fqns.sort();
1716    fqns.dedup();
1717    fqns
1718}
1719
1720fn attribute_receiver_classes(
1721    graph: &PythonGraphSource<'_>,
1722    python: &dyn PythonUsageSource,
1723    file: &ProjectFile,
1724    source: &str,
1725    receiver: Node<'_>,
1726    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1727) -> Vec<CodeUnit> {
1728    let mut classes = match receiver.kind() {
1729        "identifier" => {
1730            identifier_receiver_classes(graph, python, file, source, receiver, scope_facts)
1731        }
1732        "attribute" => {
1733            if let Some(root) = leftmost_identifier(receiver)
1734                && scope_facts.is_some_and(|facts| facts.is_shadowed(slice(root, source)))
1735            {
1736                Vec::new()
1737            } else {
1738                resolve_constructor_types(graph, python, file, source, receiver)
1739            }
1740        }
1741        _ => Vec::new(),
1742    };
1743    classes.sort();
1744    classes.dedup();
1745    classes
1746}
1747
1748fn identifier_receiver_classes(
1749    graph: &PythonGraphSource<'_>,
1750    python: &dyn PythonUsageSource,
1751    file: &ProjectFile,
1752    source: &str,
1753    receiver: Node<'_>,
1754    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1755) -> Vec<CodeUnit> {
1756    let ident = slice(receiver, source);
1757    if ident.is_empty() {
1758        return Vec::new();
1759    }
1760    if matches!(ident, "self" | "cls")
1761        && let Some(class) = enclosing_class_for_node(graph, file, receiver)
1762    {
1763        return vec![class];
1764    }
1765    if let Some(facts) = scope_facts {
1766        if let Some(raw_type) = facts
1767            .resolution_for(ident)
1768            .as_precise()
1769            .and_then(|targets| targets.iter().next())
1770            && let Some(class) = resolve_receiver_type(graph, python, file, raw_type, false)
1771        {
1772            return vec![class];
1773        }
1774        if facts.is_shadowed(ident) {
1775            return Vec::new();
1776        }
1777    }
1778    resolve_receiver_type(graph, python, file, ident, false)
1779        .into_iter()
1780        .collect()
1781}
1782
1783fn enclosing_class_for_node(
1784    graph: &PythonGraphSource<'_>,
1785    file: &ProjectFile,
1786    node: Node<'_>,
1787) -> Option<CodeUnit> {
1788    let range = Range {
1789        start_byte: node.start_byte(),
1790        end_byte: node.end_byte(),
1791        start_line: 0,
1792        end_line: 0,
1793    };
1794    let enclosing = graph.index.enclosing_code_unit(file, &range)?;
1795    // Python's structural model never separately indexes nested
1796    // function/lambda scopes as their own CodeUnit, so `self`/`cls` can only
1797    // ever need the enclosing unit itself or (a method's owner) exactly one
1798    // `parent_of` hop up — `.take(2)` keeps that bound explicit rather than
1799    // an unbounded walk that could climb past the same-file owner class.
1800    brokk_bifrost_core::analyzer::usages::common::enclosing_owner_chain(enclosing, |unit| {
1801        graph.index.parent_of(unit)
1802    })
1803    .take(2)
1804    .find(|unit| unit.is_class() && unit.source() == file)
1805}
1806
1807struct ImportedModuleBinding {
1808    module: String,
1809    consumed_attributes: usize,
1810}
1811
1812fn imported_module_bindings(
1813    ctx: &ScanCtx<'_>,
1814    root: Node<'_>,
1815    reference: Node<'_>,
1816) -> Vec<ImportedModuleBinding> {
1817    let root_text = slice(root, ctx.source);
1818    if root_text.is_empty() || import_root_shadowed(ctx, root_text, root, reference) {
1819        return Vec::new();
1820    }
1821
1822    if let Some(binding) = ctx.scoped_import_bindings.iter().rev().find(|binding| {
1823        binding.is_function_scoped()
1824            && binding.start_byte <= reference.start_byte()
1825            && binding.scope_start_byte <= reference.start_byte()
1826            && reference.end_byte() <= binding.scope_end_byte
1827            && binding.local_name == root_text
1828    }) {
1829        let qualified_name =
1830            resolve_python_relative_module(ctx.python.project(), ctx.file, &binding.qualified_name)
1831                .unwrap_or_else(|| binding.qualified_name.clone());
1832        return if usage_resolve_module_files(ctx.python, ctx.file, &qualified_name).is_empty() {
1833            Vec::new()
1834        } else {
1835            vec![ImportedModuleBinding {
1836                module: qualified_name,
1837                consumed_attributes: binding.consumed_attributes,
1838            }]
1839        };
1840    }
1841
1842    let Some(events) = ctx.raw_module_bindings.get(root_text) else {
1843        return Vec::new();
1844    };
1845    let cutoff = if python_reference_is_deferred_function_body(reference) {
1846        usize::MAX
1847    } else {
1848        reference.start_byte()
1849    };
1850    let visible: Vec<_> = events
1851        .iter()
1852        .filter(|event| event.visible_from <= cutoff)
1853        .collect();
1854    // Unaliased dotted imports sharing a root are cumulative in Python:
1855    // `import pkg.a; import pkg.b` leaves both attributes on `pkg`. Only a
1856    // binding of this local to some other value cuts off those earlier module
1857    // imports.
1858    let start = visible
1859        .iter()
1860        .rposition(|event| {
1861            if event.conditional {
1862                return false;
1863            }
1864            match &event.kind {
1865                ModuleBindingEventKind::ImportModule {
1866                    module,
1867                    consumed_attributes,
1868                } => *consumed_attributes == 0 && module != root_text,
1869                ModuleBindingEventKind::FromImport { .. } | ModuleBindingEventKind::Other => true,
1870            }
1871        })
1872        .unwrap_or(0);
1873    let mut modules = visible[start..]
1874        .iter()
1875        .filter_map(|event| match &event.kind {
1876            ModuleBindingEventKind::ImportModule {
1877                module,
1878                consumed_attributes,
1879            } => {
1880                let mut segments = parse_symbol_path(Language::Python, module);
1881                segments.truncate(segments.len().saturating_sub(*consumed_attributes));
1882                Some(ImportedModuleBinding {
1883                    module: segments.join("."),
1884                    consumed_attributes: 0,
1885                })
1886            }
1887            ModuleBindingEventKind::FromImport {
1888                module,
1889                imported_name,
1890            } => {
1891                let submodule = if module.ends_with('.') {
1892                    format!("{module}{imported_name}")
1893                } else {
1894                    format!("{module}.{imported_name}")
1895                };
1896                (!usage_resolve_module_files(ctx.python, ctx.file, &submodule).is_empty())
1897                    .then_some(ImportedModuleBinding {
1898                        module: submodule,
1899                        consumed_attributes: 0,
1900                    })
1901            }
1902            ModuleBindingEventKind::Other => None,
1903        })
1904        .collect::<Vec<_>>();
1905    modules.sort_by(|left, right| {
1906        left.module
1907            .cmp(&right.module)
1908            .then_with(|| left.consumed_attributes.cmp(&right.consumed_attributes))
1909    });
1910    modules.dedup_by(|left, right| {
1911        left.module == right.module && left.consumed_attributes == right.consumed_attributes
1912    });
1913    modules
1914}
1915
1916fn import_root_shadowed(
1917    ctx: &ScanCtx<'_>,
1918    root_text: &str,
1919    root: Node<'_>,
1920    reference: Node<'_>,
1921) -> bool {
1922    ctx.scope_entry_for_node(root)
1923        .or_else(|| ctx.scope_entry_for_node(reference))
1924        .is_some_and(|(scope, facts)| !scope.is_module() && facts.is_shadowed(root_text))
1925        || enclosing_parameters_shadow(root_text, reference, ctx.source)
1926}
1927
1928fn enclosing_parameters_shadow(root_text: &str, reference: Node<'_>, source: &str) -> bool {
1929    let mut current = reference;
1930    while let Some(parent) = current.parent() {
1931        if matches!(parent.kind(), "function_definition" | "lambda") {
1932            let Some(parameters) = parent.child_by_field_name("parameters") else {
1933                return false;
1934            };
1935            let mut cursor = parameters.walk();
1936            return parameters.named_children(&mut cursor).any(|parameter| {
1937                parameter_symbol(parameter, source).as_deref() == Some(root_text)
1938            });
1939        }
1940        current = parent;
1941    }
1942    false
1943}
1944
1945enum EnclosingParameterType {
1946    NotDeclared,
1947    Untyped,
1948    Typed(String),
1949}
1950
1951fn enclosing_runtime_parameter_type(
1952    name: &str,
1953    reference: Node<'_>,
1954    source: &str,
1955) -> EnclosingParameterType {
1956    let site_start = reference.start_byte();
1957    let site_end = reference.end_byte();
1958    let mut current = reference;
1959    while let Some(parent) = current.parent() {
1960        if matches!(parent.kind(), "function_definition" | "lambda")
1961            && parent
1962                .child_by_field_name("body")
1963                .is_some_and(|body| body.start_byte() <= site_start && site_end <= body.end_byte())
1964            && let Some(parameters) = parent.child_by_field_name("parameters")
1965        {
1966            let mut cursor = parameters.walk();
1967            for parameter in parameters.named_children(&mut cursor) {
1968                if parameter_symbol(parameter, source).as_deref() != Some(name) {
1969                    continue;
1970                }
1971                return parameter
1972                    .child_by_field_name("type")
1973                    .and_then(|annotation| normalized_receiver_type(slice(annotation, source)))
1974                    .map_or(EnclosingParameterType::Untyped, |raw_type| {
1975                        EnclosingParameterType::Typed(raw_type)
1976                    });
1977            }
1978        }
1979        current = parent;
1980    }
1981    EnclosingParameterType::NotDeclared
1982}
1983
1984fn member_receiver_match_is_unproven(
1985    object: Node<'_>,
1986    object_text: &str,
1987    node: Node<'_>,
1988    ctx: &ScanCtx<'_>,
1989) -> bool {
1990    if matches!(object_text, "self" | "cls") {
1991        return false;
1992    }
1993    match object.kind() {
1994        "identifier" => {
1995            ctx.receiver_type_is_unknown(object_text, node) && !ctx.member_best_effort_unique
1996        }
1997        "attribute" => true,
1998        _ => false,
1999    }
2000}
2001
2002pub fn is_declaration_identifier(node: Node<'_>) -> bool {
2003    let contains = |container: Node<'_>| {
2004        container.start_byte() <= node.start_byte() && node.end_byte() <= container.end_byte()
2005    };
2006    let mut current = node;
2007    while let Some(parent) = current.parent() {
2008        match parent.kind() {
2009            "class_definition" | "function_definition" => {
2010                return parent
2011                    .child_by_field_name("name")
2012                    .is_some_and(|name| name.id() == node.id())
2013                    || python_is_type_parameter_binder(node);
2014            }
2015            "parameters"
2016            | "lambda_parameters"
2017            | "list_splat_pattern"
2018            | "dictionary_splat_pattern" => return true,
2019            "default_parameter" | "typed_parameter" | "typed_default_parameter" => {
2020                return parent.child_by_field_name("name").is_some_and(contains);
2021            }
2022            "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => {
2023                return parent.child_by_field_name("left").is_some_and(contains);
2024            }
2025            "named_expression" => {
2026                return parent.child_by_field_name("name").is_some_and(contains);
2027            }
2028            "aliased_import" | "import_from_statement" | "import_statement" => return true,
2029            "pattern_list"
2030            | "tuple_pattern"
2031            | "list_pattern"
2032            | "parenthesized_expression"
2033            | "type"
2034            | "type_parameter"
2035            | "constrained_type" => current = parent,
2036            _ => return false,
2037        }
2038    }
2039    false
2040}
2041
2042#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2043enum ModuleBindingKind {
2044    TargetSymbolImport,
2045    TargetModuleImport,
2046    Other,
2047}
2048
2049#[derive(Clone, Copy, Debug)]
2050struct ClassifiedModuleBindingEvent {
2051    visible_from: usize,
2052    conditional: bool,
2053    kind: ModuleBindingKind,
2054}
2055
2056pub fn collect_module_binding_timeline(
2057    root: Node<'_>,
2058    source: &str,
2059    imports: &[ImportInfo],
2060) -> ModuleBindingTimeline {
2061    let mut timeline = ModuleBindingTimeline::default();
2062    let imports_by_statement = python_imports_by_statement(imports);
2063    let mut stack = vec![root];
2064    while let Some(node) = stack.pop() {
2065        match node.kind() {
2066            "function_definition" | "class_definition" => {
2067                if let Some(name) = node.child_by_field_name("name") {
2068                    record_module_binding(
2069                        &mut timeline,
2070                        slice(name, source),
2071                        node.end_byte(),
2072                        binding_is_conditional(node),
2073                        ModuleBindingEventKind::Other,
2074                    );
2075                }
2076                continue;
2077            }
2078            "import_statement" | "import_from_statement" => {
2079                if let Some(statement_imports) = imports_by_statement.get(&node.start_byte()) {
2080                    collect_import_binding_events(node, statement_imports, &mut timeline);
2081                }
2082                continue;
2083            }
2084            "assignment" | "augmented_assignment" | "named_expression" => {
2085                if let Some(left) = node.child_by_field_name("left") {
2086                    record_local_binding_targets(
2087                        left,
2088                        source,
2089                        node.end_byte(),
2090                        binding_is_conditional(node),
2091                        &mut timeline,
2092                    );
2093                }
2094                continue;
2095            }
2096            "for_statement" => {
2097                if let Some(left) = node.child_by_field_name("left") {
2098                    record_local_binding_targets(
2099                        left,
2100                        source,
2101                        left.end_byte(),
2102                        true,
2103                        &mut timeline,
2104                    );
2105                }
2106            }
2107            _ => {}
2108        }
2109
2110        let mut cursor = node.walk();
2111        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2112        children.reverse();
2113        stack.extend(children);
2114    }
2115    for events in timeline.values_mut() {
2116        events.sort_by_key(|event| event.visible_from);
2117    }
2118    timeline
2119}
2120
2121fn collect_import_binding_events(
2122    node: Node<'_>,
2123    imports: &[ImportInfo],
2124    timeline: &mut ModuleBindingTimeline,
2125) {
2126    for import in imports {
2127        let Some(details) = python_import_details(import) else {
2128            continue;
2129        };
2130        match details {
2131            PythonImportDetails::Import { module, alias } => {
2132                let Some(local) = import.local_name() else {
2133                    continue;
2134                };
2135                let consumed_attributes = if alias.is_some() {
2136                    0
2137                } else {
2138                    import
2139                        .path
2140                        .as_ref()
2141                        .map(|path| path.segments.len().saturating_sub(1))
2142                        .unwrap_or(0)
2143                };
2144                record_module_binding(
2145                    timeline,
2146                    local,
2147                    node.end_byte(),
2148                    binding_is_conditional(node),
2149                    ModuleBindingEventKind::ImportModule {
2150                        module,
2151                        consumed_attributes,
2152                    },
2153                );
2154            }
2155            PythonImportDetails::FromImport {
2156                module,
2157                name,
2158                wildcard,
2159                ..
2160            } => {
2161                if wildcard {
2162                    continue;
2163                }
2164                let Some(local) = import.local_name() else {
2165                    continue;
2166                };
2167                record_module_binding(
2168                    timeline,
2169                    local,
2170                    node.end_byte(),
2171                    binding_is_conditional(node),
2172                    ModuleBindingEventKind::FromImport {
2173                        module,
2174                        imported_name: name,
2175                    },
2176                );
2177            }
2178        }
2179    }
2180}
2181
2182fn classify_module_binding_timeline(
2183    python: &dyn PythonUsageSource,
2184    file: &ProjectFile,
2185    timeline: &ModuleBindingTimeline,
2186    seeds: &BTreeSet<(ProjectFile, String)>,
2187    edges: &[ImportEdge],
2188) -> HashMap<String, Vec<ClassifiedModuleBindingEvent>> {
2189    let mut classified = HashMap::default();
2190    let mut module_targets: HashMap<String, bool> = HashMap::default();
2191    let relevant_locals: HashSet<&str> =
2192        edges.iter().map(|edge| edge.local_name.as_str()).collect();
2193    for (local, events) in timeline {
2194        if !relevant_locals.contains(local.as_str()) {
2195            continue;
2196        }
2197        let classified_events = events
2198            .iter()
2199            .map(|event| {
2200                let kind = match &event.kind {
2201                    ModuleBindingEventKind::ImportModule { module, .. } => {
2202                        if *module_targets
2203                            .entry(module.clone())
2204                            .or_insert_with(|| module_contains_seed(python, file, module, seeds))
2205                        {
2206                            ModuleBindingKind::TargetModuleImport
2207                        } else {
2208                            ModuleBindingKind::Other
2209                        }
2210                    }
2211                    ModuleBindingEventKind::FromImport {
2212                        module,
2213                        imported_name,
2214                    } => {
2215                        let direct = usage_resolve_module_files(python, file, module).iter().any(
2216                            |resolved| seeds.contains(&(resolved.clone(), imported_name.clone())),
2217                        );
2218                        let submodule = if module.ends_with('.') {
2219                            format!("{module}{imported_name}")
2220                        } else {
2221                            format!("{module}.{imported_name}")
2222                        };
2223                        let imports_target_module =
2224                            *module_targets.entry(submodule.clone()).or_insert_with(|| {
2225                                module_contains_seed(python, file, &submodule, seeds)
2226                            });
2227                        if direct {
2228                            ModuleBindingKind::TargetSymbolImport
2229                        } else if imports_target_module {
2230                            ModuleBindingKind::TargetModuleImport
2231                        } else {
2232                            ModuleBindingKind::Other
2233                        }
2234                    }
2235                    ModuleBindingEventKind::Other => ModuleBindingKind::Other,
2236                };
2237                ClassifiedModuleBindingEvent {
2238                    visible_from: event.visible_from,
2239                    conditional: event.conditional,
2240                    kind,
2241                }
2242            })
2243            .collect();
2244        classified.insert(local.clone(), classified_events);
2245    }
2246    classified
2247}
2248
2249fn module_contains_seed(
2250    python: &dyn PythonUsageSource,
2251    file: &ProjectFile,
2252    module: &str,
2253    seeds: &BTreeSet<(ProjectFile, String)>,
2254) -> bool {
2255    usage_resolve_module_files(python, file, module)
2256        .iter()
2257        .any(|resolved| seeds.iter().any(|(seed_file, _)| seed_file == resolved))
2258}
2259
2260fn record_module_binding(
2261    timeline: &mut ModuleBindingTimeline,
2262    name: &str,
2263    visible_from: usize,
2264    conditional: bool,
2265    kind: ModuleBindingEventKind,
2266) {
2267    let name = name.trim();
2268    if name.is_empty() {
2269        return;
2270    }
2271    timeline
2272        .entry(name.to_string())
2273        .or_default()
2274        .push(ModuleBindingEvent {
2275            visible_from,
2276            conditional,
2277            kind,
2278        });
2279}
2280
2281fn record_local_binding_targets(
2282    target: Node<'_>,
2283    source: &str,
2284    visible_from: usize,
2285    conditional: bool,
2286    timeline: &mut ModuleBindingTimeline,
2287) {
2288    let mut stack = vec![target];
2289    while let Some(node) = stack.pop() {
2290        if node.kind() == "identifier" {
2291            record_module_binding(
2292                timeline,
2293                slice(node, source),
2294                visible_from,
2295                conditional,
2296                ModuleBindingEventKind::Other,
2297            );
2298            continue;
2299        }
2300        if matches!(node.kind(), "attribute" | "subscript") {
2301            continue;
2302        }
2303        let mut cursor = node.walk();
2304        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2305        children.reverse();
2306        stack.extend(children);
2307    }
2308}
2309
2310fn binding_is_conditional(mut node: Node<'_>) -> bool {
2311    while let Some(parent) = node.parent() {
2312        if matches!(
2313            parent.kind(),
2314            "if_statement"
2315                | "try_statement"
2316                | "except_clause"
2317                | "match_statement"
2318                | "case_clause"
2319                | "for_statement"
2320                | "while_statement"
2321        ) {
2322            return true;
2323        }
2324        if matches!(
2325            parent.kind(),
2326            "module" | "function_definition" | "class_definition"
2327        ) {
2328            return false;
2329        }
2330        node = parent;
2331    }
2332    false
2333}
2334
2335/// Collect the names an assignment or `for` target *binds*: plain identifiers
2336/// and identifiers nested in destructuring patterns. An `attribute`,
2337/// `subscript`, or `call` target is evaluated, not bound (`recv.member = v`
2338/// and `make().member = v` bind no local names), so its identifiers stay
2339/// references and must not shadow same-named imports or declarations (#898).
2340pub fn collect_assigned_identifiers(node: Node<'_>, source: &str, out: &mut HashSet<String>) {
2341    let mut stack = vec![node];
2342    while let Some(node) = stack.pop() {
2343        match node.kind() {
2344            "attribute" | "subscript" | "call" => continue,
2345            "identifier" => {
2346                let text = slice(node, source).trim();
2347                if !text.is_empty() {
2348                    out.insert(text.to_string());
2349                }
2350                continue;
2351            }
2352            _ => {}
2353        }
2354
2355        let mut cursor = node.walk();
2356        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2357        children.reverse();
2358        stack.extend(children);
2359    }
2360}
2361
2362pub fn collect_scope_facts_from_parsed_source(
2363    graph: &PythonGraphSource<'_>,
2364    python: &dyn PythonUsageSource,
2365    file: &ProjectFile,
2366    source: &str,
2367    root: Node<'_>,
2368) -> Option<PythonScopeFacts> {
2369    let facts = python.python_source_facts(graph.token, file, &|| true)?;
2370    let mut factory_return_types = collect_factory_return_types_from_root(root, source, &facts)?;
2371    collect_imported_factory_return_types(graph, python, file, &mut factory_return_types);
2372    Some(collect_scope_facts_with_factory_returns(
2373        graph,
2374        file,
2375        source,
2376        root,
2377        &factory_return_types,
2378    ))
2379}
2380
2381fn collect_imported_factory_return_types(
2382    graph: &PythonGraphSource<'_>,
2383    python: &dyn PythonUsageSource,
2384    file: &ProjectFile,
2385    factory_return_types: &mut HashMap<String, String>,
2386) {
2387    let binder = python.import_binder_of(file);
2388    for (local, binding) in &binder.bindings {
2389        if !matches!(binding.kind, ImportKind::Named) {
2390            continue;
2391        }
2392        let Some(imported) = binding.imported_name.as_deref() else {
2393            continue;
2394        };
2395        let fqn = format!("{}.{}", binding.module_specifier, imported);
2396        let units = resolve_fqn_candidates(python, Some(file), &fqn, |name| {
2397            graph.index.definitions(name).collect()
2398        });
2399        for unit in units {
2400            if unit.is_function() {
2401                if let Some(return_type) = callable_return_type_name(graph, python, &unit) {
2402                    let return_type =
2403                        resolve_receiver_type(graph, python, unit.source(), &return_type, true)
2404                            .map(|unit| unit.fq_name())
2405                            .unwrap_or(return_type);
2406                    factory_return_types
2407                        .entry(local.clone())
2408                        .or_insert(return_type);
2409                }
2410                continue;
2411            }
2412            if !unit.is_class() {
2413                continue;
2414            }
2415            factory_return_types
2416                .entry(local.clone())
2417                .or_insert_with(|| unit.identifier().to_string());
2418            collect_imported_class_method_return_types(
2419                graph,
2420                python,
2421                local,
2422                &unit,
2423                factory_return_types,
2424            );
2425        }
2426    }
2427}
2428
2429fn collect_imported_class_method_return_types(
2430    graph: &PythonGraphSource<'_>,
2431    python: &dyn PythonSource,
2432    local_class_name: &str,
2433    class_unit: &CodeUnit,
2434    factory_return_types: &mut HashMap<String, String>,
2435) {
2436    for member in graph.index.direct_children(class_unit) {
2437        if !member.is_function() {
2438            continue;
2439        }
2440        let Some(return_type) = callable_return_type_name(graph, python, &member) else {
2441            continue;
2442        };
2443        factory_return_types
2444            .entry(format!("{}.{}", local_class_name, member.identifier()))
2445            .or_insert(return_type);
2446    }
2447}
2448
2449fn callable_return_type_name(
2450    graph: &PythonGraphSource<'_>,
2451    python: &dyn PythonSource,
2452    callable: &CodeUnit,
2453) -> Option<String> {
2454    let metadata = graph.index.signature_metadata(callable);
2455    let first = metadata.first()?;
2456    #[cfg(any(test, feature = "test-support"))]
2457    note_callable_return_type_lookup_for_test(first.return_type_text().is_some());
2458    if first.return_type_text().is_some() {
2459        return first
2460            .return_type_identity()?
2461            .nominal_name()
2462            .map(|name| name.path().join("."));
2463    }
2464    // An unannotated factory's return statements are executable body evidence.
2465    // The source-owned declaration metadata above determines that this is an
2466    // inference request, so a missing annotation fact cannot trigger reparsing.
2467    let prepared = python.prepared_syntax(graph.token, callable.source())?;
2468    callable_return_type_name_in_tree(
2469        graph,
2470        callable,
2471        prepared.source(),
2472        prepared.tree().root_node(),
2473    )
2474}
2475
2476/// How `callable_return_type_name` answered, counted per arm.
2477///
2478/// Both canonical declarations and live body inference are counted so a test
2479/// cannot pass without exercising the consumer.
2480#[cfg(any(test, feature = "test-support"))]
2481#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
2482pub struct CallableReturnTypeLookupCounts {
2483    /// Answered from primary declaration metadata.
2484    pub canonical: usize,
2485    /// Inferred from an unannotated executable body in prepared syntax.
2486    pub body: usize,
2487}
2488
2489#[cfg(any(test, feature = "test-support"))]
2490thread_local! {
2491    static CALLABLE_RETURN_TYPE_LOOKUPS_FOR_TEST: std::cell::Cell<CallableReturnTypeLookupCounts> =
2492        const { std::cell::Cell::new(CallableReturnTypeLookupCounts { canonical: 0, body: 0 }) };
2493}
2494
2495#[cfg(any(test, feature = "test-support"))]
2496fn note_callable_return_type_lookup_for_test(from_canonical_metadata: bool) {
2497    CALLABLE_RETURN_TYPE_LOOKUPS_FOR_TEST.with(|counts| {
2498        let mut observed = counts.get();
2499        if from_canonical_metadata {
2500            observed.canonical += 1;
2501        } else {
2502            observed.body += 1;
2503        }
2504        counts.set(observed);
2505    });
2506}
2507
2508/// Runs `body` and reports which arm each `callable_return_type_name` call
2509/// took. Annotated declarations must use canonical metadata.
2510#[cfg(any(test, feature = "test-support"))]
2511pub fn with_callable_return_type_lookup_counter_for_test<T>(
2512    body: impl FnOnce() -> T,
2513) -> (T, CallableReturnTypeLookupCounts) {
2514    CALLABLE_RETURN_TYPE_LOOKUPS_FOR_TEST.with(|counts| {
2515        counts.set(CallableReturnTypeLookupCounts::default());
2516        let result = body();
2517        let observed = counts.get();
2518        counts.set(CallableReturnTypeLookupCounts::default());
2519        (result, observed)
2520    })
2521}
2522
2523/// Locate executable bodies for unannotated callable inference in one snapshot.
2524fn callable_return_type_name_in_tree(
2525    graph: &PythonGraphSource<'_>,
2526    callable: &CodeUnit,
2527    source: &str,
2528    root: Node<'_>,
2529) -> Option<String> {
2530    let mut ranges = graph.index.ranges(callable);
2531    ranges.sort_by_key(|range| range.start_byte);
2532    ranges.into_iter().find_map(|range| {
2533        let declaration = root.descendant_for_byte_range(range.start_byte, range.end_byte)?;
2534        let function = python_first_function_definition(declaration)?;
2535        inferred_factory_return_type(function, source, None, &HashMap::default())
2536    })
2537}
2538
2539fn factory_function_key(graph: &PythonGraphSource<'_>, callable: &CodeUnit) -> String {
2540    graph
2541        .index
2542        .parent_of(callable)
2543        .filter(CodeUnit::is_class)
2544        .map(|owner| format!("{}.{}", owner.identifier(), callable.identifier()))
2545        .unwrap_or_else(|| callable.identifier().to_string())
2546}
2547
2548pub fn python_first_function_definition(root: Node<'_>) -> Option<Node<'_>> {
2549    let mut stack = vec![root];
2550    while let Some(node) = stack.pop() {
2551        if node.kind() == "function_definition" {
2552            return Some(node);
2553        }
2554        let mut cursor = node.walk();
2555        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2556        children.reverse();
2557        stack.extend(children);
2558    }
2559    None
2560}
2561
2562fn collect_scope_facts_with_factory_returns(
2563    graph: &PythonGraphSource<'_>,
2564    file: &ProjectFile,
2565    source: &str,
2566    root: Node<'_>,
2567    factory_return_types: &HashMap<String, String>,
2568) -> PythonScopeFacts {
2569    let declarations = graph.index.declarations(file);
2570    let mut class_facts_by_name: HashMap<String, LocalBindingsSnapshot<String>> =
2571        HashMap::default();
2572    for declaration in declarations
2573        .iter()
2574        .filter(|declaration| declaration.is_class())
2575    {
2576        let facts = collect_declaration_scope_facts(
2577            graph,
2578            declaration,
2579            root,
2580            source,
2581            ScopeFactTraversal::Class,
2582            true,
2583            Some(declaration.short_name()),
2584            factory_return_types,
2585        );
2586        class_facts_by_name.insert(
2587            declaration.short_name().to_string(),
2588            facts.filtered_visible_bindings(|symbol, _| symbol.starts_with("self.")),
2589        );
2590    }
2591
2592    let mut scope_facts = HashMap::default();
2593    for declaration in declarations
2594        .iter()
2595        .filter(|declaration| declaration.is_function())
2596    {
2597        // fqname-M4: package-less short_name owner, matched below against
2598        // `class_facts_by_name` keys built from `short_name()`; `fq.parent()`
2599        // (`default_parent_fq_name`) would render the package-qualified owner,
2600        // a different string that would never hit in that map.
2601        let owner = declaration
2602            .short_name()
2603            .rsplit_once('.')
2604            .map(|(owner, _)| owner);
2605        let mut facts = collect_declaration_scope_facts(
2606            graph,
2607            declaration,
2608            root,
2609            source,
2610            ScopeFactTraversal::Function,
2611            false,
2612            owner,
2613            factory_return_types,
2614        );
2615        if let Some(owner) = owner
2616            && let Some(class_facts) = class_facts_by_name.get(owner)
2617        {
2618            facts = facts.merged_with_visible(class_facts);
2619        }
2620        scope_facts.insert(declaration.clone(), facts);
2621    }
2622
2623    // Module-level statements (e.g. a top-level `f = Foo()`) form their own scope.
2624    // `enclosing_code_unit` resolves a top-level usage to the module CodeUnit, so
2625    // its bindings must be recorded too, otherwise constructed-local receivers
2626    // used at module scope resolve to no type.
2627    for declaration in declarations.iter().filter(|d| d.is_module()) {
2628        let facts = collect_declaration_scope_facts(
2629            graph,
2630            declaration,
2631            root,
2632            source,
2633            ScopeFactTraversal::Module,
2634            false,
2635            None,
2636            factory_return_types,
2637        );
2638        scope_facts.insert(declaration.clone(), facts);
2639    }
2640    scope_facts
2641}
2642
2643#[allow(clippy::too_many_arguments)]
2644fn collect_declaration_scope_facts(
2645    graph: &PythonGraphSource<'_>,
2646    declaration: &CodeUnit,
2647    root: Node<'_>,
2648    source: &str,
2649    traversal: ScopeFactTraversal,
2650    allow_self_receivers: bool,
2651    current_class: Option<&str>,
2652    factory_return_types: &HashMap<String, String>,
2653) -> LocalBindingsSnapshot<String> {
2654    let mut events = Vec::new();
2655    let mut ranges = graph.index.ranges(declaration);
2656    ranges.sort_by_key(|range| range.start_byte);
2657    for range in ranges {
2658        if let Some(node) = root.descendant_for_byte_range(range.start_byte, range.end_byte) {
2659            collect_scope_fact_events_from_node(node, source, traversal, &mut events);
2660        }
2661    }
2662    collect_scope_facts_from_events(
2663        &events,
2664        allow_self_receivers,
2665        current_class,
2666        factory_return_types,
2667    )
2668}
2669
2670pub fn collect_function_scope_facts_from_node(
2671    function: Node<'_>,
2672    source: &str,
2673) -> LocalBindingsSnapshot<String> {
2674    let mut events = Vec::new();
2675    if function.kind() == "lambda" {
2676        if let Some(parameters) = function.child_by_field_name("parameters") {
2677            collect_parameter_events(parameters, source, &mut events);
2678        }
2679    } else {
2680        collect_scope_fact_events_from_node(
2681            function,
2682            source,
2683            ScopeFactTraversal::Function,
2684            &mut events,
2685        );
2686    }
2687    collect_scope_facts_from_events(&events, false, None, &HashMap::default())
2688}
2689
2690fn collect_scope_facts_from_events(
2691    events: &[ScopeFactEvent],
2692    allow_self_receivers: bool,
2693    current_class: Option<&str>,
2694    factory_return_types: &HashMap<String, String>,
2695) -> LocalBindingsSnapshot<String> {
2696    let mut engine = LocalInferenceEngine::new(LocalInferenceConfig::default());
2697    let globals: HashSet<&str> = events
2698        .iter()
2699        .filter_map(|event| match event {
2700            ScopeFactEvent::Global { symbol } => Some(symbol.as_str()),
2701            _ => None,
2702        })
2703        .collect();
2704    let nonlocals: HashSet<&str> = events
2705        .iter()
2706        .filter_map(|event| match event {
2707            ScopeFactEvent::Nonlocal { symbol } => Some(symbol.as_str()),
2708            _ => None,
2709        })
2710        .collect();
2711    for symbol in &nonlocals {
2712        engine.declare_shadow((*symbol).to_string());
2713    }
2714    for event in events {
2715        if let ScopeFactEvent::Parameter { symbol, .. } = event
2716            && !globals.contains(symbol.as_str())
2717            && !nonlocals.contains(symbol.as_str())
2718            && !engine.is_shadowed(symbol)
2719        {
2720            engine.declare_shadow(symbol.clone());
2721        }
2722    }
2723    let merged = merged_assignment_symbols(events, current_class, factory_return_types);
2724
2725    let mut changed = true;
2726    while changed {
2727        changed = false;
2728        let mut aliases = Vec::new();
2729        for event in events {
2730            match event {
2731                ScopeFactEvent::Parameter {
2732                    symbol,
2733                    annotation: Some(annotation),
2734                }
2735                | ScopeFactEvent::Annotation { symbol, annotation } => {
2736                    if globals.contains(symbol.as_str()) || nonlocals.contains(symbol.as_str()) {
2737                        continue;
2738                    }
2739                    apply_annotation_event(
2740                        symbol,
2741                        annotation,
2742                        allow_self_receivers,
2743                        &mut engine,
2744                        &mut changed,
2745                    );
2746                }
2747                ScopeFactEvent::Parameter {
2748                    annotation: None, ..
2749                } => {}
2750                ScopeFactEvent::Assignment { lhs, rhs } => {
2751                    if globals.contains(lhs.as_str()) {
2752                        continue;
2753                    }
2754                    if !engine.is_shadowed(lhs) {
2755                        engine.declare_shadow(lhs.clone());
2756                    }
2757                    if lhs.starts_with("self.") && !allow_self_receivers {
2758                        continue;
2759                    }
2760                    // A symbol that two assignments give two different types is
2761                    // a merge, not a proof. This inference is flow-insensitive,
2762                    // so it cannot say which assignment reaches a later use; the
2763                    // symbol stays declared and unresolved rather than taking the
2764                    // first type it saw (#2495). The declaration above still runs,
2765                    // so the name shadows any outer binding of the same spelling.
2766                    if merged.contains(lhs.as_str()) {
2767                        continue;
2768                    }
2769
2770                    match rhs {
2771                        AssignmentRhs::Call(callee) => {
2772                            if !engine.is_shadowed(callee) {
2773                                if let Some(receiver_type) = factory_return_type_for_callee(
2774                                    callee,
2775                                    current_class,
2776                                    factory_return_types,
2777                                ) && engine.resolve_symbol(lhs).is_unknown()
2778                                {
2779                                    engine.seed_symbol(lhs.clone(), receiver_type.clone());
2780                                    changed = true;
2781                                    continue;
2782                                }
2783
2784                                if let Some(receiver_type) = normalized_receiver_type(callee)
2785                                    && engine.resolve_symbol(lhs).is_unknown()
2786                                {
2787                                    engine.seed_symbol(lhs.clone(), receiver_type);
2788                                    changed = true;
2789                                    continue;
2790                                }
2791                            }
2792                        }
2793                        AssignmentRhs::Symbol(rhs_symbol) => {
2794                            if !engine.is_shadowed(rhs_symbol)
2795                                && let Some(receiver_type) = normalized_receiver_type(rhs_symbol)
2796                                && engine.resolve_symbol(lhs).is_unknown()
2797                            {
2798                                engine.seed_symbol(lhs.clone(), receiver_type);
2799                                changed = true;
2800                                continue;
2801                            }
2802
2803                            if let SymbolResolution::Precise(targets) =
2804                                engine.resolve_symbol(rhs_symbol)
2805                                && !targets.is_empty()
2806                            {
2807                                aliases.push((lhs.clone(), rhs_symbol.clone()));
2808                            }
2809                        }
2810                        AssignmentRhs::Unknown => {}
2811                    }
2812                }
2813                ScopeFactEvent::Global { .. } | ScopeFactEvent::Nonlocal { .. } => {}
2814            }
2815        }
2816        let before = engine.snapshot();
2817        engine.apply_aliases_until_stable(aliases);
2818        if engine.snapshot() != before {
2819            changed = true;
2820        }
2821    }
2822
2823    engine.snapshot()
2824}
2825
2826/// The symbols this scope assigns two or more *different* receiver types.
2827///
2828/// `store = AcmeStore()` followed by `store = AcmeCache()` -- in a branch, a
2829/// loop, or straight-line code -- leaves the receiver's type open. Seeding the
2830/// first assignment and ignoring the rest made a later `store.put(...)` read as
2831/// a proven call of `AcmeStore.put`, which the program may never dispatch to.
2832///
2833/// Only assignments that name a type directly participate. An `AssignmentRhs`
2834/// this scope cannot type at all contributes nothing, because an untypable
2835/// right-hand side is already the absence of evidence rather than a second,
2836/// conflicting type.
2837fn merged_assignment_symbols<'events>(
2838    events: &'events [ScopeFactEvent],
2839    current_class: Option<&str>,
2840    factory_return_types: &HashMap<String, String>,
2841) -> HashSet<&'events str> {
2842    let mut seen: HashMap<&str, String> = HashMap::default();
2843    let mut merged = HashSet::default();
2844    for event in events {
2845        let ScopeFactEvent::Assignment { lhs, rhs } = event else {
2846            continue;
2847        };
2848        let Some(assigned_type) = direct_assignment_type(rhs, current_class, factory_return_types)
2849        else {
2850            continue;
2851        };
2852        match seen.get(lhs.as_str()) {
2853            Some(first) if first == &assigned_type => {}
2854            Some(_) => {
2855                merged.insert(lhs.as_str());
2856            }
2857            None => {
2858                seen.insert(lhs.as_str(), assigned_type);
2859            }
2860        }
2861    }
2862    merged
2863}
2864
2865/// The receiver type one assignment's right-hand side names on its own, using
2866/// exactly the two routes [`collect_scope_facts_from_events`] seeds from.
2867fn direct_assignment_type(
2868    rhs: &AssignmentRhs,
2869    current_class: Option<&str>,
2870    factory_return_types: &HashMap<String, String>,
2871) -> Option<String> {
2872    match rhs {
2873        AssignmentRhs::Call(callee) => {
2874            factory_return_type_for_callee(callee, current_class, factory_return_types)
2875                .cloned()
2876                .or_else(|| normalized_receiver_type(callee))
2877        }
2878        AssignmentRhs::Symbol(symbol) => normalized_receiver_type(symbol),
2879        AssignmentRhs::Unknown => None,
2880    }
2881}
2882
2883fn factory_return_type_for_callee<'a>(
2884    callee: &str,
2885    current_class: Option<&str>,
2886    factory_return_types: &'a HashMap<String, String>,
2887) -> Option<&'a String> {
2888    if let Some(receiver_type) = factory_return_types.get(callee) {
2889        return Some(receiver_type);
2890    }
2891    let class_name = current_class?;
2892    let method = callee
2893        .strip_prefix("self.")
2894        .or_else(|| callee.strip_prefix("cls."))?;
2895    factory_return_types.get(&format!("{class_name}.{method}"))
2896}
2897
2898fn apply_annotation_event(
2899    symbol: &str,
2900    annotation: &str,
2901    allow_self_receivers: bool,
2902    engine: &mut LocalInferenceEngine<String>,
2903    changed: &mut bool,
2904) {
2905    if symbol.starts_with("self.") && !allow_self_receivers {
2906        return;
2907    }
2908    if let Some(receiver_type) = normalized_receiver_type(annotation)
2909        && engine.resolve_symbol(symbol).is_unknown()
2910    {
2911        engine.seed_symbol(symbol.to_string(), receiver_type);
2912        *changed = true;
2913    }
2914}
2915
2916enum ScopeFactEvent {
2917    Global {
2918        symbol: String,
2919    },
2920    Nonlocal {
2921        symbol: String,
2922    },
2923    Parameter {
2924        symbol: String,
2925        annotation: Option<String>,
2926    },
2927    Annotation {
2928        symbol: String,
2929        annotation: String,
2930    },
2931    Assignment {
2932        lhs: String,
2933        rhs: AssignmentRhs,
2934    },
2935}
2936
2937enum AssignmentRhs {
2938    Symbol(String),
2939    Call(String),
2940    Unknown,
2941}
2942
2943#[derive(Clone, Copy)]
2944enum ScopeFactTraversal {
2945    Module,
2946    Function,
2947    Class,
2948}
2949
2950fn collect_scope_fact_events_from_node(
2951    root: Node<'_>,
2952    source: &str,
2953    traversal: ScopeFactTraversal,
2954    events: &mut Vec<ScopeFactEvent>,
2955) {
2956    let mut stack = vec![(root, false)];
2957    while let Some((node, inside_function)) = stack.pop() {
2958        let next_inside_function = match traversal {
2959            ScopeFactTraversal::Module => {
2960                if matches!(
2961                    node.kind(),
2962                    "function_definition" | "class_definition" | "lambda"
2963                ) {
2964                    continue;
2965                }
2966                false
2967            }
2968            ScopeFactTraversal::Function => match node.kind() {
2969                "function_definition" if inside_function => continue,
2970                "function_definition" => true,
2971                "class_definition" | "lambda" => continue,
2972                _ => inside_function,
2973            },
2974            ScopeFactTraversal::Class => inside_function,
2975        };
2976        if matches!(traversal, ScopeFactTraversal::Function) && !next_inside_function {
2977            let mut cursor = node.walk();
2978            let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2979            children.reverse();
2980            stack.extend(children.into_iter().map(|child| (child, false)));
2981            continue;
2982        }
2983        match node.kind() {
2984            "global_statement" => collect_scope_directive_events(node, source, events, |symbol| {
2985                ScopeFactEvent::Global { symbol }
2986            }),
2987            "nonlocal_statement" => {
2988                collect_scope_directive_events(node, source, events, |symbol| {
2989                    ScopeFactEvent::Nonlocal { symbol }
2990                })
2991            }
2992            "parameters" | "lambda_parameters" => collect_parameter_events(node, source, events),
2993            "assignment" => collect_assignment_events(node, source, events),
2994            _ => {}
2995        }
2996
2997        let mut cursor = node.walk();
2998        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2999        children.reverse();
3000        stack.extend(
3001            children
3002                .into_iter()
3003                .map(|child| (child, next_inside_function)),
3004        );
3005    }
3006}
3007
3008fn collect_scope_directive_events(
3009    node: Node<'_>,
3010    source: &str,
3011    events: &mut Vec<ScopeFactEvent>,
3012    make_event: impl Fn(String) -> ScopeFactEvent,
3013) {
3014    let mut cursor = node.walk();
3015    for identifier in node
3016        .named_children(&mut cursor)
3017        .filter(|child| child.kind() == "identifier")
3018    {
3019        let Some(symbol) = non_empty_node_text(identifier, source) else {
3020            continue;
3021        };
3022        events.push(make_event(symbol));
3023    }
3024}
3025
3026fn collect_parameter_events(node: Node<'_>, source: &str, events: &mut Vec<ScopeFactEvent>) {
3027    let mut cursor = node.walk();
3028    for child in node.named_children(&mut cursor) {
3029        if child.kind() == "type_parameter" {
3030            continue;
3031        }
3032        let Some(symbol) = parameter_symbol(child, source) else {
3033            continue;
3034        };
3035        if matches!(symbol.as_str(), "self" | "cls" | "/") {
3036            continue;
3037        }
3038        let annotation = child
3039            .child_by_field_name("type")
3040            .map(|annotation| slice(annotation, source).trim().to_string())
3041            .filter(|annotation| !annotation.is_empty());
3042        events.push(ScopeFactEvent::Parameter { symbol, annotation });
3043    }
3044}
3045
3046fn parameter_symbol(node: Node<'_>, source: &str) -> Option<String> {
3047    if node.kind() == "identifier" {
3048        return non_empty_node_text(node, source);
3049    }
3050    if let Some(name) = node.child_by_field_name("name") {
3051        return non_empty_node_text(name, source);
3052    }
3053    let mut cursor = node.walk();
3054    node.named_children(&mut cursor)
3055        .find(|child| child.kind() == "identifier")
3056        .and_then(|identifier| non_empty_node_text(identifier, source))
3057}
3058
3059fn collect_assignment_events(node: Node<'_>, source: &str, events: &mut Vec<ScopeFactEvent>) {
3060    let Some(left) = node.child_by_field_name("left") else {
3061        return;
3062    };
3063    let Some(lhs) = receiver_symbol(left, source) else {
3064        return;
3065    };
3066
3067    if let Some(annotation) = node
3068        .child_by_field_name("type")
3069        .map(|annotation| slice(annotation, source).trim().to_string())
3070        .filter(|annotation| !annotation.is_empty())
3071    {
3072        events.push(ScopeFactEvent::Annotation {
3073            symbol: lhs,
3074            annotation,
3075        });
3076        return;
3077    }
3078
3079    let rhs = node
3080        .child_by_field_name("right")
3081        .and_then(|right| rhs_symbol(right, source))
3082        .unwrap_or(AssignmentRhs::Unknown);
3083    events.push(ScopeFactEvent::Assignment { lhs, rhs });
3084}
3085
3086fn receiver_symbol(node: Node<'_>, source: &str) -> Option<String> {
3087    match node.kind() {
3088        "identifier" | "attribute" => non_empty_node_text(node, source),
3089        _ => None,
3090    }
3091}
3092
3093fn rhs_symbol(node: Node<'_>, source: &str) -> Option<AssignmentRhs> {
3094    match node.kind() {
3095        "identifier" | "attribute" => non_empty_node_text(node, source).map(AssignmentRhs::Symbol),
3096        "call" => node
3097            .child_by_field_name("function")
3098            .or_else(|| node.named_child(0))
3099            .and_then(|callee| receiver_symbol(callee, source))
3100            .map(AssignmentRhs::Call),
3101        _ => None,
3102    }
3103}
3104
3105fn collect_factory_return_types_from_root(
3106    root: Node<'_>,
3107    source: &str,
3108    source_facts: &crate::source_facts::PythonFileSourceFacts,
3109) -> Option<HashMap<String, String>> {
3110    let returns_by_range: HashMap<_, _> = source_facts
3111        .facts
3112        .callable_returns
3113        .iter()
3114        .map(|fact| {
3115            let declaration = source_facts.occurrences.declaration(fact.declaration);
3116            let range = source_facts
3117                .occurrences
3118                .occurrence(declaration.occurrence)
3119                .range;
3120            ((range.start_byte, range.end_byte), fact)
3121        })
3122        .collect();
3123    let mut returns = HashMap::default();
3124    let mut functions = Vec::new();
3125    let mut stack = vec![(root, None::<String>)];
3126    while let Some((node, class_name)) = stack.pop() {
3127        match node.kind() {
3128            "class_definition" => {
3129                let next_class = node
3130                    .child_by_field_name("name")
3131                    .and_then(|name| non_empty_node_text(name, source))
3132                    .or(class_name);
3133                push_factory_index_children(node, next_class, &mut stack);
3134            }
3135            "function_definition" => {
3136                if let Some(name) = node
3137                    .child_by_field_name("name")
3138                    .and_then(|name| non_empty_node_text(name, source))
3139                {
3140                    let key = class_name
3141                        .as_ref()
3142                        .map(|class| format!("{class}.{name}"))
3143                        .unwrap_or(name);
3144                    let declaration = node
3145                        .parent()
3146                        .filter(|parent| parent.kind() == "decorated_definition")
3147                        .unwrap_or(node);
3148                    let fact = *returns_by_range
3149                        .get(&(declaration.start_byte(), declaration.end_byte()))?;
3150                    if let Some(return_type) = factory_return_type_with_known(
3151                        node,
3152                        source,
3153                        fact,
3154                        None,
3155                        &HashMap::default(),
3156                    ) {
3157                        returns.insert(key.clone(), return_type);
3158                    }
3159                    functions.push((key, class_name, node, fact));
3160                }
3161            }
3162            _ => push_factory_index_children(node, class_name, &mut stack),
3163        }
3164    }
3165    // A factory can delegate one branch to another local factory and construct
3166    // the same class directly on another branch. Canonicalize those AST-derived
3167    // return callees to a stable terminal type before deciding the branches
3168    // conflict. Iteration is bounded by the number of functions; cycles retain
3169    // the conservative raw result instead of recursing.
3170    for _ in 0..functions.len() {
3171        let mut changed = false;
3172        for (key, class_name, function, fact) in &functions {
3173            let Some(return_type) = factory_return_type_with_known(
3174                *function,
3175                source,
3176                fact,
3177                class_name.as_deref(),
3178                &returns,
3179            ) else {
3180                continue;
3181            };
3182            if returns.get(key) != Some(&return_type) {
3183                returns.insert(key.clone(), return_type);
3184                changed = true;
3185            }
3186        }
3187        if !changed {
3188            break;
3189        }
3190    }
3191    Some(returns)
3192}
3193
3194fn push_factory_index_children<'tree>(
3195    node: Node<'tree>,
3196    class_name: Option<String>,
3197    stack: &mut Vec<(Node<'tree>, Option<String>)>,
3198) {
3199    let mut cursor = node.walk();
3200    let mut children: Vec<Node<'tree>> = node.named_children(&mut cursor).collect();
3201    children.reverse();
3202    stack.extend(
3203        children
3204            .into_iter()
3205            .map(|child| (child, class_name.clone())),
3206    );
3207}
3208
3209fn factory_return_type_with_known(
3210    function: Node<'_>,
3211    source: &str,
3212    fact: &brokk_bifrost_core::analyzer::python_facts::PythonCallableReturnFact,
3213    current_class: Option<&str>,
3214    known: &HashMap<String, String>,
3215) -> Option<String> {
3216    if fact.return_annotation.is_some() {
3217        return fact
3218            .runtime_type
3219            .as_ref()?
3220            .nominal_name()
3221            .map(|name| name.path().join("."));
3222    }
3223
3224    inferred_factory_return_type(function, source, current_class, known)
3225}
3226
3227fn inferred_factory_return_type(
3228    function: Node<'_>,
3229    source: &str,
3230    current_class: Option<&str>,
3231    known: &HashMap<String, String>,
3232) -> Option<String> {
3233    let body = function.child_by_field_name("body")?;
3234    let mut candidates = HashSet::default();
3235    let mut saw_return = false;
3236    let mut saw_unknown_return = false;
3237    let mut stack = vec![body];
3238    while let Some(node) = stack.pop() {
3239        if node != body && matches!(node.kind(), "function_definition" | "class_definition") {
3240            continue;
3241        }
3242        if node.kind() == "return_statement" {
3243            saw_return = true;
3244            match node
3245                .named_child(0)
3246                .and_then(|value| returned_receiver_type(value, source))
3247            {
3248                Some(returned_type) => {
3249                    candidates.insert(
3250                        canonical_factory_return(&returned_type, current_class, known)
3251                            .unwrap_or(returned_type),
3252                    );
3253                }
3254                None => saw_unknown_return = true,
3255            }
3256        }
3257        let mut cursor = node.walk();
3258        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
3259        children.reverse();
3260        stack.extend(children);
3261    }
3262    if !saw_return || saw_unknown_return {
3263        return None;
3264    }
3265    (candidates.len() == 1)
3266        .then(|| candidates.into_iter().next())
3267        .flatten()
3268}
3269
3270fn canonical_factory_return(
3271    raw: &str,
3272    current_class: Option<&str>,
3273    known: &HashMap<String, String>,
3274) -> Option<String> {
3275    let mut current = raw;
3276    let mut seen = HashSet::default();
3277    while let Some(next) = factory_return_type_for_callee(current, current_class, known) {
3278        if !seen.insert(current.to_string()) {
3279            return None;
3280        }
3281        current = next;
3282    }
3283    Some(current.to_string())
3284}
3285
3286fn returned_receiver_type(node: Node<'_>, source: &str) -> Option<String> {
3287    let raw = match node.kind() {
3288        "identifier" => non_empty_node_text(node, source),
3289        "call" => node
3290            .child_by_field_name("function")
3291            .or_else(|| node.named_child(0))
3292            .filter(|callee| callee.kind() == "identifier")
3293            .and_then(|callee| non_empty_node_text(callee, source)),
3294        _ => None,
3295    }?;
3296    normalized_receiver_type(&raw)
3297}
3298
3299#[cfg(test)]
3300mod tests {
3301    use super::*;
3302    use brokk_bifrost_test_macros::hardwon;
3303
3304    #[test]
3305    #[hardwon("#1225: a lambda parameter failed to shadow the enclosing receiver name")]
3306    fn lambda_scope_facts_preserve_untyped_parameter_shadowing() {
3307        let source = "shadowed = lambda method: method.signature\n";
3308        let mut parser = Parser::new();
3309        parser
3310            .set_language(&tree_sitter_python::LANGUAGE.into())
3311            .unwrap();
3312        let tree = parser.parse(source, None).unwrap();
3313        let mut nodes = vec![tree.root_node()];
3314        let lambda = loop {
3315            let node = nodes.pop().unwrap();
3316            if node.kind() == "lambda" {
3317                break node;
3318            }
3319            let mut cursor = node.walk();
3320            nodes.extend(node.named_children(&mut cursor));
3321        };
3322
3323        let facts = collect_function_scope_facts_from_node(lambda, source);
3324
3325        assert!(facts.is_shadowed("method"));
3326        assert!(facts.resolution_for("method").is_unknown());
3327    }
3328}