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