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_plain_string_literal,
6};
7use brokk_bifrost_core::analyzer::fq_name::{FqName, SegmentId, SegmentKind, segment_interner};
8use brokk_bifrost_core::analyzer::model::{
9    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::python_facts::{PythonCallableReturnFact, PythonSourceFacts};
16use brokk_bifrost_core::analyzer::rust_facts::RustItemSourceFacts;
17use brokk_bifrost_core::analyzer::source_facts::{
18    PrimarySourceFactCollector, SourceDeclarationId, SourceImportId,
19};
20use brokk_bifrost_core::analyzer::structural::callable::CallSiteContext;
21use brokk_bifrost_core::analyzer::structural::collector::StructuralFactCollector;
22use brokk_bifrost_core::analyzer::structural::resolution::DeclaredVisibility;
23use brokk_bifrost_core::analyzer::structural::spec::{CompiledKinds, StructuralSpec};
24use brokk_bifrost_core::analyzer::tree_walk::{ParentIndex, WalkControl, walk_named_tree_preorder};
25use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
26use brokk_bifrost_core::hash::{HashMap, HashSet};
27use brokk_bifrost_core::path_normalization::NormalizePath;
28use brokk_bifrost_core::text_utils::{compute_line_starts, find_line_index_for_offset};
29use std::path::{Path, PathBuf};
30use tree_sitter::{Node, Parser, Tree};
31
32use crate::source_properties::{python_annotation_references, python_return_type_identity};
33use crate::structural::PYTHON_STRUCTURAL_SPEC;
34
35/// Intern one qualified-name segment in the process-global interner.
36fn py_segment(text: &str, kind: SegmentKind) -> SegmentId {
37    segment_interner().intern(text, kind)
38}
39
40/// Build the structured module-path prefix for a Python declaration.
41///
42/// Ordinary modules render as a dotted path such as `mypkg.subpkg.mymodule`,
43/// with each original path component represented by one
44/// [`SegmentKind::Package`] segment. Hidden directories such as `.agent` and
45/// `.github` are also legal components in the analyzer's path-derived Python
46/// convention, but their leading dot is ambiguous after a rendered name has
47/// been joined.
48///
49/// Build the structured name from the file path's original components so
50/// hidden-directory segments stay intact in cold extraction, synthesized module
51/// units, and persisted reconstruction.
52pub fn python_module_fq(file: &ProjectFile) -> FqName {
53    python_module_fq_from_components(&python_module_components(file))
54}
55
56fn python_module_fq_from_components(components: &[String]) -> FqName {
57    let mut fq = FqName::new();
58    for component in components {
59        fq.push(py_segment(component, SegmentKind::Package));
60    }
61    fq
62}
63
64fn python_module_components(file: &ProjectFile) -> Vec<String> {
65    python_module_components_from_root(file, &python_import_root(file))
66}
67
68/// The module-name components `file`'s path yields when it is named from the
69/// project root, which is the longest name the workspace can give it:
70/// `dependency/core/lib/common/__init__.py` is
71/// `["dependency", "core", "lib", "common"]`.
72///
73/// A Python module name is relative to the `sys.path` entry an import of it
74/// resolves against, so one file has one valid absolute name per ancestor
75/// directory on that path, and each of them is a suffix of this one.
76/// [`python_import_root`] picks the single name that becomes the module's
77/// identity; this is what an import written against any deeper root has to be
78/// matched against (#3506).
79pub fn python_project_module_components(file: &ProjectFile) -> Vec<String> {
80    python_module_components_from_root(file, Path::new(""))
81}
82
83/// `file`'s module-name components relative to `import_root_rel`, which must be
84/// a prefix of the file's own relative path.
85fn python_module_components_from_root(file: &ProjectFile, import_root_rel: &Path) -> Vec<String> {
86    let mut components = python_package_components_for_file(file, import_root_rel);
87    let module_name = file
88        .rel_path()
89        .file_stem()
90        .and_then(|stem| stem.to_str())
91        .unwrap_or_default();
92    if module_name != "__init__" || components.is_empty() {
93        components.push(module_name.to_string());
94    }
95    components
96}
97
98/// The directory `file`'s path-derived Python module name is relative to: the
99/// analyzer's stand-in for the `sys.path` entry an absolute import of that
100/// module would be resolved against.
101///
102/// It is the nearest configured setuptools import root, else the parent of the
103/// outermost `__init__.py` package chain above the file, else the project root.
104/// The result is always a prefix of the file's own relative path, and it is
105/// what makes two same-named modules in one snapshot distinguishable:
106/// `registry/purview-registry/registry/models.py` and
107/// `registry/sql-registry/registry/models.py` are both the module
108/// `registry.models`, but they belong to different roots (#3475).
109pub fn python_import_root(file: &ProjectFile) -> PathBuf {
110    let Some(parent_rel) = file.rel_path().parent() else {
111        return PathBuf::new();
112    };
113    if parent_rel.as_os_str().is_empty() {
114        return PathBuf::new();
115    }
116
117    if let Some(import_root_rel) = python_configured_import_root(file, parent_rel) {
118        return import_root_rel;
119    }
120
121    let mut effective_package_root_rel: Option<&Path> = None;
122    let mut current_rel = Some(parent_rel);
123    while let Some(path) = current_rel {
124        if file.root().join(path).join("__init__.py").exists() {
125            effective_package_root_rel = Some(path);
126        }
127        current_rel = path.parent();
128    }
129
130    // A package root's own parent is the import root; a file in no package at
131    // all is named from the project root.
132    effective_package_root_rel
133        .and_then(Path::parent)
134        .unwrap_or_else(|| Path::new(""))
135        .to_path_buf()
136}
137
138fn python_package_components_for_file(file: &ProjectFile, import_root_rel: &Path) -> Vec<String> {
139    let Some(parent_rel) = file.rel_path().parent() else {
140        return Vec::new();
141    };
142    let relative_package = parent_rel
143        .strip_prefix(import_root_rel)
144        .expect("a Python import root is a prefix of the paths it names modules for");
145    path_components(relative_package)
146}
147
148/// Find the nearest setuptools import root that contains this source file.
149///
150/// `pyproject.toml` roots take precedence over legacy `setup.py` evidence at
151/// each ancestor. An unrelated or malformed packaging file does not change the
152/// existing `__init__.py` package-root convention.
153fn python_configured_import_root(file: &ProjectFile, parent_rel: &Path) -> Option<PathBuf> {
154    let mut manifest_dir_rel = Some(parent_rel);
155    while let Some(directory) = manifest_dir_rel {
156        let manifest_dir = file.root().join(directory);
157        let mut roots = setuptools_where_entries(&manifest_dir.join("pyproject.toml"))
158            .iter()
159            .map(|entry| manifest_dir.join(entry).normalize())
160            .filter_map(|root| root.strip_prefix(file.root()).ok().map(Path::to_path_buf))
161            .filter(|root| parent_rel.starts_with(root))
162            .collect::<Vec<_>>();
163        roots.sort_by_key(|root| root.components().count());
164        if let Some(root) = roots.pop() {
165            return Some(root);
166        }
167        if let Some(package_dir) = setuptools_setup_py_import_root(&manifest_dir.join("setup.py")) {
168            let root = manifest_dir.join(package_dir).normalize();
169            if let Ok(root) = root.strip_prefix(file.root())
170                && parent_rel.starts_with(root)
171            {
172                return Some(root.to_path_buf());
173            }
174        }
175        manifest_dir_rel = directory.parent();
176    }
177    None
178}
179
180/// The root a packaging manifest declares for this file, whether that root is
181/// the project root itself or a directory below it.
182///
183/// `pyproject.toml`'s `tool.setuptools.packages.find.where` and `setup.py`'s
184/// `package_dir` state where a project's packages begin: a file below one of
185/// them is named from that directory and from nowhere else. `where = ["."]`
186/// declares the project root, and a declaration of the project root is a
187/// declaration like any other: it says this project's packages begin here, so a
188/// shorter spelling below that root names a different arrangement of the same
189/// directories. Only a project whose packaging configuration does not cover
190/// this file answers `None` (#3506).
191pub fn python_declared_import_root(file: &ProjectFile) -> Option<PathBuf> {
192    let parent_rel = file.rel_path().parent()?;
193    if parent_rel.as_os_str().is_empty() {
194        return None;
195    }
196    python_configured_import_root(file, parent_rel)
197}
198
199/// The directory the package a repeated spelling starts from has to add to its
200/// own `__path__` for `source_root_rel` to be a root the specifier is written
201/// against, or `None` when the specifier does not spell that root's own path
202/// again at its head (#3506).
203///
204/// A packaging declaration states where a project's packages begin, so a
205/// shorter spelling below it normally names a different arrangement of the same
206/// directories rather than the module: `otherpkg/vendor/api.py` under
207/// `where = ["."]` is `otherpkg.vendor.api`, and `vendor.api`, whose implied
208/// root is `otherpkg`, is not another name for it. The double-nested
209/// distribution directory is the one shape a declaration leaves open, because
210/// the specifier writes the implied root's own path again before the module's
211/// path: from `rtdetr_pose/`, `rtdetr_pose.config` names `config.py` in the
212/// same-named package that directory holds, the file the workspace indexes as
213/// `rtdetr_pose.rtdetr_pose.config`.
214///
215/// The shape is a naming question only, and it is not evidence that the import
216/// works. The interpreter finds the module through the package the specifier
217/// starts from -- `rtdetr_pose/__init__.py` -- and only that package's own
218/// statement that `rtdetr_pose/rtdetr_pose` is on its search path makes the
219/// shorter spelling name the file. The repeated directory name and the empty
220/// markers that make the directories packages state nothing on their own, so
221/// this returns the directory such a statement has to name, relative to the
222/// project root, and the snapshot decides whether the marker states it.
223pub(crate) fn python_repeated_root_extension_dir(
224    file: &ProjectFile,
225    source_root_rel: &Path,
226    declared_root_rel: &Path,
227) -> Option<PathBuf> {
228    let implied_rel = source_root_rel.strip_prefix(declared_root_rel).ok()?;
229    let implied = path_components(implied_rel);
230    if implied.is_empty()
231        || !python_module_components_from_root(file, source_root_rel).starts_with(&implied)
232    {
233        return None;
234    }
235    Some(source_root_rel.join(implied_rel))
236}
237
238#[derive(Clone, Copy, PartialEq, Eq)]
239struct FileStamp {
240    len: u64,
241    modified: Option<std::time::SystemTime>,
242}
243
244fn file_stamp(path: &Path) -> Option<FileStamp> {
245    let metadata = std::fs::metadata(path).ok()?;
246    Some(FileStamp {
247        len: metadata.len(),
248        modified: metadata.modified().ok(),
249    })
250}
251
252/// One manifest's memoized `tool.setuptools.packages.find.where` entries.
253///
254/// The stamp is what the memo is validated against, so a manifest edited in a
255/// long-running server is re-read instead of being answered from a stale parse.
256struct ManifestWhereEntries {
257    stamp: FileStamp,
258    entries: Vec<String>,
259}
260
261/// Read the setuptools package-discovery roots declared by one `pyproject.toml`.
262///
263/// Module identity is resolved once per declaration, not once per file, so this
264/// sits on a hot path: a full read plus TOML parse per call made every Python
265/// identity question proportional to the size of the nearest manifest. The
266/// parse is therefore memoized per manifest and revalidated with one `stat`,
267/// which keeps an edited manifest honored while the steady-state cost is a
268/// metadata probe. An absent, unreadable, malformed, or non-setuptools manifest
269/// declares no roots and leaves the `__init__.py` package-root convention in
270/// charge.
271fn setuptools_where_entries(manifest: &Path) -> Vec<String> {
272    static MEMO: std::sync::OnceLock<
273        std::sync::RwLock<std::collections::HashMap<PathBuf, ManifestWhereEntries>>,
274    > = std::sync::OnceLock::new();
275    let memo = MEMO.get_or_init(Default::default);
276
277    let Some(stamp) = file_stamp(manifest) else {
278        return Vec::new();
279    };
280    if let Some(cached) = memo.read().expect("manifest memo").get(manifest)
281        && cached.stamp == stamp
282    {
283        return cached.entries.clone();
284    }
285
286    let entries = parse_setuptools_where_entries(manifest);
287    memo.write().expect("manifest memo").insert(
288        manifest.to_path_buf(),
289        ManifestWhereEntries {
290            stamp,
291            entries: entries.clone(),
292        },
293    );
294    entries
295}
296
297/// A memoized static package root recovered from one legacy `setup.py`.
298/// `Some(PathBuf::new())` represents the setup script's directory, which is
299/// setuptools' default when no `package_dir` is supplied.
300struct SetupPyImportRoot {
301    stamp: FileStamp,
302    package_dir: Option<PathBuf>,
303}
304
305/// Read a legacy setuptools import root without executing `setup.py`.
306///
307/// This is intentionally narrower than Python's runtime packaging semantics:
308/// a direct top-level call to an imported setuptools or distutils.core
309/// `setup` binding must provide a `packages` argument. A literal `package_dir`
310/// establishes the root regardless of the package expression; without one,
311/// the package expression must be either a nonempty literal collection or a
312/// supported setuptools discovery call. Unsupported argument shapes or a
313/// shadowed import leave the source path-derived.
314fn setuptools_setup_py_import_root(setup_py: &Path) -> Option<PathBuf> {
315    static MEMO: std::sync::OnceLock<
316        std::sync::RwLock<std::collections::HashMap<PathBuf, SetupPyImportRoot>>,
317    > = std::sync::OnceLock::new();
318    let memo = MEMO.get_or_init(Default::default);
319
320    let stamp = file_stamp(setup_py)?;
321    if let Some(cached) = memo.read().expect("setup.py memo").get(setup_py)
322        && cached.stamp == stamp
323    {
324        return cached.package_dir.clone();
325    }
326
327    let package_dir = parse_setuptools_setup_py_import_root(setup_py);
328    memo.write().expect("setup.py memo").insert(
329        setup_py.to_path_buf(),
330        SetupPyImportRoot {
331            stamp,
332            package_dir: package_dir.clone(),
333        },
334    );
335    package_dir
336}
337
338fn parse_setuptools_setup_py_import_root(setup_py: &Path) -> Option<PathBuf> {
339    let source = std::fs::read_to_string(setup_py).ok()?;
340    let tree = parse_python_tree(&source)?;
341    let root = tree.root_node();
342    if root.has_error() {
343        return None;
344    }
345    let mut import_root = None;
346    for (call, setup_bindings) in setup_py_setup_calls(&source, root) {
347        let candidate = setup_py_import_root_from_call(call, &source, &setup_bindings)?;
348        if import_root
349            .replace(candidate.clone())
350            .is_some_and(|root| root != candidate)
351        {
352            return None;
353        }
354    }
355    import_root
356}
357
358/// Read the `python_requires` specifier a legacy `setup.py` declares.
359///
360/// This is setuptools' spelling of `pyproject.toml`'s `requires-python`, and it
361/// is what selects the standard-library semantic pack for a project that has no
362/// PEP 621 manifest. The script is read, never executed: only a literal string
363/// passed to a top-level call to an imported `setup` binding counts, and two
364/// calls that disagree declare nothing.
365pub fn setuptools_setup_py_python_requires(setup_py: &Path) -> Option<String> {
366    let source = std::fs::read_to_string(setup_py).ok()?;
367    let tree = parse_python_tree(&source)?;
368    let root = tree.root_node();
369    if root.has_error() {
370        return None;
371    }
372    let mut requirement: Option<String> = None;
373    for (call, _) in setup_py_setup_calls(&source, root) {
374        let Some(arguments) = call.child_by_field_name("arguments") else {
375            continue;
376        };
377        if arguments.kind() != "argument_list" {
378            continue;
379        }
380        let mut cursor = arguments.walk();
381        for argument in arguments.named_children(&mut cursor) {
382            if argument.kind() != "keyword_argument" {
383                continue;
384            }
385            let Some(name) = argument.child_by_field_name("name") else {
386                continue;
387            };
388            if py_node_text(name, &source).trim() != "python_requires" {
389                continue;
390            }
391            let value = argument.child_by_field_name("value")?;
392            let declared = python_plain_string_literal(value, &source)?.to_owned();
393            if requirement
394                .replace(declared.clone())
395                .is_some_and(|previous| previous != declared)
396            {
397                return None;
398            }
399        }
400    }
401    requirement
402}
403
404/// Every top-level call to an imported setuptools `setup` binding, paired with
405/// the import bindings that were live where the call appears.
406///
407/// The walk tracks bindings across the module's top-level statements, so a name
408/// a later statement rebinds stops being read as setuptools' `setup`. It does
409/// not enter function, class, or lambda bodies: a call there is conditional on
410/// something this reader does not evaluate.
411fn setup_py_setup_calls<'tree>(
412    source: &str,
413    root: Node<'tree>,
414) -> Vec<(Node<'tree>, HashMap<Vec<String>, String>)> {
415    let mut setup_bindings: HashMap<Vec<String>, String> = HashMap::default();
416    let mut calls = Vec::new();
417    let mut cursor = root.walk();
418
419    for statement in root.named_children(&mut cursor) {
420        if matches!(
421            statement.kind(),
422            "import_statement" | "import_from_statement"
423        ) {
424            for binding in setup_py_bound_names(statement, source) {
425                setup_bindings.retain(|path, _| path.first() != Some(&binding));
426            }
427            for import in python_import_infos_from_node(statement, source) {
428                if import.is_wildcard {
429                    setup_bindings.clear();
430                    continue;
431                }
432                let Some(path) = import.path else { continue };
433                let segments = path.segments.iter().map(String::as_str).collect::<Vec<_>>();
434                match path.kind {
435                    Some(StructuredImportPathKind::ImportFrom) => {
436                        let function_name = match segments.as_slice() {
437                            ["setuptools", "setup"] | ["distutils", "core", "setup"] => "setup",
438                            ["setuptools", "find_packages"] => "find_packages",
439                            ["setuptools", "find_namespace_packages"] => "find_namespace_packages",
440                            _ => continue,
441                        };
442                        setup_bindings.insert(
443                            vec![import.identifier.expect("imported function binds a name")],
444                            function_name.to_string(),
445                        );
446                    }
447                    Some(StructuredImportPathKind::Namespace) => {
448                        let function_names = match segments.as_slice() {
449                            ["setuptools"] => {
450                                ["setup", "find_packages", "find_namespace_packages"].as_slice()
451                            }
452                            ["distutils", "core"] => ["setup"].as_slice(),
453                            _ => continue,
454                        };
455                        let binding_prefix = import
456                            .alias
457                            .map(|alias| vec![alias])
458                            .unwrap_or_else(|| path.segments.clone());
459                        for function_name in function_names {
460                            let mut callable = binding_prefix.clone();
461                            callable.push((*function_name).to_string());
462                            setup_bindings.insert(callable, (*function_name).to_string());
463                        }
464                    }
465                    _ => continue,
466                }
467            }
468            continue;
469        }
470
471        if statement.kind() == "expression_statement"
472            && statement.named_child_count() == 1
473            && let Some(call) = statement.named_child(0)
474            && call.kind() == "call"
475            && setup_py_call_imported_function(call, source, &setup_bindings)
476                .is_some_and(|function| function == "setup")
477        {
478            calls.push((call, setup_bindings.clone()));
479        }
480
481        for binding in setup_py_bound_names(statement, source) {
482            setup_bindings.retain(|path, _| path.first() != Some(&binding));
483        }
484    }
485    calls
486}
487
488/// Return names that a top-level statement binds in the module scope.
489///
490/// The walk is iterative and does not enter function, class, or lambda bodies.
491/// Bindings in control-flow statements still invalidate an imported setup name:
492/// their execution is conditional, so retaining the import would overclaim its
493/// identity at a later top-level call.
494fn setup_py_bound_names(statement: Node<'_>, source: &str) -> Vec<String> {
495    let mut names = Vec::new();
496    let mut pending = vec![statement];
497    while let Some(node) = pending.pop() {
498        for binding in python_direct_scope_bindings_bounded(node, source, || true)
499            .expect("unbounded setup.py binding walk")
500        {
501            let name = py_node_text(binding.declaration, source).trim();
502            if !name.is_empty() {
503                names.push(name.to_string());
504            }
505        }
506        let excluded_body = matches!(
507            node.kind(),
508            "function_definition" | "class_definition" | "lambda"
509        )
510        .then(|| node.child_by_field_name("body").map(|body| body.id()))
511        .flatten();
512        let mut cursor = node.walk();
513        pending.extend(
514            node.named_children(&mut cursor)
515                .filter(|child| Some(child.id()) != excluded_body),
516        );
517    }
518    names
519}
520
521fn setup_py_call_imported_function<'a>(
522    call: Node<'_>,
523    source: &str,
524    setup_bindings: &'a HashMap<Vec<String>, String>,
525) -> Option<&'a str> {
526    let mut function = call.child_by_field_name("function")?;
527    let mut path = Vec::new();
528    while function.kind() == "attribute" {
529        let attribute = function.child_by_field_name("attribute")?;
530        path.push(py_node_text(attribute, source).to_string());
531        let object = function.child_by_field_name("object")?;
532        function = object;
533    }
534    if function.kind() != "identifier" {
535        return None;
536    }
537    path.push(py_node_text(function, source).to_string());
538    path.reverse();
539    setup_bindings.get(&path).map(String::as_str)
540}
541
542fn setup_py_import_root_from_call(
543    call: Node<'_>,
544    source: &str,
545    setup_bindings: &HashMap<Vec<String>, String>,
546) -> Option<PathBuf> {
547    let arguments = call.child_by_field_name("arguments")?;
548    if arguments.kind() != "argument_list" {
549        return None;
550    }
551
552    let mut packages = None;
553    let mut package_dir = None;
554    let mut cursor = arguments.walk();
555    for argument in arguments.named_children(&mut cursor) {
556        if argument.kind() == "comment" {
557            continue;
558        }
559        // Positional dictionaries and expansions can supply packaging options.
560        if argument.kind() != "keyword_argument" {
561            return None;
562        }
563        let name = argument.child_by_field_name("name")?;
564        let value = argument.child_by_field_name("value")?;
565        match py_node_text(name, source).trim() {
566            "packages" if packages.is_none() => packages = Some(value),
567            "packages" => return None,
568            "package_dir" if package_dir.is_none() => package_dir = Some(value),
569            "package_dir" => return None,
570            _ => {}
571        }
572    }
573
574    let packages = packages?;
575    if let Some(package_dir) = package_dir {
576        return setup_py_static_package_dir(package_dir, source);
577    }
578    if setup_py_nonempty_literal_packages(packages, source) {
579        return Some(PathBuf::new());
580    }
581    setup_py_discovery_root_from_call(packages, source, setup_bindings)
582}
583
584fn setup_py_discovery_root_from_call(
585    call: Node<'_>,
586    source: &str,
587    setup_bindings: &HashMap<Vec<String>, String>,
588) -> Option<PathBuf> {
589    let function = setup_py_call_imported_function(call, source, setup_bindings)?;
590    if !matches!(function, "find_packages" | "find_namespace_packages") {
591        return None;
592    }
593    let arguments = call.child_by_field_name("arguments")?;
594    if arguments.kind() != "argument_list" {
595        return None;
596    }
597
598    let mut where_value = None;
599    let mut seen_exclude = false;
600    let mut seen_include = false;
601    let mut positional_index = 0;
602    let mut cursor = arguments.walk();
603    for argument in arguments.named_children(&mut cursor) {
604        if argument.kind() == "comment" {
605            continue;
606        }
607        if matches!(argument.kind(), "list_splat" | "dictionary_splat") {
608            return None;
609        }
610        if argument.kind() == "keyword_argument" {
611            let name = py_node_text(argument.child_by_field_name("name")?, source).trim();
612            let value = argument.child_by_field_name("value")?;
613            match name {
614                "where" if where_value.is_none() => where_value = Some(value),
615                "where" => return None,
616                "exclude" if !seen_exclude => seen_exclude = true,
617                "exclude" => return None,
618                "include" if !seen_include => seen_include = true,
619                "include" => return None,
620                _ => return None,
621            }
622            continue;
623        }
624
625        let slot = positional_index;
626        positional_index += 1;
627        match slot {
628            0 if where_value.is_none() => where_value = Some(argument),
629            0 => return None,
630            1 if !seen_exclude => seen_exclude = true,
631            1 => return None,
632            2 if !seen_include => seen_include = true,
633            2 => return None,
634            _ => return None,
635        }
636    }
637
638    where_value
639        .map(|value| python_plain_string_literal(value, source))
640        .unwrap_or(Some(""))
641        .map(PathBuf::from)
642}
643
644fn setup_py_nonempty_literal_packages(value: Node<'_>, source: &str) -> bool {
645    let value = setup_py_unwrap_parenthesized(value);
646    if !matches!(value.kind(), "list" | "set" | "tuple") {
647        return false;
648    }
649    let mut cursor = value.walk();
650    let mut nonempty = false;
651    for element in value.named_children(&mut cursor) {
652        if element.kind() == "comment" {
653            continue;
654        }
655        let Some(package) = python_plain_string_literal(element, source) else {
656            return false;
657        };
658        if package.is_empty() {
659            return false;
660        }
661        nonempty = true;
662    }
663    nonempty
664}
665
666fn setup_py_static_package_dir(value: Node<'_>, source: &str) -> Option<PathBuf> {
667    let value = setup_py_unwrap_parenthesized(value);
668    if value.kind() != "dictionary" {
669        return None;
670    }
671    let mut cursor = value.walk();
672    let mut pairs = value
673        .named_children(&mut cursor)
674        .filter(|node| node.kind() != "comment");
675    let Some(pair) = pairs.next() else {
676        return Some(PathBuf::new());
677    };
678    if pairs.next().is_some() || pair.kind() != "pair" {
679        return None;
680    }
681    let key = python_plain_string_literal(pair.child_by_field_name("key")?, source)?;
682    if !key.is_empty() {
683        return None;
684    }
685    let root = python_plain_string_literal(pair.child_by_field_name("value")?, source)?;
686    let root = PathBuf::from(root);
687    (!root.is_absolute()).then_some(root)
688}
689
690fn setup_py_unwrap_parenthesized(mut node: Node<'_>) -> Node<'_> {
691    while node.kind() == "parenthesized_expression" && node.named_child_count() == 1 {
692        node = node.named_child(0).expect("parenthesized expression child");
693    }
694    node
695}
696
697fn parse_setuptools_where_entries(manifest: &Path) -> Vec<String> {
698    let Ok(source) = std::fs::read_to_string(manifest) else {
699        return Vec::new();
700    };
701    let Ok(document) = source.parse::<toml::Value>() else {
702        return Vec::new();
703    };
704    document
705        .get("tool")
706        .and_then(|tool| tool.get("setuptools"))
707        .and_then(|setuptools| setuptools.get("packages"))
708        .and_then(|packages| packages.get("find"))
709        .and_then(|find| find.get("where"))
710        .and_then(toml::Value::as_array)
711        .map(|entries| {
712            entries
713                .iter()
714                .filter_map(toml::Value::as_str)
715                .map(str::to_string)
716                .collect()
717        })
718        .unwrap_or_default()
719}
720
721fn path_components(path: &Path) -> Vec<String> {
722    path.components()
723        .map(|component| component.as_os_str().to_string_lossy().to_string())
724        .filter(|component| !component.is_empty())
725        .collect()
726}
727
728pub fn python_is_decorated_function_boundary(node: Node<'_>) -> bool {
729    if node.kind() != "decorated_definition" {
730        return false;
731    }
732    let mut cursor = node.walk();
733    node.named_children(&mut cursor)
734        .any(|child| child.kind() == "function_definition")
735}
736
737#[derive(Clone)]
738pub struct Scope {
739    kind: ScopeKind,
740    path: String,
741    /// The structured qualified name matching `path` (M1 dual representation;
742    /// see `.agents/plans/fqname-interned-segments.md`). Tracked independent of
743    /// whether this scope level was actually `capture`d as a `CodeUnit`, so a
744    /// nested class/function that IS captured can always extend an ancestor's
745    /// `fq` even when an intermediate scope level (e.g. a non-captured nested
746    /// function) has no `code_unit` of its own to read `.fq()` from.
747    fq: FqName,
748    code_unit: Option<CodeUnit>,
749    method_receiver: Option<String>,
750}
751
752#[derive(Clone, Copy, PartialEq, Eq)]
753enum ScopeKind {
754    Class,
755    Function,
756}
757
758pub struct PythonVisitor<'source, 'parsed, 'structural>
759where
760    'source: 'structural,
761{
762    pub file: &'parsed ProjectFile,
763    pub source: &'source str,
764    pub package_name: &'parsed str,
765    module_fq: &'parsed FqName,
766    pub parsed: &'parsed mut ParsedFile,
767    pub module: Option<CodeUnit>,
768    overload_decorators: PythonOverloadDecoratorBindings,
769    source_facts: PrimarySourceFactCollector<'source>,
770    structural: StructuralFactCollector<'structural, 'structural>,
771    structural_kinds: &'structural CompiledKinds,
772    call_site_context: &'structural CallSiteContext,
773    structural_parents: Vec<Option<u32>>,
774    source_imports: Vec<SourceImportFact>,
775    generic_imports: Vec<SourceImportId>,
776    source_declaration_units: Vec<(SourceDeclarationId, CodeUnit)>,
777    python_source_facts: PythonSourceFacts,
778    python_callable_return_by_node: HashMap<usize, usize>,
779    overload_metadata: Vec<(CodeUnit, usize, Vec<PythonOverloadDecoratorName>)>,
780}
781
782struct PythonFrame<'tree> {
783    node: Node<'tree>,
784    scope: Vec<Scope>,
785    module_control_depth: usize,
786    declaration: bool,
787    wrapper: Option<Node<'tree>>,
788    lexical_scopes: Vec<Node<'tree>>,
789}
790
791enum PythonWork<'tree> {
792    Enter(PythonFrame<'tree>),
793    Exit,
794}
795
796struct DeclarationChildren<'tree> {
797    body: Option<(Node<'tree>, Vec<Scope>)>,
798    container_scope: Option<Vec<Scope>>,
799    container_depth: usize,
800}
801
802impl<'source, 'parsed, 'structural> PythonVisitor<'source, 'parsed, 'structural>
803where
804    'source: 'structural,
805{
806    pub fn visit_container(
807        &mut self,
808        node: Node<'_>,
809        scope: &[Scope],
810        module_control_depth: usize,
811    ) {
812        let mut stack = vec![PythonWork::Enter(PythonFrame {
813            node,
814            scope: scope.to_vec(),
815            module_control_depth,
816            declaration: true,
817            wrapper: None,
818            lexical_scopes: Vec::new(),
819        })];
820        while let Some(work) = stack.pop() {
821            let PythonWork::Enter(frame) = work else {
822                self.structural_parents
823                    .pop()
824                    .expect("Python structural parent stack must balance");
825                continue;
826            };
827            let structural_parent = self.structural_parents.last().copied().flatten();
828            let structural_id = self.admit_structural(frame.node, structural_parent);
829            self.structural_parents
830                .push(structural_id.or(structural_parent));
831
832            if frame.node.kind() == "identifier" {
833                let text = py_node_text(frame.node, self.source).trim();
834                if !text.is_empty() {
835                    self.parsed.type_identifiers.insert(text.to_string());
836                }
837            }
838
839            if frame.node.kind() == "function_definition" {
840                self.capture_function_return_fact(frame.node, frame.wrapper);
841            }
842
843            if matches!(
844                frame.node.kind(),
845                "import_statement" | "import_from_statement"
846            ) {
847                self.visit_import_statement(frame.node, &frame.lexical_scopes);
848            }
849
850            let declaration_children = if frame.declaration {
851                self.visit_statement(
852                    frame.node,
853                    &frame.scope,
854                    frame.module_control_depth,
855                    frame.wrapper,
856                )
857            } else {
858                DeclarationChildren {
859                    body: None,
860                    container_scope: None,
861                    container_depth: frame.module_control_depth,
862                }
863            };
864
865            let mut children = Vec::new();
866            let mut cursor = frame.node.walk();
867            for child in frame.node.named_children(&mut cursor) {
868                let mut child_scope = frame.scope.clone();
869                let mut declaration = false;
870                let mut wrapper = None;
871                let mut child_depth = frame.module_control_depth;
872
873                if let Some((body, scope)) = &declaration_children.body
874                    && child.id() == body.id()
875                {
876                    declaration = true;
877                    child_scope = scope.clone();
878                } else if let Some(scope) = &declaration_children.container_scope {
879                    declaration = true;
880                    child_scope = scope.clone();
881                    child_depth = declaration_children.container_depth;
882                } else if frame.declaration
883                    && frame.node.kind() == "decorated_definition"
884                    && matches!(child.kind(), "class_definition" | "function_definition")
885                {
886                    // The wrapper owns the source range, while its concrete
887                    // definition owns the declaration semantics.
888                    declaration = true;
889                    wrapper = Some(frame.node);
890                }
891
892                let mut lexical_scopes = frame.lexical_scopes.clone();
893                if matches!(
894                    frame.node.kind(),
895                    "class_definition" | "function_definition" | "lambda"
896                ) {
897                    lexical_scopes.push(frame.node);
898                }
899                children.push(PythonFrame {
900                    node: child,
901                    scope: child_scope,
902                    module_control_depth: child_depth,
903                    declaration,
904                    wrapper,
905                    lexical_scopes,
906                });
907            }
908
909            stack.push(PythonWork::Exit);
910            stack.extend(children.into_iter().rev().map(PythonWork::Enter));
911        }
912    }
913
914    fn admit_structural(&mut self, node: Node<'_>, parent: Option<u32>) -> Option<u32> {
915        if !node.is_named() {
916            return parent;
917        }
918        let Some(raw_kind) = self.structural_kinds.kind_of(&node) else {
919            return parent;
920        };
921        if !PYTHON_STRUCTURAL_SPEC.should_extract(node, raw_kind) {
922            return parent;
923        }
924        let kind = PYTHON_STRUCTURAL_SPEC.refine_kind(
925            node,
926            raw_kind,
927            parent.map(|id| self.structural.normalized_kind(id)),
928            self.source,
929            self.call_site_context,
930        );
931        let fact_id = self
932            .structural
933            .enter(node, kind, parent, &mut self.source_facts)
934            .expect("Python structural fact collection must remain unbounded");
935        let mut sink = self.structural.role_sink(&mut self.source_facts);
936        PYTHON_STRUCTURAL_SPEC.extract(node, kind, &mut sink);
937        self.structural
938            .accept_roles(fact_id, sink.into_parts())
939            .expect("Python structural role collection must remain unbounded");
940        Some(fact_id)
941    }
942
943    fn replace_source_declaration(
944        &mut self,
945        declaration_node: Node<'_>,
946        name_node: Option<Node<'_>>,
947        unit: CodeUnit,
948        metadata_ordinal: Option<usize>,
949    ) -> SourceDeclarationId {
950        self.source_declaration_units
951            .retain(|(_, existing)| existing != &unit);
952        self.parsed
953            .source_declaration_metadata
954            .retain(|link| link.unit != unit);
955        self.record_source_declaration(declaration_node, name_node, unit, metadata_ordinal)
956    }
957
958    fn record_source_declaration(
959        &mut self,
960        declaration_node: Node<'_>,
961        name_node: Option<Node<'_>>,
962        unit: CodeUnit,
963        metadata_ordinal: Option<usize>,
964    ) -> SourceDeclarationId {
965        let occurrence = self.source_facts.intern_node(declaration_node);
966        let name = name_node.map(|node| self.source_facts.intern_node(node));
967        let declaration = self.source_facts.declare(occurrence, name);
968        self.source_declaration_units
969            .push((declaration, unit.clone()));
970        if let Some(metadata_ordinal) = metadata_ordinal {
971            self.parsed
972                .source_declaration_metadata
973                .push(SourceDeclarationMetadataLink {
974                    declaration,
975                    unit,
976                    metadata_ordinal,
977                });
978        }
979        declaration
980    }
981
982    fn capture_function_return_fact<'tree>(
983        &mut self,
984        node: Node<'tree>,
985        wrapper: Option<Node<'tree>>,
986    ) {
987        let declaration_node = wrapper
988            .or_else(|| {
989                node.parent()
990                    .filter(|parent| parent.kind() == "decorated_definition")
991            })
992            .unwrap_or(node);
993        let occurrence = self.source_facts.intern_node(declaration_node);
994        let name = node
995            .child_by_field_name("name")
996            .map(|name| self.source_facts.intern_node(name));
997        let declaration = self.source_facts.declare(occurrence, name);
998        let return_annotation = node
999            .child_by_field_name("return_type")
1000            .map(|annotation| self.source_facts.intern_node(annotation));
1001        let annotation_references = node
1002            .child_by_field_name("return_type")
1003            .map(|annotation| {
1004                python_annotation_references(annotation, self.source, &mut self.source_facts)
1005            })
1006            .unwrap_or_default();
1007        let runtime_type = node
1008            .child_by_field_name("return_type")
1009            .and_then(|annotation| python_return_type_identity(annotation, self.source));
1010        let fact_index = self.python_source_facts.callable_returns.len();
1011        self.python_source_facts
1012            .callable_returns
1013            .push(PythonCallableReturnFact {
1014                declaration,
1015                return_annotation,
1016                annotation_references,
1017                runtime_type,
1018            });
1019        assert!(
1020            self.python_callable_return_by_node
1021                .insert(node.id(), fact_index)
1022                .is_none(),
1023            "each Python function definition must have one callable return fact"
1024        );
1025    }
1026
1027    fn link_source_declaration(
1028        &mut self,
1029        declaration: SourceDeclarationId,
1030        unit: CodeUnit,
1031        metadata_ordinal: Option<usize>,
1032    ) {
1033        self.source_declaration_units
1034            .push((declaration, unit.clone()));
1035        if let Some(metadata_ordinal) = metadata_ordinal {
1036            self.parsed
1037                .source_declaration_metadata
1038                .push(SourceDeclarationMetadataLink {
1039                    declaration,
1040                    unit,
1041                    metadata_ordinal,
1042                });
1043        }
1044    }
1045
1046    fn visit_statement<'tree>(
1047        &mut self,
1048        node: Node<'tree>,
1049        scope: &[Scope],
1050        module_control_depth: usize,
1051        wrapper: Option<Node<'tree>>,
1052    ) -> DeclarationChildren<'tree> {
1053        match node.kind() {
1054            "decorated_definition" => DeclarationChildren {
1055                body: None,
1056                container_scope: None,
1057                container_depth: module_control_depth,
1058            },
1059            "class_definition" | "function_definition" => {
1060                self.visit_definition(node, wrapper, scope, module_control_depth)
1061            }
1062            "expression_statement" => {
1063                self.visit_expression_statement(node, scope, module_control_depth);
1064                DeclarationChildren {
1065                    body: None,
1066                    container_scope: None,
1067                    container_depth: module_control_depth,
1068                }
1069            }
1070            "import_statement" | "import_from_statement" => DeclarationChildren {
1071                body: None,
1072                container_scope: None,
1073                container_depth: module_control_depth,
1074            },
1075            "if_statement" | "try_statement" | "with_statement" | "for_statement"
1076            | "while_statement" => {
1077                let next_depth = if scope.is_empty() {
1078                    module_control_depth + 1
1079                } else {
1080                    module_control_depth
1081                };
1082                DeclarationChildren {
1083                    body: None,
1084                    container_scope: Some(scope.to_vec()),
1085                    container_depth: next_depth,
1086                }
1087            }
1088            "elif_clause" | "else_clause" | "except_clause" | "finally_clause" => {
1089                DeclarationChildren {
1090                    body: None,
1091                    container_scope: Some(scope.to_vec()),
1092                    container_depth: module_control_depth,
1093                }
1094            }
1095            "block" | "module" => DeclarationChildren {
1096                body: None,
1097                container_scope: Some(scope.to_vec()),
1098                container_depth: module_control_depth,
1099            },
1100            _ => DeclarationChildren {
1101                body: None,
1102                container_scope: None,
1103                container_depth: module_control_depth,
1104            },
1105        }
1106    }
1107
1108    fn visit_definition<'tree>(
1109        &mut self,
1110        definition: Node<'tree>,
1111        wrapper: Option<Node<'tree>>,
1112        scope: &[Scope],
1113        module_control_depth: usize,
1114    ) -> DeclarationChildren<'tree> {
1115        match definition.kind() {
1116            "class_definition" => self.visit_class_definition(
1117                definition,
1118                wrapper.unwrap_or(definition),
1119                scope,
1120                module_control_depth,
1121            ),
1122            "function_definition" => self.visit_function_definition(
1123                definition,
1124                wrapper.unwrap_or(definition),
1125                scope,
1126                module_control_depth,
1127            ),
1128            _ => DeclarationChildren {
1129                body: None,
1130                container_scope: None,
1131                container_depth: module_control_depth,
1132            },
1133        }
1134    }
1135
1136    fn visit_class_definition<'tree>(
1137        &mut self,
1138        node: Node<'tree>,
1139        range_node: Node<'tree>,
1140        scope: &[Scope],
1141        module_control_depth: usize,
1142    ) -> DeclarationChildren<'tree> {
1143        let Some(name_node) = node.child_by_field_name("name") else {
1144            return DeclarationChildren {
1145                body: None,
1146                container_scope: None,
1147                container_depth: module_control_depth,
1148            };
1149        };
1150        let name = py_node_text(name_node, self.source).trim();
1151        if name.is_empty() {
1152            return DeclarationChildren {
1153                body: None,
1154                container_scope: None,
1155                container_depth: module_control_depth,
1156            };
1157        }
1158
1159        let capture = !scope.is_empty() || module_control_depth <= 1;
1160
1161        let short_name = scope
1162            .last()
1163            .map(|parent| format!("{}${name}", parent.path))
1164            .unwrap_or_else(|| name.to_string());
1165        // A nested class (any parent scope, Class or Function) is always joined
1166        // with a literal `$` in the legacy convention above, which is exactly
1167        // what `SegmentKind::Nested` renders regardless of the preceding
1168        // segment's kind; a top-level class has no parent and is a plain `Type`
1169        // hanging off the module-path `Package` chain.
1170        let fq = match scope.last() {
1171            Some(parent) => parent
1172                .fq
1173                .clone()
1174                .with_pushed(py_segment(name, SegmentKind::Nested)),
1175            None => self
1176                .module_fq
1177                .clone()
1178                .with_pushed(py_segment(name, SegmentKind::Type)),
1179        };
1180        let code_unit = CodeUnit::new_fq(
1181            self.file.clone(),
1182            CodeUnitType::Class,
1183            self.package_name.to_string(),
1184            short_name.clone(),
1185            fq.clone(),
1186        );
1187        if capture {
1188            self.parsed
1189                .replace_code_unit(code_unit.clone(), range_node, self.source, None, None);
1190            self.parsed.add_signature(
1191                code_unit.clone(),
1192                python_class_signature(range_node, self.source),
1193            );
1194            if let Some(module) = &self.module
1195                && scope.is_empty()
1196            {
1197                self.parsed.add_child(module.clone(), code_unit.clone());
1198            }
1199            if let Some(parent) = scope.last()
1200                && let Some(parent_cu) = &parent.code_unit
1201            {
1202                self.parsed.add_child(parent_cu.clone(), code_unit.clone());
1203            }
1204            self.parsed.set_raw_supertypes(
1205                code_unit.clone(),
1206                extract_python_supertypes(node, self.source),
1207            );
1208            self.replace_source_declaration(range_node, Some(name_node), code_unit.clone(), None);
1209        }
1210
1211        let mut next_scope = scope.to_vec();
1212        if capture {
1213            next_scope.push(Scope {
1214                kind: ScopeKind::Class,
1215                path: short_name,
1216                fq,
1217                code_unit: Some(code_unit),
1218                method_receiver: None,
1219            });
1220        }
1221        DeclarationChildren {
1222            body: node
1223                .child_by_field_name("body")
1224                .map(|body| (body, next_scope)),
1225            container_scope: None,
1226            container_depth: module_control_depth,
1227        }
1228    }
1229
1230    fn visit_function_definition<'tree>(
1231        &mut self,
1232        node: Node<'tree>,
1233        range_node: Node<'tree>,
1234        scope: &[Scope],
1235        module_control_depth: usize,
1236    ) -> DeclarationChildren<'tree> {
1237        let Some(name_node) = node.child_by_field_name("name") else {
1238            return DeclarationChildren {
1239                body: None,
1240                container_scope: None,
1241                container_depth: module_control_depth,
1242            };
1243        };
1244        let name = py_node_text(name_node, self.source).trim();
1245        if name.is_empty() {
1246            return DeclarationChildren {
1247                body: None,
1248                container_scope: None,
1249                container_depth: module_control_depth,
1250            };
1251        }
1252
1253        // Only the shapes a reviewed summary can name are declarations: a
1254        // function written at module level (at most one control level deep, so
1255        // a `def` under `if TYPE_CHECKING:` still counts) or directly in a
1256        // class body. A function nested inside another function's body is a
1257        // local binding of its enclosing function, not a member of any owner,
1258        // so it mints no declaration and no procedure key; `@x.setter` is a
1259        // write into its class's attribute rather than a callable member.
1260        let capture = !python_is_property_mutator(range_node, self.source)
1261            && ((scope.is_empty() && module_control_depth <= 1)
1262                || scope
1263                    .last()
1264                    .is_some_and(|parent| parent.kind == ScopeKind::Class));
1265        let short_name = if let Some(parent) = scope.last() {
1266            match parent.kind {
1267                ScopeKind::Class => format!("{}.{}", parent.path, name),
1268                ScopeKind::Function => format!("{}${name}", parent.path),
1269            }
1270        } else {
1271            name.to_string()
1272        };
1273        // Mirrors `short_name` above segment-for-segment: a method owned
1274        // directly by a class joins with `.` (`Member`), while a function
1275        // nested under another function is a local/closure and joins with the
1276        // literal `$` that `SegmentKind::Nested` renders.
1277        let fq = if let Some(parent) = scope.last() {
1278            match parent.kind {
1279                ScopeKind::Class => parent
1280                    .fq
1281                    .clone()
1282                    .with_pushed(py_segment(name, SegmentKind::Member)),
1283                ScopeKind::Function => parent
1284                    .fq
1285                    .clone()
1286                    .with_pushed(py_segment(name, SegmentKind::Nested)),
1287            }
1288        } else {
1289            self.module_fq
1290                .clone()
1291                .with_pushed(py_segment(name, SegmentKind::Member))
1292        };
1293
1294        if capture {
1295            let code_unit_type = if python_function_has_decorator(node, self.source, "property") {
1296                CodeUnitType::Field
1297            } else {
1298                CodeUnitType::Function
1299            };
1300            let signature = node
1301                .child_by_field_name("parameters")
1302                .map(|parameters| py_node_text(parameters, self.source).trim().to_string());
1303            let code_unit = CodeUnit::with_signature_and_fq(
1304                self.file.clone(),
1305                code_unit_type,
1306                self.package_name.to_string(),
1307                short_name.clone(),
1308                signature,
1309                false,
1310                fq.clone(),
1311            );
1312            self.parsed
1313                .replace_code_unit(code_unit.clone(), range_node, self.source, None, None);
1314            let signature = python_function_signature(range_node, self.source);
1315            let fact_index = *self
1316                .python_callable_return_by_node
1317                .get(&node.id())
1318                .expect("function return fact must precede declaration admission");
1319            let return_fact = &self.python_source_facts.callable_returns[fact_index];
1320            let return_declaration = return_fact.declaration;
1321            let return_type_identity = return_fact.runtime_type.clone();
1322            let return_type_text = return_fact.return_annotation.map(|annotation| {
1323                let range = self.source_facts.occurrence(annotation).range;
1324                self.source[range.start_byte..range.end_byte].to_string()
1325            });
1326            let metadata_ordinal = self.parsed.add_signature_with_metadata(
1327                code_unit.clone(),
1328                python_signature_metadata(
1329                    signature,
1330                    node,
1331                    self.source,
1332                    return_type_text.as_deref(),
1333                    return_type_identity,
1334                ),
1335            );
1336            self.overload_metadata
1337                .retain(|(existing, _, _)| existing != &code_unit);
1338            self.overload_metadata.push((
1339                code_unit.clone(),
1340                metadata_ordinal,
1341                PythonOverloadDecoratorBindings::overload_decorator_names(node, self.source),
1342            ));
1343            self.source_declaration_units
1344                .retain(|(_, existing)| existing != &code_unit);
1345            self.parsed
1346                .source_declaration_metadata
1347                .retain(|link| link.unit != code_unit);
1348            self.link_source_declaration(
1349                return_declaration,
1350                code_unit.clone(),
1351                Some(metadata_ordinal),
1352            );
1353            if let Some(module) = &self.module
1354                && scope.is_empty()
1355            {
1356                self.parsed.add_child(module.clone(), code_unit.clone());
1357            }
1358            if let Some(parent) = scope.last()
1359                && parent.kind == ScopeKind::Class
1360                && let Some(parent_cu) = &parent.code_unit
1361            {
1362                self.parsed.add_child(parent_cu.clone(), code_unit.clone());
1363            }
1364            let scope_code_unit = Some(code_unit);
1365            let mut next_scope = scope.to_vec();
1366            next_scope.push(Scope {
1367                kind: ScopeKind::Function,
1368                path: short_name,
1369                fq,
1370                code_unit: scope_code_unit,
1371                method_receiver: scope
1372                    .last()
1373                    .is_some_and(|parent| parent.kind == ScopeKind::Class)
1374                    .then(|| python_instance_method_receiver_name(node, self.source))
1375                    .flatten(),
1376            });
1377            return DeclarationChildren {
1378                body: node
1379                    .child_by_field_name("body")
1380                    .map(|body| (body, next_scope)),
1381                container_scope: None,
1382                container_depth: module_control_depth,
1383            };
1384        }
1385
1386        let mut next_scope = scope.to_vec();
1387        next_scope.push(Scope {
1388            kind: ScopeKind::Function,
1389            path: short_name,
1390            fq,
1391            code_unit: None,
1392            method_receiver: None,
1393        });
1394        DeclarationChildren {
1395            body: node
1396                .child_by_field_name("body")
1397                .map(|body| (body, next_scope)),
1398            container_scope: None,
1399            container_depth: module_control_depth,
1400        }
1401    }
1402
1403    fn visit_expression_statement(
1404        &mut self,
1405        node: Node<'_>,
1406        scope: &[Scope],
1407        module_control_depth: usize,
1408    ) {
1409        let Some(assignment) = node.named_child(0) else {
1410            return;
1411        };
1412        if assignment.kind() != "assignment" {
1413            return;
1414        }
1415        let targets = python_chained_assignment_targets(assignment);
1416        if targets.is_empty() {
1417            return;
1418        }
1419        for left in &targets {
1420            self.visit_instance_attribute_assignment(*left, scope);
1421        }
1422        let names = targets
1423            .iter()
1424            .flat_map(|left| collect_assigned_name_nodes(*left, self.source));
1425        for name_node in names {
1426            let name = py_node_text(name_node, self.source).trim().to_string();
1427            if name.is_empty() {
1428                continue;
1429            }
1430            let (short_name, fq) = if let Some(parent) = scope.last() {
1431                if parent.kind != ScopeKind::Class {
1432                    continue;
1433                }
1434                (
1435                    format!("{}.{}", parent.path, name),
1436                    parent
1437                        .fq
1438                        .clone()
1439                        .with_pushed(py_segment(&name, SegmentKind::Member)),
1440                )
1441            } else if module_control_depth <= 1 {
1442                (
1443                    name.clone(),
1444                    self.module_fq
1445                        .clone()
1446                        .with_pushed(py_segment(&name, SegmentKind::Member)),
1447                )
1448            } else {
1449                continue;
1450            };
1451            let code_unit = CodeUnit::new_fq(
1452                self.file.clone(),
1453                CodeUnitType::Field,
1454                self.package_name.to_string(),
1455                short_name,
1456                fq,
1457            );
1458            if scope
1459                .last()
1460                .is_some_and(|parent| parent.kind == ScopeKind::Class)
1461            {
1462                // Reassigning a class attribute does not mint a new logical
1463                // member. Preserve every physical binding range so class-body
1464                // references between assignments can select the active one.
1465                self.parsed
1466                    .add_code_unit(code_unit.clone(), node, self.source, None, None);
1467            } else {
1468                self.parsed
1469                    .replace_code_unit(code_unit.clone(), node, self.source, None, None);
1470            }
1471            self.parsed.add_signature(
1472                code_unit.clone(),
1473                py_node_text(node, self.source).trim().to_string(),
1474            );
1475            if scope
1476                .last()
1477                .is_some_and(|parent| parent.kind == ScopeKind::Class)
1478            {
1479                self.record_source_declaration(node, Some(name_node), code_unit.clone(), None);
1480            } else {
1481                self.replace_source_declaration(node, Some(name_node), code_unit.clone(), None);
1482            }
1483            if let Some(module) = &self.module
1484                && scope.is_empty()
1485            {
1486                self.parsed.add_child(module.clone(), code_unit.clone());
1487            }
1488            if let Some(parent) = scope.last()
1489                && parent.kind == ScopeKind::Class
1490                && let Some(parent_cu) = &parent.code_unit
1491            {
1492                self.parsed.add_child(parent_cu.clone(), code_unit);
1493            }
1494        }
1495    }
1496
1497    fn visit_instance_attribute_assignment(&mut self, left: Node<'_>, scope: &[Scope]) {
1498        let Some(function) = scope
1499            .last()
1500            .filter(|scope| scope.kind == ScopeKind::Function)
1501        else {
1502            return;
1503        };
1504        let Some(receiver) = function.method_receiver.as_deref() else {
1505            return;
1506        };
1507        let Some(parent) = scope
1508            .get(scope.len().saturating_sub(2))
1509            .filter(|scope| scope.kind == ScopeKind::Class)
1510        else {
1511            return;
1512        };
1513        let Some(parent_cu) = parent.code_unit.clone() else {
1514            return;
1515        };
1516        for (name, node) in collect_self_assigned_attributes(left, self.source, receiver) {
1517            let code_unit = CodeUnit::new_fq(
1518                self.file.clone(),
1519                CodeUnitType::Field,
1520                self.package_name.to_string(),
1521                format!("{}.{}", parent.path, name),
1522                parent
1523                    .fq
1524                    .clone()
1525                    .with_pushed(py_segment(&name, SegmentKind::Member)),
1526            );
1527            if !self.parsed.contains_declaration(&code_unit) {
1528                self.parsed.replace_code_unit(
1529                    code_unit.clone(),
1530                    node,
1531                    self.source,
1532                    Some(parent_cu.clone()),
1533                    Some(parent_cu.clone()),
1534                );
1535                self.record_source_declaration(node, Some(node), code_unit.clone(), None);
1536            }
1537            self.parsed.add_signature(
1538                code_unit.clone(),
1539                py_node_text(node, self.source).trim().to_string(),
1540            );
1541        }
1542    }
1543
1544    fn visit_import_statement(&mut self, node: Node<'_>, lexical_scopes: &[Node<'_>]) {
1545        for syntax in python_import_syntaxes_from_node(node, self.source) {
1546            if lexical_scopes.is_empty() {
1547                self.overload_decorators.collect_import(&syntax.info);
1548            }
1549            let declaration = self.source_facts.intern_node(syntax.declaration);
1550            let target = syntax
1551                .target
1552                .map(|node| self.source_facts.intern_node(node));
1553            let alias_occurrence = syntax.alias.map(|node| self.source_facts.intern_node(node));
1554            let lexical_scopes = lexical_scopes
1555                .iter()
1556                .map(|node| self.source_facts.intern_node(*node))
1557                .collect();
1558            let source_import = SourceImportFact::from_import(
1559                syntax.info,
1560                declaration,
1561                target,
1562                alias_occurrence,
1563                lexical_scopes,
1564            );
1565            let id = SourceImportId::try_from_index(self.source_imports.len())
1566                .expect("Python source import ids must fit in a u32");
1567            self.source_imports.push(source_import);
1568            self.generic_imports.push(id);
1569        }
1570    }
1571
1572    fn finish(self) {
1573        let PythonVisitor {
1574            parsed,
1575            source,
1576            source_facts,
1577            structural,
1578            structural_parents,
1579            source_imports,
1580            generic_imports,
1581            source_declaration_units,
1582            python_source_facts,
1583            python_callable_return_by_node: _,
1584            overload_decorators,
1585            overload_metadata,
1586            ..
1587        } = self;
1588        assert_eq!(structural_parents, vec![None]);
1589        let structural = structural
1590            .finish()
1591            .expect("Python structural fact collection must finish");
1592        let occurrences = source_facts.finish();
1593        assert!(
1594            python_source_facts.valid_links(&occurrences),
1595            "Python callable return facts must link to exact source identities"
1596        );
1597        for (unit, metadata_ordinal, decorators) in overload_metadata {
1598            let declaration_only = decorators
1599                .iter()
1600                .any(|name| overload_decorators.matches_overload_binding(name));
1601            if declaration_only
1602                && let Some(metadata) = parsed
1603                    .signature_metadata
1604                    .get_mut(&unit)
1605                    .and_then(|entries| entries.get_mut(metadata_ordinal))
1606            {
1607                *metadata = metadata.clone().with_declaration_only(true);
1608            }
1609        }
1610        parsed.imports = generic_imports
1611            .iter()
1612            .map(|id| source_imports[id.index()].import_info(&occurrences))
1613            .collect();
1614        parsed.source_declaration_units = source_declaration_units;
1615        parsed.source_facts = Some(ParsedSourceFacts {
1616            cpp: None,
1617            go: None,
1618            java: None,
1619            js_ts: None,
1620            php: None,
1621            scala: None,
1622            ruby: None,
1623            source_bytes: source.len(),
1624            occurrences,
1625            structural,
1626            python: Some(python_source_facts),
1627            native_site_occurrences: Vec::new(),
1628            native_declaration_sources: Vec::new(),
1629            declaration_visibilities: None,
1630            rust_declaration_properties: Vec::new(),
1631            rust_modules: None,
1632            rust_types: Vec::new(),
1633            rust_items: RustItemSourceFacts::default(),
1634            imports: source_imports,
1635            generic_imports,
1636            rust_import_contexts: Vec::new(),
1637        });
1638    }
1639}
1640
1641/// Build the [`ParsedFile`] for one Python source file: module unit, type
1642/// identifiers, and the declaration walk. `analyzer/python/adapter.rs`'s
1643/// `LanguageAdapter::parse_file` is the only caller.
1644pub fn parse_python_file(file: &ProjectFile, source: &str, tree: &Tree) -> ParsedFile {
1645    let module_components = python_module_components(file);
1646    let module_name = module_components.join(".");
1647    let module_fq = python_module_fq_from_components(&module_components);
1648    let mut parsed = ParsedFile::new(module_name.clone());
1649    let root = tree.root_node();
1650
1651    let module_code_unit = module_code_unit_from_fq(file, &module_components, module_fq.clone());
1652    if let Some(module) = module_code_unit.clone() {
1653        parsed.add_code_unit(module, root, source, None, None);
1654    }
1655
1656    let overload_decorators = PythonOverloadDecoratorBindings::default();
1657    let structural_kinds = CompiledKinds::compile(
1658        &tree_sitter_python::LANGUAGE.into(),
1659        PYTHON_STRUCTURAL_SPEC.kind_table(),
1660    );
1661    let call_site_context = PYTHON_STRUCTURAL_SPEC.call_site_context(root, source);
1662    let mut visitor = PythonVisitor {
1663        file,
1664        source,
1665        package_name: &module_name,
1666        module_fq: &module_fq,
1667        parsed: &mut parsed,
1668        module: module_code_unit,
1669        overload_decorators,
1670        source_facts: PrimarySourceFactCollector::new(source),
1671        structural: StructuralFactCollector::new(
1672            &PYTHON_STRUCTURAL_SPEC,
1673            source,
1674            &call_site_context,
1675            ParentIndex::new(root),
1676            usize::MAX,
1677            None,
1678        ),
1679        structural_kinds: &structural_kinds,
1680        call_site_context: &call_site_context,
1681        structural_parents: vec![None],
1682        source_imports: Vec::new(),
1683        generic_imports: Vec::new(),
1684        source_declaration_units: Vec::new(),
1685        python_source_facts: PythonSourceFacts::default(),
1686        python_callable_return_by_node: HashMap::default(),
1687        overload_metadata: Vec::new(),
1688    };
1689    visitor.visit_container(root, &[], 0);
1690    visitor.finish();
1691
1692    parsed
1693}
1694
1695pub fn py_node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
1696    brokk_bifrost_core::analyzer::common::node_source_text(node, source)
1697}
1698
1699pub fn python_module_name(file: &ProjectFile) -> String {
1700    python_module_components(file).join(".")
1701}
1702
1703pub fn module_code_unit(file: &ProjectFile, module_fq: &str) -> Option<CodeUnit> {
1704    if module_fq.is_empty() {
1705        return None;
1706    }
1707    let components = python_module_components(file);
1708    debug_assert_eq!(
1709        module_fq,
1710        components.join("."),
1711        "module_code_unit must be built from the file's path-derived Python module name"
1712    );
1713    let structured_fq = python_module_fq_from_components(&components);
1714    module_code_unit_from_fq(file, &components, structured_fq)
1715}
1716
1717fn module_code_unit_from_fq(
1718    file: &ProjectFile,
1719    components: &[String],
1720    structured_fq: FqName,
1721) -> Option<CodeUnit> {
1722    let (short_name, package_components) = components.split_last()?;
1723    let package_name = package_components.join(".");
1724    Some(CodeUnit::new_fq(
1725        file.clone(),
1726        CodeUnitType::Module,
1727        package_name,
1728        short_name.clone(),
1729        structured_fq,
1730    ))
1731}
1732
1733fn python_class_signature(node: Node<'_>, source: &str) -> String {
1734    python_header_with_decorators(node, source)
1735}
1736
1737fn python_function_signature(node: Node<'_>, source: &str) -> String {
1738    let header = python_header_with_decorators(node, source);
1739    if let Some((head, tail)) = header.rsplit_once('\n') {
1740        format!("{head}\n{tail} ...")
1741    } else {
1742        format!("{header} ...")
1743    }
1744}
1745
1746fn python_signature_metadata(
1747    signature: String,
1748    node: Node<'_>,
1749    source: &str,
1750    return_type_text: Option<&str>,
1751    return_type_identity: Option<StructuredTypeIdentity>,
1752) -> SignatureMetadata {
1753    let Some(parameters_node) = node
1754        .child_by_field_name("parameters")
1755        .filter(|parameters| parameters.start_position().row == parameters.end_position().row)
1756    else {
1757        return SignatureMetadata::new(signature, Vec::new())
1758            .with_return_type_text(return_type_text)
1759            .with_return_type_identity(return_type_identity)
1760            .with_dispatch_extensibility(DispatchExtensibility::Open)
1761            .with_callable_modifiers(
1762                python_callable_is_static(node, source),
1763                false,
1764                DeclaredVisibility::Unknown,
1765            );
1766    };
1767    // The display header is the last line after any decorators. Parameter
1768    // labels use offsets from their exact AST nodes, so repeated spellings in
1769    // annotations/defaults cannot masquerade as another parameter's identity.
1770    // The existing one-line display omits multiline continuations; omitted
1771    // labels remain unavailable rather than pointing into unrelated text.
1772    let header_start = signature.rfind('\n').map_or(0, |offset| offset + 1);
1773    let parameters = python_parameter_label_nodes(parameters_node)
1774        .into_iter()
1775        .filter_map(|label_node| {
1776            let label = py_node_text(label_node, source);
1777            let start_byte = header_start + label_node.start_byte() - node.start_byte();
1778            let end_byte = start_byte + label.len();
1779            (signature.get(start_byte..end_byte) == Some(label))
1780                .then(|| ParameterMetadata::new(label, start_byte, end_byte))
1781        })
1782        .collect();
1783    SignatureMetadata::new(signature, parameters)
1784        .with_return_type_text(return_type_text)
1785        .with_return_type_identity(return_type_identity)
1786        .with_dispatch_extensibility(DispatchExtensibility::Open)
1787        .with_callable_modifiers(
1788            python_callable_is_static(node, source),
1789            false,
1790            DeclaredVisibility::Unknown,
1791        )
1792}
1793
1794/// Whether this Python callable binds no instance receiver, read from its own
1795/// declaration nodes (#3451).
1796///
1797/// Recording the fact is what makes a Python declaration keyable for
1798/// procedure-summary binding: `receiver_contract_of` refuses to answer for a
1799/// callable whose adapter never inspected modifiers, so before this every
1800/// Python workspace callee contributed `callee_unkeyable` and no Python effect
1801/// coverage could be exhaustive.
1802///
1803/// Only a callable written in a class body can bind an instance receiver, and
1804/// the two decorators that remove it are `@staticmethod` and `@classmethod`.
1805/// The decorator check is the language's own
1806/// [`python_function_has_decorator`], read through the `decorated_definition`
1807/// node that owns the decorators, so the receiver contract agrees with the
1808/// receiver [`python_instance_method_receiver_name`] already resolves. A
1809/// module-level function, and a function nested in another function's body, has
1810/// no type owner at all, so its contract falls out of the owner rather than out
1811/// of this flag; it is not static either.
1812///
1813/// Python spells no constructor modifier for `__init__`: calling the class
1814/// reaches an ordinary instance method, exactly as Ruby's `initialize`, so the
1815/// constructor flag stays false and its receiver contract is `Instance`.
1816/// Visibility is a naming convention (`_name`), not a declaration node, so it
1817/// stays `Unknown`.
1818fn python_callable_is_static(node: Node<'_>, source: &str) -> bool {
1819    debug_assert!(
1820        node.kind() == "function_definition",
1821        "Python callable signature metadata is built for function_definition, not {}",
1822        node.kind()
1823    );
1824    python_function_has_decorator(node, source, "staticmethod")
1825        || python_function_has_decorator(node, source, "classmethod")
1826}
1827
1828fn python_parameter_label_nodes(parameters_node: Node<'_>) -> Vec<Node<'_>> {
1829    let mut labels = Vec::new();
1830    let mut cursor = parameters_node.walk();
1831    for child in parameters_node.named_children(&mut cursor) {
1832        if let Some(label_node) = python_parameter_label_node(child) {
1833            labels.push(label_node);
1834        }
1835    }
1836    labels
1837}
1838
1839/// The identifier node that names one parameter's binding.
1840///
1841/// The grammar gives `default_parameter` and `typed_default_parameter` a
1842/// `name` field but gives `typed_parameter` and the two splat patterns none,
1843/// so a caller that reads only the field loses the binding name of every
1844/// annotated parameter. Every Python surface that names parameters reads them
1845/// through this function.
1846/// Which splat a Python formal parameter spells, looking through the
1847/// annotation wrapper.
1848///
1849/// `*args` is a `list_splat_pattern` and `**kwargs` a
1850/// `dictionary_splat_pattern`, but the grammar spells `*args: str` as a
1851/// `typed_parameter` that holds one, so a test on the parameter's own node kind
1852/// misses every annotated variadic. A parameter that misses it binds like an
1853/// ordinary formal: one positional actual each, and the rest spill onto the
1854/// formals that follow.
1855#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1856pub enum PythonParameterSplat {
1857    /// `*args`: collects every remaining positional actual.
1858    Positional,
1859    /// `**kwargs`: collects every remaining keyword actual.
1860    Keyword,
1861}
1862
1863pub fn python_parameter_splat(parameter: Node<'_>) -> Option<PythonParameterSplat> {
1864    let splat = match parameter.kind() {
1865        kind @ ("list_splat_pattern" | "dictionary_splat_pattern") => kind,
1866        _ => {
1867            let mut cursor = parameter.walk();
1868            parameter
1869                .named_children(&mut cursor)
1870                .map(|child| child.kind())
1871                .find(|kind| matches!(*kind, "list_splat_pattern" | "dictionary_splat_pattern"))?
1872        }
1873    };
1874    match splat {
1875        "list_splat_pattern" => Some(PythonParameterSplat::Positional),
1876        "dictionary_splat_pattern" => Some(PythonParameterSplat::Keyword),
1877        _ => unreachable!("the splat kind was matched above"),
1878    }
1879}
1880
1881pub fn python_parameter_label_node(node: Node<'_>) -> Option<Node<'_>> {
1882    match node.kind() {
1883        "identifier" => Some(node),
1884        "typed_parameter"
1885        | "typed_default_parameter"
1886        | "default_parameter"
1887        | "list_splat_pattern"
1888        | "dictionary_splat_pattern"
1889        | "keyword_separator" => node.child_by_field_name("name").or_else(|| {
1890            let mut cursor = node.walk();
1891            node.named_children(&mut cursor)
1892                .find_map(python_parameter_label_node)
1893        }),
1894        _ => None,
1895    }
1896}
1897
1898fn python_is_property_mutator(node: Node<'_>, source: &str) -> bool {
1899    python_header_with_decorators(node, source)
1900        .lines()
1901        .map(str::trim)
1902        .filter(|line| line.starts_with('@'))
1903        .any(|decorator| decorator.ends_with(".setter") || decorator.ends_with(".deleter"))
1904}
1905
1906pub fn python_expanded_comment_start(source: &str, start_byte: usize) -> usize {
1907    let line_starts = compute_line_starts(source);
1908    let line_index = find_line_index_for_offset(&line_starts, start_byte);
1909
1910    let mut comment_start = start_byte;
1911    for line_idx in (0..line_index).rev() {
1912        let line_start = line_starts[line_idx];
1913        let line_end = line_starts
1914            .get(line_idx + 1)
1915            .copied()
1916            .unwrap_or(source.len());
1917        let line = &source[line_start..line_end];
1918        let trimmed = line.trim_start();
1919
1920        if trimmed.trim().is_empty() {
1921            continue;
1922        }
1923
1924        if trimmed.starts_with('#') {
1925            comment_start = line_start;
1926            continue;
1927        }
1928
1929        break;
1930    }
1931
1932    comment_start
1933}
1934
1935fn python_header_with_decorators(node: Node<'_>, source: &str) -> String {
1936    let raw = py_node_text(node, source);
1937    let lines: Vec<_> = raw
1938        .lines()
1939        .map(str::trim_end)
1940        .filter(|line| !line.trim().is_empty())
1941        .collect();
1942    let mut relevant = Vec::new();
1943    for line in lines {
1944        let trimmed = line.trim_start();
1945        if trimmed.starts_with('@')
1946            || trimmed.starts_with("def ")
1947            || trimmed.starts_with("async def ")
1948            || trimmed.starts_with("class ")
1949        {
1950            relevant.push(trimmed.to_string());
1951            if trimmed.starts_with("def ")
1952                || trimmed.starts_with("async def ")
1953                || trimmed.starts_with("class ")
1954            {
1955                break;
1956            }
1957        }
1958    }
1959    relevant.join("\n")
1960}
1961
1962/// Every positional base of a class, as the spelling the hierarchy resolver
1963/// should look up.
1964///
1965/// A base this function omits is indistinguishable from a class that has no
1966/// such base, so member lookup would treat an incompletely modeled hierarchy
1967/// as a complete one and prove a member absent that the base declares. Every
1968/// positional base therefore contributes a spelling:
1969///
1970/// * A dotted name is its own spelling.
1971/// * A subscripted base (`Base[T]`, `MutableMapping[str, Any]`) contributes
1972///   its generic origin, which is the class the runtime actually inherits.
1973/// * Any other positional base -- a call such as `namedtuple(...)`, an
1974///   unpacked base list, a conditional expression -- contributes its source
1975///   spelling. Resolution fails on it, and the caller reports an unresolved
1976///   base instead of a complete member list.
1977///
1978/// A keyword argument (`metaclass=`, and the arbitrary keywords
1979/// `__init_subclass__` accepts) is not a base and contributes nothing here.
1980fn extract_python_supertypes(node: Node<'_>, source: &str) -> Vec<String> {
1981    let Some(superclasses) = node.child_by_field_name("superclasses") else {
1982        return Vec::new();
1983    };
1984    let mut result = Vec::new();
1985    let mut cursor = superclasses.walk();
1986    for child in superclasses.named_children(&mut cursor) {
1987        if child.kind() == "keyword_argument" {
1988            continue;
1989        }
1990        let named = python_base_origin_node(child);
1991        let text = py_node_text(named, source).trim();
1992        if !text.is_empty() {
1993            result.push(text.to_string());
1994        }
1995    }
1996    result
1997}
1998
1999/// The node whose text names a base class: the value a subscripted base
2000/// applies its type arguments to, or the base expression itself.
2001pub fn python_base_origin_node<'tree>(base: Node<'tree>) -> Node<'tree> {
2002    if base.kind() != "subscript" {
2003        return base;
2004    }
2005    let Some(value) = base.child_by_field_name("value") else {
2006        return base;
2007    };
2008    if matches!(value.kind(), "identifier" | "attribute") {
2009        value
2010    } else {
2011        base
2012    }
2013}
2014
2015fn collect_assigned_name_nodes<'tree>(node: Node<'tree>, _source: &str) -> Vec<Node<'tree>> {
2016    let mut names = Vec::new();
2017    walk_named_tree_preorder(node, true, |node| {
2018        match node.kind() {
2019            // An attribute or subscript target (`foo.bar = …`, `foo[i] = …`)
2020            // mutates an existing object; it declares neither the receiver nor
2021            // the member as a name, so do not descend into it.
2022            "attribute" | "subscript" => WalkControl::SkipChildren,
2023            "identifier" => {
2024                names.push(node);
2025                WalkControl::Continue
2026            }
2027            _ => WalkControl::Continue,
2028        }
2029    });
2030    names
2031}
2032
2033fn collect_self_assigned_attributes<'tree>(
2034    node: Node<'tree>,
2035    source: &str,
2036    receiver_name: &str,
2037) -> Vec<(String, Node<'tree>)> {
2038    let mut attributes = Vec::new();
2039    collect_direct_self_assigned_attributes(node, source, receiver_name, &mut attributes);
2040    attributes
2041}
2042
2043fn collect_direct_self_assigned_attributes<'tree>(
2044    node: Node<'tree>,
2045    source: &str,
2046    receiver_name: &str,
2047    attributes: &mut Vec<(String, Node<'tree>)>,
2048) {
2049    match node.kind() {
2050        "attribute" => {
2051            let Some(object) = node.child_by_field_name("object") else {
2052                return;
2053            };
2054            if object.kind() != "identifier" || py_node_text(object, source).trim() != receiver_name
2055            {
2056                return;
2057            }
2058            let Some(attribute) = node.child_by_field_name("attribute") else {
2059                return;
2060            };
2061            let name = py_node_text(attribute, source).trim();
2062            if !name.is_empty() {
2063                attributes.push((name.to_string(), attribute));
2064            }
2065        }
2066        // The grammar spells an unpacking target three ways: a bare comma list
2067        // is a `pattern_list`, and parentheses or brackets around it make a
2068        // `tuple_pattern` or a `list_pattern`. Omitting the bracketed forms
2069        // dropped every attribute a multi-line unpacking assigns.
2070        "pattern_list"
2071        | "tuple_pattern"
2072        | "list_pattern"
2073        | "tuple"
2074        | "list"
2075        | "parenthesized_expression" => {
2076            let mut cursor = node.walk();
2077            for child in node.named_children(&mut cursor) {
2078                collect_direct_self_assigned_attributes(child, source, receiver_name, attributes);
2079            }
2080        }
2081        _ => {}
2082    }
2083}
2084
2085fn python_instance_method_receiver_name(node: Node<'_>, source: &str) -> Option<String> {
2086    if python_function_has_decorator(node, source, "staticmethod")
2087        || python_function_has_decorator(node, source, "classmethod")
2088    {
2089        return None;
2090    }
2091    python_first_parameter_name(node, source)
2092}
2093
2094fn python_function_has_decorator(node: Node<'_>, source: &str, decorator_name: &str) -> bool {
2095    let Some(parent) = node.parent() else {
2096        return false;
2097    };
2098    if parent.kind() != "decorated_definition" {
2099        return false;
2100    }
2101    let mut cursor = parent.walk();
2102    parent
2103        .named_children(&mut cursor)
2104        .filter(|child| child.kind() == "decorator")
2105        .filter_map(|decorator| decorator.named_child(0))
2106        .filter_map(expression_name_node)
2107        .any(|name| py_node_text(name, source).trim() == decorator_name)
2108}
2109
2110/// The name a callable binds its first parameter to, which for a method is
2111/// the receiver every `self.x` and `setattr(self, ...)` in its body names.
2112pub fn python_first_parameter_name(node: Node<'_>, source: &str) -> Option<String> {
2113    let parameters = node.child_by_field_name("parameters")?;
2114    let mut cursor = parameters.walk();
2115    parameters
2116        .named_children(&mut cursor)
2117        .find_map(|child| python_parameter_name(child, source))
2118}
2119
2120fn python_parameter_name(node: Node<'_>, source: &str) -> Option<String> {
2121    match node.kind() {
2122        "identifier" => Some(py_node_text(node, source).trim().to_string()),
2123        "typed_parameter"
2124        | "default_parameter"
2125        | "list_splat_pattern"
2126        | "dictionary_splat_pattern" => node
2127            .child_by_field_name("name")
2128            .or_else(|| {
2129                let mut cursor = node.walk();
2130                node.named_children(&mut cursor)
2131                    .find(|child| child.kind() == "identifier")
2132            })
2133            .and_then(|name| python_parameter_name(name, source)),
2134        _ => None,
2135    }
2136    .filter(|name| !name.is_empty())
2137}
2138
2139pub fn collect_python_identifiers(node: Node<'_>, source: &str, identifiers: &mut HashSet<String>) {
2140    walk_named_tree_preorder(node, true, |node| {
2141        if node.kind() == "identifier" {
2142            let text = py_node_text(node, source).trim();
2143            if !text.is_empty() {
2144                identifiers.insert(text.to_string());
2145            }
2146        }
2147        WalkControl::Continue
2148    });
2149}
2150
2151pub fn parse_python_tree(source: &str) -> Option<Tree> {
2152    let mut parser = Parser::new();
2153    parser
2154        .set_language(&tree_sitter_python::LANGUAGE.into())
2155        .expect("failed to load python parser");
2156    parser.parse(source, None)
2157}
2158
2159#[cfg(test)]
2160mod primary_tests {
2161    use super::*;
2162    use brokk_bifrost_core::analyzer::python_facts::PythonAnnotationReferenceName;
2163
2164    fn parse(source: &str) -> ParsedFile {
2165        let file = ProjectFile::new(std::env::temp_dir(), "m4.py");
2166        let tree = parse_python_tree(source).expect("Python fixture tree");
2167        parse_python_file(&file, source, &tree)
2168    }
2169
2170    #[test]
2171    fn replacing_a_class_withdraws_descendant_metadata_links() {
2172        let parsed = parse(
2173            "class Replaced:\n    def obsolete(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",
2174        );
2175        let mut names = parsed
2176            .source_declaration_metadata
2177            .iter()
2178            .map(|link| {
2179                assert!(
2180                    parsed.signature_metadata[&link.unit]
2181                        .get(link.metadata_ordinal)
2182                        .is_some(),
2183                    "the link must name retained callable metadata: {link:?}"
2184                );
2185                link.unit.short_name().to_string()
2186            })
2187            .collect::<Vec<_>>();
2188        names.sort();
2189        assert_eq!(names, ["Kept.stable", "Replaced.current"]);
2190    }
2191
2192    #[test]
2193    fn canonical_declarations_share_structural_name_identity_and_exact_ranges() {
2194        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";
2195        let parsed = parse(source);
2196        let facts = parsed
2197            .source_facts
2198            .as_ref()
2199            .expect("canonical Python facts");
2200        for (id, unit) in &parsed.source_declaration_units {
2201            let declaration = facts.occurrences.declaration(*id);
2202            let range = facts.occurrences.occurrence(declaration.occurrence).range;
2203            assert!(parsed.ranges[unit].contains(&range), "{unit:?}: {range:?}");
2204            let name = declaration.name.expect("source declaration name");
2205            assert!(
2206                facts
2207                    .structural
2208                    .nodes()
2209                    .iter()
2210                    .any(|node| node.occurrence == name),
2211                "shared name identity for {unit:?}"
2212            );
2213        }
2214        let fields = parsed
2215            .source_declaration_units
2216            .iter()
2217            .filter(|(_, unit)| unit.short_name() == "Item.value")
2218            .collect::<Vec<_>>();
2219        assert_eq!(fields.len(), 2);
2220        assert_ne!(fields[0].0, fields[1].0);
2221        assert_eq!(
2222            parsed
2223                .source_declaration_units
2224                .iter()
2225                .filter(|(_, unit)| unit.short_name() == "Item.member")
2226                .count(),
2227            1
2228        );
2229        let class = parsed
2230            .source_declaration_units
2231            .iter()
2232            .find(|(_, unit)| unit.short_name() == "Item")
2233            .expect("decorated class");
2234        let range = facts
2235            .occurrences
2236            .occurrence(facts.occurrences.declaration(class.0).occurrence)
2237            .range;
2238        assert_eq!(range.start_byte, 0);
2239        assert_eq!(&source[range.start_byte..range.start_byte + 9], "@decorate");
2240    }
2241
2242    #[test]
2243    fn imports_keep_scope_order_and_late_overload_capture_without_local_pollution() {
2244        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";
2245        let parsed = parse(source);
2246        let facts = parsed.source_facts.as_ref().expect("canonical imports");
2247        assert_eq!(facts.imports.len(), 3);
2248        assert!(
2249            facts.imports[0]
2250                .path
2251                .as_ref()
2252                .unwrap()
2253                .lexical_scopes
2254                .is_empty()
2255        );
2256        assert_eq!(
2257            facts.imports[1].path.as_ref().unwrap().lexical_scopes.len(),
2258            1
2259        );
2260        assert!(
2261            facts.imports[2]
2262                .path
2263                .as_ref()
2264                .unwrap()
2265                .lexical_scopes
2266                .is_empty()
2267        );
2268        for (id, import) in facts.generic_imports.iter().zip(&parsed.imports) {
2269            assert_eq!(
2270                &facts.imports[id.index()].import_info(&facts.occurrences),
2271                import
2272            );
2273        }
2274        let metadata = |name: &str| {
2275            parsed
2276                .signature_metadata
2277                .iter()
2278                .find(|(unit, _)| unit.short_name() == name)
2279                .unwrap()
2280                .1
2281        };
2282        assert!(metadata("public")[0].is_declaration_only());
2283        assert!(!metadata("ordinary")[0].is_declaration_only());
2284    }
2285
2286    #[test]
2287    fn aliased_import_leaves_keep_the_statement_and_exact_binder_tokens() {
2288        let source = "from pkg import alpha as beta, beta as alpha\n";
2289        let parsed = parse(source);
2290        assert_eq!(parsed.imports.len(), 2);
2291        for (import, name) in parsed.imports.iter().zip(["beta", "alpha"]) {
2292            assert_eq!(import.path.as_ref().unwrap().declaration_start_byte, 0);
2293            let span = import.binder_span.as_ref().expect("exact alias binder");
2294            assert_eq!(&source[span.start_byte..span.end_byte], name);
2295        }
2296    }
2297
2298    #[test]
2299    fn parameter_offsets_use_ast_tokens_instead_of_annotation_spelling() {
2300        let source = "@decorate(\"(first, second)\")\ndef call(first: Literal[\"second\"], second: int):\n    pass\n";
2301        let parsed = parse(source);
2302        let metadata = parsed
2303            .signature_metadata
2304            .values()
2305            .flatten()
2306            .find(|metadata| metadata.parameters().len() == 2)
2307            .expect("parameter metadata");
2308        let second = &metadata.parameters()[1];
2309        assert_eq!(
2310            &metadata.label()[second.start_byte()..second.end_byte()],
2311            "second"
2312        );
2313        assert_eq!(
2314            second.start_byte(),
2315            metadata.label().rfind("second:").unwrap()
2316        );
2317    }
2318
2319    #[test]
2320    fn empty_and_recovered_python_publish_canonical_structural_facts() {
2321        for source in [
2322            "",
2323            "class Broken(:\n    pass\ndef valid(value):\n    return value\n",
2324        ] {
2325            let parsed = parse(source);
2326            let facts = parsed.source_facts.expect("complete producer publication");
2327            assert_eq!(facts.source_bytes, source.len());
2328            assert!(facts.python.is_some());
2329            assert_eq!(parsed.resolution_facts, Default::default());
2330            assert!(
2331                facts
2332                    .occurrences
2333                    .occurrences()
2334                    .iter()
2335                    .all(|occurrence| occurrence.range.end_byte <= source.len())
2336            );
2337        }
2338    }
2339
2340    #[test]
2341    fn unmounted_annotated_functions_keep_source_declarations_and_return_facts() {
2342        let source = "if outer:\n    if inner:\n        def hidden(value: int) -> Hidden:\n            pass\n\ndef visible() -> Visible:\n    pass\n";
2343        let parsed = parse(source);
2344        let facts = parsed
2345            .source_facts
2346            .as_ref()
2347            .expect("canonical Python facts");
2348        let python = facts.python.as_ref().expect("Python source facts");
2349        assert_eq!(python.callable_returns.len(), 2);
2350
2351        let fact_named = |name: &str| {
2352            python
2353                .callable_returns
2354                .iter()
2355                .find(|fact| {
2356                    let declaration = facts.occurrences.declaration(fact.declaration);
2357                    let Some(name_occurrence) = declaration.name else {
2358                        return false;
2359                    };
2360                    let range = facts.occurrences.occurrence(name_occurrence).range;
2361                    &source[range.start_byte..range.end_byte] == name
2362                })
2363                .expect("callable return fact")
2364        };
2365        let hidden = fact_named("hidden");
2366        let hidden_declaration = facts.occurrences.declaration(hidden.declaration);
2367        let hidden_range = facts
2368            .occurrences
2369            .occurrence(hidden_declaration.occurrence)
2370            .range;
2371        assert_eq!(
2372            &source[hidden_range.start_byte..hidden_range.start_byte + 3],
2373            "def"
2374        );
2375        let hidden_annotation = hidden.return_annotation.expect("hidden annotation");
2376        let hidden_annotation_range = facts.occurrences.occurrence(hidden_annotation).range;
2377        assert_eq!(
2378            &source[hidden_annotation_range.start_byte..hidden_annotation_range.end_byte],
2379            "Hidden"
2380        );
2381        assert!(
2382            parsed
2383                .source_declaration_units
2384                .iter()
2385                .all(|(_, unit)| unit.short_name() != "hidden")
2386        );
2387
2388        let visible = fact_named("visible");
2389        assert!(visible.return_annotation.is_some());
2390        assert!(visible.runtime_type.is_some());
2391        assert!(
2392            parsed
2393                .source_declaration_units
2394                .iter()
2395                .any(|(_, unit)| unit.short_name() == "visible")
2396        );
2397        let visible_metadata = parsed
2398            .signature_metadata
2399            .iter()
2400            .find(|(unit, _)| unit.short_name() == "visible")
2401            .and_then(|(_, metadata)| metadata.first())
2402            .expect("visible signature metadata");
2403        assert_eq!(visible_metadata.return_type_text(), Some("Visible"));
2404        assert_eq!(
2405            visible_metadata.return_type_identity(),
2406            visible.runtime_type.as_ref()
2407        );
2408    }
2409
2410    #[test]
2411    fn return_fact_preserves_unsupported_annotation_without_runtime_identity() {
2412        let source = "def unsupported() -> Left | Right:\n    pass\n\ndef plain():\n    pass\n";
2413        let parsed = parse(source);
2414        let facts = parsed
2415            .source_facts
2416            .as_ref()
2417            .expect("canonical Python facts");
2418        let python = facts.python.as_ref().expect("Python source facts");
2419        let by_name = |name: &str| {
2420            python
2421                .callable_returns
2422                .iter()
2423                .find(|fact| {
2424                    let declaration = facts.occurrences.declaration(fact.declaration);
2425                    let Some(name_occurrence) = declaration.name else {
2426                        return false;
2427                    };
2428                    let range = facts.occurrences.occurrence(name_occurrence).range;
2429                    &source[range.start_byte..range.end_byte] == name
2430                })
2431                .expect("callable return fact")
2432        };
2433        let unsupported = by_name("unsupported");
2434        assert!(unsupported.return_annotation.is_some());
2435        assert!(unsupported.runtime_type.is_none());
2436        let plain = by_name("plain");
2437        assert!(plain.return_annotation.is_none());
2438        assert!(plain.runtime_type.is_none());
2439    }
2440
2441    #[test]
2442    fn return_annotation_references_preserve_runtime_owners() {
2443        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";
2444        let parsed = parse(source);
2445        let facts = parsed
2446            .source_facts
2447            .as_ref()
2448            .expect("canonical Python facts");
2449        let python = facts.python.as_ref().expect("Python source facts");
2450        let fact_named = |name: &str| {
2451            python
2452                .callable_returns
2453                .iter()
2454                .find(|fact| {
2455                    let declaration = facts.occurrences.declaration(fact.declaration);
2456                    let Some(name_occurrence) = declaration.name else {
2457                        return false;
2458                    };
2459                    let range = facts.occurrences.occurrence(name_occurrence).range;
2460                    &source[range.start_byte..range.end_byte] == name
2461                })
2462                .expect("callable return fact")
2463        };
2464        let generic = fact_named("generic");
2465        assert_eq!(
2466            generic
2467                .annotation_references
2468                .iter()
2469                .map(|reference| &reference.name)
2470                .collect::<Vec<_>>(),
2471            vec![&PythonAnnotationReferenceName::Lexical("list".to_string())]
2472        );
2473
2474        // Generic arguments describe elements, not the returned runtime owner.
2475        let nested = fact_named("nested");
2476        assert_eq!(nested.annotation_references.len(), 1);
2477        assert!(matches!(
2478            &nested.annotation_references[0].name,
2479            PythonAnnotationReferenceName::Lexical(name) if name == "list"
2480        ));
2481
2482        let direct = fact_named("direct");
2483        assert_eq!(direct.annotation_references.len(), 3);
2484        assert_eq!(direct.annotation_references[0].subtree_end, 3);
2485        assert_eq!(direct.annotation_references[0].lookup_depth, 1);
2486        assert!(matches!(
2487            &direct.annotation_references[0].name,
2488            PythonAnnotationReferenceName::Qualified(path)
2489                if path == &vec!["pkg".to_string(), "User".to_string()]
2490        ));
2491
2492        let quoted = fact_named("quoted");
2493        assert_eq!(quoted.annotation_references.len(), 1);
2494        assert!(matches!(
2495            &quoted.annotation_references[0].name,
2496            PythonAnnotationReferenceName::Lexical(name) if name == "pkg.User"
2497        ));
2498        assert_eq!(quoted.annotation_references[0].lookup_depth, 2);
2499
2500        let unsupported = fact_named("unsupported");
2501        assert!(
2502            unsupported
2503                .annotation_references
2504                .iter()
2505                .all(|reference| !matches!(
2506                    &reference.name,
2507                    PythonAnnotationReferenceName::Qualified(_)
2508                ))
2509        );
2510    }
2511}
2512
2513/// Every target a possibly chained assignment binds.
2514///
2515/// Python's `encrypt = decrypt = process` binds both names, but the grammar
2516/// spells it as one `assignment` whose `right` is another `assignment`. Reading
2517/// only the outermost `left` declares `encrypt` and silently drops `decrypt`,
2518/// so the alias reads as an absent member on the class that defines it.
2519fn python_chained_assignment_targets<'tree>(assignment: Node<'tree>) -> Vec<Node<'tree>> {
2520    let mut targets = Vec::new();
2521    let mut node = assignment;
2522    while let Some(left) = node.child_by_field_name("left") {
2523        targets.push(left);
2524        match node.child_by_field_name("right") {
2525            Some(right) if right.kind() == "assignment" => node = right,
2526            _ => break,
2527        }
2528    }
2529    targets
2530}
2531
2532#[cfg(test)]
2533mod supertype_tests {
2534    use super::extract_python_supertypes;
2535    use tree_sitter::{Node, Parser};
2536
2537    fn class_node<'tree>(tree: &'tree tree_sitter::Tree, source: &str) -> Node<'tree> {
2538        let mut cursor = tree.root_node().walk();
2539        tree.root_node()
2540            .named_children(&mut cursor)
2541            .find(|node| node.kind() == "class_definition")
2542            .unwrap_or_else(|| panic!("source declares a class: {source}"))
2543    }
2544
2545    fn parse(source: &str) -> tree_sitter::Tree {
2546        let mut parser = Parser::new();
2547        parser
2548            .set_language(&tree_sitter_python::LANGUAGE.into())
2549            .expect("the Python grammar loads");
2550        parser.parse(source, None).expect("the source parses")
2551    }
2552
2553    #[test]
2554    fn every_positional_base_contributes_a_spelling() {
2555        for (source, expected) in [
2556            ("class A(Base): pass\n", vec!["Base"]),
2557            ("class A(pkg.Base): pass\n", vec!["pkg.Base"]),
2558            // The generic origin is the class the runtime inherits; dropping
2559            // a subscripted base made an incomplete hierarchy look complete.
2560            ("class A(Base[int]): pass\n", vec!["Base"]),
2561            ("class A(pkg.Base[str, int]): pass\n", vec!["pkg.Base"]),
2562            (
2563                "class A(Mapping[str, Any], Base): pass\n",
2564                vec!["Mapping", "Base"],
2565            ),
2566            // Not a base: a keyword argument configures class creation.
2567            ("class A(Base, metaclass=Meta): pass\n", vec!["Base"]),
2568            ("class A(metaclass=Meta): pass\n", Vec::new()),
2569            // Unnameable bases still register, so resolution reports an
2570            // unresolved base rather than a complete member list.
2571            (
2572                "class A(namedtuple(\"P\", \"x\")): pass\n",
2573                vec!["namedtuple(\"P\", \"x\")"],
2574            ),
2575            ("class A(*bases): pass\n", vec!["*bases"]),
2576            ("class A: pass\n", Vec::new()),
2577        ] {
2578            let tree = parse(source);
2579            let node = class_node(&tree, source);
2580            assert_eq!(
2581                extract_python_supertypes(node, source),
2582                expected,
2583                "supertypes of {source}"
2584            );
2585        }
2586    }
2587}
2588
2589#[cfg(test)]
2590mod callable_modifier_tests {
2591    use super::*;
2592
2593    /// The Python half of #3451. Every declaration this walk mints as a
2594    /// callable states whether it binds an instance receiver, read from its own
2595    /// decorator and definition nodes: `@staticmethod` and `@classmethod`
2596    /// remove the receiver, every other `def` in a class body keeps it, and a
2597    /// module-level or async function has none to keep. `receiver_contract_of`
2598    /// reports no contract at all for a callable whose adapter never inspected
2599    /// modifiers, so without this every Python procedure key is refused.
2600    ///
2601    /// The kinds that are deliberately not keyable are asserted here with their
2602    /// reason, so the coverage is the walk's decision rather than an omission:
2603    ///
2604    /// - `@property` is a `Field`, because Python reads it as an attribute; a
2605    ///   member whose unit kind is not a callable minted no key before and does
2606    ///   not now.
2607    /// - `assigned = lambda ...` is a `Field` too: the module walk records the
2608    ///   assignment as a field and never mints a callable declaration for the
2609    ///   lambda, so there are no signature modifiers to read and nothing for a
2610    ///   reviewed summary to bind.
2611    /// - `inner`, a function defined inside another function's body, is a local
2612    ///   binding of its enclosing function rather than a member of any owner, so
2613    ///   this walk mints no declaration, no `SignatureMetadata`, and no
2614    ///   procedure key for it.
2615    #[test]
2616    fn callable_metadata_records_python_receiver_contracts_structurally() {
2617        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";
2618        let file = ProjectFile::new(std::env::temp_dir(), "widget.py");
2619        let tree = parse_python_tree(source).expect("parse the Python fixture");
2620        let parsed = parse_python_file(&file, source, &tree);
2621
2622        let entry = |fq_name: &str| {
2623            parsed
2624                .signature_metadata
2625                .iter()
2626                .find(|(unit, _)| unit.fq_name() == fq_name)
2627                .unwrap_or_else(|| {
2628                    panic!(
2629                        "missing Python declaration {fq_name}; recorded {:?}",
2630                        parsed
2631                            .signature_metadata
2632                            .keys()
2633                            .map(CodeUnit::fq_name)
2634                            .collect::<Vec<_>>()
2635                    )
2636                })
2637        };
2638        let modifiers = |fq_name: &str| {
2639            let (_, entries) = entry(fq_name);
2640            let metadata = entries
2641                .first()
2642                .unwrap_or_else(|| panic!("{fq_name} carries no signature metadata"));
2643            assert!(
2644                metadata.callable_modifiers_recorded(),
2645                "{fq_name} must record that the walk read its declaration shape"
2646            );
2647            (
2648                metadata.callable_is_static(),
2649                metadata.callable_is_constructor(),
2650                metadata.parameters().len(),
2651            )
2652        };
2653
2654        assert_eq!(modifiers("widget.free"), (false, false, 1));
2655        assert_eq!(
2656            modifiers("widget.Widget.__init__"),
2657            (false, false, 2),
2658            "`__init__` is an ordinary instance method, exactly as Ruby's `initialize`"
2659        );
2660        assert_eq!(modifiers("widget.Widget.render"), (false, false, 2));
2661        assert_eq!(
2662            modifiers("widget.Widget.build"),
2663            (true, false, 1),
2664            "`@staticmethod` binds no instance receiver"
2665        );
2666        assert_eq!(
2667            modifiers("widget.Widget.measure"),
2668            (true, false, 2),
2669            "`@classmethod` receives the class, not an instance"
2670        );
2671        assert_eq!(
2672            modifiers("widget.fetch_all"),
2673            (false, false, 1),
2674            "an async def is the same function_definition node"
2675        );
2676        assert_eq!(modifiers("widget.outer"), (false, false, 1));
2677
2678        let (property_unit, property_entries) = entry("widget.Widget.label");
2679        assert_eq!(
2680            property_unit.kind(),
2681            CodeUnitType::Field,
2682            "`@property` is read as an attribute, so it mints a Field and no key"
2683        );
2684        assert!(!property_unit.is_callable());
2685        assert!(
2686            property_entries
2687                .first()
2688                .expect("the property still carries signature metadata")
2689                .callable_modifiers_recorded(),
2690            "the shared metadata path records modifiers for every declaration it mints"
2691        );
2692
2693        // The assignment walk records `assigned` as a field through
2694        // `add_signature`, which mints no `SignatureMetadata` at all, so there
2695        // are no callable modifiers to record and nothing a reviewed summary
2696        // could bind.
2697        let lambda_unit = parsed
2698            .declarations()
2699            .iter()
2700            .find(|unit| unit.fq_name() == "widget.assigned")
2701            .expect("the module walk still records the name-bound lambda's assignment");
2702        assert_eq!(
2703            lambda_unit.kind(),
2704            CodeUnitType::Field,
2705            "a name-bound lambda is recorded as a field, not as a callable declaration"
2706        );
2707        assert!(
2708            !parsed.signature_metadata.contains_key(lambda_unit),
2709            "a field assignment carries signature text but no callable signature metadata"
2710        );
2711        assert!(
2712            parsed
2713                .declarations()
2714                .iter()
2715                .all(|unit| unit.terminal_name() != "inner"),
2716            "a function nested in another function is a local binding, not a declaration"
2717        );
2718    }
2719}