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