Skip to main content

stryke/
static_analysis.rs

1//! Static analysis pass for detecting undefined variables and subroutines.
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7use crate::ast::{
8    Block, DerefKind, Expr, ExprKind, MatchArrayElem, Program, Sigil, Statement, StmtKind,
9    StringPart, SubSigParam,
10};
11use crate::error::{ErrorKind, StrykeError, StrykeResult};
12
13static BUILTINS: OnceLock<HashSet<&'static str>> = OnceLock::new();
14
15fn builtins() -> &'static HashSet<&'static str> {
16    BUILTINS.get_or_init(|| {
17        include_str!("lsp_completion_words.txt")
18            .lines()
19            .map(str::trim)
20            .filter(|l| !l.is_empty() && !l.starts_with('#'))
21            .collect()
22    })
23}
24
25#[derive(Default)]
26struct Scope {
27    scalars: HashSet<String>,
28    arrays: HashSet<String>,
29    hashes: HashSet<String>,
30    subs: HashSet<String>,
31    /// Scalar → Type name when the scalar's initializer is a known
32    /// constructor expression (`Point(x=>1)`, `Point->new(x=>1)`).
33    /// Drives the `$obj->method` typo-catch in MethodCall analysis.
34    scalar_types: HashMap<String, String>,
35}
36
37impl Scope {
38    fn declare_scalar(&mut self, name: &str) {
39        self.scalars.insert(name.to_string());
40    }
41    fn declare_array(&mut self, name: &str) {
42        self.arrays.insert(name.to_string());
43    }
44    fn declare_hash(&mut self, name: &str) {
45        self.hashes.insert(name.to_string());
46    }
47    fn declare_sub(&mut self, name: &str) {
48        self.subs.insert(name.to_string());
49    }
50}
51
52pub struct StaticAnalyzer {
53    scopes: Vec<Scope>,
54    errors: Vec<StrykeError>,
55    file: String,
56    current_package: String,
57    /// When `false` (the `stryke check` default), strict-vars-style
58    /// "Global symbol \"$x\" requires explicit package name" errors are
59    /// suppressed — `stryke check` is a parse / compile gate, not a
60    /// strict-vars enforcer. Set to `true` only when the source itself
61    /// has `use strict;` (or `use strict 'vars';`), in which case we
62    /// emit so the analyzer surfaces the same diagnostics the runtime
63    /// would. Topic vars (`$_0`, `@_1`, …) and special vars (`$_`,
64    /// `@ARGV`, `%ENV`, …) stay exempt regardless.
65    strict_vars: bool,
66    /// Canonicalized paths of files already walked for `require` so the
67    /// declaration sweep doesn't loop on `require A` / `require B` /
68    /// `require A` cycles.
69    seen_required_files: HashSet<PathBuf>,
70    /// Per-type field-name sets. Populated during the declaration
71    /// sweep so that calls like `Point->new(x => 10, yyg => 20)` can
72    /// be checked against the known fields of `Point` — `yyg` is
73    /// not a field, so the linter emits a diagnostic.
74    type_fields: HashMap<String, HashSet<String>>,
75    /// Per-type method-name sets — used together with `type_fields`
76    /// for the `$self->X` check inside class/struct method bodies.
77    /// A `$self->method_or_field` access is valid only when the name
78    /// is either a field of the enclosing class or a method on it.
79    type_methods: HashMap<String, HashSet<String>>,
80    /// Per-type parent list — `class Dog extends Animal, Trainable`
81    /// records `Dog → [Animal, Trainable]`. Drives the inherited-
82    /// method lookup so `$self->trail` resolves to `Animal::trail`
83    /// instead of being flagged as unknown on `Dog`.
84    type_parents: HashMap<String, Vec<String>>,
85    /// The class/struct being analyzed inside a method body. `None`
86    /// outside any type's body. Used to resolve `$self->X` against
87    /// the right type's fields + methods.
88    current_class: Option<String>,
89}
90
91impl StaticAnalyzer {
92    pub fn new(file: &str) -> Self {
93        Self::with_strict_vars(file, false)
94    }
95
96    pub fn with_strict_vars(file: &str, strict_vars: bool) -> Self {
97        let mut global = Scope::default();
98        for name in ["_", "a", "b", "ARGV", "ENV", "SIG", "INC"] {
99            global.declare_array(name);
100        }
101        for name in ["ENV", "SIG", "INC"] {
102            global.declare_hash(name);
103        }
104        for name in [
105            "_", "a", "b", "!", "$", "@", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "&",
106            "`", "'", "+", ".", "/", "\\", "|", "%", "=", "-", "~", "^", "*", "?", "\"",
107        ] {
108            global.declare_scalar(name);
109        }
110        Self {
111            scopes: vec![global],
112            errors: Vec::new(),
113            file: file.to_string(),
114            current_package: "main".to_string(),
115            strict_vars,
116            seen_required_files: HashSet::new(),
117            type_fields: HashMap::new(),
118            type_methods: HashMap::new(),
119            type_parents: HashMap::new(),
120            current_class: None,
121        }
122    }
123
124    fn push_scope(&mut self) {
125        self.scopes.push(Scope::default());
126    }
127
128    fn pop_scope(&mut self) {
129        if self.scopes.len() > 1 {
130            self.scopes.pop();
131        }
132    }
133
134    fn declare_scalar(&mut self, name: &str) {
135        if let Some(scope) = self.scopes.last_mut() {
136            scope.declare_scalar(name);
137        }
138    }
139
140    fn declare_array(&mut self, name: &str) {
141        if let Some(scope) = self.scopes.last_mut() {
142            scope.declare_array(name);
143        }
144    }
145
146    fn declare_hash(&mut self, name: &str) {
147        if let Some(scope) = self.scopes.last_mut() {
148            scope.declare_hash(name);
149        }
150    }
151
152    fn declare_sub(&mut self, name: &str) {
153        if let Some(scope) = self.scopes.first_mut() {
154            scope.declare_sub(name);
155        }
156    }
157
158    /// Record that scalar `$name` was bound to an instance of `type`.
159    /// Stored on the innermost active scope so nested blocks shadow
160    /// outer bindings the same way variable scoping already works.
161    fn declare_scalar_type(&mut self, name: &str, ty: &str) {
162        if let Some(scope) = self.scopes.last_mut() {
163            scope.scalar_types.insert(name.to_string(), ty.to_string());
164        }
165    }
166
167    /// Walk scopes outer-to-inner. Returns the Type name `$name` was
168    /// last bound to via a known constructor, if any.
169    fn resolve_scalar_type(&self, name: &str) -> Option<&str> {
170        for s in self.scopes.iter().rev() {
171            if let Some(t) = s.scalar_types.get(name) {
172                return Some(t.as_str());
173            }
174        }
175        None
176    }
177
178    /// If `init` is `Type(...)` or `Type->new(...)` AND the bare tail
179    /// resolves to a declared Type, return that type name. The lint
180    /// only fires when the file declares the Type — same gating rule
181    /// already used by `check_constructor_keys` callers.
182    fn infer_constructor_type(&self, init: &Expr) -> Option<String> {
183        match &init.kind {
184            ExprKind::FuncCall { name, .. } => {
185                let bare = name.rsplit("::").next().unwrap_or(name);
186                if self.type_fields.contains_key(name) {
187                    return Some(name.clone());
188                }
189                if self.type_fields.contains_key(bare) {
190                    return Some(bare.to_string());
191                }
192                None
193            }
194            ExprKind::MethodCall { object, method, .. } if method == "new" => {
195                if let ExprKind::Bareword(n) = &object.kind {
196                    let bare = n.rsplit("::").next().unwrap_or(n);
197                    if self.type_fields.contains_key(n) {
198                        return Some(n.clone());
199                    }
200                    if self.type_fields.contains_key(bare) {
201                        return Some(bare.to_string());
202                    }
203                }
204                None
205            }
206            _ => None,
207        }
208    }
209
210    fn is_scalar_defined(&self, name: &str) -> bool {
211        if is_special_var(name) || is_topic_var(name) {
212            return true;
213        }
214        self.scopes.iter().rev().any(|s| s.scalars.contains(name))
215    }
216
217    fn is_array_defined(&self, name: &str) -> bool {
218        if is_special_var(name) || is_topic_var(name) {
219            return true;
220        }
221        self.scopes.iter().rev().any(|s| s.arrays.contains(name))
222    }
223
224    fn is_hash_defined(&self, name: &str) -> bool {
225        if is_special_var(name) || is_topic_var(name) {
226            return true;
227        }
228        self.scopes.iter().rev().any(|s| s.hashes.contains(name))
229    }
230
231    fn is_sub_defined(&self, name: &str) -> bool {
232        // Late static binding: static::method() is always valid (runtime-resolved)
233        if name.starts_with("static::") {
234            return true;
235        }
236        // Compiler-generated calls emitted by parser desugaring — not
237        // in the user-facing `%b` builtin set but always valid. Keep
238        // this list literal — every entry must correspond to a real
239        // dispatch arm in `builtins.rs`.
240        if matches!(
241            name,
242            "_thread_par_run"
243                | "__stryke_rust_compile"
244                | "defer__internal"
245                // Parser-level constructor specials — handled by
246                // compiler.rs / vm_helper.rs as if they were built-in,
247                // but don't register through the normal `%b` path.
248                | "deque",
249        ) {
250            return true;
251        }
252        let base = name.rsplit("::").next().unwrap_or(name);
253        if builtins().contains(base) {
254            return true;
255        }
256        self.scopes
257            .iter()
258            .rev()
259            .any(|s| s.subs.contains(name) || s.subs.contains(base))
260    }
261
262    fn error(&mut self, kind: ErrorKind, msg: String, line: usize) {
263        self.errors
264            .push(StrykeError::new(kind, msg, line, &self.file));
265    }
266
267    pub fn analyze(mut self, program: &Program) -> StrykeResult<()> {
268        for stmt in &program.statements {
269            self.collect_declarations_stmt(stmt);
270        }
271        for stmt in &program.statements {
272            self.analyze_stmt(stmt);
273        }
274        if let Some(e) = self.errors.into_iter().next() {
275            Err(e)
276        } else {
277            Ok(())
278        }
279    }
280
281    fn collect_declarations_stmt(&mut self, stmt: &Statement) {
282        match &stmt.kind {
283            StmtKind::Package { name } => {
284                self.current_package = name.clone();
285            }
286            StmtKind::SubDecl { name, .. } => {
287                let fqn = if name.contains("::") {
288                    name.clone()
289                } else {
290                    format!("{}::{}", self.current_package, name)
291                };
292                self.declare_sub(name);
293                self.declare_sub(&fqn);
294            }
295            StmtKind::Use { module, imports } => {
296                self.declare_sub(module);
297                // `use constant NAME => …`, `use constant { A => 1, B => 2 }`,
298                // and `use constant NAME => (1, 2, 3)` install one sub per
299                // NAME. Recognize those name slots so the linter can resolve
300                // the constants the program references later.
301                if module == "constant" {
302                    self.collect_use_constant_names(imports);
303                }
304            }
305            // `require "./lib/foo.stk"` / `require Foo::Bar` — parse the
306            // pulled-in file and register its sub declarations so callers
307            // like `Project::Foo::bar()` are not flagged as undefined.
308            // Postfix-modifier `require … if COND;` lowers to a
309            // `PostfixIf`-wrapped expression inside an Expression statement,
310            // so this walker hits the inner Require either way.
311            StmtKind::Expression(e) => self.collect_required_subs_from_expr(e),
312            StmtKind::Block(b)
313            | StmtKind::StmtGroup(b)
314            | StmtKind::Begin(b)
315            | StmtKind::End(b)
316            | StmtKind::UnitCheck(b)
317            | StmtKind::Check(b)
318            | StmtKind::Init(b) => {
319                for s in b {
320                    self.collect_declarations_stmt(s);
321                }
322            }
323            StmtKind::If {
324                body,
325                elsifs,
326                else_block,
327                ..
328            } => {
329                for s in body {
330                    self.collect_declarations_stmt(s);
331                }
332                for (_, b) in elsifs {
333                    for s in b {
334                        self.collect_declarations_stmt(s);
335                    }
336                }
337                if let Some(b) = else_block {
338                    for s in b {
339                        self.collect_declarations_stmt(s);
340                    }
341                }
342            }
343            StmtKind::ClassDecl { def } => {
344                self.declare_sub(&def.name);
345                for m in &def.methods {
346                    if m.is_static {
347                        self.declare_sub(&format!("{}::{}", def.name, m.name));
348                    }
349                }
350                for sf in &def.static_fields {
351                    self.declare_sub(&format!("{}::{}", def.name, sf.name));
352                }
353                let mut fields: HashSet<String> = HashSet::new();
354                for f in &def.fields {
355                    fields.insert(f.name.clone());
356                }
357                self.type_fields.insert(def.name.clone(), fields);
358                // Instance + static methods for `$self->X` diagnostics.
359                let mut methods: HashSet<String> = HashSet::new();
360                for m in &def.methods {
361                    methods.insert(m.name.clone());
362                }
363                self.type_methods.insert(def.name.clone(), methods);
364                // Parent classes + implemented traits feed the
365                // inheritance/trait walker so `$self->X` resolves via
366                // any reachable type. `class Dog extends Animal impl
367                // Trainable` — both Animal AND Trainable contribute
368                // methods.
369                let mut parents = def.extends.clone();
370                parents.extend(def.implements.iter().cloned());
371                if !parents.is_empty() {
372                    self.type_parents.insert(def.name.clone(), parents);
373                }
374            }
375            StmtKind::StructDecl { def } => {
376                self.declare_sub(&def.name);
377                let mut fields: HashSet<String> = HashSet::new();
378                for f in &def.fields {
379                    fields.insert(f.name.clone());
380                }
381                self.type_fields.insert(def.name.clone(), fields);
382                let mut methods: HashSet<String> = HashSet::new();
383                for m in &def.methods {
384                    methods.insert(m.name.clone());
385                }
386                self.type_methods.insert(def.name.clone(), methods);
387            }
388            StmtKind::EnumDecl { def } => {
389                self.declare_sub(&def.name);
390                for v in &def.variants {
391                    self.declare_sub(&format!("{}::{}", def.name, v.name));
392                }
393                // Variants form the "field" set for diagnostic purposes
394                // (enums don't have fat-comma constructor calls, but
395                // record anyway for completeness).
396                let mut fields: HashSet<String> = HashSet::new();
397                for v in &def.variants {
398                    fields.insert(v.name.clone());
399                }
400                self.type_fields.insert(def.name.clone(), fields);
401            }
402            // Trait declarations contribute their method set so classes
403            // that `impl Trait` can inherit them through the parent
404            // chain. Without this, `Person impl Greetable` with a
405            // default `fn greeting { ... }` on the trait wouldn't
406            // resolve `$p->greeting`.
407            StmtKind::TraitDecl { def } => {
408                self.declare_sub(&def.name);
409                let mut methods: HashSet<String> = HashSet::new();
410                for m in &def.methods {
411                    methods.insert(m.name.clone());
412                }
413                self.type_methods.insert(def.name.clone(), methods);
414                // Empty field set so the type_fields key exists (drives
415                // the constructor-key check + hierarchy walker entry).
416                self.type_fields.entry(def.name.clone()).or_default();
417            }
418            _ => {}
419        }
420    }
421
422    /// Register every NAME slot from a `use constant ...` import list so
423    /// later references parse as defined subs. Handles all three documented
424    /// shapes:
425    ///   use constant NAME => VALUE
426    ///   use constant NAME => (V1, V2, ...)
427    ///   use constant { N1 => V1, N2 => V2, ... }
428    fn collect_use_constant_names(&mut self, imports: &[Expr]) {
429        for imp in imports {
430            match &imp.kind {
431                ExprKind::List(items) => {
432                    let mut i = 0;
433                    while i + 1 < items.len() {
434                        if let Some(name) = static_string_value(&items[i]) {
435                            let fqn = format!("{}::{}", self.current_package, name);
436                            self.declare_sub(&name);
437                            self.declare_sub(&fqn);
438                        }
439                        i += 2;
440                    }
441                }
442                ExprKind::HashRef(pairs) => {
443                    for (k, _) in pairs {
444                        if let Some(name) = static_string_value(k) {
445                            let fqn = format!("{}::{}", self.current_package, name);
446                            self.declare_sub(&name);
447                            self.declare_sub(&fqn);
448                        }
449                    }
450                }
451                _ => {}
452            }
453        }
454    }
455
456    /// Pull sub declarations out of a `require "./path"` / `require Module`
457    /// inside an expression. The analyzer scans the required file's AST so
458    /// callers of imported subs aren't flagged as undefined.
459    ///
460    /// Only static string-literal paths (`require "./lib/foo.stk"`) and
461    /// bareword module specs (`require Foo::Bar`) are followed. Dynamic
462    /// `require $var` is skipped — the analyzer can't know the target.
463    fn collect_required_subs_from_expr(&mut self, expr: &Expr) {
464        match &expr.kind {
465            ExprKind::Require(inner) => {
466                let Some(spec) = static_string_value(inner) else {
467                    return;
468                };
469                self.follow_require(&spec);
470            }
471            ExprKind::PostfixIf { expr: inner, .. }
472            | ExprKind::PostfixUnless { expr: inner, .. } => {
473                self.collect_required_subs_from_expr(inner);
474            }
475            _ => {}
476        }
477    }
478
479    /// Parse the file named by `spec` (relative to the current analyzed
480    /// file when prefixed with `./` / `../`, otherwise treated as a Perl
481    /// module specifier `Foo::Bar` → `Foo/Bar.pm`) and merge every sub
482    /// declaration it contains into the analyzer's scope. Recurses into
483    /// chained `require`s. Silently skips any path that fails to resolve
484    /// or parse — the runtime will surface the same error if it matters.
485    fn follow_require(&mut self, spec: &str) {
486        let spec = spec.trim();
487        if spec.is_empty() {
488            return;
489        }
490        // Pragma-style requires (`require strict;`) install nothing
491        // user-visible; skip cheaply.
492        if matches!(
493            spec,
494            "strict"
495                | "warnings"
496                | "utf8"
497                | "feature"
498                | "v5"
499                | "threads"
500                | "Thread::Pool"
501                | "Parallel::ForkManager"
502        ) {
503            return;
504        }
505        let Some(target) = self.resolve_require_path(spec) else {
506            return;
507        };
508        let canon = target.canonicalize().unwrap_or(target.clone());
509        if !self.seen_required_files.insert(canon.clone()) {
510            return; // already walked
511        }
512        let Ok(src) = std::fs::read_to_string(&target) else {
513            return;
514        };
515        let file_str = target.to_string_lossy().into_owned();
516        let Ok(program) = crate::parse_module_with_file(&src, &file_str) else {
517            return;
518        };
519        // Save and restore the caller's package — required files routinely
520        // contain their own `package …;` declarations that shouldn't leak.
521        let saved_pkg = std::mem::replace(&mut self.current_package, "main".to_string());
522        for stmt in &program.statements {
523            self.collect_declarations_stmt(stmt);
524        }
525        self.current_package = saved_pkg;
526    }
527
528    fn resolve_require_path(&self, spec: &str) -> Option<PathBuf> {
529        resolve_require_path_from_file(&self.file, spec)
530    }
531
532    fn analyze_stmt(&mut self, stmt: &Statement) {
533        match &stmt.kind {
534            StmtKind::Package { name } => {
535                self.current_package = name.clone();
536            }
537            StmtKind::My(decls)
538            | StmtKind::Our(decls)
539            | StmtKind::Local(decls)
540            | StmtKind::State(decls)
541            | StmtKind::MySync(decls)
542            | StmtKind::OurSync(decls) => {
543                // `our` / `oursync` inside `package Pkg` declare a
544                // package-global. References from outside the package
545                // use the qualified form `$Pkg::name` — record both
546                // spellings so strict-vars accepts either.
547                let is_package_global =
548                    matches!(stmt.kind, StmtKind::Our(_) | StmtKind::OurSync(_));
549                for d in decls {
550                    match d.sigil {
551                        Sigil::Scalar => {
552                            self.declare_scalar(&d.name);
553                            if is_package_global {
554                                let q = format!("{}::{}", self.current_package, d.name);
555                                self.declare_scalar(&q);
556                            }
557                        }
558                        Sigil::Array => {
559                            self.declare_array(&d.name);
560                            if is_package_global {
561                                let q = format!("{}::{}", self.current_package, d.name);
562                                self.declare_array(&q);
563                            }
564                        }
565                        Sigil::Hash => {
566                            self.declare_hash(&d.name);
567                            if is_package_global {
568                                let q = format!("{}::{}", self.current_package, d.name);
569                                self.declare_hash(&q);
570                            }
571                        }
572                        Sigil::Typeglob => {}
573                    }
574                    if let Some(init) = &d.initializer {
575                        if matches!(d.sigil, Sigil::Scalar) {
576                            if let Some(ty) = self.infer_constructor_type(init) {
577                                self.declare_scalar_type(&d.name, &ty);
578                            }
579                        }
580                        self.analyze_expr(init);
581                    }
582                }
583            }
584            StmtKind::Expression(e) => self.analyze_expr(e),
585            StmtKind::Return(Some(e)) => self.analyze_expr(e),
586            StmtKind::Return(None) => {}
587            StmtKind::If {
588                condition,
589                body,
590                elsifs,
591                else_block,
592            } => {
593                self.analyze_expr(condition);
594                self.push_scope();
595                self.analyze_block(body);
596                self.pop_scope();
597                for (cond, b) in elsifs {
598                    self.analyze_expr(cond);
599                    self.push_scope();
600                    self.analyze_block(b);
601                    self.pop_scope();
602                }
603                if let Some(b) = else_block {
604                    self.push_scope();
605                    self.analyze_block(b);
606                    self.pop_scope();
607                }
608            }
609            StmtKind::Unless {
610                condition,
611                body,
612                else_block,
613            } => {
614                self.analyze_expr(condition);
615                self.push_scope();
616                self.analyze_block(body);
617                self.pop_scope();
618                if let Some(b) = else_block {
619                    self.push_scope();
620                    self.analyze_block(b);
621                    self.pop_scope();
622                }
623            }
624            StmtKind::While {
625                condition,
626                body,
627                continue_block,
628                ..
629            }
630            | StmtKind::Until {
631                condition,
632                body,
633                continue_block,
634                ..
635            } => {
636                self.analyze_expr(condition);
637                self.push_scope();
638                self.analyze_block(body);
639                if let Some(cb) = continue_block {
640                    self.analyze_block(cb);
641                }
642                self.pop_scope();
643            }
644            StmtKind::DoWhile { body, condition } => {
645                self.push_scope();
646                self.analyze_block(body);
647                self.pop_scope();
648                self.analyze_expr(condition);
649            }
650            StmtKind::For {
651                init,
652                condition,
653                step,
654                body,
655                continue_block,
656                ..
657            } => {
658                self.push_scope();
659                if let Some(i) = init {
660                    self.analyze_stmt(i);
661                }
662                if let Some(c) = condition {
663                    self.analyze_expr(c);
664                }
665                if let Some(s) = step {
666                    self.analyze_expr(s);
667                }
668                self.analyze_block(body);
669                if let Some(cb) = continue_block {
670                    self.analyze_block(cb);
671                }
672                self.pop_scope();
673            }
674            StmtKind::Foreach {
675                var,
676                list,
677                body,
678                continue_block,
679                ..
680            } => {
681                self.analyze_expr(list);
682                self.push_scope();
683                self.declare_scalar(var);
684                self.analyze_block(body);
685                if let Some(cb) = continue_block {
686                    self.analyze_block(cb);
687                }
688                self.pop_scope();
689            }
690            StmtKind::SubDecl {
691                name, params, body, ..
692            } => {
693                let fqn = if name.contains("::") {
694                    name.clone()
695                } else {
696                    format!("{}::{}", self.current_package, name)
697                };
698                self.declare_sub(name);
699                self.declare_sub(&fqn);
700                self.push_scope();
701                for p in params {
702                    self.declare_param(p);
703                }
704                self.analyze_block(body);
705                self.pop_scope();
706            }
707            StmtKind::Block(b)
708            | StmtKind::StmtGroup(b)
709            | StmtKind::Begin(b)
710            | StmtKind::End(b)
711            | StmtKind::UnitCheck(b)
712            | StmtKind::Check(b)
713            | StmtKind::Init(b)
714            | StmtKind::Continue(b) => {
715                self.push_scope();
716                self.analyze_block(b);
717                self.pop_scope();
718            }
719            StmtKind::TryCatch {
720                try_block,
721                catch_var,
722                catch_block,
723                finally_block,
724            } => {
725                self.push_scope();
726                self.analyze_block(try_block);
727                self.pop_scope();
728                self.push_scope();
729                self.declare_scalar(catch_var);
730                self.analyze_block(catch_block);
731                self.pop_scope();
732                if let Some(fb) = finally_block {
733                    self.push_scope();
734                    self.analyze_block(fb);
735                    self.pop_scope();
736                }
737            }
738            StmtKind::EvalTimeout { body, .. } => {
739                self.push_scope();
740                self.analyze_block(body);
741                self.pop_scope();
742            }
743            StmtKind::Given { topic, body } => {
744                self.analyze_expr(topic);
745                self.push_scope();
746                self.analyze_block(body);
747                self.pop_scope();
748            }
749            StmtKind::When { cond, body } => {
750                self.analyze_expr(cond);
751                self.push_scope();
752                self.analyze_block(body);
753                self.pop_scope();
754            }
755            StmtKind::DefaultCase { body } => {
756                self.push_scope();
757                self.analyze_block(body);
758                self.pop_scope();
759            }
760            StmtKind::LocalExpr {
761                target,
762                initializer,
763            } => {
764                self.analyze_expr(target);
765                if let Some(init) = initializer {
766                    self.analyze_expr(init);
767                }
768            }
769            StmtKind::Goto { target } => {
770                self.analyze_expr(target);
771            }
772            StmtKind::Tie { class, args, .. } => {
773                self.analyze_expr(class);
774                for a in args {
775                    self.analyze_expr(a);
776                }
777            }
778            StmtKind::Use { imports, .. } | StmtKind::No { imports, .. } => {
779                for e in imports {
780                    self.analyze_expr(e);
781                }
782            }
783            StmtKind::StructDecl { def } => {
784                // Walk struct methods with `current_class` set so
785                // `$self->X` body references resolve against this
786                // struct's fields + methods.
787                let prev = self.current_class.take();
788                self.current_class = Some(def.name.clone());
789                for m in &def.methods {
790                    self.push_scope();
791                    self.declare_scalar("self");
792                    for p in &m.params {
793                        self.declare_param(p);
794                    }
795                    self.analyze_block(&m.body);
796                    self.pop_scope();
797                }
798                self.current_class = prev;
799            }
800            StmtKind::ClassDecl { def } => {
801                let prev = self.current_class.take();
802                self.current_class = Some(def.name.clone());
803                for m in &def.methods {
804                    if let Some(body) = &m.body {
805                        self.push_scope();
806                        self.declare_scalar("self");
807                        for p in &m.params {
808                            self.declare_param(p);
809                        }
810                        self.analyze_block(body);
811                        self.pop_scope();
812                    }
813                }
814                self.current_class = prev;
815            }
816            StmtKind::EnumDecl { .. }
817            | StmtKind::TraitDecl { .. }
818            | StmtKind::FormatDecl { .. }
819            | StmtKind::AdviceDecl { .. }
820            | StmtKind::UsePerlVersion { .. }
821            | StmtKind::UseOverload { .. }
822            | StmtKind::Last(_)
823            | StmtKind::Next(_)
824            | StmtKind::Redo(_)
825            | StmtKind::Empty => {}
826        }
827    }
828
829    fn declare_param(&mut self, param: &SubSigParam) {
830        match param {
831            SubSigParam::Scalar(name, _, _) => self.declare_scalar(name),
832            SubSigParam::Array(name, _) => self.declare_array(name),
833            SubSigParam::Hash(name, _) => self.declare_hash(name),
834            SubSigParam::ArrayDestruct(elems) => {
835                for e in elems {
836                    match e {
837                        MatchArrayElem::CaptureScalar(n) => self.declare_scalar(n),
838                        MatchArrayElem::RestBind(n) => self.declare_array(n),
839                        _ => {}
840                    }
841                }
842            }
843            SubSigParam::HashDestruct(pairs) => {
844                for (_, name) in pairs {
845                    self.declare_scalar(name);
846                }
847            }
848        }
849    }
850
851    fn analyze_block(&mut self, block: &Block) {
852        for stmt in block {
853            self.analyze_stmt(stmt);
854        }
855    }
856
857    /// Check `Type(field => value, …)` / `Type->new(field => value, …)`
858    /// constructor calls: emit a diagnostic for each fat-comma key
859    /// that doesn't match a declared field of `type_name`. No-op when
860    /// `type_name` isn't a known Type (lets ordinary FuncCalls
861    /// through untouched).
862    fn check_constructor_keys(&mut self, type_name: &str, args: &[Expr], call_line: usize) {
863        let bare_tail = type_name.rsplit("::").next().unwrap_or(type_name);
864        // Pick the resolved class name (qualified or bare). Bail if the
865        // file doesn't declare any Type by this name.
866        let resolved = if self.type_fields.contains_key(type_name) {
867            type_name.to_string()
868        } else if self.type_fields.contains_key(bare_tail) {
869            bare_tail.to_string()
870        } else {
871            return;
872        };
873        // Walk class + parents via `extends`, unioning every declared
874        // field into one set. `Dog extends Animal { name }` + `Dog
875        // { breed }` accepts `Dog(name => ..., breed => ...)`.
876        let mut all_fields: HashSet<String> = HashSet::new();
877        let mut seen: HashSet<String> = HashSet::new();
878        let mut queue: Vec<String> = vec![resolved.clone()];
879        while let Some(c) = queue.pop() {
880            if !seen.insert(c.clone()) {
881                continue;
882            }
883            if let Some(fs) = self.type_fields.get(&c) {
884                all_fields.extend(fs.iter().cloned());
885            }
886            if let Some(parents) = self.type_parents.get(&c) {
887                queue.extend(parents.iter().cloned());
888            }
889        }
890        // Detect call style: fat-comma keyed (`Type(k => v, k => v)`)
891        // vs positional (`Type(1, "title", Priority::High)`). Stryke
892        // collapses fat-comma to plain args at parse time, so the AST
893        // shapes are identical. Disambiguate by checking whether the
894        // FIRST arg matches a declared field name — if not, the call
895        // is positional and the key check would only generate noise.
896        let first_arg_is_field = args.first().is_some_and(|a| match &a.kind {
897            ExprKind::String(s) | ExprKind::Bareword(s) => all_fields.contains(s),
898            _ => false,
899        });
900        let looks_keyed = args.len().is_multiple_of(2)
901            && first_arg_is_field
902            && (0..args.len()).step_by(2).all(|i| {
903                matches!(&args[i].kind, ExprKind::String(_))
904                    || matches!(&args[i].kind, ExprKind::Bareword(s) if !s.contains("::"))
905            });
906        if !looks_keyed {
907            return;
908        }
909        let mut i = 0;
910        while i < args.len() {
911            let key_name = match &args[i].kind {
912                ExprKind::String(s) => Some(s.clone()),
913                ExprKind::Bareword(s) => Some(s.clone()),
914                _ => None,
915            };
916            if let Some(name) = key_name {
917                if !all_fields.contains(&name) {
918                    let line = if args[i].line > 0 {
919                        args[i].line
920                    } else {
921                        call_line
922                    };
923                    let bare_for_msg = type_name.rsplit("::").next().unwrap_or(type_name);
924                    self.error(
925                        ErrorKind::UndefinedSubroutine,
926                        format!(
927                            "Unknown field `{name}` in constructor call to `{bare_for_msg}` — \
928                             declared fields: {}",
929                            {
930                                let mut v: Vec<&String> = all_fields.iter().collect();
931                                v.sort();
932                                v.into_iter()
933                                    .map(String::as_str)
934                                    .collect::<Vec<_>>()
935                                    .join(", ")
936                            }
937                        ),
938                        line,
939                    );
940                }
941            }
942            i += 2;
943        }
944    }
945
946    /// True when `method` is a field OR method declared on `class_name`
947    /// OR any ancestor in the `extends` chain. Cycle-guarded so a
948    /// pathological `A extends B; B extends A` doesn't recur forever.
949    fn method_resolves_in_hierarchy(&self, class_name: &str, method: &str) -> bool {
950        // Universal methods inherited from `UNIVERSAL` (Perl) / every
951        // stryke object. Sourced from `vm_helper.rs` built-in class +
952        // struct method dispatch — keep in sync when new universal
953        // methods are added.
954        //
955        // - `isa` / `can` / `DOES` / `does` / `VERSION` — Perl UNIVERSAL.
956        // - `new` / `BUILD` / `DESTROY` / `destroy` — lifecycle hooks.
957        // - `clone` — deep copy via per-field deep_clone.
958        // - `with` — functional update returning a new instance with
959        //   the named fields changed.
960        // - `to_hash` / `to_hash_rec` / `to_hash_deep` — serialize.
961        // - `fields` / `methods` / `superclass` — runtime introspection.
962        if matches!(
963            method,
964            "isa"
965                | "can"
966                | "DOES"
967                | "does"
968                | "VERSION"
969                | "new"
970                | "BUILD"
971                | "DESTROY"
972                | "destroy"
973                | "clone"
974                | "with"
975                | "to_hash"
976                | "to_hash_rec"
977                | "to_hash_deep"
978                | "fields"
979                | "methods"
980                | "superclass"
981        ) {
982            return true;
983        }
984        let mut seen: HashSet<String> = HashSet::new();
985        let mut queue: Vec<String> = vec![class_name.to_string()];
986        while let Some(c) = queue.pop() {
987            if !seen.insert(c.clone()) {
988                continue;
989            }
990            if self.type_fields.get(&c).is_some_and(|s| s.contains(method)) {
991                return true;
992            }
993            if self
994                .type_methods
995                .get(&c)
996                .is_some_and(|s| s.contains(method))
997            {
998                return true;
999            }
1000            if let Some(parents) = self.type_parents.get(&c) {
1001                queue.extend(parents.iter().cloned());
1002            }
1003        }
1004        false
1005    }
1006
1007    /// Gather every field + method name visible on `class_name` and its
1008    /// ancestors (BFS through `extends`). Used to render the "available:
1009    /// …" suggestion list when a `$self->X` / `$obj->X` lookup fails.
1010    fn collect_hierarchy_members(&self, class_name: &str) -> Vec<String> {
1011        let mut seen: HashSet<String> = HashSet::new();
1012        let mut out: HashSet<String> = HashSet::new();
1013        let mut queue: Vec<String> = vec![class_name.to_string()];
1014        while let Some(c) = queue.pop() {
1015            if !seen.insert(c.clone()) {
1016                continue;
1017            }
1018            if let Some(fs) = self.type_fields.get(&c) {
1019                out.extend(fs.iter().cloned());
1020            }
1021            if let Some(ms) = self.type_methods.get(&c) {
1022                out.extend(ms.iter().cloned());
1023            }
1024            if let Some(parents) = self.type_parents.get(&c) {
1025                queue.extend(parents.iter().cloned());
1026            }
1027        }
1028        let mut v: Vec<String> = out.into_iter().collect();
1029        v.sort();
1030        v
1031    }
1032
1033    fn analyze_expr(&mut self, expr: &Expr) {
1034        match &expr.kind {
1035            // `$#name` — the Perl last-index-of-array form. The parser
1036            // surfaces it as `ScalarVar("#name")`; resolve to the
1037            // underlying `@name` so a defined array satisfies the
1038            // check. Bare `$#` (no name) is the magic "last index of
1039            // $_" form — always defined.
1040            ExprKind::ScalarVar(name)
1041                if self.strict_vars
1042                    && name.len() > 1
1043                    && name.starts_with('#')
1044                    && !self.is_array_defined(&name[1..]) =>
1045            {
1046                self.error(
1047                    ErrorKind::UndefinedVariable,
1048                    format!(
1049                        "Global symbol \"@{}\" requires explicit package name",
1050                        &name[1..]
1051                    ),
1052                    expr.line,
1053                );
1054            }
1055            ExprKind::ScalarVar(name) if name.starts_with('#') => {
1056                // `$#name` with @name defined OR bare `$#` — no-op.
1057            }
1058            ExprKind::ScalarVar(name) if self.strict_vars && !self.is_scalar_defined(name) => {
1059                self.error(
1060                    ErrorKind::UndefinedVariable,
1061                    format!("Global symbol \"${}\" requires explicit package name", name),
1062                    expr.line,
1063                );
1064            }
1065            ExprKind::ArrayVar(name) if self.strict_vars && !self.is_array_defined(name) => {
1066                self.error(
1067                    ErrorKind::UndefinedVariable,
1068                    format!("Global symbol \"@{}\" requires explicit package name", name),
1069                    expr.line,
1070                );
1071            }
1072            ExprKind::HashVar(name) if self.strict_vars && !self.is_hash_defined(name) => {
1073                self.error(
1074                    ErrorKind::UndefinedVariable,
1075                    format!("Global symbol \"%{}\" requires explicit package name", name),
1076                    expr.line,
1077                );
1078            }
1079            ExprKind::ArrayElement { array, index } => {
1080                if self.strict_vars
1081                    && !self.is_array_defined(array)
1082                    && !self.is_scalar_defined(array)
1083                {
1084                    self.error(
1085                        ErrorKind::UndefinedVariable,
1086                        format!(
1087                            "Global symbol \"@{}\" requires explicit package name",
1088                            array
1089                        ),
1090                        expr.line,
1091                    );
1092                }
1093                self.analyze_expr(index);
1094            }
1095            ExprKind::HashElement { hash, key } => {
1096                if self.strict_vars && !self.is_hash_defined(hash) && !self.is_scalar_defined(hash)
1097                {
1098                    self.error(
1099                        ErrorKind::UndefinedVariable,
1100                        format!("Global symbol \"%{}\" requires explicit package name", hash),
1101                        expr.line,
1102                    );
1103                }
1104                self.analyze_expr(key);
1105            }
1106            ExprKind::ArraySlice { array, indices } => {
1107                if self.strict_vars && !self.is_array_defined(array) {
1108                    self.error(
1109                        ErrorKind::UndefinedVariable,
1110                        format!(
1111                            "Global symbol \"@{}\" requires explicit package name",
1112                            array
1113                        ),
1114                        expr.line,
1115                    );
1116                }
1117                for i in indices {
1118                    self.analyze_expr(i);
1119                }
1120            }
1121            ExprKind::HashSlice { hash, keys } => {
1122                if self.strict_vars && !self.is_hash_defined(hash) {
1123                    self.error(
1124                        ErrorKind::UndefinedVariable,
1125                        format!("Global symbol \"%{}\" requires explicit package name", hash),
1126                        expr.line,
1127                    );
1128                }
1129                for k in keys {
1130                    self.analyze_expr(k);
1131                }
1132            }
1133            ExprKind::FuncCall { name, args } => {
1134                if !self.is_sub_defined(name) {
1135                    self.error(
1136                        ErrorKind::UndefinedSubroutine,
1137                        format!("Undefined subroutine &{}", name),
1138                        expr.line,
1139                    );
1140                }
1141                // Constructor-call form `Type(field => value)` — check
1142                // each fat-comma key against the Type's known fields.
1143                self.check_constructor_keys(name, args, expr.line);
1144                for a in args {
1145                    self.analyze_expr(a);
1146                }
1147            }
1148            ExprKind::MethodCall {
1149                object,
1150                method,
1151                args,
1152                ..
1153            } => {
1154                self.analyze_expr(object);
1155                // Method-form constructor `Type->new(field => value)`.
1156                if let ExprKind::Bareword(n) = &object.kind {
1157                    self.check_constructor_keys(n, args, expr.line);
1158                    // Only flag the receiver when the file actually
1159                    // declares some local Types — that's the "user
1160                    // is doing OOP here" signal that justifies typo-
1161                    // catching. Without local Types, every Bareword
1162                    // receiver (`Foo->new`, `IO::Handle->open`, etc.)
1163                    // would be false-positively flagged.
1164                    if !self.type_fields.is_empty()
1165                        && !self.type_fields.contains_key(n)
1166                        && !self.is_sub_defined(n)
1167                    {
1168                        let bare = n.rsplit("::").next().unwrap_or(n);
1169                        if !bare.is_empty()
1170                            && bare.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1171                        {
1172                            self.error(
1173                                ErrorKind::UndefinedSubroutine,
1174                                format!(
1175                                    "Unknown class `{n}` — `{n}->{method}` calls a constructor on a type that isn't declared in this file or its `require`d libs"
1176                                ),
1177                                expr.line,
1178                            );
1179                        }
1180                    }
1181                }
1182                // `$obj->X` when `$obj` was bound to a known Type via
1183                // a constructor expression (`my $p = Point(...)` or
1184                // `my $p = Point->new(...)`). Symmetric to the
1185                // `$self->X` check below — both walk the type's
1186                // field+method sets and flag unknown names.
1187                if let ExprKind::ScalarVar(name) = &object.kind {
1188                    if name != "self" {
1189                        if let Some(class_name) =
1190                            self.resolve_scalar_type(name).map(|s| s.to_string())
1191                        {
1192                            if !self.method_resolves_in_hierarchy(&class_name, method) {
1193                                let suggestions =
1194                                    self.collect_hierarchy_members(&class_name);
1195                                let avail = if suggestions.is_empty() {
1196                                    "(no fields or methods declared)".to_string()
1197                                } else {
1198                                    suggestions.join(", ")
1199                                };
1200                                self.error(
1201                                    ErrorKind::UndefinedSubroutine,
1202                                    format!(
1203                                        "`${name}->{method}` — no field or method `{method}` on `{class_name}`; available: {avail}",
1204                                    ),
1205                                    expr.line,
1206                                );
1207                            }
1208                        }
1209                    }
1210                }
1211                // `$self->X` inside a class/struct body — `X` must
1212                // be a field or method of the enclosing type.
1213                if let ExprKind::ScalarVar(name) = &object.kind {
1214                    if name == "self" {
1215                        if let Some(class_name) = self.current_class.clone() {
1216                            // Walk class + parents via `extends`. Cycle-
1217                            // guarded so a broken `extends A; A extends X`
1218                            // loop can't infinite-loop the linter.
1219                            if !self.method_resolves_in_hierarchy(&class_name, method) {
1220                                let suggestions =
1221                                    self.collect_hierarchy_members(&class_name);
1222                                let avail = if suggestions.is_empty() {
1223                                    "(no fields or methods declared)".to_string()
1224                                } else {
1225                                    suggestions.join(", ")
1226                                };
1227                                self.error(
1228                                    ErrorKind::UndefinedSubroutine,
1229                                    format!(
1230                                        "`$self->{method}` — no field or method `{method}` on `{class_name}`; available: {avail}",
1231                                    ),
1232                                    expr.line,
1233                                );
1234                            }
1235                        }
1236                    }
1237                }
1238                for a in args {
1239                    self.analyze_expr(a);
1240                }
1241            }
1242            ExprKind::IndirectCall { target, args, .. } => {
1243                self.analyze_expr(target);
1244                for a in args {
1245                    self.analyze_expr(a);
1246                }
1247            }
1248            ExprKind::BinOp { left, right, .. } => {
1249                self.analyze_expr(left);
1250                self.analyze_expr(right);
1251            }
1252            ExprKind::UnaryOp { expr: e, .. } => {
1253                self.analyze_expr(e);
1254            }
1255            ExprKind::PostfixOp { expr: e, .. } => {
1256                self.analyze_expr(e);
1257            }
1258            ExprKind::Assign { target, value } => {
1259                if let ExprKind::ScalarVar(name) = &target.kind {
1260                    self.declare_scalar(name);
1261                } else if let ExprKind::ArrayVar(name) = &target.kind {
1262                    self.declare_array(name);
1263                } else if let ExprKind::HashVar(name) = &target.kind {
1264                    self.declare_hash(name);
1265                } else {
1266                    self.analyze_expr(target);
1267                }
1268                self.analyze_expr(value);
1269            }
1270            ExprKind::CompoundAssign { target, value, .. } => {
1271                self.analyze_expr(target);
1272                self.analyze_expr(value);
1273            }
1274            ExprKind::Ternary {
1275                condition,
1276                then_expr,
1277                else_expr,
1278            } => {
1279                self.analyze_expr(condition);
1280                self.analyze_expr(then_expr);
1281                self.analyze_expr(else_expr);
1282            }
1283            ExprKind::List(exprs) | ExprKind::ArrayRef(exprs) => {
1284                for e in exprs {
1285                    self.analyze_expr(e);
1286                }
1287            }
1288            ExprKind::HashRef(pairs) => {
1289                for (k, v) in pairs {
1290                    self.analyze_expr(k);
1291                    self.analyze_expr(v);
1292                }
1293            }
1294            ExprKind::CodeRef { params, body } => {
1295                self.push_scope();
1296                for p in params {
1297                    self.declare_param(p);
1298                }
1299                self.analyze_block(body);
1300                self.pop_scope();
1301            }
1302            ExprKind::ScalarRef(e)
1303            | ExprKind::Deref { expr: e, .. }
1304            | ExprKind::Defined(e)
1305            | ExprKind::Delete(e) => {
1306                self.analyze_expr(e);
1307            }
1308            ExprKind::Exists(e)
1309                // `exists &SUB` and `exists &Pkg::sub` are introspection
1310                // calls — the point is to check whether the sub IS
1311                // defined, so flagging an "undefined" sub here is the
1312                // opposite of helpful. Skip the sub-defined check for
1313                // `SubroutineCodeRef` / `SubroutineRef` payloads;
1314                // everything else (hash keys, array indices) still gets
1315                // the normal analysis.
1316                if !matches!(
1317                    e.kind,
1318                    ExprKind::SubroutineCodeRef(_) | ExprKind::SubroutineRef(_)
1319                ) => {
1320                    self.analyze_expr(e);
1321                }
1322            ExprKind::ArrowDeref { expr, index, kind } => {
1323                self.analyze_expr(expr);
1324                if *kind != DerefKind::Call {
1325                    self.analyze_expr(index);
1326                }
1327            }
1328            ExprKind::Range { from, to, step, .. } => {
1329                self.analyze_expr(from);
1330                self.analyze_expr(to);
1331                if let Some(s) = step {
1332                    self.analyze_expr(s);
1333                }
1334            }
1335            ExprKind::SliceRange { from, to, step } => {
1336                if let Some(f) = from {
1337                    self.analyze_expr(f);
1338                }
1339                if let Some(t) = to {
1340                    self.analyze_expr(t);
1341                }
1342                if let Some(s) = step {
1343                    self.analyze_expr(s);
1344                }
1345            }
1346            ExprKind::InterpolatedString(parts) => {
1347                // Strict-vars policy inside double-quoted interpolations:
1348                // DON'T flag bare `$undef` / `@undef` / `%undef`. Strings
1349                // are commonly used as test descriptions / log messages
1350                // with template-style placeholders that the user doesn't
1351                // intend as hard variable references. Bare `$x + 1` in
1352                // code-context still gets flagged; the string interior
1353                // gets a free pass. Full `#{ EXPR }` blocks (complex
1354                // expressions wrapped in Expr) DO get walked, since
1355                // those are real code — unless the entire expression
1356                // is just a single sigil-var, in which case the same
1357                // pass-through policy applies.
1358                for part in parts {
1359                    match part {
1360                        StringPart::Expr(e) => {
1361                            // Skip the strict-vars check for the simple
1362                            // sigil-var-only shape (`$var` / `@var` / `%var`
1363                            // wrapped in Expr by the parser). For richer
1364                            // expressions (`$x + 1`, fn calls, etc.) walk
1365                            // normally.
1366                            match &e.kind {
1367                                ExprKind::ScalarVar(_)
1368                                | ExprKind::ArrayVar(_)
1369                                | ExprKind::HashVar(_) => {}
1370                                _ => self.analyze_expr(e),
1371                            }
1372                        }
1373                        StringPart::ScalarVar(_)
1374                        | StringPart::ArrayVar(_)
1375                        | StringPart::Literal(_) => {}
1376                    }
1377                }
1378            }
1379            ExprKind::Regex(_, _)
1380            | ExprKind::Substitution { .. }
1381            | ExprKind::Transliterate { .. }
1382            | ExprKind::Match { .. } => {}
1383            ExprKind::HashSliceDeref { container, keys } => {
1384                self.analyze_expr(container);
1385                for k in keys {
1386                    self.analyze_expr(k);
1387                }
1388            }
1389            ExprKind::AnonymousListSlice { source, indices } => {
1390                self.analyze_expr(source);
1391                for i in indices {
1392                    self.analyze_expr(i);
1393                }
1394            }
1395            ExprKind::SubroutineRef(name) | ExprKind::SubroutineCodeRef(name)
1396                if !self.is_sub_defined(name) =>
1397            {
1398                self.error(
1399                    ErrorKind::UndefinedSubroutine,
1400                    format!("Undefined subroutine &{}", name),
1401                    expr.line,
1402                );
1403            }
1404            ExprKind::DynamicSubCodeRef(e) => self.analyze_expr(e),
1405            ExprKind::PostfixIf { expr, condition }
1406            | ExprKind::PostfixUnless { expr, condition }
1407            | ExprKind::PostfixWhile { expr, condition }
1408            | ExprKind::PostfixUntil { expr, condition } => {
1409                self.analyze_expr(expr);
1410                self.analyze_expr(condition);
1411            }
1412            ExprKind::PostfixForeach { expr, list } => {
1413                self.analyze_expr(list);
1414                self.analyze_expr(expr);
1415            }
1416            ExprKind::Do(e) | ExprKind::Eval(e) => {
1417                self.analyze_expr(e);
1418            }
1419            ExprKind::Caller(Some(e)) => {
1420                self.analyze_expr(e);
1421            }
1422            ExprKind::Length(e) => {
1423                self.analyze_expr(e);
1424            }
1425            ExprKind::Print { args, .. }
1426            | ExprKind::Say { args, .. }
1427            | ExprKind::Printf { args, .. } => {
1428                for a in args {
1429                    self.analyze_expr(a);
1430                }
1431            }
1432            ExprKind::Die(args)
1433            | ExprKind::Warn(args)
1434            | ExprKind::Unlink(args)
1435            | ExprKind::Chmod(args)
1436            | ExprKind::System(args)
1437            | ExprKind::Exec(args) => {
1438                for a in args {
1439                    self.analyze_expr(a);
1440                }
1441            }
1442            ExprKind::Push { array, values } | ExprKind::Unshift { array, values } => {
1443                self.analyze_expr(array);
1444                for v in values {
1445                    self.analyze_expr(v);
1446                }
1447            }
1448            ExprKind::Splice {
1449                array,
1450                offset,
1451                length,
1452                replacement,
1453            } => {
1454                self.analyze_expr(array);
1455                if let Some(o) = offset {
1456                    self.analyze_expr(o);
1457                }
1458                if let Some(l) = length {
1459                    self.analyze_expr(l);
1460                }
1461                for r in replacement {
1462                    self.analyze_expr(r);
1463                }
1464            }
1465            ExprKind::MapExpr { block, list, .. } | ExprKind::GrepExpr { block, list, .. } => {
1466                self.push_scope();
1467                self.analyze_block(block);
1468                self.pop_scope();
1469                self.analyze_expr(list);
1470            }
1471            ExprKind::SortExpr { list, .. } => {
1472                self.analyze_expr(list);
1473            }
1474            ExprKind::Open { handle, mode, file } => {
1475                // `open(my $fh, ">", $path)` — `my $fh` is the lexical-
1476                // filehandle declaration Perl idiom. Register the name
1477                // so later `print $fh ...` / `close $fh` lookups pass.
1478                if let ExprKind::OpenMyHandle { name } = &handle.kind {
1479                    self.declare_scalar(name);
1480                } else {
1481                    self.analyze_expr(handle);
1482                }
1483                self.analyze_expr(mode);
1484                if let Some(f) = file {
1485                    self.analyze_expr(f);
1486                }
1487            }
1488            ExprKind::Close(e)
1489            | ExprKind::Pop(e)
1490            | ExprKind::Shift(e)
1491            | ExprKind::Keys(e)
1492            | ExprKind::Values(e)
1493            | ExprKind::Each(e)
1494            | ExprKind::Chdir(e)
1495            | ExprKind::Require(e)
1496            | ExprKind::Ref(e)
1497            | ExprKind::Chomp(e)
1498            | ExprKind::Chop(e)
1499            | ExprKind::Lc(e)
1500            | ExprKind::Uc(e)
1501            | ExprKind::Lcfirst(e)
1502            | ExprKind::Ucfirst(e)
1503            | ExprKind::Abs(e)
1504            | ExprKind::Int(e)
1505            | ExprKind::Sqrt(e)
1506            | ExprKind::Sin(e)
1507            | ExprKind::Cos(e)
1508            | ExprKind::Exp(e)
1509            | ExprKind::Log(e)
1510            | ExprKind::Chr(e)
1511            | ExprKind::Ord(e)
1512            | ExprKind::Hex(e)
1513            | ExprKind::Oct(e)
1514            | ExprKind::Readlink(e)
1515            | ExprKind::Readdir(e)
1516            | ExprKind::Closedir(e)
1517            | ExprKind::Rewinddir(e)
1518            | ExprKind::Telldir(e) => {
1519                self.analyze_expr(e);
1520            }
1521            ExprKind::Exit(Some(e)) | ExprKind::Rand(Some(e)) | ExprKind::Eof(Some(e)) => {
1522                self.analyze_expr(e);
1523            }
1524            ExprKind::Mkdir { path, mode } => {
1525                self.analyze_expr(path);
1526                if let Some(m) = mode {
1527                    self.analyze_expr(m);
1528                }
1529            }
1530            ExprKind::Rename { old, new }
1531            | ExprKind::Link { old, new }
1532            | ExprKind::Symlink { old, new } => {
1533                self.analyze_expr(old);
1534                self.analyze_expr(new);
1535            }
1536            ExprKind::Chown(files) => {
1537                for f in files {
1538                    self.analyze_expr(f);
1539                }
1540            }
1541            ExprKind::Substr {
1542                string,
1543                offset,
1544                length,
1545                replacement,
1546            } => {
1547                self.analyze_expr(string);
1548                self.analyze_expr(offset);
1549                if let Some(l) = length {
1550                    self.analyze_expr(l);
1551                }
1552                if let Some(r) = replacement {
1553                    self.analyze_expr(r);
1554                }
1555            }
1556            ExprKind::Index {
1557                string,
1558                substr,
1559                position,
1560            }
1561            | ExprKind::Rindex {
1562                string,
1563                substr,
1564                position,
1565            } => {
1566                self.analyze_expr(string);
1567                self.analyze_expr(substr);
1568                if let Some(p) = position {
1569                    self.analyze_expr(p);
1570                }
1571            }
1572            ExprKind::Sprintf { format, args } => {
1573                self.analyze_expr(format);
1574                for a in args {
1575                    self.analyze_expr(a);
1576                }
1577            }
1578            ExprKind::Bless { ref_expr, class } => {
1579                self.analyze_expr(ref_expr);
1580                if let Some(c) = class {
1581                    self.analyze_expr(c);
1582                }
1583            }
1584            ExprKind::AlgebraicMatch { subject, arms } => {
1585                self.analyze_expr(subject);
1586                for arm in arms {
1587                    self.check_match_pattern(&arm.pattern, expr.line);
1588                    if let Some(g) = &arm.guard {
1589                        self.analyze_expr(g);
1590                    }
1591                    self.analyze_expr(&arm.body);
1592                }
1593            }
1594            _ => {}
1595        }
1596    }
1597
1598    /// Walk a match arm pattern. The only shape that gets a typo check
1599    /// is `MatchPattern::Value(ExprKind::String("Type::Variant"))` —
1600    /// the parser auto-quotes bareword enum patterns into String, so
1601    /// `Sig::Hup => "..."` arrives here as a String literal, not a
1602    /// FuncCall (which would have already been linted as undefined sub).
1603    /// Other pattern shapes (Regex, Array, Hash, Any, OptionSome) walk
1604    /// their inner exprs for ordinary var-defined-ness checks.
1605    fn check_match_pattern(&mut self, pat: &crate::ast::MatchPattern, line: usize) {
1606        use crate::ast::{MatchArrayElem, MatchHashPair, MatchPattern};
1607        match pat {
1608            MatchPattern::Value(e) => {
1609                if let ExprKind::String(s) = &e.kind {
1610                    self.check_qualified_variant_string(s, line);
1611                }
1612                self.analyze_expr(e);
1613            }
1614            MatchPattern::Array(elems) => {
1615                for el in elems {
1616                    if let MatchArrayElem::Expr(e) = el {
1617                        self.analyze_expr(e);
1618                    }
1619                }
1620            }
1621            MatchPattern::Hash(pairs) => {
1622                for p in pairs {
1623                    match p {
1624                        MatchHashPair::KeyOnly { key } => self.analyze_expr(key),
1625                        MatchHashPair::Capture { key, .. } => self.analyze_expr(key),
1626                    }
1627                }
1628            }
1629            MatchPattern::Any | MatchPattern::Regex { .. } | MatchPattern::OptionSome(_) => {}
1630        }
1631    }
1632
1633    /// `Sig::Term2` arriving as an auto-quoted match-arm pattern. If
1634    /// `Sig` is a known enum and `Term2` isn't one of its variants,
1635    /// emit a typo-catching diagnostic with the available variants
1636    /// listed. Same shape as the `$obj->method` / `Type->new(field => v)`
1637    /// checks already in place.
1638    fn check_qualified_variant_string(&mut self, s: &str, line: usize) {
1639        let Some(idx) = s.rfind("::") else { return };
1640        let type_name = &s[..idx];
1641        let variant = &s[idx + 2..];
1642        if type_name.is_empty() || variant.is_empty() {
1643            return;
1644        }
1645        let type_bare = type_name.rsplit("::").next().unwrap_or(type_name);
1646        let known = self
1647            .type_fields
1648            .get(type_name)
1649            .or_else(|| self.type_fields.get(type_bare));
1650        let Some(variants) = known else { return };
1651        if variants.contains(variant) {
1652            return;
1653        }
1654        let mut available: Vec<&str> = variants.iter().map(String::as_str).collect();
1655        available.sort();
1656        let avail = if available.is_empty() {
1657            "(no variants declared)".to_string()
1658        } else {
1659            available.join(", ")
1660        };
1661        self.error(
1662            ErrorKind::UndefinedSubroutine,
1663            format!(
1664                "`{type_name}::{variant}` — no variant `{variant}` on `{type_name}`; available: {avail}"
1665            ),
1666            line,
1667        );
1668    }
1669}
1670
1671fn is_special_var(name: &str) -> bool {
1672    if name.len() == 1 {
1673        return true;
1674    }
1675    // Perl `^X`-style special vars: `$^X` (interpreter path),
1676    // `$^O` (OS), `$^V` (version), `$^W` (warnings), `$^T`, `$^R`,
1677    // `$^N`, `$^H`, `$^I`, `$^L`, `$^A`, `$^C`, `$^B`, `$^D`,
1678    // `$^E`, `$^F`, `$^G`, `$^M`, `$^P`, `$^S`, `$^U`, plus the
1679    // `$^{NAME}` long forms (`$^{MATCH}`, `$^{POSTMATCH}`, etc.).
1680    if name.starts_with('^') {
1681        return true;
1682    }
1683    // `$$` — process id. Parser stores it with the sigil included
1684    // (`ScalarVar("$$")`), so the strict-vars check sees a 2-char
1685    // name. Same shape for `$)`, `$(`, `$/`, etc. when carried with
1686    // their literal punctuation as the "name" part.
1687    if name == "$$" {
1688        return true;
1689    }
1690    matches!(
1691        name,
1692        "ARGV"
1693            | "ENV"
1694            | "SIG"
1695            | "INC"
1696            | "AUTOLOAD"
1697            | "STDERR"
1698            | "STDIN"
1699            | "STDOUT"
1700            | "DATA"
1701            | "UNIVERSAL"
1702            | "VERSION"
1703            | "ISA"
1704            | "EXPORT"
1705            | "EXPORT_OK"
1706            // AOP advice-body context vars — declared by the VM at the
1707            // entry of every `before`/`after`/`around` body. Visible
1708            // outside an advice block as ordinary globals (cheap, no
1709            // pollution), so always-defined is the right model.
1710            | "INTERCEPT_NAME"
1711            | "INTERCEPT_ARGS"
1712            | "INTERCEPT_RESULT"
1713            | "INTERCEPT_MS"
1714            | "INTERCEPT_US"
1715            // stryke-VERSION qualifiers + meta-constants commonly
1716            // referenced in module headers and never declared.
1717            | "stryke::VERSION"
1718    )
1719}
1720
1721/// Stryke implicit closure-positional slots — `_0`, `_1`, …, `_99`. These
1722/// are auto-bound inside any block that takes positional args (sort
1723/// comparators, reduce blocks, sub bodies, map/grep blocks) and must never
1724/// be flagged as undeclared by `stryke check` regardless of strict mode.
1725/// Mirrors the scalar exemption at `vm_helper.rs:strict_scalar_exempt`,
1726/// extended uniformly to scalar / array / hash sigils — `$_1`, `@_1[0]`,
1727/// `%_1{k}` are all legitimate topic-var spellings.
1728fn is_topic_var(name: &str) -> bool {
1729    // Stryke block-param grammar (sigil already stripped):
1730    //   `_`                — bare topic
1731    //   `_N`               — Nth positional arg
1732    //   `_<<<<<`           — outer-chain, any depth of `<`
1733    //   `_<N`              — indexed-ascent shortcut
1734    //   `_N<<<<<` / `_N<M` — positional + outer chain combined
1735    // Pattern: `_` then (digits? then chevrons? then digits?), with at
1736    // least one chevron OR digit after `_<` to disambiguate from a
1737    // bare `_<` operator pair.
1738    if !name.starts_with('_') {
1739        return false;
1740    }
1741    let rest = &name[1..];
1742    if rest.is_empty() {
1743        return true; // bare `_`
1744    }
1745    let bytes = rest.as_bytes();
1746    let mut i = 0;
1747    // Optional positional digits.
1748    while i < bytes.len() && bytes[i].is_ascii_digit() {
1749        i += 1;
1750    }
1751    let digits_consumed = i;
1752    if i == bytes.len() {
1753        // `_N` — pure positional. Must have at least one digit.
1754        return digits_consumed > 0;
1755    }
1756    // Optional `<...` outer-chain segment.
1757    if bytes[i] != b'<' {
1758        return false;
1759    }
1760    while i < bytes.len() && bytes[i] == b'<' {
1761        i += 1;
1762    }
1763    // Optional trailing digits (indexed-ascent shortcut).
1764    while i < bytes.len() && bytes[i].is_ascii_digit() {
1765        i += 1;
1766    }
1767    i == bytes.len()
1768}
1769
1770/// Map a `require` spec to a filesystem path. `./path` and `../path` resolve
1771/// against the project root derived from the source file's location. Perl
1772/// convention: scripts under `t/` / `tests/` / `test/` / `spec/` / `xt/`
1773/// sit one level below the project root that holds `lib/`. Any other layout:
1774/// the file's own directory IS the project root. One path computation, no
1775/// walking. Bareword `Foo::Bar` becomes `<root>/lib/Foo/Bar.pm`. Absolute
1776/// paths pass through. Shared by the static analyzer's require-follower
1777/// and the LSP go-to-definition cross-file lookup.
1778pub fn resolve_require_path_from_file(file: &str, spec: &str) -> Option<PathBuf> {
1779    let p = Path::new(spec);
1780    if p.is_absolute() {
1781        return p.exists().then(|| p.to_path_buf());
1782    }
1783    let file_dir = Path::new(file)
1784        .parent()
1785        .map(Path::to_path_buf)
1786        .unwrap_or_else(|| PathBuf::from("."));
1787    // Detect project root by walking UP from the source file's
1788    // directory looking for a sibling `lib/`. Typical Perl/CPAN
1789    // layout: project/lib/Foo/Bar.pm with `require "./lib/Foo/Bar.stk"`
1790    // anywhere in the tree resolving back to project/. Falls back to:
1791    // (1) the file's own directory if no ancestor has `lib/`;
1792    // (2) the parent directory when the file sits in `t/tests/test/
1793    // spec/xt` (the classic test layout).
1794    let project_root = find_project_root(&file_dir);
1795    // Also try the file's own directory as a same-dir fallback (e.g.
1796    // siblings in the same folder use `./sibling.stk`).
1797    let candidate_via_root = if spec.starts_with("./") || spec.starts_with("../") {
1798        project_root.join(spec)
1799    } else if spec.contains("::") {
1800        let relpath: PathBuf = PathBuf::from(spec.replace("::", "/")).with_extension("pm");
1801        project_root.join("lib").join(relpath)
1802    } else {
1803        project_root.join(spec)
1804    };
1805    if candidate_via_root.exists() {
1806        return Some(candidate_via_root);
1807    }
1808    // Fallback: resolve `./foo.stk` against the file's own directory
1809    // for the simple sibling-script case.
1810    if spec.starts_with("./") || spec.starts_with("../") {
1811        let direct = file_dir.join(spec);
1812        if direct.exists() {
1813            return Some(direct);
1814        }
1815    }
1816    None
1817}
1818
1819/// Walk UP from `start_dir` looking for an ancestor that has a `lib/`
1820/// subdirectory (typical Perl/CPAN project layout). Returns the
1821/// first such ancestor, falling back to the original directory
1822/// (or its parent when it's `t`/`tests`/`test`/`spec`/`xt`).
1823fn find_project_root(start_dir: &Path) -> PathBuf {
1824    let mut cur = start_dir.to_path_buf();
1825    for _ in 0..16 {
1826        // capped depth — pathological infinite-symlink protection
1827        if cur.join("lib").is_dir() {
1828            return cur;
1829        }
1830        match cur.parent() {
1831            Some(p) if p != cur => cur = p.to_path_buf(),
1832            _ => break,
1833        }
1834    }
1835    match start_dir.file_name().and_then(|s| s.to_str()) {
1836        Some("t") | Some("tests") | Some("test") | Some("spec") | Some("xt") => start_dir
1837            .parent()
1838            .map(Path::to_path_buf)
1839            .unwrap_or_else(|| start_dir.to_path_buf()),
1840        _ => start_dir.to_path_buf(),
1841    }
1842}
1843
1844/// If `e` is a literal string / single-segment interpolated string / bareword,
1845/// return its constant text. Used for `require "LITERAL"` / `require Mod::Name`.
1846/// Returns `None` for any dynamic expression — the analyzer can't follow those.
1847pub fn static_string_value(e: &Expr) -> Option<String> {
1848    match &e.kind {
1849        ExprKind::String(s) => Some(s.clone()),
1850        ExprKind::Bareword(s) => Some(s.clone()),
1851        ExprKind::InterpolatedString(parts) => {
1852            // All parts must be literal text — no variable interpolation.
1853            let mut out = String::new();
1854            for part in parts {
1855                match part {
1856                    StringPart::Literal(s) => out.push_str(s),
1857                    _ => return None,
1858                }
1859            }
1860            Some(out)
1861        }
1862        _ => None,
1863    }
1864}
1865
1866pub fn analyze_program(program: &Program, file: &str) -> StrykeResult<()> {
1867    StaticAnalyzer::new(file).analyze(program)
1868}
1869
1870/// Same as [`analyze_program`] but emits strict-vars-style undefined-symbol
1871/// errors only when the source itself opted into strict (`use strict;`).
1872/// `stryke check` calls this with `strict_vars = false` so the lint pass is
1873/// a parse + compile gate, not a strict-vars enforcer.
1874pub fn analyze_program_with_strict(
1875    program: &Program,
1876    file: &str,
1877    strict_vars: bool,
1878) -> StrykeResult<()> {
1879    StaticAnalyzer::with_strict_vars(file, strict_vars).analyze(program)
1880}
1881
1882#[cfg(test)]
1883mod tests {
1884    use super::*;
1885    use crate::parse_with_file;
1886
1887    /// Test helper: run the analyzer with `strict_vars=true` so the
1888    /// undefined-variable detection paths actually fire. The default
1889    /// `analyze_program` entry point is lenient (parse + compile gate
1890    /// for `stryke check` — strict-vars-style errors are gated on the
1891    /// source actually doing `use strict;`); this helper exercises the
1892    /// strict-on path that the rest of the tests below assume.
1893    fn lint(code: &str) -> StrykeResult<()> {
1894        let prog = parse_with_file(code, "test.stk").expect("parse");
1895        analyze_program_with_strict(&prog, "test.stk", true)
1896    }
1897
1898    #[test]
1899    fn undefined_scalar_detected() {
1900        let r = lint("p $undefined");
1901        assert!(r.is_err());
1902        let e = r.unwrap_err();
1903        assert_eq!(e.kind, ErrorKind::UndefinedVariable);
1904        assert!(e.message.contains("$undefined"));
1905    }
1906
1907    #[test]
1908    fn defined_scalar_ok() {
1909        assert!(lint("my $x = 1; p $x").is_ok());
1910    }
1911
1912    #[test]
1913    fn undefined_sub_detected() {
1914        let r = lint("nonexistent_function()");
1915        assert!(r.is_err());
1916        let e = r.unwrap_err();
1917        assert_eq!(e.kind, ErrorKind::UndefinedSubroutine);
1918        assert!(e.message.contains("nonexistent_function"));
1919    }
1920
1921    #[test]
1922    fn defined_sub_ok() {
1923        assert!(lint("fn foo { 1 } foo()").is_ok());
1924    }
1925
1926    #[test]
1927    fn builtin_sub_ok() {
1928        assert!(lint("p 'hello'").is_ok());
1929        assert!(lint("print 'hello'").is_ok());
1930        assert!(lint("my @x = map { $_ * 2 } 1..3").is_ok());
1931    }
1932
1933    #[test]
1934    fn special_vars_ok() {
1935        assert!(lint("p $_").is_ok());
1936        assert!(lint("p @_").is_ok());
1937        assert!(lint("p $a <=> $b").is_ok());
1938    }
1939
1940    #[test]
1941    fn foreach_var_in_scope() {
1942        assert!(lint("foreach my $i (1..3) { p $i; }").is_ok());
1943    }
1944
1945    #[test]
1946    fn sub_params_in_scope() {
1947        assert!(lint("fn foo($x) { p $x; } foo(1)").is_ok());
1948    }
1949
1950    #[test]
1951    fn assignment_declares_var() {
1952        assert!(lint("$x = 1; p $x").is_ok());
1953    }
1954
1955    #[test]
1956    fn builtin_inc_ok() {
1957        assert!(lint("my $x = 1; inc($x)").is_ok());
1958    }
1959
1960    #[test]
1961    fn builtin_dec_ok() {
1962        assert!(lint("my $x = 1; dec($x)").is_ok());
1963    }
1964
1965    #[test]
1966    fn builtin_rev_ok() {
1967        assert!(lint("my $s = rev 'hello'").is_ok());
1968    }
1969
1970    #[test]
1971    fn builtin_p_alias_for_say_ok() {
1972        assert!(lint("p 'hello'").is_ok());
1973    }
1974
1975    #[test]
1976    fn builtin_t_thread_ok() {
1977        assert!(lint("t 1 inc inc").is_ok());
1978    }
1979
1980    #[test]
1981    fn thread_with_undefined_var_detected() {
1982        let r = lint("t $undefined inc");
1983        assert!(r.is_err());
1984    }
1985
1986    #[test]
1987    fn try_catch_var_in_scope() {
1988        assert!(lint("try { die 'err'; } catch ($e) { p $e; }").is_ok());
1989    }
1990
1991    #[test]
1992    fn interpolated_string_undefined_var() {
1993        // Policy change (post-test_bugs_phase3_pin): bare `$undef`
1994        // inside `"..."` is a free pass. String interpolation is used
1995        // as template/description text in tests + log messages, and
1996        // false positives on `"got $fh ..."` style test descriptions
1997        // were the dominant noise source. Strict-vars on bare code-
1998        // context references is unchanged.
1999        assert!(
2000            lint(r#"p "hello $undefined""#).is_ok(),
2001            "undefined scalar inside string-interp must NOT flag",
2002        );
2003        // The check still fires in code context.
2004        assert!(lint(r#"use strict; p $undefined"#).is_err());
2005    }
2006
2007    #[test]
2008    fn interpolated_string_defined_var_ok() {
2009        assert!(lint(r#"my $x = 1; p "hello $x""#).is_ok());
2010    }
2011
2012    #[test]
2013    fn coderef_params_in_scope() {
2014        assert!(lint("my $f = fn ($x) { p $x; }; $f->(1)").is_ok());
2015    }
2016
2017    #[test]
2018    fn nested_sub_scope() {
2019        assert!(lint("fn wrap { my $x = 1; fn inner { p $x; } }").is_ok());
2020    }
2021
2022    #[test]
2023    fn hash_element_access_ok() {
2024        assert!(lint("my %h = (a => 1); p $h{a}").is_ok());
2025    }
2026
2027    #[test]
2028    fn array_element_access_ok() {
2029        assert!(lint("my @a = (1, 2, 3); p $a[0]").is_ok());
2030    }
2031
2032    #[test]
2033    fn undefined_hash_detected() {
2034        let r = lint("p $undefined_hash{key}");
2035        assert!(r.is_err());
2036    }
2037
2038    #[test]
2039    fn undefined_array_detected() {
2040        let r = lint("p $undefined_array[0]");
2041        assert!(r.is_err());
2042    }
2043
2044    #[test]
2045    fn map_with_topic_ok() {
2046        assert!(lint("my @x = map { $_ * 2 } 1..3").is_ok());
2047    }
2048
2049    #[test]
2050    fn grep_with_topic_ok() {
2051        assert!(lint("my @x = grep { $_ > 1 } 1..3").is_ok());
2052    }
2053
2054    #[test]
2055    fn sort_with_ab_ok() {
2056        assert!(lint("my @x = sort { $a <=> $b } 1..3").is_ok());
2057    }
2058
2059    #[test]
2060    fn ternary_undefined_var_detected() {
2061        let r = lint("my $x = $undefined ? 1 : 0");
2062        assert!(r.is_err());
2063    }
2064
2065    #[test]
2066    fn binop_undefined_var_detected() {
2067        let r = lint("my $x = 1 + $undefined");
2068        assert!(r.is_err());
2069    }
2070
2071    #[test]
2072    fn postfix_if_undefined_detected() {
2073        let r = lint("p 'x' if $undefined");
2074        assert!(r.is_err());
2075    }
2076
2077    #[test]
2078    fn while_loop_var_ok() {
2079        assert!(lint("my $i = 0; while ($i < 10) { p $i; $i++; }").is_ok());
2080    }
2081
2082    #[test]
2083    fn for_loop_init_var_in_scope() {
2084        assert!(lint("for (my $i = 0; $i < 10; $i++) { p $i; }").is_ok());
2085    }
2086
2087    #[test]
2088    fn given_when_ok() {
2089        assert!(lint("my $x = 1; given ($x) { when (1) { p 'one'; } }").is_ok());
2090    }
2091
2092    #[test]
2093    fn arrow_deref_ok() {
2094        assert!(lint("my $h = { a => 1 }; p $h->{a}").is_ok());
2095    }
2096
2097    #[test]
2098    fn method_call_ok() {
2099        assert!(lint("my $obj = bless {}, 'Foo'; $obj->method()").is_ok());
2100    }
2101
2102    #[test]
2103    fn push_builtin_ok() {
2104        assert!(lint("my @a; push @a, 1, 2, 3").is_ok());
2105    }
2106
2107    #[test]
2108    fn splice_builtin_ok() {
2109        assert!(lint("my @a = (1, 2, 3); splice @a, 1, 1, 'x'").is_ok());
2110    }
2111
2112    #[test]
2113    fn substr_builtin_ok() {
2114        assert!(lint("my $s = 'hello'; p substr($s, 0, 2)").is_ok());
2115    }
2116
2117    #[test]
2118    fn sprintf_builtin_ok() {
2119        assert!(lint("my $s = sprintf('%d', 42)").is_ok());
2120    }
2121
2122    #[test]
2123    fn range_ok() {
2124        assert!(lint("my @a = 1..10").is_ok());
2125    }
2126
2127    #[test]
2128    fn qw_ok() {
2129        assert!(lint("my @a = qw(a b c)").is_ok());
2130    }
2131
2132    #[test]
2133    fn regex_ok() {
2134        assert!(lint("my $x = 'hello'; $x =~ /ell/").is_ok());
2135    }
2136
2137    #[test]
2138    fn anonymous_sub_captures_outer_var() {
2139        assert!(lint("my $x = 1; my $f = fn { p $x; }").is_ok());
2140    }
2141
2142    #[test]
2143    fn state_var_ok() {
2144        assert!(lint("fn Test::counter { state $n = 0; $n++; }").is_ok());
2145    }
2146
2147    #[test]
2148    fn our_var_ok() {
2149        assert!(lint("our $VERSION = '1.0'").is_ok());
2150    }
2151
2152    #[test]
2153    fn local_var_ok() {
2154        assert!(lint("local $/ = undef").is_ok());
2155    }
2156
2157    #[test]
2158    fn chained_method_calls_ok() {
2159        assert!(lint("my $x = Foo->new->bar->baz").is_ok());
2160    }
2161
2162    #[test]
2163    fn list_assignment_ok() {
2164        assert!(lint("my ($a, $b, $c) = (1, 2, 3); p $a + $b + $c").is_ok());
2165    }
2166
2167    #[test]
2168    fn hash_slice_ok() {
2169        assert!(lint("my %h = (a => 1, b => 2); my @v = @h{qw(a b)}").is_ok());
2170    }
2171
2172    #[test]
2173    fn array_slice_ok() {
2174        assert!(lint("my @a = (1, 2, 3, 4); my @b = @a[0, 2]").is_ok());
2175    }
2176
2177    #[test]
2178    fn instance_method_on_typed_var_flags_unknown_method() {
2179        // `my $p = Point(...)` binds `$p` to Point. `$p->dgdd()` is an
2180        // unknown method/field on Point and must be flagged the same
2181        // way `Point->new(dgdd => ...)` and `$self->dgdd` already are.
2182        let r = lint(
2183            "class Point { x : Float\n y : Float\n fn mag_sq { 1 } }\n\
2184             my $p = Point(x => 3, y => 4)\n\
2185             $p->dgdd()",
2186        );
2187        assert!(r.is_err(), "expected error for `$$p->dgdd`");
2188        let e = r.unwrap_err();
2189        assert_eq!(e.kind, ErrorKind::UndefinedSubroutine);
2190        assert!(
2191            e.message.contains("dgdd") && e.message.contains("Point"),
2192            "expected message to name `dgdd` and `Point`, got: {}",
2193            e.message,
2194        );
2195    }
2196
2197    #[test]
2198    fn instance_method_on_typed_var_known_method_ok() {
2199        // Same setup, but `$p->mag_sq` IS a declared method — must pass.
2200        assert!(lint(
2201            "class Point { x : Float\n y : Float\n fn mag_sq { 1 } }\n\
2202                 my $p = Point(x => 3, y => 4)\n\
2203                 $p->mag_sq()"
2204        )
2205        .is_ok());
2206    }
2207
2208    #[test]
2209    fn instance_method_on_typed_var_field_ok() {
2210        // Field access via `->`: `$p->x` reads field `x` on Point.
2211        assert!(lint(
2212            "class Point { x : Float\n y : Float }\n\
2213                 my $p = Point(x => 3, y => 4)\n\
2214                 p $p->x"
2215        )
2216        .is_ok());
2217    }
2218
2219    #[test]
2220    fn strict_never_flags_topic_variants() {
2221        // Stryke topic / block-param family — strict-vars must never
2222        // flag ANY of these as undefined, regardless of sigil or shape.
2223        let cases = [
2224            // Bare topic + positional.
2225            "_", "_0", "_1", "_42", // Outer-chain.
2226            "_<", "_<<<<<", // Indexed-ascent.
2227            "_<3", "_<10", // Positional + outer chain combined.
2228            "_2<", "_2<<<", "_2<5",
2229        ];
2230        for name in cases {
2231            assert!(
2232                super::is_topic_var(name),
2233                "is_topic_var({name:?}) must return true (topic/block-param form)",
2234            );
2235        }
2236        // Run the analyzer on each form (sigiled + bare contexts) to
2237        // ensure no UndefinedVariable error fires.
2238        let src = "use strict\np _ + _1 + _< + _<2 + _<<<<< + _2<<< + _2<5\n";
2239        let prog = crate::parse_with_file(src, "test.stk").expect("parse");
2240        super::analyze_program_with_strict(&prog, "test.stk", true)
2241            .expect("strict-vars must not flag topic-variant block params");
2242    }
2243
2244    #[test]
2245    fn is_topic_var_rejects_non_topic_underscore_names() {
2246        // Anti-cases: names that START with `_` but aren't topic vars.
2247        // The grammar is strict — only `_`, `_N`, `_<...`, `_<N`, `_N<<<`,
2248        // `_N<M` patterns qualify. Anything with a letter after the
2249        // optional digit/chevron run is NOT a topic var.
2250        for bad in [
2251            "_x",              // underscore + letter
2252            "_foo",            // underscore + word
2253            "_3abc",           // digits then letters
2254            "_<bad",           // chevron then letters
2255            "_2<xyz",          // positional + chevron + letters
2256            "x_",              // doesn't start with underscore
2257            "__",              // double underscore — not a topic form
2258            "_<<<x",           // chevrons then letters
2259        ] {
2260            assert!(
2261                !super::is_topic_var(bad),
2262                "is_topic_var({bad:?}) must return false (not a topic form)",
2263            );
2264        }
2265    }
2266
2267    #[test]
2268    fn universal_methods_skip_hierarchy_lookup() {
2269        // `isa` / `can` / `DOES` / `new` / `BUILD` / `DESTROY` short-
2270        // circuit the BFS — they work on every class regardless of
2271        // declared method set.
2272        let mut a = super::StaticAnalyzer::new("test.stk");
2273        // Empty class with no methods.
2274        a.type_methods.insert("Empty".to_string(), HashSet::new());
2275        a.type_fields.insert("Empty".to_string(), HashSet::new());
2276        for method in ["isa", "can", "DOES", "does", "new", "BUILD", "DESTROY"] {
2277            assert!(
2278                a.method_resolves_in_hierarchy("Empty", method),
2279                "method `{method}` must resolve on any class via the universal whitelist",
2280            );
2281        }
2282        // A made-up name does NOT short-circuit.
2283        assert!(
2284            !a.method_resolves_in_hierarchy("Empty", "totally_made_up"),
2285            "non-universal unknown method must still be flagged",
2286        );
2287    }
2288
2289    #[test]
2290    fn method_resolves_walks_extends_chain() {
2291        // Dog extends Animal; Animal has `trail`. `$self->trail` on
2292        // Dog must resolve via the parent.
2293        let mut a = super::StaticAnalyzer::new("test.stk");
2294        let mut animal_methods = HashSet::new();
2295        animal_methods.insert("trail".to_string());
2296        a.type_methods.insert("Animal".to_string(), animal_methods);
2297        a.type_fields.insert("Animal".to_string(), HashSet::new());
2298        a.type_methods.insert("Dog".to_string(), HashSet::new());
2299        a.type_fields.insert("Dog".to_string(), HashSet::new());
2300        a.type_parents.insert("Dog".to_string(), vec!["Animal".to_string()]);
2301        assert!(
2302            a.method_resolves_in_hierarchy("Dog", "trail"),
2303            "`trail` on Dog must resolve via Animal in extends chain",
2304        );
2305        // Method on neither class — still fails.
2306        assert!(
2307            !a.method_resolves_in_hierarchy("Dog", "fly"),
2308            "`fly` on Dog must NOT resolve (absent from Dog AND Animal)",
2309        );
2310    }
2311
2312    #[test]
2313    fn method_resolves_cycle_protected() {
2314        // Pathological `A extends B; B extends A` mutual recursion
2315        // must not infinite-loop.
2316        let mut a = super::StaticAnalyzer::new("test.stk");
2317        a.type_methods.insert("A".to_string(), HashSet::new());
2318        a.type_fields.insert("A".to_string(), HashSet::new());
2319        a.type_methods.insert("B".to_string(), HashSet::new());
2320        a.type_fields.insert("B".to_string(), HashSet::new());
2321        a.type_parents.insert("A".to_string(), vec!["B".to_string()]);
2322        a.type_parents.insert("B".to_string(), vec!["A".to_string()]);
2323        // Should return false (method nowhere) without hanging.
2324        assert!(!a.method_resolves_in_hierarchy("A", "missing"));
2325        assert!(!a.method_resolves_in_hierarchy("B", "missing"));
2326    }
2327
2328    #[test]
2329    fn dollar_obj_method_in_string_interp_not_flagged() {
2330        // `"#{ $obj->method }"` inside a string — the interpolation
2331        // is real code, but the method-call shape mustn't false-
2332        // positive when the method name happens to look like a typo
2333        // (since strict-vars-on-by-default skips simple sigil-vars
2334        // in InterpolatedString but DOES walk `#{ EXPR }`).
2335        assert!(
2336            lint(
2337                "class P { x: Int = 0\n fn show { $self->x } }\n\
2338                 my $p = P(x => 5)\np \"got #{ $p->show }\""
2339            )
2340            .is_ok(),
2341        );
2342    }
2343
2344    #[test]
2345    fn dollar_hash_with_complex_expression_in_string_interp() {
2346        // `"#{ $hash->{key} + 1 }"` — full expression inside #{...}.
2347        // The complex-expression branch DOES walk strict (per the
2348        // current policy), so undefined refs inside still flag.
2349        assert!(
2350            lint(
2351                "use strict\n\
2352                 my %h = (n => 5)\n\
2353                 p \"got #{ $h{n} + 1 }\""
2354            )
2355            .is_ok(),
2356            "defined hash element inside #{{}} must not flag",
2357        );
2358        let r = lint("use strict\np \"got #{ $undef_typo + 1 }\"");
2359        assert!(
2360            r.is_err(),
2361            "complex-expr #{{}} block must still strict-check unknown vars",
2362        );
2363    }
2364
2365    #[test]
2366    fn is_topic_var_accepts_extra_grammar_variants() {
2367        // Edge cases of the grammar not covered by the main test:
2368        for good in [
2369            "_99",                  // many positional digits
2370            "_<<<<<<<<<<",          // very deep outer chain (10 chevrons)
2371            "_<999",                // multi-digit indexed ascent
2372            "_42<<<",               // 2-digit positional + chevrons
2373            "_42<42",               // 2-digit positional + 2-digit index
2374        ] {
2375            assert!(
2376                super::is_topic_var(good),
2377                "is_topic_var({good:?}) must return true",
2378            );
2379        }
2380    }
2381
2382    #[test]
2383    fn strict_never_flags_sigiled_topic_variants() {
2384        // Same set but with `$` sigil prefix — `is_topic_var` is called
2385        // with the bare name (sigil already stripped by the AST), so
2386        // the bare-name check is what matters.
2387        let src = "use strict\nmy $tot = $_ + $_0 + $_1 + $_< + $_<2 + $_<<<<< + $_2<<< + $_2<5\np $tot\n";
2388        let prog = crate::parse_with_file(src, "test.stk").expect("parse");
2389        super::analyze_program_with_strict(&prog, "test.stk", true)
2390            .expect("strict-vars must not flag sigiled topic-variant block params");
2391    }
2392
2393    #[test]
2394    fn strict_still_flags_undefined_underscore_prefixed_ident() {
2395        // Guardrail: an identifier that merely *starts* with `_` (like
2396        // `$_underscore_name`) is NOT a topic var and SHOULD be flagged.
2397        let r = lint("p $_underscore_name");
2398        assert!(r.is_err(), "expected $_underscore_name to be flagged");
2399    }
2400
2401    #[test]
2402    fn qualified_our_scalar_visible_across_packages() {
2403        // `package Foo; our $x = 1; package main; p $Foo::x` — strict-
2404        // vars must accept `$Foo::x` from main. Regression for the
2405        // false-positive on `oursync $val` in `package Counter`.
2406        assert!(
2407            lint("package Foo\nour $x = 1\npackage main\np $Foo::x").is_ok(),
2408            "qualified `$Foo::x` must resolve to `our $x` declared in package Foo",
2409        );
2410    }
2411
2412    #[test]
2413    fn qualified_oursync_scalar_visible_across_packages() {
2414        // The exact `oursync` form from examples/test_namespaces_pin.stk.
2415        assert!(
2416            lint("package Counter\noursync $val = 0\npackage main\np $Counter::val").is_ok(),
2417            "qualified `$Counter::val` must resolve to `oursync $val` in package Counter",
2418        );
2419    }
2420
2421    #[test]
2422    fn qualified_our_array_visible_across_packages() {
2423        assert!(lint("package Foo\nour @xs = (1,2,3)\npackage main\np @Foo::xs").is_ok());
2424    }
2425
2426    #[test]
2427    fn qualified_our_hash_visible_across_packages() {
2428        assert!(lint("package Foo\nour %h = (a=>1)\npackage main\np %Foo::h").is_ok());
2429    }
2430
2431    #[test]
2432    fn thread_par_run_compiler_generated_call_not_flagged() {
2433        // `~p>` desugars to `_thread_par_run(...)` — the linter must
2434        // not flag this synthetic name as an undefined sub.
2435        assert!(
2436            lint("my @xs = (1,2,3)\nmy @r = ~p> @xs map { _ * 2 }").is_ok(),
2437            "compiler-generated _thread_par_run must be whitelisted",
2438        );
2439    }
2440
2441    #[test]
2442    fn deque_constructor_not_flagged() {
2443        // `deque(...)` is a parser-level constructor handled by
2444        // compiler.rs / vm_helper.rs but not registered in `%all`,
2445        // so it needs an explicit whitelist entry in is_sub_defined.
2446        assert!(
2447            lint("my $dq = deque(1, 2, 3)\np $dq->len").is_ok(),
2448            "deque constructor must not be flagged as undefined sub",
2449        );
2450    }
2451
2452    #[test]
2453    fn defer_block_compiler_generated_call_not_flagged() {
2454        // `defer { BLOCK }` desugars to `defer__internal(fn { BLOCK })`.
2455        // Same whitelist rule as `_thread_par_run`.
2456        assert!(
2457            lint("fn ff { defer { p \"cleanup\" }; 42 }").is_ok(),
2458            "compiler-generated defer__internal must be whitelisted",
2459        );
2460        // Negative guard: an actual unknown `_foo` is still flagged.
2461        let r = lint("use strict; _unknown_helper()");
2462        assert!(r.is_err(), "arbitrary `_foo` must still flag");
2463    }
2464
2465    #[test]
2466    fn aop_intercept_context_vars_not_flagged() {
2467        // `before/after/around` advice bodies see `$INTERCEPT_NAME`,
2468        // `@INTERCEPT_ARGS`, `$INTERCEPT_RESULT`, `$INTERCEPT_MS`,
2469        // `$INTERCEPT_US` as VM-injected always-defined vars.
2470        assert!(lint("before \"fetch\" { p $INTERCEPT_NAME, @INTERCEPT_ARGS }").is_ok());
2471        assert!(
2472            lint("after \"fetch\" { p $INTERCEPT_RESULT, $INTERCEPT_MS, $INTERCEPT_US }").is_ok()
2473        );
2474    }
2475
2476    #[test]
2477    fn string_interpolation_never_flags_undefined_simple_var() {
2478        // Bare `$undef` / `@undef` / `%undef` interpolated inside
2479        // `"..."` is a free pass — strings are used as test
2480        // descriptions / log messages with template placeholders that
2481        // aren't intended as hard variable refs. Bare code-context
2482        // references (`p $undef`) still get flagged.
2483        assert!(
2484            lint("p \"printf $fh writes to STDOUT\"").is_ok(),
2485            "simple $var inside string-interp must not be flagged",
2486        );
2487        assert!(
2488            lint("p \"got @items here\"").is_ok(),
2489            "simple @var inside string-interp must not be flagged",
2490        );
2491        // $#arr-inside-string also gets the free pass.
2492        assert!(
2493            lint("p \"got $#missing_array items\"").is_ok(),
2494            "$#arr-style inside string-interp must not be flagged",
2495        );
2496        // Negative guard: outside strings, the strict-vars check still
2497        // fires.
2498        assert!(
2499            lint("use strict\np $fh").is_err(),
2500            "bare $fh outside string must still flag",
2501        );
2502    }
2503
2504    #[test]
2505    fn string_interp_complex_expr_still_walks_strict() {
2506        // `"#{ $undef + 1 }"` — the `#{EXPR}` block is real code, so
2507        // bare `$undef` reference inside the expression should flag.
2508        let r = lint("use strict\np \"got #{ $undef + 1 }\"");
2509        assert!(
2510            r.is_err(),
2511            "complex expr inside #{{}} must still strict-check vars",
2512        );
2513    }
2514
2515    #[test]
2516    fn qualified_main_var_visible_in_default_package() {
2517        // `our $x = ...` in default package `main` — `$main::x` must
2518        // resolve. Regression for test_bugs_phase3_pin.stk where
2519        // `BEGIN { $main::log_begin .= ... }` was flagged.
2520        assert!(lint("our $log_begin = \"\"\nBEGIN { $main::log_begin .= \"B:\" }").is_ok(),);
2521        assert!(lint("our @items = (1,2)\np @main::items").is_ok());
2522        assert!(lint("our %map = ()\np keys %main::map").is_ok());
2523    }
2524
2525    #[test]
2526    fn dollar_hash_array_last_index_uses_underlying_array() {
2527        // `$#name` is the last-index-of-@name shortcut. Strict-vars
2528        // must check @name (the array), not $#name as a scalar.
2529        assert!(lint("my @arr = (1,2,3); p $#arr").is_ok());
2530        let r = lint("use strict\np $#undefined_array");
2531        assert!(r.is_err(), "$#undefined_array must flag @undefined_array");
2532        assert!(
2533            r.unwrap_err().message.contains("@undefined_array"),
2534            "error must name @undefined_array, not $#undefined_array",
2535        );
2536    }
2537
2538    #[test]
2539    fn match_arm_enum_variant_typo_flagged() {
2540        // Auto-quoted enum patterns: `Sig::Term2 => "..."` arrives
2541        // as MatchPattern::Value(String("Sig::Term2")). When Sig is
2542        // a known enum and Term2 isn't a variant, must flag.
2543        let r = lint(
2544            "enum Sig { Hup, Int, Term, Kill }\n\
2545             fn handle($s) {\n\
2546                 match ($s) {\n\
2547                     Sig::Hup => \"reload\",\n\
2548                     Sig::Term2 => \"drain\",\n\
2549                     Sig::Kill => \"reap\",\n\
2550                 }\n\
2551             }",
2552        );
2553        assert!(r.is_err(), "expected flag on Sig::Term2");
2554        let msg = r.unwrap_err().message;
2555        assert!(
2556            msg.contains("Term2") && msg.contains("Sig"),
2557            "message must name Term2 and Sig: {msg}",
2558        );
2559    }
2560
2561    #[test]
2562    fn match_arm_known_enum_variant_passes() {
2563        // Symmetric guard: real variants must not be flagged.
2564        assert!(lint(
2565            "enum Sig { Hup, Int, Term, Kill }\n\
2566                 fn handle($s) {\n\
2567                     match ($s) {\n\
2568                         Sig::Hup => \"reload\",\n\
2569                         Sig::Int => \"shutdown\",\n\
2570                         Sig::Term => \"drain\",\n\
2571                         Sig::Kill => \"reap\",\n\
2572                     }\n\
2573                 }"
2574        )
2575        .is_ok());
2576    }
2577
2578    #[test]
2579    fn dollar_caret_perl_special_vars_not_flagged() {
2580        // `$^X`, `$^O`, `$^V`, `$^W`, `$^T` etc. — Perl special vars
2581        // prefixed with `^`. All must be treated as always-defined.
2582        for name in ["$^X", "$^O", "$^V", "$^W", "$^T", "$^R", "$^N", "$^H"] {
2583            assert!(
2584                lint(&format!("use strict\np {name}")).is_ok(),
2585                "{name} must not be flagged by strict-vars",
2586            );
2587        }
2588    }
2589
2590    #[test]
2591    fn lexical_filehandle_open_my_var_not_flagged() {
2592        // `open(my $fh, ">", $path)` — `my $fh` inside the call
2593        // declares a lexical scalar. Later `print $fh ...` /
2594        // `close $fh` must see it as defined.
2595        assert!(lint(
2596            "use strict\nmy $efile = \"/tmp/x\"\n\
2597                 open(my $wfh, \">\", $efile) or die\n\
2598                 print $wfh \"line1\\n\"\nclose $wfh"
2599        )
2600        .is_ok(),);
2601    }
2602
2603    #[test]
2604    fn exists_subroutine_ref_does_not_flag_undefined() {
2605        // `exists &Pkg::sub` is introspection — flagging the sub as
2606        // undefined defeats the entire purpose.
2607        assert!(lint(
2608            "package Foo\nfn greet = 1\npackage main\n\
2609                 p exists(&Foo::greet) ? \"y\" : \"n\"\n\
2610                 p exists(&Foo::missing) ? \"y\" : \"n\""
2611        )
2612        .is_ok(),);
2613    }
2614
2615    #[test]
2616    fn universal_methods_resolve_on_any_class() {
2617        // `isa`, `can`, `DOES`, `does`, `VERSION`, lifecycle hooks
2618        // (`new`, `BUILD`, `DESTROY`, `destroy`), and built-in class
2619        // methods (`clone`, `with`, `to_hash`, `to_hash_rec`,
2620        // `to_hash_deep`, `fields`, `methods`, `superclass`) are
2621        // always callable on any class instance — never flag them
2622        // via $obj->X or $self->X.
2623        assert!(lint(
2624            "class Square { side: Float\n fn area { 1 } }\n\
2625                 my $sq = Square->new(side => 5)\n\
2626                 p $sq->isa(\"Square\")\n\
2627                 p $sq->can(\"area\")\n\
2628                 p $sq->DOES(\"Shape\")\n\
2629                 my $cloned = $sq->clone()\n\
2630                 my $changed = $sq->with(side => 9)\n\
2631                 p $sq->to_hash()\n\
2632                 p $sq->fields()"
2633        )
2634        .is_ok(),);
2635    }
2636
2637    #[test]
2638    fn builtin_struct_methods_resolve_on_any_struct() {
2639        // Same whitelist applies to structs: `clone`, `with`,
2640        // `to_hash`, `to_hash_rec`, `to_hash_deep`, `fields`.
2641        assert!(lint(
2642            "struct Point { x: Float\n y: Float }\n\
2643                 my $p = Point(x => 1.0, y => 2.0)\n\
2644                 my $c = $p->clone()\n\
2645                 my $u = $p->with(x => 9.0)\n\
2646                 p $p->to_hash()\n\
2647                 p $p->fields()"
2648        )
2649        .is_ok(),);
2650    }
2651
2652    #[test]
2653    fn class_inheritance_resolves_parent_methods_on_self() {
2654        // `class Dog extends Animal` — `$self->trail` inside Dog's
2655        // body must walk up to Animal and find `trail` there.
2656        assert!(lint(
2657            "class Animal { name: Str = \"\"\n fn trail { \"...\" } }\n\
2658                 class Dog extends Animal {\n\
2659                     breed: Str = \"\"\n\
2660                     fn show { $self->trail }\n\
2661                 }"
2662        )
2663        .is_ok(),);
2664    }
2665
2666    #[test]
2667    fn class_inheritance_resolves_parent_fields_in_constructor() {
2668        // `Dog(name => "Rex", breed => "Lab")` — `name` is on Animal,
2669        // `breed` on Dog. Constructor key check must accept both.
2670        assert!(lint(
2671            "class Animal { name: Str = \"\" }\n\
2672                 class Dog extends Animal { breed: Str = \"\" }\n\
2673                 my $d = Dog(name => \"Rex\", breed => \"Lab\")\n\
2674                 p $d->name"
2675        )
2676        .is_ok(),);
2677    }
2678
2679    #[test]
2680    fn resolve_require_path_finds_lib_root_from_nested_source() {
2681        // Project layout: tmp_root/lib/ai/{matrix,neural_network}.stk.
2682        // From neural_network.stk, `require "./lib/ai/matrix.stk"`
2683        // must resolve to tmp_root/lib/ai/matrix.stk even though the
2684        // current file sits inside `lib/ai/`. Without walking up to
2685        // find the `lib/`-bearing ancestor, the resolver would land
2686        // in `lib/ai/lib/ai/matrix.stk` which doesn't exist.
2687        let tmp = std::env::temp_dir().join(format!("stryke_resolve_test_{}", std::process::id()));
2688        let _ = std::fs::remove_dir_all(&tmp);
2689        std::fs::create_dir_all(tmp.join("lib").join("ai")).unwrap();
2690        let mat = tmp.join("lib").join("ai").join("matrix.stk");
2691        std::fs::write(&mat, "1\n").unwrap();
2692        let nn = tmp.join("lib").join("ai").join("neural_network.stk");
2693        std::fs::write(&nn, "1\n").unwrap();
2694        let resolved =
2695            super::resolve_require_path_from_file(nn.to_str().unwrap(), "./lib/ai/matrix.stk");
2696        assert!(
2697            resolved.as_ref().is_some_and(|p| p == &mat)
2698                || resolved
2699                    .as_ref()
2700                    .is_some_and(|p| { p.canonicalize().ok() == mat.canonicalize().ok() }),
2701            "expected to resolve to {mat:?}, got {resolved:?}",
2702        );
2703        let _ = std::fs::remove_dir_all(&tmp);
2704    }
2705
2706    #[test]
2707    fn resolve_require_path_sibling_in_same_dir() {
2708        // `require "./sibling.stk"` from same-directory script should
2709        // resolve regardless of `lib/` presence.
2710        let tmp =
2711            std::env::temp_dir().join(format!("stryke_resolve_sibling_{}", std::process::id()));
2712        let _ = std::fs::remove_dir_all(&tmp);
2713        std::fs::create_dir_all(&tmp).unwrap();
2714        let sib = tmp.join("sibling.stk");
2715        std::fs::write(&sib, "1\n").unwrap();
2716        let me = tmp.join("me.stk");
2717        std::fs::write(&me, "1\n").unwrap();
2718        let resolved = super::resolve_require_path_from_file(me.to_str().unwrap(), "./sibling.stk");
2719        assert!(
2720            resolved.is_some(),
2721            "expected to resolve ./sibling.stk in same dir, got None",
2722        );
2723        let _ = std::fs::remove_dir_all(&tmp);
2724    }
2725
2726    #[test]
2727    fn positional_constructor_args_not_checked_as_keys() {
2728        // `Task(1, "Setup", Priority::High)` is positional — none of
2729        // the args are field names. Must not flag the String /
2730        // Bareword args as "unknown field".
2731        assert!(lint(
2732            "enum Priority { Low, Medium, High, Critical }\n\
2733                 class Task {\n\
2734                     id: Int\n\
2735                     title: Str = \"\"\n\
2736                     priority: Any = undef\n\
2737                 }\n\
2738                 my $t = Task(1, \"Setup\", Priority::High)"
2739        )
2740        .is_ok(),);
2741    }
2742
2743    #[test]
2744    fn positional_constructor_with_string_value_not_flagged() {
2745        // `Person(\"Alice\", 30)` — String literal is the FIRST arg
2746        // (and would-be field name), but since "Alice" isn't a
2747        // declared field of Person, the call is positional.
2748        assert!(lint(
2749            "class Person { name: Str = \"\"\n age: Int = 0 }\n\
2750                 my $p = Person(\"Alice\", 30)"
2751        )
2752        .is_ok(),);
2753    }
2754
2755    #[test]
2756    fn keyed_constructor_still_flags_typo() {
2757        // Guardrail: `Point(x => 10, yyg => 20)` — `yyg` IS a typo.
2758        // First arg matches a declared field (`x`), so the heuristic
2759        // correctly classifies the call as keyed and the check fires.
2760        let r = lint(
2761            "class Point { x: Float\n y: Float }\n\
2762             my $p = Point(x => 10, yyg => 20)",
2763        );
2764        assert!(r.is_err(), "expected `yyg` typo to be flagged");
2765        assert!(
2766            r.unwrap_err().message.contains("yyg"),
2767            "error must name `yyg` field",
2768        );
2769    }
2770
2771    #[test]
2772    fn dollar_dollar_pid_not_flagged() {
2773        // `$$` is the process ID — always-defined special var.
2774        // Parser stores it as `ScalarVar("$$")` so the strict-vars
2775        // check sees a 2-char name; is_special_var must whitelist.
2776        assert!(lint("use strict\np $$").is_ok());
2777        assert!(lint("use strict\n$$ > 0 ? 1 : 0").is_ok());
2778    }
2779
2780    #[test]
2781    fn class_impl_trait_resolves_default_method() {
2782        // `trait Greetable { fn greeting { "Hello" } }` + `class Person
2783        // impl Greetable { ... }` — `$p->greeting` must resolve via
2784        // the trait's default impl. Regression for
2785        // test_extended_features_pin.stk.
2786        assert!(lint(
2787            "trait Greetable {\n\
2788                     fn greeting { \"Hello\" }\n\
2789                     fn name\n\
2790                 }\n\
2791                 class Person impl Greetable {\n\
2792                     n: Str = \"\"\n\
2793                     fn name { $self->n }\n\
2794                 }\n\
2795                 my $p = Person(n => \"Alice\")\n\
2796                 p $p->greeting()"
2797        )
2798        .is_ok(),);
2799    }
2800
2801    #[test]
2802    fn class_impl_multiple_traits_resolves_methods_from_all() {
2803        assert!(lint(
2804            "trait Greetable { fn greeting { \"hi\" } }\n\
2805                 trait Loggable  { fn log_it { 1 } }\n\
2806                 class Hybrid impl Greetable, Loggable {}\n\
2807                 my $h = Hybrid->new\n\
2808                 p $h->greeting()\n\
2809                 p $h->log_it()"
2810        )
2811        .is_ok(),);
2812    }
2813
2814    #[test]
2815    fn class_impl_trait_still_flags_unknown_method() {
2816        // Guardrail: if the method exists on NEITHER the class NOR
2817        // any implemented trait, still flag.
2818        let r = lint(
2819            "trait Greetable { fn greeting }\n\
2820             class Person impl Greetable { n: Str = \"\" }\n\
2821             my $p = Person->new\n\
2822             p $p->fly()",
2823        );
2824        assert!(r.is_err(), "expected $p->fly to be flagged");
2825    }
2826
2827    #[test]
2828    fn class_inheritance_still_flags_unknown_method() {
2829        // Symmetric guard: a method that exists on NEITHER child nor
2830        // parent must still be flagged.
2831        let r = lint(
2832            "class Animal { fn trail { \"\" } }\n\
2833             class Dog extends Animal {\n\
2834                 fn show { $self->fly }\n\
2835             }",
2836        );
2837        assert!(r.is_err(), "expected $self->fly to be flagged");
2838    }
2839
2840    #[test]
2841    fn instance_method_on_arrow_new_form_typed_var_flags() {
2842        // `my $p = Point->new(x => 3)` also binds `$p` to Point.
2843        let r = lint(
2844            "class Point { x : Float\n y : Float }\n\
2845             my $p = Point->new(x => 3, y => 4)\n\
2846             $p->whatever()",
2847        );
2848        assert!(r.is_err(), "expected error for `$$p->whatever`");
2849    }
2850}