Skip to main content

bock_types/
ownership.rs

1//! Ownership analysis — O-AIR pass.
2//!
3//! Implements tree-walk ownership analysis over the AIR with
4//! divergence-aware branch merging. Detects:
5//!
6//! - Use-after-move errors
7//! - Mutable borrows of non-`mut` variables
8//! - Moves inside loop bodies (would double-move on next iteration)
9//!
10//! # Algorithm
11//!
12//! Walk the AIR, maintaining a per-variable `VarOwnership` map. At
13//! control-flow join points (if/else, match, guard) merge branch states:
14//! diverging branches are excluded; among non-diverging branches, any move
15//! makes the variable considered moved at the join.
16
17use std::collections::{HashMap, HashSet};
18
19use bock_air::stubs::Value;
20use bock_air::{AIRNode, AirInterpolationPart, NodeId, NodeKind};
21use bock_ast::AssignOp;
22use bock_errors::{DiagnosticBag, DiagnosticCode, Span};
23
24// ─── Diagnostic codes ─────────────────────────────────────────────────────────
25
26const E_USE_AFTER_MOVE: DiagnosticCode = DiagnosticCode {
27    prefix: 'E',
28    number: 5001,
29};
30const E_MUT_BORROW_NEEDS_MUT: DiagnosticCode = DiagnosticCode {
31    prefix: 'E',
32    number: 5002,
33};
34const E_LOOP_MOVE: DiagnosticCode = DiagnosticCode {
35    prefix: 'E',
36    number: 5003,
37};
38/// `E5004` — an in-place `List` mutator (`push`/`append`, DQ18;
39/// `pop`/`remove_at`/`insert`/`reverse`/`set`, DQ30) was called on a receiver
40/// that is not a mutable lvalue. These methods mutate the list in place — most
41/// return `Void`; `pop` returns `Optional[T]` and `remove_at` the removed `T`,
42/// but the receiver contract is identical — so they require a `mut` binding (a
43/// `let mut` list, a `mut` parameter, or a field reachable through a `mut`
44/// receiver). Functional list-building (`+` / `concat`) stays value-returning
45/// and needs no `mut`.
46const E_MUT_RECEIVER_NEEDED: DiagnosticCode = DiagnosticCode {
47    prefix: 'E',
48    number: 5004,
49};
50
51/// The in-place `List` mutators that require a `mut` receiver: DQ18's
52/// `push`/`append` plus DQ30's `pop`/`remove_at`/`insert`/`reverse` and the
53/// pinned indexed `set`. The remaining receiver-returning combinators
54/// (`sort`/`dedup`/`concat`/…) are value-returning and stay `mut`-free.
55const MUT_LIST_RECEIVER_METHODS: &[&str] = &[
56    "push",
57    "append",
58    "pop",
59    "remove_at",
60    "insert",
61    "reverse",
62    "set",
63];
64
65// ─── Public types ─────────────────────────────────────────────────────────────
66
67/// Convenience alias — the AIR module root node.
68pub type AIRModule = AIRNode;
69
70/// Ownership state of a variable at a given program point.
71#[derive(Debug, Clone, PartialEq)]
72pub enum OwnershipState {
73    /// Variable owns its value.
74    Owned,
75    /// Variable is currently immutably borrowed.
76    Borrowed,
77    /// Variable is currently mutably borrowed.
78    MutBorrowed,
79    /// Value has been moved out; variable is invalid.
80    Moved,
81    /// Annotated `@managed` — GC semantics, no tracking.
82    Managed,
83}
84
85/// Ownership information for a single binding at a program point.
86#[derive(Debug, Clone, PartialEq)]
87pub struct OwnershipInfo {
88    /// Current ownership state.
89    pub state: OwnershipState,
90    /// Whether the binding was declared `mut`.
91    pub mutable: bool,
92    /// Node that established ownership (the binding site).
93    pub origin: NodeId,
94}
95
96// ─── Internal bookkeeping ─────────────────────────────────────────────────────
97
98#[derive(Clone)]
99struct VarOwnership {
100    state: OwnershipState,
101    is_mut: bool,
102    /// The span where the value was moved, if it has been.
103    move_site: Option<Span>,
104}
105
106// ─── Analyzer ────────────────────────────────────────────────────────────────
107
108struct OwnershipAnalyzer {
109    diags: DiagnosticBag,
110    env: HashMap<String, VarOwnership>,
111    in_loop: bool,
112    /// Variable names that existed when the current loop was entered.
113    loop_entry_keys: HashSet<String>,
114    /// Whether we are inside a `@managed` function body.
115    in_managed: bool,
116}
117
118impl OwnershipAnalyzer {
119    fn new() -> Self {
120        Self {
121            diags: DiagnosticBag::new(),
122            env: HashMap::new(),
123            in_loop: false,
124            loop_entry_keys: HashSet::new(),
125            in_managed: false,
126        }
127    }
128
129    fn snapshot(&self) -> HashMap<String, VarOwnership> {
130        self.env.clone()
131    }
132
133    /// Check that `name` is still valid (not moved). Emits a diagnostic if it
134    /// has been moved.
135    fn check_use(&mut self, name: &str, use_span: Span) {
136        if let Some(var) = self.env.get(name) {
137            if matches!(var.state, OwnershipState::Moved) {
138                let move_site = var.move_site;
139                let diag = self.diags.error(
140                    E_USE_AFTER_MOVE,
141                    format!("use of moved variable `{name}`"),
142                    use_span,
143                );
144                if let Some(ms) = move_site {
145                    diag.label(ms, "value moved here");
146                }
147            }
148        }
149    }
150
151    /// Mark `name` as moved from `move_span`. Emits loop-move or
152    /// double-move diagnostics as needed.
153    fn do_move(&mut self, name: &str, move_span: Span) {
154        if let Some(var) = self.env.get(name) {
155            if matches!(var.state, OwnershipState::Managed) {
156                return; // @managed — no tracking
157            }
158        }
159
160        // Moving a pre-loop variable inside a loop body = double-move error.
161        if self.in_loop && self.loop_entry_keys.contains(name) {
162            self.diags.error(
163                E_LOOP_MOVE,
164                format!(
165                    "cannot move `{name}` inside a loop \
166                     (would be moved on every iteration)"
167                ),
168                move_span,
169            );
170        }
171
172        if let Some(var) = self.env.get_mut(name) {
173            if matches!(var.state, OwnershipState::Moved) {
174                // Already moved — use-after-move.
175                let move_site = var.move_site;
176                let diag = self.diags.error(
177                    E_USE_AFTER_MOVE,
178                    format!("use of moved variable `{name}`"),
179                    move_span,
180                );
181                if let Some(ms) = move_site {
182                    diag.label(ms, "value moved here");
183                }
184            } else {
185                var.state = OwnershipState::Moved;
186                var.move_site = Some(move_span);
187            }
188        }
189    }
190
191    /// Analyze a node whose produced value will be *moved* (transferred).
192    ///
193    /// For bare identifiers this marks the variable as moved; for everything
194    /// else it delegates to the normal analysis (the value comes from a
195    /// fresh temporary and nothing in the env is moved).
196    fn analyze_move(&mut self, node: &AIRNode) -> bool {
197        if let NodeKind::Identifier { name } = &node.kind {
198            if let Some(var) = self.env.get(&name.name) {
199                if matches!(var.state, OwnershipState::Managed) {
200                    return false; // @managed: skip
201                }
202            }
203            // Primitive types (Int, Float, Bool, Char, String) have copy
204            // semantics — using them doesn't transfer ownership.
205            if node.metadata.get("copy_type") == Some(&Value::Bool(true)) {
206                return false;
207            }
208            self.do_move(&name.name, node.span);
209            false
210        } else {
211            self.analyze_node(node)
212        }
213    }
214
215    /// Whether `node` is a **mutable lvalue** — a place that may be mutated in
216    /// place. Used to enforce DQ18's `mut`-receiver requirement on the in-place
217    /// `List` mutators (`push`/`append`).
218    ///
219    /// The rules mirror the binding-level `mut` model `bind_pattern`/`bind_param`
220    /// already track:
221    ///
222    /// - A bare identifier is mutable iff its binding was declared `mut`
223    ///   (`let mut` / `mut` parameter). An unknown identifier (not in `env` —
224    ///   e.g. a global const or a name from an un-analysed scope) is treated as
225    ///   non-mutable. A `@managed` binding is always permitted (GC semantics, no
226    ///   `mut` tracking).
227    /// - A field access (`r.items`) or index (`xs[i]`) is mutable iff its base
228    ///   place is mutable, so `r.items.push(x)` requires `r` to be `mut`.
229    /// - Anything else (a call result, a literal, an arbitrary expression) is a
230    ///   temporary, not a place, and is non-mutable.
231    fn is_mutable_lvalue(&self, node: &AIRNode) -> bool {
232        match &node.kind {
233            NodeKind::Identifier { name } => match self.env.get(&name.name) {
234                Some(var) => var.is_mut || matches!(var.state, OwnershipState::Managed),
235                // Not a tracked local (global/const/unknown) — not a `mut` place.
236                None => false,
237            },
238            NodeKind::FieldAccess { object, .. } => self.is_mutable_lvalue(object),
239            NodeKind::Index { object, .. } => self.is_mutable_lvalue(object),
240            _ => false,
241        }
242    }
243
244    /// Enforce the `mut`-receiver requirement for the in-place `List` mutators
245    /// (`push`/`append`, DQ18; `pop`/`remove_at`/`insert`/`reverse`/`set`,
246    /// DQ30).
247    ///
248    /// The lowerer desugars `recv.push(x)` to
249    /// `Call { callee: FieldAccess(recv, "push"), args: [recv, x] }` and the
250    /// checker stamps the call node with `recv_kind = "List"`. When the receiver
251    /// is not a mutable lvalue, emit `E5004` pointing at the receiver and
252    /// suggesting `let mut`.
253    fn check_list_mut_receiver(&mut self, call: &AIRNode, callee: &AIRNode) {
254        // Only fire on a `List` receiver. An absent stamp (unresolved receiver
255        // type) is left alone so this never false-positives on a user type whose
256        // method happens to be named `push`.
257        if self.recv_kind(call) != Some("List") {
258            return;
259        }
260        let NodeKind::FieldAccess { object, field } = &callee.kind else {
261            return;
262        };
263        if !MUT_LIST_RECEIVER_METHODS.contains(&field.name.as_str()) {
264            return;
265        }
266        if self.is_mutable_lvalue(object) {
267            return;
268        }
269        let recv_desc = match &object.kind {
270            NodeKind::Identifier { name } => format!("`{}`", name.name),
271            _ => "the receiver".to_string(),
272        };
273        self.diags
274            .error(
275                E_MUT_RECEIVER_NEEDED,
276                format!(
277                    "cannot call `{}` on {recv_desc}: \
278                     it mutates the list in place and requires a `mut` receiver",
279                    field.name
280                ),
281                object.span,
282            )
283            .note(
284                "declare the list with `let mut`, or use `+` / `concat` to build a \
285                 new list without mutation",
286            );
287    }
288
289    /// Read the checker's `recv_kind` stamp off a (desugared) call node, if any.
290    fn recv_kind<'a>(&self, node: &'a AIRNode) -> Option<&'a str> {
291        match node.metadata.get(crate::checker::RECV_KIND_META_KEY) {
292            Some(Value::String(s)) => Some(s.as_str()),
293            _ => None,
294        }
295    }
296
297    /// Merge branch states after a fork.
298    ///
299    /// `pre` is the state at the fork entry. `branches` is a list of
300    /// (diverges, post-state) pairs. Diverging branches are excluded.
301    /// If all branches diverge the join is unreachable and `pre` is returned.
302    fn merge_states(
303        &self,
304        pre: &HashMap<String, VarOwnership>,
305        branches: &[(bool, HashMap<String, VarOwnership>)],
306    ) -> HashMap<String, VarOwnership> {
307        let non_div: Vec<&HashMap<String, VarOwnership>> = branches
308            .iter()
309            .filter(|(div, _)| !*div)
310            .map(|(_, s)| s)
311            .collect();
312
313        if non_div.is_empty() {
314            // Unreachable join — propagate pre-state unchanged.
315            return pre.clone();
316        }
317
318        let mut result = pre.clone();
319        for name in pre.keys() {
320            let any_moved = non_div.iter().any(|state| {
321                state
322                    .get(name)
323                    .is_some_and(|v| matches!(v.state, OwnershipState::Moved))
324            });
325            if any_moved {
326                if let Some(var) = result.get_mut(name) {
327                    let move_site = non_div
328                        .iter()
329                        .filter_map(|state| state.get(name))
330                        .find(|v| matches!(v.state, OwnershipState::Moved))
331                        .and_then(|v| v.move_site);
332                    var.state = OwnershipState::Moved;
333                    var.move_site = move_site;
334                }
335            }
336        }
337        result
338    }
339
340    /// Add a parameter as an owned binding.
341    fn bind_param(&mut self, param: &AIRNode) {
342        if let NodeKind::Param { pattern, .. } = &param.kind {
343            if let NodeKind::BindPat { name, is_mut } = &pattern.kind {
344                let state = if self.in_managed {
345                    OwnershipState::Managed
346                } else {
347                    OwnershipState::Owned
348                };
349                self.env.insert(
350                    name.name.clone(),
351                    VarOwnership {
352                        state,
353                        is_mut: *is_mut,
354                        move_site: None,
355                    },
356                );
357            }
358        }
359    }
360
361    /// Add bindings introduced by a pattern (e.g. in `let`, match arms, loops).
362    fn bind_pattern(&mut self, pat: &AIRNode) {
363        let base_state = if self.in_managed {
364            OwnershipState::Managed
365        } else {
366            OwnershipState::Owned
367        };
368        match &pat.kind {
369            NodeKind::BindPat { name, is_mut } => {
370                self.env.insert(
371                    name.name.clone(),
372                    VarOwnership {
373                        state: base_state,
374                        is_mut: *is_mut,
375                        move_site: None,
376                    },
377                );
378            }
379            NodeKind::TuplePat { elems } => {
380                for e in elems {
381                    self.bind_pattern(e);
382                }
383            }
384            NodeKind::ConstructorPat { fields, .. } => {
385                for f in fields {
386                    self.bind_pattern(f);
387                }
388            }
389            NodeKind::RecordPat { fields, .. } => {
390                for f in fields {
391                    if let Some(p) = &f.pattern {
392                        self.bind_pattern(p);
393                    } else {
394                        self.env.insert(
395                            f.name.name.clone(),
396                            VarOwnership {
397                                state: base_state.clone(),
398                                is_mut: false,
399                                move_site: None,
400                            },
401                        );
402                    }
403                }
404            }
405            NodeKind::ListPat { elems, rest } => {
406                for e in elems {
407                    self.bind_pattern(e);
408                }
409                if let Some(r) = rest {
410                    self.bind_pattern(r);
411                }
412            }
413            NodeKind::OrPat { alternatives } => {
414                if let Some(first) = alternatives.first() {
415                    self.bind_pattern(first);
416                }
417            }
418            _ => {}
419        }
420    }
421
422    /// Returns `true` if this node always diverges (never returns normally).
423    #[allow(clippy::too_many_lines)]
424    fn analyze_node(&mut self, node: &AIRNode) -> bool {
425        match &node.kind {
426            // ── Module root ──────────────────────────────────────────────────
427            NodeKind::Module { imports, items, .. } => {
428                for n in imports {
429                    self.analyze_node(n);
430                }
431                for n in items {
432                    self.analyze_node(n);
433                }
434                false
435            }
436
437            // ── Declarations ─────────────────────────────────────────────────
438            NodeKind::FnDecl {
439                annotations,
440                params,
441                body,
442                ..
443            } => {
444                let outer = self.snapshot();
445                let outer_managed = self.in_managed;
446                if annotations.iter().any(|a| a.name.name == "managed") {
447                    self.in_managed = true;
448                }
449                for p in params {
450                    self.bind_param(p);
451                }
452                self.analyze_node(body);
453                self.env = outer;
454                self.in_managed = outer_managed;
455                false
456            }
457            NodeKind::ImplBlock { methods, .. } => {
458                for m in methods {
459                    self.analyze_node(m);
460                }
461                false
462            }
463            NodeKind::ClassDecl { methods, .. } => {
464                for m in methods {
465                    self.analyze_node(m);
466                }
467                false
468            }
469            NodeKind::TraitDecl { methods, .. } => {
470                for m in methods {
471                    self.analyze_node(m);
472                }
473                false
474            }
475            // Type-level / effect decls carry no runtime ownership.
476            NodeKind::RecordDecl { .. }
477            | NodeKind::EnumDecl { .. }
478            | NodeKind::TypeAlias { .. }
479            | NodeKind::EffectDecl { .. }
480            | NodeKind::ConstDecl { .. }
481            | NodeKind::ImportDecl { .. }
482            | NodeKind::ModuleHandle { .. }
483            | NodeKind::PropertyTest { .. } => false,
484
485            // ── Block ─────────────────────────────────────────────────────────
486            NodeKind::Block { stmts, tail } => {
487                let pre_keys: HashSet<String> = self.env.keys().cloned().collect();
488                let mut diverges = false;
489                for stmt in stmts {
490                    if diverges {
491                        break;
492                    }
493                    diverges = self.analyze_node(stmt);
494                }
495                if !diverges {
496                    if let Some(t) = tail {
497                        diverges = self.analyze_node(t);
498                    }
499                }
500                // Drop bindings that were introduced in this block scope.
501                self.env.retain(|k, _| pre_keys.contains(k));
502                diverges
503            }
504
505            // ── Let binding ───────────────────────────────────────────────────
506            NodeKind::LetBinding {
507                is_mut,
508                pattern,
509                value,
510                ..
511            } => {
512                let is_managed =
513                    self.in_managed || node.metadata.get("managed") == Some(&Value::Bool(true));
514
515                self.analyze_move(value);
516
517                let state = if is_managed {
518                    OwnershipState::Managed
519                } else {
520                    OwnershipState::Owned
521                };
522
523                // For tracking purposes we only need to insert simple BindPat
524                // names; complex patterns are handled by bind_pattern which
525                // also adds Owned state. @managed simply overrides.
526                let own = VarOwnership {
527                    state,
528                    is_mut: *is_mut,
529                    move_site: None,
530                };
531                if is_managed {
532                    // Insert with Managed state so reads are still fine.
533                    if let NodeKind::BindPat { name, .. } = &pattern.kind {
534                        self.env.insert(name.name.clone(), own);
535                    } else {
536                        self.bind_pattern(pattern);
537                        // Override all inserted entries to Managed.
538                        // (Nested managed patterns are uncommon; this is best-effort.)
539                    }
540                } else {
541                    self.bind_pattern(pattern);
542                }
543                false
544            }
545
546            // ── Assignment ────────────────────────────────────────────────────
547            NodeKind::Assign { op, target, value } => {
548                match op {
549                    AssignOp::Assign => {
550                        // Plain assignment: rhs value is moved into lhs.
551                        self.analyze_move(value);
552                        self.analyze_node(target);
553                    }
554                    _ => {
555                        // Compound assignment: both are reads.
556                        self.analyze_node(target);
557                        self.analyze_node(value);
558                    }
559                }
560                false
561            }
562
563            // ── Identifier use ────────────────────────────────────────────────
564            NodeKind::Identifier { name } => {
565                self.check_use(&name.name, node.span);
566                false
567            }
568
569            // ── Explicit ownership operations ─────────────────────────────────
570            NodeKind::Move { expr } => {
571                self.analyze_move(expr);
572                false
573            }
574            NodeKind::Borrow { expr } => {
575                // Immutable borrow — check validity, no ownership change.
576                self.analyze_node(expr);
577                false
578            }
579            NodeKind::MutableBorrow { expr } => {
580                // Mutable borrow requires `mut` on the binding.
581                if let NodeKind::Identifier { name } = &expr.kind {
582                    if let Some(var) = self.env.get(&name.name) {
583                        if !var.is_mut && !matches!(var.state, OwnershipState::Managed) {
584                            self.diags.error(
585                                E_MUT_BORROW_NEEDS_MUT,
586                                format!(
587                                    "cannot mutably borrow `{}`: \
588                                     variable not declared `mut`",
589                                    name.name
590                                ),
591                                expr.span,
592                            );
593                        }
594                    }
595                }
596                self.analyze_node(expr);
597                false
598            }
599
600            // ── Diverging statements ──────────────────────────────────────────
601            NodeKind::Return { value } => {
602                if let Some(v) = value {
603                    // Return exits the function (and any enclosing loop),
604                    // so moves in the return value cannot repeat.
605                    let old_in_loop = self.in_loop;
606                    self.in_loop = false;
607                    self.analyze_move(v);
608                    self.in_loop = old_in_loop;
609                }
610                true
611            }
612            NodeKind::Break { value } => {
613                if let Some(v) = value {
614                    // Break exits the loop, so moves in the break value
615                    // cannot repeat on the next iteration.
616                    let old_in_loop = self.in_loop;
617                    self.in_loop = false;
618                    self.analyze_move(v);
619                    self.in_loop = old_in_loop;
620                }
621                true
622            }
623            NodeKind::Continue => true,
624            NodeKind::Unreachable => true,
625
626            // ── If / if-let ───────────────────────────────────────────────────
627            NodeKind::If {
628                condition,
629                then_block,
630                else_block,
631                ..
632            } => {
633                self.analyze_node(condition);
634
635                let pre = self.snapshot();
636
637                let then_div = self.analyze_node(then_block);
638                let then_state = self.snapshot();
639
640                self.env = pre.clone();
641                let (else_div, else_state) = match else_block {
642                    Some(eb) => {
643                        let d = self.analyze_node(eb);
644                        (d, self.snapshot())
645                    }
646                    None => {
647                        // No else branch = implicitly non-diverging with pre-state.
648                        (false, pre.clone())
649                    }
650                };
651
652                self.env =
653                    self.merge_states(&pre, &[(then_div, then_state), (else_div, else_state)]);
654                then_div && else_div
655            }
656
657            // ── Guard ─────────────────────────────────────────────────────────
658            NodeKind::Guard {
659                let_pattern,
660                condition,
661                else_block,
662            } => {
663                if let Some(pat) = let_pattern {
664                    self.analyze_node(pat);
665                }
666                self.analyze_node(condition);
667                // The else_block should diverge; even if it doesn't we still
668                // exclude its state from the main path per spec.
669                let pre = self.snapshot();
670                self.analyze_node(else_block);
671                // Main path continues from pre-state (else excluded).
672                self.env = pre;
673                false
674            }
675
676            // ── Match ─────────────────────────────────────────────────────────
677            NodeKind::Match { scrutinee, arms } => {
678                self.analyze_node(scrutinee);
679
680                let pre = self.snapshot();
681                let mut arm_results: Vec<(bool, HashMap<String, VarOwnership>)> =
682                    Vec::with_capacity(arms.len());
683
684                for arm in arms {
685                    self.env = pre.clone();
686                    let div = self.analyze_node(arm);
687                    arm_results.push((div, self.snapshot()));
688                }
689
690                self.env = self.merge_states(&pre, &arm_results);
691                arm_results.iter().all(|(d, _)| *d)
692            }
693
694            NodeKind::MatchArm {
695                pattern,
696                guard,
697                body,
698            } => {
699                let pre_keys: HashSet<String> = self.env.keys().cloned().collect();
700                self.bind_pattern(pattern);
701                if let Some(g) = guard {
702                    self.analyze_node(g);
703                }
704                let div = self.analyze_node(body);
705                // Drop arm-local bindings.
706                self.env.retain(|k, _| pre_keys.contains(k));
707                div
708            }
709
710            // ── Loops ─────────────────────────────────────────────────────────
711            NodeKind::For {
712                pattern,
713                iterable,
714                body,
715            } => {
716                self.analyze_node(iterable);
717                let pre = self.snapshot();
718                let old_in_loop = self.in_loop;
719                let old_loop_keys = std::mem::take(&mut self.loop_entry_keys);
720                self.in_loop = true;
721                self.loop_entry_keys = pre.keys().cloned().collect();
722                self.bind_pattern(pattern);
723                self.analyze_node(body);
724                self.in_loop = old_in_loop;
725                self.loop_entry_keys = old_loop_keys;
726                // Loop may execute zero times — restore pre-state.
727                self.env = pre;
728                false
729            }
730            NodeKind::While { condition, body } => {
731                self.analyze_node(condition);
732                let pre = self.snapshot();
733                let old_in_loop = self.in_loop;
734                let old_loop_keys = std::mem::take(&mut self.loop_entry_keys);
735                self.in_loop = true;
736                self.loop_entry_keys = pre.keys().cloned().collect();
737                self.analyze_node(body);
738                self.in_loop = old_in_loop;
739                self.loop_entry_keys = old_loop_keys;
740                self.env = pre;
741                false
742            }
743            NodeKind::Loop { body } => {
744                let pre = self.snapshot();
745                let old_in_loop = self.in_loop;
746                let old_loop_keys = std::mem::take(&mut self.loop_entry_keys);
747                self.in_loop = true;
748                self.loop_entry_keys = pre.keys().cloned().collect();
749                self.analyze_node(body);
750                self.in_loop = old_in_loop;
751                self.loop_entry_keys = old_loop_keys;
752                self.env = pre;
753                false
754            }
755
756            // ── Calls ─────────────────────────────────────────────────────────
757            NodeKind::Call { callee, args, .. } => {
758                // DQ18: an in-place `List` mutator (`push`/`append`) requires a
759                // `mut` receiver. The lowerer desugars `recv.push(x)` into this
760                // `Call(FieldAccess(recv, …), [recv, …])` shape and the checker
761                // stamps `recv_kind = "List"`, so this is the single site that
762                // sees both the method name and the receiver place.
763                self.check_list_mut_receiver(node, callee);
764                self.analyze_node(callee);
765                for arg in args {
766                    // Arguments are borrows by default; explicit `move` or
767                    // `MutableBorrow` wrapping is handled by their own arms.
768                    self.analyze_node(&arg.value);
769                }
770                false
771            }
772            NodeKind::MethodCall { receiver, args, .. } => {
773                self.analyze_node(receiver);
774                for arg in args {
775                    self.analyze_node(&arg.value);
776                }
777                false
778            }
779
780            // ── Lambda ────────────────────────────────────────────────────────
781            NodeKind::Lambda { params, body } => {
782                let outer = self.snapshot();
783                for p in params {
784                    self.bind_param(p);
785                }
786                self.analyze_node(body);
787                self.env = outer;
788                false
789            }
790
791            // ── Other expressions ─────────────────────────────────────────────
792            NodeKind::BinaryOp { left, right, .. } => {
793                self.analyze_node(left);
794                self.analyze_node(right);
795                false
796            }
797            NodeKind::UnaryOp { operand, .. } => {
798                self.analyze_node(operand);
799                false
800            }
801            NodeKind::FieldAccess { object, .. } => {
802                self.analyze_node(object);
803                false
804            }
805            NodeKind::Index { object, index } => {
806                self.analyze_node(object);
807                self.analyze_node(index);
808                false
809            }
810            NodeKind::Propagate { expr } => {
811                self.analyze_node(expr);
812                false
813            }
814            NodeKind::Pipe { left, right } | NodeKind::Compose { left, right } => {
815                self.analyze_node(left);
816                self.analyze_node(right);
817                false
818            }
819            NodeKind::Await { expr } => {
820                self.analyze_node(expr);
821                false
822            }
823            NodeKind::Range { lo, hi, .. } => {
824                self.analyze_node(lo);
825                self.analyze_node(hi);
826                false
827            }
828            NodeKind::RecordConstruct { fields, spread, .. } => {
829                for f in fields {
830                    if let Some(v) = &f.value {
831                        self.analyze_move(v);
832                    }
833                }
834                if let Some(s) = spread {
835                    self.analyze_node(s);
836                }
837                false
838            }
839            NodeKind::ListLiteral { elems }
840            | NodeKind::SetLiteral { elems }
841            | NodeKind::TupleLiteral { elems } => {
842                for e in elems {
843                    self.analyze_move(e);
844                }
845                false
846            }
847            NodeKind::MapLiteral { entries } => {
848                for entry in entries {
849                    self.analyze_move(&entry.key);
850                    self.analyze_move(&entry.value);
851                }
852                false
853            }
854            NodeKind::Interpolation { parts } => {
855                for part in parts {
856                    if let AirInterpolationPart::Expr(e) = part {
857                        self.analyze_node(e);
858                    }
859                }
860                false
861            }
862            NodeKind::ResultConstruct { value, .. } => {
863                if let Some(v) = value {
864                    self.analyze_move(v);
865                }
866                false
867            }
868            NodeKind::HandlingBlock { handlers, body } => {
869                for h in handlers {
870                    self.analyze_node(&h.handler);
871                }
872                self.analyze_node(body);
873                false
874            }
875
876            // ── Terminals ─────────────────────────────────────────────────────
877            NodeKind::Literal { .. }
878            | NodeKind::Placeholder
879            | NodeKind::TypeSelf
880            | NodeKind::WildcardPat
881            | NodeKind::LiteralPat { .. }
882            | NodeKind::RestPat
883            | NodeKind::TypeNamed { .. }
884            | NodeKind::TypeTuple { .. }
885            | NodeKind::TypeFunction { .. }
886            | NodeKind::TypeOptional { .. }
887            | NodeKind::EffectOp { .. }
888            | NodeKind::EffectRef { .. }
889            | NodeKind::Error => false,
890
891            // ── Patterns (visited via bind_pattern, not analyze_node) ─────────
892            NodeKind::BindPat { .. }
893            | NodeKind::ConstructorPat { .. }
894            | NodeKind::RecordPat { .. }
895            | NodeKind::TuplePat { .. }
896            | NodeKind::ListPat { .. }
897            | NodeKind::OrPat { .. }
898            | NodeKind::GuardPat { .. }
899            | NodeKind::RangePat { .. } => false,
900
901            // ── Catch-all for future node kinds ──────────────────────────────
902            _ => false,
903        }
904    }
905}
906
907// ─── Public entry point ───────────────────────────────────────────────────────
908
909/// Perform ownership analysis on an AIR module.
910///
911/// Returns a [`DiagnosticBag`] containing any ownership violations found.
912/// A non-empty bag with errors indicates the program has ownership errors.
913///
914/// # Analysis performed
915///
916/// - **Use-after-move**: using a variable after its value was moved out.
917/// - **Mutable borrow of non-`mut`**: `&mut x` when `x` was not declared `mut`.
918/// - **Move in loop body**: moving a pre-loop variable inside a loop.
919/// - **`@managed` escape hatch**: variables with `metadata["managed"] = true`
920///   are excluded from ownership tracking.
921/// - **Divergence-aware branch merging**: diverging branches (return, break,
922///   continue, unreachable) are excluded from join-point state merges.
923#[must_use]
924pub fn analyze_ownership(module: &AIRModule) -> DiagnosticBag {
925    let mut analyzer = OwnershipAnalyzer::new();
926    analyzer.analyze_node(module);
927    analyzer.diags
928}
929
930// ─── Tests ────────────────────────────────────────────────────────────────────
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935    use bock_air::stubs::Value;
936    use bock_air::{AIRNode, AirArg, NodeIdGen, NodeKind};
937    use bock_ast::{Ident, Literal};
938    use bock_errors::{FileId, Span};
939
940    // ── Helpers ───────────────────────────────────────────────────────────────
941
942    fn span() -> Span {
943        Span {
944            file: FileId(0),
945            start: 0,
946            end: 0,
947        }
948    }
949
950    fn span_at(start: usize, end: usize) -> Span {
951        Span {
952            file: FileId(0),
953            start,
954            end,
955        }
956    }
957
958    fn ident(name: &str) -> Ident {
959        Ident {
960            name: name.to_string(),
961            span: span(),
962        }
963    }
964
965    fn node(gen: &NodeIdGen, kind: NodeKind) -> AIRNode {
966        AIRNode::new(gen.next(), span(), kind)
967    }
968
969    fn node_at(gen: &NodeIdGen, kind: NodeKind, s: usize, e: usize) -> AIRNode {
970        AIRNode::new(gen.next(), span_at(s, e), kind)
971    }
972
973    fn id_node(gen: &NodeIdGen, name: &str) -> AIRNode {
974        node(gen, NodeKind::Identifier { name: ident(name) })
975    }
976
977    fn id_node_at(gen: &NodeIdGen, name: &str, s: usize, e: usize) -> AIRNode {
978        node_at(gen, NodeKind::Identifier { name: ident(name) }, s, e)
979    }
980
981    fn lit_node(gen: &NodeIdGen) -> AIRNode {
982        node(
983            gen,
984            NodeKind::Literal {
985                lit: Literal::Int("42".into()),
986            },
987        )
988    }
989
990    fn bind_pat(gen: &NodeIdGen, name: &str, is_mut: bool) -> AIRNode {
991        node(
992            gen,
993            NodeKind::BindPat {
994                name: ident(name),
995                is_mut,
996            },
997        )
998    }
999
1000    fn let_binding(gen: &NodeIdGen, name: &str, is_mut: bool, value: AIRNode) -> AIRNode {
1001        node(
1002            gen,
1003            NodeKind::LetBinding {
1004                is_mut,
1005                pattern: Box::new(bind_pat(gen, name, is_mut)),
1006                ty: None,
1007                value: Box::new(value),
1008            },
1009        )
1010    }
1011
1012    fn block(gen: &NodeIdGen, stmts: Vec<AIRNode>, tail: Option<AIRNode>) -> AIRNode {
1013        node(
1014            gen,
1015            NodeKind::Block {
1016                stmts,
1017                tail: tail.map(Box::new),
1018            },
1019        )
1020    }
1021
1022    fn module(gen: &NodeIdGen, items: Vec<AIRNode>) -> AIRNode {
1023        node(
1024            gen,
1025            NodeKind::Module {
1026                path: None,
1027                annotations: vec![],
1028                imports: vec![],
1029                items,
1030            },
1031        )
1032    }
1033
1034    fn fn_decl(gen: &NodeIdGen, body: AIRNode) -> AIRNode {
1035        fn_decl_with(gen, body, vec![])
1036    }
1037
1038    fn managed_fn_decl(gen: &NodeIdGen, body: AIRNode) -> AIRNode {
1039        use bock_ast::Annotation;
1040        fn_decl_with(
1041            gen,
1042            body,
1043            vec![Annotation {
1044                id: 0,
1045                span: span(),
1046                name: ident("managed"),
1047                args: vec![],
1048            }],
1049        )
1050    }
1051
1052    fn fn_decl_with(
1053        gen: &NodeIdGen,
1054        body: AIRNode,
1055        annotations: Vec<bock_ast::Annotation>,
1056    ) -> AIRNode {
1057        node(
1058            gen,
1059            NodeKind::FnDecl {
1060                annotations,
1061                visibility: bock_ast::Visibility::Public,
1062                is_async: false,
1063                name: ident("f"),
1064                generic_params: vec![],
1065                params: vec![],
1066                return_type: None,
1067                effect_clause: vec![],
1068                where_clause: vec![],
1069                body: Box::new(body),
1070            },
1071        )
1072    }
1073
1074    fn return_node(gen: &NodeIdGen, val: Option<AIRNode>) -> AIRNode {
1075        node(
1076            gen,
1077            NodeKind::Return {
1078                value: val.map(Box::new),
1079            },
1080        )
1081    }
1082
1083    fn move_node(gen: &NodeIdGen, expr: AIRNode) -> AIRNode {
1084        node(
1085            gen,
1086            NodeKind::Move {
1087                expr: Box::new(expr),
1088            },
1089        )
1090    }
1091
1092    fn mut_borrow(gen: &NodeIdGen, expr: AIRNode) -> AIRNode {
1093        node(
1094            gen,
1095            NodeKind::MutableBorrow {
1096                expr: Box::new(expr),
1097            },
1098        )
1099    }
1100
1101    fn if_node(gen: &NodeIdGen, cond: AIRNode, then: AIRNode, else_: Option<AIRNode>) -> AIRNode {
1102        node(
1103            gen,
1104            NodeKind::If {
1105                let_pattern: None,
1106                condition: Box::new(cond),
1107                then_block: Box::new(then),
1108                else_block: else_.map(Box::new),
1109            },
1110        )
1111    }
1112
1113    fn guard_node(gen: &NodeIdGen, cond: AIRNode, else_block: AIRNode) -> AIRNode {
1114        node(
1115            gen,
1116            NodeKind::Guard {
1117                let_pattern: None,
1118                condition: Box::new(cond),
1119                else_block: Box::new(else_block),
1120            },
1121        )
1122    }
1123
1124    fn loop_node(gen: &NodeIdGen, body: AIRNode) -> AIRNode {
1125        node(
1126            gen,
1127            NodeKind::Loop {
1128                body: Box::new(body),
1129            },
1130        )
1131    }
1132
1133    fn match_node(gen: &NodeIdGen, scrutinee: AIRNode, arms: Vec<AIRNode>) -> AIRNode {
1134        node(
1135            gen,
1136            NodeKind::Match {
1137                scrutinee: Box::new(scrutinee),
1138                arms,
1139            },
1140        )
1141    }
1142
1143    fn match_arm(gen: &NodeIdGen, pat: AIRNode, body: AIRNode) -> AIRNode {
1144        node(
1145            gen,
1146            NodeKind::MatchArm {
1147                pattern: Box::new(pat),
1148                guard: None,
1149                body: Box::new(body),
1150            },
1151        )
1152    }
1153
1154    // ── Tests: move detection ─────────────────────────────────────────────────
1155
1156    #[test]
1157    fn no_error_simple_borrow() {
1158        // let data = 42
1159        // summarize(data)   -- borrow, data still owned
1160        // use(data)         -- still ok
1161        let gen = NodeIdGen::new();
1162        let data_lit = lit_node(&gen);
1163        let let_data = let_binding(&gen, "data", false, data_lit);
1164        let use1 = id_node(&gen, "data");
1165        let use2 = id_node(&gen, "data");
1166        let call = node(
1167            &gen,
1168            NodeKind::Call {
1169                callee: Box::new(id_node(&gen, "summarize")),
1170                args: vec![AirArg {
1171                    label: None,
1172                    value: use1,
1173                }],
1174                type_args: vec![],
1175            },
1176        );
1177        let b = block(&gen, vec![let_data, call, use2], None);
1178        let m = module(&gen, vec![fn_decl(&gen, b)]);
1179        let diags = analyze_ownership(&m);
1180        assert!(
1181            !diags.has_errors(),
1182            "expected no errors, got: {:?}",
1183            diags.iter().collect::<Vec<_>>()
1184        );
1185    }
1186
1187    #[test]
1188    fn move_on_let_binding() {
1189        // let data = 42
1190        // let archive = data   -- moves data
1191        // use(data)            -- ERROR: use after move
1192        let gen = NodeIdGen::new();
1193        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1194        let id_data = id_node_at(&gen, "data", 10, 14);
1195        let let_archive = let_binding(&gen, "archive", false, id_data);
1196        let use_data = id_node_at(&gen, "data", 20, 24);
1197        let b = block(&gen, vec![let_data, let_archive, use_data], None);
1198        let m = module(&gen, vec![fn_decl(&gen, b)]);
1199        let diags = analyze_ownership(&m);
1200        assert!(diags.has_errors());
1201        assert!(diags.iter().any(|d| d.code == E_USE_AFTER_MOVE));
1202    }
1203
1204    #[test]
1205    fn explicit_move_node() {
1206        // let data = 42
1207        // move data
1208        // use data  -- ERROR
1209        let gen = NodeIdGen::new();
1210        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1211        let mv = move_node(&gen, id_node_at(&gen, "data", 5, 9));
1212        let use_data = id_node_at(&gen, "data", 15, 19);
1213        let b = block(&gen, vec![let_data, mv, use_data], None);
1214        let m = module(&gen, vec![fn_decl(&gen, b)]);
1215        let diags = analyze_ownership(&m);
1216        assert!(diags.has_errors());
1217        assert!(diags.iter().any(|d| d.code == E_USE_AFTER_MOVE));
1218    }
1219
1220    #[test]
1221    fn use_after_move_has_move_site_label() {
1222        let gen = NodeIdGen::new();
1223        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1224        let mv = move_node(&gen, id_node_at(&gen, "data", 5, 9));
1225        let use_data = id_node_at(&gen, "data", 15, 19);
1226        let b = block(&gen, vec![let_data, mv, use_data], None);
1227        let m = module(&gen, vec![fn_decl(&gen, b)]);
1228        let diags = analyze_ownership(&m);
1229        let err = diags.iter().find(|d| d.code == E_USE_AFTER_MOVE).unwrap();
1230        assert!(
1231            !err.labels.is_empty(),
1232            "expected a label pointing to move site"
1233        );
1234        assert!(err.labels[0].message.contains("moved"));
1235    }
1236
1237    // ── Tests: mutable borrow ─────────────────────────────────────────────────
1238
1239    #[test]
1240    fn mut_borrow_of_non_mut_errors() {
1241        // let data = 42       -- not mut
1242        // &mut data           -- ERROR
1243        let gen = NodeIdGen::new();
1244        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1245        let mb = mut_borrow(&gen, id_node(&gen, "data"));
1246        let b = block(&gen, vec![let_data, mb], None);
1247        let m = module(&gen, vec![fn_decl(&gen, b)]);
1248        let diags = analyze_ownership(&m);
1249        assert!(diags.has_errors());
1250        assert!(diags.iter().any(|d| d.code == E_MUT_BORROW_NEEDS_MUT));
1251    }
1252
1253    #[test]
1254    fn mut_borrow_of_mut_ok() {
1255        // let mut data = 42
1256        // &mut data           -- OK
1257        let gen = NodeIdGen::new();
1258        let let_data = let_binding(&gen, "data", true, lit_node(&gen));
1259        let mb = mut_borrow(&gen, id_node(&gen, "data"));
1260        let b = block(&gen, vec![let_data, mb], None);
1261        let m = module(&gen, vec![fn_decl(&gen, b)]);
1262        let diags = analyze_ownership(&m);
1263        assert!(!diags.has_errors());
1264    }
1265
1266    // ── Tests: @managed ───────────────────────────────────────────────────────
1267
1268    #[test]
1269    fn managed_skips_ownership_tracking() {
1270        // @managed let data = 42  (metadata["managed"] = true)
1271        // let archive = data       -- would be a move error, but @managed skips
1272        // use data                 -- no error
1273        let gen = NodeIdGen::new();
1274        let mut let_data = let_binding(&gen, "data", false, lit_node(&gen));
1275        let_data
1276            .metadata
1277            .insert("managed".into(), Value::Bool(true));
1278        let id_data = id_node(&gen, "data");
1279        let let_archive = let_binding(&gen, "archive", false, id_data);
1280        let use_data = id_node(&gen, "data");
1281        let b = block(&gen, vec![let_data, let_archive, use_data], None);
1282        let m = module(&gen, vec![fn_decl(&gen, b)]);
1283        let diags = analyze_ownership(&m);
1284        assert!(!diags.has_errors());
1285    }
1286
1287    #[test]
1288    fn managed_fn_suppresses_move_errors() {
1289        // @managed
1290        // fn f() {
1291        //   let data = 42
1292        //   let a = data   -- move
1293        //   let b = data   -- would be use-after-move, but @managed suppresses
1294        // }
1295        let gen = NodeIdGen::new();
1296        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1297        let id_data1 = id_node(&gen, "data");
1298        let let_a = let_binding(&gen, "a", false, id_data1);
1299        let id_data2 = id_node(&gen, "data");
1300        let let_b = let_binding(&gen, "b", false, id_data2);
1301        let b = block(&gen, vec![let_data, let_a, let_b], None);
1302        let m = module(&gen, vec![managed_fn_decl(&gen, b)]);
1303        let diags = analyze_ownership(&m);
1304        assert!(
1305            !diags.has_errors(),
1306            "expected no errors in @managed fn, got: {:?}",
1307            diags.iter().collect::<Vec<_>>()
1308        );
1309    }
1310
1311    #[test]
1312    fn non_managed_fn_still_errors_on_reuse() {
1313        // fn f() {
1314        //   let data = 42
1315        //   let a = data
1316        //   let b = data   -- ERROR: use after move
1317        // }
1318        let gen = NodeIdGen::new();
1319        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1320        let id_data1 = id_node(&gen, "data");
1321        let let_a = let_binding(&gen, "a", false, id_data1);
1322        let id_data2 = id_node_at(&gen, "data", 20, 24);
1323        let let_b = let_binding(&gen, "b", false, id_data2);
1324        let b = block(&gen, vec![let_data, let_a, let_b], None);
1325        let m = module(&gen, vec![fn_decl(&gen, b)]);
1326        let diags = analyze_ownership(&m);
1327        assert!(diags.has_errors());
1328        assert!(diags.iter().any(|d| d.code == E_USE_AFTER_MOVE));
1329    }
1330
1331    #[test]
1332    fn managed_fn_loop_move_suppressed() {
1333        // @managed
1334        // fn f() {
1335        //   let data = 42
1336        //   loop { let _ = data }   -- would be E5003 loop-move, but @managed suppresses
1337        // }
1338        let gen = NodeIdGen::new();
1339        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1340        let id_data = id_node(&gen, "data");
1341        let let_discard = let_binding(&gen, "_d", false, id_data);
1342        let loop_body = block(&gen, vec![let_discard], None);
1343        let lp = loop_node(&gen, loop_body);
1344        let b = block(&gen, vec![let_data, lp], None);
1345        let m = module(&gen, vec![managed_fn_decl(&gen, b)]);
1346        let diags = analyze_ownership(&m);
1347        assert!(
1348            !diags.has_errors(),
1349            "expected no errors in @managed fn loop, got: {:?}",
1350            diags.iter().collect::<Vec<_>>()
1351        );
1352    }
1353
1354    // ── Tests: diverging branches ─────────────────────────────────────────────
1355
1356    #[test]
1357    fn guard_else_diverges_data_still_owned() {
1358        // guard (cond) else { return }   -- else diverges
1359        // use data                        -- data still owned
1360        let gen = NodeIdGen::new();
1361        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1362        let cond = lit_node(&gen);
1363        let else_block = block(&gen, vec![return_node(&gen, None)], None);
1364        let guard = guard_node(&gen, cond, else_block);
1365        let use_data = id_node(&gen, "data");
1366        let b = block(&gen, vec![let_data, guard, use_data], None);
1367        let m = module(&gen, vec![fn_decl(&gen, b)]);
1368        let diags = analyze_ownership(&m);
1369        assert!(!diags.has_errors());
1370    }
1371
1372    #[test]
1373    fn if_with_diverging_then_data_still_owned() {
1374        // let data = 42
1375        // if (cond) { consume(data); return }   -- then diverges
1376        // use data                               -- data still owned
1377        let gen = NodeIdGen::new();
1378        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1379        let cond = lit_node(&gen);
1380        // then: move data then return
1381        let id_data = id_node(&gen, "data");
1382        let let_archive = let_binding(&gen, "archive", false, id_data);
1383        let ret = return_node(&gen, None);
1384        let then_block = block(&gen, vec![let_archive, ret], None);
1385        let if_node_ = if_node(&gen, cond, then_block, None);
1386        let use_data = id_node(&gen, "data");
1387        let b = block(&gen, vec![let_data, if_node_, use_data], None);
1388        let m = module(&gen, vec![fn_decl(&gen, b)]);
1389        let diags = analyze_ownership(&m);
1390        assert!(!diags.has_errors());
1391    }
1392
1393    #[test]
1394    fn if_non_diverging_branch_moves_makes_moved_at_join() {
1395        // let data = 42
1396        // if (cond) { let archive = data }   -- non-diverging, moves data
1397        // use data                            -- ERROR
1398        let gen = NodeIdGen::new();
1399        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1400        let cond = lit_node(&gen);
1401        let id_data = id_node(&gen, "data");
1402        let let_archive = let_binding(&gen, "archive", false, id_data);
1403        let then_block = block(&gen, vec![let_archive], None);
1404        let if_node_ = if_node(&gen, cond, then_block, None);
1405        let use_data = id_node_at(&gen, "data", 30, 34);
1406        let b = block(&gen, vec![let_data, if_node_, use_data], None);
1407        let m = module(&gen, vec![fn_decl(&gen, b)]);
1408        let diags = analyze_ownership(&m);
1409        assert!(diags.has_errors());
1410        assert!(diags.iter().any(|d| d.code == E_USE_AFTER_MOVE));
1411    }
1412
1413    #[test]
1414    fn if_both_branches_diverge_join_uses_pre_state() {
1415        // let data = 42
1416        // if (cond) { return } else { return }  -- both diverge
1417        // (unreachable join — no error)
1418        let gen = NodeIdGen::new();
1419        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1420        let cond = lit_node(&gen);
1421        let then_block = block(&gen, vec![return_node(&gen, None)], None);
1422        let else_block = block(&gen, vec![return_node(&gen, None)], None);
1423        let if_node_ = if_node(&gen, cond, then_block, Some(else_block));
1424        let b = block(&gen, vec![let_data, if_node_], None);
1425        let m = module(&gen, vec![fn_decl(&gen, b)]);
1426        let diags = analyze_ownership(&m);
1427        assert!(!diags.has_errors());
1428    }
1429
1430    // ── Tests: loop move detection ────────────────────────────────────────────
1431
1432    #[test]
1433    fn move_inside_loop_is_error() {
1434        // let data = 42
1435        // loop { let archive = data }   -- ERROR: moving pre-loop var in loop
1436        let gen = NodeIdGen::new();
1437        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1438        let id_data = id_node_at(&gen, "data", 10, 14);
1439        let let_archive = let_binding(&gen, "archive", false, id_data);
1440        let loop_body = block(&gen, vec![let_archive], None);
1441        let lp = loop_node(&gen, loop_body);
1442        let b = block(&gen, vec![let_data, lp], None);
1443        let m = module(&gen, vec![fn_decl(&gen, b)]);
1444        let diags = analyze_ownership(&m);
1445        assert!(diags.has_errors());
1446        assert!(diags.iter().any(|d| d.code == E_LOOP_MOVE));
1447    }
1448
1449    #[test]
1450    fn variable_defined_inside_loop_can_be_moved() {
1451        // loop { let tmp = 42; let _ = tmp }  -- OK: tmp is fresh each iteration
1452        let gen = NodeIdGen::new();
1453        let let_tmp = let_binding(&gen, "tmp", false, lit_node(&gen));
1454        let id_tmp = id_node(&gen, "tmp");
1455        let let_discard = let_binding(&gen, "_unused", false, id_tmp);
1456        let loop_body = block(&gen, vec![let_tmp, let_discard], None);
1457        let lp = loop_node(&gen, loop_body);
1458        let b = block(&gen, vec![lp], None);
1459        let m = module(&gen, vec![fn_decl(&gen, b)]);
1460        let diags = analyze_ownership(&m);
1461        assert!(!diags.has_errors());
1462    }
1463
1464    // ── Tests: match with diverging arms ─────────────────────────────────────
1465
1466    #[test]
1467    fn match_all_arms_diverge_no_use_after_move() {
1468        // let data = 42
1469        // match x {
1470        //   Ok(v) => { let _ = data; return }   -- diverges
1471        //   Err(_) => return                     -- diverges
1472        // }
1473        // (unreachable join)
1474        let gen = NodeIdGen::new();
1475        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1476        let scrutinee = lit_node(&gen);
1477        let id_data = id_node(&gen, "data");
1478        let let_discard = let_binding(&gen, "_d", false, id_data);
1479        let ret1 = return_node(&gen, None);
1480        let arm1_body = block(&gen, vec![let_discard, ret1], None);
1481        let arm1 = match_arm(&gen, node(&gen, NodeKind::WildcardPat), arm1_body);
1482        let ret2 = return_node(&gen, None);
1483        let arm2_body = block(&gen, vec![ret2], None);
1484        let arm2 = match_arm(&gen, node(&gen, NodeKind::WildcardPat), arm2_body);
1485        let m_node = match_node(&gen, scrutinee, vec![arm1, arm2]);
1486        let b = block(&gen, vec![let_data, m_node], None);
1487        let m = module(&gen, vec![fn_decl(&gen, b)]);
1488        let diags = analyze_ownership(&m);
1489        assert!(!diags.has_errors());
1490    }
1491
1492    #[test]
1493    fn match_non_diverging_arm_moves_is_error_after() {
1494        // let data = 42
1495        // match x {
1496        //   A => { let _ = data }  -- non-diverging, moves data
1497        //   B => return             -- diverges
1498        // }
1499        // use data  -- ERROR: moved in non-diverging arm
1500        let gen = NodeIdGen::new();
1501        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1502        let scrutinee = lit_node(&gen);
1503        let id_data = id_node(&gen, "data");
1504        let let_discard = let_binding(&gen, "_d", false, id_data);
1505        let arm1_body = block(&gen, vec![let_discard], None);
1506        let arm1 = match_arm(&gen, node(&gen, NodeKind::WildcardPat), arm1_body);
1507        let ret = return_node(&gen, None);
1508        let arm2_body = block(&gen, vec![ret], None);
1509        let arm2 = match_arm(&gen, node(&gen, NodeKind::WildcardPat), arm2_body);
1510        let m_node = match_node(&gen, scrutinee, vec![arm1, arm2]);
1511        let use_data = id_node_at(&gen, "data", 50, 54);
1512        let b = block(&gen, vec![let_data, m_node, use_data], None);
1513        let m = module(&gen, vec![fn_decl(&gen, b)]);
1514        let diags = analyze_ownership(&m);
1515        assert!(diags.has_errors());
1516        assert!(diags.iter().any(|d| d.code == E_USE_AFTER_MOVE));
1517    }
1518
1519    // ── Tests: nested control flow ────────────────────────────────────────────
1520
1521    #[test]
1522    fn if_inside_match_arm_nested() {
1523        // let data = 42
1524        // match x {
1525        //   _ => if (cond) { let _ = data } else { return }
1526        //        -- then: non-div, moves data; else: diverges
1527        //        -- after if: data is moved (from non-div branch)
1528        // }
1529        // use data  -- ERROR
1530        let gen = NodeIdGen::new();
1531        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1532        let scrutinee = lit_node(&gen);
1533        let cond = lit_node(&gen);
1534        let id_data = id_node(&gen, "data");
1535        let let_discard = let_binding(&gen, "_d", false, id_data);
1536        let then_block = block(&gen, vec![let_discard], None);
1537        let else_block = block(&gen, vec![return_node(&gen, None)], None);
1538        let if_expr = if_node(&gen, cond, then_block, Some(else_block));
1539        let arm_body = block(&gen, vec![if_expr], None);
1540        let arm = match_arm(&gen, node(&gen, NodeKind::WildcardPat), arm_body);
1541        let m_node = match_node(&gen, scrutinee, vec![arm]);
1542        let use_data = id_node_at(&gen, "data", 50, 54);
1543        let b = block(&gen, vec![let_data, m_node, use_data], None);
1544        let m = module(&gen, vec![fn_decl(&gen, b)]);
1545        let diags = analyze_ownership(&m);
1546        assert!(diags.has_errors());
1547        assert!(diags.iter().any(|d| d.code == E_USE_AFTER_MOVE));
1548    }
1549
1550    #[test]
1551    fn no_false_positive_if_else_both_leave_owned() {
1552        // let data = 42
1553        // if (cond) { use(data) } else { use(data) }  -- borrows in both branches
1554        // use data  -- still owned, no error
1555        let gen = NodeIdGen::new();
1556        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1557        let cond = lit_node(&gen);
1558        let use1 = id_node(&gen, "data");
1559        let use2 = id_node(&gen, "data");
1560        let then_block = block(&gen, vec![use1], None);
1561        let else_block = block(&gen, vec![use2], None);
1562        let if_expr = if_node(&gen, cond, then_block, Some(else_block));
1563        let use_after = id_node(&gen, "data");
1564        let b = block(&gen, vec![let_data, if_expr, use_after], None);
1565        let m = module(&gen, vec![fn_decl(&gen, b)]);
1566        let diags = analyze_ownership(&m);
1567        assert!(!diags.has_errors());
1568    }
1569
1570    #[test]
1571    fn double_move_error() {
1572        // let data = 42
1573        // let a = data   -- moves data
1574        // let b = data   -- ERROR: use after move
1575        let gen = NodeIdGen::new();
1576        let let_data = let_binding(&gen, "data", false, lit_node(&gen));
1577        let let_a = let_binding(&gen, "a", false, id_node(&gen, "data"));
1578        let let_b = let_binding(&gen, "b", false, id_node_at(&gen, "data", 20, 24));
1579        let b = block(&gen, vec![let_data, let_a, let_b], None);
1580        let m = module(&gen, vec![fn_decl(&gen, b)]);
1581        let diags = analyze_ownership(&m);
1582        assert!(diags.has_errors());
1583        assert!(diags.iter().any(|d| d.code == E_USE_AFTER_MOVE));
1584    }
1585
1586    // ── Tests: DQ18 in-place List mutator (`push`/`append`) mut-receiver ───────
1587
1588    /// Build a desugared `recv.<method>(arg)` call in the lowerer's shape
1589    /// (`Call { callee: FieldAccess(recv, method), args: [recv, arg] }`) with the
1590    /// checker's `recv_kind = "List"` stamp. The `self` arg shares the
1591    /// field-access object's NodeId, matching `desugared_self_call`'s identity
1592    /// test.
1593    fn desugared_list_call(
1594        gen: &NodeIdGen,
1595        receiver: AIRNode,
1596        method: &str,
1597        arg: AIRNode,
1598    ) -> AIRNode {
1599        // The receiver appears in both the field-access object and the `self`
1600        // arg with the SAME id (the lowerer clones it identically).
1601        let recv_self = receiver.clone();
1602        let callee = node(
1603            gen,
1604            NodeKind::FieldAccess {
1605                object: Box::new(receiver),
1606                field: ident(method),
1607            },
1608        );
1609        let mut call = node(
1610            gen,
1611            NodeKind::Call {
1612                callee: Box::new(callee),
1613                args: vec![
1614                    AirArg {
1615                        label: None,
1616                        value: recv_self,
1617                    },
1618                    AirArg {
1619                        label: None,
1620                        value: arg,
1621                    },
1622                ],
1623                type_args: vec![],
1624            },
1625        );
1626        call.metadata.insert(
1627            crate::checker::RECV_KIND_META_KEY.to_string(),
1628            Value::String("List".to_string()),
1629        );
1630        call
1631    }
1632
1633    #[test]
1634    fn push_on_mut_let_binding_ok() {
1635        // let mut acc = 42   (stands in for a `mut` List binding)
1636        // acc.push(1)        -- OK: receiver is `mut`
1637        let gen = NodeIdGen::new();
1638        let let_acc = let_binding(&gen, "acc", true, lit_node(&gen));
1639        let push = desugared_list_call(&gen, id_node(&gen, "acc"), "push", lit_node(&gen));
1640        let b = block(&gen, vec![let_acc, push], None);
1641        let m = module(&gen, vec![fn_decl(&gen, b)]);
1642        let diags = analyze_ownership(&m);
1643        assert!(
1644            !diags.iter().any(|d| d.code == E_MUT_RECEIVER_NEEDED),
1645            "push on a `mut` binding must not error: {:?}",
1646            diags.iter().collect::<Vec<_>>()
1647        );
1648    }
1649
1650    #[test]
1651    fn push_on_non_mut_let_binding_errors() {
1652        // let acc = 42
1653        // acc.push(1)   -- ERROR: receiver not `mut`
1654        let gen = NodeIdGen::new();
1655        let let_acc = let_binding(&gen, "acc", false, lit_node(&gen));
1656        let push = desugared_list_call(
1657            &gen,
1658            id_node_at(&gen, "acc", 10, 13),
1659            "push",
1660            lit_node(&gen),
1661        );
1662        let b = block(&gen, vec![let_acc, push], None);
1663        let m = module(&gen, vec![fn_decl(&gen, b)]);
1664        let diags = analyze_ownership(&m);
1665        assert!(diags.iter().any(|d| d.code == E_MUT_RECEIVER_NEEDED));
1666        let err = diags
1667            .iter()
1668            .find(|d| d.code == E_MUT_RECEIVER_NEEDED)
1669            .unwrap();
1670        assert!(err.message.contains("push"));
1671        assert!(err.message.contains("acc"));
1672        assert!(
1673            !err.notes.is_empty(),
1674            "expected a `let mut` suggestion note"
1675        );
1676    }
1677
1678    #[test]
1679    fn append_on_non_mut_let_binding_errors() {
1680        // `append` is the spelling alias and is held to the same rule as `push`.
1681        let gen = NodeIdGen::new();
1682        let let_acc = let_binding(&gen, "acc", false, lit_node(&gen));
1683        let push = desugared_list_call(&gen, id_node(&gen, "acc"), "append", lit_node(&gen));
1684        let b = block(&gen, vec![let_acc, push], None);
1685        let m = module(&gen, vec![fn_decl(&gen, b)]);
1686        let diags = analyze_ownership(&m);
1687        assert!(diags.iter().any(|d| d.code == E_MUT_RECEIVER_NEEDED));
1688    }
1689
1690    #[test]
1691    fn push_on_mut_parameter_ok() {
1692        // fn f(mut xs: List) { xs.push(1) }   -- OK: `mut` parameter
1693        let gen = NodeIdGen::new();
1694        let push = desugared_list_call(&gen, id_node(&gen, "xs"), "push", lit_node(&gen));
1695        let b = block(&gen, vec![push], None);
1696        // A `mut` parameter `xs`.
1697        let param = node(
1698            &gen,
1699            NodeKind::Param {
1700                pattern: Box::new(bind_pat(&gen, "xs", true)),
1701                ty: None,
1702                default: None,
1703            },
1704        );
1705        let f = node(
1706            &gen,
1707            NodeKind::FnDecl {
1708                annotations: vec![],
1709                visibility: bock_ast::Visibility::Public,
1710                is_async: false,
1711                name: ident("f"),
1712                generic_params: vec![],
1713                params: vec![param],
1714                return_type: None,
1715                effect_clause: vec![],
1716                where_clause: vec![],
1717                body: Box::new(b),
1718            },
1719        );
1720        let m = module(&gen, vec![f]);
1721        let diags = analyze_ownership(&m);
1722        assert!(
1723            !diags.iter().any(|d| d.code == E_MUT_RECEIVER_NEEDED),
1724            "push on a `mut` parameter must not error"
1725        );
1726    }
1727
1728    #[test]
1729    fn dq30_mutators_on_non_mut_let_binding_error() {
1730        // DQ30: `pop`/`remove_at`/`insert`/`reverse`/`set` are held to the same
1731        // `mut`-receiver rule as DQ18's `push`/`append`.
1732        for method in ["pop", "remove_at", "insert", "reverse", "set"] {
1733            let gen = NodeIdGen::new();
1734            let let_acc = let_binding(&gen, "acc", false, lit_node(&gen));
1735            let call = desugared_list_call(&gen, id_node(&gen, "acc"), method, lit_node(&gen));
1736            let b = block(&gen, vec![let_acc, call], None);
1737            let m = module(&gen, vec![fn_decl(&gen, b)]);
1738            let diags = analyze_ownership(&m);
1739            assert!(
1740                diags.iter().any(|d| d.code == E_MUT_RECEIVER_NEEDED),
1741                "`{method}` on a non-`mut` binding must emit E5004"
1742            );
1743        }
1744    }
1745
1746    #[test]
1747    fn dq30_mutators_on_mut_let_binding_ok() {
1748        for method in ["pop", "remove_at", "insert", "reverse", "set"] {
1749            let gen = NodeIdGen::new();
1750            let let_acc = let_binding(&gen, "acc", true, lit_node(&gen));
1751            let call = desugared_list_call(&gen, id_node(&gen, "acc"), method, lit_node(&gen));
1752            let b = block(&gen, vec![let_acc, call], None);
1753            let m = module(&gen, vec![fn_decl(&gen, b)]);
1754            let diags = analyze_ownership(&m);
1755            assert!(
1756                !diags.iter().any(|d| d.code == E_MUT_RECEIVER_NEEDED),
1757                "`{method}` on a `mut` binding must not emit E5004: {:?}",
1758                diags.iter().collect::<Vec<_>>()
1759            );
1760        }
1761    }
1762
1763    #[test]
1764    fn push_in_loop_on_mut_binding_ok() {
1765        // The canonical loop-build pattern: pushing onto a `mut` accumulator
1766        // inside a loop is fine (the receiver place is mutable; the loop-move
1767        // guard is about moving the *binding*, which push does not).
1768        let gen = NodeIdGen::new();
1769        let let_acc = let_binding(&gen, "acc", true, lit_node(&gen));
1770        let push = desugared_list_call(&gen, id_node(&gen, "acc"), "push", lit_node(&gen));
1771        let loop_body = block(&gen, vec![push], None);
1772        let lp = loop_node(&gen, loop_body);
1773        let b = block(&gen, vec![let_acc, lp], None);
1774        let m = module(&gen, vec![fn_decl(&gen, b)]);
1775        let diags = analyze_ownership(&m);
1776        assert!(
1777            !diags.iter().any(|d| d.code == E_MUT_RECEIVER_NEEDED),
1778            "push in a loop on a `mut` binding must not error: {:?}",
1779            diags.iter().collect::<Vec<_>>()
1780        );
1781    }
1782}