Skip to main content

bonsai_lang_python/
lib.rs

1//! Python language adapter.
2use bonsai_common::{FileId, Span, SymbolId};
3use bonsai_lang_api::{
4    decl_index_with_handler, extract_imports_via,
5    kit::{
6        call_arg_from_nodes_with_handler, collect_kinds, language_from_pack, node_text,
7        normalize_call_name_whitespace, parse_with, span_of,
8    },
9    AdapterContext, AdapterError, AssignmentValueIndex, CallArg, CallKind, CallTargetExtraction,
10    CharacterClass, CharacterConstraintDomain, CharacterConstraintFact, CharacterConstraintOutput,
11    CharacterSubstitutionDomain, CharacterSubstitutionFact, Comment, CommentKind, ConditionEquality,
12    ConditionExpressionFact, ConditionOperandFact, DeclIndex, DeclKind, FiniteLiteralSelectionFact,
13    FlowEvent, GrammarHandler, ImportIndex, ImportScope, ImportSpec, LanguageAdapter, LanguageCapabilities,
14    LanguageId, PatternSourceProjection, ProjectedPatternBindingSite, SameOriginPathConstraintFact,
15    StaticScalarValue, StaticStringMapEntry, StringCompositionFact, StringCompositionPart, TypeAliasBinding,
16    Visibility, EMPTY_HANDLER,
17};
18use tree_sitter::{Language, Node, Tree};
19
20pub const LANG_ID: LanguageId = LanguageId::new("python");
21const PACK_NAME: &str = "python";
22
23fn python_call_target<'tree>(node: Node<'tree>, src: &[u8]) -> Option<CallTargetExtraction<'tree>> {
24    if node.kind() != "call" {
25        return None;
26    }
27    let target = node.child_by_field_name("function")?;
28    let full_text = node_text(&target, src).trim();
29    (!full_text.is_empty()).then_some(CallTargetExtraction {
30        node: target,
31        full_text: full_text.to_string(),
32    })
33}
34
35fn python_pattern_bindings<'tree>(node: Node<'tree>, src: &[u8]) -> Vec<ProjectedPatternBindingSite<'tree>> {
36    if node.kind() != "match_statement" {
37        return Vec::new();
38    }
39    let Some(source) = node.child_by_field_name("subject") else {
40        return Vec::new();
41    };
42    let mut sites = Vec::new();
43    let mut stack = vec![node];
44    while let Some(current) = stack.pop() {
45        if current.id() != node.id() && current.kind() == "match_statement" {
46            continue;
47        }
48        if current.kind() == "case_clause" {
49            let mut cursor = current.walk();
50            for pattern in current
51                .named_children(&mut cursor)
52                .filter(|child| child.kind() == "case_pattern")
53            {
54                // The capture is established by the pattern header, before
55                // the case body executes.  Using the whole `case_clause`
56                // span would place the synthetic write after calls in its
57                // body when the IDG orders transfers by source span.
58                collect_python_pattern_bindings(pattern, pattern, source, &[], src, &mut sites);
59            }
60            continue;
61        }
62        let mut cursor = current.walk();
63        stack.extend(current.named_children(&mut cursor));
64    }
65    sites
66}
67
68fn collect_python_pattern_bindings<'tree>(
69    pattern: Node<'tree>,
70    span_node: Node<'tree>,
71    source: Node<'tree>,
72    projection: &[PatternSourceProjection],
73    src: &[u8],
74    out: &mut Vec<ProjectedPatternBindingSite<'tree>>,
75) {
76    match pattern.kind() {
77        "case_pattern" => {
78            if let Some(child) = pattern.named_child(0) {
79                collect_python_pattern_bindings(child, span_node, source, projection, src, out);
80            }
81        }
82        "dotted_name" => {
83            let mut cursor = pattern.walk();
84            let identifiers = pattern
85                .named_children(&mut cursor)
86                .filter(|child| child.kind() == "identifier")
87                .collect::<Vec<_>>();
88            if identifiers.len() == 1 {
89                push_python_pattern_binding(identifiers[0], span_node, source, projection, src, out);
90            }
91        }
92        "identifier" => {
93            push_python_pattern_binding(pattern, span_node, source, projection, src, out);
94        }
95        "dict_pattern" => {
96            let mut pending_key = None;
97            let mut cursor = pattern.walk();
98            if cursor.goto_first_child() {
99                loop {
100                    let child = cursor.node();
101                    if child.is_named() {
102                        match cursor.field_name() {
103                            Some("key") => {
104                                pending_key = Some(python_static_subscript_key(child, src).map_or(
105                                    PatternSourceProjection::Descendants,
106                                    PatternSourceProjection::Field,
107                                ));
108                            }
109                            Some("value") => {
110                                let mut child_projection = projection.to_vec();
111                                child_projection
112                                    .push(pending_key.take().unwrap_or(PatternSourceProjection::Descendants));
113                                collect_python_pattern_bindings(
114                                    child,
115                                    span_node,
116                                    source,
117                                    &child_projection,
118                                    src,
119                                    out,
120                                );
121                            }
122                            _ if child.kind() == "splat_pattern" => {
123                                let mut child_projection = projection.to_vec();
124                                child_projection.push(PatternSourceProjection::Descendants);
125                                collect_python_pattern_bindings(
126                                    child,
127                                    span_node,
128                                    source,
129                                    &child_projection,
130                                    src,
131                                    out,
132                                );
133                            }
134                            _ => {}
135                        }
136                    }
137                    if !cursor.goto_next_sibling() {
138                        break;
139                    }
140                }
141            }
142        }
143        "class_pattern" => {
144            let mut cursor = pattern.walk();
145            let children = pattern.named_children(&mut cursor).collect::<Vec<_>>();
146            for child in children.into_iter().skip(1) {
147                let core = child.named_child(0).unwrap_or(child);
148                let child_projection = if core.kind() == "keyword_pattern" {
149                    projection.to_vec()
150                } else {
151                    let mut projected = projection.to_vec();
152                    projected.push(PatternSourceProjection::Descendants);
153                    projected
154                };
155                collect_python_pattern_bindings(child, span_node, source, &child_projection, src, out);
156            }
157        }
158        "keyword_pattern" => {
159            let mut cursor = pattern.walk();
160            let children = pattern.named_children(&mut cursor).collect::<Vec<_>>();
161            if let (Some(label), Some(value)) = (children.first(), children.get(1)) {
162                let label = node_text(label, src).trim();
163                let mut child_projection = projection.to_vec();
164                if label.is_empty() {
165                    child_projection.push(PatternSourceProjection::Descendants);
166                } else {
167                    child_projection.push(PatternSourceProjection::Field(label.to_string()));
168                }
169                collect_python_pattern_bindings(*value, span_node, source, &child_projection, src, out);
170            }
171        }
172        "as_pattern" => {
173            let alias_wrapper = pattern.child_by_field_name("alias");
174            let mut cursor = pattern.walk();
175            for child in pattern.named_children(&mut cursor) {
176                if alias_wrapper.is_some_and(|alias| alias.id() == child.id()) {
177                    if let Some(alias) = first_python_identifier(child) {
178                        push_python_pattern_binding(alias, span_node, source, projection, src, out);
179                    }
180                } else {
181                    collect_python_pattern_bindings(child, span_node, source, projection, src, out);
182                }
183            }
184        }
185        "list_pattern" | "tuple_pattern" => {
186            let mut cursor = pattern.walk();
187            let children = pattern.named_children(&mut cursor).collect::<Vec<_>>();
188            let has_remainder = children.iter().any(|child| {
189                child.kind() == "splat_pattern"
190                    || child
191                        .named_child(0)
192                        .is_some_and(|nested| nested.kind() == "splat_pattern")
193            });
194            for (index, child) in children.into_iter().enumerate() {
195                let mut child_projection = projection.to_vec();
196                if has_remainder {
197                    child_projection.push(PatternSourceProjection::Descendants);
198                } else {
199                    child_projection.push(PatternSourceProjection::Element(index));
200                }
201                collect_python_pattern_bindings(child, span_node, source, &child_projection, src, out);
202            }
203        }
204        "splat_pattern" => {
205            if let Some(target) = first_python_identifier(pattern) {
206                push_python_pattern_binding(target, span_node, source, projection, src, out);
207            }
208        }
209        "union_pattern" => {
210            let mut cursor = pattern.walk();
211            for child in pattern.named_children(&mut cursor) {
212                collect_python_pattern_bindings(child, span_node, source, projection, src, out);
213            }
214        }
215        _ => {}
216    }
217}
218
219fn first_python_identifier(node: Node<'_>) -> Option<Node<'_>> {
220    if node.kind() == "identifier" {
221        return Some(node);
222    }
223    let mut stack = vec![node];
224    while let Some(current) = stack.pop() {
225        let mut cursor = current.walk();
226        for child in current.named_children(&mut cursor) {
227            if child.kind() == "identifier" {
228                return Some(child);
229            }
230            stack.push(child);
231        }
232    }
233    None
234}
235
236fn push_python_pattern_binding<'tree>(
237    target: Node<'tree>,
238    span_node: Node<'tree>,
239    source: Node<'tree>,
240    projection: &[PatternSourceProjection],
241    src: &[u8],
242    out: &mut Vec<ProjectedPatternBindingSite<'tree>>,
243) {
244    let name = node_text(&target, src).trim();
245    if !python_match_capture_identifier(name) {
246        return;
247    }
248    let site = ProjectedPatternBindingSite {
249        span_node,
250        target,
251        source,
252        projection: projection.to_vec(),
253    };
254    if !out.iter().any(|existing| {
255        existing.target.id() == site.target.id()
256            && existing.source.id() == site.source.id()
257            && existing.projection == site.projection
258    }) {
259        out.push(site);
260    }
261}
262
263fn python_using_alias(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
264    if node.kind() != "as_pattern" {
265        return None;
266    }
267    let alias_wrapper = node.child_by_field_name("alias")?;
268    let alias = if alias_wrapper.kind() == "identifier" {
269        alias_wrapper
270    } else {
271        let mut cursor = alias_wrapper.walk();
272        let alias = alias_wrapper
273            .named_children(&mut cursor)
274            .find(|child| child.kind() == "identifier");
275        alias?
276    };
277    let mut cursor = node.walk();
278    let value = node
279        .named_children(&mut cursor)
280        .find(|child| child.id() != alias_wrapper.id());
281    Some((alias, value?))
282}
283
284fn python_comprehension_binding(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
285    if node.kind() != "for_in_clause" {
286        return None;
287    }
288    // tree-sitter-python assigns the iteration target to `left` and the
289    // evaluated iterable expression to `right`. These roles are semantic:
290    // reversing them turns `[p for p in parts]` into the false overwrite
291    // `parts = p`, which kills the real reaching definition of `parts`.
292    let binding = node.child_by_field_name("left")?;
293    let iterable = node.child_by_field_name("right")?;
294    Some((binding, iterable))
295}
296
297fn python_foreach_binding(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
298    (node.kind() == "for_statement")
299        .then(|| {
300            Some((
301                node.child_by_field_name("left")?,
302                node.child_by_field_name("right")?,
303            ))
304        })
305        .flatten()
306}
307
308const HANDLER: GrammarHandler = GrammarHandler {
309    literal_value_kinds: &["none", "integer", "float", "true", "false"],
310    literal_value_spellings: &[],
311    string_literal_kinds: &["string", "concatenated_string"],
312    comment_kinds: &["comment"],
313    doc_comment_kinds: &[],
314    doc_comment_prefixes: &[],
315    decorator_kinds: &["decorator"],
316    parameter_container_kinds: &["parameters"],
317    parameter_kinds: &[
318        "identifier",
319        "typed_parameter",
320        "default_parameter",
321        "typed_default_parameter",
322        "list_splat_pattern",
323        "dictionary_splat_pattern",
324    ],
325    parameter_modifier_kinds: &[],
326    parameter_annotation_kinds: &["decorator"],
327    parameter_annotation_name_extractor: None,
328    keyword_parameter_kinds: &[],
329    parameter_selector_kinds: &[],
330    implicit_parameter_kinds: &[],
331    self_parameter_kinds: &[],
332    last_identifier_parameter_kinds: &[],
333    binding_identifier_kinds: &["identifier"],
334    non_binding_pattern_kinds: &[],
335    binding_lhs_pattern_kinds: &[],
336    binding_pattern_field_names: &[],
337    pattern_head_value_kinds: &["class_pattern"],
338    multi_segment_value_pattern_kinds: &["dotted_name"],
339    non_binding_pattern_field_names: &["type", "key", "class", "guard"],
340    binding_name_extractor: None,
341    binding_name_filter: None,
342    pattern_binding_extractor: None,
343    projected_pattern_binding_extractor: Some(python_pattern_bindings),
344    anonymous_variadic_token: None,
345    variadic_parameter_kinds: &["list_splat_pattern"],
346    destructured_parameter_kinds: &["list_pattern", "tuple_pattern", "dictionary_pattern"],
347    identifier_kinds: &["identifier"],
348    aggregate_pattern_kinds: &["pattern_list", "list_pattern", "tuple_pattern"],
349    comprehension_kinds: &[
350        "list_comprehension",
351        "dictionary_comprehension",
352        "set_comprehension",
353        "generator_expression",
354    ],
355    comprehension_binding_clause_kinds: &["for_in_clause"],
356    comprehension_binding_extractor: Some(python_comprehension_binding),
357    named_aggregate_kinds: &["dictionary"],
358    positional_aggregate_kinds: &["tuple", "list", "set"],
359    aggregate_pair_kinds: &["pair", "dict_pattern"],
360    two_child_aggregate_pair_kinds: &[],
361    aggregate_pair_extractor: None,
362    aggregate_key_field_names: &["key"],
363    aggregate_value_field_names: &["value"],
364    static_field_name_kinds: &["identifier"],
365    shorthand_field_kinds: &[],
366    spread_kinds: &["list_splat", "dictionary_splat", "parenthesized_list_splat"],
367    spread_value_field_names: &["value"],
368    aggregate_syntax_only_kinds: &[],
369    multi_child_aggregate_pattern_kinds: &[],
370    lambda_value_container_kinds: &["dictionary", "list", "set"],
371    transparent_call_wrapper_kinds: &["attribute", "subscript", "parenthesized_expression", "await"],
372    single_expression_group_kinds: &["expression_list"],
373    assignment_target_wrapper_kinds: &[],
374    binding_declaration_keyword_spellings: &[],
375    nested_type_ownership: true,
376    fn_kinds: &["function_definition"],
377    class_kinds: &["class_definition"],
378    class_decl_kinds: &[("class_definition", DeclKind::Class)],
379    method_kinds: &[],
380    method_context_kinds: &["class_definition"],
381    method_owner_barrier_kinds: &[],
382    constructor_method_kinds: &[],
383    constructor_names: &["__init__"],
384    function_definition_extractor: None,
385    inline_closure_yield_extractor: None,
386    if_kinds: &[
387        "if_statement",
388        "conditional_expression",
389        "match_statement",
390        "elif_clause",
391    ],
392    branch_then_field_names: &["consequence", "body"],
393    branch_else_field_names: &["alternative"],
394    branch_condition_field_names: &["condition", "subject"],
395    branch_condition_kinds: &[],
396    branch_alias_extractor: None,
397    branch_arm_kinds: &["block", "elif_clause", "else_clause"],
398    additional_alternative_kinds: &["elif_clause", "else_clause"],
399    for_kinds: &[],
400    foreach_kinds: &["for_statement"],
401    foreach_binding_extractor: Some(python_foreach_binding),
402    while_kinds: &["while_statement"],
403    do_kinds: &[],
404    loop_kinds: &[],
405    loop_body_field_names: &["body"],
406    loop_body_kinds: &["block"],
407    call_kinds: &["call"],
408    constructor_call_kinds: &[],
409    nested_call_component_kinds: &[],
410    call_callee_field_names: &["function"],
411    call_target_extractor: Some(python_call_target),
412    call_receiver_extractor: None,
413    call_receiver_field_names: &[],
414    call_member_field_names: &[],
415    constructor_type_field_names: &[],
416    call_argument_field_names: &["arguments"],
417    call_argument_container_kinds: &["argument_list"],
418    call_argument_wrapper_kinds: &[],
419    call_callee_is_first_named_child: false,
420    argument_wrapper_kinds: &["keyword_argument"],
421    argument_name_field_names: &["name"],
422    argument_value_field_names: &["value"],
423    named_argument_extractor: None,
424    direct_call_info_extractor: None,
425    call_ref_node_filter: None,
426    expression_call_span_extractor: None,
427    writeback_operand_field_names: &[],
428    direct_call_argument_excluded_fields: &[],
429    transparent_expression_wrapper_kinds: &["parenthesized_expression"],
430    pseudo_call_extractor: None,
431    syntax_event_extractor: None,
432    syntax_events_extractor: None,
433    call_encoded_control_flow_extractor: None,
434    pseudo_call_receiver_extractor: None,
435    argument_passing_mode_extractor: None,
436    expression_value_kind_extractor: None,
437    assignment_kinds: &["assignment", "augmented_assignment", "named_expression"],
438    assignment_semantics_extractor: None,
439    assignment_place_extractor: None,
440    compound_assignment_kinds: &["augmented_assignment"],
441    compound_assignment_operators: &[],
442    type_only_declaration_kinds: &[],
443    positional_aggregate_assignment_kinds: &[],
444    positional_aggregate_value_kinds: &[],
445    return_kinds: &["return_statement"],
446    throw_kinds: &["raise_statement"],
447    lambda_kinds: &["lambda"],
448    inline_closure_kinds: &[],
449    implicit_lambda_parameter_name: None,
450    lambda_body_field_names: &["body"],
451    lambda_body_kinds: &[],
452    try_kinds: &["try_statement"],
453    catch_kinds: &["except_clause"],
454    finally_kinds: &["finally_clause"],
455    try_fallback_body_kinds: &["block"],
456    catch_body_follows_marker: false,
457    break_kinds: &["break_statement"],
458    continue_kinds: &["continue_statement"],
459    control_label_field_names: &[],
460    yield_kinds: &["yield"],
461    yield_value_field_names: &["value", "expression"],
462    await_kinds: &["await"],
463    defer_kinds: &[],
464    deferred_body_extractor: None,
465    using_kinds: &["with_statement"],
466    using_body_field_names: &["body"],
467    try_body_field_names: &["body"],
468    using_alias_extractor: Some(python_using_alias),
469    special_forms: &[],
470    runtime_type_guard_calls: &["isinstance"],
471    runtime_type_guard_operators: &[],
472    runtime_typeof_operators: &[],
473    runtime_type_equality_operators: &[],
474    runtime_type_wrapper_kinds: &["parenthesized_expression"],
475    value_free_expression_kinds: &[],
476    value_free_call_names: &[],
477    value_free_unary_operators: &[],
478    call_ref_kinds: &["call"],
479    member_expression_kinds: &["attribute"],
480    subscript_expression_kinds: &["subscript"],
481    member_base_field_names: &["object"],
482    member_name_field_names: &["attribute"],
483    subscript_base_field_names: &["value"],
484    subscript_index_field_names: &["subscript"],
485    static_subscript_key_extractor: Some(python_static_subscript_key),
486    computed_subscript_extractor: None,
487    sigil_variable_kinds: &[],
488    global_variable_kinds: &[],
489    reference_name_extractor: None,
490    expression_place_extractor: None,
491    indirect_place_operand_extractor: None,
492    subscript_base_call_refs: true,
493    non_call_ref_names: &[],
494    call_name_suffix_tokens: &[],
495    syntax_error_tolerant_call_names: &[],
496    callable_reference_kinds: &[],
497    callable_reference_extractor: None,
498    method_receiver_param_index: Some(0),
499    receiver_presence_extractor: Some(python_function_has_receiver),
500    // `self` for ordinary instance methods; `super` so `super().foo()`
501    // and `super(Class, self).foo()` resolve to the parent class's
502    // `foo` via the engine's `resolve_super_method_candidates`. The adapter
503    // normalizes these call receivers to the two forms declared below.
504    implicit_receiver_names: &["self", "super"],
505    implicit_receiver_prefixes: EMPTY_HANDLER.implicit_receiver_prefixes,
506    tail_expression_returns: EMPTY_HANDLER.tail_expression_returns,
507    void_return_type_names: EMPTY_HANDLER.void_return_type_names,
508};
509
510/// Python methods normally bind parameter zero as their receiver, except for
511/// the exact built-in `staticmethod` decorator forms. This syntax belongs to
512/// the Python adapter; shared lowering receives only the resulting boolean.
513fn python_function_has_receiver(node: Node<'_>, src: &[u8]) -> bool {
514    let Some(parent) = node
515        .parent()
516        .filter(|parent| parent.kind() == "decorated_definition")
517    else {
518        return true;
519    };
520    let mut cursor = parent.walk();
521    let is_static = parent
522        .named_children(&mut cursor)
523        .filter(|child| child.kind() == "decorator")
524        .any(|decorator| python_decorator_is_staticmethod(decorator, src));
525    !is_static
526}
527
528fn python_decorator_is_staticmethod(decorator: Node<'_>, src: &[u8]) -> bool {
529    let mut cursor = decorator.walk();
530    let is_static = decorator
531        .named_children(&mut cursor)
532        .next()
533        .is_some_and(|expression| python_expr_is_staticmethod(expression, src));
534    is_static
535}
536
537fn python_expr_is_staticmethod(node: Node<'_>, src: &[u8]) -> bool {
538    match node.kind() {
539        "identifier" => node_text(&node, src).trim() == "staticmethod",
540        "attribute" => {
541            node.child_by_field_name("attribute")
542                .is_some_and(|attribute| node_text(&attribute, src).trim() == "staticmethod")
543                && node
544                    .child_by_field_name("object")
545                    .is_some_and(|object| node_text(&object, src).trim() == "builtins")
546        }
547        "parenthesized_expression" => {
548            let mut cursor = node.walk();
549            let is_static = node
550                .named_children(&mut cursor)
551                .next()
552                .is_some_and(|inner| python_expr_is_staticmethod(inner, src));
553            is_static
554        }
555        "call" => node
556            .child_by_field_name("function")
557            .is_some_and(|callee| python_expr_is_staticmethod(callee, src)),
558        _ => false,
559    }
560}
561
562#[derive(Debug, Default, Copy, Clone)]
563pub struct PythonAdapter;
564
565impl PythonAdapter {
566    #[must_use]
567    pub fn new() -> Self {
568        Self
569    }
570}
571
572impl LanguageAdapter for PythonAdapter {
573    fn language_id(&self) -> LanguageId {
574        LANG_ID
575    }
576    fn display_name(&self) -> &'static str {
577        "Python"
578    }
579    fn file_extensions(&self) -> &'static [&'static str] {
580        &["py", "pyi"]
581    }
582    fn tree_sitter_language(&self) -> Result<Language, AdapterError> {
583        language_from_pack(PACK_NAME)
584    }
585    fn capabilities(&self) -> LanguageCapabilities {
586        // Reflection: the adapter rewrites the constant-string forms
587        // of `getattr` / `setattr` / `hasattr` into attribute calls
588        // before the engine sees them (see
589        // `rewrite_python_constant_reflection`). Dynamic forms with a
590        // computed name remain unrewritten and rules anchored on the
591        // reflective shape are still rejected at rulepack load time.
592        LanguageCapabilities {
593            module_default_export_names: &[],
594            universal_type_names: &["Any", "object"],
595            module_path_syntax: bonsai_lang_api::ModulePathSyntax::none(),
596            reflection: bonsai_lang_api::CapabilityLevel::Partial,
597            receiver_types: bonsai_lang_api::CapabilityLevel::Partial,
598            // Static attribute/subscript projections are exact, but Python
599            // still has dynamic subscripts and reflective projections whose
600            // selected field is unknowable from syntax alone. Keep the
601            // workspace field universe open for those aggregate reads.
602            field_places_complete: false,
603            constructor_method_names: &["__init__"],
604            bare_call_constructor_syntax: true,
605            super_receiver_tokens: &["super", "super()"],
606            // Python's receiver is the adapter-proven first method parameter;
607            // `self` is a convention, not an implicit grammar token.
608            implicit_receiver_tokens: &[],
609            receiver_type_syntax: bonsai_lang_api::ReceiverTypeSyntax {
610                wrapper_calls: &["type"],
611                class_object_suffixes: &[".__class__"],
612            },
613            ..LanguageCapabilities::partial_baseline()
614        }
615    }
616    fn extract_declarations(&self, file: FileId, ctx: &AdapterContext<'_>) -> DeclIndex {
617        let mut idx = decl_index_with_handler(PACK_NAME, file, ctx, &HANDLER);
618        // Python module path: the dotted module name derived from the
619        // file path. e.g. `pkg/sub/foo.py` -> ["pkg", "sub", "foo"].
620        // Falls back to file-stem if the path isn't usable.
621        // Workspace-relative path → dotted module path. Adapters
622        // without a workspace root (unit tests) fall through to
623        // file-stem-only via the helper below.
624        let segments: Vec<String> = ctx
625            .workspace_relative_path(file)
626            .and_then(|path| {
627                let stem = path.file_stem()?.to_string_lossy().into_owned();
628                let mut segs: Vec<String> = path
629                    .parent()?
630                    .components()
631                    .filter_map(|c| match c {
632                        std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
633                        _ => None,
634                    })
635                    .collect();
636                segs.push(stem);
637                Some(segs)
638            })
639            .unwrap_or_default();
640        if segments.is_empty() {
641            bonsai_lang_api::apply_file_stem_semantic_identity(&mut idx, ctx);
642        } else {
643            bonsai_lang_api::apply_module_path_semantic_identity(&mut idx, segments);
644        }
645        // Python privacy is convention-based but the `__name` (dunder)
646        // form triggers actual name-mangling at runtime, so the
647        // resolver should treat it as truly Private. Single-underscore
648        // `_name` is convention only — keep `Public`.
649        for decl in &mut idx.defs {
650            if decl.name.starts_with("__") && !decl.name.ends_with("__") {
651                decl.visibility = Visibility::Private;
652            }
653        }
654        // `__all__ = ["foo", "bar"]` (or the tuple form) declares the
655        // names exported by `from module import *` ONLY. It is NOT a
656        // visibility boundary: `from module import run_query` and
657        // `import module; module.run_query(x)` are legal for names
658        // absent from `__all__`. Downgrading unlisted top-level decls
659        // to `Visibility::Module` made the resolver drop every
660        // cross-module flow through an internal helper (the common
661        // "public API in __all__, sink-bearing helpers omitted" idiom),
662        // a soundness false-negative. We therefore keep such decls
663        // `Public` and let the `__name` -> Private dunder rule above be
664        // the only visibility filter. Precise wildcard-import narrowing
665        // (consult `__all__` only on the `from module import *` path)
666        // belongs in the resolver as a separate exported-names fact.
667        // Per-decl compiler facts: walk the tree and record `param: Type`
668        // annotations plus the exact direct call used as a parameter default
669        // (`param: T = module.factory(...)`). The adapter deliberately does
670        // not interpret framework/API names: rules decide what a default call
671        // means through `target.default_call`. The matcher consults
672        // `Decl.type_aliases` when resolving `attribute: [Type, method]`
673        // rules, so typed receivers such as `[UploadFile, filename]` still
674        // resolve per
675        // docs/contributing/design-patterns.mdx::Semantic Resolution Always.
676        if let Some((snapshot, tree)) = parse_with(PACK_NAME, file, ctx) {
677            let src = snapshot.text.as_bytes();
678            idx.comments.extend(python_docstring_comments(&tree, file, src));
679            populate_python_condition_expressions(&mut idx, &tree, file, src);
680            idx.string_compositions = python_string_compositions(&tree, file, src);
681            idx.finite_literal_selections = python_finite_literal_selections(&idx, &tree, file, src);
682            idx.character_substitutions = python_character_substitutions(&idx, &tree, file, src);
683            idx.character_constraints = python_character_constraints(&idx, &tree, file, src);
684            idx.same_origin_path_constraints = python_same_origin_path_constraints(&idx, &tree, file, src);
685            // Phase-6 return-type extraction: `def f() -> T:` populates
686            // `Decl.return_type`, which `apply_assign_call_result_types`
687            // then propagates onto LHS type_aliases.
688            bonsai_lang_api::populate_decl_return_types(&mut idx, &tree, src, &HANDLER);
689            let aliases_by_span = collect_python_method_type_aliases(&tree, file, src);
690            let param_default_calls_by_span = collect_python_param_default_calls(&tree, file, src);
691            for decl in &mut idx.defs {
692                if let Some(aliases) = aliases_by_span
693                    .iter()
694                    .find_map(|(span, aliases)| (*span == decl.span).then_some(aliases))
695                {
696                    decl.type_aliases = aliases.clone();
697                }
698                if let Some(default_calls) = param_default_calls_by_span
699                    .iter()
700                    .find_map(|(span, calls)| (*span == decl.span).then_some(calls))
701                {
702                    merge_python_param_default_calls(decl, default_calls);
703                }
704            }
705            // Per-class `bases`: `class C(Base, Mixin):` →
706            // ["Base", "Mixin"]. Lets `kind: param` rules require
707            // an ancestor type (`in_class: [WebSocketHandler]`
708            // matching a user `class Echo(WebSocketHandler):`).
709            let bases_by_span = collect_python_class_bases(&tree, file, src);
710            for decl in &mut idx.defs {
711                if !matches!(decl.kind, bonsai_lang_api::DeclKind::Class) {
712                    continue;
713                }
714                if let Some(bases) = bases_by_span
715                    .iter()
716                    .find_map(|(span, bases)| (*span == decl.span).then_some(bases))
717                {
718                    decl.bases = bases.clone();
719                }
720            }
721            let iterable_yield_bindings = collect_python_iterable_yield_bindings(&tree, file, src);
722            let property_fn_spans = collect_python_property_function_spans(&tree, file, src);
723            let property_aliases = collect_python_property_aliases(&idx, &property_fn_spans);
724            let property_aliases_by_decl = python_property_aliases_by_decl(&idx, &property_aliases);
725            let assignment_values = AssignmentValueIndex::new(&idx.assignment_values);
726            let assignment_projected_reads = collect_python_assignment_projected_reads(&tree, file, src);
727            let call_argument_places = collect_python_call_argument_places(&tree, file, src);
728            let return_places = collect_python_return_places(&tree, file, src);
729            let callable_spans: Vec<Span> = idx
730                .defs
731                .iter()
732                .filter(|decl| {
733                    matches!(
734                        decl.kind,
735                        bonsai_lang_api::DeclKind::Function
736                            | bonsai_lang_api::DeclKind::Method
737                            | bonsai_lang_api::DeclKind::Constructor
738                    )
739                })
740                .map(|decl| decl.span)
741                .collect();
742            for decl in &mut idx.defs {
743                let owned_yield_bindings = iterable_yield_bindings
744                    .iter()
745                    .filter(|event| {
746                        python_span_owned_by_decl(python_flow_event_span(event), decl.span, &callable_spans)
747                    })
748                    .cloned()
749                    .collect::<Vec<_>>();
750                let comprehension_iterable_calls =
751                    collect_python_comprehension_iterable_call_events(&tree, file, src, decl.span);
752                augment_python_comprehension_flow_events(
753                    &mut decl.flow_events,
754                    snapshot.text.as_ref(),
755                    &assignment_values,
756                );
757                insert_python_flow_events_by_span(
758                    &mut decl.flow_events,
759                    decl.span,
760                    &comprehension_iterable_calls,
761                );
762                insert_python_iterable_yield_bindings(&mut decl.flow_events, &owned_yield_bindings);
763                augment_python_dict_flow_events(
764                    &mut decl.flow_events,
765                    snapshot.text.as_ref(),
766                    &assignment_values,
767                    &assignment_projected_reads,
768                );
769                if let Some(property_aliases_for_decl) = property_aliases_by_decl.get(&decl.symbol) {
770                    augment_python_property_flow_events(&mut decl.flow_events, property_aliases_for_decl);
771                }
772                rewrite_python_constant_reflection(&mut decl.flow_events);
773                rewrite_python_generator_send(&mut decl.flow_events);
774                augment_python_asyncio_to_thread_calls(&mut decl.flow_events);
775                apply_python_call_argument_places(&mut decl.flow_events, &call_argument_places);
776                apply_python_return_places(&mut decl.flow_events, &return_places);
777            }
778            bonsai_lang_api::kit::populate_call_argument_static_values(
779                &mut idx,
780                &tree,
781                file,
782                src,
783                &HANDLER,
784                python_static_scalar,
785            );
786        }
787        for decl in &mut idx.defs {
788            bonsai_lang_api::normalize_call_result_assignment_sources(&mut decl.flow_events);
789        }
790        // Precompute `self.<field> → Type` bindings from each class's
791        // constructor `receiver_field_writes` so receiver-typed
792        // dispatch through stable instance state is an O(1) lookup
793        // against the method's `type_aliases` instead of a per-call
794        // walk over sibling decls.
795        bonsai_lang_api::apply_class_field_type_aliases(&mut idx);
796        idx
797    }
798    fn extract_imports(&self, file: FileId, ctx: &AdapterContext<'_>) -> ImportIndex {
799        extract_imports_via(PACK_NAME, file, ctx, parse_imports)
800    }
801}
802
803/// Python defines a docstring as the first bare string expression in a
804/// module, function, async function, or class body. This is language syntax,
805/// so the adapter owns the scope and wrapper kinds; shared comment lowering
806/// only consumes explicit comment nodes declared by its active handler.
807fn python_docstring_comments(tree: &Tree, file: FileId, src: &[u8]) -> Vec<Comment> {
808    let mut out = Vec::new();
809    for scope in collect_kinds(
810        tree,
811        &[
812            "module",
813            "function_definition",
814            "class_definition",
815            "async_function_definition",
816        ],
817    ) {
818        let body = scope.child_by_field_name("body").unwrap_or(scope);
819        let Some(first_statement) = body.named_child(0) else {
820            continue;
821        };
822        let value = if first_statement.kind() == "expression_statement" {
823            first_statement.named_child(0).unwrap_or(first_statement)
824        } else {
825            first_statement
826        };
827        if !HANDLER.string_literal_kinds.contains(&value.kind()) {
828            continue;
829        }
830        let text = node_text(&value, src).trim().to_string();
831        if text.is_empty() {
832            continue;
833        }
834        out.push(Comment {
835            span: span_of(file, &value),
836            kind: CommentKind::classify(&text, true),
837            text,
838        });
839    }
840    out.sort_by_key(|comment| (comment.span.start, comment.span.end));
841    out.dedup_by_key(|comment| comment.span);
842    out
843}
844
845/// Lower Python's boolean-expression grammar into the shared semantic
846/// condition IR. Operator spellings are consumed here, in the language
847/// frontend; security and dataflow analyses only see `Any`/`All`/`Not`,
848/// equality, membership, and exact syntax spans.
849fn populate_python_condition_expressions(index: &mut DeclIndex, tree: &Tree, file: FileId, src: &[u8]) {
850    for branch in collect_kinds(tree, &["if_statement", "elif_clause"]) {
851        let branch_span = span_of(file, &branch);
852        let Some(condition) = branch.child_by_field_name("condition") else {
853            continue;
854        };
855        let Some(fact) = index
856            .branch_conditions
857            .iter_mut()
858            .find(|fact| fact.branch_span == branch_span)
859        else {
860            continue;
861        };
862        fact.expression = Some(lower_python_condition_expression(condition, file, src));
863    }
864}
865
866fn lower_python_condition_expression(node: Node<'_>, file: FileId, src: &[u8]) -> ConditionExpressionFact {
867    if matches!(
868        node.kind(),
869        "parenthesized_expression" | "parenthesized_expression_list"
870    ) {
871        if let Some(inner) = node.named_child(0) {
872            return lower_python_condition_expression(inner, file, src);
873        }
874    }
875
876    let span = span_of(file, &node);
877    if node.kind() == "not_operator" {
878        if let Some(operand) = node
879            .child_by_field_name("argument")
880            .or_else(|| node.child_by_field_name("operand"))
881            .or_else(|| node.named_child(0))
882        {
883            return ConditionExpressionFact::Not {
884                span,
885                operand: Box::new(lower_python_condition_expression(operand, file, src)),
886            };
887        }
888    }
889
890    if node.kind() == "boolean_operator" {
891        if let (Some(left), Some(right)) = (
892            node.child_by_field_name("left"),
893            node.child_by_field_name("right"),
894        ) {
895            let operator = src
896                .get(left.end_byte()..right.start_byte())
897                .and_then(|bytes| std::str::from_utf8(bytes).ok())
898                .map(str::trim);
899            match operator {
900                Some("or") => {
901                    return merge_python_condition_junction(
902                        span,
903                        lower_python_condition_expression(left, file, src),
904                        lower_python_condition_expression(right, file, src),
905                        false,
906                    );
907                }
908                Some("and") => {
909                    return merge_python_condition_junction(
910                        span,
911                        lower_python_condition_expression(left, file, src),
912                        lower_python_condition_expression(right, file, src),
913                        true,
914                    );
915                }
916                _ => {}
917            }
918        }
919    }
920
921    if node.kind() == "comparison_operator" && node.named_child_count() == 2 {
922        if let (Some(left), Some(right)) = (node.named_child(0), node.named_child(1)) {
923            let operator = src
924                .get(left.end_byte()..right.start_byte())
925                .and_then(|bytes| std::str::from_utf8(bytes).ok())
926                .map(str::trim);
927            match operator {
928                Some("==" | "!=") => {
929                    return ConditionExpressionFact::Equality {
930                        span,
931                        relation: if operator == Some("==") {
932                            ConditionEquality::Equal
933                        } else {
934                            ConditionEquality::NotEqual
935                        },
936                        left: python_condition_operand(left, file, src),
937                        right: python_condition_operand(right, file, src),
938                    };
939                }
940                Some("in" | "not in") => {
941                    return ConditionExpressionFact::Membership {
942                        span,
943                        subject: python_condition_operand(left, file, src),
944                        collection: python_condition_operand(right, file, src),
945                        then_contains: operator == Some("in"),
946                    };
947                }
948                _ => {}
949            }
950        }
951    }
952
953    if node.kind() == "call" {
954        let function = node.child_by_field_name("function");
955        let arguments = node.child_by_field_name("arguments");
956        if let (Some(function), Some(arguments)) = (function, arguments) {
957            let mut cursor = arguments.walk();
958            let values: Vec<_> = arguments.named_children(&mut cursor).collect();
959            if function.kind() == "identifier"
960                && node_text(&function, src).trim() == "isinstance"
961                && values.len() == 2
962                && matches!(
963                    values[1].kind(),
964                    "identifier" | "type" | "attribute" | "generic_type"
965                )
966            {
967                let type_name = node_text(&values[1], src).trim().to_string();
968                if !type_name.is_empty() {
969                    return ConditionExpressionFact::TypeTest {
970                        span,
971                        subject: python_condition_operand(values[0], file, src),
972                        type_name,
973                    };
974                }
975            }
976        }
977    }
978
979    if matches!(node.kind(), "identifier" | "attribute" | "subscript") {
980        return ConditionExpressionFact::Truthy {
981            span,
982            operand: python_condition_operand(node, file, src),
983        };
984    }
985
986    ConditionExpressionFact::Atom { span }
987}
988
989fn merge_python_condition_junction(
990    span: Span,
991    left: ConditionExpressionFact,
992    right: ConditionExpressionFact,
993    all: bool,
994) -> ConditionExpressionFact {
995    let mut operands = Vec::new();
996    let mut push = |operand: ConditionExpressionFact| match (all, operand) {
997        (true, ConditionExpressionFact::All { operands: nested, .. })
998        | (false, ConditionExpressionFact::Any { operands: nested, .. }) => operands.extend(nested),
999        (_, operand) => operands.push(operand),
1000    };
1001    push(left);
1002    push(right);
1003    if all {
1004        ConditionExpressionFact::All { span, operands }
1005    } else {
1006        ConditionExpressionFact::Any { span, operands }
1007    }
1008}
1009
1010fn python_condition_operand(node: Node<'_>, file: FileId, src: &[u8]) -> ConditionOperandFact {
1011    let value_node = python_condition_dynamic_value_node(node, src);
1012    ConditionOperandFact {
1013        span: span_of(file, &node),
1014        value_flow: bonsai_lang_api::kit::expression_flow_from_node_with_handler(
1015            value_node, file, src, &HANDLER,
1016        ),
1017        static_string: python_static_string(node, src),
1018        static_value: python_static_scalar(node, src),
1019    }
1020}
1021
1022/// Preserve the exact dynamic operand of Python's common falsey-fallback
1023/// expression (`value or <static>`). The complete operand span remains on the
1024/// condition fact, while value-flow points at the Tree-sitter node whose
1025/// runtime value is being constrained. This is language semantics, not a
1026/// security classification.
1027fn python_condition_dynamic_value_node<'tree>(mut node: Node<'tree>, src: &[u8]) -> Node<'tree> {
1028    loop {
1029        if matches!(
1030            node.kind(),
1031            "parenthesized_expression" | "parenthesized_expression_list"
1032        ) {
1033            if let Some(inner) = node.named_child(0) {
1034                node = inner;
1035                continue;
1036            }
1037        }
1038        if node.kind() == "boolean_operator" {
1039            if let (Some(left), Some(right)) = (
1040                node.child_by_field_name("left"),
1041                node.child_by_field_name("right"),
1042            ) {
1043                let operator = src
1044                    .get(left.end_byte()..right.start_byte())
1045                    .and_then(|bytes| std::str::from_utf8(bytes).ok())
1046                    .map(str::trim);
1047                if operator == Some("or") && python_static_scalar(right, src).is_some() {
1048                    node = left;
1049                    continue;
1050                }
1051            }
1052        }
1053        return node;
1054    }
1055}
1056
1057fn python_static_scalar(node: Node<'_>, src: &[u8]) -> Option<StaticScalarValue> {
1058    match node.kind() {
1059        "true" => Some(StaticScalarValue::Boolean(true)),
1060        "false" => Some(StaticScalarValue::Boolean(false)),
1061        "none" => Some(StaticScalarValue::Null),
1062        "string" => Some(StaticScalarValue::String(python_static_string(node, src)?)),
1063        _ => None,
1064    }
1065}
1066
1067fn python_static_subscript_key(node: Node<'_>, src: &[u8]) -> Option<String> {
1068    match python_static_scalar(node, src)? {
1069        StaticScalarValue::String(value) => Some(value),
1070        StaticScalarValue::Boolean(_) | StaticScalarValue::Null => None,
1071    }
1072}
1073
1074fn python_static_string(node: Node<'_>, src: &[u8]) -> Option<String> {
1075    if node.kind() != "string" {
1076        return None;
1077    }
1078    let text = node_text(&node, src).trim();
1079    let quote_start = text.find(['\'', '"'])?;
1080    let prefix = text.get(..quote_start)?.to_ascii_lowercase();
1081    if prefix.contains('f') || prefix.contains('b') || prefix.chars().any(|ch| !matches!(ch, 'r' | 'u')) {
1082        return None;
1083    }
1084    let quoted = text.get(quote_start..)?;
1085    let delimiter = if quoted.starts_with("'''") {
1086        "'''"
1087    } else if quoted.starts_with("\"\"\"") {
1088        "\"\"\""
1089    } else if quoted.starts_with('\'') {
1090        "'"
1091    } else if quoted.starts_with('"') {
1092        "\""
1093    } else {
1094        return None;
1095    };
1096    let inner = quoted.strip_prefix(delimiter)?.strip_suffix(delimiter)?;
1097    if prefix.contains('r') {
1098        return Some(inner.to_string());
1099    }
1100    let mut decoded = String::new();
1101    let mut characters = inner.chars();
1102    while let Some(character) = characters.next() {
1103        if character != '\\' {
1104            decoded.push(character);
1105            continue;
1106        }
1107        decoded.push(match characters.next()? {
1108            'r' => '\r',
1109            'n' => '\n',
1110            't' => '\t',
1111            '\\' => '\\',
1112            '\'' => '\'',
1113            '"' => '"',
1114            'x' => {
1115                let digits = [characters.next()?, characters.next()?];
1116                let value = u8::from_str_radix(&digits.iter().collect::<String>(), 16).ok()?;
1117                char::from(value)
1118            }
1119            _ => return None,
1120        });
1121    }
1122    Some(decoded)
1123}
1124
1125fn python_character_constraints(
1126    index: &DeclIndex,
1127    tree: &Tree,
1128    file: FileId,
1129    src: &[u8],
1130) -> Vec<CharacterConstraintFact> {
1131    let mut facts = python_comprehension_character_constraints(index, tree, file, src);
1132    facts.extend(python_regex_substitution_constraints(index, tree, file, src));
1133    facts.extend(python_regex_validation_constraints(index, tree, file, src));
1134    facts.sort_by_key(|fact| (fact.transform_span.start, fact.transform_span.end));
1135    facts.dedup_by_key(|fact| fact.transform_span);
1136    facts
1137}
1138
1139fn python_finite_literal_selections(
1140    index: &DeclIndex,
1141    tree: &Tree,
1142    file: FileId,
1143    src: &[u8],
1144) -> Vec<FiniteLiteralSelectionFact> {
1145    #[derive(Clone)]
1146    struct FiniteMapBinding {
1147        name: String,
1148        declaration_end: usize,
1149        owner: Option<Span>,
1150    }
1151
1152    fn binding_visible(
1153        resolver: &PythonLexicalBindingResolver<'_, '_>,
1154        bindings: &[FiniteMapBinding],
1155        map_name: &str,
1156        use_start: usize,
1157        use_span: Span,
1158    ) -> bool {
1159        let owner = resolver.owner_for_use(use_span, map_name);
1160        bindings.iter().any(|binding| {
1161            binding.name == map_name
1162                && (binding.owner.is_none() || binding.declaration_end <= use_start)
1163                && binding.owner == owner
1164        })
1165    }
1166
1167    let assignments = collect_kinds(tree, &["assignment"]);
1168    let binding_resolver = PythonLexicalBindingResolver::new(index, tree, file, src, &assignments);
1169    let mut finite_maps = Vec::new();
1170    for assignment in &assignments {
1171        let (Some(target), Some(value)) = (
1172            assignment.child_by_field_name("left"),
1173            assignment.child_by_field_name("right"),
1174        ) else {
1175            continue;
1176        };
1177        if target.kind() != "identifier" || !python_finite_static_map(value, src) {
1178            continue;
1179        }
1180        let name = node_text(&target, src).trim().to_string();
1181        let owner = binding_resolver.owner_for_use(span_of(file, assignment), &name);
1182        let writes = assignments
1183            .iter()
1184            .filter(|candidate| {
1185                candidate
1186                    .child_by_field_name("left")
1187                    .is_some_and(|left| left.kind() == "identifier" && node_text(&left, src).trim() == name)
1188                    && binding_resolver.owner_for_use(span_of(file, candidate), &name) == owner
1189            })
1190            .count();
1191        let projected_write = assignments.iter().any(|candidate| {
1192            candidate.child_by_field_name("left").is_some_and(|left| {
1193                left.kind() == "subscript"
1194                    && left.child_by_field_name("value").is_some_and(|base| {
1195                        base.kind() == "identifier" && node_text(&base, src).trim() == name
1196                    })
1197            }) && binding_resolver.owner_for_use(span_of(file, candidate), &name) == owner
1198        });
1199        let aliases_or_mutations = collect_kinds(tree, &["assignment", "augmented_assignment", "call"])
1200            .into_iter()
1201            .any(|candidate| {
1202                binding_resolver.owner_for_use(span_of(file, &candidate), &name) == owner
1203                    && python_map_binding_may_escape_or_mutate(candidate, &name, assignment.id(), src)
1204            })
1205            || python_map_binding_has_unsafe_use(&binding_resolver, &name, assignment.id(), owner);
1206        if writes == 1 && !projected_write && !aliases_or_mutations {
1207            finite_maps.push(FiniteMapBinding {
1208                name,
1209                declaration_end: assignment.end_byte(),
1210                owner,
1211            });
1212        }
1213    }
1214
1215    let mut facts = Vec::new();
1216    for assignment in &assignments {
1217        let (Some(target), Some(value)) = (
1218            assignment.child_by_field_name("left"),
1219            assignment.child_by_field_name("right"),
1220        ) else {
1221            continue;
1222        };
1223        if target.kind() != "identifier" || value.kind() != "conditional_expression" {
1224            continue;
1225        }
1226        let mut cursor = value.walk();
1227        let operands: Vec<_> = value.named_children(&mut cursor).collect();
1228        let [selected, condition, fallback] = operands.as_slice() else {
1229            continue;
1230        };
1231        let Some(collection) = python_positive_membership_collection(*condition, *selected, src) else {
1232            continue;
1233        };
1234        let collection_is_finite = python_finite_literal_collection(collection, src)
1235            || (collection.kind() == "identifier"
1236                && binding_visible(
1237                    &binding_resolver,
1238                    &finite_maps,
1239                    node_text(&collection, src).trim(),
1240                    condition.start_byte(),
1241                    span_of(file, condition),
1242                ));
1243        if !collection_is_finite || !python_finite_membership_literal(*fallback, src) {
1244            continue;
1245        }
1246        facts.push(FiniteLiteralSelectionFact {
1247            selection_span: span_of(file, &value),
1248            assignment_span: Some(span_of(file, assignment)),
1249            target: Some(node_text(&target, src).trim().to_string()),
1250            call_span: None,
1251            argument_index: None,
1252        });
1253    }
1254    for call in collect_kinds(tree, &["call"]) {
1255        let Some((function, _)) = python_call_parts(call) else {
1256            continue;
1257        };
1258        let Some((receiver, method)) = python_attribute_parts(function, src) else {
1259            continue;
1260        };
1261        if method != "get" || receiver.kind() != "identifier" {
1262            continue;
1263        }
1264        let map_name = node_text(&receiver, src).trim();
1265        let selection_span = span_of(file, &call);
1266        let finite_match = binding_visible(
1267            &binding_resolver,
1268            &finite_maps,
1269            map_name,
1270            call.start_byte(),
1271            selection_span,
1272        );
1273        if !finite_match {
1274            continue;
1275        }
1276        let Some(assignment) = index
1277            .assignment_values
1278            .iter()
1279            .filter(|fact| {
1280                fact.target.is_some()
1281                    && fact.value_span.start <= selection_span.start
1282                    && selection_span.end <= fact.value_span.end
1283            })
1284            .min_by_key(|fact| fact.value_span.len())
1285        else {
1286            continue;
1287        };
1288        facts.push(FiniteLiteralSelectionFact {
1289            selection_span,
1290            assignment_span: Some(assignment.assignment_span),
1291            target: assignment.target.clone(),
1292            call_span: None,
1293            argument_index: None,
1294        });
1295    }
1296    facts.sort_by_key(|fact| {
1297        let span = fact.assignment_span.unwrap_or(fact.selection_span);
1298        (span.start, span.end, fact.selection_span.start)
1299    });
1300    facts.dedup();
1301    facts
1302}
1303
1304/// Return the collection from a positive `selected in collection` condition
1305/// whose subject is the conditional's selected value. The caller proves that
1306/// collection is either literal syntax or an immutable finite-map binding.
1307fn python_positive_membership_collection<'tree>(
1308    condition: Node<'tree>,
1309    selected: Node<'tree>,
1310    src: &[u8],
1311) -> Option<Node<'tree>> {
1312    if condition.kind() != "comparison_operator" || selected.kind() != "identifier" {
1313        return None;
1314    }
1315    let mut cursor = condition.walk();
1316    let operands: Vec<_> = condition.named_children(&mut cursor).collect();
1317    let [subject, collection] = operands.as_slice() else {
1318        return None;
1319    };
1320    if subject.kind() != "identifier" || node_text(subject, src).trim() != node_text(&selected, src).trim() {
1321        return None;
1322    }
1323    let operator = src
1324        .get(subject.end_byte()..collection.start_byte())
1325        .and_then(|bytes| std::str::from_utf8(bytes).ok())
1326        .map(str::trim);
1327    if operator != Some("in") {
1328        return None;
1329    }
1330    Some(*collection)
1331}
1332
1333fn python_finite_literal_collection(collection: Node<'_>, src: &[u8]) -> bool {
1334    if !matches!(collection.kind(), "set" | "list" | "tuple") {
1335        return false;
1336    }
1337    let mut collection_cursor = collection.walk();
1338    let values: Vec<_> = collection.named_children(&mut collection_cursor).collect();
1339    !values.is_empty()
1340        && values
1341            .into_iter()
1342            .all(|value| python_finite_membership_literal(value, src))
1343}
1344
1345fn python_finite_membership_literal(node: Node<'_>, src: &[u8]) -> bool {
1346    match node.kind() {
1347        "string" => python_static_string(node, src).is_some(),
1348        "integer" | "float" | "true" | "false" | "none" => true,
1349        _ => false,
1350    }
1351}
1352
1353fn python_character_substitutions(
1354    index: &DeclIndex,
1355    tree: &Tree,
1356    file: FileId,
1357    src: &[u8],
1358) -> Vec<CharacterSubstitutionFact> {
1359    let assignments = collect_kinds(tree, &["assignment"]);
1360    let binding_resolver = PythonLexicalBindingResolver::new(index, tree, file, src, &assignments);
1361    let mut tables = Vec::new();
1362    for assignment in &assignments {
1363        let (Some(target), Some(value)) = (
1364            assignment.child_by_field_name("left"),
1365            assignment.child_by_field_name("right"),
1366        ) else {
1367            continue;
1368        };
1369        if target.kind() != "identifier" {
1370            continue;
1371        }
1372        let Some(entries) = python_static_string_map(value, src) else {
1373            continue;
1374        };
1375        let name = node_text(&target, src).trim().to_string();
1376        let owner = binding_resolver.owner_for_use(span_of(file, assignment), &name);
1377        let writes = assignments
1378            .iter()
1379            .filter(|candidate| {
1380                candidate
1381                    .child_by_field_name("left")
1382                    .is_some_and(|left| left.kind() == "identifier" && node_text(&left, src).trim() == name)
1383                    && binding_resolver.owner_for_use(span_of(file, candidate), &name) == owner
1384            })
1385            .count();
1386        let projected_write = assignments.iter().any(|candidate| {
1387            candidate.child_by_field_name("left").is_some_and(|left| {
1388                left.kind() == "subscript"
1389                    && left.child_by_field_name("value").is_some_and(|base| {
1390                        base.kind() == "identifier" && node_text(&base, src).trim() == name
1391                    })
1392            }) && binding_resolver.owner_for_use(span_of(file, candidate), &name) == owner
1393        });
1394        let aliases_or_mutations = collect_kinds(tree, &["assignment", "augmented_assignment", "call"])
1395            .into_iter()
1396            .any(|candidate| {
1397                binding_resolver.owner_for_use(span_of(file, &candidate), &name) == owner
1398                    && python_map_binding_may_escape_or_mutate(candidate, &name, assignment.id(), src)
1399            })
1400            || python_map_binding_has_unsafe_use(&binding_resolver, &name, assignment.id(), owner);
1401        if writes == 1 && !projected_write && !aliases_or_mutations {
1402            tables.push((name, assignment.end_byte(), owner, entries));
1403        }
1404    }
1405
1406    let mut facts = Vec::new();
1407    for return_node in collect_kinds(tree, &["return_statement"]) {
1408        let Some(returned) = return_node.named_child(0) else {
1409            continue;
1410        };
1411        let Some((join_function, join_arguments)) = python_call_parts(returned) else {
1412            continue;
1413        };
1414        let Some((join_receiver, join_method)) = python_attribute_parts(join_function, src) else {
1415            continue;
1416        };
1417        if join_method != "join" || python_static_string(join_receiver, src).as_deref() != Some("") {
1418            continue;
1419        }
1420        let generator = if join_arguments.kind() == "generator_expression" {
1421            join_arguments
1422        } else {
1423            let arguments = python_argument_nodes(join_arguments);
1424            let [generator] = arguments.as_slice() else {
1425                continue;
1426            };
1427            *generator
1428        };
1429        let Some((table, input_place)) = python_static_map_substitution_generator(generator, src) else {
1430            continue;
1431        };
1432        let transform_span = span_of(file, &return_node);
1433        let Some(decl) = python_enclosing_callable(index, transform_span) else {
1434            continue;
1435        };
1436        if !python_is_single_statement_return(return_node) {
1437            continue;
1438        }
1439        let Some(input_param_index) = decl.params.iter().position(|parameter| parameter == &input_place)
1440        else {
1441            continue;
1442        };
1443        let owner = binding_resolver.owner_for_use(transform_span, &table);
1444        let Some((_, _, _, exact_mappings)) =
1445            tables.iter().find(|(name, declaration_end, binding_owner, _)| {
1446                name == &table
1447                    && (binding_owner.is_none() || *declaration_end <= return_node.start_byte())
1448                    && *binding_owner == owner
1449            })
1450        else {
1451            continue;
1452        };
1453        facts.push(CharacterSubstitutionFact {
1454            function_span: decl.span,
1455            transform_span,
1456            input_param_index,
1457            exact_mappings: exact_mappings.clone(),
1458            table,
1459            domain: CharacterSubstitutionDomain::TableKeysWithIdentityFallback,
1460        });
1461    }
1462    facts.sort_by_key(|fact| (fact.transform_span.start, fact.transform_span.end));
1463    facts.dedup_by_key(|fact| fact.transform_span);
1464    facts
1465}
1466
1467fn python_static_string_map(node: Node<'_>, src: &[u8]) -> Option<Vec<StaticStringMapEntry>> {
1468    if node.kind() != "dictionary" || node.named_child_count() == 0 {
1469        return None;
1470    }
1471    let mut entries = Vec::new();
1472    let mut cursor = node.walk();
1473    for entry in node.named_children(&mut cursor) {
1474        if entry.kind() != "pair" {
1475            return None;
1476        }
1477        let key = entry
1478            .child_by_field_name("key")
1479            .and_then(|key| python_static_string(key, src))?;
1480        let value = entry
1481            .child_by_field_name("value")
1482            .and_then(|value| python_static_string(value, src))?;
1483        entries.push(StaticStringMapEntry { key, value });
1484    }
1485    entries.sort_by(|left, right| left.key.cmp(&right.key));
1486    entries.dedup_by(|left, right| left.key == right.key && left.value == right.value);
1487    Some(entries)
1488}
1489
1490fn python_static_map_substitution_generator(generator: Node<'_>, src: &[u8]) -> Option<(String, String)> {
1491    if generator.kind() != "generator_expression" {
1492        return None;
1493    }
1494    let body = generator.named_child(0)?;
1495    let (function, arguments) = python_call_parts(body)?;
1496    let (receiver, method) = python_attribute_parts(function, src)?;
1497    if receiver.kind() != "identifier" || method != "get" {
1498        return None;
1499    }
1500    let table = node_text(&receiver, src).trim().to_string();
1501    let lookup_arguments = python_argument_nodes(arguments);
1502    let [key, fallback] = lookup_arguments.as_slice() else {
1503        return None;
1504    };
1505    if key.kind() != "identifier" || fallback.kind() != "identifier" {
1506        return None;
1507    }
1508    let loop_variable = node_text(key, src).trim().to_string();
1509    if node_text(fallback, src).trim() != loop_variable {
1510        return None;
1511    }
1512    let mut cursor = generator.walk();
1513    let clauses = generator.named_children(&mut cursor).skip(1).collect::<Vec<_>>();
1514    let [for_clause] = clauses.as_slice() else {
1515        return None;
1516    };
1517    if for_clause.kind() != "for_in_clause" {
1518        return None;
1519    }
1520    let left = for_clause
1521        .child_by_field_name("left")
1522        .or_else(|| for_clause.named_child(0))?;
1523    let right = for_clause
1524        .child_by_field_name("right")
1525        .or_else(|| for_clause.named_child(1))?;
1526    if left.kind() != "identifier" || node_text(&left, src).trim() != loop_variable {
1527        return None;
1528    }
1529    let input_place = python_identity_fallback_input(right, src)?;
1530    Some((table, input_place))
1531}
1532
1533fn python_identity_fallback_input(mut node: Node<'_>, src: &[u8]) -> Option<String> {
1534    while node.kind() == "parenthesized_expression" {
1535        node = node.named_child(0)?;
1536    }
1537    if node.kind() == "identifier" {
1538        return Some(node_text(&node, src).trim().to_string());
1539    }
1540    if node.kind() != "boolean_operator" {
1541        return None;
1542    }
1543    let (Some(left), Some(right)) = (
1544        node.child_by_field_name("left"),
1545        node.child_by_field_name("right"),
1546    ) else {
1547        return None;
1548    };
1549    let operator = src
1550        .get(left.end_byte()..right.start_byte())
1551        .and_then(|bytes| std::str::from_utf8(bytes).ok())
1552        .map(str::trim);
1553    (operator == Some("or")
1554        && left.kind() == "identifier"
1555        && python_static_string(right, src).as_deref() == Some(""))
1556    .then(|| node_text(&left, src).trim().to_string())
1557}
1558
1559fn python_lexical_owner(index: &DeclIndex, span: bonsai_common::Span) -> Option<bonsai_common::Span> {
1560    index
1561        .defs
1562        .iter()
1563        .filter(|decl| {
1564            decl.name != bonsai_lang_api::MODULE_DECL_NAME
1565                && matches!(
1566                    decl.kind,
1567                    DeclKind::Function | DeclKind::Method | DeclKind::Constructor | DeclKind::Class
1568                )
1569                && decl.span.start <= span.start
1570                && span.end <= decl.span.end
1571        })
1572        .min_by_key(|decl| decl.span.len())
1573        .map(|decl| decl.span)
1574}
1575
1576/// Per-file Python lexical binding resolver shared by value-shape lowerers.
1577/// Directive nodes are indexed once so repeated candidate checks remain
1578/// linear in the small set of relevant scopes instead of rescanning the CST.
1579struct PythonLexicalBindingResolver<'a, 'tree> {
1580    index: &'a DeclIndex,
1581    file: FileId,
1582    src: &'a [u8],
1583    assignments: &'a [Node<'tree>],
1584    directives: Vec<Node<'tree>>,
1585    identifiers: Vec<Node<'tree>>,
1586}
1587
1588impl<'a, 'tree> PythonLexicalBindingResolver<'a, 'tree> {
1589    fn new(
1590        index: &'a DeclIndex,
1591        tree: &'tree Tree,
1592        file: FileId,
1593        src: &'a [u8],
1594        assignments: &'a [Node<'tree>],
1595    ) -> Self {
1596        Self {
1597            index,
1598            file,
1599            src,
1600            assignments,
1601            directives: collect_kinds(tree, &["global_statement", "nonlocal_statement"]),
1602            identifiers: collect_kinds(tree, &["identifier"]),
1603        }
1604    }
1605
1606    /// Resolve one Python binding spelling to its lexical compiler owner.
1607    ///
1608    /// Python decides whether a name is local for the whole callable from
1609    /// parsed parameters and assignments, not source order. Nested callables
1610    /// close over the nearest such owner; `global` and `nonlocal` directives
1611    /// alter that lookup. Class bodies own their direct assignments, but a
1612    /// method does not capture the class namespace through a bare identifier.
1613    fn owner_for_use(&self, use_span: Span, name: &str) -> Option<Span> {
1614        let mut scopes = self
1615            .index
1616            .defs
1617            .iter()
1618            .filter(|decl| {
1619                decl.name != bonsai_lang_api::MODULE_DECL_NAME
1620                    && matches!(
1621                        decl.kind,
1622                        DeclKind::Function | DeclKind::Method | DeclKind::Constructor | DeclKind::Class
1623                    )
1624                    && decl.span.start <= use_span.start
1625                    && use_span.end <= decl.span.end
1626            })
1627            .collect::<Vec<_>>();
1628        scopes.sort_by_key(|decl| decl.span.len());
1629
1630        let mut inside_callable = false;
1631        for scope in scopes {
1632            let callable = matches!(
1633                scope.kind,
1634                DeclKind::Function | DeclKind::Method | DeclKind::Constructor
1635            );
1636            if scope.kind == DeclKind::Class && inside_callable {
1637                continue;
1638            }
1639            if callable {
1640                inside_callable = true;
1641                if self.scope_has_name_directive(scope.span, "global_statement", name) {
1642                    return None;
1643                }
1644                if self.scope_has_name_directive(scope.span, "nonlocal_statement", name) {
1645                    continue;
1646                }
1647            }
1648            let parameter = callable && scope.params.iter().any(|parameter| parameter == name);
1649            let assigned = self.assignments.iter().any(|assignment| {
1650                python_lexical_owner(self.index, span_of(self.file, assignment)) == Some(scope.span)
1651                    && assignment.child_by_field_name("left").is_some_and(|left| {
1652                        left.kind() == "identifier" && node_text(&left, self.src).trim() == name
1653                    })
1654            });
1655            if parameter || assigned {
1656                return Some(scope.span);
1657            }
1658        }
1659        None
1660    }
1661
1662    fn scope_has_name_directive(&self, scope: Span, kind: &str, name: &str) -> bool {
1663        self.directives.iter().any(|directive| {
1664            if directive.kind() != kind
1665                || python_lexical_owner(self.index, span_of(self.file, directive)) != Some(scope)
1666            {
1667                return false;
1668            }
1669            let mut cursor = directive.walk();
1670            let contains_name = directive
1671                .named_children(&mut cursor)
1672                .any(|child| child.kind() == "identifier" && node_text(&child, self.src).trim() == name);
1673            contains_name
1674        })
1675    }
1676}
1677
1678fn python_map_binding_may_escape_or_mutate(
1679    node: Node<'_>,
1680    map_name: &str,
1681    declaration_id: usize,
1682    src: &[u8],
1683) -> bool {
1684    if node.id() == declaration_id {
1685        return false;
1686    }
1687    match node.kind() {
1688        "assignment" | "augmented_assignment" => {
1689            let left = node.child_by_field_name("left");
1690            let right = node.child_by_field_name("right");
1691            left.is_some_and(|left| {
1692                (left.kind() == "identifier" && node_text(&left, src).trim() == map_name)
1693                    || (left.kind() == "subscript"
1694                        && left.child_by_field_name("value").is_some_and(|base| {
1695                            base.kind() == "identifier" && node_text(&base, src).trim() == map_name
1696                        }))
1697            }) || right.is_some_and(|right| {
1698                right.kind() == "identifier" && node_text(&right, src).trim() == map_name
1699            })
1700        }
1701        "call" => {
1702            let Some((function, _)) = python_call_parts(node) else {
1703                return false;
1704            };
1705            python_attribute_parts(function, src).is_some_and(|(receiver, method)| {
1706                receiver.kind() == "identifier"
1707                    && node_text(&receiver, src).trim() == map_name
1708                    && method != "get"
1709            })
1710        }
1711        _ => false,
1712    }
1713}
1714
1715fn python_map_binding_has_unsafe_use(
1716    resolver: &PythonLexicalBindingResolver<'_, '_>,
1717    map_name: &str,
1718    declaration_id: usize,
1719    owner: Option<Span>,
1720) -> bool {
1721    resolver.identifiers.iter().any(|identifier| {
1722        if node_text(identifier, resolver.src).trim() != map_name {
1723            return false;
1724        }
1725        if resolver.owner_for_use(span_of(resolver.file, identifier), map_name) != owner {
1726            return false;
1727        }
1728        if identifier
1729            .parent()
1730            .is_some_and(|parent| parent.kind() == "assignment" && parent.id() == declaration_id)
1731        {
1732            return false;
1733        }
1734        if identifier
1735            .parent()
1736            .is_some_and(|parent| matches!(parent.kind(), "global_statement" | "nonlocal_statement"))
1737        {
1738            // Binding directives carry no runtime value and cannot expose
1739            // or mutate the selected map by themselves.
1740            return false;
1741        }
1742        let Some(attribute) = identifier.parent().filter(|parent| parent.kind() == "attribute") else {
1743            // Reading one entry from a complete immutable literal map
1744            // cannot introduce the dynamic key into its selected value.
1745            // Projected writes were rejected above; this is only the
1746            // parsed value/base position of a subscript expression.
1747            if identifier.parent().is_some_and(|parent| {
1748                parent.kind() == "subscript"
1749                    && parent
1750                        .child_by_field_name("value")
1751                        .is_some_and(|value| value.id() == identifier.id())
1752            }) {
1753                return false;
1754            }
1755            // `candidate in STATIC_MAP` reads only the immutable map's
1756            // finite key set. It neither exposes nor mutates the map and
1757            // is the exact runtime predicate used to constrain a later
1758            // subscript selection.
1759            if identifier.parent().is_some_and(|parent| {
1760                if parent.kind() != "comparison_operator" {
1761                    return false;
1762                }
1763                let mut cursor = parent.walk();
1764                let operands = parent.named_children(&mut cursor).collect::<Vec<_>>();
1765                let [subject, collection] = operands.as_slice() else {
1766                    return false;
1767                };
1768                collection.id() == identifier.id()
1769                    && resolver
1770                        .src
1771                        .get(subject.end_byte()..collection.start_byte())
1772                        .and_then(|bytes| std::str::from_utf8(bytes).ok())
1773                        .is_some_and(|operator| operator.trim() == "in")
1774            }) {
1775                return false;
1776            }
1777            return true;
1778        };
1779        if attribute
1780            .child_by_field_name("object")
1781            .is_none_or(|object| object.id() != identifier.id())
1782        {
1783            return true;
1784        }
1785        let Some(call) = attribute.parent().filter(|parent| parent.kind() == "call") else {
1786            return true;
1787        };
1788        call.child_by_field_name("function")
1789            .is_none_or(|function| function.id() != attribute.id())
1790            || attribute
1791                .child_by_field_name("attribute")
1792                .is_none_or(|method| node_text(&method, resolver.src).trim() != "get")
1793    })
1794}
1795
1796fn python_finite_static_map(node: Node<'_>, src: &[u8]) -> bool {
1797    if node.kind() != "dictionary" || node.named_child_count() == 0 {
1798        return false;
1799    }
1800    let mut cursor = node.walk();
1801    let finite = node.named_children(&mut cursor).all(|entry| {
1802        entry.kind() == "pair"
1803            && entry
1804                .child_by_field_name("key")
1805                .is_some_and(|key| python_static_string(key, src).is_some())
1806            && entry
1807                .child_by_field_name("value")
1808                .is_some_and(|value| python_statically_constructed_value(value, src))
1809    });
1810    finite
1811}
1812
1813fn python_statically_constructed_value(node: Node<'_>, src: &[u8]) -> bool {
1814    match node.kind() {
1815        "string" | "concatenated_string" => python_static_string(node, src).is_some(),
1816        "integer" | "float" | "true" | "false" | "none" => true,
1817        "list" | "tuple" | "set" | "dictionary" => {
1818            let mut cursor = node.walk();
1819            let finite = node.named_children(&mut cursor).all(|child| {
1820                if child.kind() == "pair" {
1821                    child
1822                        .child_by_field_name("key")
1823                        .is_some_and(|key| python_statically_constructed_value(key, src))
1824                        && child
1825                            .child_by_field_name("value")
1826                            .is_some_and(|value| python_statically_constructed_value(value, src))
1827                } else {
1828                    python_statically_constructed_value(child, src)
1829                }
1830            });
1831            finite
1832        }
1833        "call" => {
1834            let Some((_, arguments)) = python_call_parts(node) else {
1835                return false;
1836            };
1837            python_argument_nodes(arguments)
1838                .into_iter()
1839                .all(|argument| python_statically_constructed_value(argument, src))
1840        }
1841        _ => false,
1842    }
1843}
1844
1845fn python_comprehension_character_constraints(
1846    index: &DeclIndex,
1847    tree: &Tree,
1848    file: FileId,
1849    src: &[u8],
1850) -> Vec<CharacterConstraintFact> {
1851    let mut facts = Vec::new();
1852    for assignment in collect_kinds(tree, &["assignment"]) {
1853        let Some(target_node) = assignment.child_by_field_name("left") else {
1854            continue;
1855        };
1856        let Some(mut value_node) = assignment.child_by_field_name("right") else {
1857            continue;
1858        };
1859        if target_node.kind() != "identifier" {
1860            continue;
1861        }
1862        while matches!(value_node.kind(), "subscript" | "parenthesized_expression") {
1863            let Some(inner) = value_node
1864                .child_by_field_name("value")
1865                .or_else(|| value_node.named_child(0))
1866            else {
1867                break;
1868            };
1869            value_node = inner;
1870        }
1871        let Some((function, arguments)) = python_call_parts(value_node) else {
1872            continue;
1873        };
1874        let Some((receiver, method)) = python_attribute_parts(function, src) else {
1875            continue;
1876        };
1877        if method != "join" || python_static_string(receiver, src).as_deref() != Some("") {
1878            continue;
1879        }
1880        let generator = if arguments.kind() == "generator_expression" {
1881            arguments
1882        } else {
1883            let args = python_argument_nodes(arguments);
1884            let [generator] = args.as_slice() else {
1885                continue;
1886            };
1887            *generator
1888        };
1889        let Some((input_place, classes, exact_characters)) =
1890            python_filtered_character_generator(generator, src)
1891        else {
1892            continue;
1893        };
1894        let transform_span = span_of(file, &assignment);
1895        let Some(decl) = python_enclosing_callable(index, transform_span) else {
1896            continue;
1897        };
1898        let target = node_text(&target_node, src).trim().to_string();
1899        let input_param_index = decl.params.iter().position(|param| param == &input_place);
1900        facts.push(CharacterConstraintFact {
1901            function_span: decl.span,
1902            transform_span,
1903            input_place,
1904            input_param_index,
1905            output: CharacterConstraintOutput::Assignment { target },
1906            domain: CharacterConstraintDomain::AllowOnly {
1907                classes,
1908                exact_characters,
1909            },
1910        });
1911    }
1912    facts
1913}
1914
1915fn python_filtered_character_generator(
1916    generator: Node<'_>,
1917    src: &[u8],
1918) -> Option<(String, Vec<CharacterClass>, Vec<String>)> {
1919    if generator.kind() != "generator_expression" {
1920        return None;
1921    }
1922    let body = generator.named_child(0)?;
1923    if body.kind() != "identifier" {
1924        return None;
1925    }
1926    let loop_variable = node_text(&body, src).trim();
1927    let mut cursor = generator.walk();
1928    let clauses: Vec<_> = generator.named_children(&mut cursor).skip(1).collect();
1929    let [for_clause, if_clause] = clauses.as_slice() else {
1930        return None;
1931    };
1932    if for_clause.kind() != "for_in_clause" || if_clause.kind() != "if_clause" {
1933        return None;
1934    }
1935    let left = for_clause
1936        .child_by_field_name("left")
1937        .or_else(|| for_clause.named_child(0))?;
1938    let right = for_clause
1939        .child_by_field_name("right")
1940        .or_else(|| for_clause.named_child(1))?;
1941    if left.kind() != "identifier"
1942        || right.kind() != "identifier"
1943        || node_text(&left, src).trim() != loop_variable
1944    {
1945        return None;
1946    }
1947    let condition = if_clause
1948        .child_by_field_name("condition")
1949        .or_else(|| if_clause.named_child(0))?;
1950    let mut classes = Vec::new();
1951    let mut exact_characters = Vec::new();
1952    if !python_character_predicate(condition, loop_variable, src, &mut classes, &mut exact_characters) {
1953        return None;
1954    }
1955    classes.sort_by_key(|class| match class {
1956        CharacterClass::Alphabetic => 0,
1957        CharacterClass::Alphanumeric => 1,
1958        CharacterClass::Digit => 2,
1959    });
1960    classes.dedup();
1961    exact_characters.sort();
1962    exact_characters.dedup();
1963    Some((
1964        node_text(&right, src).trim().to_string(),
1965        classes,
1966        exact_characters,
1967    ))
1968}
1969
1970fn python_character_predicate(
1971    node: Node<'_>,
1972    variable: &str,
1973    src: &[u8],
1974    classes: &mut Vec<CharacterClass>,
1975    exact_characters: &mut Vec<String>,
1976) -> bool {
1977    if node.kind() == "boolean_operator" {
1978        let (Some(left), Some(right)) = (
1979            node.child_by_field_name("left"),
1980            node.child_by_field_name("right"),
1981        ) else {
1982            return false;
1983        };
1984        let operator = src
1985            .get(left.end_byte()..right.start_byte())
1986            .and_then(|bytes| std::str::from_utf8(bytes).ok())
1987            .map(str::trim);
1988        return operator == Some("or")
1989            && python_character_predicate(left, variable, src, classes, exact_characters)
1990            && python_character_predicate(right, variable, src, classes, exact_characters);
1991    }
1992    if let Some((function, arguments)) = python_call_parts(node) {
1993        let Some((receiver, method)) = python_attribute_parts(function, src) else {
1994            return false;
1995        };
1996        if receiver.kind() != "identifier"
1997            || node_text(&receiver, src).trim() != variable
1998            || !python_argument_nodes(arguments).is_empty()
1999        {
2000            return false;
2001        }
2002        let class = match method {
2003            "isalpha" => CharacterClass::Alphabetic,
2004            "isalnum" => CharacterClass::Alphanumeric,
2005            "isdigit" => CharacterClass::Digit,
2006            _ => return false,
2007        };
2008        classes.push(class);
2009        return true;
2010    }
2011    if node.kind() != "comparison_operator" || node.named_child_count() != 2 {
2012        return false;
2013    }
2014    let (Some(left), Some(right)) = (node.named_child(0), node.named_child(1)) else {
2015        return false;
2016    };
2017    let operator = src
2018        .get(left.end_byte()..right.start_byte())
2019        .and_then(|bytes| std::str::from_utf8(bytes).ok())
2020        .map(str::trim);
2021    if operator != Some("==") {
2022        return false;
2023    }
2024    let literal = if left.kind() == "identifier" && node_text(&left, src).trim() == variable {
2025        python_static_string(right, src)
2026    } else if right.kind() == "identifier" && node_text(&right, src).trim() == variable {
2027        python_static_string(left, src)
2028    } else {
2029        None
2030    };
2031    let Some(literal) = literal.filter(|value| value.chars().count() == 1) else {
2032        return false;
2033    };
2034    exact_characters.push(literal);
2035    true
2036}
2037
2038fn python_regex_substitution_constraints(
2039    index: &DeclIndex,
2040    tree: &Tree,
2041    file: FileId,
2042    src: &[u8],
2043) -> Vec<CharacterConstraintFact> {
2044    let assignments = collect_kinds(tree, &["assignment"]);
2045    let mut compiled = Vec::new();
2046    for assignment in &assignments {
2047        let (Some(target), Some(value)) = (
2048            assignment.child_by_field_name("left"),
2049            assignment.child_by_field_name("right"),
2050        ) else {
2051            continue;
2052        };
2053        if target.kind() != "identifier" {
2054            continue;
2055        }
2056        let Some((function, arguments)) = python_call_parts(value) else {
2057            continue;
2058        };
2059        let args = python_argument_nodes(arguments);
2060        let Some(pattern) = args.first().and_then(|node| python_static_string(*node, src)) else {
2061            continue;
2062        };
2063        let Some(characters) = python_exact_regex_character_class(&pattern) else {
2064            continue;
2065        };
2066        let name = node_text(&target, src).trim().to_string();
2067        let writes = assignments
2068            .iter()
2069            .filter(|candidate| {
2070                candidate
2071                    .child_by_field_name("left")
2072                    .is_some_and(|left| node_text(&left, src).trim() == name)
2073            })
2074            .count();
2075        if writes == 1 {
2076            compiled.push((
2077                name,
2078                span_of(file, assignment),
2079                characters,
2080                node_text(&function, src).trim().to_string(),
2081            ));
2082        }
2083    }
2084
2085    let mut facts = Vec::new();
2086    for return_node in collect_kinds(tree, &["return_statement"]) {
2087        let Some(call) = return_node.named_child(0) else {
2088            continue;
2089        };
2090        let Some((function, arguments)) = python_call_parts(call) else {
2091            continue;
2092        };
2093        let Some((receiver, _)) = python_attribute_parts(function, src) else {
2094            continue;
2095        };
2096        if receiver.kind() != "identifier" {
2097            continue;
2098        }
2099        let receiver_name = node_text(&receiver, src).trim();
2100        let Some((_, _, mut excluded, factory_call)) = compiled
2101            .iter()
2102            .find(|(name, assignment_span, _, _)| {
2103                name == receiver_name && assignment_span.start < return_node.start_byte() as u64
2104            })
2105            .cloned()
2106        else {
2107            continue;
2108        };
2109        let args = python_argument_nodes(arguments);
2110        let [replacement, input] = args.as_slice() else {
2111            continue;
2112        };
2113        let (Some(replacement), true) = (
2114            python_exact_replacement_string(*replacement, src),
2115            input.kind() == "identifier",
2116        ) else {
2117            continue;
2118        };
2119        excluded.retain(|character| !replacement.contains(character));
2120        if excluded.is_empty() {
2121            continue;
2122        }
2123        let return_span = span_of(file, &return_node);
2124        let Some(decl) = python_enclosing_callable(index, return_span) else {
2125            continue;
2126        };
2127        if !python_is_single_statement_return(return_node) {
2128            continue;
2129        }
2130        let input_place = node_text(input, src).trim().to_string();
2131        let Some(input_param_index) = decl.params.iter().position(|param| param == &input_place) else {
2132            continue;
2133        };
2134        facts.push(CharacterConstraintFact {
2135            function_span: decl.span,
2136            transform_span: return_span,
2137            input_place,
2138            input_param_index: Some(input_param_index),
2139            output: CharacterConstraintOutput::Return,
2140            domain: CharacterConstraintDomain::ProviderBound {
2141                factory_call,
2142                operation_call: node_text(&function, src).trim().to_string(),
2143                domain: Box::new(CharacterConstraintDomain::ExcludesExact { characters: excluded }),
2144            },
2145        });
2146    }
2147    facts
2148}
2149
2150/// Lower a compiled-regex rejection guard as a provider-bound alphabet fact.
2151/// The frontend proves anchoring, the accepted character domain, branch
2152/// polarity, and terminal rejection from Python syntax. It records the exact
2153/// factory and predicate calls but does not decide that those APIs are a
2154/// sanitizer; rulepack metadata performs that selection.
2155fn python_regex_validation_constraints(
2156    index: &DeclIndex,
2157    tree: &Tree,
2158    file: FileId,
2159    src: &[u8],
2160) -> Vec<CharacterConstraintFact> {
2161    let assignments = collect_kinds(tree, &["assignment"]);
2162    let mut compiled = Vec::new();
2163    for assignment in &assignments {
2164        let (Some(target), Some(value)) = (
2165            assignment.child_by_field_name("left"),
2166            assignment.child_by_field_name("right"),
2167        ) else {
2168            continue;
2169        };
2170        if target.kind() != "identifier" {
2171            continue;
2172        }
2173        let Some((function, arguments)) = python_call_parts(value) else {
2174            continue;
2175        };
2176        let args = python_argument_nodes(arguments);
2177        let Some(pattern) = args.first().and_then(|node| python_static_string(*node, src)) else {
2178            continue;
2179        };
2180        let Some(domain) = python_anchored_regex_character_domain(&pattern) else {
2181            continue;
2182        };
2183        let name = node_text(&target, src).trim().to_string();
2184        if assignments
2185            .iter()
2186            .filter(|candidate| {
2187                candidate
2188                    .child_by_field_name("left")
2189                    .is_some_and(|left| node_text(&left, src).trim() == name)
2190            })
2191            .count()
2192            != 1
2193        {
2194            continue;
2195        }
2196        compiled.push((
2197            name,
2198            span_of(file, assignment),
2199            node_text(&function, src).trim().to_string(),
2200            domain,
2201        ));
2202    }
2203
2204    let mut facts = Vec::new();
2205    for branch in collect_kinds(tree, &["if_statement"]) {
2206        let (Some(condition), Some(consequence)) = (
2207            branch.child_by_field_name("condition"),
2208            branch.child_by_field_name("consequence"),
2209        ) else {
2210            continue;
2211        };
2212        if branch.child_by_field_name("alternative").is_some() || !python_block_abruptly_exits(consequence) {
2213            continue;
2214        }
2215        let Some(call) = python_negated_guard_call(condition) else {
2216            continue;
2217        };
2218        let Some((function, arguments)) = python_call_parts(call) else {
2219            continue;
2220        };
2221        let Some((receiver, _)) = python_attribute_parts(function, src) else {
2222            continue;
2223        };
2224        if receiver.kind() != "identifier" {
2225            continue;
2226        }
2227        let args = python_argument_nodes(arguments);
2228        let [input] = args.as_slice() else {
2229            continue;
2230        };
2231        let Some(input_place) = python_exact_guarded_identifier(*input, src) else {
2232            continue;
2233        };
2234        let receiver_name = node_text(&receiver, src).trim();
2235        let Some((_, _, factory_call, domain)) = compiled
2236            .iter()
2237            .filter(|(name, span, _, _)| name == receiver_name && span.start < branch.start_byte() as u64)
2238            .max_by_key(|(_, span, _, _)| (span.start, span.end))
2239            .cloned()
2240        else {
2241            continue;
2242        };
2243        let branch_span = span_of(file, &branch);
2244        let Some(decl) = python_enclosing_callable(index, branch_span) else {
2245            continue;
2246        };
2247        if assignments.iter().any(|assignment| {
2248            assignment.start_byte() > branch.end_byte()
2249                && assignment.end_byte() <= decl.span.end as usize
2250                && assignment
2251                    .child_by_field_name("left")
2252                    .is_some_and(|left| node_text(&left, src).trim() == input_place)
2253        }) {
2254            continue;
2255        }
2256        facts.push(CharacterConstraintFact {
2257            function_span: decl.span,
2258            transform_span: branch_span,
2259            input_param_index: decl.params.iter().position(|parameter| parameter == &input_place),
2260            input_place: input_place.clone(),
2261            output: CharacterConstraintOutput::Assignment { target: input_place },
2262            domain: CharacterConstraintDomain::ProviderBound {
2263                factory_call,
2264                operation_call: node_text(&function, src).trim().to_string(),
2265                domain: Box::new(domain),
2266            },
2267        });
2268    }
2269    facts
2270}
2271
2272fn python_negated_guard_call(mut condition: Node<'_>) -> Option<Node<'_>> {
2273    while matches!(
2274        condition.kind(),
2275        "parenthesized_expression" | "parenthesized_expression_list"
2276    ) {
2277        condition = condition.named_child(0)?;
2278    }
2279    if condition.kind() != "not_operator" {
2280        return None;
2281    }
2282    condition
2283        .child_by_field_name("argument")
2284        .or_else(|| condition.named_child(0))
2285}
2286
2287fn python_block_abruptly_exits(block: Node<'_>) -> bool {
2288    let mut cursor = block.walk();
2289    let last = block
2290        .named_children(&mut cursor)
2291        .filter(|node| node.kind() != "comment")
2292        .last();
2293    last.is_some_and(|node| matches!(node.kind(), "return_statement" | "raise_statement"))
2294}
2295
2296fn python_exact_guarded_identifier(mut node: Node<'_>, src: &[u8]) -> Option<String> {
2297    while node.kind() == "parenthesized_expression" {
2298        node = node.named_child(0)?;
2299    }
2300    (node.kind() == "identifier").then(|| node_text(&node, src).trim().to_string())
2301}
2302
2303fn python_anchored_regex_character_domain(pattern: &str) -> Option<CharacterConstraintDomain> {
2304    let body = pattern.strip_prefix('^')?.strip_suffix('$')?;
2305    if body.is_empty() || body.contains("[^") || body.contains("(?") {
2306        return None;
2307    }
2308    let mut in_class = false;
2309    let mut escaped = false;
2310    let mut class = String::new();
2311    for character in body.chars() {
2312        if escaped {
2313            if !matches!(character, '.' | '-' | '_' | 'd' | 'w') {
2314                return None;
2315            }
2316            escaped = false;
2317            if in_class {
2318                class.push(character);
2319            }
2320            continue;
2321        }
2322        match character {
2323            '\\' => escaped = true,
2324            '[' if !in_class => {
2325                in_class = true;
2326                class.clear();
2327            }
2328            ']' if in_class => {
2329                if !python_safe_regex_character_class(&class) {
2330                    return None;
2331                }
2332                in_class = false;
2333            }
2334            '/' => return None,
2335            '.' if !in_class => return None,
2336            character if in_class => class.push(character),
2337            character if character.is_ascii_alphanumeric() || "_-()|{}?+*,".contains(character) => {}
2338            _ => return None,
2339        }
2340    }
2341    if escaped || in_class {
2342        return None;
2343    }
2344    Some(CharacterConstraintDomain::ExcludesExact {
2345        characters: vec!["/".to_string(), "\\".to_string()],
2346    })
2347}
2348
2349fn python_safe_regex_character_class(class: &str) -> bool {
2350    if class.is_empty() || class.contains('/') || class.contains('\\') {
2351        return false;
2352    }
2353    let without_ranges = class.replace("A-Z", "").replace("a-z", "").replace("0-9", "");
2354    without_ranges
2355        .chars()
2356        .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.'))
2357}
2358
2359fn python_exact_replacement_string(node: Node<'_>, src: &[u8]) -> Option<String> {
2360    python_static_string(node, src)
2361}
2362
2363fn python_exact_regex_character_class(pattern: &str) -> Option<Vec<String>> {
2364    let inner = pattern.strip_prefix('[')?.strip_suffix(']')?;
2365    if inner.starts_with('^') {
2366        return None;
2367    }
2368    let mut characters = Vec::new();
2369    let mut chars = inner.chars().peekable();
2370    while let Some(character) = chars.next() {
2371        if character == '-' {
2372            return None;
2373        }
2374        let decoded = if character != '\\' {
2375            character
2376        } else {
2377            match chars.next()? {
2378                'r' => '\r',
2379                'n' => '\n',
2380                't' => '\t',
2381                '\\' => '\\',
2382                '"' => '"',
2383                '\'' => '\'',
2384                'x' => {
2385                    let digits = [chars.next()?, chars.next()?];
2386                    let value = u8::from_str_radix(&digits.iter().collect::<String>(), 16).ok()?;
2387                    char::from(value)
2388                }
2389                _ => return None,
2390            }
2391        };
2392        characters.push(decoded.to_string());
2393    }
2394    characters.sort();
2395    characters.dedup();
2396    (!characters.is_empty()).then_some(characters)
2397}
2398
2399fn python_call_parts(call: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
2400    (call.kind() == "call").then_some((
2401        call.child_by_field_name("function")?,
2402        call.child_by_field_name("arguments")?,
2403    ))
2404}
2405
2406fn python_attribute_parts<'a>(attribute: Node<'a>, src: &'a [u8]) -> Option<(Node<'a>, &'a str)> {
2407    if attribute.kind() != "attribute" {
2408        return None;
2409    }
2410    let object = attribute.child_by_field_name("object")?;
2411    let name = attribute.child_by_field_name("attribute")?;
2412    Some((object, node_text(&name, src).trim()))
2413}
2414
2415fn python_argument_nodes(arguments: Node<'_>) -> Vec<Node<'_>> {
2416    let mut cursor = arguments.walk();
2417    arguments
2418        .named_children(&mut cursor)
2419        .filter(|node| node.kind() != "keyword_argument")
2420        .collect()
2421}
2422
2423fn python_enclosing_callable(index: &DeclIndex, span: Span) -> Option<&bonsai_lang_api::Decl> {
2424    index
2425        .defs
2426        .iter()
2427        .filter(|decl| {
2428            matches!(
2429                decl.kind,
2430                bonsai_lang_api::DeclKind::Function
2431                    | bonsai_lang_api::DeclKind::Method
2432                    | bonsai_lang_api::DeclKind::Constructor
2433            ) && decl.span.start <= span.start
2434                && span.end <= decl.span.end
2435        })
2436        .min_by_key(|decl| decl.span.len())
2437}
2438
2439fn python_is_single_statement_return(return_node: Node<'_>) -> bool {
2440    return_node.parent().is_some_and(|block| {
2441        block.kind() == "block"
2442            && block
2443                .named_children(&mut block.walk())
2444                .filter(|node| node.kind() != "comment")
2445                .count()
2446                == 1
2447    })
2448}
2449
2450fn python_same_origin_path_constraints(
2451    index: &DeclIndex,
2452    tree: &Tree,
2453    file: FileId,
2454    src: &[u8],
2455) -> Vec<SameOriginPathConstraintFact> {
2456    let imports = parse_imports(tree, src, file);
2457    let mut facts = Vec::new();
2458    for function in collect_kinds(tree, &["function_definition"]) {
2459        let function_span = span_of(file, &function);
2460        let Some(decl) = index.defs.iter().find(|decl| decl.span == function_span) else {
2461            continue;
2462        };
2463        let Some(body) = function.child_by_field_name("body") else {
2464            continue;
2465        };
2466        let mut cursor = body.walk();
2467        let statements: Vec<_> = body
2468            .named_children(&mut cursor)
2469            .filter(|node| node.kind() != "comment")
2470            .collect();
2471        let [assignment, guard, final_return] = statements.as_slice() else {
2472            continue;
2473        };
2474        if assignment.kind() != "expression_statement" && assignment.kind() != "assignment" {
2475            continue;
2476        }
2477        let assignment = if assignment.kind() == "assignment" {
2478            *assignment
2479        } else {
2480            assignment.named_child(0).unwrap_or(*assignment)
2481        };
2482        let (Some(parsed_node), Some(parser_call)) = (
2483            assignment.child_by_field_name("left"),
2484            assignment.child_by_field_name("right"),
2485        ) else {
2486            continue;
2487        };
2488        if parsed_node.kind() != "identifier" {
2489            continue;
2490        }
2491        let parsed = node_text(&parsed_node, src).trim();
2492        let Some((parser, parser_arguments)) = python_call_parts(parser_call) else {
2493            continue;
2494        };
2495        let Some(provider_call) = python_imported_call_identity(parser, &imports, src) else {
2496            continue;
2497        };
2498        let parser_args = python_argument_nodes(parser_arguments);
2499        let [input_node] = parser_args.as_slice() else {
2500            continue;
2501        };
2502        if input_node.kind() != "identifier" {
2503            continue;
2504        }
2505        let input = node_text(input_node, src).trim();
2506        let Some(input_param_index) = decl.params.iter().position(|parameter| parameter == input) else {
2507            continue;
2508        };
2509        if guard.kind() != "if_statement" || guard.child_by_field_name("alternative").is_some() {
2510            continue;
2511        }
2512        let (Some(condition), Some(consequence)) = (
2513            guard.child_by_field_name("condition"),
2514            guard.child_by_field_name("consequence"),
2515        ) else {
2516            continue;
2517        };
2518        if !python_block_returns_static_path(consequence, src, "/")
2519            || !python_return_is_exact_place(*final_return, input, src)
2520        {
2521            continue;
2522        }
2523        let mut terms = Vec::new();
2524        python_collect_or_terms(condition, src, &mut terms);
2525        if terms.len() != 4 {
2526            continue;
2527        }
2528        let rejects_scheme = terms
2529            .iter()
2530            .any(|term| python_attribute_is(*term, parsed, "scheme", src));
2531        let rejects_authority = terms
2532            .iter()
2533            .any(|term| python_attribute_is(*term, parsed, "netloc", src));
2534        let requires_absolute_path = terms.iter().any(|term| {
2535            term.kind() == "not_operator"
2536                && term
2537                    .named_child(0)
2538                    .is_some_and(|operand| python_startswith_literal(operand, input, "/", src))
2539        });
2540        let rejects_scheme_relative_path = terms
2541            .iter()
2542            .any(|term| python_startswith_literal(*term, input, "//", src));
2543        if rejects_scheme && rejects_authority && requires_absolute_path && rejects_scheme_relative_path {
2544            facts.push(SameOriginPathConstraintFact {
2545                function_span,
2546                guard_span: span_of(file, guard),
2547                input_place: input.to_string(),
2548                input_param_index: Some(input_param_index),
2549                provider_call: Some(provider_call),
2550                rejects_scheme,
2551                rejects_authority,
2552                requires_absolute_path,
2553                rejects_scheme_relative_path,
2554            });
2555        }
2556    }
2557    facts.sort_by_key(|fact| (fact.function_span.start, fact.guard_span.start));
2558    facts.dedup();
2559    facts
2560}
2561
2562fn python_imported_call_identity(callee: Node<'_>, imports: &[ImportSpec], src: &[u8]) -> Option<String> {
2563    let rendered = node_text(&callee, src).trim();
2564    if rendered.is_empty() {
2565        return None;
2566    }
2567    for import in imports {
2568        if let Some(original) = import.original_name.as_deref() {
2569            let local = import.alias.as_deref().unwrap_or(original);
2570            if rendered == local {
2571                return Some(format!("{}.{}", import.module, original));
2572            }
2573            if let Some(suffix) = rendered
2574                .strip_prefix(local)
2575                .and_then(|tail| tail.strip_prefix('.'))
2576            {
2577                return Some(format!("{}.{}.{}", import.module, original, suffix));
2578            }
2579            continue;
2580        }
2581        if rendered == import.module || rendered.starts_with(&format!("{}.", import.module)) {
2582            return Some(rendered.to_string());
2583        }
2584        let Some(local) = import.alias.as_deref() else {
2585            continue;
2586        };
2587        if rendered == local {
2588            return Some(import.module.clone());
2589        }
2590        if let Some(suffix) = rendered
2591            .strip_prefix(local)
2592            .and_then(|tail| tail.strip_prefix('.'))
2593        {
2594            return Some(format!("{}.{}", import.module, suffix));
2595        }
2596    }
2597    None
2598}
2599
2600fn python_collect_or_terms<'tree>(node: Node<'tree>, src: &[u8], out: &mut Vec<Node<'tree>>) {
2601    if node.kind() == "boolean_operator" {
2602        let (Some(left), Some(right)) = (
2603            node.child_by_field_name("left"),
2604            node.child_by_field_name("right"),
2605        ) else {
2606            out.push(node);
2607            return;
2608        };
2609        let operator = src
2610            .get(left.end_byte()..right.start_byte())
2611            .and_then(|bytes| std::str::from_utf8(bytes).ok())
2612            .map(str::trim);
2613        if operator == Some("or") {
2614            python_collect_or_terms(left, src, out);
2615            python_collect_or_terms(right, src, out);
2616            return;
2617        }
2618    }
2619    out.push(node);
2620}
2621
2622fn python_attribute_is(node: Node<'_>, object: &str, field: &str, src: &[u8]) -> bool {
2623    python_attribute_parts(node, src).is_some_and(|(receiver, name)| {
2624        receiver.kind() == "identifier" && node_text(&receiver, src).trim() == object && name == field
2625    })
2626}
2627
2628fn python_startswith_literal(node: Node<'_>, receiver: &str, literal: &str, src: &[u8]) -> bool {
2629    let Some((function, arguments)) = python_call_parts(node) else {
2630        return false;
2631    };
2632    let Some((object, method)) = python_attribute_parts(function, src) else {
2633        return false;
2634    };
2635    let args = python_argument_nodes(arguments);
2636    object.kind() == "identifier"
2637        && node_text(&object, src).trim() == receiver
2638        && method == "startswith"
2639        && args
2640            .as_slice()
2641            .first()
2642            .and_then(|node| python_static_string(*node, src))
2643            .as_deref()
2644            == Some(literal)
2645        && args.len() == 1
2646}
2647
2648fn python_block_returns_static_path(block: Node<'_>, src: &[u8], expected: &str) -> bool {
2649    let mut cursor = block.walk();
2650    let statements: Vec<_> = block
2651        .named_children(&mut cursor)
2652        .filter(|node| node.kind() != "comment")
2653        .collect();
2654    let [return_node] = statements.as_slice() else {
2655        return false;
2656    };
2657    return_node.kind() == "return_statement"
2658        && return_node
2659            .named_child(0)
2660            .and_then(|value| python_static_string(value, src))
2661            .as_deref()
2662            == Some(expected)
2663}
2664
2665fn python_return_is_exact_place(return_node: Node<'_>, expected: &str, src: &[u8]) -> bool {
2666    return_node.kind() == "return_statement"
2667        && return_node
2668            .named_child(0)
2669            .is_some_and(|value| value.kind() == "identifier" && node_text(&value, src).trim() == expected)
2670}
2671
2672/// Lower Python string concatenation and `value or literal` fallback syntax
2673/// into a complete, typed composition. Unsupported operands fail closed, so
2674/// consumers never infer safety from a partial expression.
2675fn python_string_compositions(tree: &Tree, file: FileId, src: &[u8]) -> Vec<StringCompositionFact> {
2676    let mut facts = Vec::new();
2677    for assignment in collect_kinds(tree, &["assignment"]) {
2678        let (Some(target), Some(value)) = (
2679            assignment.child_by_field_name("left"),
2680            assignment.child_by_field_name("right"),
2681        ) else {
2682            continue;
2683        };
2684        if target.kind() != "identifier" {
2685            continue;
2686        }
2687        let mut parts = Vec::new();
2688        if lower_python_string_composition(value, file, src, &mut parts) && parts.len() > 1 {
2689            facts.push(StringCompositionFact {
2690                container_span: span_of(file, &assignment),
2691                value_span: span_of(file, &value),
2692                target: Some(node_text(&target, src).trim().to_string()),
2693                parts,
2694            });
2695        }
2696    }
2697    for return_node in collect_kinds(tree, &["return_statement"]) {
2698        let Some(value) = return_node.named_child(0) else {
2699            continue;
2700        };
2701        let mut parts = Vec::new();
2702        if lower_python_string_composition(value, file, src, &mut parts) && parts.len() > 1 {
2703            facts.push(StringCompositionFact {
2704                container_span: span_of(file, &return_node),
2705                value_span: span_of(file, &value),
2706                target: None,
2707                parts,
2708            });
2709        }
2710    }
2711    facts.sort_by_key(|fact| {
2712        (
2713            fact.container_span.start,
2714            fact.container_span.end,
2715            fact.value_span.start,
2716            fact.value_span.end,
2717        )
2718    });
2719    facts.dedup();
2720    facts
2721}
2722
2723fn lower_python_string_composition(
2724    mut node: Node<'_>,
2725    file: FileId,
2726    src: &[u8],
2727    out: &mut Vec<StringCompositionPart>,
2728) -> bool {
2729    while matches!(
2730        node.kind(),
2731        "parenthesized_expression" | "parenthesized_expression_list"
2732    ) {
2733        let Some(inner) = node.named_child(0) else {
2734            return false;
2735        };
2736        node = inner;
2737    }
2738    if let Some(value) = python_static_string(node, src) {
2739        out.push(StringCompositionPart::Literal { value });
2740        return true;
2741    }
2742    if node.kind() == "string" && lower_python_formatted_string(node, src, out) {
2743        return true;
2744    }
2745    if let Some(place) = python_exact_place(node, src) {
2746        out.push(StringCompositionPart::Place { place });
2747        return true;
2748    }
2749    if let Some((function, _)) = python_call_parts(node) {
2750        out.push(StringCompositionPart::Call {
2751            span: span_of(file, &function),
2752        });
2753        return true;
2754    }
2755    if node.kind() == "binary_operator" {
2756        let (Some(left), Some(right)) = (
2757            node.child_by_field_name("left"),
2758            node.child_by_field_name("right"),
2759        ) else {
2760            return false;
2761        };
2762        let operator = src
2763            .get(left.end_byte()..right.start_byte())
2764            .and_then(|bytes| std::str::from_utf8(bytes).ok())
2765            .map(str::trim);
2766        return operator == Some("+")
2767            && lower_python_string_composition(left, file, src, out)
2768            && lower_python_string_composition(right, file, src, out);
2769    }
2770    if node.kind() == "boolean_operator" {
2771        let (Some(left), Some(right)) = (
2772            node.child_by_field_name("left"),
2773            node.child_by_field_name("right"),
2774        ) else {
2775            return false;
2776        };
2777        let operator = src
2778            .get(left.end_byte()..right.start_byte())
2779            .and_then(|bytes| std::str::from_utf8(bytes).ok())
2780            .map(str::trim);
2781        let (Some(place), Some(fallback)) = (python_exact_place(left, src), python_static_string(right, src))
2782        else {
2783            return false;
2784        };
2785        if operator == Some("or") {
2786            out.push(StringCompositionPart::PlaceOrLiteral { place, fallback });
2787            return true;
2788        }
2789    }
2790    false
2791}
2792
2793fn lower_python_formatted_string(node: Node<'_>, src: &[u8], out: &mut Vec<StringCompositionPart>) -> bool {
2794    let text = node_text(&node, src).trim();
2795    let Some(quote_start) = text.find(['\'', '"']) else {
2796        return false;
2797    };
2798    let prefix = text[..quote_start].to_ascii_lowercase();
2799    if !prefix.contains('f')
2800        || prefix.contains('b')
2801        || prefix
2802            .chars()
2803            .any(|character| !matches!(character, 'f' | 'r' | 'u'))
2804    {
2805        return false;
2806    }
2807    let mut saw_interpolation = false;
2808    let mut cursor = node.walk();
2809    for child in node.named_children(&mut cursor) {
2810        match child.kind() {
2811            "string_start" | "string_end" => {}
2812            "string_content" => {
2813                let value = node_text(&child, src);
2814                if value.contains('\\') {
2815                    return false;
2816                }
2817                push_python_composition_literal(out, value);
2818            }
2819            "interpolation" => {
2820                let Some(expression) = child
2821                    .child_by_field_name("expression")
2822                    .or_else(|| child.named_child(0))
2823                else {
2824                    return false;
2825                };
2826                let Some(place) = python_exact_place(expression, src) else {
2827                    return false;
2828                };
2829                out.push(StringCompositionPart::Place { place });
2830                saw_interpolation = true;
2831            }
2832            _ => return false,
2833        }
2834    }
2835    saw_interpolation
2836}
2837
2838fn push_python_composition_literal(out: &mut Vec<StringCompositionPart>, value: &str) {
2839    if value.is_empty() {
2840        return;
2841    }
2842    if let Some(StringCompositionPart::Literal { value: previous }) = out.last_mut() {
2843        previous.push_str(value);
2844    } else {
2845        out.push(StringCompositionPart::Literal {
2846            value: value.to_string(),
2847        });
2848    }
2849}
2850
2851fn python_exact_place(node: Node<'_>, src: &[u8]) -> Option<String> {
2852    match node.kind() {
2853        "identifier" => {
2854            let name = node_text(&node, src).trim();
2855            (!name.is_empty()).then(|| name.to_string())
2856        }
2857        "attribute" => {
2858            let object = node.child_by_field_name("object")?;
2859            let attribute = node.child_by_field_name("attribute")?;
2860            let object = python_exact_place(object, src)?;
2861            let attribute = node_text(&attribute, src).trim();
2862            (!attribute.is_empty()).then(|| format!("{object}.{attribute}"))
2863        }
2864        "parenthesized_expression" | "parenthesized_expression_list" => {
2865            python_exact_place(node.named_child(0)?, src)
2866        }
2867        _ => None,
2868    }
2869}
2870
2871/// Walk every function/method/lambda body once and record the
2872/// parameter type-alias bindings emitted by typed parameters. Returns
2873/// `(decl_span, aliases)` pairs so `extract_declarations` can attach them to
2874/// the right `Decl`.
2875fn collect_python_method_type_aliases(
2876    tree: &Tree,
2877    file: FileId,
2878    src: &[u8],
2879) -> Vec<(bonsai_common::Span, Vec<TypeAliasBinding>)> {
2880    let mut out = Vec::new();
2881    for fn_node in collect_kinds(tree, &["function_definition", "lambda"]) {
2882        let mut aliases: Vec<TypeAliasBinding> = Vec::new();
2883        if let Some(params) = fn_node.child_by_field_name("parameters") {
2884            collect_python_parameter_aliases(params, src, &mut aliases);
2885        }
2886        if let Some(body) = fn_node.child_by_field_name("body") {
2887            collect_python_annotated_assignment_aliases(body, src, &mut aliases);
2888        }
2889        dedup_python_type_aliases(&mut aliases);
2890        if !aliases.is_empty() {
2891            out.push((span_of(file, &fn_node), aliases));
2892        }
2893    }
2894    out
2895}
2896
2897/// Collect exact local / receiver-field annotations from one function body.
2898/// Nested callables and classes own separate declaration scopes and are
2899/// indexed independently, so this walk deliberately does not descend into
2900/// them.
2901fn collect_python_annotated_assignment_aliases(node: Node<'_>, src: &[u8], out: &mut Vec<TypeAliasBinding>) {
2902    if node.kind() == "assignment" {
2903        if let (Some(left), Some(type_node)) =
2904            (node.child_by_field_name("left"), node.child_by_field_name("type"))
2905        {
2906            if let (Some(place), Some(type_name)) = (
2907                python_exact_place(left, src),
2908                canonical_python_type_from_node(type_node, src),
2909            ) {
2910                push_python_type_alias(out, &place, &type_name);
2911            }
2912        }
2913    }
2914    let mut cursor = node.walk();
2915    for child in node.named_children(&mut cursor) {
2916        if matches!(
2917            child.kind(),
2918            "function_definition" | "lambda" | "class_definition"
2919        ) {
2920            continue;
2921        }
2922        collect_python_annotated_assignment_aliases(child, src, out);
2923    }
2924}
2925
2926/// Collect direct parameter-default calls without assigning them any
2927/// framework semantics. For example, both `x = Body(...)` and
2928/// `x = project.Body(...)` become syntax facts owned by the matching
2929/// parameter; rulepack `default_call` constraints decide whether either fact
2930/// represents a source, sanitizer, or other security boundary.
2931fn collect_python_param_default_calls(
2932    tree: &Tree,
2933    file: FileId,
2934    src: &[u8],
2935) -> Vec<(Span, Vec<(String, String)>)> {
2936    let mut out = Vec::new();
2937    for fn_node in collect_kinds(tree, &["function_definition", "lambda"]) {
2938        let mut calls = Vec::new();
2939        if let Some(params) = fn_node.child_by_field_name("parameters") {
2940            collect_python_parameter_default_calls(params, src, &mut calls);
2941        }
2942        dedup_python_param_default_calls(&mut calls);
2943        if !calls.is_empty() {
2944            out.push((span_of(file, &fn_node), calls));
2945        }
2946    }
2947    out
2948}
2949
2950fn collect_python_parameter_default_calls(node: Node<'_>, src: &[u8], out: &mut Vec<(String, String)>) {
2951    let mut cursor = node.walk();
2952    for child in node.named_children(&mut cursor) {
2953        match child.kind() {
2954            "typed_default_parameter" | "default_parameter" => {
2955                if let Some(binding) = python_param_default_call(child, src) {
2956                    out.push(binding);
2957                }
2958            }
2959            _ => collect_python_parameter_default_calls(child, src, out),
2960        }
2961    }
2962}
2963
2964fn python_param_default_call(node: Node<'_>, src: &[u8]) -> Option<(String, String)> {
2965    let name_node = node
2966        .child_by_field_name("name")
2967        .or_else(|| first_named_child_of_kind(node, &["identifier"]))?;
2968    let name = node_text(&name_node, src).trim().to_string();
2969    if name.is_empty() {
2970        return None;
2971    }
2972    let value_node = node.child_by_field_name("value")?;
2973    if value_node.kind() != "call" {
2974        return None;
2975    }
2976    let function = value_node.child_by_field_name("function")?;
2977    let callee = python_exact_place(function, src)?;
2978    Some((name, callee))
2979}
2980
2981fn dedup_python_param_default_calls(calls: &mut Vec<(String, String)>) {
2982    let mut deduped = Vec::new();
2983    for (name, callee) in calls.drain(..) {
2984        if !deduped
2985            .iter()
2986            .any(|(existing_name, existing_callee)| existing_name == &name && existing_callee == &callee)
2987        {
2988            deduped.push((name, callee));
2989        }
2990    }
2991    *calls = deduped;
2992}
2993
2994fn merge_python_param_default_calls(decl: &mut bonsai_lang_api::Decl, calls: &[(String, String)]) {
2995    if decl.params.is_empty() || calls.is_empty() {
2996        return;
2997    }
2998    if decl.param_default_calls.len() < decl.params.len() {
2999        decl.param_default_calls.resize_with(decl.params.len(), Vec::new);
3000    }
3001    for (name, callee) in calls {
3002        let Some(idx) = decl.params.iter().position(|param| param == name) else {
3003            continue;
3004        };
3005        let defaults = &mut decl.param_default_calls[idx];
3006        if !defaults.iter().any(|existing| existing == callee) {
3007            defaults.push(callee.clone());
3008            defaults.sort();
3009            defaults.dedup();
3010        }
3011    }
3012}
3013
3014#[derive(Clone, Debug, Eq, PartialEq)]
3015struct PythonPropertyAlias {
3016    class_symbol: SymbolId,
3017    property_name: String,
3018    receiver_name: String,
3019    target_tail: String,
3020}
3021
3022fn collect_python_property_function_spans(tree: &Tree, file: FileId, src: &[u8]) -> Vec<Span> {
3023    let mut spans = Vec::new();
3024    for decorated in collect_kinds(tree, &["decorated_definition"]) {
3025        if !python_decorated_definition_has_property(&decorated, src) {
3026            continue;
3027        }
3028        let Some(function) = first_named_child_of_kind(decorated, &["function_definition"]) else {
3029            continue;
3030        };
3031        let span = span_of(file, &function);
3032        if !spans.contains(&span) {
3033            spans.push(span);
3034        }
3035    }
3036    spans
3037}
3038
3039fn python_decorated_definition_has_property(node: &Node<'_>, src: &[u8]) -> bool {
3040    let mut cursor = node.walk();
3041    let has_property = node
3042        .named_children(&mut cursor)
3043        .any(|child| child.kind() == "decorator" && python_decorator_is_property(&child, src));
3044    has_property
3045}
3046
3047fn python_decorator_is_property(node: &Node<'_>, src: &[u8]) -> bool {
3048    let text = node_text(node, src).trim();
3049    text.strip_prefix('@')
3050        .map(str::trim)
3051        .is_some_and(|decorator| decorator == "property")
3052}
3053
3054fn collect_python_property_aliases(idx: &DeclIndex, property_fn_spans: &[Span]) -> Vec<PythonPropertyAlias> {
3055    let mut aliases = Vec::new();
3056    for decl in &idx.defs {
3057        if !property_fn_spans.contains(&decl.span) {
3058            continue;
3059        }
3060        let Some(class_symbol) = decl.parent else {
3061            continue;
3062        };
3063        let Some(receiver_idx) = decl.receiver_param_index else {
3064            continue;
3065        };
3066        let Some(receiver_name) = decl.params.get(receiver_idx) else {
3067            continue;
3068        };
3069        let Some(target_tail) = python_property_return_tail(decl, receiver_name) else {
3070            continue;
3071        };
3072        aliases.push(PythonPropertyAlias {
3073            class_symbol,
3074            property_name: decl.name.clone(),
3075            receiver_name: receiver_name.clone(),
3076            target_tail,
3077        });
3078    }
3079    aliases
3080}
3081
3082fn python_property_return_tail(decl: &bonsai_lang_api::Decl, receiver_name: &str) -> Option<String> {
3083    for event in &decl.flow_events {
3084        if let Some(tail) = python_property_return_tail_from_event(event, receiver_name) {
3085            return Some(tail);
3086        }
3087    }
3088    None
3089}
3090
3091fn python_property_return_tail_from_event(
3092    event: &bonsai_lang_api::FlowEvent,
3093    receiver_name: &str,
3094) -> Option<String> {
3095    match event {
3096        bonsai_lang_api::FlowEvent::Return { value_flow, .. } => value_flow
3097            .projection
3098            .as_ref()
3099            .filter(|projection| projection.base == receiver_name)
3100            .map(|projection| projection.path.join("."))
3101            .filter(|tail| !tail.is_empty()),
3102        bonsai_lang_api::FlowEvent::Branch {
3103            then_events,
3104            else_events,
3105            ..
3106        } => then_events
3107            .iter()
3108            .chain(else_events.iter())
3109            .find_map(|event| python_property_return_tail_from_event(event, receiver_name)),
3110        bonsai_lang_api::FlowEvent::Loop { body, .. }
3111        | bonsai_lang_api::FlowEvent::Defer { body, .. }
3112        | bonsai_lang_api::FlowEvent::Using { body, .. } => body
3113            .iter()
3114            .find_map(|event| python_property_return_tail_from_event(event, receiver_name)),
3115        bonsai_lang_api::FlowEvent::Try {
3116            body,
3117            catch_events,
3118            finally_events,
3119            ..
3120        } => body
3121            .iter()
3122            .chain(catch_events.iter())
3123            .chain(finally_events.iter())
3124            .find_map(|event| python_property_return_tail_from_event(event, receiver_name)),
3125        _ => None,
3126    }
3127}
3128
3129fn python_property_aliases_for_decl(
3130    idx: &DeclIndex,
3131    decl: &bonsai_lang_api::Decl,
3132    property_aliases: &[PythonPropertyAlias],
3133) -> Vec<PythonPropertyAlias> {
3134    let Some(class_symbol) = decl.parent else {
3135        return Vec::new();
3136    };
3137    let mut out = Vec::new();
3138    let mut seen_classes = std::collections::HashSet::new();
3139    collect_python_property_aliases_for_class(
3140        idx,
3141        class_symbol,
3142        property_aliases,
3143        &mut seen_classes,
3144        &mut out,
3145    );
3146    if let Some(receiver_name) = python_decl_receiver_name(decl) {
3147        for alias in &mut out {
3148            alias.receiver_name.clone_from(&receiver_name);
3149        }
3150    }
3151    out
3152}
3153
3154fn python_decl_receiver_name(decl: &bonsai_lang_api::Decl) -> Option<String> {
3155    decl.receiver_param_index
3156        .and_then(|idx| decl.params.get(idx))
3157        .filter(|name| !name.trim().is_empty())
3158        .cloned()
3159}
3160
3161fn python_property_aliases_by_decl(
3162    idx: &DeclIndex,
3163    property_aliases: &[PythonPropertyAlias],
3164) -> std::collections::HashMap<SymbolId, Vec<PythonPropertyAlias>> {
3165    let mut by_decl = std::collections::HashMap::new();
3166    for decl in &idx.defs {
3167        let aliases = python_property_aliases_for_decl(idx, decl, property_aliases);
3168        if !aliases.is_empty() {
3169            by_decl.insert(decl.symbol, aliases);
3170        }
3171    }
3172    by_decl
3173}
3174
3175fn collect_python_property_aliases_for_class(
3176    idx: &DeclIndex,
3177    class_symbol: SymbolId,
3178    property_aliases: &[PythonPropertyAlias],
3179    seen_classes: &mut std::collections::HashSet<SymbolId>,
3180    out: &mut Vec<PythonPropertyAlias>,
3181) {
3182    if !seen_classes.insert(class_symbol) {
3183        return;
3184    }
3185    for alias in property_aliases
3186        .iter()
3187        .filter(|alias| alias.class_symbol == class_symbol)
3188    {
3189        if !out.iter().any(|existing| existing == alias) {
3190            out.push(alias.clone());
3191        }
3192    }
3193    let Some(class_decl) = idx.defs.iter().find(|decl| decl.symbol == class_symbol) else {
3194        return;
3195    };
3196    for base in &class_decl.bases {
3197        let Some(base_symbol) = idx
3198            .defs
3199            .iter()
3200            .find(|decl| {
3201                matches!(decl.kind, bonsai_lang_api::DeclKind::Class)
3202                    && (decl.name == *base || decl.name == base.rsplit('.').next().unwrap_or(base))
3203            })
3204            .map(|decl| decl.symbol)
3205        else {
3206            continue;
3207        };
3208        collect_python_property_aliases_for_class(idx, base_symbol, property_aliases, seen_classes, out);
3209    }
3210}
3211
3212fn augment_python_property_flow_events(
3213    events: &mut [bonsai_lang_api::FlowEvent],
3214    property_aliases: &[PythonPropertyAlias],
3215) {
3216    if property_aliases.is_empty() {
3217        return;
3218    }
3219    for event in events {
3220        match event {
3221            bonsai_lang_api::FlowEvent::Assign { source_names, .. } => {
3222                augment_python_property_source_names(source_names, property_aliases);
3223            }
3224            bonsai_lang_api::FlowEvent::Call { args, .. } => {
3225                for arg in args {
3226                    augment_python_property_source_names(&mut arg.source_names, property_aliases);
3227                }
3228            }
3229            bonsai_lang_api::FlowEvent::Branch {
3230                then_events,
3231                else_events,
3232                ..
3233            } => {
3234                augment_python_property_flow_events(then_events, property_aliases);
3235                augment_python_property_flow_events(else_events, property_aliases);
3236            }
3237            bonsai_lang_api::FlowEvent::Loop { body, .. }
3238            | bonsai_lang_api::FlowEvent::Defer { body, .. }
3239            | bonsai_lang_api::FlowEvent::Using { body, .. } => {
3240                augment_python_property_flow_events(body, property_aliases);
3241            }
3242            bonsai_lang_api::FlowEvent::Try {
3243                body,
3244                catch_events,
3245                finally_events,
3246                ..
3247            } => {
3248                augment_python_property_flow_events(body, property_aliases);
3249                augment_python_property_flow_events(catch_events, property_aliases);
3250                augment_python_property_flow_events(finally_events, property_aliases);
3251            }
3252            _ => {}
3253        }
3254    }
3255}
3256
3257fn augment_python_property_source_names(
3258    source_names: &mut Vec<String>,
3259    property_aliases: &[PythonPropertyAlias],
3260) {
3261    let existing = source_names.clone();
3262    for source in existing {
3263        for alias in property_aliases {
3264            if let Some(rewritten) = python_property_alias_source_name(&source, alias) {
3265                push_python_source_name(source_names, rewritten);
3266            }
3267        }
3268    }
3269}
3270
3271fn python_property_alias_source_name(source: &str, alias: &PythonPropertyAlias) -> Option<String> {
3272    let prefix = format!("{}.{}", alias.receiver_name, alias.property_name);
3273    let source = source.trim();
3274    if source == prefix {
3275        return Some(format!("{}.{}", alias.receiver_name, alias.target_tail));
3276    }
3277    let tail = source.strip_prefix(&prefix)?.strip_prefix('.')?;
3278    if tail.is_empty() {
3279        return None;
3280    }
3281    Some(format!("{}.{}.{}", alias.receiver_name, alias.target_tail, tail))
3282}
3283
3284fn augment_python_comprehension_flow_events(
3285    events: &mut [bonsai_lang_api::FlowEvent],
3286    source: &str,
3287    assignment_values: &AssignmentValueIndex,
3288) {
3289    for event in events {
3290        match event {
3291            bonsai_lang_api::FlowEvent::Assign {
3292                span, source_names, ..
3293            } => {
3294                if let Some(rhs) = assignment_values.rendering(*span, source) {
3295                    for iterable in python_comprehension_iterables(rhs) {
3296                        push_python_source_name(source_names, iterable);
3297                    }
3298                }
3299            }
3300            bonsai_lang_api::FlowEvent::Branch {
3301                then_events,
3302                else_events,
3303                ..
3304            } => {
3305                augment_python_comprehension_flow_events(then_events, source, assignment_values);
3306                augment_python_comprehension_flow_events(else_events, source, assignment_values);
3307            }
3308            bonsai_lang_api::FlowEvent::Loop { body, .. }
3309            | bonsai_lang_api::FlowEvent::Defer { body, .. }
3310            | bonsai_lang_api::FlowEvent::Using { body, .. } => {
3311                augment_python_comprehension_flow_events(body, source, assignment_values);
3312            }
3313            bonsai_lang_api::FlowEvent::Try {
3314                body,
3315                catch_events,
3316                finally_events,
3317                ..
3318            } => {
3319                augment_python_comprehension_flow_events(body, source, assignment_values);
3320                augment_python_comprehension_flow_events(catch_events, source, assignment_values);
3321                augment_python_comprehension_flow_events(finally_events, source, assignment_values);
3322            }
3323            _ => {}
3324        }
3325    }
3326}
3327
3328fn collect_python_comprehension_iterable_call_events(
3329    tree: &Tree,
3330    file: FileId,
3331    src: &[u8],
3332    decl_span: Span,
3333) -> Vec<FlowEvent> {
3334    let mut out = Vec::new();
3335    for clause in collect_kinds(tree, &["for_in_clause"]) {
3336        let clause_span = span_of(file, &clause);
3337        if !python_span_contains(decl_span, clause_span) || !python_for_in_clause_is_comprehension(&clause) {
3338            continue;
3339        }
3340        let Some(iterable) = clause.child_by_field_name("right") else {
3341            continue;
3342        };
3343        collect_python_call_events_from_node(iterable, file, src, &mut out);
3344    }
3345    out.sort_by_key(|event| python_flow_event_span(event).start);
3346    out.dedup_by(|left, right| python_flow_event_same_call(left, right));
3347    out
3348}
3349
3350fn python_for_in_clause_is_comprehension(clause: &Node<'_>) -> bool {
3351    let mut parent = clause.parent();
3352    while let Some(node) = parent {
3353        if matches!(
3354            node.kind(),
3355            "list_comprehension"
3356                | "dict_comprehension"
3357                | "dictionary_comprehension"
3358                | "set_comprehension"
3359                | "generator_expression"
3360        ) {
3361            return true;
3362        }
3363        if matches!(
3364            node.kind(),
3365            "function_definition" | "lambda" | "for_statement" | "while_statement" | "if_statement" | "block"
3366        ) {
3367            return false;
3368        }
3369        parent = node.parent();
3370    }
3371    false
3372}
3373
3374fn collect_python_call_events_from_node(node: Node<'_>, file: FileId, src: &[u8], out: &mut Vec<FlowEvent>) {
3375    if node.kind() == "call" {
3376        if let Some(event) = build_python_call_event(node, file, src) {
3377            out.push(event);
3378        }
3379    }
3380    let mut cursor = node.walk();
3381    for child in node.named_children(&mut cursor) {
3382        collect_python_call_events_from_node(child, file, src, out);
3383    }
3384}
3385
3386fn build_python_call_event(node: Node<'_>, file: FileId, src: &[u8]) -> Option<FlowEvent> {
3387    if node.kind() != "call" {
3388        return None;
3389    }
3390    let callee_node = node.child_by_field_name("function")?;
3391    let name = normalize_call_name_whitespace(node_text(&callee_node, src));
3392    if name.is_empty() {
3393        return None;
3394    }
3395    let receiver = python_call_receiver_from_name(&name);
3396    let call_kind = if receiver.is_some() {
3397        CallKind::Method
3398    } else {
3399        CallKind::Function
3400    };
3401    let mut args = Vec::new();
3402    if let Some(arguments) = node.child_by_field_name("arguments") {
3403        let mut cursor = arguments.walk();
3404        for arg in arguments.named_children(&mut cursor) {
3405            let (name, value_node) = if arg.kind() == "keyword_argument" {
3406                let key = arg
3407                    .child_by_field_name("name")
3408                    .map(|node| node_text(&node, src).trim().to_string())
3409                    .filter(|name| !name.is_empty());
3410                let value = arg.child_by_field_name("value").unwrap_or(arg);
3411                (key, value)
3412            } else {
3413                (None, arg)
3414            };
3415            if let Some(argument) =
3416                call_arg_from_nodes_with_handler(arg, value_node, file, src, name, &HANDLER)
3417            {
3418                let mut argument = argument;
3419                if let Some(place) = python_exact_expression_place(value_node, src) {
3420                    argument.place = Some(place);
3421                }
3422                args.push(argument);
3423            }
3424        }
3425    }
3426    Some(FlowEvent::Call {
3427        span: span_of(file, &callee_node),
3428        name,
3429        receiver,
3430        receiver_types: Vec::new(),
3431        call_kind,
3432        args,
3433    })
3434}
3435
3436/// Exact addressable call arguments lowered from Python's Tree-sitter nodes.
3437///
3438/// The shared call walker deliberately does not interpret language syntax.
3439/// Python static subscripts therefore have to become canonical compiler
3440/// places here: `obj["field"]` is `obj.field`, while `obj[key]` remains an
3441/// aggregate read because its selected field is not statically known.
3442fn collect_python_call_argument_places(tree: &Tree, file: FileId, src: &[u8]) -> Vec<(Span, String)> {
3443    let mut out = Vec::new();
3444    for call in collect_kinds(tree, &["call"]) {
3445        let Some(arguments) = call.child_by_field_name("arguments") else {
3446            continue;
3447        };
3448        let mut cursor = arguments.walk();
3449        for argument in arguments.named_children(&mut cursor) {
3450            let value = if argument.kind() == "keyword_argument" {
3451                argument.child_by_field_name("value").unwrap_or(argument)
3452            } else {
3453                argument
3454            };
3455            let Some(place) = python_exact_expression_place(value, src) else {
3456                continue;
3457            };
3458            out.push((span_of(file, &argument), place));
3459        }
3460    }
3461    out.sort_by_key(|(span, _)| (span.start, span.end));
3462    out.dedup_by_key(|(span, _)| *span);
3463    out
3464}
3465
3466fn python_exact_expression_place(node: Node<'_>, src: &[u8]) -> Option<String> {
3467    match node.kind() {
3468        "identifier" => {
3469            let name = node_text(&node, src).trim();
3470            (!name.is_empty()).then(|| name.to_string())
3471        }
3472        "attribute" => {
3473            let object = node.child_by_field_name("object")?;
3474            let attribute = node.child_by_field_name("attribute")?;
3475            let base = python_exact_expression_place(object, src)?;
3476            let field = node_text(&attribute, src).trim();
3477            (!field.is_empty()).then(|| format!("{base}.{field}"))
3478        }
3479        "subscript" => {
3480            let value = node.child_by_field_name("value")?;
3481            let subscript = node.child_by_field_name("subscript")?;
3482            let base = python_exact_expression_place(value, src)?;
3483            let field = python_static_string(subscript, src)?;
3484            if field.is_empty()
3485                || !field
3486                    .chars()
3487                    .next()
3488                    .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
3489                || !field.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
3490            {
3491                return None;
3492            }
3493            Some(format!("{base}.{field}"))
3494        }
3495        "parenthesized_expression" => {
3496            let mut cursor = node.walk();
3497            let child = node.named_children(&mut cursor).next()?;
3498            python_exact_expression_place(child, src)
3499        }
3500        _ => None,
3501    }
3502}
3503
3504/// Exact addressable return operands lowered from Python's Tree-sitter nodes.
3505/// Static attribute/subscript returns carry their complete storage place;
3506/// dynamic subscripts deliberately retain the generic aggregate fact.
3507fn collect_python_return_places(tree: &Tree, file: FileId, src: &[u8]) -> Vec<(Span, String)> {
3508    let mut out = Vec::new();
3509    for statement in collect_kinds(tree, &["return_statement"]) {
3510        let Some(value) = statement.named_child(0) else {
3511            continue;
3512        };
3513        let Some(place) = python_exact_expression_place(value, src) else {
3514            continue;
3515        };
3516        out.push((span_of(file, &statement), place));
3517    }
3518    out.sort_by_key(|(span, _)| (span.start, span.end));
3519    out.dedup_by_key(|(span, _)| *span);
3520    out
3521}
3522
3523fn apply_python_return_places(events: &mut [FlowEvent], places: &[(Span, String)]) {
3524    for event in events {
3525        match event {
3526            FlowEvent::Return {
3527                span,
3528                value_name,
3529                value_flow,
3530                ..
3531            } => {
3532                if let Ok(index) = places.binary_search_by_key(&(span.start, span.end), |(candidate, _)| {
3533                    (candidate.start, candidate.end)
3534                }) {
3535                    let place = places[index].1.clone();
3536                    *value_name = Some(place.clone());
3537                    value_flow.place = Some(place.clone());
3538                    value_flow.source_names.clear();
3539                    value_flow.source_names.push(place);
3540                }
3541            }
3542            FlowEvent::Branch {
3543                then_events,
3544                else_events,
3545                ..
3546            } => {
3547                apply_python_return_places(then_events, places);
3548                apply_python_return_places(else_events, places);
3549            }
3550            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
3551                apply_python_return_places(body, places);
3552            }
3553            FlowEvent::Try {
3554                body,
3555                catch_events,
3556                finally_events,
3557                ..
3558            } => {
3559                apply_python_return_places(body, places);
3560                apply_python_return_places(catch_events, places);
3561                apply_python_return_places(finally_events, places);
3562            }
3563            _ => {}
3564        }
3565    }
3566}
3567
3568fn apply_python_call_argument_places(events: &mut [FlowEvent], places: &[(Span, String)]) {
3569    for event in events {
3570        match event {
3571            FlowEvent::Call { args, .. } => {
3572                for argument in args {
3573                    if let Ok(index) = places
3574                        .binary_search_by_key(&(argument.span.start, argument.span.end), |(span, _)| {
3575                            (span.start, span.end)
3576                        })
3577                    {
3578                        argument.place = Some(places[index].1.clone());
3579                    }
3580                }
3581            }
3582            FlowEvent::Branch {
3583                then_events,
3584                else_events,
3585                ..
3586            } => {
3587                apply_python_call_argument_places(then_events, places);
3588                apply_python_call_argument_places(else_events, places);
3589            }
3590            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
3591                apply_python_call_argument_places(body, places);
3592            }
3593            FlowEvent::Try {
3594                body,
3595                catch_events,
3596                finally_events,
3597                ..
3598            } => {
3599                apply_python_call_argument_places(body, places);
3600                apply_python_call_argument_places(catch_events, places);
3601                apply_python_call_argument_places(finally_events, places);
3602            }
3603            _ => {}
3604        }
3605    }
3606}
3607
3608/// Lower the value delivered by a direct iterable call into a sparse
3609/// yield-result binding. Python uses the same `for_statement` grammar shape
3610/// for synchronous and asynchronous iteration; the IDG only activates this
3611/// relation when resolution proves that the callee owns a `Yield` endpoint.
3612/// Ordinary container-returning calls therefore retain their existing return
3613/// semantics, while generator fields remain field-precise at the loop target.
3614fn collect_python_iterable_yield_bindings(tree: &Tree, file: FileId, src: &[u8]) -> Vec<FlowEvent> {
3615    let mut out = Vec::new();
3616    for loop_node in collect_kinds(tree, &["for_statement"]) {
3617        let (Some(binding), Some(iterable)) = (
3618            loop_node.child_by_field_name("left"),
3619            loop_node.child_by_field_name("right"),
3620        ) else {
3621            continue;
3622        };
3623        let Some(FlowEvent::Call { name, args, .. }) = build_python_call_event(iterable, file, src) else {
3624            continue;
3625        };
3626        let source_call_args = args.into_iter().map(|arg| arg.value_text).collect::<Vec<_>>();
3627        for target in python_loop_binding_targets(binding, src) {
3628            out.push(FlowEvent::Assign {
3629                // Use the loop statement's write span, not the callee token.
3630                // The generic frontend already lowered the loop binding at
3631                // this span. Inserting the sparse YieldResult relation next
3632                // to that event reuses the same IDG write node, so unresolved
3633                // ordinary iterables cannot overwrite and kill the compiler's
3634                // local loop flow. `assign_call_site_hint` still joins this
3635                // containing span to the exact sibling Call event.
3636                span: span_of(file, &loop_node),
3637                target,
3638                source_name: None,
3639                source_call: Some(name.clone()),
3640                source_call_args: source_call_args.clone(),
3641                source_names: Vec::new(),
3642                declares_new_binding: false,
3643                value_kind: Some(bonsai_lang_api::AssignValueKind::YieldResult),
3644            });
3645        }
3646    }
3647    out.sort_by_key(|event| {
3648        let span = python_flow_event_span(event);
3649        let target = match event {
3650            FlowEvent::Assign { target, .. } => target.as_str(),
3651            _ => "",
3652        };
3653        (span.start, span.end, target.to_string())
3654    });
3655    out.dedup_by(|left, right| match (left, right) {
3656        (
3657            FlowEvent::Assign {
3658                span: left_span,
3659                target: left_target,
3660                source_call: left_call,
3661                ..
3662            },
3663            FlowEvent::Assign {
3664                span: right_span,
3665                target: right_target,
3666                source_call: right_call,
3667                ..
3668            },
3669        ) => left_span == right_span && left_target == right_target && left_call == right_call,
3670        _ => false,
3671    });
3672    out
3673}
3674
3675/// Place Python's generator relation beside the generic loop-binding event.
3676///
3677/// The shared walker lowers a `for` binding before its `Loop` body. Keeping
3678/// this adapter-owned refinement in the same event vector and at the same
3679/// statement span makes both facts refer to one compiler write. Appending it
3680/// inside the body would create a later definition and incorrectly erase the
3681/// generic binding whenever the iterable resolves to external code.
3682fn insert_python_iterable_yield_bindings(events: &mut Vec<FlowEvent>, bindings: &[FlowEvent]) {
3683    for event in events.iter_mut() {
3684        match event {
3685            FlowEvent::Branch {
3686                then_events,
3687                else_events,
3688                ..
3689            } => {
3690                insert_python_iterable_yield_bindings(then_events, bindings);
3691                insert_python_iterable_yield_bindings(else_events, bindings);
3692            }
3693            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
3694                insert_python_iterable_yield_bindings(body, bindings);
3695            }
3696            FlowEvent::Try {
3697                body,
3698                catch_events,
3699                finally_events,
3700                ..
3701            } => {
3702                insert_python_iterable_yield_bindings(body, bindings);
3703                insert_python_iterable_yield_bindings(catch_events, bindings);
3704                insert_python_iterable_yield_bindings(finally_events, bindings);
3705            }
3706            _ => {}
3707        }
3708    }
3709
3710    let loop_spans = events
3711        .iter()
3712        .filter_map(|event| match event {
3713            FlowEvent::Loop { span, .. } => Some(*span),
3714            _ => None,
3715        })
3716        .collect::<Vec<_>>();
3717    for loop_span in loop_spans {
3718        let Some(mut insert_at) = events
3719            .iter()
3720            .position(|event| matches!(event, FlowEvent::Loop { span, .. } if *span == loop_span))
3721        else {
3722            continue;
3723        };
3724        for binding in bindings
3725            .iter()
3726            .filter(|binding| python_flow_event_span(binding) == loop_span)
3727        {
3728            let mut binding = binding.clone();
3729            // Tuple loop bindings are lowered twice at the same statement:
3730            // the generic call-result fallback owns an exact synthetic tuple
3731            // projection, while this Python refinement adds the generator
3732            // yield alternative. Give both facts the same compiler place so
3733            // the refinement cannot become a later clean overwrite merely
3734            // because the target was destructured.
3735            if let FlowEvent::Assign {
3736                target,
3737                source_names,
3738                value_kind: Some(bonsai_lang_api::AssignValueKind::YieldResult),
3739                ..
3740            } = &mut binding
3741            {
3742                if let Some(tuple_sources) = events.iter().find_map(|existing| match existing {
3743                    FlowEvent::Assign {
3744                        span,
3745                        target: existing_target,
3746                        source_names: existing_sources,
3747                        value_kind,
3748                        ..
3749                    } if *span == loop_span
3750                        && existing_target == target
3751                        && !matches!(value_kind, Some(bonsai_lang_api::AssignValueKind::YieldResult))
3752                        && existing_sources.iter().any(|source| {
3753                            source.starts_with(bonsai_lang_api::kit::SYNTHETIC_TUPLE_RESULT_PREFIX)
3754                        }) =>
3755                    {
3756                        Some(existing_sources.clone())
3757                    }
3758                    _ => None,
3759                }) {
3760                    *source_names = tuple_sources;
3761                }
3762            }
3763            if events.iter().any(|existing| existing == &binding) {
3764                continue;
3765            }
3766            events.insert(insert_at, binding);
3767            insert_at += 1;
3768        }
3769    }
3770}
3771
3772fn python_loop_binding_targets(node: Node<'_>, src: &[u8]) -> Vec<String> {
3773    fn collect(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
3774        if node.kind() == "identifier" {
3775            let name = node_text(&node, src).trim();
3776            if python_match_capture_identifier(name) {
3777                out.push(name.to_string());
3778            }
3779            return;
3780        }
3781        if !matches!(
3782            node.kind(),
3783            "pattern_list" | "tuple_pattern" | "list_pattern" | "star_pattern"
3784        ) {
3785            return;
3786        }
3787        let mut cursor = node.walk();
3788        for child in node.named_children(&mut cursor) {
3789            collect(child, src, out);
3790        }
3791    }
3792
3793    let mut out = Vec::new();
3794    collect(node, src, &mut out);
3795    out.sort();
3796    out.dedup();
3797    out
3798}
3799
3800fn python_call_receiver_from_name(name: &str) -> Option<String> {
3801    let (receiver, _) = name.rsplit_once('.')?;
3802    let receiver = receiver.trim();
3803    (!receiver.is_empty()).then(|| receiver.to_string())
3804}
3805
3806fn python_is_identifier_like(text: &str) -> bool {
3807    let mut chars = text.chars();
3808    let Some(first) = chars.next() else {
3809        return false;
3810    };
3811    (first == '_' || first.is_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_alphanumeric())
3812}
3813
3814fn augment_python_asyncio_to_thread_calls(events: &mut Vec<FlowEvent>) {
3815    let mut i = 0;
3816    while i < events.len() {
3817        match &mut events[i] {
3818            FlowEvent::Branch {
3819                then_events,
3820                else_events,
3821                ..
3822            } => {
3823                augment_python_asyncio_to_thread_calls(then_events);
3824                augment_python_asyncio_to_thread_calls(else_events);
3825            }
3826            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
3827                augment_python_asyncio_to_thread_calls(body);
3828            }
3829            FlowEvent::Try {
3830                body,
3831                catch_events,
3832                finally_events,
3833                ..
3834            } => {
3835                augment_python_asyncio_to_thread_calls(body);
3836                augment_python_asyncio_to_thread_calls(catch_events);
3837                augment_python_asyncio_to_thread_calls(finally_events);
3838            }
3839            _ => {}
3840        }
3841
3842        let synthetic = match &events[i] {
3843            FlowEvent::Call { span, name, args, .. } if python_is_asyncio_to_thread_name(name) => {
3844                let Some((target, shifted_args)) = python_to_thread_target_and_args(args) else {
3845                    i += 1;
3846                    continue;
3847                };
3848                let receiver = python_call_receiver_from_name(&target);
3849                let call_kind = if receiver.is_some() {
3850                    CallKind::Method
3851                } else {
3852                    CallKind::Function
3853                };
3854                Some(FlowEvent::Call {
3855                    span: *span,
3856                    name: target,
3857                    receiver,
3858                    receiver_types: Vec::new(),
3859                    call_kind,
3860                    args: shifted_args,
3861                })
3862            }
3863            _ => None,
3864        };
3865        if let Some(call) = synthetic {
3866            events.insert(i + 1, call);
3867            i += 1;
3868        }
3869        i += 1;
3870    }
3871}
3872
3873fn python_is_asyncio_to_thread_name(name: &str) -> bool {
3874    matches!(name.trim(), "asyncio.to_thread" | "to_thread")
3875}
3876
3877fn python_to_thread_target_and_args(args: &[CallArg]) -> Option<(String, Vec<CallArg>)> {
3878    let target = args.first()?.value_text.trim();
3879    if !python_is_qualified_identifier_like(target) {
3880        return None;
3881    }
3882    Some((target.to_string(), args.iter().skip(1).cloned().collect()))
3883}
3884
3885fn python_is_qualified_identifier_like(text: &str) -> bool {
3886    let text = text.trim();
3887    !text.is_empty()
3888        && text
3889            .split('.')
3890            .all(|part| !part.is_empty() && python_is_identifier_like(part))
3891}
3892
3893fn insert_python_flow_events_by_span(events: &mut Vec<FlowEvent>, owner_span: Span, synthetic: &[FlowEvent]) {
3894    for event in events.iter_mut() {
3895        let event_span = python_flow_event_span(event);
3896        match event {
3897            FlowEvent::Branch {
3898                then_events,
3899                else_events,
3900                ..
3901            } => {
3902                insert_python_flow_events_by_span(then_events, event_span, synthetic);
3903                insert_python_flow_events_by_span(else_events, event_span, synthetic);
3904            }
3905            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
3906                insert_python_flow_events_by_span(body, event_span, synthetic);
3907            }
3908            FlowEvent::Try {
3909                body,
3910                catch_events,
3911                finally_events,
3912                ..
3913            } => {
3914                insert_python_flow_events_by_span(body, event_span, synthetic);
3915                insert_python_flow_events_by_span(catch_events, event_span, synthetic);
3916                insert_python_flow_events_by_span(finally_events, event_span, synthetic);
3917            }
3918            _ => {}
3919        }
3920    }
3921
3922    let mut pending: Vec<FlowEvent> = synthetic
3923        .iter()
3924        .filter(|event| {
3925            let span = python_flow_event_span(event);
3926            python_span_contains(owner_span, span)
3927                && !python_event_tree_contains_call(events, event)
3928                && !events.iter().any(|candidate| {
3929                    python_flow_event_is_container(candidate)
3930                        && python_span_contains(python_flow_event_span(candidate), span)
3931                })
3932        })
3933        .cloned()
3934        .collect();
3935    pending.sort_by_key(|event| python_flow_event_span(event).start);
3936    for event in pending {
3937        let span = python_flow_event_span(&event);
3938        let insert_at = events
3939            .iter()
3940            .position(|existing| python_flow_event_span(existing).start > span.start)
3941            .unwrap_or(events.len());
3942        events.insert(insert_at, event);
3943    }
3944}
3945
3946fn python_flow_event_is_container(event: &FlowEvent) -> bool {
3947    matches!(
3948        event,
3949        FlowEvent::Branch { .. }
3950            | FlowEvent::Loop { .. }
3951            | FlowEvent::Try { .. }
3952            | FlowEvent::Defer { .. }
3953            | FlowEvent::Using { .. }
3954    )
3955}
3956
3957fn python_event_tree_contains_call(events: &[FlowEvent], needle: &FlowEvent) -> bool {
3958    events.iter().any(|event| {
3959        python_flow_event_same_call(event, needle)
3960            || match event {
3961                FlowEvent::Branch {
3962                    then_events,
3963                    else_events,
3964                    ..
3965                } => {
3966                    python_event_tree_contains_call(then_events, needle)
3967                        || python_event_tree_contains_call(else_events, needle)
3968                }
3969                FlowEvent::Loop { body, .. }
3970                | FlowEvent::Defer { body, .. }
3971                | FlowEvent::Using { body, .. } => python_event_tree_contains_call(body, needle),
3972                FlowEvent::Try {
3973                    body,
3974                    catch_events,
3975                    finally_events,
3976                    ..
3977                } => {
3978                    python_event_tree_contains_call(body, needle)
3979                        || python_event_tree_contains_call(catch_events, needle)
3980                        || python_event_tree_contains_call(finally_events, needle)
3981                }
3982                _ => false,
3983            }
3984    })
3985}
3986
3987fn python_flow_event_same_call(left: &FlowEvent, right: &FlowEvent) -> bool {
3988    matches!(
3989        (left, right),
3990        (
3991            FlowEvent::Call {
3992                span: left_span,
3993                name: left_name,
3994                ..
3995            },
3996            FlowEvent::Call {
3997                span: right_span,
3998                name: right_name,
3999                ..
4000            }
4001        ) if left_span == right_span && left_name == right_name
4002    )
4003}
4004
4005fn python_comprehension_iterables(rhs: &str) -> Vec<String> {
4006    let mut out = Vec::new();
4007    let mut quote: Option<char> = None;
4008    let mut escaped = false;
4009    let mut depth = 0usize;
4010    let iter = rhs.char_indices().peekable();
4011    for (idx, ch) in iter {
4012        if let Some(q) = quote {
4013            if escaped {
4014                escaped = false;
4015            } else if ch == '\\' {
4016                escaped = true;
4017            } else if ch == q {
4018                quote = None;
4019            }
4020            continue;
4021        }
4022        if matches!(ch, '\'' | '"' | '`') {
4023            quote = Some(ch);
4024            continue;
4025        }
4026        match ch {
4027            '(' | '[' | '{' => depth = depth.saturating_add(1),
4028            ')' | ']' | '}' => depth = depth.saturating_sub(1),
4029            'i' if rhs[idx..].starts_with("in") && python_keyword_boundary(rhs, idx, idx + 2) => {
4030                let start = idx + 2;
4031                let end = python_comprehension_iterable_end(rhs, start, depth);
4032                for token in python_access_tokens(&rhs[start..end]) {
4033                    push_python_source_name(&mut out, token);
4034                }
4035            }
4036            _ => {}
4037        }
4038    }
4039    out
4040}
4041
4042fn python_keyword_boundary(text: &str, start: usize, end: usize) -> bool {
4043    let before = text[..start].chars().next_back();
4044    let after = text[end..].chars().next();
4045    !before.is_some_and(|ch| ch == '_' || ch.is_ascii_alphanumeric())
4046        && !after.is_some_and(|ch| ch == '_' || ch.is_ascii_alphanumeric())
4047}
4048
4049fn python_comprehension_iterable_end(text: &str, start: usize, initial_depth: usize) -> usize {
4050    let mut quote: Option<char> = None;
4051    let mut escaped = false;
4052    let mut depth = initial_depth;
4053    for (idx, ch) in text.char_indices().skip_while(|(idx, _)| *idx < start) {
4054        if let Some(q) = quote {
4055            if escaped {
4056                escaped = false;
4057            } else if ch == '\\' {
4058                escaped = true;
4059            } else if ch == q {
4060                quote = None;
4061            }
4062            continue;
4063        }
4064        if matches!(ch, '\'' | '"' | '`') {
4065            quote = Some(ch);
4066            continue;
4067        }
4068        match ch {
4069            '(' | '[' | '{' => depth = depth.saturating_add(1),
4070            ')' | ']' | '}' => {
4071                if depth <= initial_depth {
4072                    return idx;
4073                }
4074                depth = depth.saturating_sub(1);
4075            }
4076            ',' if depth == initial_depth => return idx,
4077            'i' if depth == initial_depth
4078                && text[idx..].starts_with("if")
4079                && python_keyword_boundary(text, idx, idx + 2) =>
4080            {
4081                return idx;
4082            }
4083            'f' if depth == initial_depth
4084                && text[idx..].starts_with("for")
4085                && python_keyword_boundary(text, idx, idx + 3) =>
4086            {
4087                return idx;
4088            }
4089            _ => {}
4090        }
4091    }
4092    text.len()
4093}
4094
4095fn python_access_tokens(text: &str) -> Vec<String> {
4096    let mut out = Vec::new();
4097    let mut token = String::new();
4098    let mut quote: Option<char> = None;
4099    let mut escaped = false;
4100    for ch in text.chars().chain(std::iter::once(' ')) {
4101        if let Some(q) = quote {
4102            if escaped {
4103                escaped = false;
4104            } else if ch == '\\' {
4105                escaped = true;
4106            } else if ch == q {
4107                quote = None;
4108            }
4109            continue;
4110        }
4111        if matches!(ch, '\'' | '"' | '`') {
4112            push_python_source_name(&mut out, token.trim_matches('.').to_string());
4113            token.clear();
4114            quote = Some(ch);
4115            continue;
4116        }
4117        if ch == '.' || ch == '_' || ch.is_ascii_alphanumeric() {
4118            token.push(ch);
4119            continue;
4120        }
4121        push_python_source_name(&mut out, token.trim_matches('.').to_string());
4122        token.clear();
4123    }
4124    out
4125}
4126
4127fn push_python_source_name(out: &mut Vec<String>, value: String) {
4128    if !value.is_empty() && !out.iter().any(|existing| existing == &value) {
4129        out.push(value);
4130    }
4131}
4132
4133fn python_match_capture_identifier(text: &str) -> bool {
4134    if matches!(
4135        text,
4136        "" | "_" | "True" | "False" | "None" | "case" | "if" | "in" | "and" | "or" | "not"
4137    ) {
4138        return false;
4139    }
4140    let mut chars = text.chars();
4141    let Some(first) = chars.next() else {
4142        return false;
4143    };
4144    if !(first == '_' || first.is_alphabetic()) {
4145        return false;
4146    }
4147    chars.all(|ch| ch == '_' || ch.is_alphanumeric())
4148}
4149
4150fn python_span_owned_by_decl(span: Span, decl_span: Span, callable_spans: &[Span]) -> bool {
4151    if !python_span_contains(decl_span, span) {
4152        return false;
4153    }
4154    let Some(owner) = callable_spans
4155        .iter()
4156        .copied()
4157        .filter(|candidate| python_span_contains(*candidate, span))
4158        .min_by_key(|span| span.end.saturating_sub(span.start))
4159    else {
4160        return false;
4161    };
4162    owner == decl_span
4163}
4164
4165fn python_span_contains(outer: Span, inner: Span) -> bool {
4166    outer.file == inner.file && outer.start <= inner.start && inner.end <= outer.end
4167}
4168
4169fn python_flow_event_span(event: &bonsai_lang_api::FlowEvent) -> Span {
4170    match event {
4171        bonsai_lang_api::FlowEvent::Assign { span, .. }
4172        | bonsai_lang_api::FlowEvent::AggregateAssign { span, .. }
4173        | bonsai_lang_api::FlowEvent::Call { span, .. }
4174        | bonsai_lang_api::FlowEvent::Return { span, .. }
4175        | bonsai_lang_api::FlowEvent::Throw { span, .. }
4176        | bonsai_lang_api::FlowEvent::Branch { span, .. }
4177        | bonsai_lang_api::FlowEvent::Loop { span, .. }
4178        | bonsai_lang_api::FlowEvent::Try { span, .. }
4179        | bonsai_lang_api::FlowEvent::Defer { span, .. }
4180        | bonsai_lang_api::FlowEvent::Using { span, .. }
4181        | bonsai_lang_api::FlowEvent::Yield { span, .. }
4182        | bonsai_lang_api::FlowEvent::Await { span, .. }
4183        | bonsai_lang_api::FlowEvent::Break { span, .. }
4184        | bonsai_lang_api::FlowEvent::Continue { span, .. }
4185        | bonsai_lang_api::FlowEvent::Lifecycle { span, .. } => *span,
4186    }
4187}
4188
4189fn augment_python_dict_flow_events(
4190    events: &mut Vec<bonsai_lang_api::FlowEvent>,
4191    source: &str,
4192    assignment_values: &AssignmentValueIndex,
4193    assignment_projected_reads: &[(Span, Vec<String>)],
4194) {
4195    for event in events.iter_mut() {
4196        match event {
4197            bonsai_lang_api::FlowEvent::Branch {
4198                then_events,
4199                else_events,
4200                ..
4201            } => {
4202                augment_python_dict_flow_events(
4203                    then_events,
4204                    source,
4205                    assignment_values,
4206                    assignment_projected_reads,
4207                );
4208                augment_python_dict_flow_events(
4209                    else_events,
4210                    source,
4211                    assignment_values,
4212                    assignment_projected_reads,
4213                );
4214            }
4215            bonsai_lang_api::FlowEvent::Loop { body, .. }
4216            | bonsai_lang_api::FlowEvent::Defer { body, .. }
4217            | bonsai_lang_api::FlowEvent::Using { body, .. } => {
4218                augment_python_dict_flow_events(body, source, assignment_values, assignment_projected_reads);
4219            }
4220            bonsai_lang_api::FlowEvent::Try {
4221                body,
4222                catch_events,
4223                finally_events,
4224                ..
4225            } => {
4226                augment_python_dict_flow_events(body, source, assignment_values, assignment_projected_reads);
4227                augment_python_dict_flow_events(
4228                    catch_events,
4229                    source,
4230                    assignment_values,
4231                    assignment_projected_reads,
4232                );
4233                augment_python_dict_flow_events(
4234                    finally_events,
4235                    source,
4236                    assignment_values,
4237                    assignment_projected_reads,
4238                );
4239            }
4240            _ => {}
4241        }
4242    }
4243
4244    let mut known_fields: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
4245    let mut rewritten = Vec::with_capacity(events.len());
4246    for event in events.drain(..) {
4247        let mut synthetic = Vec::new();
4248        if let bonsai_lang_api::FlowEvent::Assign { span, target, .. } = &event {
4249            if let Some(rhs) = assignment_values.rendering(*span, source) {
4250                for (field, value) in python_dict_field_initializers(rhs) {
4251                    push_python_source_name(known_fields.entry(target.clone()).or_default(), field.clone());
4252                    let source_names = python_value_source_names(&value);
4253                    synthetic.push(bonsai_lang_api::FlowEvent::Assign {
4254                        span: *span,
4255                        target: format!("{target}.{field}"),
4256                        source_name: None,
4257                        source_call: None,
4258                        source_call_args: Vec::new(),
4259                        source_names,
4260                        declares_new_binding: false,
4261                        value_kind: None,
4262                    });
4263                }
4264                for field_read in assignment_projected_reads
4265                    .binary_search_by_key(&(span.start, span.end), |(candidate, _)| {
4266                        (candidate.start, candidate.end)
4267                    })
4268                    .ok()
4269                    .and_then(|index| assignment_projected_reads.get(index))
4270                    .map_or(&[][..], |(_, reads)| reads.as_slice())
4271                {
4272                    synthetic.push(bonsai_lang_api::FlowEvent::Assign {
4273                        span: *span,
4274                        target: target.clone(),
4275                        source_name: Some(field_read.clone()),
4276                        source_call: None,
4277                        source_call_args: Vec::new(),
4278                        source_names: vec![field_read.clone()],
4279                        declares_new_binding: false,
4280                        value_kind: None,
4281                    });
4282                }
4283                for spread in python_dict_spreads(rhs) {
4284                    if let Some(fields) = known_fields.get(&spread).cloned() {
4285                        for field in fields {
4286                            synthetic.push(bonsai_lang_api::FlowEvent::Assign {
4287                                span: *span,
4288                                target: format!("{target}.{field}"),
4289                                source_name: Some(format!("{spread}.{field}")),
4290                                source_call: None,
4291                                source_call_args: Vec::new(),
4292                                source_names: vec![format!("{spread}.{field}")],
4293                                declares_new_binding: false,
4294                                value_kind: None,
4295                            });
4296                            push_python_source_name(known_fields.entry(target.clone()).or_default(), field);
4297                        }
4298                    }
4299                }
4300            }
4301        }
4302        rewritten.push(event);
4303        rewritten.extend(synthetic);
4304    }
4305    *events = rewritten;
4306}
4307
4308/// AST-derived projected reads on assignment right-hand sides.
4309///
4310/// The generic expression walker intentionally treats a subscript as an
4311/// aggregate unless the owning adapter proves its key is static. Python can
4312/// prove literal string subscripts from the CST, so it emits an additional
4313/// exact read beside the conservative aggregate fact. Dynamic keys remain
4314/// aggregate reads and therefore cannot be mistaken for a sibling field.
4315fn collect_python_assignment_projected_reads(
4316    tree: &Tree,
4317    file: FileId,
4318    src: &[u8],
4319) -> Vec<(Span, Vec<String>)> {
4320    fn collect(node: Node<'_>, src: &[u8], out: &mut Vec<String>) {
4321        if node.kind() == "subscript" {
4322            if let Some(place) = python_exact_expression_place(node, src) {
4323                push_python_source_name(out, place);
4324                return;
4325            }
4326        }
4327        if node.kind() == "call" {
4328            let selected_field = (|| {
4329                let function = node.child_by_field_name("function")?;
4330                let (receiver, method) = python_attribute_parts(function, src)?;
4331                if method != "get" {
4332                    return None;
4333                }
4334                let arguments = node.child_by_field_name("arguments")?;
4335                let first = python_argument_nodes(arguments).into_iter().next()?;
4336                let field = python_static_string(first, src)?;
4337                let base = python_exact_expression_place(receiver, src)?;
4338                Some(format!("{base}.{field}"))
4339            })();
4340            if let Some(place) = selected_field {
4341                push_python_source_name(out, place);
4342                return;
4343            }
4344        }
4345
4346        let mut cursor = node.walk();
4347        for child in node.named_children(&mut cursor) {
4348            collect(child, src, out);
4349        }
4350    }
4351
4352    let mut reads = Vec::new();
4353    for assignment in collect_kinds(tree, &["assignment", "named_expression"]) {
4354        let Some(value) = assignment.child_by_field_name("right") else {
4355            continue;
4356        };
4357        // Aggregate members already lower independently to field writes.
4358        // Adding their projected operands as a second whole-target
4359        // assignment would overwrite those fields at the same statement and
4360        // erase the exact spread/return relation.
4361        if matches!(value.kind(), "dictionary" | "list" | "set" | "tuple") {
4362            continue;
4363        }
4364        let mut projected = Vec::new();
4365        collect(value, src, &mut projected);
4366        if !projected.is_empty() {
4367            reads.push((span_of(file, &assignment), projected));
4368        }
4369    }
4370    reads.sort_by_key(|(span, _)| (span.start, span.end));
4371    reads
4372}
4373
4374fn python_value_source_names(text: &str) -> Vec<String> {
4375    let mut out = python_access_tokens(text);
4376    for field_read in python_static_subscript_field_reads(text) {
4377        push_python_source_name(&mut out, field_read);
4378    }
4379    for field_read in python_static_get_field_reads(text) {
4380        push_python_source_name(&mut out, field_read);
4381    }
4382    out
4383}
4384
4385fn python_static_subscript_field_reads(text: &str) -> Vec<String> {
4386    let mut out = Vec::new();
4387    let mut quote: Option<char> = None;
4388    let mut escaped = false;
4389    let mut depth = 0usize;
4390    for (idx, ch) in text.char_indices() {
4391        if let Some(q) = quote {
4392            if escaped {
4393                escaped = false;
4394            } else if ch == '\\' {
4395                escaped = true;
4396            } else if ch == q {
4397                quote = None;
4398            }
4399            continue;
4400        }
4401        if matches!(ch, '\'' | '"' | '`') {
4402            quote = Some(ch);
4403            continue;
4404        }
4405        match ch {
4406            '[' if depth == 0 => {
4407                let Some(receiver) = python_receiver_before_index(text, idx) else {
4408                    depth = depth.saturating_add(1);
4409                    continue;
4410                };
4411                let Some(end) = python_matching_bracket_end(text, idx + 1) else {
4412                    depth = depth.saturating_add(1);
4413                    continue;
4414                };
4415                if let Some(field) = python_static_dict_key(&text[idx + 1..end]) {
4416                    push_python_source_name(&mut out, format!("{receiver}.{field}"));
4417                }
4418                depth = depth.saturating_add(1);
4419            }
4420            '(' | '[' | '{' => depth = depth.saturating_add(1),
4421            ')' | ']' | '}' => depth = depth.saturating_sub(1),
4422            _ => {}
4423        }
4424    }
4425    out
4426}
4427
4428fn python_static_get_field_reads(text: &str) -> Vec<String> {
4429    let mut out = Vec::new();
4430    let mut quote: Option<char> = None;
4431    let mut escaped = false;
4432    let mut depth = 0usize;
4433    for (idx, ch) in text.char_indices() {
4434        if let Some(q) = quote {
4435            if escaped {
4436                escaped = false;
4437            } else if ch == '\\' {
4438                escaped = true;
4439            } else if ch == q {
4440                quote = None;
4441            }
4442            continue;
4443        }
4444        if matches!(ch, '\'' | '"' | '`') {
4445            quote = Some(ch);
4446            continue;
4447        }
4448        match ch {
4449            '(' | '[' | '{' => depth = depth.saturating_add(1),
4450            ')' | ']' | '}' => depth = depth.saturating_sub(1),
4451            '.' if depth == 0 && text[idx..].starts_with(".get(") => {
4452                let Some(receiver) = python_receiver_before_dot_get(text, idx) else {
4453                    continue;
4454                };
4455                let args_start = idx + ".get(".len();
4456                let Some(args_end) = python_matching_paren_end(text, args_start) else {
4457                    continue;
4458                };
4459                let args = &text[args_start..args_end];
4460                let Some(first_arg) = python_split_top_level(args, ',').into_iter().next() else {
4461                    continue;
4462                };
4463                if let Some(field) = python_static_dict_key(&first_arg) {
4464                    push_python_source_name(&mut out, format!("{receiver}.{field}"));
4465                }
4466            }
4467            _ => {}
4468        }
4469    }
4470    out
4471}
4472
4473fn python_receiver_before_index(text: &str, index_idx: usize) -> Option<String> {
4474    let prefix = text.get(..index_idx)?;
4475    let mut start = index_idx;
4476    for (idx, ch) in prefix.char_indices().rev() {
4477        if ch == '.' || ch == '_' || ch.is_ascii_alphanumeric() {
4478            start = idx;
4479            continue;
4480        }
4481        break;
4482    }
4483    let receiver = text[start..index_idx].trim_matches('.');
4484    if receiver.is_empty() {
4485        None
4486    } else {
4487        Some(receiver.to_string())
4488    }
4489}
4490
4491fn python_receiver_before_dot_get(text: &str, dot_idx: usize) -> Option<String> {
4492    let prefix = text.get(..dot_idx)?;
4493    let mut start = dot_idx;
4494    for (idx, ch) in prefix.char_indices().rev() {
4495        if ch == '.' || ch == '_' || ch.is_ascii_alphanumeric() {
4496            start = idx;
4497            continue;
4498        }
4499        break;
4500    }
4501    let receiver = text[start..dot_idx].trim_matches('.');
4502    if receiver.is_empty() {
4503        None
4504    } else {
4505        Some(receiver.to_string())
4506    }
4507}
4508
4509fn python_matching_bracket_end(text: &str, args_start: usize) -> Option<usize> {
4510    python_matching_delimiter_end(text, args_start, '[', ']')
4511}
4512
4513fn python_matching_paren_end(text: &str, args_start: usize) -> Option<usize> {
4514    python_matching_delimiter_end(text, args_start, '(', ')')
4515}
4516
4517fn python_matching_delimiter_end(
4518    text: &str,
4519    args_start: usize,
4520    open_delimiter: char,
4521    close_delimiter: char,
4522) -> Option<usize> {
4523    let mut quote: Option<char> = None;
4524    let mut escaped = false;
4525    let mut depth = 1usize;
4526    for (idx, ch) in text.char_indices().skip_while(|(idx, _)| *idx < args_start) {
4527        if let Some(q) = quote {
4528            if escaped {
4529                escaped = false;
4530            } else if ch == '\\' {
4531                escaped = true;
4532            } else if ch == q {
4533                quote = None;
4534            }
4535            continue;
4536        }
4537        if matches!(ch, '\'' | '"' | '`') {
4538            quote = Some(ch);
4539            continue;
4540        }
4541        match ch {
4542            ch if ch == open_delimiter => depth = depth.saturating_add(1),
4543            ch if ch == close_delimiter => {
4544                depth = depth.saturating_sub(1);
4545                if depth == 0 {
4546                    return Some(idx);
4547                }
4548            }
4549            _ => {}
4550        }
4551    }
4552    None
4553}
4554
4555fn python_dict_field_initializers(text: &str) -> Vec<(String, String)> {
4556    let mut out = Vec::new();
4557    for body in python_dict_bodies(text) {
4558        for part in python_split_top_level(&body, ',') {
4559            if part.trim_start().starts_with("**") {
4560                continue;
4561            }
4562            let Some((key, value)) = python_split_top_level_once(&part, ':') else {
4563                continue;
4564            };
4565            let Some(field) = python_static_dict_key(&key) else {
4566                continue;
4567            };
4568            out.push((field, value.trim().to_string()));
4569        }
4570    }
4571    out
4572}
4573
4574fn python_dict_spreads(text: &str) -> Vec<String> {
4575    let mut out = Vec::new();
4576    for body in python_dict_bodies(text) {
4577        for part in python_split_top_level(&body, ',') {
4578            let Some(rest) = part.trim_start().strip_prefix("**") else {
4579                continue;
4580            };
4581            for token in python_access_tokens(rest) {
4582                push_python_source_name(&mut out, token);
4583            }
4584        }
4585    }
4586    out
4587}
4588
4589fn python_dict_bodies(text: &str) -> Vec<String> {
4590    let mut out = Vec::new();
4591    let mut stack = Vec::new();
4592    let mut quote: Option<char> = None;
4593    let mut escaped = false;
4594    for (idx, ch) in text.char_indices() {
4595        if let Some(q) = quote {
4596            if escaped {
4597                escaped = false;
4598            } else if ch == '\\' {
4599                escaped = true;
4600            } else if ch == q {
4601                quote = None;
4602            }
4603            continue;
4604        }
4605        if matches!(ch, '\'' | '"' | '`') {
4606            quote = Some(ch);
4607            continue;
4608        }
4609        match ch {
4610            '{' => stack.push(idx),
4611            '}' => {
4612                if let Some(start) = stack.pop() {
4613                    if start < idx {
4614                        out.push(text[start + 1..idx].to_string());
4615                    }
4616                }
4617            }
4618            _ => {}
4619        }
4620    }
4621    out
4622}
4623
4624fn python_split_top_level(text: &str, delimiter: char) -> Vec<String> {
4625    let mut out = Vec::new();
4626    let mut quote: Option<char> = None;
4627    let mut escaped = false;
4628    let mut depth = 0usize;
4629    let mut start = 0usize;
4630    for (idx, ch) in text.char_indices() {
4631        if let Some(q) = quote {
4632            if escaped {
4633                escaped = false;
4634            } else if ch == '\\' {
4635                escaped = true;
4636            } else if ch == q {
4637                quote = None;
4638            }
4639            continue;
4640        }
4641        if matches!(ch, '\'' | '"' | '`') {
4642            quote = Some(ch);
4643            continue;
4644        }
4645        match ch {
4646            '(' | '[' | '{' => depth = depth.saturating_add(1),
4647            ')' | ']' | '}' => depth = depth.saturating_sub(1),
4648            ch if ch == delimiter && depth == 0 => {
4649                let part = text[start..idx].trim();
4650                if !part.is_empty() {
4651                    out.push(part.to_string());
4652                }
4653                start = idx + ch.len_utf8();
4654            }
4655            _ => {}
4656        }
4657    }
4658    let part = text[start..].trim();
4659    if !part.is_empty() {
4660        out.push(part.to_string());
4661    }
4662    out
4663}
4664
4665fn python_split_top_level_once(text: &str, delimiter: char) -> Option<(String, String)> {
4666    let mut quote: Option<char> = None;
4667    let mut escaped = false;
4668    let mut depth = 0usize;
4669    for (idx, ch) in text.char_indices() {
4670        if let Some(q) = quote {
4671            if escaped {
4672                escaped = false;
4673            } else if ch == '\\' {
4674                escaped = true;
4675            } else if ch == q {
4676                quote = None;
4677            }
4678            continue;
4679        }
4680        if matches!(ch, '\'' | '"' | '`') {
4681            quote = Some(ch);
4682            continue;
4683        }
4684        match ch {
4685            '(' | '[' | '{' => depth = depth.saturating_add(1),
4686            ')' | ']' | '}' => depth = depth.saturating_sub(1),
4687            ch if ch == delimiter && depth == 0 => {
4688                return Some((text[..idx].to_string(), text[idx + ch.len_utf8()..].to_string()));
4689            }
4690            _ => {}
4691        }
4692    }
4693    None
4694}
4695
4696fn python_static_dict_key(text: &str) -> Option<String> {
4697    let key = text
4698        .trim()
4699        .strip_prefix('"')
4700        .and_then(|part| part.strip_suffix('"'))
4701        .or_else(|| {
4702            text.trim()
4703                .strip_prefix('\'')
4704                .and_then(|part| part.strip_suffix('\''))
4705        })?
4706        .trim();
4707    if key.is_empty()
4708        || !key
4709            .chars()
4710            .next()
4711            .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
4712        || !key.chars().all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
4713    {
4714        return None;
4715    }
4716    Some(key.to_string())
4717}
4718
4719fn collect_python_parameter_aliases(node: Node<'_>, src: &[u8], out: &mut Vec<TypeAliasBinding>) {
4720    let mut cursor = node.walk();
4721    for child in node.named_children(&mut cursor) {
4722        match child.kind() {
4723            "typed_parameter" | "typed_default_parameter" => {
4724                python_typed_parameter_alias(child, src, out);
4725            }
4726            // Recurse for nested parameter lists (lambda's `parameters`
4727            // node sometimes nests under `lambda_parameters`).
4728            _ => collect_python_parameter_aliases(child, src, out),
4729        }
4730    }
4731}
4732
4733fn python_typed_parameter_alias(node: Node<'_>, src: &[u8], out: &mut Vec<TypeAliasBinding>) {
4734    let Some(name_node) = first_named_child_of_kind(node, &["identifier"]) else {
4735        return;
4736    };
4737    let name = node_text(&name_node, src).trim().to_string();
4738    if name.is_empty() {
4739        return;
4740    }
4741    // Type annotation: `name: T` — `type` field, which is a `type` node
4742    // wrapping a `genericised_type` / `identifier` / etc.
4743    let type_node = node.child_by_field_name("type");
4744    if let Some(t) = type_node {
4745        if let Some(canonical) = canonical_python_type_from_node(t, src) {
4746            push_python_type_alias(out, &name, &canonical);
4747        }
4748    }
4749}
4750
4751fn first_named_child_of_kind<'a>(node: Node<'a>, kinds: &[&str]) -> Option<Node<'a>> {
4752    let count = node.named_child_count();
4753    for i in 0..count {
4754        let idx = u32::try_from(i).ok()?;
4755        let child = node.named_child(idx)?;
4756        if kinds.contains(&child.kind()) {
4757            return Some(child);
4758        }
4759    }
4760    None
4761}
4762
4763/// Lower a Python type-annotation AST to the runtime receiver type used for
4764/// dispatch. Container generics keep their outer type (`list[str]` → `list`),
4765/// while typing-only wrappers expose their first payload (`Annotated[T, …]`
4766/// and `Optional[T]` → `T`). Union syntax chooses the first non-`None` arm.
4767fn canonical_python_type_from_node(node: Node<'_>, src: &[u8]) -> Option<String> {
4768    match node.kind() {
4769        "type" | "parenthesized_expression" | "parenthesized_list_splat" => {
4770            let mut cursor = node.walk();
4771            let canonical = node
4772                .named_children(&mut cursor)
4773                .find_map(|child| canonical_python_type_from_node(child, src));
4774            canonical
4775        }
4776        "generic_type" => {
4777            let base = node.named_child(0)?;
4778            let base_name = canonical_python_type_name(node_text(&base, src))?;
4779            if matches!(
4780                base_name.as_str(),
4781                "Annotated" | "Optional" | "ClassVar" | "Final" | "Required" | "NotRequired"
4782            ) {
4783                let parameters = node.named_child(1)?;
4784                let mut cursor = parameters.walk();
4785                return parameters
4786                    .named_children(&mut cursor)
4787                    .find_map(|child| canonical_python_type_from_node(child, src));
4788            }
4789            Some(base_name)
4790        }
4791        "union_type" | "binary_operator" => {
4792            let mut cursor = node.walk();
4793            let canonical = node.named_children(&mut cursor).find_map(|child| {
4794                let candidate = canonical_python_type_from_node(child, src)?;
4795                (!matches!(candidate.as_str(), "None" | "NoneType")).then_some(candidate)
4796            });
4797            canonical
4798        }
4799        _ => canonical_python_type_name(node_text(&node, src)),
4800    }
4801}
4802
4803fn canonical_python_type_name(raw: &str) -> Option<String> {
4804    let trimmed = raw.trim().split('|').next().unwrap_or(raw).trim();
4805    let head = trimmed.split('[').next().unwrap_or(trimmed).trim();
4806    let bare = head.rsplit('.').next().unwrap_or(head).trim();
4807    if bare.is_empty() {
4808        return None;
4809    }
4810    Some(bare.to_string())
4811}
4812
4813fn push_python_type_alias(out: &mut Vec<TypeAliasBinding>, name: &str, type_name: &str) {
4814    if name.is_empty() || type_name.is_empty() || name == type_name {
4815        return;
4816    }
4817    out.push(TypeAliasBinding {
4818        name: name.to_string(),
4819        type_name: type_name.to_string(),
4820    });
4821}
4822
4823fn dedup_python_type_aliases(out: &mut Vec<TypeAliasBinding>) {
4824    let mut seen = std::collections::HashSet::new();
4825    out.retain(|alias| seen.insert((alias.name.clone(), alias.type_name.clone())));
4826}
4827
4828/// Walk every `class_definition` once and record the bare base-type
4829/// names listed in the parenthesized parent list. `class C(A, B):`
4830/// → `[("A".into(), "B".into())]` keyed by the class decl's span.
4831fn collect_python_class_bases(
4832    tree: &Tree,
4833    file: FileId,
4834    src: &[u8],
4835) -> Vec<(bonsai_common::Span, Vec<String>)> {
4836    let mut out = Vec::new();
4837    for class_node in collect_kinds(tree, &["class_definition"]) {
4838        let Some(superclasses) = class_node.child_by_field_name("superclasses") else {
4839            continue;
4840        };
4841        let mut bases: Vec<String> = Vec::new();
4842        let count = superclasses.named_child_count();
4843        for i in 0..count {
4844            let Some(idx) = u32::try_from(i).ok() else {
4845                continue;
4846            };
4847            let Some(child) = superclasses.named_child(idx) else {
4848                continue;
4849            };
4850            // Skip kwargs and other non-base entries (`metaclass=Foo`).
4851            if child.kind() == "keyword_argument" {
4852                continue;
4853            }
4854            let raw = node_text(&child, src);
4855            if let Some(canonical) = canonical_python_type_name(raw) {
4856                if !bases.iter().any(|b| b == &canonical) {
4857                    bases.push(canonical);
4858                }
4859            }
4860        }
4861        if !bases.is_empty() {
4862            out.push((span_of(file, &class_node), bases));
4863        }
4864    }
4865    out
4866}
4867
4868fn parse_imports(tree: &Tree, src: &[u8], file: FileId) -> Vec<ImportSpec> {
4869    let mut out = Vec::new();
4870    // `import X` / `import X as Y` — `import_statement` with one or more
4871    // `name:` children, each either a `dotted_name` or an
4872    // `aliased_import` (name + alias).
4873    for import_node in collect_kinds(tree, &["import_statement"]) {
4874        let mut cursor = import_node.walk();
4875        for child in import_node.named_children(&mut cursor) {
4876            // Two shapes: `import X` (dotted_name) or `import X as Y` (aliased_import).
4877            let (module_node, alias_text) = if child.kind() == "aliased_import" {
4878                let module_field = child.child_by_field_name("name");
4879                let alias_field = child
4880                    .child_by_field_name("alias")
4881                    .map(|alias_node| node_text(&alias_node, src).to_string());
4882                (module_field, alias_field)
4883            } else if child.kind() == "dotted_name" {
4884                (Some(child), None)
4885            } else {
4886                continue;
4887            };
4888            let Some(module_name_node) = module_node else {
4889                continue;
4890            };
4891            let module_name = node_text(&module_name_node, src).trim().to_string();
4892            if module_name.is_empty() {
4893                continue;
4894            }
4895            // Bare `import X` and `import X.Y` bind the FIRST segment
4896            // as the local name (`X` in both forms) — Python imports
4897            // a module by binding its head, not its leaf. Without a
4898            // self-binding alias here, the resolver cannot rewrite
4899            // `service.load_file(...)` through the workspace
4900            // `service` module, so cross-module edges from
4901            // `import`-form callers stay invisible. The `import X.Y
4902            // as Z` form already supplied an alias and skips this
4903            // fallback; wildcard imports never apply because
4904            // `import *` isn't a Python `import_statement` shape
4905            // (only `from X import *` is).
4906            let alias = alias_text.or_else(|| {
4907                module_name
4908                    .split('.')
4909                    .next()
4910                    .map(str::trim)
4911                    .filter(|leaf| !leaf.is_empty())
4912                    .map(str::to_string)
4913            });
4914            out.push(ImportSpec {
4915                span: span_of(file, &import_node),
4916                module: module_name,
4917                alias,
4918                is_wildcard: false,
4919                original_name: None,
4920                scope: ImportScope::Module,
4921            });
4922        }
4923    }
4924    // `from X import Y [as Z]` / `from . import Y` — emit one ImportSpec
4925    // per imported symbol (`from x import y as z` → original=y, alias=z).
4926    for from_import_node in collect_kinds(tree, &["import_from_statement"]) {
4927        let Some(module_node) = from_import_node.child_by_field_name("module_name") else {
4928            continue;
4929        };
4930        let module_name = node_text(&module_node, src).trim().to_string();
4931        let mut cursor = from_import_node.walk();
4932        // (original_name, alias) pairs — one per imported symbol.
4933        let mut imported_symbols: Vec<(Option<String>, Option<String>)> = Vec::new();
4934        let mut is_wildcard = false;
4935        for child in from_import_node.named_children(&mut cursor) {
4936            // Skip the module child itself; we already captured it above.
4937            if child.id() == module_node.id() {
4938                continue;
4939            }
4940            if child.kind() == "wildcard_import" {
4941                is_wildcard = true;
4942                continue;
4943            }
4944            if child.kind() == "aliased_import" {
4945                let original_name = child
4946                    .child_by_field_name("name")
4947                    .map(|name_node| node_text(&name_node, src).to_string());
4948                let alias_text = child
4949                    .child_by_field_name("alias")
4950                    .map(|alias_node| node_text(&alias_node, src).to_string());
4951                imported_symbols.push((original_name, alias_text));
4952            } else if child.kind() == "dotted_name" {
4953                imported_symbols.push((Some(node_text(&child, src).to_string()), None));
4954            }
4955        }
4956        // Bare `from X import *` — emit a single wildcard ImportSpec.
4957        if imported_symbols.is_empty() {
4958            out.push(ImportSpec {
4959                span: span_of(file, &from_import_node),
4960                module: module_name,
4961                alias: None,
4962                is_wildcard,
4963                original_name: None,
4964                scope: ImportScope::Module,
4965            });
4966        } else {
4967            // One ImportSpec per imported symbol so call resolution can
4968            // bind `y` to the unaliased original name and `z` to the alias.
4969            for (original_name, alias_text) in imported_symbols {
4970                out.push(ImportSpec {
4971                    span: span_of(file, &from_import_node),
4972                    module: module_name.clone(),
4973                    alias: alias_text,
4974                    is_wildcard,
4975                    original_name,
4976                    scope: ImportScope::Module,
4977                });
4978            }
4979        }
4980    }
4981    out
4982}
4983
4984/// Rewrite Python's reflective `getattr(obj, "literal", default)` /
4985/// `setattr(obj, "literal", value)` / `hasattr(obj, "literal")` shapes
4986/// into the equivalent attribute access. The kit emits the call as
4987/// `Call { name: "getattr", args: [{value_text: "obj"}, {value_text:
4988/// "\"literal\""}, ...] }`. We rewrite to the synthesized call
4989/// `Call { name: "obj.literal", receiver: Some("obj"), args: [...] }`
4990/// so the resolver narrows the dispatch like a normal method call.
4991///
4992/// This is the constant-string sub-case of P2.1; dynamic forms
4993/// (`getattr(obj, runtime_name)`) stay unrewritten and the engine's
4994/// `reflection: Unsupported` rule continues to gate them out.
4995///
4996/// The transformation lives in the Python adapter (not the engine)
4997/// because `getattr` / `setattr` / `hasattr` are Python-language-
4998/// specific names. The taint engine sees the rewritten call as just
4999/// another method dispatch.
5000fn rewrite_python_constant_reflection(events: &mut [bonsai_lang_api::FlowEvent]) {
5001    use bonsai_lang_api::FlowEvent;
5002    for event in events {
5003        match event {
5004            // `getattr(obj, "literal")` / `setattr(obj, "literal", v)` /
5005            // `hasattr(obj, "literal")` — only the first two args matter
5006            // for the rewrite.
5007            FlowEvent::Call {
5008                name, receiver, args, ..
5009            } if matches!(name.as_str(), "getattr" | "setattr" | "hasattr") && args.len() >= 2 => {
5010                let receiver_arg = &args[0];
5011                let attr_arg = &args[1];
5012                // Skip dynamic forms — the rule-load gate still rejects
5013                // rules anchored on the reflective shape, which is the
5014                // safe fallback when the attribute name isn't constant.
5015                if !is_python_string_literal(&attr_arg.value_text) {
5016                    continue;
5017                }
5018                let attr_name = strip_python_string_quotes(&attr_arg.value_text);
5019                if attr_name.is_empty() {
5020                    continue;
5021                }
5022                let receiver_text = receiver_arg.value_text.trim();
5023                if receiver_text.is_empty() {
5024                    continue;
5025                }
5026                // Synthesize the direct attribute call: `getattr(obj,
5027                // "process")(...)` becomes `obj.process(...)` from the
5028                // engine's perspective.
5029                *name = format!("{receiver_text}.{attr_name}");
5030                *receiver = Some(receiver_text.to_string());
5031            }
5032            // Reflective calls can hide in any container — recurse.
5033            FlowEvent::Branch {
5034                then_events,
5035                else_events,
5036                ..
5037            } => {
5038                rewrite_python_constant_reflection(then_events);
5039                rewrite_python_constant_reflection(else_events);
5040            }
5041            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
5042                rewrite_python_constant_reflection(body);
5043            }
5044            FlowEvent::Try {
5045                body,
5046                catch_events,
5047                finally_events,
5048                ..
5049            } => {
5050                rewrite_python_constant_reflection(body);
5051                rewrite_python_constant_reflection(catch_events);
5052                rewrite_python_constant_reflection(finally_events);
5053            }
5054            _ => {}
5055        }
5056    }
5057}
5058
5059/// True iff `text` looks like a Python string literal. Accepts both
5060/// double- and single-quoted forms (Python treats them equivalently);
5061/// rejects f-strings, raw strings, and any non-quoted identifier.
5062fn is_python_string_literal(text: &str) -> bool {
5063    let trimmed = text.trim();
5064    let starts_with_quote = trimmed.starts_with('"') || trimmed.starts_with('\'');
5065    let ends_with_quote = trimmed.ends_with('"') || trimmed.ends_with('\'');
5066    starts_with_quote && ends_with_quote && trimmed.len() >= 2
5067}
5068
5069/// Strip the outermost matching quote pair from a Python string literal.
5070/// Caller is expected to have already validated the input via
5071/// `is_python_string_literal`; this just yields the inner text.
5072fn strip_python_string_quotes(text: &str) -> String {
5073    text.trim()
5074        .trim_start_matches(['"', '\''])
5075        .trim_end_matches(['"', '\''])
5076        .to_string()
5077}
5078
5079/// Rewrite `g.send(value)` (Python's coroutine resume) into a
5080/// synthesized direct call to the generator factory whose result `g`
5081/// was bound to. After the rewrite, the engine's normal
5082/// interprocedural propagation taints the generator function's body
5083/// when `value` is tainted.
5084///
5085/// Pattern recognized:
5086///   `g = gen()`             ← Assign with source_call: "gen"
5087///   `g.send(arg)`           ← Call with name: "g.send", receiver: "g"
5088///
5089/// Effect: rewrite the second event into
5090///   `Call { name: "gen", args: [arg] }`
5091///
5092/// Generators without a recognizable factory binding are left alone.
5093/// Dynamic forms (`gens[i].send(...)`) likewise stay unrewritten;
5094/// the engine's existing yield-as-return-equivalent model still
5095/// applies for those.
5096#[allow(clippy::case_sensitive_file_extension_comparisons)] // `.send` is a method name, not an extension
5097fn rewrite_python_generator_send(events: &mut [bonsai_lang_api::FlowEvent]) {
5098    use bonsai_lang_api::{CallKind, FlowEvent};
5099    use std::collections::HashMap;
5100    let mut gen_factories: HashMap<String, String> = HashMap::new();
5101    for event in events.iter_mut() {
5102        match event {
5103            FlowEvent::Assign {
5104                target, source_call, ..
5105            } => {
5106                if let Some(call) = source_call {
5107                    // Only register simple bare-identifier factories
5108                    // — `gen()` not `pkg.gen(args)` — to keep the
5109                    // rewrite low-risk. The right-hand call must
5110                    // also have no qualifying receiver.
5111                    if !call.contains('.') && !call.is_empty() {
5112                        gen_factories.insert(target.clone(), call.clone());
5113                    }
5114                }
5115            }
5116            FlowEvent::Call {
5117                name,
5118                receiver,
5119                args,
5120                call_kind,
5121                ..
5122            } => {
5123                let Some(rcv) = receiver.clone() else {
5124                    continue;
5125                };
5126                if !name.ends_with(".send") {
5127                    continue;
5128                }
5129                let Some(factory) = gen_factories.get(&rcv) else {
5130                    continue;
5131                };
5132                name.clone_from(factory);
5133                *receiver = None;
5134                *call_kind = CallKind::Function;
5135                let _ = args; // first arg becomes the resumed value, propagated as-is.
5136            }
5137            FlowEvent::Branch {
5138                then_events,
5139                else_events,
5140                ..
5141            } => {
5142                rewrite_python_generator_send(then_events);
5143                rewrite_python_generator_send(else_events);
5144            }
5145            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
5146                rewrite_python_generator_send(body);
5147            }
5148            FlowEvent::Try {
5149                body,
5150                catch_events,
5151                finally_events,
5152                ..
5153            } => {
5154                rewrite_python_generator_send(body);
5155                rewrite_python_generator_send(catch_events);
5156                rewrite_python_generator_send(finally_events);
5157            }
5158            _ => {}
5159        }
5160    }
5161}
5162
5163#[cfg(test)]
5164mod pattern_tests {
5165    use super::*;
5166
5167    #[test]
5168    fn match_bindings_follow_only_capture_positions_with_exact_projections() {
5169        let src = br#"match subject:
5170    case {"value": value, "nested": {"item": item}, **rest} if limit:
5171        pass
5172    case Point(x=px, y=py) as point:
5173        pass
5174"#;
5175        let mut parser = tree_sitter::Parser::new();
5176        let language = language_from_pack(PACK_NAME).expect("Python grammar");
5177        parser.set_language(&language).expect("Python grammar");
5178        let tree = parser.parse(src, None).expect("Python parse");
5179        let match_node = tree
5180            .root_node()
5181            .named_child(0)
5182            .expect("top-level match statement");
5183        let sites = python_pattern_bindings(match_node, src);
5184        let mut facts = sites
5185            .iter()
5186            .map(|site| {
5187                (
5188                    node_text(&site.target, src).trim().to_string(),
5189                    site.projection.clone(),
5190                )
5191            })
5192            .collect::<Vec<_>>();
5193        facts.sort_by(|left, right| left.0.cmp(&right.0));
5194
5195        let mut expected = vec![
5196            (
5197                "item".to_string(),
5198                vec![
5199                    PatternSourceProjection::Field("nested".to_string()),
5200                    PatternSourceProjection::Field("item".to_string()),
5201                ],
5202            ),
5203            ("point".to_string(), Vec::<PatternSourceProjection>::new()),
5204            (
5205                "px".to_string(),
5206                vec![PatternSourceProjection::Field("x".to_string())],
5207            ),
5208            (
5209                "py".to_string(),
5210                vec![PatternSourceProjection::Field("y".to_string())],
5211            ),
5212            ("rest".to_string(), vec![PatternSourceProjection::Descendants]),
5213            (
5214                "value".to_string(),
5215                vec![PatternSourceProjection::Field("value".to_string())],
5216            ),
5217        ];
5218        expected.sort_by(|left, right| left.0.cmp(&right.0));
5219        assert_eq!(facts, expected);
5220    }
5221}