Skip to main content

seqc/
resource_lint.rs

1//! Resource Leak Detection (Phase 2a)
2//!
3//! Data flow analysis to detect resource leaks within single word definitions.
4//! Tracks resources (weave handles, channels) through stack operations and
5//! control flow to ensure proper cleanup.
6//!
7//! # Architecture
8//!
9//! 1. **Resource Tagging**: Values from resource-creating words are tagged
10//!    with their creation location.
11//!
12//! 2. **Stack Simulation**: Abstract interpretation tracks tagged values
13//!    through stack operations (dup, swap, drop, etc.).
14//!
15//! 3. **Control Flow**: If/else and match branches must handle resources
16//!    consistently - either all consume or all preserve.
17//!
18//! 4. **Escape Analysis**: Resources returned from a word are the caller's
19//!    responsibility - no warning emitted.
20//!
21//! # Known Limitations
22//!
23//! - **`strand.resume` completion not tracked**: When `strand.resume` returns
24//!   false, the weave completed and handle is consumed. We can't determine this
25//!   statically, so we assume the handle remains active. Use pattern-based lint
26//!   rules to catch unchecked resume results.
27//!
28//! - **Unknown word effects**: User-defined words and FFI calls have unknown
29//!   stack effects. We conservatively leave the stack unchanged, which may
30//!   cause false negatives if those words consume or create resources.
31//!
32//! - **Cross-word analysis is basic**: Resources returned from user-defined
33//!   words are tracked via `ProgramResourceAnalyzer`, but external/FFI words
34//!   with unknown effects are treated conservatively (no stack change assumed).
35
36use crate::ast::{MatchArm, Program, Span, Statement, WordDef};
37use crate::lint::{LintDiagnostic, Severity};
38use std::collections::HashMap;
39use std::path::Path;
40
41/// Identifies a resource type for tracking
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub(crate) enum ResourceKind {
44    /// Weave handle from `strand.weave`
45    WeaveHandle,
46    /// Channel from `chan.make`
47    Channel,
48}
49
50impl ResourceKind {
51    fn name(&self) -> &'static str {
52        match self {
53            ResourceKind::WeaveHandle => "WeaveHandle",
54            ResourceKind::Channel => "Channel",
55        }
56    }
57
58    fn cleanup_suggestion(&self) -> &'static str {
59        match self {
60            ResourceKind::WeaveHandle => "use `strand.weave-cancel` or resume to completion",
61            ResourceKind::Channel => "use `chan.close` when done",
62        }
63    }
64}
65
66/// A tracked resource with its origin
67#[derive(Debug, Clone)]
68pub(crate) struct TrackedResource {
69    /// What kind of resource this is
70    pub kind: ResourceKind,
71    /// Unique ID for this resource instance
72    pub id: usize,
73    /// Line where the resource was created (0-indexed)
74    pub created_line: usize,
75    /// The word that created this resource
76    pub created_by: String,
77}
78
79/// A value on the abstract stack - either a resource or unknown
80#[derive(Debug, Clone)]
81pub(crate) enum StackValue {
82    /// A tracked resource
83    Resource(TrackedResource),
84    /// An unknown value (literal, result of non-resource operation)
85    Unknown,
86}
87
88/// State of the abstract stack during analysis
89#[derive(Debug, Clone)]
90pub(crate) struct StackState {
91    /// The stack contents (top is last element)
92    stack: Vec<StackValue>,
93    /// Resources that have been properly consumed
94    consumed: Vec<TrackedResource>,
95    /// Next resource ID to assign
96    next_id: usize,
97}
98
99impl Default for StackState {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl StackState {
106    pub fn new() -> Self {
107        StackState {
108            stack: Vec::new(),
109            consumed: Vec::new(),
110            next_id: 0,
111        }
112    }
113
114    /// Push an unknown value onto the stack
115    pub fn push_unknown(&mut self) {
116        self.stack.push(StackValue::Unknown);
117    }
118
119    /// Push a new tracked resource onto the stack
120    pub fn push_resource(&mut self, kind: ResourceKind, line: usize, word: &str) {
121        let resource = TrackedResource {
122            kind,
123            id: self.next_id,
124            created_line: line,
125            created_by: word.to_string(),
126        };
127        self.next_id += 1;
128        self.stack.push(StackValue::Resource(resource));
129    }
130
131    /// Pop a value from the stack
132    pub fn pop(&mut self) -> Option<StackValue> {
133        self.stack.pop()
134    }
135
136    /// Peek at the top value without removing it
137    pub fn peek(&self) -> Option<&StackValue> {
138        self.stack.last()
139    }
140
141    /// Get stack depth
142    pub fn depth(&self) -> usize {
143        self.stack.len()
144    }
145
146    /// Mark a resource as consumed (properly cleaned up)
147    pub fn consume_resource(&mut self, resource: TrackedResource) {
148        self.consumed.push(resource);
149    }
150
151    /// Get all resources still on the stack (potential leaks)
152    pub fn remaining_resources(&self) -> Vec<&TrackedResource> {
153        self.stack
154            .iter()
155            .filter_map(|v| match v {
156                StackValue::Resource(r) => Some(r),
157                StackValue::Unknown => None,
158            })
159            .collect()
160    }
161
162    /// Merge two stack states (for branch unification)
163    /// Returns resources that are leaked in one branch but not the other
164    pub fn merge(&self, other: &StackState) -> BranchMergeResult {
165        let self_resources: HashMap<usize, &TrackedResource> = self
166            .stack
167            .iter()
168            .filter_map(|v| match v {
169                StackValue::Resource(r) => Some((r.id, r)),
170                StackValue::Unknown => None,
171            })
172            .collect();
173
174        let other_resources: HashMap<usize, &TrackedResource> = other
175            .stack
176            .iter()
177            .filter_map(|v| match v {
178                StackValue::Resource(r) => Some((r.id, r)),
179                StackValue::Unknown => None,
180            })
181            .collect();
182
183        let self_consumed: std::collections::HashSet<usize> =
184            self.consumed.iter().map(|r| r.id).collect();
185        let other_consumed: std::collections::HashSet<usize> =
186            other.consumed.iter().map(|r| r.id).collect();
187
188        let mut inconsistent = Vec::new();
189
190        // Find resources consumed in one branch but not the other
191        for (id, resource) in &self_resources {
192            if other_consumed.contains(id) && !self_consumed.contains(id) {
193                // Consumed in 'other' branch, still on stack in 'self'
194                inconsistent.push(InconsistentResource {
195                    resource: (*resource).clone(),
196                    consumed_in_else: true,
197                });
198            }
199        }
200
201        for (id, resource) in &other_resources {
202            if self_consumed.contains(id) && !other_consumed.contains(id) {
203                // Consumed in 'self' branch, still on stack in 'other'
204                inconsistent.push(InconsistentResource {
205                    resource: (*resource).clone(),
206                    consumed_in_else: false,
207                });
208            }
209        }
210
211        BranchMergeResult { inconsistent }
212    }
213
214    /// Compute a lattice join of two stack states for continuation after branches.
215    ///
216    /// The join is conservative:
217    /// - Resources present in EITHER branch are tracked (we don't know which path was taken)
218    /// - Resources are only marked consumed if consumed in BOTH branches
219    /// - The next_id is taken from the max of both states
220    ///
221    /// This ensures we don't miss potential leaks from either branch.
222    pub fn join(&self, other: &StackState) -> StackState {
223        // Collect resource IDs consumed in each branch
224        let other_consumed: std::collections::HashSet<usize> =
225            other.consumed.iter().map(|r| r.id).collect();
226
227        // Resources consumed in BOTH branches are definitely consumed
228        let definitely_consumed: Vec<TrackedResource> = self
229            .consumed
230            .iter()
231            .filter(|r| other_consumed.contains(&r.id))
232            .cloned()
233            .collect();
234
235        // For the stack, we need to be careful. After if/else, stacks should
236        // have the same depth (Seq requires balanced stack effects in branches).
237        // We take the union of resources - if a resource appears in either
238        // branch's stack, it should be tracked.
239        //
240        // Since we can't know which branch was taken, we use the then-branch
241        // stack structure but ensure any resource from either branch is present.
242        let mut joined_stack = self.stack.clone();
243
244        // Collect resources from other branch that might not be in self
245        let other_resources: HashMap<usize, TrackedResource> = other
246            .stack
247            .iter()
248            .filter_map(|v| match v {
249                StackValue::Resource(r) => Some((r.id, r.clone())),
250                StackValue::Unknown => None,
251            })
252            .collect();
253
254        // For each position, if other has a resource that self doesn't, use other's
255        for (i, val) in joined_stack.iter_mut().enumerate() {
256            if matches!(val, StackValue::Unknown)
257                && i < other.stack.len()
258                && let StackValue::Resource(r) = &other.stack[i]
259            {
260                *val = StackValue::Resource(r.clone());
261            }
262        }
263
264        // Also check if other branch has resources we should track
265        // (in case stacks have different structures due to analysis imprecision)
266        let self_resource_ids: std::collections::HashSet<usize> = joined_stack
267            .iter()
268            .filter_map(|v| match v {
269                StackValue::Resource(r) => Some(r.id),
270                StackValue::Unknown => None,
271            })
272            .collect();
273
274        for (id, resource) in other_resources {
275            if !self_resource_ids.contains(&id) && !definitely_consumed.iter().any(|r| r.id == id) {
276                // Resource from other branch not in our stack - add it
277                // This handles cases where branches have different stack shapes
278                joined_stack.push(StackValue::Resource(resource));
279            }
280        }
281
282        StackState {
283            stack: joined_stack,
284            consumed: definitely_consumed,
285            next_id: self.next_id.max(other.next_id),
286        }
287    }
288}
289
290/// Result of merging two branch states
291#[derive(Debug)]
292pub(crate) struct BranchMergeResult {
293    /// Resources handled inconsistently between branches
294    pub inconsistent: Vec<InconsistentResource>,
295}
296
297/// A resource handled differently in different branches
298#[derive(Debug)]
299pub(crate) struct InconsistentResource {
300    pub resource: TrackedResource,
301    /// True if consumed in else branch but not then branch
302    pub consumed_in_else: bool,
303}
304
305// ============================================================================
306// Cross-Word Analysis (Phase 2b)
307// ============================================================================
308
309/// Information about a word's resource behavior
310#[derive(Debug, Clone, Default)]
311pub(crate) struct WordResourceInfo {
312    /// Resource kinds this word returns (resources on stack at word end)
313    pub returns: Vec<ResourceKind>,
314}
315
316/// Program-wide resource analyzer for cross-word analysis
317///
318/// This analyzer performs two passes:
319/// 1. Collect resource information about each word (what resources it returns)
320/// 2. Analyze each word with knowledge of callee behavior
321pub struct ProgramResourceAnalyzer {
322    /// Per-word resource information (populated in first pass)
323    word_info: HashMap<String, WordResourceInfo>,
324    /// File being analyzed
325    file: std::path::PathBuf,
326    /// Diagnostics collected during analysis
327    diagnostics: Vec<LintDiagnostic>,
328}
329
330impl ProgramResourceAnalyzer {
331    pub fn new(file: &Path) -> Self {
332        ProgramResourceAnalyzer {
333            word_info: HashMap::new(),
334            file: file.to_path_buf(),
335            diagnostics: Vec::new(),
336        }
337    }
338
339    /// Analyze an entire program for resource leaks with cross-word tracking
340    pub fn analyze_program(&mut self, program: &Program) -> Vec<LintDiagnostic> {
341        self.diagnostics.clear();
342        self.word_info.clear();
343
344        // Pass 1: Collect resource information about each word
345        for word in &program.words {
346            let info = self.collect_word_info(word);
347            self.word_info.insert(word.name.clone(), info);
348        }
349
350        // Pass 2: Analyze each word with cross-word context
351        for word in &program.words {
352            self.analyze_word_with_context(word);
353        }
354
355        std::mem::take(&mut self.diagnostics)
356    }
357
358    /// First pass: Determine what resources a word returns
359    fn collect_word_info(&self, word: &WordDef) -> WordResourceInfo {
360        let mut state = StackState::new();
361
362        // Simple analysis without emitting diagnostics
363        self.simulate_statements(&word.body, &mut state);
364
365        // Collect resource kinds remaining on stack (these are "returned")
366        let returns: Vec<ResourceKind> = state
367            .remaining_resources()
368            .into_iter()
369            .map(|r| r.kind)
370            .collect();
371
372        WordResourceInfo { returns }
373    }
374
375    /// Simulate statements to track resources (no diagnostics)
376    fn simulate_statements(&self, statements: &[Statement], state: &mut StackState) {
377        for stmt in statements {
378            self.simulate_statement(stmt, state);
379        }
380    }
381
382    /// Simulate a single statement (simplified, no diagnostics)
383    fn simulate_statement(&self, stmt: &Statement, state: &mut StackState) {
384        match stmt {
385            Statement::IntLiteral(_)
386            | Statement::FloatLiteral(_)
387            | Statement::BoolLiteral(_)
388            | Statement::StringLiteral(_)
389            | Statement::Symbol(_) => {
390                state.push_unknown();
391            }
392
393            Statement::WordCall { name, span } => {
394                self.simulate_word_call(name, span.as_ref(), state);
395            }
396
397            Statement::Quotation { .. } => {
398                state.push_unknown();
399            }
400
401            Statement::If {
402                then_branch,
403                else_branch,
404            } => {
405                state.pop(); // condition
406                let mut then_state = state.clone();
407                let mut else_state = state.clone();
408                self.simulate_statements(then_branch, &mut then_state);
409                if let Some(else_stmts) = else_branch {
410                    self.simulate_statements(else_stmts, &mut else_state);
411                }
412                *state = then_state.join(&else_state);
413            }
414
415            Statement::Match { arms } => {
416                state.pop();
417                let mut arm_states: Vec<StackState> = Vec::new();
418                for arm in arms {
419                    let mut arm_state = state.clone();
420                    self.simulate_statements(&arm.body, &mut arm_state);
421                    arm_states.push(arm_state);
422                }
423                if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
424                    *state = joined;
425                }
426            }
427        }
428    }
429
430    /// Simulate common word operations shared between first and second pass.
431    ///
432    /// Returns `true` if the word was handled, `false` if the caller should
433    /// handle it (for pass-specific operations).
434    ///
435    /// The `on_resource_dropped` callback is invoked when a resource is dropped
436    /// without being consumed. The second pass uses this to emit warnings.
437    fn simulate_word_common<F>(
438        name: &str,
439        span: Option<&Span>,
440        state: &mut StackState,
441        word_info: &HashMap<String, WordResourceInfo>,
442        mut on_resource_dropped: F,
443    ) -> bool
444    where
445        F: FnMut(&TrackedResource),
446    {
447        let line = span.map(|s| s.line).unwrap_or(0);
448
449        match name {
450            // Resource-creating builtins
451            "strand.weave" => {
452                state.pop();
453                state.push_resource(ResourceKind::WeaveHandle, line, name);
454            }
455            "chan.make" => {
456                state.push_resource(ResourceKind::Channel, line, name);
457            }
458
459            // Resource-consuming builtins
460            "strand.weave-cancel" => {
461                if let Some(StackValue::Resource(r)) = state.pop()
462                    && r.kind == ResourceKind::WeaveHandle
463                {
464                    state.consume_resource(r);
465                }
466            }
467            "chan.close" => {
468                if let Some(StackValue::Resource(r)) = state.pop()
469                    && r.kind == ResourceKind::Channel
470                {
471                    state.consume_resource(r);
472                }
473            }
474
475            // Stack operations
476            "drop" => {
477                let dropped = state.pop();
478                if let Some(StackValue::Resource(r)) = dropped {
479                    // Check if already consumed (e.g., via strand.spawn)
480                    let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
481                    if !already_consumed {
482                        on_resource_dropped(&r);
483                    }
484                }
485            }
486            "dup" => {
487                // Only duplicate if there's something on the stack
488                // Don't push unknown on empty - maintains original first-pass behavior
489                if let Some(top) = state.peek().cloned() {
490                    state.stack.push(top);
491                }
492            }
493            "swap" => {
494                let a = state.pop();
495                let b = state.pop();
496                if let Some(av) = a {
497                    state.stack.push(av);
498                }
499                if let Some(bv) = b {
500                    state.stack.push(bv);
501                }
502            }
503            "over" => {
504                // ( ..a x y -- ..a x y x )
505                if state.depth() >= 2 {
506                    let second = state.stack[state.depth() - 2].clone();
507                    state.stack.push(second);
508                }
509            }
510            "rot" => {
511                // ( ..a x y z -- ..a y z x )
512                let c = state.pop();
513                let b = state.pop();
514                let a = state.pop();
515                if let Some(bv) = b {
516                    state.stack.push(bv);
517                }
518                if let Some(cv) = c {
519                    state.stack.push(cv);
520                }
521                if let Some(av) = a {
522                    state.stack.push(av);
523                }
524            }
525            "nip" => {
526                // ( ..a x y -- ..a y ) - drops x, which may be a resource
527                let b = state.pop();
528                let a = state.pop();
529                if let Some(StackValue::Resource(r)) = a {
530                    let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
531                    if !already_consumed {
532                        on_resource_dropped(&r);
533                    }
534                }
535                if let Some(bv) = b {
536                    state.stack.push(bv);
537                }
538            }
539            "tuck" => {
540                // ( ..a x y -- ..a y x y )
541                let b = state.pop();
542                let a = state.pop();
543                if let Some(bv) = b.clone() {
544                    state.stack.push(bv);
545                }
546                if let Some(av) = a {
547                    state.stack.push(av);
548                }
549                if let Some(bv) = b {
550                    state.stack.push(bv);
551                }
552            }
553
554            // strand.spawn transfers resources
555            "strand.spawn" => {
556                state.pop();
557                let resources: Vec<TrackedResource> = state
558                    .stack
559                    .iter()
560                    .filter_map(|v| match v {
561                        StackValue::Resource(r) => Some(r.clone()),
562                        StackValue::Unknown => None,
563                    })
564                    .collect();
565                for r in resources {
566                    state.consume_resource(r);
567                }
568                state.push_unknown();
569            }
570
571            // Map operations that store values safely
572            "map.set" => {
573                // ( map key value -- map' ) - value is stored in map
574                let value = state.pop();
575                state.pop(); // key
576                state.pop(); // map
577                // Value is now safely stored in the map - consume if resource
578                if let Some(StackValue::Resource(r)) = value {
579                    state.consume_resource(r);
580                }
581                state.push_unknown(); // map'
582            }
583
584            // List operations that store values safely
585            "list.push" | "list.prepend" => {
586                // ( list value -- list' ) - value is stored in list
587                let value = state.pop();
588                state.pop(); // list
589                if let Some(StackValue::Resource(r)) = value {
590                    state.consume_resource(r);
591                }
592                state.push_unknown(); // list'
593            }
594
595            // User-defined words - check if we have info about them
596            _ => {
597                if let Some(info) = word_info.get(name) {
598                    // Push resources that this word returns
599                    for kind in &info.returns {
600                        state.push_resource(*kind, line, name);
601                    }
602                    return true;
603                }
604                // Not handled - caller should handle pass-specific operations
605                return false;
606            }
607        }
608        true
609    }
610
611    /// Simulate a word call (for first pass)
612    fn simulate_word_call(&self, name: &str, span: Option<&Span>, state: &mut StackState) {
613        // First pass uses shared logic with no-op callback for dropped resources
614        Self::simulate_word_common(name, span, state, &self.word_info, |_| {});
615    }
616
617    /// Second pass: Analyze a word with full cross-word context
618    fn analyze_word_with_context(&mut self, word: &WordDef) {
619        let mut state = StackState::new();
620
621        self.analyze_statements_with_context(&word.body, &mut state, word);
622
623        // Resources on stack at end are returned - no warning (escape analysis)
624    }
625
626    /// Analyze statements with diagnostics and cross-word tracking
627    fn analyze_statements_with_context(
628        &mut self,
629        statements: &[Statement],
630        state: &mut StackState,
631        word: &WordDef,
632    ) {
633        for stmt in statements {
634            self.analyze_statement_with_context(stmt, state, word);
635        }
636    }
637
638    /// Analyze a single statement with cross-word context
639    fn analyze_statement_with_context(
640        &mut self,
641        stmt: &Statement,
642        state: &mut StackState,
643        word: &WordDef,
644    ) {
645        match stmt {
646            Statement::IntLiteral(_)
647            | Statement::FloatLiteral(_)
648            | Statement::BoolLiteral(_)
649            | Statement::StringLiteral(_)
650            | Statement::Symbol(_) => {
651                state.push_unknown();
652            }
653
654            Statement::WordCall { name, span } => {
655                self.analyze_word_call_with_context(name, span.as_ref(), state, word);
656            }
657
658            Statement::Quotation { .. } => {
659                state.push_unknown();
660            }
661
662            Statement::If {
663                then_branch,
664                else_branch,
665            } => {
666                state.pop();
667                let mut then_state = state.clone();
668                let mut else_state = state.clone();
669
670                self.analyze_statements_with_context(then_branch, &mut then_state, word);
671                if let Some(else_stmts) = else_branch {
672                    self.analyze_statements_with_context(else_stmts, &mut else_state, word);
673                }
674
675                // Check for inconsistent handling
676                let merge_result = then_state.merge(&else_state);
677                for inconsistent in merge_result.inconsistent {
678                    self.emit_branch_inconsistency_warning(&inconsistent, word);
679                }
680
681                *state = then_state.join(&else_state);
682            }
683
684            Statement::Match { arms } => {
685                state.pop();
686                let mut arm_states: Vec<StackState> = Vec::new();
687
688                for arm in arms {
689                    let mut arm_state = state.clone();
690                    self.analyze_statements_with_context(&arm.body, &mut arm_state, word);
691                    arm_states.push(arm_state);
692                }
693
694                // Check consistency
695                if arm_states.len() >= 2 {
696                    let first = &arm_states[0];
697                    for other in &arm_states[1..] {
698                        let merge_result = first.merge(other);
699                        for inconsistent in merge_result.inconsistent {
700                            self.emit_branch_inconsistency_warning(&inconsistent, word);
701                        }
702                    }
703                }
704
705                if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
706                    *state = joined;
707                }
708            }
709        }
710    }
711
712    /// Analyze a word call with cross-word tracking
713    fn analyze_word_call_with_context(
714        &mut self,
715        name: &str,
716        span: Option<&Span>,
717        state: &mut StackState,
718        word: &WordDef,
719    ) {
720        // Collect dropped resources to emit warnings after shared simulation
721        let mut dropped_resources: Vec<TrackedResource> = Vec::new();
722
723        // Try shared logic first
724        let handled = Self::simulate_word_common(name, span, state, &self.word_info, |r| {
725            dropped_resources.push(r.clone())
726        });
727
728        // Emit warnings for any resources dropped without cleanup
729        for r in dropped_resources {
730            self.emit_drop_warning(&r, span, word);
731        }
732
733        if handled {
734            return;
735        }
736
737        // Handle operations unique to the second pass
738        match name {
739            // strand.resume handling - can't be shared because it has complex stack behavior
740            "strand.resume" => {
741                let value = state.pop();
742                let handle = state.pop();
743                if let Some(h) = handle {
744                    state.stack.push(h);
745                } else {
746                    state.push_unknown();
747                }
748                if let Some(v) = value {
749                    state.stack.push(v);
750                } else {
751                    state.push_unknown();
752                }
753                state.push_unknown();
754            }
755
756            "2dup" => {
757                if state.depth() >= 2 {
758                    let b = state.stack[state.depth() - 1].clone();
759                    let a = state.stack[state.depth() - 2].clone();
760                    state.stack.push(a);
761                    state.stack.push(b);
762                } else {
763                    state.push_unknown();
764                    state.push_unknown();
765                }
766            }
767
768            "3drop" => {
769                for _ in 0..3 {
770                    if let Some(StackValue::Resource(r)) = state.pop() {
771                        let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
772                        if !already_consumed {
773                            self.emit_drop_warning(&r, span, word);
774                        }
775                    }
776                }
777            }
778
779            "pick" | "roll" => {
780                state.pop();
781                state.push_unknown();
782            }
783
784            "chan.send" | "chan.receive" => {
785                state.pop();
786                state.pop();
787                state.push_unknown();
788                state.push_unknown();
789            }
790
791            // Unknown words: leave stack unchanged (may cause false negatives)
792            _ => {}
793        }
794    }
795
796    fn emit_drop_warning(
797        &mut self,
798        resource: &TrackedResource,
799        span: Option<&Span>,
800        word: &WordDef,
801    ) {
802        let line = span
803            .map(|s| s.line)
804            .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
805        let column = span.map(|s| s.column);
806
807        self.diagnostics.push(LintDiagnostic {
808            id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
809            message: format!(
810                "{} from `{}` (line {}) dropped without cleanup - {}",
811                resource.kind.name(),
812                resource.created_by,
813                resource.created_line + 1,
814                resource.kind.cleanup_suggestion()
815            ),
816            severity: Severity::Warning,
817            replacement: String::new(),
818            file: self.file.clone(),
819            line,
820            end_line: None,
821            start_column: column,
822            end_column: column.map(|c| c + 4),
823            word_name: word.name.clone(),
824            start_index: 0,
825            end_index: 0,
826        });
827    }
828
829    fn emit_branch_inconsistency_warning(
830        &mut self,
831        inconsistent: &InconsistentResource,
832        word: &WordDef,
833    ) {
834        let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
835        let branch = if inconsistent.consumed_in_else {
836            "else"
837        } else {
838            "then"
839        };
840
841        self.diagnostics.push(LintDiagnostic {
842            id: "resource-branch-inconsistent".to_string(),
843            message: format!(
844                "{} from `{}` (line {}) is consumed in {} branch but not the other - all branches must handle resources consistently",
845                inconsistent.resource.kind.name(),
846                inconsistent.resource.created_by,
847                inconsistent.resource.created_line + 1,
848                branch
849            ),
850            severity: Severity::Warning,
851            replacement: String::new(),
852            file: self.file.clone(),
853            line,
854            end_line: None,
855            start_column: None,
856            end_column: None,
857            word_name: word.name.clone(),
858            start_index: 0,
859            end_index: 0,
860        });
861    }
862}
863
864/// The resource leak analyzer (single-word analysis)
865pub struct ResourceAnalyzer {
866    /// Diagnostics collected during analysis
867    diagnostics: Vec<LintDiagnostic>,
868    /// File being analyzed
869    file: std::path::PathBuf,
870}
871
872impl ResourceAnalyzer {
873    pub fn new(file: &Path) -> Self {
874        ResourceAnalyzer {
875            diagnostics: Vec::new(),
876            file: file.to_path_buf(),
877        }
878    }
879
880    /// Analyze a word definition for resource leaks
881    pub fn analyze_word(&mut self, word: &WordDef) -> Vec<LintDiagnostic> {
882        self.diagnostics.clear();
883
884        let mut state = StackState::new();
885
886        // Analyze the word body
887        self.analyze_statements(&word.body, &mut state, word);
888
889        // Check for leaked resources at end of word
890        // Note: Resources still on stack at word end could be:
891        // 1. Intentionally returned (escape) - caller's responsibility
892        // 2. Leaked - forgot to clean up
893        //
894        // For Phase 2a, we apply escape analysis: if a resource is still
895        // on the stack at word end, it's being returned to the caller.
896        // This is valid - the caller becomes responsible for cleanup.
897        // We only warn about resources that are explicitly dropped without
898        // cleanup, or handled inconsistently across branches.
899        //
900        // Phase 2b could add cross-word analysis to track if callers
901        // properly handle returned resources.
902        let _ = state.remaining_resources(); // Intentional: escape = no warning
903
904        std::mem::take(&mut self.diagnostics)
905    }
906
907    /// Analyze a sequence of statements
908    fn analyze_statements(
909        &mut self,
910        statements: &[Statement],
911        state: &mut StackState,
912        word: &WordDef,
913    ) {
914        for stmt in statements {
915            self.analyze_statement(stmt, state, word);
916        }
917    }
918
919    /// Analyze a single statement
920    fn analyze_statement(&mut self, stmt: &Statement, state: &mut StackState, word: &WordDef) {
921        match stmt {
922            Statement::IntLiteral(_)
923            | Statement::FloatLiteral(_)
924            | Statement::BoolLiteral(_)
925            | Statement::StringLiteral(_)
926            | Statement::Symbol(_) => {
927                state.push_unknown();
928            }
929
930            Statement::WordCall { name, span } => {
931                self.analyze_word_call(name, span.as_ref(), state, word);
932            }
933
934            Statement::Quotation { body, .. } => {
935                // Quotations capture the current stack conceptually but don't
936                // execute immediately. For now, just push an unknown value
937                // (the quotation itself). We could analyze the body when
938                // we see `call`, but that's Phase 2b.
939                let _ = body; // Acknowledge we're not analyzing the body yet
940                state.push_unknown();
941            }
942
943            Statement::If {
944                then_branch,
945                else_branch,
946            } => {
947                self.analyze_if(then_branch, else_branch.as_ref(), state, word);
948            }
949
950            Statement::Match { arms } => {
951                self.analyze_match(arms, state, word);
952            }
953        }
954    }
955
956    /// Analyze a word call
957    fn analyze_word_call(
958        &mut self,
959        name: &str,
960        span: Option<&Span>,
961        state: &mut StackState,
962        word: &WordDef,
963    ) {
964        let line = span.map(|s| s.line).unwrap_or(0);
965
966        match name {
967            // Resource-creating words
968            "strand.weave" => {
969                // Pops quotation, pushes WeaveHandle
970                state.pop(); // quotation
971                state.push_resource(ResourceKind::WeaveHandle, line, name);
972            }
973
974            "chan.make" => {
975                // Pushes a new channel
976                state.push_resource(ResourceKind::Channel, line, name);
977            }
978
979            // Resource-consuming words
980            "strand.weave-cancel" => {
981                // Pops and consumes WeaveHandle
982                if let Some(StackValue::Resource(r)) = state.pop()
983                    && r.kind == ResourceKind::WeaveHandle
984                {
985                    state.consume_resource(r);
986                }
987            }
988
989            "chan.close" => {
990                // Pops and consumes Channel
991                if let Some(StackValue::Resource(r)) = state.pop()
992                    && r.kind == ResourceKind::Channel
993                {
994                    state.consume_resource(r);
995                }
996            }
997
998            // strand.resume is special - it returns (handle value bool)
999            // If bool is false, the weave completed and handle is consumed
1000            // We can't know statically, so we just track that the handle
1001            // is still in play (on the stack after resume)
1002            "strand.resume" => {
1003                // Pops (handle value), pushes (handle value bool)
1004                let value = state.pop(); // value to send
1005                let handle = state.pop(); // handle
1006
1007                // Push them back plus the bool result
1008                if let Some(h) = handle {
1009                    state.stack.push(h);
1010                } else {
1011                    state.push_unknown();
1012                }
1013                if let Some(v) = value {
1014                    state.stack.push(v);
1015                } else {
1016                    state.push_unknown();
1017                }
1018                state.push_unknown(); // bool result
1019            }
1020
1021            // Stack operations
1022            "drop" => {
1023                let dropped = state.pop();
1024                // If we dropped a resource without consuming it properly, that's a leak
1025                // But check if it was already consumed (e.g., transferred via strand.spawn)
1026                if let Some(StackValue::Resource(r)) = dropped {
1027                    let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1028                    if !already_consumed {
1029                        self.emit_drop_warning(&r, span, word);
1030                    }
1031                }
1032            }
1033
1034            "dup" => {
1035                if let Some(top) = state.peek().cloned() {
1036                    state.stack.push(top);
1037                } else {
1038                    state.push_unknown();
1039                }
1040            }
1041
1042            "swap" => {
1043                let a = state.pop();
1044                let b = state.pop();
1045                if let Some(av) = a {
1046                    state.stack.push(av);
1047                }
1048                if let Some(bv) = b {
1049                    state.stack.push(bv);
1050                }
1051            }
1052
1053            "over" => {
1054                // ( a b -- a b a ) - copy second element to top
1055                if state.depth() >= 2 {
1056                    let second = state.stack[state.depth() - 2].clone();
1057                    state.stack.push(second);
1058                } else {
1059                    state.push_unknown();
1060                }
1061            }
1062
1063            "rot" => {
1064                // ( a b c -- b c a )
1065                let c = state.pop();
1066                let b = state.pop();
1067                let a = state.pop();
1068                if let Some(bv) = b {
1069                    state.stack.push(bv);
1070                }
1071                if let Some(cv) = c {
1072                    state.stack.push(cv);
1073                }
1074                if let Some(av) = a {
1075                    state.stack.push(av);
1076                }
1077            }
1078
1079            "nip" => {
1080                // ( a b -- b ) - drop second
1081                let b = state.pop();
1082                let a = state.pop();
1083                if let Some(StackValue::Resource(r)) = a {
1084                    let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1085                    if !already_consumed {
1086                        self.emit_drop_warning(&r, span, word);
1087                    }
1088                }
1089                if let Some(bv) = b {
1090                    state.stack.push(bv);
1091                }
1092            }
1093
1094            "tuck" => {
1095                // ( a b -- b a b )
1096                let b = state.pop();
1097                let a = state.pop();
1098                if let Some(bv) = b.clone() {
1099                    state.stack.push(bv);
1100                }
1101                if let Some(av) = a {
1102                    state.stack.push(av);
1103                }
1104                if let Some(bv) = b {
1105                    state.stack.push(bv);
1106                }
1107            }
1108
1109            "2dup" => {
1110                // ( a b -- a b a b )
1111                if state.depth() >= 2 {
1112                    let b = state.stack[state.depth() - 1].clone();
1113                    let a = state.stack[state.depth() - 2].clone();
1114                    state.stack.push(a);
1115                    state.stack.push(b);
1116                } else {
1117                    state.push_unknown();
1118                    state.push_unknown();
1119                }
1120            }
1121
1122            "3drop" => {
1123                for _ in 0..3 {
1124                    if let Some(StackValue::Resource(r)) = state.pop() {
1125                        let already_consumed = state.consumed.iter().any(|c| c.id == r.id);
1126                        if !already_consumed {
1127                            self.emit_drop_warning(&r, span, word);
1128                        }
1129                    }
1130                }
1131            }
1132
1133            "pick" => {
1134                // ( ... n -- ... value_at_n )
1135                // We can't know n statically, so just push unknown
1136                state.pop(); // pop n
1137                state.push_unknown();
1138            }
1139
1140            "roll" => {
1141                // Similar to pick but also removes the item
1142                state.pop(); // pop n
1143                state.push_unknown();
1144            }
1145
1146            // Channel operations that don't consume
1147            "chan.send" | "chan.receive" => {
1148                // These use the channel but don't consume it
1149                // chan.send: ( chan value -- bool )
1150                // chan.receive: ( chan -- value bool )
1151                state.pop();
1152                state.pop();
1153                state.push_unknown();
1154                state.push_unknown();
1155            }
1156
1157            // strand.spawn clones the stack to the child strand
1158            // Resources on the stack are transferred to child's responsibility
1159            "strand.spawn" => {
1160                // Pops quotation, pushes strand-id
1161                // All resources currently on stack are now shared with child
1162                // Mark them as consumed since child takes responsibility
1163                state.pop(); // quotation
1164                let resources_on_stack: Vec<TrackedResource> = state
1165                    .stack
1166                    .iter()
1167                    .filter_map(|v| match v {
1168                        StackValue::Resource(r) => Some(r.clone()),
1169                        StackValue::Unknown => None,
1170                    })
1171                    .collect();
1172                for r in resources_on_stack {
1173                    state.consume_resource(r);
1174                }
1175                state.push_unknown(); // strand-id
1176            }
1177
1178            // For any other word, we don't know its stack effect
1179            // Conservatively, we could assume it consumes/produces unknown values
1180            // For now, we just leave the stack unchanged (may cause false positives)
1181            _ => {
1182                // Unknown word - could be user-defined
1183                // We'd need type info to know its stack effect
1184                // For Phase 2a, we'll be conservative and do nothing
1185            }
1186        }
1187    }
1188
1189    /// Analyze an if/else statement
1190    fn analyze_if(
1191        &mut self,
1192        then_branch: &[Statement],
1193        else_branch: Option<&Vec<Statement>>,
1194        state: &mut StackState,
1195        word: &WordDef,
1196    ) {
1197        // Pop the condition
1198        state.pop();
1199
1200        // Clone state for each branch
1201        let mut then_state = state.clone();
1202        let mut else_state = state.clone();
1203
1204        // Analyze then branch
1205        self.analyze_statements(then_branch, &mut then_state, word);
1206
1207        // Analyze else branch if present
1208        if let Some(else_stmts) = else_branch {
1209            self.analyze_statements(else_stmts, &mut else_state, word);
1210        }
1211
1212        // Check for inconsistent resource handling between branches
1213        let merge_result = then_state.merge(&else_state);
1214        for inconsistent in merge_result.inconsistent {
1215            self.emit_branch_inconsistency_warning(&inconsistent, word);
1216        }
1217
1218        // Compute proper lattice join of both branch states
1219        // This ensures we track resources from either branch and only
1220        // consider resources consumed if consumed in BOTH branches
1221        *state = then_state.join(&else_state);
1222    }
1223
1224    /// Analyze a match statement
1225    fn analyze_match(&mut self, arms: &[MatchArm], state: &mut StackState, word: &WordDef) {
1226        // Pop the matched value
1227        state.pop();
1228
1229        if arms.is_empty() {
1230            return;
1231        }
1232
1233        // Analyze each arm
1234        let mut arm_states: Vec<StackState> = Vec::new();
1235
1236        for arm in arms {
1237            let mut arm_state = state.clone();
1238
1239            // Match arms may push extracted fields - for now we push unknowns
1240            // based on the pattern (simplified)
1241            match &arm.pattern {
1242                crate::ast::Pattern::Variant(_) => {
1243                    // Variant match pushes all fields - we don't know how many
1244                    // so we just continue with current state
1245                }
1246                crate::ast::Pattern::VariantWithBindings { bindings, .. } => {
1247                    // Push unknowns for each binding
1248                    for _ in bindings {
1249                        arm_state.push_unknown();
1250                    }
1251                }
1252            }
1253
1254            self.analyze_statements(&arm.body, &mut arm_state, word);
1255            arm_states.push(arm_state);
1256        }
1257
1258        // Check consistency between all arms
1259        if arm_states.len() >= 2 {
1260            let first = &arm_states[0];
1261            for other in &arm_states[1..] {
1262                let merge_result = first.merge(other);
1263                for inconsistent in merge_result.inconsistent {
1264                    self.emit_branch_inconsistency_warning(&inconsistent, word);
1265                }
1266            }
1267        }
1268
1269        // Compute proper lattice join of all arm states
1270        // Resources are only consumed if consumed in ALL arms
1271        if let Some(first) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
1272            *state = first;
1273        }
1274    }
1275
1276    /// Emit a warning for a resource dropped without cleanup
1277    fn emit_drop_warning(
1278        &mut self,
1279        resource: &TrackedResource,
1280        span: Option<&Span>,
1281        word: &WordDef,
1282    ) {
1283        let line = span
1284            .map(|s| s.line)
1285            .unwrap_or_else(|| word.source.as_ref().map(|s| s.start_line).unwrap_or(0));
1286        let column = span.map(|s| s.column);
1287
1288        self.diagnostics.push(LintDiagnostic {
1289            id: format!("resource-leak-{}", resource.kind.name().to_lowercase()),
1290            message: format!(
1291                "{} created at line {} dropped without cleanup - {}",
1292                resource.kind.name(),
1293                resource.created_line + 1,
1294                resource.kind.cleanup_suggestion()
1295            ),
1296            severity: Severity::Warning,
1297            replacement: String::new(),
1298            file: self.file.clone(),
1299            line,
1300            end_line: None,
1301            start_column: column,
1302            end_column: column.map(|c| c + 4), // approximate
1303            word_name: word.name.clone(),
1304            start_index: 0,
1305            end_index: 0,
1306        });
1307    }
1308
1309    /// Emit a warning for inconsistent resource handling between branches
1310    fn emit_branch_inconsistency_warning(
1311        &mut self,
1312        inconsistent: &InconsistentResource,
1313        word: &WordDef,
1314    ) {
1315        let line = word.source.as_ref().map(|s| s.start_line).unwrap_or(0);
1316        let branch = if inconsistent.consumed_in_else {
1317            "else"
1318        } else {
1319            "then"
1320        };
1321
1322        self.diagnostics.push(LintDiagnostic {
1323            id: "resource-branch-inconsistent".to_string(),
1324            message: format!(
1325                "{} created at line {} is consumed in {} branch but not the other - all branches must handle resources consistently",
1326                inconsistent.resource.kind.name(),
1327                inconsistent.resource.created_line + 1,
1328                branch
1329            ),
1330            severity: Severity::Warning,
1331            replacement: String::new(),
1332            file: self.file.clone(),
1333            line,
1334            end_line: None,
1335            start_column: None,
1336            end_column: None,
1337            word_name: word.name.clone(),
1338            start_index: 0,
1339            end_index: 0,
1340        });
1341    }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346    use super::*;
1347    use crate::ast::{Statement, WordDef};
1348
1349    fn make_word_call(name: &str) -> Statement {
1350        Statement::WordCall {
1351            name: name.to_string(),
1352            span: Some(Span::new(0, 0, name.len())),
1353        }
1354    }
1355
1356    #[test]
1357    fn test_immediate_weave_drop() {
1358        // : bad ( -- ) [ gen ] strand.weave drop ;
1359        let word = WordDef {
1360            name: "bad".to_string(),
1361            effect: None,
1362            body: vec![
1363                Statement::Quotation {
1364                    span: None,
1365                    id: 0,
1366                    body: vec![make_word_call("gen")],
1367                },
1368                make_word_call("strand.weave"),
1369                make_word_call("drop"),
1370            ],
1371            source: None,
1372            allowed_lints: vec![],
1373        };
1374
1375        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1376        let diagnostics = analyzer.analyze_word(&word);
1377
1378        assert_eq!(diagnostics.len(), 1);
1379        assert!(diagnostics[0].id.contains("weavehandle"));
1380        assert!(diagnostics[0].message.contains("dropped without cleanup"));
1381    }
1382
1383    #[test]
1384    fn test_weave_properly_cancelled() {
1385        // : good ( -- ) [ gen ] strand.weave strand.weave-cancel ;
1386        let word = WordDef {
1387            name: "good".to_string(),
1388            effect: None,
1389            body: vec![
1390                Statement::Quotation {
1391                    span: None,
1392                    id: 0,
1393                    body: vec![make_word_call("gen")],
1394                },
1395                make_word_call("strand.weave"),
1396                make_word_call("strand.weave-cancel"),
1397            ],
1398            source: None,
1399            allowed_lints: vec![],
1400        };
1401
1402        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1403        let diagnostics = analyzer.analyze_word(&word);
1404
1405        assert!(
1406            diagnostics.is_empty(),
1407            "Expected no warnings for properly cancelled weave"
1408        );
1409    }
1410
1411    #[test]
1412    fn test_branch_inconsistent_handling() {
1413        // : bad ( -- )
1414        //   [ gen ] strand.weave
1415        //   true if strand.weave-cancel else drop then ;
1416        let word = WordDef {
1417            name: "bad".to_string(),
1418            effect: None,
1419            body: vec![
1420                Statement::Quotation {
1421                    span: None,
1422                    id: 0,
1423                    body: vec![make_word_call("gen")],
1424                },
1425                make_word_call("strand.weave"),
1426                Statement::BoolLiteral(true),
1427                Statement::If {
1428                    then_branch: vec![make_word_call("strand.weave-cancel")],
1429                    else_branch: Some(vec![make_word_call("drop")]),
1430                },
1431            ],
1432            source: None,
1433            allowed_lints: vec![],
1434        };
1435
1436        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1437        let diagnostics = analyzer.analyze_word(&word);
1438
1439        // Should warn about drop in else branch
1440        assert!(!diagnostics.is_empty());
1441    }
1442
1443    #[test]
1444    fn test_both_branches_cancel() {
1445        // : good ( -- )
1446        //   [ gen ] strand.weave
1447        //   true if strand.weave-cancel else strand.weave-cancel then ;
1448        let word = WordDef {
1449            name: "good".to_string(),
1450            effect: None,
1451            body: vec![
1452                Statement::Quotation {
1453                    span: None,
1454                    id: 0,
1455                    body: vec![make_word_call("gen")],
1456                },
1457                make_word_call("strand.weave"),
1458                Statement::BoolLiteral(true),
1459                Statement::If {
1460                    then_branch: vec![make_word_call("strand.weave-cancel")],
1461                    else_branch: Some(vec![make_word_call("strand.weave-cancel")]),
1462                },
1463            ],
1464            source: None,
1465            allowed_lints: vec![],
1466        };
1467
1468        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1469        let diagnostics = analyzer.analyze_word(&word);
1470
1471        assert!(
1472            diagnostics.is_empty(),
1473            "Expected no warnings when both branches cancel"
1474        );
1475    }
1476
1477    #[test]
1478    fn test_channel_leak() {
1479        // : bad ( -- ) chan.make drop ;
1480        let word = WordDef {
1481            name: "bad".to_string(),
1482            effect: None,
1483            body: vec![make_word_call("chan.make"), make_word_call("drop")],
1484            source: None,
1485            allowed_lints: vec![],
1486        };
1487
1488        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1489        let diagnostics = analyzer.analyze_word(&word);
1490
1491        assert_eq!(diagnostics.len(), 1);
1492        assert!(diagnostics[0].id.contains("channel"));
1493    }
1494
1495    #[test]
1496    fn test_channel_properly_closed() {
1497        // : good ( -- ) chan.make chan.close ;
1498        let word = WordDef {
1499            name: "good".to_string(),
1500            effect: None,
1501            body: vec![make_word_call("chan.make"), make_word_call("chan.close")],
1502            source: None,
1503            allowed_lints: vec![],
1504        };
1505
1506        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1507        let diagnostics = analyzer.analyze_word(&word);
1508
1509        assert!(
1510            diagnostics.is_empty(),
1511            "Expected no warnings for properly closed channel"
1512        );
1513    }
1514
1515    #[test]
1516    fn test_swap_resource_tracking() {
1517        // : test ( -- ) chan.make 1 swap drop drop ;
1518        // After swap: chan is on top, 1 is second
1519        // First drop removes chan (should warn), second drop removes 1
1520        let word = WordDef {
1521            name: "test".to_string(),
1522            effect: None,
1523            body: vec![
1524                make_word_call("chan.make"),
1525                Statement::IntLiteral(1),
1526                make_word_call("swap"),
1527                make_word_call("drop"), // drops chan - should warn
1528                make_word_call("drop"), // drops 1
1529            ],
1530            source: None,
1531            allowed_lints: vec![],
1532        };
1533
1534        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1535        let diagnostics = analyzer.analyze_word(&word);
1536
1537        assert_eq!(
1538            diagnostics.len(),
1539            1,
1540            "Expected warning for dropped channel: {:?}",
1541            diagnostics
1542        );
1543        assert!(diagnostics[0].id.contains("channel"));
1544    }
1545
1546    #[test]
1547    fn test_over_resource_tracking() {
1548        // : test ( -- ) chan.make 1 over drop drop drop ;
1549        // Stack after chan.make: (chan)
1550        // Stack after 1: (chan 1)
1551        // Stack after over: (chan 1 chan) - chan copied to top
1552        // Both chan references are dropped without cleanup - both warn
1553        let word = WordDef {
1554            name: "test".to_string(),
1555            effect: None,
1556            body: vec![
1557                make_word_call("chan.make"),
1558                Statement::IntLiteral(1),
1559                make_word_call("over"),
1560                make_word_call("drop"), // drops copied chan - warns
1561                make_word_call("drop"), // drops 1
1562                make_word_call("drop"), // drops original chan - also warns
1563            ],
1564            source: None,
1565            allowed_lints: vec![],
1566        };
1567
1568        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1569        let diagnostics = analyzer.analyze_word(&word);
1570
1571        // Both channel drops warn (they share ID but neither was properly consumed)
1572        assert_eq!(
1573            diagnostics.len(),
1574            2,
1575            "Expected 2 warnings for dropped channels: {:?}",
1576            diagnostics
1577        );
1578    }
1579
1580    #[test]
1581    fn test_channel_transferred_via_spawn() {
1582        // Pattern from shopping-cart: channel transferred to spawned worker
1583        // : accept-loop ( -- )
1584        //   chan.make                  # create channel
1585        //   dup [ worker ] strand.spawn  # transfer to worker
1586        //   drop drop                  # drop strand-id and dup'd chan
1587        //   chan.send                  # use remaining chan
1588        // ;
1589        let word = WordDef {
1590            name: "accept-loop".to_string(),
1591            effect: None,
1592            body: vec![
1593                make_word_call("chan.make"),
1594                make_word_call("dup"),
1595                Statement::Quotation {
1596                    span: None,
1597                    id: 0,
1598                    body: vec![make_word_call("worker")],
1599                },
1600                make_word_call("strand.spawn"),
1601                make_word_call("drop"),
1602                make_word_call("drop"),
1603                make_word_call("chan.send"),
1604            ],
1605            source: None,
1606            allowed_lints: vec![],
1607        };
1608
1609        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1610        let diagnostics = analyzer.analyze_word(&word);
1611
1612        assert!(
1613            diagnostics.is_empty(),
1614            "Expected no warnings when channel is transferred via strand.spawn: {:?}",
1615            diagnostics
1616        );
1617    }
1618
1619    #[test]
1620    fn test_else_branch_only_leak() {
1621        // : test ( -- )
1622        //   chan.make
1623        //   true if chan.close else drop then ;
1624        // The else branch drops without cleanup - should warn about inconsistency
1625        // AND the join should track that the resource might not be consumed
1626        let word = WordDef {
1627            name: "test".to_string(),
1628            effect: None,
1629            body: vec![
1630                make_word_call("chan.make"),
1631                Statement::BoolLiteral(true),
1632                Statement::If {
1633                    then_branch: vec![make_word_call("chan.close")],
1634                    else_branch: Some(vec![make_word_call("drop")]),
1635                },
1636            ],
1637            source: None,
1638            allowed_lints: vec![],
1639        };
1640
1641        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1642        let diagnostics = analyzer.analyze_word(&word);
1643
1644        // Should have warnings: branch inconsistency + drop without cleanup
1645        assert!(
1646            !diagnostics.is_empty(),
1647            "Expected warnings for else-branch leak: {:?}",
1648            diagnostics
1649        );
1650    }
1651
1652    #[test]
1653    fn test_branch_join_both_consume() {
1654        // : test ( -- )
1655        //   chan.make
1656        //   true if chan.close else chan.close then ;
1657        // Both branches properly consume - no warnings
1658        let word = WordDef {
1659            name: "test".to_string(),
1660            effect: None,
1661            body: vec![
1662                make_word_call("chan.make"),
1663                Statement::BoolLiteral(true),
1664                Statement::If {
1665                    then_branch: vec![make_word_call("chan.close")],
1666                    else_branch: Some(vec![make_word_call("chan.close")]),
1667                },
1668            ],
1669            source: None,
1670            allowed_lints: vec![],
1671        };
1672
1673        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1674        let diagnostics = analyzer.analyze_word(&word);
1675
1676        assert!(
1677            diagnostics.is_empty(),
1678            "Expected no warnings when both branches consume: {:?}",
1679            diagnostics
1680        );
1681    }
1682
1683    #[test]
1684    fn test_branch_join_neither_consume() {
1685        // : test ( -- )
1686        //   chan.make
1687        //   true if else then drop ;
1688        // Neither branch consumes, then drop after - should warn
1689        let word = WordDef {
1690            name: "test".to_string(),
1691            effect: None,
1692            body: vec![
1693                make_word_call("chan.make"),
1694                Statement::BoolLiteral(true),
1695                Statement::If {
1696                    then_branch: vec![],
1697                    else_branch: Some(vec![]),
1698                },
1699                make_word_call("drop"), // drops the channel
1700            ],
1701            source: None,
1702            allowed_lints: vec![],
1703        };
1704
1705        let mut analyzer = ResourceAnalyzer::new(Path::new("test.seq"));
1706        let diagnostics = analyzer.analyze_word(&word);
1707
1708        assert_eq!(
1709            diagnostics.len(),
1710            1,
1711            "Expected warning for dropped channel: {:?}",
1712            diagnostics
1713        );
1714        assert!(diagnostics[0].id.contains("channel"));
1715    }
1716
1717    // ========================================================================
1718    // Cross-word analysis tests (ProgramResourceAnalyzer)
1719    // ========================================================================
1720
1721    #[test]
1722    fn test_cross_word_resource_tracking() {
1723        // Test that resources returned from user-defined words are tracked
1724        //
1725        // : make-chan ( -- chan ) chan.make ;
1726        // : leak-it ( -- ) make-chan drop ;
1727        //
1728        // The drop in leak-it should warn because make-chan returns a channel
1729        use crate::ast::Program;
1730
1731        let make_chan = WordDef {
1732            name: "make-chan".to_string(),
1733            effect: None,
1734            body: vec![make_word_call("chan.make")],
1735            source: None,
1736            allowed_lints: vec![],
1737        };
1738
1739        let leak_it = WordDef {
1740            name: "leak-it".to_string(),
1741            effect: None,
1742            body: vec![make_word_call("make-chan"), make_word_call("drop")],
1743            source: None,
1744            allowed_lints: vec![],
1745        };
1746
1747        let program = Program {
1748            words: vec![make_chan, leak_it],
1749            includes: vec![],
1750            unions: vec![],
1751        };
1752
1753        let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1754        let diagnostics = analyzer.analyze_program(&program);
1755
1756        assert_eq!(
1757            diagnostics.len(),
1758            1,
1759            "Expected warning for dropped channel from make-chan: {:?}",
1760            diagnostics
1761        );
1762        assert!(diagnostics[0].id.contains("channel"));
1763        assert!(diagnostics[0].message.contains("make-chan"));
1764    }
1765
1766    #[test]
1767    fn test_cross_word_proper_cleanup() {
1768        // Test that properly cleaned up cross-word resources don't warn
1769        //
1770        // : make-chan ( -- chan ) chan.make ;
1771        // : use-it ( -- ) make-chan chan.close ;
1772        use crate::ast::Program;
1773
1774        let make_chan = WordDef {
1775            name: "make-chan".to_string(),
1776            effect: None,
1777            body: vec![make_word_call("chan.make")],
1778            source: None,
1779            allowed_lints: vec![],
1780        };
1781
1782        let use_it = WordDef {
1783            name: "use-it".to_string(),
1784            effect: None,
1785            body: vec![make_word_call("make-chan"), make_word_call("chan.close")],
1786            source: None,
1787            allowed_lints: vec![],
1788        };
1789
1790        let program = Program {
1791            words: vec![make_chan, use_it],
1792            includes: vec![],
1793            unions: vec![],
1794        };
1795
1796        let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1797        let diagnostics = analyzer.analyze_program(&program);
1798
1799        assert!(
1800            diagnostics.is_empty(),
1801            "Expected no warnings for properly closed channel: {:?}",
1802            diagnostics
1803        );
1804    }
1805
1806    #[test]
1807    fn test_cross_word_chain() {
1808        // Test multi-level cross-word tracking
1809        //
1810        // : make-chan ( -- chan ) chan.make ;
1811        // : wrap-chan ( -- chan ) make-chan ;
1812        // : leak-chain ( -- ) wrap-chan drop ;
1813        use crate::ast::Program;
1814
1815        let make_chan = WordDef {
1816            name: "make-chan".to_string(),
1817            effect: None,
1818            body: vec![make_word_call("chan.make")],
1819            source: None,
1820            allowed_lints: vec![],
1821        };
1822
1823        let wrap_chan = WordDef {
1824            name: "wrap-chan".to_string(),
1825            effect: None,
1826            body: vec![make_word_call("make-chan")],
1827            source: None,
1828            allowed_lints: vec![],
1829        };
1830
1831        let leak_chain = WordDef {
1832            name: "leak-chain".to_string(),
1833            effect: None,
1834            body: vec![make_word_call("wrap-chan"), make_word_call("drop")],
1835            source: None,
1836            allowed_lints: vec![],
1837        };
1838
1839        let program = Program {
1840            words: vec![make_chan, wrap_chan, leak_chain],
1841            includes: vec![],
1842            unions: vec![],
1843        };
1844
1845        let mut analyzer = ProgramResourceAnalyzer::new(Path::new("test.seq"));
1846        let diagnostics = analyzer.analyze_program(&program);
1847
1848        // Should detect the leak through the chain
1849        assert_eq!(
1850            diagnostics.len(),
1851            1,
1852            "Expected warning for dropped channel through chain: {:?}",
1853            diagnostics
1854        );
1855    }
1856}