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