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