Skip to main content

brokk_bifrost_python/
declarations.rs

1use crate::bindings::python_direct_scope_bindings_bounded;
2use crate::imports::{python_import_infos_from_node, python_import_syntaxes_from_node};
3use crate::syntax::{
4    PythonOverloadDecoratorBindings, PythonOverloadDecoratorName, expression_name_node,
5    python_parameter_name, python_plain_string_literal,
6};
7use brokk_bifrost_core::analyzer::fq_name::{FqName, SegmentKind, segment_interner};
8use brokk_bifrost_core::analyzer::model::{
9    CallableArity, CodeUnitType, DispatchExtensibility, ParameterMetadata, SignatureMetadata,
10    StructuredImportPathKind, StructuredTypeIdentity,
11};
12use brokk_bifrost_core::analyzer::parsed_file::{
13    ParsedFile, ParsedSourceFacts, SourceDeclarationMetadataLink, SourceImportFact,
14};
15use brokk_bifrost_core::analyzer::project::Project;
16use brokk_bifrost_core::analyzer::python_facts::{PythonCallableReturnFact, PythonSourceFacts};
17use brokk_bifrost_core::analyzer::resolution_facts::ResolutionSiteId;
18use brokk_bifrost_core::analyzer::rust_facts::RustItemSourceFacts;
19use brokk_bifrost_core::analyzer::source_facts::{
20    PrimarySourceFactCollector, SourceDeclarationId, SourceImportId, SourceOccurrenceId,
21};
22use brokk_bifrost_core::analyzer::structural::callable::CallSiteContext;
23use brokk_bifrost_core::analyzer::structural::collector::StructuralFactCollector;
24use brokk_bifrost_core::analyzer::structural::resolution::DeclaredVisibility;
25use brokk_bifrost_core::analyzer::structural::spec::{CompiledKinds, StructuralSpec};
26use brokk_bifrost_core::analyzer::tree_walk::{ParentIndex, WalkControl, walk_named_tree_preorder};
27use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
28use brokk_bifrost_core::hash::{HashMap, HashSet};
29use brokk_bifrost_core::path_normalization::NormalizePath;
30use brokk_bifrost_core::text_utils::{compute_line_starts, find_line_index_for_offset};
31use std::borrow::Cow;
32use std::path::{Path, PathBuf};
33use tree_sitter::{Node, Parser, Tree};
34
35use crate::source_properties::{python_annotation_references, python_return_type_identity};
36use crate::structural::{PYTHON_STRUCTURAL_SPEC, python_parenthesized_expression_child};
37
38/// Build the structured module-path prefix for a Python declaration.
39///
40/// Ordinary modules render as a dotted path such as `mypkg.subpkg.mymodule`,
41/// with each original path component represented by one
42/// [`SegmentKind::Package`] segment. Hidden directories such as `.agent` and
43/// `.github` are also legal components in the analyzer's path-derived Python
44/// convention, but their leading dot is ambiguous after a rendered name has
45/// been joined.
46///
47/// Build the structured name from the file path's original components so
48/// hidden-directory segments stay intact in cold extraction, synthesized module
49/// units, and persisted reconstruction.
50pub fn python_module_fq(project: &dyn Project, file: &ProjectFile) -> FqName {
51    FqName::from_package_components(&python_module_components(project, file))
52}
53
54fn python_module_components(project: &dyn Project, file: &ProjectFile) -> Vec<String> {
55    python_module_components_from_root(file, &python_import_root(project, file))
56}
57
58/// Restore a published module identity without probing packaging inputs.
59/// Match whole path components so a hidden directory's leading dot stays inside
60/// its segment. The stored rendered name is a value, not source syntax.
61pub fn python_module_components_from_name(file: &ProjectFile, name: &str) -> Option<Vec<String>> {
62    // A package initializer drops its file stem unless its selected import
63    // root is its own directory. In that case the module is named __init__.
64    // Reuse the producer's rule at each possible root instead of taking only
65    // suffixes of the project-root name, which has already dropped that stem.
66    let roots = file.rel_path().parent()?.ancestors().collect::<Vec<_>>();
67    roots.into_iter().rev().find_map(|root| {
68        let components = python_module_components_from_root(file, root);
69        (components.join(".") == name).then_some(components)
70    })
71}
72
73/// The module-name components `file`'s path yields when it is named from the
74/// project root, which is the longest name the workspace can give it:
75/// `dependency/core/lib/common/__init__.py` is
76/// `["dependency", "core", "lib", "common"]`.
77///
78/// A Python module name is relative to the `sys.path` entry an import of it
79/// resolves against, so one file has one valid absolute name per ancestor
80/// directory on that path. Each is a suffix of this one, except an initializer
81/// directly at the import root, which is named `__init__`.
82/// [`python_import_root`] picks the single name that becomes the module's
83/// identity; this is what an import written against any deeper root has to be
84/// matched against (#3506).
85pub fn python_project_module_components(file: &ProjectFile) -> Vec<String> {
86    python_module_components_from_root(file, Path::new(""))
87}
88
89/// `file`'s module-name components relative to `import_root_rel`, which must be
90/// a prefix of the file's own relative path.
91fn python_module_components_from_root(file: &ProjectFile, import_root_rel: &Path) -> Vec<String> {
92    let mut components = python_package_components_for_file(file, import_root_rel);
93    let module_name = file
94        .rel_path()
95        .file_stem()
96        .and_then(|stem| stem.to_str())
97        .unwrap_or_default();
98    if module_name != "__init__" || components.is_empty() {
99        components.push(module_name.to_string());
100    }
101    components
102}
103
104/// Structured, selected inputs to the shared Python packaging-root algorithm.
105pub trait PythonImportRootInputs {
106    fn contains(&self, relative_path: &Path) -> bool;
107    fn configuration_source(&self, relative_path: &Path) -> std::io::Result<Option<Cow<'_, str>>>;
108}
109
110struct ProjectImportRootInputs<'a>(&'a dyn Project);
111impl PythonImportRootInputs for ProjectImportRootInputs<'_> {
112    fn contains(&self, relative_path: &Path) -> bool {
113        self.0.file_by_rel_path(relative_path).is_some()
114    }
115    fn configuration_source(&self, relative_path: &Path) -> std::io::Result<Option<Cow<'_, str>>> {
116        Ok(self
117            .0
118            .file_by_rel_path(relative_path)
119            .and_then(|file| self.0.read_source(&file).ok())
120            .map(Cow::Owned))
121    }
122}
123
124/// The directory `file`'s path-derived Python module name is relative to: the
125/// analyzer's stand-in for the `sys.path` entry an absolute import of that
126/// module would be resolved against.
127///
128/// It is the nearest configured setuptools import root, else the parent of the
129/// outermost `__init__.py` package chain above the file, else the project root.
130/// The result is always a prefix of the file's own relative path, and it is
131/// what makes two same-named modules in one snapshot distinguishable:
132/// `registry/purview-registry/registry/models.py` and
133/// `registry/sql-registry/registry/models.py` are both the module
134/// `registry.models`, but they belong to different roots (#3475).
135pub fn python_import_root(project: &dyn Project, file: &ProjectFile) -> PathBuf {
136    python_import_root_with_inputs(
137        file.root(),
138        file.rel_path(),
139        &ProjectImportRootInputs(project),
140    )
141    .expect("raw project import-root inputs are infallible")
142}
143
144/// Select a root using configuration bytes and membership from one snapshot.
145pub fn python_import_root_with_inputs(
146    root: &Path,
147    relative_path: &Path,
148    inputs: &impl PythonImportRootInputs,
149) -> std::io::Result<PathBuf> {
150    let Some(parent_rel) = relative_path.parent() else {
151        return Ok(PathBuf::new());
152    };
153    if parent_rel.as_os_str().is_empty() {
154        return Ok(PathBuf::new());
155    }
156    if let Some(import_root_rel) = python_configured_import_root(root, parent_rel, inputs)? {
157        return Ok(import_root_rel);
158    }
159    let mut effective_package_root_rel: Option<&Path> = None;
160    for path in parent_rel.ancestors() {
161        if inputs.contains(&path.join("__init__.py")) {
162            effective_package_root_rel = Some(path);
163        }
164    }
165    Ok(effective_package_root_rel
166        .and_then(Path::parent)
167        .unwrap_or_else(|| Path::new(""))
168        .to_path_buf())
169}
170
171fn python_package_components_for_file(file: &ProjectFile, import_root_rel: &Path) -> Vec<String> {
172    let Some(parent_rel) = file.rel_path().parent() else {
173        return Vec::new();
174    };
175    let relative_package = parent_rel
176        .strip_prefix(import_root_rel)
177        .expect("a Python import root is a prefix of the paths it names modules for");
178    path_components(relative_package)
179}
180
181/// Packaging inputs retained by the analyzer and selected by environment staging.
182pub const PYTHON_CONFIGURATION_INPUT_NAMES: [&str; 3] =
183    ["pyproject.toml", "setup.py", "__init__.py"];
184
185/// Find the nearest setuptools import root that contains this source file.
186///
187/// `pyproject.toml` roots take precedence over legacy `setup.py` evidence at
188/// each ancestor. An unrelated or malformed packaging file does not change the
189/// existing `__init__.py` package-root convention.
190fn python_configured_import_root(
191    workspace_root: &Path,
192    parent_rel: &Path,
193    inputs: &impl PythonImportRootInputs,
194) -> std::io::Result<Option<PathBuf>> {
195    let mut manifest_dir_rel = Some(parent_rel);
196    while let Some(directory) = manifest_dir_rel {
197        let manifest_dir = workspace_root.join(directory);
198        let mut roots = inputs
199            .configuration_source(&directory.join("pyproject.toml"))?
200            .map(|source| parse_setuptools_where_entries(&source))
201            .unwrap_or_default()
202            .iter()
203            .map(|entry| manifest_dir.join(entry).normalize())
204            .filter_map(|root| {
205                root.strip_prefix(workspace_root)
206                    .ok()
207                    .map(Path::to_path_buf)
208            })
209            .filter(|root| parent_rel.starts_with(root))
210            .collect::<Vec<_>>();
211        roots.sort_by_key(|root| root.components().count());
212        if let Some(root) = roots.pop() {
213            return Ok(Some(root));
214        }
215        if let Some(package_dir) = inputs
216            .configuration_source(&directory.join("setup.py"))?
217            .and_then(|source| parse_setuptools_setup_py_import_root(&source))
218        {
219            let root = manifest_dir.join(package_dir).normalize();
220            if let Ok(root) = root.strip_prefix(workspace_root)
221                && parent_rel.starts_with(root)
222            {
223                return Ok(Some(root.to_path_buf()));
224            }
225        }
226        manifest_dir_rel = directory.parent();
227    }
228    Ok(None)
229}
230
231/// The root a packaging manifest declares for this file, whether that root is
232/// the project root itself or a directory below it.
233///
234/// `pyproject.toml`'s `tool.setuptools.packages.find.where` and `setup.py`'s
235/// `package_dir` state where a project's packages begin: a file below one of
236/// them is named from that directory and from nowhere else. `where = ["."]`
237/// declares the project root, and a declaration of the project root is a
238/// declaration like any other: it says this project's packages begin here, so a
239/// shorter spelling below that root names a different arrangement of the same
240/// directories. Only a project whose packaging configuration does not cover
241/// this file answers `None` (#3506).
242pub fn python_declared_import_root(project: &dyn Project, file: &ProjectFile) -> Option<PathBuf> {
243    let parent_rel = file.rel_path().parent()?;
244    if parent_rel.as_os_str().is_empty() {
245        return None;
246    }
247    python_configured_import_root(file.root(), parent_rel, &ProjectImportRootInputs(project))
248        .expect("raw project import-root inputs are infallible")
249}
250
251/// The directory the package a repeated spelling starts from has to add to its
252/// own `__path__` for `source_root_rel` to be a root the specifier is written
253/// against, or `None` when the specifier does not spell that root's own path
254/// again at its head (#3506).
255///
256/// A packaging declaration states where a project's packages begin, so a
257/// shorter spelling below it normally names a different arrangement of the same
258/// directories rather than the module: `otherpkg/vendor/api.py` under
259/// `where = ["."]` is `otherpkg.vendor.api`, and `vendor.api`, whose implied
260/// root is `otherpkg`, is not another name for it. The double-nested
261/// distribution directory is the one shape a declaration leaves open, because
262/// the specifier writes the implied root's own path again before the module's
263/// path: from `rtdetr_pose/`, `rtdetr_pose.config` names `config.py` in the
264/// same-named package that directory holds, the file the workspace indexes as
265/// `rtdetr_pose.rtdetr_pose.config`.
266///
267/// The shape is a naming question only, and it is not evidence that the import
268/// works. The interpreter finds the module through the package the specifier
269/// starts from -- `rtdetr_pose/__init__.py` -- and only that package's own
270/// statement that `rtdetr_pose/rtdetr_pose` is on its search path makes the
271/// shorter spelling name the file. The repeated directory name and the empty
272/// markers that make the directories packages state nothing on their own, so
273/// this returns the directory such a statement has to name, relative to the
274/// project root, and the snapshot decides whether the marker states it.
275pub(crate) fn python_repeated_root_extension_dir(
276    file: &ProjectFile,
277    source_root_rel: &Path,
278    declared_root_rel: &Path,
279) -> Option<PathBuf> {
280    let implied_rel = source_root_rel.strip_prefix(declared_root_rel).ok()?;
281    let implied = path_components(implied_rel);
282    if implied.is_empty()
283        || !python_module_components_from_root(file, source_root_rel).starts_with(&implied)
284    {
285        return None;
286    }
287    Some(source_root_rel.join(implied_rel))
288}
289
290/// Interpret a legacy setuptools root statically, without executing setup.py.
291/// A direct top-level call to an imported setuptools or distutils.core setup
292/// binding must supply packages. A literal package_dir establishes the root;
293/// otherwise packages must be a nonempty literal collection or supported
294/// discovery call. Unsupported shapes and shadowed imports declare no root.
295fn parse_setuptools_setup_py_import_root(source: &str) -> Option<PathBuf> {
296    let tree = parse_python_tree(source)?;
297    let root = tree.root_node();
298    if root.has_error() {
299        return None;
300    }
301    let mut import_root = None;
302    for (call, setup_bindings) in setup_py_setup_calls(source, root) {
303        let candidate = setup_py_import_root_from_call(call, source, &setup_bindings)?;
304        if import_root
305            .replace(candidate.clone())
306            .is_some_and(|root| root != candidate)
307        {
308            return None;
309        }
310    }
311    import_root
312}
313
314/// Read the `python_requires` specifier a legacy `setup.py` declares.
315///
316/// This is setuptools' spelling of `pyproject.toml`'s `requires-python`, and it
317/// is what selects the standard-library semantic pack for a project that has no
318/// PEP 621 manifest. The script is read, never executed: only a literal string
319/// passed to a top-level call to an imported `setup` binding counts, and two
320/// calls that disagree declare nothing.
321pub fn setuptools_setup_py_python_requires(setup_py: &Path) -> Option<String> {
322    let source = std::fs::read_to_string(setup_py).ok()?;
323    setuptools_setup_py_python_requires_from_source(&source)
324}
325
326/// Snapshot-fed counterpart of the legacy declaration reader.
327pub fn setuptools_setup_py_python_requires_from_source(source: &str) -> Option<String> {
328    let tree = parse_python_tree(source)?;
329    let root = tree.root_node();
330    if root.has_error() {
331        return None;
332    }
333    let mut requirement: Option<String> = None;
334    for (call, _) in setup_py_setup_calls(source, root) {
335        let Some(arguments) = call.child_by_field_name("arguments") else {
336            continue;
337        };
338        if arguments.kind() != "argument_list" {
339            continue;
340        }
341        let mut cursor = arguments.walk();
342        for argument in arguments.named_children(&mut cursor) {
343            if argument.kind() != "keyword_argument" {
344                continue;
345            }
346            let Some(name) = argument.child_by_field_name("name") else {
347                continue;
348            };
349            if py_node_text(name, source).trim() != "python_requires" {
350                continue;
351            }
352            let value = argument.child_by_field_name("value")?;
353            let declared = python_plain_string_literal(value, source)?.to_owned();
354            if requirement
355                .replace(declared.clone())
356                .is_some_and(|previous| previous != declared)
357            {
358                return None;
359            }
360        }
361    }
362    requirement
363}
364
365/// One supported setuptools keyword, with its original AST range. Unknown
366/// expressions remain `None`; this function never executes the script.
367#[derive(Debug, Clone)]
368pub struct SetuptoolsLiteralArgument {
369    pub name: String,
370    pub value: Option<SetuptoolsLiteralValue>,
371    pub byte_range: std::ops::Range<usize>,
372    pub call_index: usize,
373}
374
375#[derive(Debug, Clone)]
376pub enum SetuptoolsLiteralValue {
377    String(String),
378    Strings(Vec<String>),
379    Extras(Vec<(String, Vec<String>)>),
380}
381
382pub fn setuptools_setup_py_literal_arguments(
383    source: &str,
384) -> Option<Vec<SetuptoolsLiteralArgument>> {
385    let tree = parse_python_tree(source)?;
386    let root = tree.root_node();
387    if root.has_error() {
388        return None;
389    }
390    let mut output = Vec::new();
391    for (call_index, (call, _)) in setup_py_setup_calls(source, root).into_iter().enumerate() {
392        let arguments = call.child_by_field_name("arguments")?;
393        let mut cursor = arguments.walk();
394        for argument in arguments.named_children(&mut cursor) {
395            if argument.kind() == "dictionary_splat" {
396                output.push(SetuptoolsLiteralArgument {
397                    name: "**kwargs".to_owned(),
398                    value: None,
399                    byte_range: argument.byte_range(),
400                    call_index,
401                });
402                continue;
403            }
404            if argument.kind() != "keyword_argument" {
405                continue;
406            }
407            let name = py_node_text(argument.child_by_field_name("name")?, source).to_owned();
408            let node = argument.child_by_field_name("value")?;
409            let value = match name.as_str() {
410                "name" | "version" | "python_requires" => python_plain_string_literal(node, source)
411                    .map(|value| SetuptoolsLiteralValue::String(value.to_owned())),
412                "install_requires" | "setup_requires" | "tests_require" => {
413                    setuptools_literal_strings(node, source).map(SetuptoolsLiteralValue::Strings)
414                }
415                "extras_require" => {
416                    let mut entries = Vec::new();
417                    let mut cursor = node.walk();
418                    let mut complete = node.kind() == "dictionary";
419                    for pair in node.named_children(&mut cursor) {
420                        let entry = pair
421                            .child_by_field_name("key")
422                            .and_then(|key| python_plain_string_literal(key, source))
423                            .zip(
424                                pair.child_by_field_name("value")
425                                    .and_then(|value| setuptools_literal_strings(value, source)),
426                            );
427                        if let Some((key, values)) = entry {
428                            entries.push((key.to_owned(), values));
429                        } else {
430                            complete = false;
431                        }
432                    }
433                    complete.then_some(SetuptoolsLiteralValue::Extras(entries))
434                }
435                _ => continue,
436            };
437            output.push(SetuptoolsLiteralArgument {
438                name,
439                value,
440                byte_range: node.byte_range(),
441                call_index,
442            });
443        }
444    }
445    Some(output)
446}
447
448fn setuptools_literal_strings(node: Node<'_>, source: &str) -> Option<Vec<String>> {
449    if !matches!(node.kind(), "list" | "tuple") {
450        return None;
451    }
452    let mut cursor = node.walk();
453    node.named_children(&mut cursor)
454        .filter(|child| child.kind() != "comment")
455        .map(|child| python_plain_string_literal(child, source).map(str::to_owned))
456        .collect()
457}
458
459/// Every top-level call to an imported setuptools `setup` binding, paired with
460/// the import bindings that were live where the call appears.
461///
462/// The walk tracks bindings across the module's top-level statements, so a name
463/// a later statement rebinds stops being read as setuptools' `setup`. It does
464/// not enter function, class, or lambda bodies: a call there is conditional on
465/// something this reader does not evaluate.
466fn setup_py_setup_calls<'tree>(
467    source: &str,
468    root: Node<'tree>,
469) -> Vec<(Node<'tree>, HashMap<Vec<String>, String>)> {
470    let mut setup_bindings: HashMap<Vec<String>, String> = HashMap::default();
471    let mut calls = Vec::new();
472    let mut cursor = root.walk();
473
474    for statement in root.named_children(&mut cursor) {
475        if matches!(
476            statement.kind(),
477            "import_statement" | "import_from_statement"
478        ) {
479            for binding in setup_py_bound_names(statement, source) {
480                setup_bindings.retain(|path, _| path.first() != Some(&binding));
481            }
482            for import in python_import_infos_from_node(statement, source) {
483                if import.is_wildcard {
484                    setup_bindings.clear();
485                    continue;
486                }
487                let Some(path) = import.path else { continue };
488                let segments = path.segments.iter().map(String::as_str).collect::<Vec<_>>();
489                match path.kind {
490                    Some(StructuredImportPathKind::ImportFrom) => {
491                        let function_name = match segments.as_slice() {
492                            ["setuptools", "setup"] | ["distutils", "core", "setup"] => "setup",
493                            ["setuptools", "find_packages"] => "find_packages",
494                            ["setuptools", "find_namespace_packages"] => "find_namespace_packages",
495                            _ => continue,
496                        };
497                        setup_bindings.insert(
498                            vec![import.identifier.expect("imported function binds a name")],
499                            function_name.to_string(),
500                        );
501                    }
502                    Some(StructuredImportPathKind::Namespace) => {
503                        let function_names = match segments.as_slice() {
504                            ["setuptools"] => {
505                                ["setup", "find_packages", "find_namespace_packages"].as_slice()
506                            }
507                            ["distutils", "core"] => ["setup"].as_slice(),
508                            _ => continue,
509                        };
510                        let binding_prefix = import
511                            .alias
512                            .map(|alias| vec![alias])
513                            .unwrap_or_else(|| path.segments.clone());
514                        for function_name in function_names {
515                            let mut callable = binding_prefix.clone();
516                            callable.push((*function_name).to_string());
517                            setup_bindings.insert(callable, (*function_name).to_string());
518                        }
519                    }
520                    _ => continue,
521                }
522            }
523            continue;
524        }
525
526        if statement.kind() == "expression_statement"
527            && statement.named_child_count() == 1
528            && let Some(call) = statement.named_child(0)
529            && call.kind() == "call"
530            && setup_py_call_imported_function(call, source, &setup_bindings)
531                .is_some_and(|function| function == "setup")
532        {
533            calls.push((call, setup_bindings.clone()));
534        }
535
536        for binding in setup_py_bound_names(statement, source) {
537            setup_bindings.retain(|path, _| path.first() != Some(&binding));
538        }
539    }
540    calls
541}
542
543/// Return names that a top-level statement binds in the module scope.
544///
545/// The walk is iterative and does not enter function, class, or lambda bodies.
546/// Bindings in control-flow statements still invalidate an imported setup name:
547/// their execution is conditional, so retaining the import would overclaim its
548/// identity at a later top-level call.
549fn setup_py_bound_names(statement: Node<'_>, source: &str) -> Vec<String> {
550    let mut names = Vec::new();
551    let mut pending = vec![statement];
552    while let Some(node) = pending.pop() {
553        for binding in python_direct_scope_bindings_bounded(node, source, || true)
554            .expect("unbounded setup.py binding walk")
555        {
556            let name = py_node_text(binding.declaration, source).trim();
557            if !name.is_empty() {
558                names.push(name.to_string());
559            }
560        }
561        let excluded_body = matches!(
562            node.kind(),
563            "function_definition" | "class_definition" | "lambda"
564        )
565        .then(|| node.child_by_field_name("body").map(|body| body.id()))
566        .flatten();
567        let mut cursor = node.walk();
568        pending.extend(
569            node.named_children(&mut cursor)
570                .filter(|child| Some(child.id()) != excluded_body),
571        );
572    }
573    names
574}
575
576fn setup_py_call_imported_function<'a>(
577    call: Node<'_>,
578    source: &str,
579    setup_bindings: &'a HashMap<Vec<String>, String>,
580) -> Option<&'a str> {
581    let mut function = call.child_by_field_name("function")?;
582    let mut path = Vec::new();
583    while function.kind() == "attribute" {
584        let attribute = function.child_by_field_name("attribute")?;
585        path.push(py_node_text(attribute, source).to_string());
586        let object = function.child_by_field_name("object")?;
587        function = object;
588    }
589    if function.kind() != "identifier" {
590        return None;
591    }
592    path.push(py_node_text(function, source).to_string());
593    path.reverse();
594    setup_bindings.get(&path).map(String::as_str)
595}
596
597fn setup_py_import_root_from_call(
598    call: Node<'_>,
599    source: &str,
600    setup_bindings: &HashMap<Vec<String>, String>,
601) -> Option<PathBuf> {
602    let arguments = call.child_by_field_name("arguments")?;
603    if arguments.kind() != "argument_list" {
604        return None;
605    }
606
607    let mut packages = None;
608    let mut package_dir = None;
609    let mut cursor = arguments.walk();
610    for argument in arguments.named_children(&mut cursor) {
611        if argument.kind() == "comment" {
612            continue;
613        }
614        // Positional dictionaries and expansions can supply packaging options.
615        if argument.kind() != "keyword_argument" {
616            return None;
617        }
618        let name = argument.child_by_field_name("name")?;
619        let value = argument.child_by_field_name("value")?;
620        match py_node_text(name, source).trim() {
621            "packages" if packages.is_none() => packages = Some(value),
622            "packages" => return None,
623            "package_dir" if package_dir.is_none() => package_dir = Some(value),
624            "package_dir" => return None,
625            _ => {}
626        }
627    }
628
629    let packages = packages?;
630    if let Some(package_dir) = package_dir {
631        return setup_py_static_package_dir(package_dir, source);
632    }
633    if setup_py_nonempty_literal_packages(packages, source) {
634        return Some(PathBuf::new());
635    }
636    setup_py_discovery_root_from_call(packages, source, setup_bindings)
637}
638
639fn setup_py_discovery_root_from_call(
640    call: Node<'_>,
641    source: &str,
642    setup_bindings: &HashMap<Vec<String>, String>,
643) -> Option<PathBuf> {
644    let function = setup_py_call_imported_function(call, source, setup_bindings)?;
645    if !matches!(function, "find_packages" | "find_namespace_packages") {
646        return None;
647    }
648    let arguments = call.child_by_field_name("arguments")?;
649    if arguments.kind() != "argument_list" {
650        return None;
651    }
652
653    let mut where_value = None;
654    let mut seen_exclude = false;
655    let mut seen_include = false;
656    let mut positional_index = 0;
657    let mut cursor = arguments.walk();
658    for argument in arguments.named_children(&mut cursor) {
659        if argument.kind() == "comment" {
660            continue;
661        }
662        if matches!(argument.kind(), "list_splat" | "dictionary_splat") {
663            return None;
664        }
665        if argument.kind() == "keyword_argument" {
666            let name = py_node_text(argument.child_by_field_name("name")?, source).trim();
667            let value = argument.child_by_field_name("value")?;
668            match name {
669                "where" if where_value.is_none() => where_value = Some(value),
670                "where" => return None,
671                "exclude" if !seen_exclude => seen_exclude = true,
672                "exclude" => return None,
673                "include" if !seen_include => seen_include = true,
674                "include" => return None,
675                _ => return None,
676            }
677            continue;
678        }
679
680        let slot = positional_index;
681        positional_index += 1;
682        match slot {
683            0 if where_value.is_none() => where_value = Some(argument),
684            0 => return None,
685            1 if !seen_exclude => seen_exclude = true,
686            1 => return None,
687            2 if !seen_include => seen_include = true,
688            2 => return None,
689            _ => return None,
690        }
691    }
692
693    where_value
694        .map(|value| python_plain_string_literal(value, source))
695        .unwrap_or(Some(""))
696        .map(PathBuf::from)
697}
698
699fn setup_py_nonempty_literal_packages(value: Node<'_>, source: &str) -> bool {
700    let value = setup_py_unwrap_parenthesized(value);
701    if !matches!(value.kind(), "list" | "set" | "tuple") {
702        return false;
703    }
704    let mut cursor = value.walk();
705    let mut nonempty = false;
706    for element in value.named_children(&mut cursor) {
707        if element.kind() == "comment" {
708            continue;
709        }
710        let Some(package) = python_plain_string_literal(element, source) else {
711            return false;
712        };
713        if package.is_empty() {
714            return false;
715        }
716        nonempty = true;
717    }
718    nonempty
719}
720
721fn setup_py_static_package_dir(value: Node<'_>, source: &str) -> Option<PathBuf> {
722    let value = setup_py_unwrap_parenthesized(value);
723    if value.kind() != "dictionary" {
724        return None;
725    }
726    let mut cursor = value.walk();
727    let mut pairs = value
728        .named_children(&mut cursor)
729        .filter(|node| node.kind() != "comment");
730    let Some(pair) = pairs.next() else {
731        return Some(PathBuf::new());
732    };
733    if pairs.next().is_some() || pair.kind() != "pair" {
734        return None;
735    }
736    let key = python_plain_string_literal(pair.child_by_field_name("key")?, source)?;
737    if !key.is_empty() {
738        return None;
739    }
740    let root = python_plain_string_literal(pair.child_by_field_name("value")?, source)?;
741    let root = PathBuf::from(root);
742    (!root.is_absolute()).then_some(root)
743}
744
745fn setup_py_unwrap_parenthesized(node: Node<'_>) -> Node<'_> {
746    brokk_bifrost_core::analyzer::tree_walk::unwrap_parenthesized_expression_with(
747        node,
748        python_parenthesized_expression_child,
749    )
750}
751
752fn parse_setuptools_where_entries(source: &str) -> Vec<String> {
753    let Ok(document) = source.parse::<toml::Value>() else {
754        return Vec::new();
755    };
756    document
757        .get("tool")
758        .and_then(|tool| tool.get("setuptools"))
759        .and_then(|setuptools| setuptools.get("packages"))
760        .and_then(|packages| packages.get("find"))
761        .and_then(|find| find.get("where"))
762        .and_then(toml::Value::as_array)
763        .map(|entries| {
764            entries
765                .iter()
766                .filter_map(toml::Value::as_str)
767                .map(str::to_string)
768                .collect()
769        })
770        .unwrap_or_default()
771}
772
773fn path_components(path: &Path) -> Vec<String> {
774    path.components()
775        .map(|component| component.as_os_str().to_string_lossy().to_string())
776        .filter(|component| !component.is_empty())
777        .collect()
778}
779
780pub fn python_is_decorated_function_boundary(node: Node<'_>) -> bool {
781    if node.kind() != "decorated_definition" {
782        return false;
783    }
784    let mut cursor = node.walk();
785    node.named_children(&mut cursor)
786        .any(|child| child.kind() == "function_definition")
787}
788
789#[derive(Clone)]
790pub struct Scope {
791    kind: ScopeKind,
792    path: String,
793    /// The structured qualified name matching `path`. Tracked independently of
794    /// whether this scope level was actually `capture`d as a `CodeUnit`, so a
795    /// nested class/function that IS captured can always extend an ancestor's
796    /// `fq` even when an intermediate scope level (e.g. a non-captured nested
797    /// function) has no `code_unit` of its own to read `.fq()` from.
798    fq: FqName,
799    code_unit: Option<CodeUnit>,
800    method_receiver: Option<String>,
801}
802
803#[derive(Clone, Copy, PartialEq, Eq)]
804enum ScopeKind {
805    Class,
806    Function,
807}
808
809pub struct PythonVisitor<'source, 'parsed, 'structural>
810where
811    'source: 'structural,
812{
813    pub file: &'parsed ProjectFile,
814    pub source: &'source str,
815    pub package_name: &'parsed str,
816    module_fq: &'parsed FqName,
817    pub parsed: &'parsed mut ParsedFile,
818    pub module: Option<CodeUnit>,
819    overload_decorators: PythonOverloadDecoratorBindings,
820    source_facts: PrimarySourceFactCollector<'source>,
821    structural: StructuralFactCollector<'structural, 'structural>,
822    structural_kinds: &'structural CompiledKinds,
823    call_site_context: &'structural CallSiteContext,
824    structural_parents: Vec<Option<u32>>,
825    source_imports: Vec<SourceImportFact>,
826    generic_imports: Vec<SourceImportId>,
827    source_declaration_units: Vec<(SourceDeclarationId, CodeUnit)>,
828    // Source links can outlive the indexed unit when an enclosing class is replaced.
829    // This membership belongs only to the current file walk.
830    source_declaration_unit_keys: HashSet<CodeUnit>,
831    python_source_facts: PythonSourceFacts,
832    python_callable_return_by_node: HashMap<usize, usize>,
833    overload_metadata: Vec<(CodeUnit, usize, Vec<PythonOverloadDecoratorName>)>,
834}
835
836struct PythonFrame<'tree> {
837    node: Node<'tree>,
838    scope: Vec<Scope>,
839    module_control_depth: usize,
840    declaration: bool,
841    wrapper: Option<Node<'tree>>,
842    lexical_scopes: Vec<Node<'tree>>,
843}
844
845enum PythonWork<'tree> {
846    Enter(PythonFrame<'tree>),
847    Exit,
848}
849
850struct DeclarationChildren<'tree> {
851    body: Option<(Node<'tree>, Vec<Scope>)>,
852    container_scope: Option<Vec<Scope>>,
853    container_depth: usize,
854}
855
856impl<'source, 'parsed, 'structural> PythonVisitor<'source, 'parsed, 'structural>
857where
858    'source: 'structural,
859{
860    pub fn visit_container(
861        &mut self,
862        node: Node<'_>,
863        scope: &[Scope],
864        module_control_depth: usize,
865    ) {
866        let mut stack = vec![PythonWork::Enter(PythonFrame {
867            node,
868            scope: scope.to_vec(),
869            module_control_depth,
870            declaration: true,
871            wrapper: None,
872            lexical_scopes: Vec::new(),
873        })];
874        while let Some(work) = stack.pop() {
875            let PythonWork::Enter(frame) = work else {
876                self.structural_parents
877                    .pop()
878                    .expect("Python structural parent stack must balance");
879                continue;
880            };
881            let structural_parent = self.structural_parents.last().copied().flatten();
882            let structural_id = self.admit_structural(frame.node, structural_parent);
883            self.structural_parents
884                .push(structural_id.or(structural_parent));
885
886            if frame.node.kind() == "identifier" {
887                let text = py_node_text(frame.node, self.source).trim();
888                if !text.is_empty() {
889                    self.parsed.type_identifiers.insert(text.to_string());
890                }
891            }
892
893            if frame.node.kind() == "function_definition" {
894                self.capture_function_return_fact(frame.node, frame.wrapper);
895            }
896
897            if matches!(
898                frame.node.kind(),
899                "import_statement" | "import_from_statement"
900            ) {
901                self.visit_import_statement(frame.node, &frame.lexical_scopes);
902            }
903
904            let declaration_children = if frame.declaration {
905                self.visit_statement(
906                    frame.node,
907                    &frame.scope,
908                    frame.module_control_depth,
909                    frame.wrapper,
910                )
911            } else {
912                DeclarationChildren {
913                    body: None,
914                    container_scope: None,
915                    container_depth: frame.module_control_depth,
916                }
917            };
918
919            let mut children = Vec::new();
920            let mut cursor = frame.node.walk();
921            for child in frame.node.named_children(&mut cursor) {
922                let mut child_scope = frame.scope.clone();
923                let mut declaration = false;
924                let mut wrapper = None;
925                let mut child_depth = frame.module_control_depth;
926
927                if let Some((body, scope)) = &declaration_children.body
928                    && child.id() == body.id()
929                {
930                    declaration = true;
931                    child_scope = scope.clone();
932                } else if let Some(scope) = &declaration_children.container_scope {
933                    declaration = true;
934                    child_scope = scope.clone();
935                    child_depth = declaration_children.container_depth;
936                } else if frame.declaration
937                    && frame.node.kind() == "decorated_definition"
938                    && matches!(child.kind(), "class_definition" | "function_definition")
939                {
940                    // The wrapper owns the source range, while its concrete
941                    // definition owns the declaration semantics.
942                    declaration = true;
943                    wrapper = Some(frame.node);
944                }
945
946                let mut lexical_scopes = frame.lexical_scopes.clone();
947                if matches!(
948                    frame.node.kind(),
949                    "class_definition" | "function_definition" | "lambda"
950                ) {
951                    lexical_scopes.push(frame.node);
952                }
953                children.push(PythonFrame {
954                    node: child,
955                    scope: child_scope,
956                    module_control_depth: child_depth,
957                    declaration,
958                    wrapper,
959                    lexical_scopes,
960                });
961            }
962
963            stack.push(PythonWork::Exit);
964            stack.extend(children.into_iter().rev().map(PythonWork::Enter));
965        }
966    }
967
968    fn admit_structural(&mut self, node: Node<'_>, parent: Option<u32>) -> Option<u32> {
969        if !node.is_named() {
970            return parent;
971        }
972        let Some(raw_kind) = self.structural_kinds.kind_of(&node) else {
973            return parent;
974        };
975        if !PYTHON_STRUCTURAL_SPEC.should_extract(node, raw_kind) {
976            return parent;
977        }
978        let kind = PYTHON_STRUCTURAL_SPEC.refine_kind(
979            node,
980            raw_kind,
981            parent.map(|id| self.structural.normalized_kind(id)),
982            self.source,
983            self.call_site_context,
984        );
985        let fact_id = self
986            .structural
987            .enter(node, kind, parent, &mut self.source_facts)
988            .expect("Python structural fact collection must remain unbounded");
989        let mut sink = self.structural.role_sink(&mut self.source_facts);
990        PYTHON_STRUCTURAL_SPEC.extract(node, kind, &mut sink);
991        self.structural
992            .accept_roles(fact_id, sink.into_parts())
993            .expect("Python structural role collection must remain unbounded");
994        Some(fact_id)
995    }
996
997    fn replace_source_declaration(
998        &mut self,
999        declaration_node: Node<'_>,
1000        name_node: Option<Node<'_>>,
1001        unit: CodeUnit,
1002        metadata_ordinal: Option<usize>,
1003    ) -> SourceDeclarationId {
1004        self.source_declaration_units
1005            .retain(|(_, existing)| existing != &unit);
1006        self.parsed
1007            .source_declaration_metadata
1008            .retain(|link| link.unit != unit);
1009        self.record_source_declaration(declaration_node, name_node, unit, metadata_ordinal)
1010    }
1011
1012    fn record_source_declaration(
1013        &mut self,
1014        declaration_node: Node<'_>,
1015        name_node: Option<Node<'_>>,
1016        unit: CodeUnit,
1017        metadata_ordinal: Option<usize>,
1018    ) -> SourceDeclarationId {
1019        let declaration = self.source_facts.declare_node(declaration_node, name_node);
1020        self.link_source_declaration(declaration, unit, metadata_ordinal);
1021        declaration
1022    }
1023
1024    fn capture_function_return_fact<'tree>(
1025        &mut self,
1026        node: Node<'tree>,
1027        wrapper: Option<Node<'tree>>,
1028    ) {
1029        let declaration_node = wrapper
1030            .or_else(|| {
1031                node.parent()
1032                    .filter(|parent| parent.kind() == "decorated_definition")
1033            })
1034            .unwrap_or(node);
1035        let declaration = self
1036            .source_facts
1037            .declare_node(declaration_node, node.child_by_field_name("name"));
1038        let return_annotation = node
1039            .child_by_field_name("return_type")
1040            .map(|annotation| self.source_facts.intern_node(annotation));
1041        let annotation_references = node
1042            .child_by_field_name("return_type")
1043            .map(|annotation| {
1044                python_annotation_references(annotation, self.source, &mut self.source_facts)
1045            })
1046            .unwrap_or_default();
1047        let runtime_type = node
1048            .child_by_field_name("return_type")
1049            .and_then(|annotation| python_return_type_identity(annotation, self.source));
1050        let fact_index = self.python_source_facts.callable_returns.len();
1051        self.python_source_facts
1052            .callable_returns
1053            .push(PythonCallableReturnFact {
1054                declaration,
1055                return_annotation,
1056                annotation_references,
1057                runtime_type,
1058            });
1059        assert!(
1060            self.python_callable_return_by_node
1061                .insert(node.id(), fact_index)
1062                .is_none(),
1063            "each Python function definition must have one callable return fact"
1064        );
1065    }
1066
1067    fn link_source_declaration(
1068        &mut self,
1069        declaration: SourceDeclarationId,
1070        unit: CodeUnit,
1071        metadata_ordinal: Option<usize>,
1072    ) {
1073        self.source_declaration_unit_keys.insert(unit.clone());
1074        self.source_declaration_units
1075            .push((declaration, unit.clone()));
1076        if let Some(metadata_ordinal) = metadata_ordinal {
1077            self.parsed
1078                .source_declaration_metadata
1079                .push(SourceDeclarationMetadataLink {
1080                    declaration,
1081                    unit,
1082                    metadata_ordinal,
1083                });
1084        }
1085    }
1086
1087    fn visit_statement<'tree>(
1088        &mut self,
1089        node: Node<'tree>,
1090        scope: &[Scope],
1091        module_control_depth: usize,
1092        wrapper: Option<Node<'tree>>,
1093    ) -> DeclarationChildren<'tree> {
1094        match node.kind() {
1095            "decorated_definition" => DeclarationChildren {
1096                body: None,
1097                container_scope: None,
1098                container_depth: module_control_depth,
1099            },
1100            "class_definition" | "function_definition" => {
1101                self.visit_definition(node, wrapper, scope, module_control_depth)
1102            }
1103            "expression_statement" => {
1104                self.visit_expression_statement(node, scope, module_control_depth);
1105                DeclarationChildren {
1106                    body: None,
1107                    container_scope: None,
1108                    container_depth: module_control_depth,
1109                }
1110            }
1111            "import_statement" | "import_from_statement" => DeclarationChildren {
1112                body: None,
1113                container_scope: None,
1114                container_depth: module_control_depth,
1115            },
1116            "if_statement" | "try_statement" | "with_statement" | "for_statement"
1117            | "while_statement" => {
1118                let next_depth = if scope.is_empty() {
1119                    module_control_depth + 1
1120                } else {
1121                    module_control_depth
1122                };
1123                DeclarationChildren {
1124                    body: None,
1125                    container_scope: Some(scope.to_vec()),
1126                    container_depth: next_depth,
1127                }
1128            }
1129            "elif_clause" | "else_clause" | "except_clause" | "finally_clause" => {
1130                DeclarationChildren {
1131                    body: None,
1132                    container_scope: Some(scope.to_vec()),
1133                    container_depth: module_control_depth,
1134                }
1135            }
1136            "block" | "module" => DeclarationChildren {
1137                body: None,
1138                container_scope: Some(scope.to_vec()),
1139                container_depth: module_control_depth,
1140            },
1141            _ => DeclarationChildren {
1142                body: None,
1143                container_scope: None,
1144                container_depth: module_control_depth,
1145            },
1146        }
1147    }
1148
1149    fn visit_definition<'tree>(
1150        &mut self,
1151        definition: Node<'tree>,
1152        wrapper: Option<Node<'tree>>,
1153        scope: &[Scope],
1154        module_control_depth: usize,
1155    ) -> DeclarationChildren<'tree> {
1156        match definition.kind() {
1157            "class_definition" => self.visit_class_definition(
1158                definition,
1159                wrapper.unwrap_or(definition),
1160                scope,
1161                module_control_depth,
1162            ),
1163            "function_definition" => self.visit_function_definition(
1164                definition,
1165                wrapper.unwrap_or(definition),
1166                scope,
1167                module_control_depth,
1168            ),
1169            _ => DeclarationChildren {
1170                body: None,
1171                container_scope: None,
1172                container_depth: module_control_depth,
1173            },
1174        }
1175    }
1176
1177    fn visit_class_definition<'tree>(
1178        &mut self,
1179        node: Node<'tree>,
1180        range_node: Node<'tree>,
1181        scope: &[Scope],
1182        module_control_depth: usize,
1183    ) -> DeclarationChildren<'tree> {
1184        let Some(name_node) = node.child_by_field_name("name") else {
1185            return DeclarationChildren {
1186                body: None,
1187                container_scope: None,
1188                container_depth: module_control_depth,
1189            };
1190        };
1191        let name = py_node_text(name_node, self.source).trim();
1192        if name.is_empty() {
1193            return DeclarationChildren {
1194                body: None,
1195                container_scope: None,
1196                container_depth: module_control_depth,
1197            };
1198        }
1199
1200        let capture = !scope.is_empty() || module_control_depth <= 1;
1201
1202        let short_name = scope
1203            .last()
1204            .map(|parent| format!("{}${name}", parent.path))
1205            .unwrap_or_else(|| name.to_string());
1206        // A nested class (any parent scope, Class or Function) is always joined
1207        // with a literal `$` in the legacy convention above, which is exactly
1208        // what `SegmentKind::Nested` renders regardless of the preceding
1209        // segment's kind; a top-level class has no parent and is a plain `Type`
1210        // hanging off the module-path `Package` chain.
1211        let fq = match scope.last() {
1212            Some(parent) => parent
1213                .fq
1214                .clone()
1215                .with_pushed(segment_interner().intern(name, SegmentKind::Nested)),
1216            None => self
1217                .module_fq
1218                .clone()
1219                .with_pushed(segment_interner().intern(name, SegmentKind::Type)),
1220        };
1221        let code_unit = CodeUnit::new_fq(
1222            self.file.clone(),
1223            CodeUnitType::Class,
1224            self.package_name.to_string(),
1225            short_name.clone(),
1226            fq.clone(),
1227        );
1228        if capture {
1229            self.parsed
1230                .replace_code_unit(code_unit.clone(), range_node, self.source, None, None);
1231            self.parsed.add_signature(
1232                code_unit.clone(),
1233                python_class_signature(range_node, self.source),
1234            );
1235            if let Some(module) = &self.module
1236                && scope.is_empty()
1237            {
1238                self.parsed.add_child(module.clone(), code_unit.clone());
1239            }
1240            if let Some(parent) = scope.last()
1241                && let Some(parent_cu) = &parent.code_unit
1242            {
1243                self.parsed.add_child(parent_cu.clone(), code_unit.clone());
1244            }
1245            self.parsed.set_raw_supertypes(
1246                code_unit.clone(),
1247                extract_python_supertypes(node, self.source),
1248            );
1249            self.replace_source_declaration(range_node, Some(name_node), code_unit.clone(), None);
1250        }
1251
1252        let mut next_scope = scope.to_vec();
1253        if capture {
1254            next_scope.push(Scope {
1255                kind: ScopeKind::Class,
1256                path: short_name,
1257                fq,
1258                code_unit: Some(code_unit),
1259                method_receiver: None,
1260            });
1261        }
1262        DeclarationChildren {
1263            body: node
1264                .child_by_field_name("body")
1265                .map(|body| (body, next_scope)),
1266            container_scope: None,
1267            container_depth: module_control_depth,
1268        }
1269    }
1270
1271    fn visit_function_definition<'tree>(
1272        &mut self,
1273        node: Node<'tree>,
1274        range_node: Node<'tree>,
1275        scope: &[Scope],
1276        module_control_depth: usize,
1277    ) -> DeclarationChildren<'tree> {
1278        let Some(name_node) = node.child_by_field_name("name") else {
1279            return DeclarationChildren {
1280                body: None,
1281                container_scope: None,
1282                container_depth: module_control_depth,
1283            };
1284        };
1285        let name = py_node_text(name_node, self.source).trim();
1286        if name.is_empty() {
1287            return DeclarationChildren {
1288                body: None,
1289                container_scope: None,
1290                container_depth: module_control_depth,
1291            };
1292        }
1293
1294        // Only the shapes a reviewed summary can name are declarations: a
1295        // function written at module level (at most one control level deep, so
1296        // a `def` under `if TYPE_CHECKING:` still counts) or directly in a
1297        // class body. A function nested inside another function's body is a
1298        // local binding of its enclosing function, not a member of any owner,
1299        // so it mints no declaration and no procedure key; `@x.setter` is a
1300        // write into its class's attribute rather than a callable member.
1301        let capture = !python_is_property_mutator(range_node, self.source)
1302            && ((scope.is_empty() && module_control_depth <= 1)
1303                || scope
1304                    .last()
1305                    .is_some_and(|parent| parent.kind == ScopeKind::Class));
1306        let short_name = if let Some(parent) = scope.last() {
1307            match parent.kind {
1308                ScopeKind::Class => format!("{}.{}", parent.path, name),
1309                ScopeKind::Function => format!("{}${name}", parent.path),
1310            }
1311        } else {
1312            name.to_string()
1313        };
1314        // Mirrors `short_name` above segment-for-segment: a method owned
1315        // directly by a class joins with `.` (`Member`), while a function
1316        // nested under another function is a local/closure and joins with the
1317        // literal `$` that `SegmentKind::Nested` renders.
1318        let fq = if let Some(parent) = scope.last() {
1319            match parent.kind {
1320                ScopeKind::Class => parent
1321                    .fq
1322                    .clone()
1323                    .with_pushed(segment_interner().intern(name, SegmentKind::Member)),
1324                ScopeKind::Function => parent
1325                    .fq
1326                    .clone()
1327                    .with_pushed(segment_interner().intern(name, SegmentKind::Nested)),
1328            }
1329        } else {
1330            self.module_fq
1331                .clone()
1332                .with_pushed(segment_interner().intern(name, SegmentKind::Member))
1333        };
1334
1335        if capture {
1336            let code_unit_type = if python_function_has_property_decorator(node, self.source) {
1337                CodeUnitType::Field
1338            } else {
1339                CodeUnitType::Function
1340            };
1341            let signature = node
1342                .child_by_field_name("parameters")
1343                .map(|parameters| py_node_text(parameters, self.source).trim().to_string());
1344            let code_unit = CodeUnit::with_signature_and_fq(
1345                self.file.clone(),
1346                code_unit_type,
1347                self.package_name.to_string(),
1348                short_name.clone(),
1349                signature,
1350                false,
1351                fq.clone(),
1352            );
1353            // Only a redefinition can invalidate earlier declaration links.
1354            // Scanning every prior link for each fresh function is quadratic.
1355            let replaces_declaration = self.parsed.contains_declaration(&code_unit);
1356            let replaces_links = self.source_declaration_unit_keys.contains(&code_unit);
1357            if replaces_declaration {
1358                self.parsed.replace_code_unit(
1359                    code_unit.clone(),
1360                    range_node,
1361                    self.source,
1362                    None,
1363                    None,
1364                );
1365            } else {
1366                self.parsed
1367                    .add_code_unit(code_unit.clone(), range_node, self.source, None, None);
1368            }
1369            let signature = python_function_signature(node, self.source);
1370            let fact_index = *self
1371                .python_callable_return_by_node
1372                .get(&node.id())
1373                .expect("function return fact must precede declaration admission");
1374            let return_fact = &self.python_source_facts.callable_returns[fact_index];
1375            let return_declaration = return_fact.declaration;
1376            let return_type_identity = return_fact.runtime_type.clone();
1377            let return_type_text = return_fact.return_annotation.map(|annotation| {
1378                let range = self.source_facts.occurrence(annotation).range;
1379                self.source[range.start_byte..range.end_byte].to_string()
1380            });
1381            let metadata_ordinal = self.parsed.add_signature_with_metadata(
1382                code_unit.clone(),
1383                python_signature_metadata(
1384                    signature,
1385                    node,
1386                    self.source,
1387                    return_type_text.as_deref(),
1388                    return_type_identity,
1389                ),
1390            );
1391            if replaces_links {
1392                self.overload_metadata
1393                    .retain(|(existing, _, _)| existing != &code_unit);
1394                self.source_declaration_units
1395                    .retain(|(_, existing)| existing != &code_unit);
1396                self.parsed
1397                    .source_declaration_metadata
1398                    .retain(|link| link.unit != code_unit);
1399            }
1400            self.overload_metadata.push((
1401                code_unit.clone(),
1402                metadata_ordinal,
1403                PythonOverloadDecoratorBindings::overload_decorator_names(node, self.source),
1404            ));
1405            self.link_source_declaration(
1406                return_declaration,
1407                code_unit.clone(),
1408                Some(metadata_ordinal),
1409            );
1410            if let Some(module) = &self.module
1411                && scope.is_empty()
1412            {
1413                self.parsed.add_child(module.clone(), code_unit.clone());
1414            }
1415            if let Some(parent) = scope.last()
1416                && parent.kind == ScopeKind::Class
1417                && let Some(parent_cu) = &parent.code_unit
1418            {
1419                self.parsed.add_child(parent_cu.clone(), code_unit.clone());
1420            }
1421            let scope_code_unit = Some(code_unit);
1422            let mut next_scope = scope.to_vec();
1423            next_scope.push(Scope {
1424                kind: ScopeKind::Function,
1425                path: short_name,
1426                fq,
1427                code_unit: scope_code_unit,
1428                method_receiver: scope
1429                    .last()
1430                    .is_some_and(|parent| parent.kind == ScopeKind::Class)
1431                    .then(|| python_instance_method_receiver_name(node, self.source))
1432                    .flatten(),
1433            });
1434            return DeclarationChildren {
1435                body: node
1436                    .child_by_field_name("body")
1437                    .map(|body| (body, next_scope)),
1438                container_scope: None,
1439                container_depth: module_control_depth,
1440            };
1441        }
1442
1443        let mut next_scope = scope.to_vec();
1444        next_scope.push(Scope {
1445            kind: ScopeKind::Function,
1446            path: short_name,
1447            fq,
1448            code_unit: None,
1449            method_receiver: None,
1450        });
1451        DeclarationChildren {
1452            body: node
1453                .child_by_field_name("body")
1454                .map(|body| (body, next_scope)),
1455            container_scope: None,
1456            container_depth: module_control_depth,
1457        }
1458    }
1459
1460    fn visit_expression_statement(
1461        &mut self,
1462        node: Node<'_>,
1463        scope: &[Scope],
1464        module_control_depth: usize,
1465    ) {
1466        let Some(assignment) = node.named_child(0) else {
1467            return;
1468        };
1469        if assignment.kind() != "assignment" {
1470            return;
1471        }
1472        let targets = python_chained_assignment_targets(assignment);
1473        if targets.is_empty() {
1474            return;
1475        }
1476        for left in &targets {
1477            self.visit_instance_attribute_assignment(*left, scope);
1478        }
1479        let names = targets
1480            .iter()
1481            .flat_map(|left| collect_assigned_name_nodes(*left, self.source));
1482        for name_node in names {
1483            let name = py_node_text(name_node, self.source).trim().to_string();
1484            if name.is_empty() {
1485                continue;
1486            }
1487            let (short_name, fq) = if let Some(parent) = scope.last() {
1488                if parent.kind != ScopeKind::Class {
1489                    continue;
1490                }
1491                (
1492                    format!("{}.{}", parent.path, name),
1493                    parent
1494                        .fq
1495                        .clone()
1496                        .with_pushed(segment_interner().intern(&name, SegmentKind::Member)),
1497                )
1498            } else if module_control_depth <= 1 {
1499                (
1500                    name.clone(),
1501                    self.module_fq
1502                        .clone()
1503                        .with_pushed(segment_interner().intern(&name, SegmentKind::Member)),
1504                )
1505            } else {
1506                continue;
1507            };
1508            let code_unit = CodeUnit::new_fq(
1509                self.file.clone(),
1510                CodeUnitType::Field,
1511                self.package_name.to_string(),
1512                short_name,
1513                fq,
1514            );
1515            // Reassigning a module or class attribute does not mint a new
1516            // logical member. Keep every writer's range and exact
1517            // SourceDeclarationId: references between assignments select the
1518            // active writer, and the native bridge looks up each writer's
1519            // exact declaration.
1520            self.parsed
1521                .add_code_unit(code_unit.clone(), node, self.source, None, None);
1522            self.parsed.add_signature(
1523                code_unit.clone(),
1524                py_node_text(node, self.source).trim().to_string(),
1525            );
1526            self.record_source_declaration(node, Some(name_node), code_unit.clone(), None);
1527            if let Some(module) = &self.module
1528                && scope.is_empty()
1529            {
1530                self.parsed.add_child(module.clone(), code_unit.clone());
1531            }
1532            if let Some(parent) = scope.last()
1533                && parent.kind == ScopeKind::Class
1534                && let Some(parent_cu) = &parent.code_unit
1535            {
1536                self.parsed.add_child(parent_cu.clone(), code_unit);
1537            }
1538        }
1539    }
1540
1541    fn visit_instance_attribute_assignment(&mut self, left: Node<'_>, scope: &[Scope]) {
1542        let Some(function) = scope
1543            .last()
1544            .filter(|scope| scope.kind == ScopeKind::Function)
1545        else {
1546            return;
1547        };
1548        let Some(receiver) = function.method_receiver.as_deref() else {
1549            return;
1550        };
1551        let Some(parent) = scope
1552            .get(scope.len().saturating_sub(2))
1553            .filter(|scope| scope.kind == ScopeKind::Class)
1554        else {
1555            return;
1556        };
1557        let Some(parent_cu) = parent.code_unit.clone() else {
1558            return;
1559        };
1560        for (name, node) in collect_self_assigned_attributes(left, self.source, receiver) {
1561            let code_unit = CodeUnit::new_fq(
1562                self.file.clone(),
1563                CodeUnitType::Field,
1564                self.package_name.to_string(),
1565                format!("{}.{}", parent.path, name),
1566                parent
1567                    .fq
1568                    .clone()
1569                    .with_pushed(segment_interner().intern(&name, SegmentKind::Member)),
1570            );
1571            if !self.parsed.contains_declaration(&code_unit) {
1572                self.parsed.replace_code_unit(
1573                    code_unit.clone(),
1574                    node,
1575                    self.source,
1576                    Some(parent_cu.clone()),
1577                    Some(parent_cu.clone()),
1578                );
1579                self.record_source_declaration(node, Some(node), code_unit.clone(), None);
1580            }
1581            self.parsed.add_signature(
1582                code_unit.clone(),
1583                py_node_text(node, self.source).trim().to_string(),
1584            );
1585        }
1586    }
1587
1588    fn visit_import_statement(&mut self, node: Node<'_>, lexical_scopes: &[Node<'_>]) {
1589        for syntax in python_import_syntaxes_from_node(node, self.source) {
1590            if lexical_scopes.is_empty() {
1591                self.overload_decorators.collect_import(&syntax.info);
1592            }
1593            let declaration = self.source_facts.intern_node(syntax.declaration);
1594            let target = syntax
1595                .target
1596                .map(|node| self.source_facts.intern_node(node));
1597            let alias_occurrence = syntax.alias.map(|node| self.source_facts.intern_node(node));
1598            let lexical_scopes = lexical_scopes
1599                .iter()
1600                .map(|node| self.source_facts.intern_node(*node))
1601                .collect();
1602            let source_import = SourceImportFact::from_import(
1603                syntax.info,
1604                declaration,
1605                target,
1606                alias_occurrence,
1607                lexical_scopes,
1608            );
1609            let id = SourceImportId::try_from_index(self.source_imports.len())
1610                .expect("Python source import ids must fit in a u32");
1611            self.source_imports.push(source_import);
1612            self.generic_imports.push(id);
1613        }
1614    }
1615
1616    fn finish(
1617        self,
1618        root: Node<'_>,
1619        native_step: impl FnOnce(
1620            &mut ParsedFile,
1621            &mut PrimarySourceFactCollector<'source>,
1622            &[(SourceDeclarationId, CodeUnit)],
1623        ) -> NativeSourceBridges,
1624    ) {
1625        let PythonVisitor {
1626            parsed,
1627            source,
1628            mut source_facts,
1629            structural,
1630            structural_parents,
1631            source_imports,
1632            generic_imports,
1633            source_declaration_units,
1634            python_source_facts,
1635            python_callable_return_by_node: _,
1636            overload_decorators,
1637            overload_metadata,
1638            ..
1639        } = self;
1640        assert_eq!(structural_parents, vec![None]);
1641        let structural = structural
1642            .finish(root)
1643            .expect("Python structural fact collection must finish");
1644        let (native_site_occurrences, native_declaration_sources) =
1645            native_step(parsed, &mut source_facts, &source_declaration_units);
1646        let occurrences = source_facts.finish();
1647        assert!(
1648            python_source_facts.valid_links(&occurrences),
1649            "Python callable return facts must link to exact source identities"
1650        );
1651        for (unit, metadata_ordinal, decorators) in overload_metadata {
1652            let declaration_only = decorators
1653                .iter()
1654                .any(|name| overload_decorators.matches_overload_binding(name));
1655            if declaration_only
1656                && let Some(metadata) = parsed
1657                    .signature_metadata
1658                    .get_mut(&unit)
1659                    .and_then(|entries| entries.get_mut(metadata_ordinal))
1660            {
1661                *metadata = metadata.clone().with_declaration_only(true);
1662            }
1663        }
1664        parsed.imports = generic_imports
1665            .iter()
1666            .map(|id| source_imports[id.index()].import_info(&occurrences))
1667            .collect();
1668        parsed.source_declaration_units = source_declaration_units;
1669        parsed.source_facts = Some(ParsedSourceFacts {
1670            cpp: None,
1671            go: None,
1672            java: None,
1673            js_ts: None,
1674            php: None,
1675            scala: None,
1676            ruby: None,
1677            source_bytes: source.len(),
1678            occurrences,
1679            structural,
1680            python: Some(python_source_facts),
1681            native_site_occurrences,
1682            native_declaration_sources,
1683            declaration_visibilities: None,
1684            rust_declaration_properties: Vec::new(),
1685            rust_modules: None,
1686            rust_types: Vec::new(),
1687            rust_items: RustItemSourceFacts::default(),
1688            imports: source_imports,
1689            generic_imports,
1690            rust_import_contexts: Vec::new(),
1691        });
1692    }
1693}
1694
1695type NativeSourceBridges = (
1696    Vec<SourceOccurrenceId>,
1697    Vec<(ResolutionSiteId, SourceDeclarationId)>,
1698);
1699
1700/// Build production Python declarations and source facts without native facts.
1701/// Native publication starts with Python's first production native reader.
1702pub fn parse_python_file(
1703    project: &dyn Project,
1704    file: &ProjectFile,
1705    source: &str,
1706    tree: &Tree,
1707) -> ParsedFile {
1708    parse_python_file_with_step(project, file, source, tree, |_, _, _| Default::default())
1709}
1710
1711/// Build coordinated Python source and native facts for producer validation.
1712/// Production callers keep using [`parse_python_file`] until the reader switch.
1713#[cfg(any(test, feature = "test-support"))]
1714pub fn parse_python_file_with_native_facts(
1715    project: &dyn Project,
1716    file: &ProjectFile,
1717    source: &str,
1718    tree: &Tree,
1719) -> ParsedFile {
1720    parse_python_file_with_step(project, file, source, tree, |parsed, collector, units| {
1721        // Class replacement withdraws old descendant CodeUnits, while their
1722        // source occurrences and lexical identities remain in the native facts.
1723        let (facts, occurrences, declarations) = crate::resolution::produce(
1724            tree.root_node(),
1725            source,
1726            collector,
1727            units
1728                .iter()
1729                .filter(|(_, unit)| parsed.contains_declaration(unit)),
1730        );
1731        parsed.resolution_facts = facts;
1732        (occurrences, declarations)
1733    })
1734}
1735
1736fn parse_python_file_with_step<'source>(
1737    project: &dyn Project,
1738    file: &ProjectFile,
1739    source: &'source str,
1740    tree: &Tree,
1741    native_step: impl FnOnce(
1742        &mut ParsedFile,
1743        &mut PrimarySourceFactCollector<'source>,
1744        &[(SourceDeclarationId, CodeUnit)],
1745    ) -> NativeSourceBridges,
1746) -> ParsedFile {
1747    let module_components = python_module_components(project, file);
1748    let module_name = module_components.join(".");
1749    let module_fq = FqName::from_package_components(&module_components);
1750    let mut parsed = ParsedFile::new(module_name.clone());
1751    let root = tree.root_node();
1752
1753    let module_code_unit = module_code_unit_from_fq(file, &module_components, module_fq.clone());
1754    if let Some(module) = module_code_unit.clone() {
1755        parsed.add_code_unit(module, root, source, None, None);
1756    }
1757
1758    let overload_decorators = PythonOverloadDecoratorBindings::default();
1759    let structural_kinds = CompiledKinds::compile(
1760        &tree_sitter_python::LANGUAGE.into(),
1761        PYTHON_STRUCTURAL_SPEC.kind_table(),
1762    );
1763    let call_site_context = PYTHON_STRUCTURAL_SPEC.call_site_context(root, source);
1764    let mut visitor = PythonVisitor {
1765        file,
1766        source,
1767        package_name: &module_name,
1768        module_fq: &module_fq,
1769        parsed: &mut parsed,
1770        module: module_code_unit,
1771        overload_decorators,
1772        source_facts: PrimarySourceFactCollector::new(source),
1773        structural: StructuralFactCollector::new(
1774            &PYTHON_STRUCTURAL_SPEC,
1775            source,
1776            &call_site_context,
1777            ParentIndex::new(root),
1778            usize::MAX,
1779            None,
1780        ),
1781        structural_kinds: &structural_kinds,
1782        call_site_context: &call_site_context,
1783        structural_parents: vec![None],
1784        source_imports: Vec::new(),
1785        generic_imports: Vec::new(),
1786        source_declaration_units: Vec::new(),
1787        source_declaration_unit_keys: HashSet::default(),
1788        python_source_facts: PythonSourceFacts::default(),
1789        python_callable_return_by_node: HashMap::default(),
1790        overload_metadata: Vec::new(),
1791    };
1792    visitor.visit_container(root, &[], 0);
1793    visitor.finish(root, native_step);
1794
1795    parsed
1796}
1797
1798pub use brokk_bifrost_core::analyzer::common::node_source_text as py_node_text;
1799
1800pub fn python_module_name(project: &dyn Project, file: &ProjectFile) -> String {
1801    python_module_components(project, file).join(".")
1802}
1803
1804/// Select module components using caller-owned configuration and package membership.
1805/// Hydration can supply the open SQL read without changing root selection.
1806pub fn python_module_components_with_inputs(
1807    file: &ProjectFile,
1808    inputs: &impl PythonImportRootInputs,
1809) -> std::io::Result<Vec<String>> {
1810    let root = python_import_root_with_inputs(file.root(), file.rel_path(), inputs)?;
1811    Ok(python_module_components_from_root(file, &root))
1812}
1813
1814/// Hydrate a module unit using its already published identity.
1815pub fn module_code_unit_from_name(file: &ProjectFile, name: &str) -> Option<CodeUnit> {
1816    let components = python_module_components_from_name(file, name)?;
1817    module_code_unit_from_components(file, &components)
1818}
1819
1820pub fn module_code_unit(project: &dyn Project, file: &ProjectFile) -> Option<CodeUnit> {
1821    let components = python_module_components(project, file);
1822    module_code_unit_from_components(file, &components)
1823}
1824
1825/// Build a path module without rendering and reparsing its selected components.
1826pub fn module_code_unit_from_components(
1827    file: &ProjectFile,
1828    components: &[String],
1829) -> Option<CodeUnit> {
1830    module_code_unit_from_fq(
1831        file,
1832        components,
1833        FqName::from_package_components(components),
1834    )
1835}
1836
1837fn module_code_unit_from_fq(
1838    file: &ProjectFile,
1839    components: &[String],
1840    structured_fq: FqName,
1841) -> Option<CodeUnit> {
1842    let (short_name, package_components) = components.split_last()?;
1843    let package_name = package_components.join(".");
1844    Some(CodeUnit::new_fq(
1845        file.clone(),
1846        CodeUnitType::Module,
1847        package_name,
1848        short_name.clone(),
1849        structured_fq,
1850    ))
1851}
1852
1853fn python_class_signature(node: Node<'_>, source: &str) -> String {
1854    python_header_with_decorators(node, source)
1855}
1856
1857fn python_function_signature(function: Node<'_>, source: &str) -> String {
1858    let header_end = (0..function.child_count())
1859        .rev()
1860        .filter_map(|index| function.child(index))
1861        .find(|child| child.kind() == ":")
1862        .map_or_else(
1863            || {
1864                function
1865                    .child_by_field_name("body")
1866                    .map_or(function.end_byte(), |body| body.start_byte())
1867            },
1868            |colon| colon.end_byte(),
1869        );
1870    let header_node = python_function_header_node(function);
1871    let header_start = header_node.start_byte();
1872    let header = source
1873        .get(header_start..header_end)
1874        .expect("Python function header must be a source range")
1875        .trim_end();
1876    format!(
1877        "{} ...",
1878        python_dedent_function_header(header, header_node.start_position().column)
1879    )
1880}
1881
1882fn python_dedent_function_header(header: &str, base_indent: usize) -> String {
1883    let mut lines = header.lines();
1884    let Some(first) = lines.next() else {
1885        return String::new();
1886    };
1887    let mut dedented = String::with_capacity(header.len());
1888    dedented.push_str(first);
1889    for line in lines {
1890        dedented.push('\n');
1891        dedented.push_str(&line[python_header_indent_bytes(line, base_indent)..]);
1892    }
1893    dedented
1894}
1895
1896/// Map an AST byte position in a source prefix to the matching offset after
1897/// function-header dedenting and CRLF normalization.
1898fn python_dedented_header_offset(source_prefix: &str, base_indent: usize) -> usize {
1899    let mut removed_bytes = 0;
1900    let mut line_start = 0;
1901    let mut continuation_line = false;
1902
1903    while line_start < source_prefix.len() {
1904        let newline = source_prefix.as_bytes()[line_start..]
1905            .iter()
1906            .position(|byte| *byte == b'\n')
1907            .map(|offset| line_start + offset);
1908        let line_end = newline.unwrap_or(source_prefix.len());
1909        let line = &source_prefix[line_start..line_end];
1910        if continuation_line {
1911            removed_bytes += python_header_indent_bytes(line, base_indent);
1912        }
1913        let Some(newline) = newline else {
1914            break;
1915        };
1916        if newline > 0 && source_prefix.as_bytes()[newline - 1] == b'\r' {
1917            removed_bytes += 1;
1918        }
1919        line_start = newline + 1;
1920        continuation_line = true;
1921    }
1922
1923    source_prefix.len() - removed_bytes
1924}
1925
1926fn python_header_indent_bytes(line: &str, base_indent: usize) -> usize {
1927    line.bytes()
1928        .take_while(|byte| matches!(*byte, b' ' | b'\t'))
1929        .count()
1930        .min(base_indent)
1931}
1932
1933fn python_signature_metadata(
1934    signature: String,
1935    node: Node<'_>,
1936    source: &str,
1937    return_type_text: Option<&str>,
1938    return_type_identity: Option<StructuredTypeIdentity>,
1939) -> SignatureMetadata {
1940    let Some(parameters_node) = node.child_by_field_name("parameters") else {
1941        return SignatureMetadata::new(signature, Vec::new())
1942            .with_return_type_text(return_type_text)
1943            .with_return_type_identity(return_type_identity)
1944            .with_dispatch_extensibility(DispatchExtensibility::Open)
1945            .with_callable_modifiers(
1946                python_callable_is_static(node, source),
1947                false,
1948                DeclaredVisibility::Unknown,
1949            );
1950    };
1951    let arity = python_callable_arity(parameters_node, source);
1952    // Parameter labels use offsets from their exact AST nodes, so repeated
1953    // spellings in annotations/defaults cannot masquerade as another
1954    // parameter's identity. The full source header, including multiline
1955    // continuations and decorators, is retained in the signature label.
1956    let header_node = python_function_header_node(node);
1957    let header_start = header_node.start_byte();
1958    let base_indent = header_node.start_position().column;
1959    let parameters = python_parameter_label_nodes(parameters_node)
1960        .into_iter()
1961        .map(|label_node| {
1962            let label = py_node_text(label_node, source);
1963            let label_range = label_node.byte_range();
1964            let source_prefix = source
1965                .get(header_start..label_range.start)
1966                .expect("Python parameter must follow its function header");
1967            let start_byte = python_dedented_header_offset(source_prefix, base_indent);
1968            let end_byte = start_byte + label.len();
1969            assert_eq!(
1970                signature.get(start_byte..end_byte),
1971                Some(label),
1972                "Python parameter metadata must point at its exact signature label"
1973            );
1974            ParameterMetadata::new(label, start_byte, end_byte)
1975        })
1976        .collect();
1977    let metadata = SignatureMetadata::new(signature, parameters)
1978        .with_return_type_text(return_type_text)
1979        .with_return_type_identity(return_type_identity)
1980        .with_dispatch_extensibility(DispatchExtensibility::Open)
1981        .with_callable_modifiers(
1982            python_callable_is_static(node, source),
1983            false,
1984            DeclaredVisibility::Unknown,
1985        );
1986    if let Some(arity) = arity {
1987        metadata.with_callable_arity(arity)
1988    } else {
1989        metadata
1990    }
1991}
1992
1993/// Positional arity of one Python parameter list, derived from its structured
1994/// parameter nodes. Keyword-only parameters are intentionally excluded from
1995/// positional counts; `*args` makes the upper bound open, while `**kwargs`
1996/// does not accept extra positional values.
1997fn python_callable_arity(parameters: Node<'_>, source: &str) -> Option<CallableArity> {
1998    let mut required = 0;
1999    let mut total = 0;
2000    let mut repeated = false;
2001    let mut keyword_only = false;
2002    let mut cursor = parameters.walk();
2003    for parameter in parameters.named_children(&mut cursor) {
2004        match parameter.kind() {
2005            "positional_separator" => continue,
2006            "keyword_separator" => {
2007                keyword_only = true;
2008                continue;
2009            }
2010            "ERROR" => return None,
2011            _ => {}
2012        }
2013        if !matches!(
2014            parameter.kind(),
2015            "identifier"
2016                | "typed_parameter"
2017                | "default_parameter"
2018                | "typed_default_parameter"
2019                | "list_splat_pattern"
2020                | "dictionary_splat_pattern"
2021        ) {
2022            continue;
2023        }
2024        let label = python_parameter_label_node(parameter)?;
2025        source.get(label.byte_range())?;
2026        match python_parameter_splat(parameter) {
2027            Some(PythonParameterSplat::Positional) => {
2028                repeated = true;
2029                keyword_only = true;
2030            }
2031            Some(PythonParameterSplat::Keyword) => {}
2032            None if !keyword_only => {
2033                total += 1;
2034                if !matches!(
2035                    parameter.kind(),
2036                    "default_parameter" | "typed_default_parameter"
2037                ) {
2038                    required += 1;
2039                }
2040            }
2041            None => {}
2042        }
2043    }
2044    Some(CallableArity::new(required, total, repeated))
2045}
2046
2047fn python_function_header_node(node: Node<'_>) -> Node<'_> {
2048    node.parent()
2049        .filter(|parent| parent.kind() == "decorated_definition")
2050        .unwrap_or(node)
2051}
2052
2053/// Whether this Python callable binds no instance receiver, read from its own
2054/// declaration nodes (#3451).
2055///
2056/// Recording the fact is what makes a Python declaration keyable for
2057/// procedure-summary binding: `receiver_contract_of` refuses to answer for a
2058/// callable whose adapter never inspected modifiers, so before this every
2059/// Python workspace callee contributed `callee_unkeyable` and no Python effect
2060/// coverage could be exhaustive.
2061///
2062/// Only a callable written in a class body can bind an instance receiver, and
2063/// the two decorators that remove it are `@staticmethod` and `@classmethod`.
2064/// The decorator check is the language's own
2065/// [`python_function_has_decorator`], read through the `decorated_definition`
2066/// node that owns the decorators, so the receiver contract agrees with the
2067/// receiver [`python_instance_method_receiver_name`] already resolves. A
2068/// module-level function, and a function nested in another function's body, has
2069/// no type owner at all, so its contract falls out of the owner rather than out
2070/// of this flag; it is not static either.
2071///
2072/// Python spells no constructor modifier for `__init__`: calling the class
2073/// reaches an ordinary instance method, exactly as Ruby's `initialize`, so the
2074/// constructor flag stays false and its receiver contract is `Instance`.
2075/// Visibility is a naming convention (`_name`), not a declaration node, so it
2076/// stays `Unknown`.
2077fn python_callable_is_static(node: Node<'_>, source: &str) -> bool {
2078    debug_assert!(
2079        node.kind() == "function_definition",
2080        "Python callable signature metadata is built for function_definition, not {}",
2081        node.kind()
2082    );
2083    python_function_has_decorator(node, source, "staticmethod")
2084        || python_function_has_decorator(node, source, "classmethod")
2085}
2086
2087fn python_parameter_label_nodes(parameters_node: Node<'_>) -> Vec<Node<'_>> {
2088    brokk_bifrost_core::analyzer::tree_walk::named_children_iter(parameters_node)
2089        .filter_map(python_parameter_label_node)
2090        .collect()
2091}
2092
2093/// The identifier node that names one parameter's binding.
2094///
2095/// The grammar gives `default_parameter` and `typed_default_parameter` a
2096/// `name` field but gives `typed_parameter` and the two splat patterns none,
2097/// so a caller that reads only the field loses the binding name of every
2098/// annotated parameter. Every Python surface that names parameters reads them
2099/// through this function.
2100/// Which splat a Python formal parameter spells, looking through the
2101/// annotation wrapper.
2102///
2103/// `*args` is a `list_splat_pattern` and `**kwargs` a
2104/// `dictionary_splat_pattern`, but the grammar spells `*args: str` as a
2105/// `typed_parameter` that holds one, so a test on the parameter's own node kind
2106/// misses every annotated variadic. A parameter that misses it binds like an
2107/// ordinary formal: one positional actual each, and the rest spill onto the
2108/// formals that follow.
2109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2110pub enum PythonParameterSplat {
2111    /// `*args`: collects every remaining positional actual.
2112    Positional,
2113    /// `**kwargs`: collects every remaining keyword actual.
2114    Keyword,
2115}
2116
2117pub fn python_parameter_splat(parameter: Node<'_>) -> Option<PythonParameterSplat> {
2118    let splat = match parameter.kind() {
2119        kind @ ("list_splat_pattern" | "dictionary_splat_pattern") => kind,
2120        _ => {
2121            let mut cursor = parameter.walk();
2122            parameter
2123                .named_children(&mut cursor)
2124                .map(|child| child.kind())
2125                .find(|kind| matches!(*kind, "list_splat_pattern" | "dictionary_splat_pattern"))?
2126        }
2127    };
2128    match splat {
2129        "list_splat_pattern" => Some(PythonParameterSplat::Positional),
2130        "dictionary_splat_pattern" => Some(PythonParameterSplat::Keyword),
2131        _ => unreachable!("the splat kind was matched above"),
2132    }
2133}
2134
2135pub fn python_parameter_label_node(node: Node<'_>) -> Option<Node<'_>> {
2136    match node.kind() {
2137        "identifier" => Some(node),
2138        "typed_parameter"
2139        | "typed_default_parameter"
2140        | "default_parameter"
2141        | "list_splat_pattern"
2142        | "dictionary_splat_pattern"
2143        | "keyword_separator" => node.child_by_field_name("name").or_else(|| {
2144            let mut cursor = node.walk();
2145            node.named_children(&mut cursor)
2146                .find_map(python_parameter_label_node)
2147        }),
2148        _ => None,
2149    }
2150}
2151
2152fn python_is_property_mutator(node: Node<'_>, source: &str) -> bool {
2153    python_header_with_decorators(node, source)
2154        .lines()
2155        .map(str::trim)
2156        .filter(|line| line.starts_with('@'))
2157        .any(|decorator| decorator.ends_with(".setter") || decorator.ends_with(".deleter"))
2158}
2159
2160pub fn python_expanded_comment_start(source: &str, start_byte: usize) -> usize {
2161    let line_starts = compute_line_starts(source);
2162    let line_index = find_line_index_for_offset(&line_starts, start_byte);
2163
2164    let mut comment_start = start_byte;
2165    for line_idx in (0..line_index).rev() {
2166        let line_start = line_starts[line_idx];
2167        let line_end = line_starts
2168            .get(line_idx + 1)
2169            .copied()
2170            .unwrap_or(source.len());
2171        let line = &source[line_start..line_end];
2172        let trimmed = line.trim_start();
2173
2174        if trimmed.trim().is_empty() {
2175            continue;
2176        }
2177
2178        if trimmed.starts_with('#') {
2179            comment_start = line_start;
2180            continue;
2181        }
2182
2183        break;
2184    }
2185
2186    comment_start
2187}
2188
2189fn python_header_with_decorators(node: Node<'_>, source: &str) -> String {
2190    let raw = py_node_text(node, source);
2191    let lines: Vec<_> = raw
2192        .lines()
2193        .map(str::trim_end)
2194        .filter(|line| !line.trim().is_empty())
2195        .collect();
2196    let mut relevant = Vec::new();
2197    for line in lines {
2198        let trimmed = line.trim_start();
2199        if trimmed.starts_with('@')
2200            || trimmed.starts_with("def ")
2201            || trimmed.starts_with("async def ")
2202            || trimmed.starts_with("class ")
2203        {
2204            relevant.push(trimmed.to_string());
2205            if trimmed.starts_with("def ")
2206                || trimmed.starts_with("async def ")
2207                || trimmed.starts_with("class ")
2208            {
2209                break;
2210            }
2211        }
2212    }
2213    relevant.join("\n")
2214}
2215
2216/// Every positional base of a class, as the spelling the hierarchy resolver
2217/// should look up.
2218///
2219/// A base this function omits is indistinguishable from a class that has no
2220/// such base, so member lookup would treat an incompletely modeled hierarchy
2221/// as a complete one and prove a member absent that the base declares. Every
2222/// positional base therefore contributes a spelling:
2223///
2224/// * A dotted name is its own spelling.
2225/// * A subscripted base (`Base[T]`, `MutableMapping[str, Any]`) contributes
2226///   its generic origin, which is the class the runtime actually inherits.
2227/// * Any other positional base -- a call such as `namedtuple(...)`, an
2228///   unpacked base list, a conditional expression -- contributes its source
2229///   spelling. Resolution fails on it, and the caller reports an unresolved
2230///   base instead of a complete member list.
2231///
2232/// A keyword argument (`metaclass=`, and the arbitrary keywords
2233/// `__init_subclass__` accepts) is not a base and contributes nothing here.
2234fn extract_python_supertypes(node: Node<'_>, source: &str) -> Vec<String> {
2235    let Some(superclasses) = node.child_by_field_name("superclasses") else {
2236        return Vec::new();
2237    };
2238    let mut result = Vec::new();
2239    let mut cursor = superclasses.walk();
2240    for child in superclasses.named_children(&mut cursor) {
2241        if child.kind() == "keyword_argument" {
2242            continue;
2243        }
2244        let named = python_base_origin_node(child);
2245        let text = py_node_text(named, source).trim();
2246        if !text.is_empty() {
2247            result.push(text.to_string());
2248        }
2249    }
2250    result
2251}
2252
2253/// The node whose text names a base class: the value a subscripted base
2254/// applies its type arguments to, or the base expression itself.
2255pub fn python_base_origin_node<'tree>(base: Node<'tree>) -> Node<'tree> {
2256    if base.kind() != "subscript" {
2257        return base;
2258    }
2259    let Some(value) = base.child_by_field_name("value") else {
2260        return base;
2261    };
2262    if matches!(value.kind(), "identifier" | "attribute") {
2263        value
2264    } else {
2265        base
2266    }
2267}
2268
2269fn collect_assigned_name_nodes<'tree>(node: Node<'tree>, _source: &str) -> Vec<Node<'tree>> {
2270    let mut names = Vec::new();
2271    walk_named_tree_preorder(node, true, |node| {
2272        match node.kind() {
2273            // An attribute or subscript target (`foo.bar = …`, `foo[i] = …`)
2274            // mutates an existing object; it declares neither the receiver nor
2275            // the member as a name, so do not descend into it.
2276            "attribute" | "subscript" => WalkControl::SkipChildren,
2277            "identifier" => {
2278                names.push(node);
2279                WalkControl::Continue
2280            }
2281            _ => WalkControl::Continue,
2282        }
2283    });
2284    names
2285}
2286
2287fn collect_self_assigned_attributes<'tree>(
2288    node: Node<'tree>,
2289    source: &str,
2290    receiver_name: &str,
2291) -> Vec<(String, Node<'tree>)> {
2292    let mut attributes = Vec::new();
2293    collect_direct_self_assigned_attributes(node, source, receiver_name, &mut attributes);
2294    attributes
2295}
2296
2297fn collect_direct_self_assigned_attributes<'tree>(
2298    node: Node<'tree>,
2299    source: &str,
2300    receiver_name: &str,
2301    attributes: &mut Vec<(String, Node<'tree>)>,
2302) {
2303    let mut pending = vec![node];
2304    while let Some(node) = pending.pop() {
2305        match node.kind() {
2306            "attribute" => {
2307                let Some(object) = node.child_by_field_name("object") else {
2308                    continue;
2309                };
2310                if object.kind() != "identifier"
2311                    || py_node_text(object, source).trim() != receiver_name
2312                {
2313                    continue;
2314                }
2315                let Some(attribute) = node.child_by_field_name("attribute") else {
2316                    continue;
2317                };
2318                let name = py_node_text(attribute, source).trim();
2319                if !name.is_empty() {
2320                    attributes.push((name.to_string(), attribute));
2321                }
2322            }
2323            // The grammar spells an unpacking target three ways: a bare comma list
2324            // is a `pattern_list`, and parentheses or brackets around it make a
2325            // `tuple_pattern` or a `list_pattern`. Omitting the bracketed forms
2326            // dropped every attribute a multi-line unpacking assigns.
2327            "pattern_list"
2328            | "tuple_pattern"
2329            | "list_pattern"
2330            | "tuple"
2331            | "list"
2332            | "parenthesized_expression" => {
2333                let mut cursor = node.walk();
2334                let children: Vec<_> = node.named_children(&mut cursor).collect();
2335                pending.extend(children.into_iter().rev());
2336            }
2337            _ => {}
2338        }
2339    }
2340}
2341
2342fn python_instance_method_receiver_name(node: Node<'_>, source: &str) -> Option<String> {
2343    if python_function_has_decorator(node, source, "staticmethod")
2344        || python_function_has_decorator(node, source, "classmethod")
2345    {
2346        return None;
2347    }
2348    python_first_parameter_name(node, source)
2349}
2350
2351pub fn python_function_has_decorator(node: Node<'_>, source: &str, decorator_name: &str) -> bool {
2352    let Some(parent) = node.parent() else {
2353        return false;
2354    };
2355    if parent.kind() != "decorated_definition" {
2356        return false;
2357    }
2358    let mut cursor = parent.walk();
2359    parent
2360        .named_children(&mut cursor)
2361        .filter(|child| child.kind() == "decorator")
2362        .filter_map(|decorator| decorator.named_child(0))
2363        .filter_map(expression_name_node)
2364        .any(|name| py_node_text(name, source).trim() == decorator_name)
2365}
2366
2367/// Whether a function's decorator syntax makes it a Python property descriptor.
2368///
2369/// The terminal decorator name is selected from the decorator AST, so both
2370/// `@cached_property` and `@functools.cached_property` are recognized without
2371/// searching source text. Declaration indexing and type-flow use this shared
2372/// predicate so a cached property is both a present attribute and an unmodeled
2373/// descriptor read.
2374pub fn python_function_has_property_decorator(node: Node<'_>, source: &str) -> bool {
2375    python_function_has_decorator(node, source, "property")
2376        || python_function_has_decorator(node, source, "cached_property")
2377}
2378
2379/// The name a callable binds its first parameter to, which for a method is
2380/// the receiver every `self.x` and `setattr(self, ...)` in its body names.
2381pub fn python_first_parameter_name(node: Node<'_>, source: &str) -> Option<String> {
2382    let parameters = node.child_by_field_name("parameters")?;
2383    let mut cursor = parameters.walk();
2384    parameters
2385        .named_children(&mut cursor)
2386        .find_map(|child| python_parameter_name(child, source))
2387}
2388
2389pub fn collect_python_identifiers(node: Node<'_>, source: &str, identifiers: &mut HashSet<String>) {
2390    brokk_bifrost_core::analyzer::tree_walk::collect_trimmed_node_texts(
2391        node,
2392        source,
2393        identifiers,
2394        |node| node.kind() == "identifier",
2395    );
2396}
2397
2398pub fn parse_python_tree(source: &str) -> Option<Tree> {
2399    let mut parser = Parser::new();
2400    parser
2401        .set_language(&tree_sitter_python::LANGUAGE.into())
2402        .expect("failed to load python parser");
2403    parser.parse(source, None)
2404}
2405
2406#[cfg(test)]
2407mod primary_tests {
2408    use super::*;
2409    use brokk_bifrost_core::analyzer::python_facts::PythonAnnotationReferenceName;
2410
2411    fn parse(source: &str) -> ParsedFile {
2412        let file = ProjectFile::new(std::env::temp_dir(), "m4.py");
2413        let tree = parse_python_tree(source).expect("Python fixture tree");
2414        parse_python_file(
2415            &brokk_bifrost_core::analyzer::project::TestProject::new(
2416                file.root(),
2417                brokk_bifrost_core::analyzer::Language::Python,
2418            ),
2419            &file,
2420            source,
2421            &tree,
2422        )
2423    }
2424
2425    fn parse_native(source: &str) -> ParsedFile {
2426        let file = ProjectFile::new(std::env::temp_dir(), "m4.py");
2427        let tree = parse_python_tree(source).expect("Python fixture tree");
2428        parse_python_file_with_native_facts(
2429            &brokk_bifrost_core::analyzer::project::TestProject::new(
2430                file.root(),
2431                brokk_bifrost_core::analyzer::Language::Python,
2432            ),
2433            &file,
2434            source,
2435            &tree,
2436        )
2437    }
2438
2439    #[test]
2440    #[brokk_bifrost_test_macros::hardwon(
2441        "6b123df7cc68: quicksilver round 6 on brokk-bifrost-python: nested assignment targets recursively exhausted the stack"
2442    )]
2443    fn qs6_nested_unpacking_is_stack_safe() {
2444        let depth = 1024;
2445        let source = format!(
2446            "{}self.first, self.last{} = pair\n",
2447            "[".repeat(depth),
2448            "]".repeat(depth)
2449        );
2450        let tree = parse_python_tree(&source).unwrap();
2451        assert!(!tree.root_node().has_error());
2452        let assignment = tree
2453            .root_node()
2454            .named_child(0)
2455            .unwrap()
2456            .named_child(0)
2457            .unwrap();
2458        let target = assignment.child_by_field_name("left").unwrap();
2459        let attributes = std::thread::scope(|scope| {
2460            std::thread::Builder::new()
2461                .stack_size(64 * 1024)
2462                .spawn_scoped(scope, || {
2463                    collect_self_assigned_attributes(target, &source, "self")
2464                })
2465                .unwrap()
2466                .join()
2467                .unwrap()
2468        });
2469        assert_eq!(
2470            attributes
2471                .iter()
2472                .map(|(name, _)| name.as_str())
2473                .collect::<Vec<_>>(),
2474            ["first", "last"]
2475        );
2476    }
2477
2478    #[test]
2479    fn qs6_setuptools_literal_dependency_boundary() {
2480        // setup.py is an external artifact: tuples and lists both encode a
2481        // sequence; executing an expression is outside static acquisition.
2482        let source = "from setuptools import setup\nsetup(install_requires=('requests>=2',), setup_requires=['wheel'], tests_require=('pytest',), extras_require={'speed': ('orjson',)})\nsetup(install_requires=dynamic())\n";
2483        let arguments = setuptools_setup_py_literal_arguments(source).unwrap();
2484        let rendered = arguments
2485            .iter()
2486            .map(|argument| {
2487                format!(
2488                    "{} {} {:?} {}",
2489                    argument.call_index,
2490                    argument.name,
2491                    argument.value,
2492                    &source[argument.byte_range.clone()]
2493                )
2494            })
2495            .collect::<Vec<_>>()
2496            .join("\n");
2497        assert_eq!(
2498            rendered,
2499            concat!(
2500                "0 install_requires Some(Strings([\"requests>=2\"])) ('requests>=2',)\n",
2501                "0 setup_requires Some(Strings([\"wheel\"])) ['wheel']\n",
2502                "0 tests_require Some(Strings([\"pytest\"])) ('pytest',)\n",
2503                "0 extras_require Some(Extras([(\"speed\", [\"orjson\"])])) {'speed': ('orjson',)}\n",
2504                "1 install_requires None dynamic()",
2505            )
2506        );
2507    }
2508
2509    #[test]
2510    #[brokk_bifrost_test_macros::hardwon(
2511        "#3673: declarations ignored an overlay-only setuptools manifest"
2512    )]
2513    fn snapshot_root_declaration_witness() {
2514        use brokk_bifrost_core::analyzer::Language;
2515        use brokk_bifrost_core::analyzer::project::{OverlayProject, Project, TestProject};
2516        use std::sync::Arc;
2517        let root = tempfile::tempdir().unwrap();
2518        let disk = Arc::new(TestProject::new(root.path(), Language::Python));
2519        let overlay = OverlayProject::new(disk);
2520        let manifest = ProjectFile::new(root.path(), "pyproject.toml");
2521        assert!(overlay.set(
2522            manifest.abs_path(),
2523            "[tool.setuptools.packages.find]\nwhere = ['source']\n".to_string()
2524        ));
2525        let file = ProjectFile::new(root.path(), "source/pkg/model.py");
2526        assert!(overlay.set(file.abs_path(), "class Widget:\n    pass\n".to_string()));
2527        let snapshot = overlay.snapshot();
2528        assert!(snapshot.read_source(&manifest).unwrap().contains("source"));
2529        let source = snapshot.read_source(&file).unwrap();
2530        let tree = parse_python_tree(&source).unwrap();
2531        let parsed = parse_python_file(&snapshot, &file, &source, &tree);
2532        let names: Vec<_> = parsed
2533            .declarations()
2534            .iter()
2535            .map(CodeUnit::fq_name)
2536            .collect();
2537        assert!(names.contains(&"pkg.model.Widget".to_string()), "{names:?}");
2538    }
2539
2540    #[test]
2541    fn setup_py_unwrap_parenthesized_skips_comments() {
2542        let source = "VALUE = (\n    # before\n    \"value\"  # after\n)\n";
2543        let tree = parse_python_tree(source).expect("Python fixture tree");
2544        let mut pending = vec![tree.root_node()];
2545        let parenthesized = loop {
2546            let node = pending.pop().expect("parenthesized expression exists");
2547            if node.kind() == "parenthesized_expression" {
2548                break node;
2549            }
2550            let mut cursor = node.walk();
2551            pending.extend(node.named_children(&mut cursor));
2552        };
2553
2554        assert_eq!(
2555            setup_py_unwrap_parenthesized(parenthesized).kind(),
2556            "string"
2557        );
2558    }
2559
2560    #[test]
2561    #[brokk_bifrost_test_macros::hardwon(
2562        "f4495e5c7c62: replacing a duplicate Python class left stale descendant callable metadata links"
2563    )]
2564    fn replacing_a_class_withdraws_descendant_metadata_links() {
2565        let parsed = parse_native(
2566            "from typing import overload\nclass Replaced:\n    def obsolete(self): pass\n    @overload\n    def current(self): pass\n    class Nested:\n        def stale(self): pass\n\nclass Kept:\n    def stable(self): pass\n\nclass Replaced:\n    def current(self): pass\n",
2567        );
2568        for bridge in &parsed.resolution_facts.definition_units {
2569            assert!(
2570                parsed.contains_declaration(&bridge.unit),
2571                "native definitions must not resurrect withdrawn descendants: {bridge:?}"
2572            );
2573        }
2574        let mut names = parsed
2575            .source_declaration_metadata
2576            .iter()
2577            .map(|link| {
2578                assert!(
2579                    parsed.signature_metadata[&link.unit]
2580                        .get(link.metadata_ordinal)
2581                        .is_some(),
2582                    "the link must name retained callable metadata: {link:?}"
2583                );
2584                link.unit.short_name().to_string()
2585            })
2586            .collect::<Vec<_>>();
2587        names.sort();
2588        assert_eq!(names, ["Kept.stable", "Replaced.current"]);
2589        let current = parsed
2590            .source_declaration_metadata
2591            .iter()
2592            .find(|link| link.unit.short_name() == "Replaced.current")
2593            .unwrap();
2594        assert!(
2595            !parsed.signature_metadata[&current.unit][current.metadata_ordinal]
2596                .is_declaration_only(),
2597            "a withdrawn overload must not mark the new implementation"
2598        );
2599        assert_eq!(
2600            parsed
2601                .source_declaration_units
2602                .iter()
2603                .filter(|(_, unit)| unit.short_name() == "Replaced.current")
2604                .count(),
2605            1,
2606            "the new method replaces even an already-withdrawn source link"
2607        );
2608    }
2609
2610    #[test]
2611    fn canonical_declarations_share_structural_name_identity_and_exact_ranges() {
2612        let source = "@decorate\nclass Item:\n    value = 1\n    value = 2\n    def write(self):\n        self.member = 1\n        self.member = 2\n\nfirst, second = 1, 2\n";
2613        let parsed = parse(source);
2614        let facts = parsed
2615            .source_facts
2616            .as_ref()
2617            .expect("canonical Python facts");
2618        for (id, unit) in &parsed.source_declaration_units {
2619            let declaration = facts.occurrences.declaration(*id);
2620            let range = facts.occurrences.occurrence(declaration.occurrence).range;
2621            assert!(parsed.ranges[unit].contains(&range), "{unit:?}: {range:?}");
2622            let name = declaration.name.expect("source declaration name");
2623            assert!(
2624                facts
2625                    .structural
2626                    .nodes()
2627                    .iter()
2628                    .any(|node| node.occurrence == name),
2629                "shared name identity for {unit:?}"
2630            );
2631        }
2632        let fields = parsed
2633            .source_declaration_units
2634            .iter()
2635            .filter(|(_, unit)| unit.short_name() == "Item.value")
2636            .collect::<Vec<_>>();
2637        assert_eq!(fields.len(), 2);
2638        assert_ne!(fields[0].0, fields[1].0);
2639        assert_eq!(
2640            parsed
2641                .source_declaration_units
2642                .iter()
2643                .filter(|(_, unit)| unit.short_name() == "Item.member")
2644                .count(),
2645            1
2646        );
2647        let class = parsed
2648            .source_declaration_units
2649            .iter()
2650            .find(|(_, unit)| unit.short_name() == "Item")
2651            .expect("decorated class");
2652        let range = facts
2653            .occurrences
2654            .occurrence(facts.occurrences.declaration(class.0).occurrence)
2655            .range;
2656        assert_eq!(range.start_byte, 0);
2657        assert_eq!(&source[range.start_byte..range.start_byte + 9], "@decorate");
2658    }
2659
2660    #[test]
2661    fn imports_keep_scope_order_and_late_overload_capture_without_local_pollution() {
2662        let source = "@overload\ndef public(value: int):\n    pass\nfrom typing import overload\n\ndef outer():\n    from typing import overload as local_only\n@local_only\ndef ordinary(value: int):\n    pass\nmatch 1:\n    case 1:\n        from .models import Item as Imported\n";
2663        let parsed = parse(source);
2664        let facts = parsed.source_facts.as_ref().expect("canonical imports");
2665        assert_eq!(facts.imports.len(), 3);
2666        assert!(
2667            facts.imports[0]
2668                .path
2669                .as_ref()
2670                .unwrap()
2671                .lexical_scopes
2672                .is_empty()
2673        );
2674        assert_eq!(
2675            facts.imports[1].path.as_ref().unwrap().lexical_scopes.len(),
2676            1
2677        );
2678        assert!(
2679            facts.imports[2]
2680                .path
2681                .as_ref()
2682                .unwrap()
2683                .lexical_scopes
2684                .is_empty()
2685        );
2686        for (id, import) in facts.generic_imports.iter().zip(&parsed.imports) {
2687            assert_eq!(
2688                &facts.imports[id.index()].import_info(&facts.occurrences),
2689                import
2690            );
2691        }
2692        let metadata = |name: &str| {
2693            parsed
2694                .signature_metadata
2695                .iter()
2696                .find(|(unit, _)| unit.short_name() == name)
2697                .unwrap()
2698                .1
2699        };
2700        assert!(metadata("public")[0].is_declaration_only());
2701        assert!(!metadata("ordinary")[0].is_declaration_only());
2702    }
2703
2704    #[test]
2705    #[brokk_bifrost_test_macros::hardwon(
2706        "1fa35d421feb: aliased import leaves pointed at imported-name ranges instead of the whole import statement"
2707    )]
2708    fn aliased_import_leaves_keep_the_statement_and_exact_binder_tokens() {
2709        let source = "from pkg import alpha as beta, beta as alpha\n";
2710        let parsed = parse(source);
2711        assert_eq!(parsed.imports.len(), 2);
2712        for (import, name) in parsed.imports.iter().zip(["beta", "alpha"]) {
2713            assert_eq!(import.path.as_ref().unwrap().declaration_start_byte, 0);
2714            let span = import.binder_span.as_ref().expect("exact alias binder");
2715            assert_eq!(&source[span.start_byte..span.end_byte], name);
2716        }
2717    }
2718
2719    #[test]
2720    fn parameter_offsets_use_ast_tokens_instead_of_annotation_spelling() {
2721        let source = "@decorate(\"(first, second)\")\ndef call(first: Literal[\"second\"], second: int):\n    pass\n";
2722        let parsed = parse(source);
2723        let metadata = parsed
2724            .signature_metadata
2725            .values()
2726            .flatten()
2727            .find(|metadata| metadata.parameters().len() == 2)
2728            .expect("parameter metadata");
2729        let second = &metadata.parameters()[1];
2730        assert_eq!(
2731            &metadata.label()[second.start_byte()..second.end_byte()],
2732            "second"
2733        );
2734        assert_eq!(
2735            second.start_byte(),
2736            metadata.label().rfind("second:").unwrap()
2737        );
2738    }
2739
2740    #[test]
2741    fn multiline_parameter_metadata_keeps_each_formal_name_at_its_ast_offset() {
2742        let source = concat!(
2743            "@decorate\n",
2744            "def call(\n",
2745            "    bare,\n",
2746            "    typed: T,\n",
2747            "    default=1,\n",
2748            "    typed_default: U = 2,\n",
2749            "    *args: V,\n",
2750            "    required_kw,\n",
2751            "    **kwargs: W,\n",
2752            "):\n",
2753            "    pass\n",
2754        );
2755        let parsed = parse(source);
2756        let metadata = parsed
2757            .signature_metadata
2758            .values()
2759            .flatten()
2760            .find(|metadata| metadata.parameters().len() == 7)
2761            .expect("multiline callable parameter metadata");
2762        let names = metadata
2763            .parameters()
2764            .iter()
2765            .map(|parameter| &metadata.label()[parameter.start_byte()..parameter.end_byte()])
2766            .collect::<Vec<_>>();
2767        assert_eq!(
2768            names,
2769            [
2770                "bare",
2771                "typed",
2772                "default",
2773                "typed_default",
2774                "args",
2775                "required_kw",
2776                "kwargs"
2777            ]
2778        );
2779        assert!(metadata.label().contains("    typed_default: U = 2,"));
2780        assert!(metadata.label().ends_with(" ..."));
2781    }
2782
2783    #[test]
2784    fn crlf_multiline_parameter_metadata_keeps_each_formal_name_at_its_ast_offset() {
2785        let source = concat!(
2786            "@decorate\r\n",
2787            "def call(\r\n",
2788            "    bare,\r\n",
2789            "    typed: T,\r\n",
2790            "    default=1,\r\n",
2791            "):\r\n",
2792            "    pass\r\n",
2793        );
2794        let parsed = parse(source);
2795        let metadata = parsed
2796            .signature_metadata
2797            .values()
2798            .flatten()
2799            .find(|metadata| metadata.parameters().len() == 3)
2800            .expect("multiline callable parameter metadata");
2801        let names = metadata
2802            .parameters()
2803            .iter()
2804            .map(|parameter| &metadata.label()[parameter.start_byte()..parameter.end_byte()])
2805            .collect::<Vec<_>>();
2806        assert_eq!(names, ["bare", "typed", "default"]);
2807    }
2808
2809    #[test]
2810    fn crlf_parameter_metadata_preserves_normalized_signature_offsets() {
2811        for indentation in ["", "    "] {
2812            let header = concat!(
2813                "@decorate\r\n",
2814                "def call(\r\n",
2815                "    first: T,\r\n",
2816                "    second: U = 2,\r\n",
2817                "):\r\n",
2818                "    pass\r\n",
2819            );
2820            let source = if indentation.is_empty() {
2821                header.to_owned()
2822            } else {
2823                format!(
2824                    "class Service:\r\n{}",
2825                    header
2826                        .split_inclusive('\n')
2827                        .map(|line| format!("{indentation}{line}"))
2828                        .collect::<String>()
2829                )
2830            };
2831            let parsed = parse(&source);
2832            let metadata = parsed
2833                .signature_metadata
2834                .values()
2835                .flatten()
2836                .find(|metadata| metadata.parameters().len() == 2)
2837                .expect("CRLF callable metadata");
2838            assert!(!metadata.label().contains('\r'));
2839            let labels = metadata
2840                .parameters()
2841                .iter()
2842                .map(|parameter| &metadata.label()[parameter.start_byte()..parameter.end_byte()])
2843                .collect::<Vec<_>>();
2844            assert_eq!(labels, ["first", "second"]);
2845        }
2846    }
2847
2848    #[test]
2849    #[brokk_bifrost_test_macros::hardwon(
2850        "#4455: multiline Python parameter offsets dropped a newline before an unindented label"
2851    )]
2852    fn multiline_parameter_offsets_follow_ast_ranges_through_header_normalization() {
2853        let source = concat!(
2854            "def manyargs(first: Annotated[int, 'second'] = second, second: int = 1,\r\n",
2855            "# comment between parameters\r\n",
2856            "third: int = second,\r\n",
2857            "/,\r\n",
2858            "*args: object,\r\n",
2859            "keyword: int = 2,\r\n",
2860            "**kwargs: object,\r\n",
2861            "):\r\n",
2862            "    pass\r\n",
2863            "def keyword_only(\r\n",
2864            "positional,\r\n",
2865            "*,\r\n",
2866            "required,\r\n",
2867            "):\r\n",
2868            "    pass\r\n",
2869            "class Service:\n",
2870            "\tdef tabbed(\n",
2871            "\t\tfirst,\n",
2872            "\t\tsecond,\n",
2873            "\t):\n",
2874            "\t\tpass\n",
2875        );
2876        let parsed = parse(source);
2877        let metadata = |name: &str| {
2878            parsed
2879                .signature_metadata
2880                .iter()
2881                .find(|(unit, _)| unit.short_name() == name)
2882                .unwrap_or_else(|| {
2883                    let available = parsed
2884                        .signature_metadata
2885                        .keys()
2886                        .map(|unit| unit.short_name())
2887                        .collect::<Vec<_>>();
2888                    panic!("callable metadata for {name}; available names: {available:?}");
2889                })
2890                .1
2891                .first()
2892                .expect("one signature")
2893        };
2894        let parameter_names = |name: &str| {
2895            let metadata = metadata(name);
2896            metadata
2897                .parameters()
2898                .iter()
2899                .map(|parameter| &metadata.label()[parameter.start_byte()..parameter.end_byte()])
2900                .collect::<Vec<_>>()
2901        };
2902
2903        let manyargs = metadata("manyargs");
2904        assert_eq!(
2905            parameter_names("manyargs"),
2906            ["first", "second", "third", "args", "keyword", "kwargs"]
2907        );
2908        assert!(
2909            manyargs
2910                .label()
2911                .contains("# comment between parameters\nthird: int")
2912        );
2913        assert!(manyargs.label().contains("\n/,\n*args: object"));
2914        assert!(!manyargs.label().contains('\r'));
2915        assert_eq!(parameter_names("keyword_only"), ["positional", "required"]);
2916        assert_eq!(parameter_names("Service.tabbed"), ["first", "second"]);
2917    }
2918
2919    #[test]
2920    fn empty_and_recovered_python_publish_canonical_structural_facts() {
2921        for source in [
2922            "",
2923            "class Broken(:\n    pass\ndef valid(value):\n    return value\n",
2924            "def broken(:\n    pass\n",
2925        ] {
2926            let parsed = parse(source);
2927            let facts = parsed.source_facts.expect("complete producer publication");
2928            assert_eq!(facts.source_bytes, source.len());
2929            assert!(facts.python.is_some());
2930            assert_eq!(parsed.resolution_facts, Default::default());
2931            assert!(facts.native_site_occurrences.is_empty());
2932            assert!(facts.native_declaration_sources.is_empty());
2933            assert!(
2934                facts
2935                    .occurrences
2936                    .occurrences()
2937                    .iter()
2938                    .all(|occurrence| occurrence.range.end_byte <= source.len())
2939            );
2940        }
2941    }
2942
2943    #[test]
2944    fn unmounted_annotated_functions_keep_source_declarations_and_return_facts() {
2945        let source = "if outer:\n    if inner:\n        def hidden(value: int) -> Hidden:\n            pass\n\ndef visible() -> Visible:\n    pass\n";
2946        let parsed = parse(source);
2947        let facts = parsed
2948            .source_facts
2949            .as_ref()
2950            .expect("canonical Python facts");
2951        let python = facts.python.as_ref().expect("Python source facts");
2952        assert_eq!(python.callable_returns.len(), 2);
2953
2954        let fact_named = |name: &str| {
2955            python
2956                .callable_returns
2957                .iter()
2958                .find(|fact| {
2959                    let declaration = facts.occurrences.declaration(fact.declaration);
2960                    let Some(name_occurrence) = declaration.name else {
2961                        return false;
2962                    };
2963                    let range = facts.occurrences.occurrence(name_occurrence).range;
2964                    &source[range.start_byte..range.end_byte] == name
2965                })
2966                .expect("callable return fact")
2967        };
2968        let hidden = fact_named("hidden");
2969        let hidden_declaration = facts.occurrences.declaration(hidden.declaration);
2970        let hidden_range = facts
2971            .occurrences
2972            .occurrence(hidden_declaration.occurrence)
2973            .range;
2974        assert_eq!(
2975            &source[hidden_range.start_byte..hidden_range.start_byte + 3],
2976            "def"
2977        );
2978        let hidden_annotation = hidden.return_annotation.expect("hidden annotation");
2979        let hidden_annotation_range = facts.occurrences.occurrence(hidden_annotation).range;
2980        assert_eq!(
2981            &source[hidden_annotation_range.start_byte..hidden_annotation_range.end_byte],
2982            "Hidden"
2983        );
2984        assert!(
2985            parsed
2986                .source_declaration_units
2987                .iter()
2988                .all(|(_, unit)| unit.short_name() != "hidden")
2989        );
2990
2991        let visible = fact_named("visible");
2992        assert!(visible.return_annotation.is_some());
2993        assert!(visible.runtime_type.is_some());
2994        assert!(
2995            parsed
2996                .source_declaration_units
2997                .iter()
2998                .any(|(_, unit)| unit.short_name() == "visible")
2999        );
3000        let visible_metadata = parsed
3001            .signature_metadata
3002            .iter()
3003            .find(|(unit, _)| unit.short_name() == "visible")
3004            .and_then(|(_, metadata)| metadata.first())
3005            .expect("visible signature metadata");
3006        assert_eq!(visible_metadata.return_type_text(), Some("Visible"));
3007        assert_eq!(
3008            visible_metadata.return_type_identity(),
3009            visible.runtime_type.as_ref()
3010        );
3011    }
3012
3013    #[test]
3014    fn return_fact_preserves_unsupported_annotation_without_runtime_identity() {
3015        let source = "def unsupported() -> Left | Right:\n    pass\n\ndef plain():\n    pass\n";
3016        let parsed = parse(source);
3017        let facts = parsed
3018            .source_facts
3019            .as_ref()
3020            .expect("canonical Python facts");
3021        let python = facts.python.as_ref().expect("Python source facts");
3022        let by_name = |name: &str| {
3023            python
3024                .callable_returns
3025                .iter()
3026                .find(|fact| {
3027                    let declaration = facts.occurrences.declaration(fact.declaration);
3028                    let Some(name_occurrence) = declaration.name else {
3029                        return false;
3030                    };
3031                    let range = facts.occurrences.occurrence(name_occurrence).range;
3032                    &source[range.start_byte..range.end_byte] == name
3033                })
3034                .expect("callable return fact")
3035        };
3036        let unsupported = by_name("unsupported");
3037        assert!(unsupported.return_annotation.is_some());
3038        assert!(unsupported.runtime_type.is_none());
3039        let plain = by_name("plain");
3040        assert!(plain.return_annotation.is_none());
3041        assert!(plain.runtime_type.is_none());
3042    }
3043
3044    #[test]
3045    #[brokk_bifrost_test_macros::hardwon(
3046        "45e71db39f13: generic Python annotations mistook a list element for the returned class owner"
3047    )]
3048    fn return_annotation_references_preserve_runtime_owners() {
3049        let source = "def generic() -> list[User]:\n    pass\n\ndef nested() -> list[pkg.User]:\n    pass\n\ndef direct() -> pkg.User:\n    pass\n\ndef quoted() -> \"pkg.User\":\n    pass\n\ndef unsupported() -> Left | Right:\n    pass\n";
3050        let parsed = parse(source);
3051        let facts = parsed
3052            .source_facts
3053            .as_ref()
3054            .expect("canonical Python facts");
3055        let python = facts.python.as_ref().expect("Python source facts");
3056        let fact_named = |name: &str| {
3057            python
3058                .callable_returns
3059                .iter()
3060                .find(|fact| {
3061                    let declaration = facts.occurrences.declaration(fact.declaration);
3062                    let Some(name_occurrence) = declaration.name else {
3063                        return false;
3064                    };
3065                    let range = facts.occurrences.occurrence(name_occurrence).range;
3066                    &source[range.start_byte..range.end_byte] == name
3067                })
3068                .expect("callable return fact")
3069        };
3070        let generic = fact_named("generic");
3071        assert_eq!(
3072            generic
3073                .annotation_references
3074                .iter()
3075                .map(|reference| &reference.name)
3076                .collect::<Vec<_>>(),
3077            vec![&PythonAnnotationReferenceName::Lexical("list".to_string())]
3078        );
3079
3080        // Generic arguments describe elements, not the returned runtime owner.
3081        let nested = fact_named("nested");
3082        assert_eq!(nested.annotation_references.len(), 1);
3083        assert!(matches!(
3084            &nested.annotation_references[0].name,
3085            PythonAnnotationReferenceName::Lexical(name) if name == "list"
3086        ));
3087
3088        let direct = fact_named("direct");
3089        assert_eq!(direct.annotation_references.len(), 3);
3090        assert_eq!(direct.annotation_references[0].subtree_end, 3);
3091        assert_eq!(direct.annotation_references[0].lookup_depth, 1);
3092        assert!(matches!(
3093            &direct.annotation_references[0].name,
3094            PythonAnnotationReferenceName::Qualified(path)
3095                if path == &vec!["pkg".to_string(), "User".to_string()]
3096        ));
3097
3098        let quoted = fact_named("quoted");
3099        assert_eq!(quoted.annotation_references.len(), 1);
3100        assert!(matches!(
3101            &quoted.annotation_references[0].name,
3102            PythonAnnotationReferenceName::Lexical(name) if name == "pkg.User"
3103        ));
3104        assert_eq!(quoted.annotation_references[0].lookup_depth, 2);
3105
3106        let unsupported = fact_named("unsupported");
3107        assert!(
3108            unsupported
3109                .annotation_references
3110                .iter()
3111                .all(|reference| !matches!(
3112                    &reference.name,
3113                    PythonAnnotationReferenceName::Qualified(_)
3114                ))
3115        );
3116    }
3117}
3118
3119/// Every target a possibly chained assignment binds.
3120///
3121/// Python's `encrypt = decrypt = process` binds both names, but the grammar
3122/// spells it as one `assignment` whose `right` is another `assignment`. Reading
3123/// only the outermost `left` declares `encrypt` and silently drops `decrypt`,
3124/// so the alias reads as an absent member on the class that defines it.
3125fn python_chained_assignment_targets<'tree>(assignment: Node<'tree>) -> Vec<Node<'tree>> {
3126    super::imports::python_assignment_chain(assignment).0
3127}
3128
3129#[cfg(test)]
3130mod supertype_tests {
3131    use super::extract_python_supertypes;
3132    use tree_sitter::Node;
3133
3134    fn class_node<'tree>(tree: &'tree tree_sitter::Tree, source: &str) -> Node<'tree> {
3135        let mut cursor = tree.root_node().walk();
3136        tree.root_node()
3137            .named_children(&mut cursor)
3138            .find(|node| node.kind() == "class_definition")
3139            .unwrap_or_else(|| panic!("source declares a class: {source}"))
3140    }
3141
3142    fn parse(source: &str) -> tree_sitter::Tree {
3143        super::parse_python_tree(source).expect("the source parses")
3144    }
3145
3146    #[test]
3147    #[brokk_bifrost_test_macros::hardwon(
3148        "50c1574b2070: unsupported Python positional bases were dropped and created false complete absence"
3149    )]
3150    fn every_positional_base_contributes_a_spelling() {
3151        for (source, expected) in [
3152            ("class A(Base): pass\n", vec!["Base"]),
3153            ("class A(pkg.Base): pass\n", vec!["pkg.Base"]),
3154            // The generic origin is the class the runtime inherits; dropping
3155            // a subscripted base made an incomplete hierarchy look complete.
3156            ("class A(Base[int]): pass\n", vec!["Base"]),
3157            ("class A(pkg.Base[str, int]): pass\n", vec!["pkg.Base"]),
3158            (
3159                "class A(Mapping[str, Any], Base): pass\n",
3160                vec!["Mapping", "Base"],
3161            ),
3162            // Not a base: a keyword argument configures class creation.
3163            ("class A(Base, metaclass=Meta): pass\n", vec!["Base"]),
3164            ("class A(metaclass=Meta): pass\n", Vec::new()),
3165            // Unnameable bases still register, so resolution reports an
3166            // unresolved base rather than a complete member list.
3167            (
3168                "class A(namedtuple(\"P\", \"x\")): pass\n",
3169                vec!["namedtuple(\"P\", \"x\")"],
3170            ),
3171            ("class A(*bases): pass\n", vec!["*bases"]),
3172            ("class A: pass\n", Vec::new()),
3173        ] {
3174            let tree = parse(source);
3175            let node = class_node(&tree, source);
3176            assert_eq!(
3177                extract_python_supertypes(node, source),
3178                expected,
3179                "supertypes of {source}"
3180            );
3181        }
3182    }
3183}
3184
3185#[cfg(test)]
3186mod callable_modifier_tests {
3187    use super::*;
3188
3189    /// The Python half of #3451. Every declaration this walk mints as a
3190    /// callable states whether it binds an instance receiver, read from its own
3191    /// decorator and definition nodes: `@staticmethod` and `@classmethod`
3192    /// remove the receiver, every other `def` in a class body keeps it, and a
3193    /// module-level or async function has none to keep. `receiver_contract_of`
3194    /// reports no contract at all for a callable whose adapter never inspected
3195    /// modifiers, so without this every Python procedure key is refused.
3196    ///
3197    /// The kinds that are deliberately not keyable are asserted here with their
3198    /// reason, so the coverage is the walk's decision rather than an omission:
3199    ///
3200    /// - `@property` is a `Field`, because Python reads it as an attribute; a
3201    ///   member whose unit kind is not a callable minted no key before and does
3202    ///   not now.
3203    /// - `assigned = lambda ...` is a `Field` too: the module walk records the
3204    ///   assignment as a field and never mints a callable declaration for the
3205    ///   lambda, so there are no signature modifiers to read and nothing for a
3206    ///   reviewed summary to bind.
3207    /// - `inner`, a function defined inside another function's body, is a local
3208    ///   binding of its enclosing function rather than a member of any owner, so
3209    ///   this walk mints no declaration, no `SignatureMetadata`, and no
3210    ///   procedure key for it.
3211    #[test]
3212    #[brokk_bifrost_test_macros::hardwon(
3213        "#3451: Python callable receiver modifiers were missing, leaving workspace procedures unkeyable"
3214    )]
3215    fn callable_metadata_records_python_receiver_contracts_structurally() {
3216        let source = "def free(value):\n    return value\n\nclass Widget:\n    def __init__(self, spec):\n        self.spec = spec\n\n    def render(self, target):\n        return target\n\n    @staticmethod\n    def build(spec):\n        return spec\n\n    @classmethod\n    def measure(cls, target):\n        return target\n\n    @property\n    def label(self):\n        return \"widget\"\n\nasync def fetch_all(url):\n    return url\n\ndef outer(seed):\n    def inner(value):\n        return value\n    return inner(seed)\n\nassigned = lambda value: value\n";
3217        let file = ProjectFile::new(std::env::temp_dir(), "widget.py");
3218        let tree = parse_python_tree(source).expect("parse the Python fixture");
3219        let parsed = parse_python_file(
3220            &brokk_bifrost_core::analyzer::project::TestProject::new(
3221                file.root(),
3222                brokk_bifrost_core::analyzer::Language::Python,
3223            ),
3224            &file,
3225            source,
3226            &tree,
3227        );
3228
3229        let entry = |fq_name: &str| {
3230            parsed
3231                .signature_metadata
3232                .iter()
3233                .find(|(unit, _)| unit.fq_name() == fq_name)
3234                .unwrap_or_else(|| {
3235                    panic!(
3236                        "missing Python declaration {fq_name}; recorded {:?}",
3237                        parsed
3238                            .signature_metadata
3239                            .keys()
3240                            .map(CodeUnit::fq_name)
3241                            .collect::<Vec<_>>()
3242                    )
3243                })
3244        };
3245        let modifiers = |fq_name: &str| {
3246            let (_, entries) = entry(fq_name);
3247            let metadata = entries
3248                .first()
3249                .unwrap_or_else(|| panic!("{fq_name} carries no signature metadata"));
3250            assert!(
3251                metadata.callable_modifiers_recorded(),
3252                "{fq_name} must record that the walk read its declaration shape"
3253            );
3254            (
3255                metadata.callable_is_static(),
3256                metadata.callable_is_constructor(),
3257                metadata.parameters().len(),
3258            )
3259        };
3260
3261        assert_eq!(modifiers("widget.free"), (false, false, 1));
3262        assert_eq!(
3263            modifiers("widget.Widget.__init__"),
3264            (false, false, 2),
3265            "`__init__` is an ordinary instance method, exactly as Ruby's `initialize`"
3266        );
3267        assert_eq!(modifiers("widget.Widget.render"), (false, false, 2));
3268        assert_eq!(
3269            modifiers("widget.Widget.build"),
3270            (true, false, 1),
3271            "`@staticmethod` binds no instance receiver"
3272        );
3273        assert_eq!(
3274            modifiers("widget.Widget.measure"),
3275            (true, false, 2),
3276            "`@classmethod` receives the class, not an instance"
3277        );
3278        assert_eq!(
3279            modifiers("widget.fetch_all"),
3280            (false, false, 1),
3281            "an async def is the same function_definition node"
3282        );
3283        assert_eq!(modifiers("widget.outer"), (false, false, 1));
3284
3285        let (property_unit, property_entries) = entry("widget.Widget.label");
3286        assert_eq!(
3287            property_unit.kind(),
3288            CodeUnitType::Field,
3289            "`@property` is read as an attribute, so it mints a Field and no key"
3290        );
3291        assert!(!property_unit.is_callable());
3292        assert!(
3293            property_entries
3294                .first()
3295                .expect("the property still carries signature metadata")
3296                .callable_modifiers_recorded(),
3297            "the shared metadata path records modifiers for every declaration it mints"
3298        );
3299
3300        // The assignment walk records `assigned` as a field through
3301        // `add_signature`, which mints no `SignatureMetadata` at all, so there
3302        // are no callable modifiers to record and nothing a reviewed summary
3303        // could bind.
3304        let lambda_unit = parsed
3305            .declarations()
3306            .iter()
3307            .find(|unit| unit.fq_name() == "widget.assigned")
3308            .expect("the module walk still records the name-bound lambda's assignment");
3309        assert_eq!(
3310            lambda_unit.kind(),
3311            CodeUnitType::Field,
3312            "a name-bound lambda is recorded as a field, not as a callable declaration"
3313        );
3314        assert!(
3315            !parsed.signature_metadata.contains_key(lambda_unit),
3316            "a field assignment carries signature text but no callable signature metadata"
3317        );
3318        assert!(
3319            parsed
3320                .declarations()
3321                .iter()
3322                .all(|unit| unit.terminal_name() != "inner"),
3323            "a function nested in another function is a local binding, not a declaration"
3324        );
3325    }
3326}