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