Skip to main content

seqc/
error_flag_lint.rs

1//! Error Flag Detection (Phase 2b)
2//!
3//! Abstract stack simulation that tracks Bool values produced by fallible
4//! operations. Warns when these "error flags" are dropped without being
5//! checked via `if` or `cond`.
6//!
7//! This catches patterns that the TOML-based pattern linter misses:
8//! - `file.slurp swap nip` (Bool moved by swap, then dropped by nip)
9//! - `i./ >aux ... aux> drop` (Bool stashed on aux stack, dropped later)
10//!
11//! # Architecture
12//!
13//! Modeled on `resource_lint.rs`:
14//! 1. Tag Bools from fallible ops with their origin
15//! 2. Simulate stack operations to track tag movement
16//! 3. When a tagged Bool is consumed by `if`/`cond`, mark checked
17//! 4. When consumed by `drop`/`nip`/other, emit warning
18//!
19//! # Conservative Design
20//!
21//! - Only tracks Bools from known fallible builtins (not all Bools)
22//! - If a tagged Bool flows into an unknown user word, assume checked
23//!   (avoids false positives from cross-word analysis)
24//! - Bools remaining on the stack at word end are assumed returned
25//!   (escape analysis, same as resource_lint)
26
27use crate::ast::{Program, Span, Statement, WordDef};
28use crate::lint::{LintDiagnostic, Severity};
29use std::path::{Path, PathBuf};
30
31/// A tracked error flag with its origin
32#[derive(Debug, Clone)]
33struct ErrorFlag {
34    /// Line where the fallible operation was called (0-indexed)
35    created_line: usize,
36    /// The operation that produced this flag
37    operation: String,
38    /// Human-readable description of what failure the Bool indicates
39    description: String,
40}
41
42/// A value on the abstract stack
43#[derive(Debug, Clone)]
44enum StackVal {
45    /// A tracked error flag that hasn't been checked yet
46    Flag(ErrorFlag),
47    /// Any other value (not tracked)
48    Other,
49}
50
51/// Abstract stack state for error flag tracking
52#[derive(Debug, Clone)]
53struct FlagStack {
54    stack: Vec<StackVal>,
55    aux: Vec<StackVal>,
56}
57
58impl FlagStack {
59    fn new() -> Self {
60        FlagStack {
61            stack: Vec::new(),
62            aux: Vec::new(),
63        }
64    }
65
66    fn push_other(&mut self) {
67        self.stack.push(StackVal::Other);
68    }
69
70    fn push_flag(&mut self, line: usize, operation: &str, description: &str) {
71        let flag = ErrorFlag {
72            created_line: line,
73            operation: operation.to_string(),
74            description: description.to_string(),
75        };
76        self.stack.push(StackVal::Flag(flag));
77    }
78
79    fn pop(&mut self) -> Option<StackVal> {
80        self.stack.pop()
81    }
82
83    fn depth(&self) -> usize {
84        self.stack.len()
85    }
86
87    /// Join two states after branching (conservative: keep flags from either)
88    fn join(&self, other: &FlagStack) -> FlagStack {
89        // Use the longer stack, preserving flags from either branch
90        let len = self.stack.len().max(other.stack.len());
91        let mut joined = Vec::with_capacity(len);
92
93        for i in 0..len {
94            let a = self.stack.get(i);
95            let b = other.stack.get(i);
96            // If either branch has a flag at this position, keep it
97            let val = match (a, b) {
98                (Some(StackVal::Flag(f)), _) => StackVal::Flag(f.clone()),
99                (_, Some(StackVal::Flag(f))) => StackVal::Flag(f.clone()),
100                _ => StackVal::Other,
101            };
102            joined.push(val);
103        }
104
105        // Join aux stacks similarly
106        let aux_len = self.aux.len().max(other.aux.len());
107        let mut joined_aux = Vec::with_capacity(aux_len);
108        for i in 0..aux_len {
109            let a = self.aux.get(i);
110            let b = other.aux.get(i);
111            let val = match (a, b) {
112                (Some(StackVal::Flag(f)), _) => StackVal::Flag(f.clone()),
113                (_, Some(StackVal::Flag(f))) => StackVal::Flag(f.clone()),
114                _ => StackVal::Other,
115            };
116            joined_aux.push(val);
117        }
118
119        FlagStack {
120            stack: joined,
121            aux: joined_aux,
122        }
123    }
124}
125
126/// Information about a fallible operation.
127struct FallibleOpInfo {
128    /// Number of values the operation consumes from the stack
129    inputs: usize,
130    /// Number of values pushed BEFORE the Bool (e.g., 1 for `( -- String Bool )`)
131    values_before_bool: usize,
132    /// Human-readable description of what failure the Bool indicates
133    description: &'static str,
134}
135
136/// Single source of truth for all fallible operations.
137/// Maps operation name → (inputs consumed, values before Bool, description).
138fn fallible_op_info(name: &str) -> Option<FallibleOpInfo> {
139    let (inputs, values_before_bool, description) = match name {
140        // Division — ( Int Int -- Int Bool )
141        "i./" | "i.divide" => (2, 1, "division by zero"),
142        "i.%" | "i.modulo" => (2, 1, "modulo by zero"),
143
144        // File I/O
145        "file.slurp" => (1, 1, "file read failure"),
146        "file.spit" => (2, 0, "file write failure"),
147        "file.append" => (2, 0, "file append failure"),
148        "file.delete" => (1, 0, "file delete failure"),
149        "file.size" => (1, 1, "file size failure"),
150        "dir.make" => (1, 0, "directory creation failure"),
151        "dir.delete" => (1, 0, "directory delete failure"),
152        "dir.list" => (1, 1, "directory list failure"),
153
154        // I/O — ( -- String Bool )
155        "io.read-line" => (0, 1, "read failure"),
156
157        // Parsing — ( String -- value Bool )
158        "string->int" => (1, 1, "parse failure"),
159        "string->float" => (1, 1, "parse failure"),
160
161        // Channels
162        "chan.send" => (2, 0, "send failure"),
163        "chan.receive" => (1, 1, "receive failure"),
164
165        // Map/List lookups
166        "map.get" => (2, 1, "key not found"),
167        "list.get" => (2, 1, "index out of bounds"),
168        "list.set" => (3, 1, "index out of bounds"),
169
170        // TCP
171        "tcp.listen" => (1, 1, "listen failure"),
172        "tcp.accept" => (1, 1, "accept failure"),
173        "tcp.read" => (1, 1, "read failure"),
174        "tcp.write" => (2, 0, "write failure"),
175        "tcp.close" => (1, 0, "close failure"),
176
177        // OS
178        "os.getenv" => (1, 1, "env var not set"),
179        "os.home-dir" => (0, 1, "home dir not available"),
180        "os.current-dir" => (0, 1, "current dir not available"),
181        "os.path-parent" => (1, 1, "no parent path"),
182        "os.path-filename" => (1, 1, "no filename"),
183
184        // Regex
185        "regex.find" => (2, 1, "no match or invalid regex"),
186        "regex.find-all" => (2, 1, "invalid regex"),
187        "regex.replace" => (3, 1, "invalid regex"),
188        "regex.replace-all" => (3, 1, "invalid regex"),
189        "regex.captures" => (2, 1, "invalid regex"),
190        "regex.split" => (2, 1, "invalid regex"),
191
192        // Encoding
193        "encoding.base64-decode" => (1, 1, "invalid base64"),
194        "encoding.base64url-decode" => (1, 1, "invalid base64url"),
195        "encoding.hex-decode" => (1, 1, "invalid hex"),
196
197        // Crypto
198        "crypto.aes-gcm-encrypt" => (2, 1, "encryption failure"),
199        "crypto.aes-gcm-decrypt" => (2, 1, "decryption failure"),
200        "crypto.pbkdf2-sha256" => (3, 1, "key derivation failure"),
201        "crypto.ed25519-sign" => (2, 1, "signing failure"),
202
203        // Compression
204        "compress.gzip" => (1, 1, "compression failure"),
205        "compress.gzip-level" => (2, 1, "compression failure"),
206        "compress.gunzip" => (1, 1, "decompression failure"),
207        "compress.zstd" => (1, 1, "compression failure"),
208        "compress.zstd-level" => (2, 1, "compression failure"),
209        "compress.unzstd" => (1, 1, "decompression failure"),
210
211        _ => return None,
212    };
213    Some(FallibleOpInfo {
214        inputs,
215        values_before_bool,
216        description,
217    })
218}
219
220/// Words that consume a Bool as an error-checking mechanism
221fn is_checking_consumer(name: &str) -> bool {
222    // `if` is handled structurally (it's a Statement::If, not a WordCall)
223    // `cond` consumes Bools as conditions
224    name == "cond"
225}
226
227/// Analyzer for unchecked error flags
228pub struct ErrorFlagAnalyzer {
229    file: PathBuf,
230    diagnostics: Vec<LintDiagnostic>,
231}
232
233impl ErrorFlagAnalyzer {
234    pub fn new(file: &Path) -> Self {
235        ErrorFlagAnalyzer {
236            file: file.to_path_buf(),
237            diagnostics: Vec::new(),
238        }
239    }
240
241    pub fn analyze_program(&mut self, program: &Program) -> Vec<LintDiagnostic> {
242        let mut all_diagnostics = Vec::new();
243        for word in &program.words {
244            // Skip words with seq:allow(unchecked-error-flag)
245            if word
246                .allowed_lints
247                .iter()
248                .any(|l| l == "unchecked-error-flag")
249            {
250                continue;
251            }
252            let diags = self.analyze_word(word);
253            all_diagnostics.extend(diags);
254        }
255        all_diagnostics
256    }
257
258    fn analyze_word(&mut self, word: &WordDef) -> Vec<LintDiagnostic> {
259        self.diagnostics.clear();
260        let mut state = FlagStack::new();
261        self.analyze_statements(&word.body, &mut state, word);
262        // Flags remaining on stack at word end = returned to caller (escape)
263        std::mem::take(&mut self.diagnostics)
264    }
265
266    fn analyze_statements(
267        &mut self,
268        statements: &[Statement],
269        state: &mut FlagStack,
270        word: &WordDef,
271    ) {
272        for stmt in statements {
273            self.analyze_statement(stmt, state, word);
274        }
275    }
276
277    fn analyze_statement(&mut self, stmt: &Statement, state: &mut FlagStack, word: &WordDef) {
278        match stmt {
279            Statement::IntLiteral(_)
280            | Statement::FloatLiteral(_)
281            | Statement::BoolLiteral(_)
282            | Statement::StringLiteral(_)
283            | Statement::Symbol(_) => {
284                state.push_other();
285            }
286
287            Statement::Quotation { .. } => {
288                state.push_other();
289            }
290
291            Statement::WordCall { name, span } => {
292                self.analyze_word_call(name, span.as_ref(), state, word);
293            }
294
295            Statement::If {
296                then_branch,
297                else_branch,
298                span: _,
299            } => {
300                // `if` consumes the Bool on top — this IS a check
301                state.pop();
302
303                let mut then_state = state.clone();
304                let mut else_state = state.clone();
305                self.analyze_statements(then_branch, &mut then_state, word);
306                if let Some(else_stmts) = else_branch {
307                    self.analyze_statements(else_stmts, &mut else_state, word);
308                }
309                *state = then_state.join(&else_state);
310            }
311
312            Statement::Match { arms, span: _ } => {
313                state.pop(); // match value consumed
314                let mut arm_states: Vec<FlagStack> = Vec::new();
315                for arm in arms {
316                    let mut arm_state = state.clone();
317                    // Match arm bindings push values onto stack
318                    match &arm.pattern {
319                        crate::ast::Pattern::Variant(_) => {
320                            // Variant without named bindings — field count unknown
321                            // statically. Same limitation as resource_lint.
322                        }
323                        crate::ast::Pattern::VariantWithBindings { bindings, .. } => {
324                            for _binding in bindings {
325                                arm_state.push_other();
326                            }
327                        }
328                    }
329                    self.analyze_statements(&arm.body, &mut arm_state, word);
330                    arm_states.push(arm_state);
331                }
332                if let Some(joined) = arm_states.into_iter().reduce(|acc, s| acc.join(&s)) {
333                    *state = joined;
334                }
335            }
336        }
337    }
338
339    fn analyze_word_call(
340        &mut self,
341        name: &str,
342        span: Option<&Span>,
343        state: &mut FlagStack,
344        word: &WordDef,
345    ) {
346        let line = span.map(|s| s.line).unwrap_or(0);
347
348        // Check if this is a fallible operation
349        if let Some(info) = fallible_op_info(name) {
350            // Pop inputs consumed by the operation
351            for _ in 0..info.inputs {
352                state.pop();
353            }
354            // Push output values, then the error flag Bool
355            for _ in 0..info.values_before_bool {
356                state.push_other();
357            }
358            state.push_flag(line, name, info.description);
359            return;
360        }
361
362        // Check if this is a checking consumer
363        if is_checking_consumer(name) {
364            // `cond` is a multi-way conditional that consumes quotation pairs
365            // + a count from the stack. Its variable arity means we can't
366            // precisely model what it consumes. Conservative: assume it
367            // checks any flags it touches (no warning), but don't clear
368            // the entire stack — flags below the cond args may still need checking.
369            state.pop(); // at minimum, the count argument
370            return;
371        }
372
373        // Stack operations — simulate movement
374        match name {
375            "drop" => {
376                if let Some(StackVal::Flag(flag)) = state.pop() {
377                    self.emit_warning(&flag, line, word);
378                }
379            }
380            "nip" => {
381                // ( a b -- b ) — drops a (second from top)
382                let top = state.pop();
383                if let Some(StackVal::Flag(flag)) = state.pop() {
384                    self.emit_warning(&flag, line, word);
385                }
386                if let Some(v) = top {
387                    state.stack.push(v);
388                }
389            }
390            "3drop" => {
391                for _ in 0..3 {
392                    if let Some(StackVal::Flag(flag)) = state.pop() {
393                        self.emit_warning(&flag, line, word);
394                    }
395                }
396            }
397            "2drop" => {
398                for _ in 0..2 {
399                    if let Some(StackVal::Flag(flag)) = state.pop() {
400                        self.emit_warning(&flag, line, word);
401                    }
402                }
403            }
404            "dup" => {
405                if let Some(top) = state.stack.last().cloned() {
406                    state.stack.push(top);
407                }
408            }
409            "swap" => {
410                let a = state.pop();
411                let b = state.pop();
412                if let Some(v) = a {
413                    state.stack.push(v);
414                }
415                if let Some(v) = b {
416                    state.stack.push(v);
417                }
418            }
419            "over" => {
420                if state.depth() >= 2 {
421                    let second = state.stack[state.depth() - 2].clone();
422                    state.stack.push(second);
423                }
424            }
425            "rot" => {
426                let c = state.pop();
427                let b = state.pop();
428                let a = state.pop();
429                if let Some(v) = b {
430                    state.stack.push(v);
431                }
432                if let Some(v) = c {
433                    state.stack.push(v);
434                }
435                if let Some(v) = a {
436                    state.stack.push(v);
437                }
438            }
439            "tuck" => {
440                let b = state.pop();
441                let a = state.pop();
442                if let Some(v) = b.clone() {
443                    state.stack.push(v);
444                }
445                if let Some(v) = a {
446                    state.stack.push(v);
447                }
448                if let Some(v) = b {
449                    state.stack.push(v);
450                }
451            }
452            "2dup" => {
453                if state.depth() >= 2 {
454                    let a = state.stack[state.depth() - 2].clone();
455                    let b = state.stack[state.depth() - 1].clone();
456                    state.stack.push(a);
457                    state.stack.push(b);
458                }
459            }
460            ">aux" => {
461                if let Some(v) = state.pop() {
462                    state.aux.push(v);
463                }
464            }
465            "aux>" => {
466                if let Some(v) = state.aux.pop() {
467                    state.stack.push(v);
468                }
469            }
470            "pick" | "roll" => {
471                // Conservative: push unknown (can't statically know depth)
472                state.push_other();
473            }
474
475            // Combinators — dip hides top, runs quotation, restores
476            "dip" => {
477                // ( x quot -- ? x ) — pop quot, pop x, run quot (unknown effect), push x
478                state.pop(); // quotation
479                let preserved = state.pop();
480                // Quotation effect unknown — conservatively clear flags from stack
481                // (quotation might check them, might not)
482                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
483                if let Some(v) = preserved {
484                    state.stack.push(v);
485                }
486            }
487            "keep" => {
488                // ( x quot -- ? x ) — similar to dip but quotation gets x
489                state.pop(); // quotation
490                let preserved = state.pop();
491                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
492                if let Some(v) = preserved {
493                    state.stack.push(v);
494                }
495            }
496            "bi" => {
497                // ( x q1 q2 -- ? ) — two quotations consume x
498                state.pop(); // q2
499                state.pop(); // q1
500                state.pop(); // x
501                // Both quotations have unknown effects
502                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
503            }
504
505            // call — quotation effect unknown, conservatively assume it checks
506            "call" => {
507                state.pop(); // quotation
508                // Conservative: clear tracked flags (quotation might do anything)
509                state.stack.retain(|v| !matches!(v, StackVal::Flag(_)));
510            }
511
512            // Known type-conversion words that consume one value and push one
513            "int->string" | "int->float" | "float->int" | "float->string" | "char->string"
514            | "symbol->string" | "string->symbol" => {
515                // These consume the top value. If it's a flag, that's suspicious
516                // but not necessarily wrong (e.g., converting a Bool to string for display).
517                // Conservative: don't warn, just remove tracking.
518                state.pop();
519                state.push_other();
520            }
521
522            // Boolean operations that legitimately consume Bools
523            "and" | "or" | "not" => {
524                // These consume Bool(s) and produce Bool — not a check per se,
525                // but the user is clearly working with the Bool value.
526                // Conservative: mark as consumed (no warning).
527                state.pop();
528                if name != "not" {
529                    state.pop();
530                }
531                state.push_other();
532            }
533
534            // Test assertions that check Bools
535            "test.assert" | "test.assert-not" => {
536                state.pop(); // Bool consumed by assertion = checked
537            }
538
539            // All other words: conservative — assume they consume/produce
540            // unknown values. Pop any flags without warning (might be checked
541            // inside the word).
542            _ => {
543                // For unknown words, we don't know the stack effect.
544                // Conservative: leave the stack as-is (don't warn, don't clear).
545                // This avoids false positives from user-defined words that
546                // properly handle the Bool internally.
547            }
548        }
549    }
550
551    fn emit_warning(&mut self, flag: &ErrorFlag, drop_line: usize, word: &WordDef) {
552        // Don't warn if the drop is adjacent to the operation (within 2 lines).
553        // Adjacent drops like `tcp.write drop` are covered by the pattern-based
554        // linter with better precision (exact column info, replacement suggestions).
555        // We only add value for non-adjacent drops (e.g., swap nip, aux round-trips).
556        // Note: if spans are missing, both lines default to 0 and this suppresses
557        // the warning — acceptable since span-less nodes are rare (synthetic AST only).
558        if drop_line <= flag.created_line + 2 {
559            return;
560        }
561
562        self.diagnostics.push(LintDiagnostic {
563            id: "unchecked-error-flag".to_string(),
564            message: format!(
565                "`{}` returns a Bool error flag (indicates {}) — dropped without checking",
566                flag.operation, flag.description,
567            ),
568            severity: Severity::Warning,
569            replacement: String::new(),
570            file: self.file.clone(),
571            line: flag.created_line,
572            end_line: Some(drop_line),
573            start_column: None,
574            end_column: None,
575            word_name: word.name.clone(),
576            start_index: 0,
577            end_index: 0,
578        });
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use crate::ast::{Statement, WordDef};
586    use crate::types::{Effect, StackType};
587
588    fn make_word(name: &str, body: Vec<Statement>) -> WordDef {
589        WordDef {
590            name: name.to_string(),
591            effect: Some(Effect::new(StackType::Empty, StackType::Empty)),
592            body,
593            source: None,
594            allowed_lints: vec![],
595        }
596    }
597
598    fn word_call(name: &str, line: usize) -> Statement {
599        Statement::WordCall {
600            name: name.to_string(),
601            span: Some(Span {
602                line,
603                column: 0,
604                length: 1,
605            }),
606        }
607    }
608
609    #[test]
610    fn test_adjacent_drop_not_flagged() {
611        // file.slurp drop — same line, pattern linter handles this
612        let word = make_word(
613            "test",
614            vec![
615                Statement::StringLiteral("foo".to_string()),
616                word_call("file.slurp", 1),
617                word_call("drop", 1),
618            ],
619        );
620        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
621        let diags = analyzer.analyze_word(&word);
622        assert!(
623            diags.is_empty(),
624            "Adjacent drop should be left to pattern linter"
625        );
626    }
627
628    #[test]
629    fn test_non_adjacent_drop_flagged() {
630        // file.slurp swap nip — swap puts Bool below String, nip drops Bool
631        // Stack: (String Bool) → swap → (Bool String) → nip → (String)
632        // Bool was nipped without checking (lines spread apart)
633        let word = make_word(
634            "test",
635            vec![
636                Statement::StringLiteral("foo".to_string()),
637                word_call("file.slurp", 1),
638                word_call("swap", 5),
639                word_call("nip", 10),
640            ],
641        );
642        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
643        let diags = analyzer.analyze_word(&word);
644        assert_eq!(diags.len(), 1);
645        assert_eq!(diags[0].id, "unchecked-error-flag");
646        assert!(diags[0].message.contains("file.slurp"));
647    }
648
649    #[test]
650    fn test_checked_by_if() {
651        // file.slurp if ... then — Bool checked
652        let word = make_word(
653            "test",
654            vec![
655                Statement::StringLiteral("foo".to_string()),
656                word_call("file.slurp", 1),
657                Statement::If {
658                    then_branch: vec![word_call("io.write-line", 3)],
659                    else_branch: Some(vec![word_call("drop", 5)]),
660                    span: Some(Span {
661                        line: 2,
662                        column: 0,
663                        length: 2,
664                    }),
665                },
666            ],
667        );
668        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
669        let diags = analyzer.analyze_word(&word);
670        assert!(diags.is_empty(), "Bool checked by if should not warn");
671    }
672
673    #[test]
674    fn test_aux_round_trip_drop() {
675        // file.slurp >aux ... aux> drop — Bool stashed and dropped
676        let word = make_word(
677            "test",
678            vec![
679                Statement::StringLiteral("foo".to_string()),
680                word_call("file.slurp", 1),
681                word_call(">aux", 5),
682                Statement::StringLiteral("other work".to_string()),
683                word_call("drop", 8),
684                word_call("aux>", 12),
685                word_call("drop", 15),
686            ],
687        );
688        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
689        let diags = analyzer.analyze_word(&word);
690        assert_eq!(diags.len(), 1);
691        assert!(diags[0].message.contains("file.slurp"));
692    }
693
694    #[test]
695    fn test_division_checked() {
696        // 10 0 i./ if ... then — division result checked
697        let word = make_word(
698            "test",
699            vec![
700                Statement::IntLiteral(10),
701                Statement::IntLiteral(0),
702                word_call("i./", 1),
703                Statement::If {
704                    then_branch: vec![],
705                    else_branch: Some(vec![word_call("drop", 3)]),
706                    span: Some(Span {
707                        line: 2,
708                        column: 0,
709                        length: 2,
710                    }),
711                },
712            ],
713        );
714        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
715        let diags = analyzer.analyze_word(&word);
716        assert!(diags.is_empty());
717    }
718
719    #[test]
720    fn test_nip_preserves_flag_on_top() {
721        // string->int produces (Int Bool). nip drops Int, keeps Bool on top.
722        // Bool is still on stack (returned = escape). No warning.
723        let word = make_word(
724            "test",
725            vec![
726                Statement::StringLiteral("42".to_string()),
727                word_call("string->int", 1),
728                word_call("nip", 2),
729            ],
730        );
731        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
732        let diags = analyzer.analyze_word(&word);
733        assert!(diags.is_empty(), "nip keeps Bool on top — no warning");
734    }
735
736    #[test]
737    fn test_swap_nip_drops_flag() {
738        // string->int swap nip — swap puts Bool below Int, nip drops Bool
739        let word = make_word(
740            "test",
741            vec![
742                Statement::StringLiteral("42".to_string()),
743                word_call("string->int", 1),
744                word_call("swap", 5),
745                word_call("nip", 10),
746            ],
747        );
748        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
749        let diags = analyzer.analyze_word(&word);
750        assert_eq!(diags.len(), 1);
751        assert!(diags[0].message.contains("string->int"));
752    }
753
754    #[test]
755    fn test_allow_suppresses_warning() {
756        // seq:allow(unchecked-error-flag) should suppress the warning
757        let word = WordDef {
758            name: "test".to_string(),
759            effect: Some(Effect::new(StackType::Empty, StackType::Empty)),
760            body: vec![
761                Statement::StringLiteral("foo".to_string()),
762                word_call("file.slurp", 1),
763                word_call("swap", 5),
764                word_call("nip", 10),
765            ],
766            source: None,
767            allowed_lints: vec!["unchecked-error-flag".to_string()],
768        };
769        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
770        let program = crate::ast::Program {
771            includes: vec![],
772            unions: vec![],
773            words: vec![word],
774        };
775        let diags = analyzer.analyze_program(&program);
776        assert!(diags.is_empty(), "seq:allow should suppress warning");
777    }
778
779    #[test]
780    fn test_multiple_flags_both_dropped() {
781        // Two fallible calls, both flags dropped non-adjacently
782        let word = make_word(
783            "test",
784            vec![
785                Statement::StringLiteral("foo".to_string()),
786                word_call("file.slurp", 1),   // pushes (String, Flag)
787                word_call("swap", 5),         // (Flag, String)
788                word_call("nip", 10),         // drops Flag #1
789                word_call("string->int", 15), // pushes (Int, Flag)
790                word_call("swap", 20),
791                word_call("nip", 25), // drops Flag #2
792            ],
793        );
794        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
795        let diags = analyzer.analyze_word(&word);
796        assert_eq!(diags.len(), 2, "Both flags should produce warnings");
797    }
798
799    #[test]
800    fn test_dip_clears_flags_no_false_positive() {
801        // dip runs a quotation with unknown effects — flags on the
802        // pre-dip stack are conservatively cleared (no false positive)
803        let word = make_word(
804            "test",
805            vec![
806                Statement::StringLiteral("foo".to_string()),
807                word_call("file.slurp", 1), // (String, Flag)
808                Statement::Quotation {
809                    id: 0,
810                    body: vec![word_call("drop", 5)],
811                    span: None,
812                },
813                word_call("dip", 10),
814            ],
815        );
816        let mut analyzer = ErrorFlagAnalyzer::new(Path::new("test.seq"));
817        let diags = analyzer.analyze_word(&word);
818        assert!(
819            diags.is_empty(),
820            "dip conservatively clears flags — no false positive"
821        );
822    }
823}