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