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, UsageReferenceKind, 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            pending: Vec::new(),
214        };
215        scan_tree(input.root(), &mut ctx);
216        ctx.resolve_pending();
217        ctx.edges
218    }
219}
220
221fn reference_span(node: Node<'_>) -> (UsageReferenceKind, usize, usize) {
222    (
223        classify_reference_node(node),
224        node.start_byte(),
225        node.end_byte(),
226    )
227}
228
229fn canonical_import_module_fqn(
230    graph: &PythonGraphSource<'_>,
231    python: &dyn PythonUsageSource,
232    importing_file: &ProjectFile,
233    module_specifier: &str,
234) -> Option<String> {
235    let resolved = usage_resolve_module_files(python, importing_file, module_specifier);
236    let [module_file] = resolved.as_slice() else {
237        return None;
238    };
239    graph
240        .index
241        .declarations(module_file)
242        .into_iter()
243        .find(CodeUnit::is_module)
244        .map(|module| module.fq_name())
245}
246
247struct PyScan<'a> {
248    graph: &'a PythonGraphSource<'a>,
249    python: &'a dyn PythonUsageSource,
250    targets: Option<&'a HashSet<String>>,
251    targets_by_terminal: &'a HashMap<String, Vec<String>>,
252    file: &'a ProjectFile,
253    source: &'a str,
254    named: HashMap<String, String>,
255    namespace: HashMap<String, NamespaceBinding>,
256    same_file: HashMap<String, String>,
257    module_bindings: Arc<ModuleBindingTimeline>,
258    scope_facts: &'a HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
259    canonical_namespace_candidates: &'a Mutex<HashMap<String, Arc<Vec<String>>>>,
260    input: &'a FileEdgeScanInput<'a>,
261    edges: PerFileEdges,
262    /// Rooted-mode existence checks deferred until [`PyScan::resolve_pending`] runs
263    /// them as one batch instead of one live store round trip per reference. Empty
264    /// and unused in bounded (`targets: Some`) mode, where membership is already a
265    /// local hash check.
266    pending: Vec<PendingCallee>,
267}
268
269/// One reference site's deferred existence check, resolved in [`PyScan::resolve_pending`]
270/// after a single [`PythonSource::prefetch_definitions`] batch has warmed every candidate
271/// name below. Each variant mirrors the immediate-check logic its call site used to run
272/// inline; deferring only changes when the check runs, not what it decides.
273enum PendingCallee {
274    /// Record `callee` if it has a definition.
275    Direct {
276        callee: String,
277        kind: UsageReferenceKind,
278        start: usize,
279        end: usize,
280    },
281    /// Record `direct` if it has a definition; otherwise expand it into every
282    /// workspace candidate `canonical_namespace_candidates` finds.
283    WithNamespaceFallback {
284        direct: String,
285        kind: UsageReferenceKind,
286        start: usize,
287        end: usize,
288    },
289    /// Record `direct` if it has a definition; otherwise record every fqn in
290    /// `inherited` that does.
291    WithAncestorFallback {
292        direct: String,
293        inherited: Vec<String>,
294        kind: UsageReferenceKind,
295        start: usize,
296        end: usize,
297    },
298}
299
300struct NamespaceBinding {
301    root_module: String,
302    module: String,
303    workspace_module: bool,
304    consumed_attributes: usize,
305}
306
307impl PyScan<'_> {
308    /// The callee fqn a bare name refers to: a named import, a namespace import of
309    /// a symbol (module_specifier is the full fqn), or a same-file declaration.
310    fn bare_callee(&self, text: &str, node: Node<'_>) -> Option<String> {
311        if let Some(fqn) = self.named.get(text) {
312            return Some(fqn.clone());
313        }
314        if let Some(fqn) = self.namespace.get(text) {
315            return self
316                .visible_namespace_root(text, node)
317                .or_else(|| Some(fqn.root_module.clone()));
318        }
319        if let Some(fqn) = self.same_file.get(text) {
320            return Some(fqn.clone());
321        }
322        None
323    }
324
325    fn visible_namespace_root(&self, local: &str, node: Node<'_>) -> Option<String> {
326        let events = self.module_bindings.get(local)?;
327        let cutoff = if reference_is_deferred_function_body(node) {
328            usize::MAX
329        } else {
330            node.start_byte()
331        };
332        let visible: Vec<_> = events
333            .iter()
334            .filter(|event| event.visible_from <= cutoff)
335            .collect();
336        let start = visible
337            .iter()
338            .rposition(|event| {
339                if event.conditional {
340                    return false;
341                }
342                match &event.kind {
343                    ModuleBindingEventKind::ImportModule {
344                        module,
345                        consumed_attributes,
346                    } => *consumed_attributes == 0 && module != local,
347                    ModuleBindingEventKind::FromImport { .. } | ModuleBindingEventKind::Other => {
348                        true
349                    }
350                }
351            })
352            .unwrap_or(0);
353        let mut roots = visible[start..]
354            .iter()
355            .filter_map(|event| match &event.kind {
356                ModuleBindingEventKind::ImportModule {
357                    module,
358                    consumed_attributes,
359                } => {
360                    let mut segments = parse_symbol_path(Language::Python, module);
361                    segments.truncate(segments.len().saturating_sub(*consumed_attributes));
362                    let root = segments.join(".");
363                    canonical_import_module_fqn(self.graph, self.python, self.file, &root)
364                        .or(Some(root))
365                }
366                ModuleBindingEventKind::FromImport { .. } | ModuleBindingEventKind::Other => None,
367            })
368            .collect::<Vec<_>>();
369        roots.sort();
370        roots.dedup();
371        (roots.len() == 1).then(|| roots.remove(0))
372    }
373
374    /// The class fqn `receiver` is typed as within the given scope `facts` — a
375    /// typed parameter or a `recv = Class()` local — so `recv.method` resolves to
376    /// `Class.method`. Reuses the forward scan's receiver typing.
377    fn receiver_type_fqn(
378        &self,
379        facts: &LocalBindingsSnapshot<String>,
380        receiver: &str,
381    ) -> Option<String> {
382        let resolution = facts.resolution_for(receiver);
383        let type_name = resolution
384            .as_precise()
385            .and_then(|targets| targets.iter().next())?;
386        // `target_self_file = false`: resolve only via this file's imports and its
387        // own declarations. The forward path's workspace-wide first-match fallback
388        // is gated on matching a known target owner; the inverted builder has no
389        // target to validate against, so enabling it would let an unimported,
390        // non-local type name bind to an unrelated same-named class elsewhere.
391        resolve_receiver_type(self.graph, self.python, self.file, type_name, false)
392            .map(|unit| unit.fq_name())
393    }
394
395    fn record(&mut self, callee: String, node: Node<'_>) {
396        let (kind, start, end) = reference_span(node);
397        // Bounded mode already knows every valid callee as a local hash set, so
398        // `accepts_target` is a cheap in-memory check: no reason to defer it.
399        // Rooted mode has no such set; `accepts_target` there falls through to a
400        // live store lookup, so every rooted-mode reference is deferred and
401        // resolved together in `resolve_pending`, batching what would otherwise be
402        // one round trip per reference.
403        if self.targets.is_none() {
404            self.pending.push(PendingCallee::Direct {
405                callee,
406                kind,
407                start,
408                end,
409            });
410            return;
411        }
412        self.record_direct(callee, kind, start, end);
413    }
414
415    /// Record `direct`, or expand it into `canonical_namespace_candidates` when it
416    /// has no definition. Splits out from `record` because the choice of fallback
417    /// itself depends on the same existence check `record` defers, so bounded and
418    /// rooted mode must each make that choice at the same point `record` does.
419    fn record_direct_or_namespace_fallback(&mut self, direct: String, node: Node<'_>) {
420        let (kind, start, end) = reference_span(node);
421        if self.targets.is_none() {
422            self.pending.push(PendingCallee::WithNamespaceFallback {
423                direct,
424                kind,
425                start,
426                end,
427            });
428            return;
429        }
430        self.record_namespace_fallback(direct, kind, start, end);
431    }
432
433    /// Record `direct`, or every fqn in `inherited` that has a definition when
434    /// `direct` does not. `inherited` is resolved eagerly (a local type-hierarchy
435    /// walk, not a store round trip) so only the existence checks are deferred.
436    fn record_direct_or_ancestor_fallback(
437        &mut self,
438        direct: String,
439        inherited: Vec<String>,
440        node: Node<'_>,
441    ) {
442        let (kind, start, end) = reference_span(node);
443        if self.targets.is_none() {
444            self.pending.push(PendingCallee::WithAncestorFallback {
445                direct,
446                inherited,
447                kind,
448                start,
449                end,
450            });
451            return;
452        }
453        self.record_ancestor_fallback(direct, inherited, kind, start, end);
454    }
455
456    /// `record`'s immediate (bounded-mode) decision, also `resolve_pending`'s
457    /// `Direct` resolution once its batch has warmed the cache. One place for
458    /// the accept/reject decision so the two callers cannot drift apart.
459    fn record_direct(
460        &mut self,
461        callee: String,
462        kind: UsageReferenceKind,
463        start: usize,
464        end: usize,
465    ) {
466        if self.accepts_target(&callee) {
467            self.edges.record_kind(self.input, callee, kind, start, end);
468        }
469    }
470
471    /// `record_direct_or_namespace_fallback`'s immediate decision, also
472    /// `resolve_pending`'s `WithNamespaceFallback` resolution.
473    fn record_namespace_fallback(
474        &mut self,
475        direct: String,
476        kind: UsageReferenceKind,
477        start: usize,
478        end: usize,
479    ) {
480        if self.accepts_target(&direct) {
481            self.edges.record_kind(self.input, direct, kind, start, end);
482            return;
483        }
484        // Each candidate needs its own `accepts_target` check, not just workspace
485        // membership: in bounded mode a namespace candidate can be a real declaration
486        // that simply isn't one of the caller's requested targets.
487        for resolved in self.canonical_namespace_candidates(&direct).iter() {
488            self.record_direct(resolved.clone(), kind, start, end);
489        }
490    }
491
492    /// `record_direct_or_ancestor_fallback`'s immediate decision, also
493    /// `resolve_pending`'s `WithAncestorFallback` resolution.
494    fn record_ancestor_fallback(
495        &mut self,
496        direct: String,
497        inherited: Vec<String>,
498        kind: UsageReferenceKind,
499        start: usize,
500        end: usize,
501    ) {
502        if self.accepts_target(&direct) {
503            self.edges.record_kind(self.input, direct, kind, start, end);
504            return;
505        }
506        for candidate in inherited {
507            if self.accepts_target(&candidate) {
508                self.edges
509                    .record_kind(self.input, candidate, kind, start, end);
510            }
511        }
512    }
513
514    /// Resolve every rooted-mode reference `record` and its siblings deferred,
515    /// in one batch instead of one live store round trip per reference. A no-op
516    /// in bounded mode, which never defers (see `record`).
517    fn resolve_pending(&mut self) {
518        if self.pending.is_empty() {
519            return;
520        }
521        let mut names = Vec::new();
522        for item in &self.pending {
523            match item {
524                PendingCallee::Direct { callee, .. } => names.push(callee.clone()),
525                PendingCallee::WithNamespaceFallback { direct, .. } => names.push(direct.clone()),
526                PendingCallee::WithAncestorFallback {
527                    direct, inherited, ..
528                } => {
529                    names.push(direct.clone());
530                    names.extend(inherited.iter().cloned());
531                }
532            }
533        }
534        self.graph.index.prefetch_definitions(&names);
535        for item in std::mem::take(&mut self.pending) {
536            match item {
537                PendingCallee::Direct {
538                    callee,
539                    kind,
540                    start,
541                    end,
542                } => self.record_direct(callee, kind, start, end),
543                PendingCallee::WithNamespaceFallback {
544                    direct,
545                    kind,
546                    start,
547                    end,
548                } => self.record_namespace_fallback(direct, kind, start, end),
549                PendingCallee::WithAncestorFallback {
550                    direct,
551                    inherited,
552                    kind,
553                    start,
554                    end,
555                } => self.record_ancestor_fallback(direct, inherited, kind, start, end),
556            }
557        }
558    }
559
560    fn record_unproven_name(&mut self, name: &str, node: Node<'_>) {
561        let Some(targets) = self.targets_by_terminal.get(name) else {
562            return;
563        };
564        for target in targets {
565            self.edges.record_unproven(
566                self.input,
567                target.clone(),
568                node.start_byte(),
569                node.end_byte(),
570            );
571        }
572    }
573
574    fn accepts_target(&self, fqn: &str) -> bool {
575        self.targets.map_or_else(
576            || self.graph.index.definitions(fqn).next().is_some(),
577            |targets| targets.contains(fqn),
578        )
579    }
580
581    fn may_have_target_terminal(&self, terminal: &str) -> bool {
582        self.targets.is_none() || self.targets_by_terminal.contains_key(terminal)
583    }
584
585    fn canonical_namespace_candidates(&self, direct: &str) -> Arc<Vec<String>> {
586        if let Some(cached) = self
587            .canonical_namespace_candidates
588            .lock()
589            .expect("Python namespace candidate cache mutex poisoned")
590            .get(direct)
591            .cloned()
592        {
593            return cached;
594        }
595
596        let resolved: Arc<Vec<String>> = Arc::new(
597            resolve_fqn_candidates(self.python, direct, |name| {
598                self.graph.index.definitions(name).collect()
599            })
600            .into_iter()
601            .map(|unit| unit.fq_name())
602            .collect(),
603        );
604        self.canonical_namespace_candidates
605            .lock()
606            .expect("Python namespace candidate cache mutex poisoned")
607            .entry(direct.to_string())
608            .or_insert_with(|| resolved.clone())
609            .clone()
610    }
611}
612
613fn scan_tree(root: Node<'_>, ctx: &mut PyScan<'_>) {
614    // A stack of in-scope local names, one frame per enclosing function. A name
615    // bound in any frame shadows a same-named import/declaration.
616    let mut scopes: Vec<FunctionScope> = Vec::new();
617    walk(root, ctx, &mut scopes, None);
618}
619
620fn walk(
621    node: Node<'_>,
622    ctx: &mut PyScan<'_>,
623    scopes: &mut Vec<FunctionScope>,
624    facts: Option<usize>,
625) {
626    let mut merged_facts = Vec::new();
627    let mut stack = vec![WalkFrame::Enter { node, facts }];
628    while let Some(frame) = stack.pop() {
629        match frame {
630            WalkFrame::Enter { node, facts } => match node.kind() {
631                "import_statement" | "import_from_statement" => {}
632                // A function (or lambda) opens a scope; its parameters and the names it
633                // assigns are local throughout it, so collect them up front. Resolve the
634                // scope's receiver-type facts once here and thread them down.
635                "function_definition" | "lambda" => {
636                    let function_scope = collect_function_scope(node, ctx.source);
637                    let scope_facts = merged_enclosing_scope_facts(
638                        ctx.graph,
639                        ctx.file,
640                        ctx.scope_facts,
641                        &mut merged_facts,
642                        node,
643                        ctx.source,
644                        facts,
645                    );
646                    push_function_children(node, facts, scope_facts, function_scope, &mut stack);
647                }
648                // A class body is not a function scope: code at the class-body level has
649                // no enclosing-function facts. Methods inside re-resolve their own facts.
650                "class_definition" => push_children(node, None, &mut stack),
651                "identifier" => {
652                    if !handle_annotation_reference(node, ctx) {
653                        handle_identifier(node, ctx, scopes);
654                    }
655                    push_children(node, facts, &mut stack);
656                }
657                "attribute" => {
658                    if handle_annotation_reference(node, ctx) {
659                        continue;
660                    }
661                    let scope_facts = facts.and_then(|id| merged_facts.get(id));
662                    handle_attribute(node, ctx, scopes, scope_facts);
663                    push_children(node, facts, &mut stack);
664                }
665                "string_content" => {
666                    handle_annotation_reference(node, ctx);
667                }
668                "keyword_argument" => {
669                    handle_keyword_argument(node, ctx, scopes);
670                    if let Some(value) = node.child_by_field_name("value") {
671                        stack.push(WalkFrame::Enter { node: value, facts });
672                    }
673                }
674                _ => push_children(node, facts, &mut stack),
675            },
676            WalkFrame::ExitScope => {
677                scopes.pop();
678            }
679            WalkFrame::EnterScope(scope) => scopes.push(scope),
680        }
681    }
682}
683
684enum WalkFrame<'tree> {
685    Enter {
686        node: Node<'tree>,
687        facts: Option<usize>,
688    },
689    EnterScope(FunctionScope),
690    ExitScope,
691}
692
693fn push_children<'tree>(
694    node: Node<'tree>,
695    facts: Option<usize>,
696    stack: &mut Vec<WalkFrame<'tree>>,
697) {
698    for index in (0..node.named_child_count()).rev() {
699        if let Some(child) = node.named_child(index) {
700            stack.push(WalkFrame::Enter { node: child, facts });
701        }
702    }
703}
704
705fn push_function_children<'tree>(
706    function: Node<'tree>,
707    enclosing_facts: Option<usize>,
708    body_facts: Option<usize>,
709    function_scope: FunctionScope,
710    stack: &mut Vec<WalkFrame<'tree>>,
711) {
712    let body = function.child_by_field_name("body");
713    let mut function_scope = Some(function_scope);
714    for index in (0..function.named_child_count()).rev() {
715        if let Some(child) = function.named_child(index) {
716            // Defaults and annotations are evaluated while defining the
717            // function, before its parameters and locals exist. Only the body
718            // executes in the new lexical scope.
719            let facts = if body == Some(child) {
720                body_facts
721            } else {
722                enclosing_facts
723            };
724            if body == Some(child) {
725                stack.push(WalkFrame::ExitScope);
726                stack.push(WalkFrame::Enter { node: child, facts });
727                stack.push(WalkFrame::EnterScope(
728                    function_scope
729                        .take()
730                        .expect("a function has exactly one body scope"),
731                ));
732            } else {
733                stack.push(WalkFrame::Enter { node: child, facts });
734            }
735        }
736    }
737}
738
739fn merged_enclosing_scope_facts(
740    graph: &PythonGraphSource<'_>,
741    file: &ProjectFile,
742    scope_facts: &HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
743    merged_facts: &mut Vec<LocalBindingsSnapshot<String>>,
744    node: Node<'_>,
745    source: &str,
746    inherited: Option<usize>,
747) -> Option<usize> {
748    let structural_local = collect_function_scope_facts_from_node(node, source);
749    // A top-level function or class method has a complete indexed snapshot,
750    // including factory-return facts that the node-only structural pass cannot
751    // reconstruct. Nested functions and lambdas instead need their structural
752    // declarations to shadow the inherited outer snapshot.
753    let local = if inherited.is_none() && node.kind() == "function_definition" {
754        enclosing_scope_facts(graph.index, file, scope_facts, node)
755            .cloned()
756            .unwrap_or(structural_local)
757    } else {
758        structural_local
759    };
760    match (local, inherited) {
761        (local, Some(inherited_id)) => {
762            let inherited = merged_facts.get(inherited_id)?;
763            let merged = inherited.merged_with_shadowing(&local);
764            let next_id = merged_facts.len();
765            merged_facts.push(merged);
766            Some(next_id)
767        }
768        (local, None) => {
769            let next_id = merged_facts.len();
770            merged_facts.push(local);
771            Some(next_id)
772        }
773    }
774}
775
776#[derive(Default)]
777struct FunctionScope {
778    locals: HashSet<String>,
779    parameters: HashSet<String>,
780    globals: HashSet<String>,
781}
782
783fn is_shadowed(scopes: &[FunctionScope], name: &str) -> bool {
784    for scope in scopes.iter().rev() {
785        if scope.globals.contains(name) {
786            return false;
787        }
788        if scope.locals.contains(name) {
789            return true;
790        }
791    }
792    false
793}
794
795fn is_receiver_parameter(scopes: &[FunctionScope], name: &str) -> bool {
796    scopes
797        .iter()
798        .rev()
799        .any(|scope| scope.parameters.contains(name))
800}
801
802fn handle_identifier(node: Node<'_>, ctx: &mut PyScan<'_>, scopes: &[FunctionScope]) {
803    // The object of an `attribute` is handled by handle_attribute.
804    if node
805        .parent()
806        .is_some_and(|parent| parent.kind() == "attribute")
807    {
808        return;
809    }
810    if is_declaration_identifier(node) {
811        return;
812    }
813    let text = slice(node, ctx.source);
814    if text.is_empty()
815        || is_shadowed(scopes, text)
816        || python_comprehension_binds_name_at(text, node, ctx.source)
817        || python_type_parameter_binds_name_at(text, node, ctx.source)
818    {
819        return;
820    }
821    if let Some(callee) = ctx.bare_callee(text, node) {
822        ctx.record(callee, node);
823    }
824}
825
826fn handle_annotation_reference(node: Node<'_>, ctx: &mut PyScan<'_>) -> bool {
827    let Some(candidates) =
828        annotation_reference_candidates(ctx.graph, ctx.python, ctx.file, ctx.source, node, false)
829    else {
830        return false;
831    };
832    let [candidate] = candidates.as_slice() else {
833        return !(node.kind() == "attribute" && candidates.is_empty());
834    };
835
836    let site = if node.kind() == "attribute" {
837        node.child_by_field_name("attribute").unwrap_or(node)
838    } else {
839        node
840    };
841    ctx.record(candidate.fq_name(), site);
842    true
843}
844
845fn handle_attribute(
846    node: Node<'_>,
847    ctx: &mut PyScan<'_>,
848    scopes: &[FunctionScope],
849    facts: Option<&LocalBindingsSnapshot<String>>,
850) {
851    let (Some(object), Some(attribute)) = (
852        node.child_by_field_name("object"),
853        node.child_by_field_name("attribute"),
854    ) else {
855        return;
856    };
857    let object_text = slice(object, ctx.source);
858    let attribute_text = slice(attribute, ctx.source);
859    if object_text.is_empty() || attribute_text.is_empty() {
860        return;
861    }
862    if object.kind() == "identifier"
863        && ctx.may_have_target_terminal(attribute_text)
864        && let Some(module) =
865            imported_module_assignment_at(node, object_text, ctx.source, |local| {
866                !is_shadowed(scopes, local)
867                    && ctx.namespace.get(local).is_some_and(|binding| {
868                        binding.module == "importlib" && binding.consumed_attributes == 0
869                    })
870            })
871    {
872        let direct = format!("{module}.{attribute_text}");
873        ctx.record_direct_or_namespace_fallback(direct, attribute);
874    }
875    if object.kind() == "call" && ctx.may_have_target_terminal(attribute_text) {
876        for class in call_result_types(ctx.graph, ctx.python, ctx.file, ctx.source, object, facts) {
877            let direct = format!("{}.{attribute_text}", class.fq_name());
878            let inherited = ctx.graph.hierarchy.map_or_else(Vec::new, |provider| {
879                provider
880                    .get_ancestors(&class)
881                    .into_iter()
882                    .map(|ancestor| format!("{}.{attribute_text}", ancestor.fq_name()))
883                    .collect()
884            });
885            ctx.record_direct_or_ancestor_fallback(direct, inherited, attribute);
886        }
887    }
888    // `module.symbol` or a deeper `module.ns.symbol` chain rooted at a
889    // namespace import. Walk the attribute structure from the leftmost imported
890    // root so deep chains stay exact without source-text splitting.
891    if let Some((root, attributes)) = attribute_chain(node) {
892        let root_text = slice(root, ctx.source);
893        if !root_text.is_empty()
894            && !is_shadowed(scopes, root_text)
895            && let Some(binding) = ctx.namespace.get(root_text)
896        {
897            let mut direct = binding.module.clone();
898            let workspace_module = binding.workspace_module;
899            let consumed_attributes = binding.consumed_attributes;
900            if object.kind() == "identifier" {
901                ctx.record(direct.clone(), object);
902            }
903            for member in attributes.into_iter().skip(consumed_attributes) {
904                let member_text = slice(member, ctx.source);
905                if member_text.is_empty() {
906                    return;
907                }
908                direct.push('.');
909                direct.push_str(member_text);
910            }
911            // A re-export alias can change the terminal name (`proto.module` may
912            // canonically resolve to `proto.modules.define_module`), so terminal-name
913            // filtering is not sound here. Namespace imports are already a narrow,
914            // structured subset of attributes; resolve their workspace candidates
915            // and let `record` retain only requested targets.
916            if workspace_module {
917                ctx.record_direct_or_namespace_fallback(direct, attribute);
918            } else {
919                ctx.record(direct, attribute);
920            }
921            return;
922        }
923    }
924
925    // `recv.method` where recv is a typed local/parameter: resolve to the
926    // receiver's class fqn. Unknown or ambiguous receiver facts are not enough
927    // for a proven edge, but they are structured evidence that a same-named
928    // member may be reachable, so bulk dead-code treats the candidate as
929    // inconclusive instead of dead.
930    if let Some(facts) = facts
931        && ctx.may_have_target_terminal(attribute_text)
932    {
933        if matches!(object_text, "self" | "cls") {
934            // `self.member` / `cls.member` is a same-owner reference (#1138):
935            // record it as unproven inbound rather than a proven edge, so a
936            // member reachable only through same-owner access reads
937            // INCONCLUSIVE, never confidently dead — matching the other
938            // languages.
939            ctx.record_unproven_name(attribute_text, attribute);
940        } else if let Some(type_fqn) = ctx.receiver_type_fqn(facts, object_text) {
941            ctx.record(format!("{type_fqn}.{attribute_text}"), attribute);
942        } else if object.kind() == "identifier" && !ctx.named.contains_key(object_text) {
943            let resolution = facts.resolution_for(object_text);
944            if resolution.is_ambiguous()
945                || (resolution.is_unknown() && is_receiver_parameter(scopes, object_text))
946            {
947                ctx.record_unproven_name(attribute_text, attribute);
948            }
949        }
950    }
951}
952
953fn handle_keyword_argument(node: Node<'_>, ctx: &mut PyScan<'_>, scopes: &[FunctionScope]) {
954    let (Some(name), Some(arguments)) = (node.child_by_field_name("name"), node.parent()) else {
955        return;
956    };
957    if name.kind() != "identifier" || arguments.kind() != "argument_list" {
958        return;
959    }
960    let Some(call) = arguments.parent().filter(|parent| parent.kind() == "call") else {
961        return;
962    };
963    let Some(function) = call.child_by_field_name("function") else {
964        return;
965    };
966    let member = slice(name, ctx.source);
967    if member.is_empty() || !ctx.may_have_target_terminal(member) {
968        return;
969    }
970    let scoped_class_fqn = if function.kind() == "identifier" {
971        enclosing_scope_facts(ctx.graph.index, ctx.file, ctx.scope_facts, function)
972            .and_then(|facts| ctx.receiver_type_fqn(facts, slice(function, ctx.source)))
973    } else {
974        None
975    };
976    let function_name = (function.kind() == "identifier").then(|| slice(function, ctx.source));
977    let mut default_classes = function_name.map_or_else(Vec::new, |local_name| {
978        resolve_callable_parameter_default_types(
979            ctx.graph, ctx.python, ctx.file, ctx.source, function, local_name,
980        )
981    });
982    let root_shadowed = leftmost_identifier(function)
983        .is_some_and(|root| is_shadowed(scopes, slice(root, ctx.source)));
984    if !root_shadowed && let Some(local_name) = function_name {
985        let cutoff = if reference_is_deferred_function_body(function) {
986            usize::MAX
987        } else {
988            function.start_byte()
989        };
990        default_classes.extend(resolve_visible_named_import_candidates(
991            ctx.graph,
992            ctx.python,
993            ctx.file,
994            ctx.module_bindings.as_ref(),
995            local_name,
996            cutoff,
997        ));
998    }
999    let mut classes = if function_name == Some("cls") {
1000        lexical_class(ctx, function).into_iter().collect()
1001    } else {
1002        if root_shadowed && scoped_class_fqn.is_none() && default_classes.is_empty() {
1003            return;
1004        }
1005        if !root_shadowed {
1006            default_classes.extend(resolve_constructor_types(
1007                ctx.graph, ctx.python, ctx.file, ctx.source, function,
1008            ));
1009        }
1010        default_classes
1011    };
1012    if let Some(fqn) = scoped_class_fqn {
1013        classes.extend(ctx.graph.index.definitions(&fqn).filter(CodeUnit::is_class));
1014        classes.sort();
1015        classes.dedup();
1016    }
1017    for class in classes {
1018        for declaration in resolved_member_declarations(ctx.graph, &class, member) {
1019            ctx.record(declaration.fq_name(), name);
1020        }
1021    }
1022}
1023
1024fn lexical_class(ctx: &PyScan<'_>, node: Node<'_>) -> Option<CodeUnit> {
1025    let range = Range {
1026        start_byte: node.start_byte(),
1027        end_byte: node.end_byte(),
1028        start_line: 0,
1029        end_line: 0,
1030    };
1031    let enclosing = ctx.graph.index.enclosing_code_unit(ctx.file, &range)?;
1032    if enclosing.is_class() {
1033        Some(enclosing)
1034    } else {
1035        ctx.graph
1036            .index
1037            .parent_of(&enclosing)
1038            .filter(CodeUnit::is_class)
1039    }
1040}
1041
1042fn leftmost_identifier(mut node: Node<'_>) -> Option<Node<'_>> {
1043    loop {
1044        match node.kind() {
1045            "identifier" => return Some(node),
1046            "attribute" => node = node.child_by_field_name("object")?,
1047            _ => return None,
1048        }
1049    }
1050}
1051
1052fn attribute_chain<'a>(node: Node<'a>) -> Option<(Node<'a>, Vec<Node<'a>>)> {
1053    let mut attributes = Vec::new();
1054    let mut current = node;
1055    loop {
1056        if current.kind() != "attribute" {
1057            return None;
1058        }
1059        attributes.push(current.child_by_field_name("attribute")?);
1060        current = current.child_by_field_name("object")?;
1061        if current.kind() == "identifier" {
1062            attributes.reverse();
1063            return Some((current, attributes));
1064        }
1065    }
1066}
1067
1068/// The local names a function binds: its parameters plus every name it assigns.
1069/// Python scoping is function-wide, so a name assigned anywhere in the body is
1070/// local throughout; nested function/class scopes are skipped (they get their
1071/// own frame), but the names they bind in *this* scope are kept.
1072fn collect_function_scope(func: Node<'_>, source: &str) -> FunctionScope {
1073    let mut scope = FunctionScope::default();
1074    if let Some(params) = func.child_by_field_name("parameters") {
1075        collect_parameter_names(params, source, &mut scope.parameters);
1076        scope.locals.extend(scope.parameters.iter().cloned());
1077    }
1078    if let Some(body) = func.child_by_field_name("body") {
1079        collect_bound_targets(body, source, &mut scope.locals);
1080        collect_scope_globals(body, source, &mut scope.globals);
1081        scope.locals.retain(|name| !scope.globals.contains(name));
1082    }
1083    scope
1084}
1085
1086fn collect_scope_globals(node: Node<'_>, source: &str, out: &mut HashSet<String>) {
1087    let root = node;
1088    let mut stack = vec![node];
1089    while let Some(node) = stack.pop() {
1090        if node.kind() == "global_statement" {
1091            let mut cursor = node.walk();
1092            for child in node.named_children(&mut cursor) {
1093                if child.kind() == "identifier" {
1094                    let name = slice(child, source).trim();
1095                    if !name.is_empty() {
1096                        out.insert(name.to_string());
1097                    }
1098                }
1099            }
1100            continue;
1101        }
1102        if node != root
1103            && matches!(
1104                node.kind(),
1105                "function_definition" | "lambda" | "class_definition"
1106            )
1107        {
1108            continue;
1109        }
1110        let mut cursor = node.walk();
1111        let mut children: Vec<_> = node.named_children(&mut cursor).collect();
1112        children.reverse();
1113        stack.extend(children);
1114    }
1115}
1116
1117fn collect_parameter_names(params: Node<'_>, source: &str, out: &mut HashSet<String>) {
1118    let mut cursor = params.walk();
1119    for child in params.named_children(&mut cursor) {
1120        let name = match child.kind() {
1121            "identifier" => Some(child),
1122            // typed / default / splat parameters carry the binding either in a
1123            // `name` field or as their first identifier child.
1124            _ => child
1125                .child_by_field_name("name")
1126                .or_else(|| child.named_child(0).filter(|n| n.kind() == "identifier")),
1127        };
1128        if let Some(name) = name {
1129            let text = slice(name, source).trim();
1130            if !text.is_empty() {
1131                out.insert(text.to_string());
1132            }
1133        }
1134    }
1135}
1136
1137/// Collect names bound by assignment within a scope, without descending into
1138/// nested function/class scopes (only the nested definition's own name is bound
1139/// here).
1140fn collect_bound_targets(node: Node<'_>, source: &str, out: &mut HashSet<String>) {
1141    let mut stack = vec![node];
1142    while let Some(node) = stack.pop() {
1143        match node.kind() {
1144            "function_definition" | "class_definition" => {
1145                if let Some(name) = node.child_by_field_name("name") {
1146                    let text = slice(name, source).trim();
1147                    if !text.is_empty() {
1148                        out.insert(text.to_string());
1149                    }
1150                }
1151                continue;
1152            }
1153            "lambda" => continue,
1154            "assignment" | "augmented_assignment" | "for_statement" | "for_in_clause" => {
1155                if let Some(left) = node.child_by_field_name("left") {
1156                    collect_assigned_identifiers(left, source, out);
1157                }
1158            }
1159            "named_expression" => {
1160                if let Some(name) = node.child_by_field_name("name") {
1161                    collect_assigned_identifiers(name, source, out);
1162                }
1163            }
1164            _ => {}
1165        }
1166        let mut cursor = node.walk();
1167        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
1168        children.reverse();
1169        stack.extend(children);
1170    }
1171}