Skip to main content

brokk_bifrost_python/graph/
inverted.rs

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