Skip to main content

seqc/
resource_lint.rs

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