Skip to main content

brokk_bifrost_python/graph/
inverted.rs

1//! Whole-workspace inverted edge builder for Python.
2//!
3//! Walks each file once and resolves every reference to the callee fqn it names,
4//! via the shared [`build_edges`] driver. Python node fqns are dotted module
5//! paths (`pkg.util.format_value`, `app.helper`), so a reference resolves through
6//! the file's import binder:
7//!
8//! - a `from pkg.util import f` binding resolves a bare `f` to `pkg.util.f`;
9//! - an `import pkg.util as u` binding resolves `u.f` to `pkg.util.f`;
10//! - a same-file/same-module name resolves to that declaration's fqn.
11//!
12//! Parameters and local assignments shadow same-named imports and module-level
13//! declarations (Python scopes are function-wide), matching the forward scan's
14//! shadow handling so a local named like an import does not produce a false edge.
15//! A typed receiver — a `recv: Foo` parameter or a `recv = Foo()` local —
16//! resolves `recv.method` to `Foo.method` via the forward scan's shared receiver
17//! typing ([`collect_scope_facts`] + [`resolve_receiver_type`]).
18
19use super::extractor::{
20    call_result_types, collect_assigned_identifiers, collect_function_scope_facts_from_node,
21    collect_scope_facts_from_parsed_source, enclosing_scope_facts, is_declaration_identifier,
22    slice,
23};
24use super::resolver::{
25    annotation_reference_candidates, resolve_callable_parameter_default_types,
26    resolve_constructor_types, resolve_receiver_type,
27};
28use crate::graph::PythonGraphSource;
29use crate::graph_support::PythonUsageSource;
30use crate::imports::resolve_fqn_candidates;
31use crate::usage_index::{usage_resolve_module_files, usage_scope_facts};
32use brokk_bifrost_core::analyzer::symbol_path::parse_symbol_path;
33use brokk_bifrost_core::analyzer::usages::inverted_edges::{
34    FileEdgeScanInput, PerFileEdges, classify_reference_node,
35};
36use brokk_bifrost_core::analyzer::usages::local_inference::LocalBindingsSnapshot;
37use brokk_bifrost_core::analyzer::usages::model::ImportKind;
38use brokk_bifrost_core::analyzer::{CodeUnit, Language, ProjectFile, Range};
39use brokk_bifrost_core::hash::{HashMap, HashSet};
40use std::sync::{Arc, Mutex};
41use tree_sitter::Node;
42
43/// The whole-pass state the per-file walk shares: the terminal-segment index
44/// over `targets`, and the namespace-candidate memo the walk fills as it goes.
45///
46/// Built once per inverted pass and then borrowed by every worker, so
47/// `brokk-bifrost-analysis`'s fan-out (`build_edge_output` + `parse_and_collect`,
48/// both analysis-owned) can hold it across the parallel closure.
49pub struct PythonEdgeScan<'a> {
50    targets: &'a HashSet<String>,
51    targets_by_terminal: HashMap<String, Vec<String>>,
52    canonical_namespace_candidates: Mutex<HashMap<String, Arc<Vec<String>>>>,
53}
54
55impl<'a> PythonEdgeScan<'a> {
56    /// `nodes` remains the complete caller/callee graph domain. `targets` is the
57    /// subset whose inbound references this build must resolve and retain.
58    pub fn new(nodes: &HashSet<String>, targets: &'a HashSet<String>) -> Self {
59        debug_assert!(targets.is_subset(nodes));
60        let mut targets_by_terminal: HashMap<String, Vec<String>> = HashMap::default();
61        for target in targets {
62            // Python fqns are dotted module paths with no other delimiter (per the
63            // module doc comment above), so re-tokenizing with the shared structured
64            // splitter and taking the terminal segment reproduces
65            // `rsplit('.').next()`'s terminal split exactly.
66            let terminal = parse_symbol_path(Language::Python, target)
67                .pop()
68                .unwrap_or_else(|| target.clone());
69            targets_by_terminal
70                .entry(terminal)
71                .or_default()
72                .push(target.clone());
73        }
74        Self {
75            targets,
76            targets_by_terminal,
77            canonical_namespace_candidates: Mutex::new(HashMap::default()),
78        }
79    }
80
81    /// Resolve every reference in one already-parsed file.
82    ///
83    /// Reaches no other file's tree: the import binder, same-file declarations,
84    /// and the receiver-type facts are all derived from this file plus the
85    /// analyzer's own (tree-free) caches.
86    pub fn scan_file(
87        &self,
88        graph: &PythonGraphSource<'_>,
89        python: &dyn PythonUsageSource,
90        file: &ProjectFile,
91        input: &FileEdgeScanInput<'_>,
92    ) -> PerFileEdges {
93        let source = input.source;
94
95        // Per-file resolution context from the import binder. A namespace
96        // binding's module_specifier is either the full fqn (for
97        // `from m import f`) or the module prefix (for `import m as u`); the
98        // node-membership check downstream disambiguates which applies.
99        let binder = python.import_binder_of(file);
100        let mut named: HashMap<String, String> = HashMap::default();
101        let mut namespace: HashMap<String, NamespaceBinding> = HashMap::default();
102        for (local, binding) in &binder.bindings {
103            match binding.kind {
104                ImportKind::Named => {
105                    if let Some(imported) = &binding.imported_name {
106                        let module = canonical_import_module_fqn(
107                            graph,
108                            python,
109                            file,
110                            &binding.module_specifier,
111                        )
112                        .unwrap_or_else(|| binding.module_specifier.clone());
113                        let imported_fqn = if module.ends_with('.') {
114                            format!("{module}{imported}")
115                        } else {
116                            format!("{module}.{imported}")
117                        };
118                        if let Some(imported_module) =
119                            canonical_import_module_fqn(graph, python, file, &imported_fqn)
120                        {
121                            namespace.insert(
122                                local.clone(),
123                                NamespaceBinding {
124                                    module: imported_module,
125                                    workspace_module: true,
126                                    consumed_attributes: 0,
127                                },
128                            );
129                        } else {
130                            named.insert(local.clone(), imported_fqn);
131                        }
132                    }
133                }
134                ImportKind::Namespace => {
135                    let direct_module = binding.module_specifier.clone();
136                    let imported_module = binding
137                        .namespace_imported_module
138                        .as_deref()
139                        .unwrap_or(&direct_module);
140                    let module = canonical_import_module_fqn(graph, python, file, imported_module);
141                    let workspace_module = module.is_some();
142                    let consumed_attributes = module.as_ref().map_or(0, |_| {
143                        let imported_segments =
144                            parse_symbol_path(Language::Python, imported_module);
145                        let bound_segments = parse_symbol_path(Language::Python, &direct_module);
146                        imported_segments.len().saturating_sub(bound_segments.len())
147                    });
148                    namespace.insert(
149                        local.clone(),
150                        NamespaceBinding {
151                            module: module.unwrap_or(direct_module),
152                            workspace_module,
153                            consumed_attributes,
154                        },
155                    );
156                }
157                ImportKind::Default | ImportKind::CommonJsRequire | ImportKind::Glob => {}
158            }
159        }
160        let same_file: HashMap<String, String> = graph
161            .index
162            .declarations(file)
163            .into_iter()
164            .map(|unit| (unit.identifier().to_string(), unit.fq_name()))
165            .collect();
166
167        // Per-function receiver-type facts (typed params + `x = Foo()`),
168        // computed by the same routine the forward scan uses, so a typed
169        // `recv.method` resolves to the receiver's class fqn.
170        let scope_facts = usage_scope_facts(python, file, || {
171            collect_scope_facts_from_parsed_source(graph, python, file, source, input.root())
172        });
173
174        let mut ctx = PyScan {
175            graph,
176            python,
177            targets: self.targets,
178            targets_by_terminal: &self.targets_by_terminal,
179            file,
180            source,
181            named,
182            namespace,
183            same_file,
184            scope_facts: scope_facts.as_ref(),
185            canonical_namespace_candidates: &self.canonical_namespace_candidates,
186            input,
187            edges: PerFileEdges::default(),
188        };
189        scan_tree(input.root(), &mut ctx);
190        ctx.edges
191    }
192}
193
194fn canonical_import_module_fqn(
195    graph: &PythonGraphSource<'_>,
196    python: &dyn PythonUsageSource,
197    importing_file: &ProjectFile,
198    module_specifier: &str,
199) -> Option<String> {
200    let resolved = usage_resolve_module_files(python, importing_file, module_specifier);
201    let [module_file] = resolved.as_slice() else {
202        return None;
203    };
204    graph
205        .index
206        .declarations(module_file)
207        .into_iter()
208        .find(CodeUnit::is_module)
209        .map(|module| module.fq_name())
210}
211
212struct PyScan<'a> {
213    graph: &'a PythonGraphSource<'a>,
214    python: &'a dyn PythonUsageSource,
215    targets: &'a HashSet<String>,
216    targets_by_terminal: &'a HashMap<String, Vec<String>>,
217    file: &'a ProjectFile,
218    source: &'a str,
219    named: HashMap<String, String>,
220    namespace: HashMap<String, NamespaceBinding>,
221    same_file: HashMap<String, String>,
222    scope_facts: &'a HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
223    canonical_namespace_candidates: &'a Mutex<HashMap<String, Arc<Vec<String>>>>,
224    input: &'a FileEdgeScanInput<'a>,
225    edges: PerFileEdges,
226}
227
228struct NamespaceBinding {
229    module: String,
230    workspace_module: bool,
231    consumed_attributes: usize,
232}
233
234impl PyScan<'_> {
235    /// The callee fqn a bare name refers to: a named import, a namespace import of
236    /// a symbol (module_specifier is the full fqn), or a same-file declaration.
237    fn bare_callee(&self, text: &str) -> Option<String> {
238        if let Some(fqn) = self.named.get(text) {
239            return Some(fqn.clone());
240        }
241        if let Some(fqn) = self.namespace.get(text) {
242            return Some(fqn.module.clone());
243        }
244        if let Some(fqn) = self.same_file.get(text) {
245            return Some(fqn.clone());
246        }
247        None
248    }
249
250    /// The class fqn `receiver` is typed as within the given scope `facts` — a
251    /// typed parameter or a `recv = Class()` local — so `recv.method` resolves to
252    /// `Class.method`. Reuses the forward scan's receiver typing.
253    fn receiver_type_fqn(
254        &self,
255        facts: &LocalBindingsSnapshot<String>,
256        receiver: &str,
257    ) -> Option<String> {
258        let resolution = facts.resolution_for(receiver);
259        let type_name = resolution
260            .as_precise()
261            .and_then(|targets| targets.iter().next())?;
262        // `target_self_file = false`: resolve only via this file's imports and its
263        // own declarations. The forward path's workspace-wide first-match fallback
264        // is gated on matching a known target owner; the inverted builder has no
265        // target to validate against, so enabling it would let an unimported,
266        // non-local type name bind to an unrelated same-named class elsewhere.
267        resolve_receiver_type(self.graph, self.python, self.file, type_name, false)
268            .map(|unit| unit.fq_name())
269    }
270
271    fn record(&mut self, callee: String, node: Node<'_>) {
272        if !self.targets.contains(&callee) {
273            return;
274        }
275        self.edges.record_kind(
276            self.input,
277            callee,
278            classify_reference_node(node),
279            node.start_byte(),
280            node.end_byte(),
281        );
282    }
283
284    fn record_unproven_name(&mut self, name: &str, node: Node<'_>) {
285        let Some(targets) = self.targets_by_terminal.get(name) else {
286            return;
287        };
288        for target in targets {
289            self.edges.record_unproven(
290                self.input,
291                target.clone(),
292                node.start_byte(),
293                node.end_byte(),
294            );
295        }
296    }
297
298    fn canonical_namespace_candidates(&self, direct: &str) -> Arc<Vec<String>> {
299        if let Some(cached) = self
300            .canonical_namespace_candidates
301            .lock()
302            .expect("Python namespace candidate cache mutex poisoned")
303            .get(direct)
304            .cloned()
305        {
306            return cached;
307        }
308
309        let resolved: Arc<Vec<String>> = Arc::new(
310            resolve_fqn_candidates(self.python, direct, |name| {
311                self.graph.index.definitions(name).collect()
312            })
313            .into_iter()
314            .map(|unit| unit.fq_name())
315            .collect(),
316        );
317        self.canonical_namespace_candidates
318            .lock()
319            .expect("Python namespace candidate cache mutex poisoned")
320            .entry(direct.to_string())
321            .or_insert_with(|| resolved.clone())
322            .clone()
323    }
324}
325
326fn scan_tree(root: Node<'_>, ctx: &mut PyScan<'_>) {
327    // A stack of in-scope local names, one frame per enclosing function. A name
328    // bound in any frame shadows a same-named import/declaration.
329    let mut scopes: Vec<FunctionScope> = Vec::new();
330    walk(root, ctx, &mut scopes, None);
331}
332
333fn walk(
334    node: Node<'_>,
335    ctx: &mut PyScan<'_>,
336    scopes: &mut Vec<FunctionScope>,
337    facts: Option<usize>,
338) {
339    let mut merged_facts = Vec::new();
340    let mut stack = vec![WalkFrame::Enter { node, facts }];
341    while let Some(frame) = stack.pop() {
342        match frame {
343            WalkFrame::Enter { node, facts } => match node.kind() {
344                "import_statement" | "import_from_statement" => {}
345                // A function (or lambda) opens a scope; its parameters and the names it
346                // assigns are local throughout it, so collect them up front. Resolve the
347                // scope's receiver-type facts once here and thread them down.
348                "function_definition" | "lambda" => {
349                    let function_scope = collect_function_scope(node, ctx.source);
350                    let scope_facts = merged_enclosing_scope_facts(
351                        ctx.graph,
352                        ctx.file,
353                        ctx.scope_facts,
354                        &mut merged_facts,
355                        node,
356                        ctx.source,
357                        facts,
358                    );
359                    push_function_children(node, facts, scope_facts, function_scope, &mut stack);
360                }
361                // A class body is not a function scope: code at the class-body level has
362                // no enclosing-function facts. Methods inside re-resolve their own facts.
363                "class_definition" => push_children(node, None, &mut stack),
364                "identifier" => {
365                    if !handle_annotation_reference(node, ctx) {
366                        handle_identifier(node, ctx, scopes);
367                    }
368                    push_children(node, facts, &mut stack);
369                }
370                "attribute" => {
371                    if handle_annotation_reference(node, ctx) {
372                        continue;
373                    }
374                    let scope_facts = facts.and_then(|id| merged_facts.get(id));
375                    handle_attribute(node, ctx, scopes, scope_facts);
376                    push_children(node, facts, &mut stack);
377                }
378                "string_content" => {
379                    handle_annotation_reference(node, ctx);
380                }
381                "keyword_argument" => {
382                    handle_keyword_argument(node, ctx, scopes);
383                    if let Some(value) = node.child_by_field_name("value") {
384                        stack.push(WalkFrame::Enter { node: value, facts });
385                    }
386                }
387                _ => push_children(node, facts, &mut stack),
388            },
389            WalkFrame::ExitScope => {
390                scopes.pop();
391            }
392            WalkFrame::EnterScope(scope) => scopes.push(scope),
393        }
394    }
395}
396
397enum WalkFrame<'tree> {
398    Enter {
399        node: Node<'tree>,
400        facts: Option<usize>,
401    },
402    EnterScope(FunctionScope),
403    ExitScope,
404}
405
406fn push_children<'tree>(
407    node: Node<'tree>,
408    facts: Option<usize>,
409    stack: &mut Vec<WalkFrame<'tree>>,
410) {
411    for index in (0..node.named_child_count()).rev() {
412        if let Some(child) = node.named_child(index) {
413            stack.push(WalkFrame::Enter { node: child, facts });
414        }
415    }
416}
417
418fn push_function_children<'tree>(
419    function: Node<'tree>,
420    enclosing_facts: Option<usize>,
421    body_facts: Option<usize>,
422    function_scope: FunctionScope,
423    stack: &mut Vec<WalkFrame<'tree>>,
424) {
425    let body = function.child_by_field_name("body");
426    let mut function_scope = Some(function_scope);
427    for index in (0..function.named_child_count()).rev() {
428        if let Some(child) = function.named_child(index) {
429            // Defaults and annotations are evaluated while defining the
430            // function, before its parameters and locals exist. Only the body
431            // executes in the new lexical scope.
432            let facts = if body == Some(child) {
433                body_facts
434            } else {
435                enclosing_facts
436            };
437            if body == Some(child) {
438                stack.push(WalkFrame::ExitScope);
439                stack.push(WalkFrame::Enter { node: child, facts });
440                stack.push(WalkFrame::EnterScope(
441                    function_scope
442                        .take()
443                        .expect("a function has exactly one body scope"),
444                ));
445            } else {
446                stack.push(WalkFrame::Enter { node: child, facts });
447            }
448        }
449    }
450}
451
452fn merged_enclosing_scope_facts(
453    graph: &PythonGraphSource<'_>,
454    file: &ProjectFile,
455    scope_facts: &HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
456    merged_facts: &mut Vec<LocalBindingsSnapshot<String>>,
457    node: Node<'_>,
458    source: &str,
459    inherited: Option<usize>,
460) -> Option<usize> {
461    let structural_local = collect_function_scope_facts_from_node(node, source);
462    // A top-level function or class method has a complete indexed snapshot,
463    // including factory-return facts that the node-only structural pass cannot
464    // reconstruct. Nested functions and lambdas instead need their structural
465    // declarations to shadow the inherited outer snapshot.
466    let local = if inherited.is_none() && node.kind() == "function_definition" {
467        enclosing_scope_facts(graph.index, file, scope_facts, node)
468            .cloned()
469            .unwrap_or(structural_local)
470    } else {
471        structural_local
472    };
473    match (local, inherited) {
474        (local, Some(inherited_id)) => {
475            let inherited = merged_facts.get(inherited_id)?;
476            let merged = inherited.merged_with_shadowing(&local);
477            let next_id = merged_facts.len();
478            merged_facts.push(merged);
479            Some(next_id)
480        }
481        (local, None) => {
482            let next_id = merged_facts.len();
483            merged_facts.push(local);
484            Some(next_id)
485        }
486    }
487}
488
489#[derive(Default)]
490struct FunctionScope {
491    locals: HashSet<String>,
492    parameters: HashSet<String>,
493}
494
495fn is_shadowed(scopes: &[FunctionScope], name: &str) -> bool {
496    scopes.iter().any(|scope| scope.locals.contains(name))
497}
498
499fn is_receiver_parameter(scopes: &[FunctionScope], name: &str) -> bool {
500    scopes
501        .iter()
502        .rev()
503        .any(|scope| scope.parameters.contains(name))
504}
505
506fn handle_identifier(node: Node<'_>, ctx: &mut PyScan<'_>, scopes: &[FunctionScope]) {
507    // The object of an `attribute` is handled by handle_attribute.
508    if node
509        .parent()
510        .is_some_and(|parent| parent.kind() == "attribute")
511    {
512        return;
513    }
514    if is_declaration_identifier(node) {
515        return;
516    }
517    let text = slice(node, ctx.source);
518    if text.is_empty() || is_shadowed(scopes, text) {
519        return;
520    }
521    if let Some(callee) = ctx.bare_callee(text) {
522        ctx.record(callee, node);
523    }
524}
525
526fn handle_annotation_reference(node: Node<'_>, ctx: &mut PyScan<'_>) -> bool {
527    let Some(candidates) =
528        annotation_reference_candidates(ctx.graph, ctx.python, ctx.file, ctx.source, node, false)
529    else {
530        return false;
531    };
532    let [candidate] = candidates.as_slice() else {
533        return !(node.kind() == "attribute" && candidates.is_empty());
534    };
535
536    let site = if node.kind() == "attribute" {
537        node.child_by_field_name("attribute").unwrap_or(node)
538    } else {
539        node
540    };
541    ctx.record(candidate.fq_name(), site);
542    true
543}
544
545fn handle_attribute(
546    node: Node<'_>,
547    ctx: &mut PyScan<'_>,
548    scopes: &[FunctionScope],
549    facts: Option<&LocalBindingsSnapshot<String>>,
550) {
551    let (Some(object), Some(attribute)) = (
552        node.child_by_field_name("object"),
553        node.child_by_field_name("attribute"),
554    ) else {
555        return;
556    };
557    let object_text = slice(object, ctx.source);
558    let attribute_text = slice(attribute, ctx.source);
559    if object_text.is_empty() || attribute_text.is_empty() {
560        return;
561    }
562    if object.kind() == "call" && ctx.targets_by_terminal.contains_key(attribute_text) {
563        for class in call_result_types(ctx.graph, ctx.python, ctx.file, ctx.source, object, facts) {
564            let direct = format!("{}.{attribute_text}", class.fq_name());
565            if ctx.targets.contains(&direct) {
566                ctx.record(direct, attribute);
567                continue;
568            }
569            if let Some(provider) = ctx.graph.hierarchy {
570                for ancestor in provider.get_ancestors(&class) {
571                    let inherited = format!("{}.{attribute_text}", ancestor.fq_name());
572                    if ctx.targets.contains(&inherited) {
573                        ctx.record(inherited, attribute);
574                    }
575                }
576            }
577        }
578    }
579    // `module.symbol` or a deeper `module.ns.symbol` chain rooted at a
580    // namespace import. Walk the attribute structure from the leftmost imported
581    // root so deep chains stay exact without source-text splitting.
582    if let Some((root, attributes)) = attribute_chain(node) {
583        let root_text = slice(root, ctx.source);
584        if !root_text.is_empty()
585            && !is_shadowed(scopes, root_text)
586            && let Some(binding) = ctx.namespace.get(root_text)
587        {
588            let mut direct = binding.module.clone();
589            let workspace_module = binding.workspace_module;
590            let consumed_attributes = binding.consumed_attributes;
591            if object.kind() == "identifier" && ctx.targets.contains(&direct) {
592                ctx.record(direct.clone(), object);
593            }
594            for member in attributes.into_iter().skip(consumed_attributes) {
595                let member_text = slice(member, ctx.source);
596                if member_text.is_empty() {
597                    return;
598                }
599                direct.push('.');
600                direct.push_str(member_text);
601            }
602            if ctx.targets.contains(&direct) {
603                ctx.record(direct, attribute);
604                return;
605            }
606            // A re-export alias can change the terminal name (`proto.module` may
607            // canonically resolve to `proto.modules.define_module`), so terminal-name
608            // filtering is not sound here. Namespace imports are already a narrow,
609            // structured subset of attributes; resolve their workspace candidates
610            // and let `record` retain only requested targets.
611            if workspace_module {
612                for resolved in ctx.canonical_namespace_candidates(&direct).iter() {
613                    ctx.record(resolved.clone(), attribute);
614                }
615            }
616            return;
617        }
618    }
619
620    // `recv.method` where recv is a typed local/parameter: resolve to the
621    // receiver's class fqn. Unknown or ambiguous receiver facts are not enough
622    // for a proven edge, but they are structured evidence that a same-named
623    // member may be reachable, so bulk dead-code treats the candidate as
624    // inconclusive instead of dead.
625    if let Some(facts) = facts
626        && ctx.targets_by_terminal.contains_key(attribute_text)
627    {
628        if matches!(object_text, "self" | "cls") {
629            // `self.member` / `cls.member` is a same-owner reference (#1138):
630            // record it as unproven inbound rather than a proven edge, so a
631            // member reachable only through same-owner access reads
632            // INCONCLUSIVE, never confidently dead — matching the other
633            // languages.
634            ctx.record_unproven_name(attribute_text, attribute);
635        } else if let Some(type_fqn) = ctx.receiver_type_fqn(facts, object_text) {
636            ctx.record(format!("{type_fqn}.{attribute_text}"), attribute);
637        } else if object.kind() == "identifier" && !ctx.named.contains_key(object_text) {
638            let resolution = facts.resolution_for(object_text);
639            if resolution.is_ambiguous()
640                || (resolution.is_unknown() && is_receiver_parameter(scopes, object_text))
641            {
642                ctx.record_unproven_name(attribute_text, attribute);
643            }
644        }
645    }
646}
647
648fn handle_keyword_argument(node: Node<'_>, ctx: &mut PyScan<'_>, scopes: &[FunctionScope]) {
649    let (Some(name), Some(arguments)) = (node.child_by_field_name("name"), node.parent()) else {
650        return;
651    };
652    if name.kind() != "identifier" || arguments.kind() != "argument_list" {
653        return;
654    }
655    let Some(call) = arguments.parent().filter(|parent| parent.kind() == "call") else {
656        return;
657    };
658    let Some(function) = call.child_by_field_name("function") else {
659        return;
660    };
661    let member = slice(name, ctx.source);
662    if member.is_empty() || !ctx.targets_by_terminal.contains_key(member) {
663        return;
664    }
665    let scoped_class_fqn = if function.kind() == "identifier" {
666        enclosing_scope_facts(ctx.graph.index, ctx.file, ctx.scope_facts, function)
667            .and_then(|facts| ctx.receiver_type_fqn(facts, slice(function, ctx.source)))
668    } else {
669        None
670    };
671    let function_name = (function.kind() == "identifier").then(|| slice(function, ctx.source));
672    let mut default_classes = function_name.map_or_else(Vec::new, |local_name| {
673        resolve_callable_parameter_default_types(
674            ctx.graph, ctx.python, ctx.file, ctx.source, function, local_name,
675        )
676    });
677    let root_shadowed = leftmost_identifier(function)
678        .is_some_and(|root| is_shadowed(scopes, slice(root, ctx.source)));
679    let mut classes = if function_name == Some("cls") {
680        lexical_class(ctx, function).into_iter().collect()
681    } else {
682        if root_shadowed && scoped_class_fqn.is_none() && default_classes.is_empty() {
683            return;
684        }
685        if !root_shadowed {
686            default_classes.extend(resolve_constructor_types(
687                ctx.graph, ctx.python, ctx.file, ctx.source, function,
688            ));
689        }
690        default_classes
691    };
692    if let Some(fqn) = scoped_class_fqn {
693        classes.extend(ctx.graph.index.definitions(&fqn).filter(CodeUnit::is_class));
694        classes.sort();
695        classes.dedup();
696    }
697    for class in classes {
698        let direct = format!("{}.{member}", class.fq_name());
699        if ctx.targets.contains(&direct) {
700            ctx.record(direct, name);
701            continue;
702        }
703        if let Some(provider) = ctx.graph.hierarchy {
704            for ancestor in provider.get_ancestors(&class) {
705                let inherited = format!("{}.{member}", ancestor.fq_name());
706                if ctx.targets.contains(&inherited) {
707                    ctx.record(inherited, name);
708                }
709            }
710        }
711    }
712}
713
714fn lexical_class(ctx: &PyScan<'_>, node: Node<'_>) -> Option<CodeUnit> {
715    let range = Range {
716        start_byte: node.start_byte(),
717        end_byte: node.end_byte(),
718        start_line: 0,
719        end_line: 0,
720    };
721    let enclosing = ctx.graph.index.enclosing_code_unit(ctx.file, &range)?;
722    if enclosing.is_class() {
723        Some(enclosing)
724    } else {
725        ctx.graph
726            .index
727            .parent_of(&enclosing)
728            .filter(CodeUnit::is_class)
729    }
730}
731
732fn leftmost_identifier(mut node: Node<'_>) -> Option<Node<'_>> {
733    loop {
734        match node.kind() {
735            "identifier" => return Some(node),
736            "attribute" => node = node.child_by_field_name("object")?,
737            _ => return None,
738        }
739    }
740}
741
742fn attribute_chain<'a>(node: Node<'a>) -> Option<(Node<'a>, Vec<Node<'a>>)> {
743    let mut attributes = Vec::new();
744    let mut current = node;
745    loop {
746        if current.kind() != "attribute" {
747            return None;
748        }
749        attributes.push(current.child_by_field_name("attribute")?);
750        current = current.child_by_field_name("object")?;
751        if current.kind() == "identifier" {
752            attributes.reverse();
753            return Some((current, attributes));
754        }
755    }
756}
757
758/// The local names a function binds: its parameters plus every name it assigns.
759/// Python scoping is function-wide, so a name assigned anywhere in the body is
760/// local throughout; nested function/class scopes are skipped (they get their
761/// own frame), but the names they bind in *this* scope are kept.
762fn collect_function_scope(func: Node<'_>, source: &str) -> FunctionScope {
763    let mut scope = FunctionScope::default();
764    if let Some(params) = func.child_by_field_name("parameters") {
765        collect_parameter_names(params, source, &mut scope.parameters);
766        scope.locals.extend(scope.parameters.iter().cloned());
767    }
768    if let Some(body) = func.child_by_field_name("body") {
769        collect_bound_targets(body, source, &mut scope.locals);
770    }
771    scope
772}
773
774fn collect_parameter_names(params: Node<'_>, source: &str, out: &mut HashSet<String>) {
775    let mut cursor = params.walk();
776    for child in params.named_children(&mut cursor) {
777        let name = match child.kind() {
778            "identifier" => Some(child),
779            // typed / default / splat parameters carry the binding either in a
780            // `name` field or as their first identifier child.
781            _ => child
782                .child_by_field_name("name")
783                .or_else(|| child.named_child(0).filter(|n| n.kind() == "identifier")),
784        };
785        if let Some(name) = name {
786            let text = slice(name, source).trim();
787            if !text.is_empty() {
788                out.insert(text.to_string());
789            }
790        }
791    }
792}
793
794/// Collect names bound by assignment within a scope, without descending into
795/// nested function/class scopes (only the nested definition's own name is bound
796/// here).
797fn collect_bound_targets(node: Node<'_>, source: &str, out: &mut HashSet<String>) {
798    let mut stack = vec![node];
799    while let Some(node) = stack.pop() {
800        match node.kind() {
801            "function_definition" | "class_definition" => {
802                if let Some(name) = node.child_by_field_name("name") {
803                    let text = slice(name, source).trim();
804                    if !text.is_empty() {
805                        out.insert(text.to_string());
806                    }
807                }
808                continue;
809            }
810            "lambda" => continue,
811            "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => {
812                if let Some(left) = node.child_by_field_name("left") {
813                    collect_assigned_identifiers(left, source, out);
814                }
815            }
816            "named_expression" => {
817                if let Some(name) = node.child_by_field_name("name") {
818                    collect_assigned_identifiers(name, source, out);
819                }
820            }
821            _ => {}
822        }
823        let mut cursor = node.walk();
824        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
825        children.reverse();
826        stack.extend(children);
827    }
828}