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