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/// `StaticAnalyzer` — see fields for layout.
52pub struct StaticAnalyzer {
53    /// `scopes` field.
54    scopes: Vec<Scope>,
55    /// `errors` field.
56    errors: Vec<StrykeError>,
57    /// `file` field.
58    file: String,
59    /// `current_package` field.
60    current_package: String,
61    /// When `false` (the `stryke check` default), strict-vars-style
62    /// "Global symbol \"$x\" requires explicit package name" errors are
63    /// suppressed — `stryke check` is a parse / compile gate, not a
64    /// strict-vars enforcer. Set to `true` only when the source itself
65    /// has `use strict;` (or `use strict 'vars';`), in which case we
66    /// emit so the analyzer surfaces the same diagnostics the runtime
67    /// would. Topic vars (`$_0`, `@_1`, …) and special vars (`$_`,
68    /// `@ARGV`, `%ENV`, …) stay exempt regardless.
69    strict_vars: bool,
70    /// Canonicalized paths of files already walked for `require` so the
71    /// declaration sweep doesn't loop on `require A` / `require B` /
72    /// `require A` cycles.
73    seen_required_files: HashSet<PathBuf>,
74    /// Per-type field-name sets. Populated during the declaration
75    /// sweep so that calls like `Point->new(x => 10, yyg => 20)` can
76    /// be checked against the known fields of `Point` — `yyg` is
77    /// not a field, so the linter emits a diagnostic.
78    type_fields: HashMap<String, HashSet<String>>,
79    /// Per-type method-name sets — used together with `type_fields`
80    /// for the `$self->X` check inside class/struct method bodies.
81    /// A `$self->method_or_field` access is valid only when the name
82    /// is either a field of the enclosing class or a method on it.
83    type_methods: HashMap<String, HashSet<String>>,
84    /// Per-type parent list — `class Dog extends Animal, Trainable`
85    /// records `Dog → [Animal, Trainable]`. Drives the inherited-
86    /// method lookup so `$self->trail` resolves to `Animal::trail`
87    /// instead of being flagged as unknown on `Dog`.
88    type_parents: HashMap<String, Vec<String>>,
89    /// The class/struct being analyzed inside a method body. `None`
90    /// outside any type's body. Used to resolve `$self->X` against
91    /// the right type's fields + methods.
92    current_class: Option<String>,
93    /// True when the file contains a string-form `eval` or a dynamic
94    /// `require $var` whose target the analyzer can't trace. Those
95    /// constructs install subs at runtime that no static walker can
96    /// see; flagging the caller as "Undefined subroutine" would just
97    /// be noise. Set during the declaration sweep; consulted by
98    /// `is_sub_defined`.
99    has_dynamic_subs: bool,
100}
101
102impl StaticAnalyzer {
103    /// `new` — see implementation.
104    pub fn new(file: &str) -> Self {
105        Self::with_strict_vars(file, false)
106    }
107    /// `with_strict_vars` — see implementation.
108    pub fn with_strict_vars(file: &str, strict_vars: bool) -> Self {
109        let mut global = Scope::default();
110        for name in ["_", "a", "b", "ARGV", "ENV", "SIG", "INC"] {
111            global.declare_array(name);
112        }
113        for name in ["ENV", "SIG", "INC"] {
114            global.declare_hash(name);
115        }
116        for name in [
117            "_", "a", "b", "!", "$", "@", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "&",
118            "`", "'", "+", ".", "/", "\\", "|", "%", "=", "-", "~", "^", "*", "?", "\"",
119        ] {
120            global.declare_scalar(name);
121        }
122        let mut a = Self {
123            scopes: vec![global],
124            errors: Vec::new(),
125            file: file.to_string(),
126            current_package: "main".to_string(),
127            strict_vars,
128            seen_required_files: HashSet::new(),
129            type_fields: HashMap::new(),
130            type_methods: HashMap::new(),
131            type_parents: HashMap::new(),
132            current_class: None,
133            has_dynamic_subs: false,
134        };
135        // Pre-declare every `[ffi].exports` name from the nearest sibling
136        // stryke.toml. cdylib packages (stryke-gui, stryke-aws, etc.) ship
137        // a thin .stk wrapper that calls FFI symbols (`gui__mouse_pos`)
138        // registered at runtime via dlopen — those aren't declared in the
139        // .stk source anywhere. Without this pre-pass the analyzer flags
140        // every `gui__*` call in lib/GUI.stk as UndefinedSubroutine.
141        a.declare_ffi_exports_for(Path::new(file));
142        a
143    }
144
145    fn push_scope(&mut self) {
146        self.scopes.push(Scope::default());
147    }
148
149    fn pop_scope(&mut self) {
150        if self.scopes.len() > 1 {
151            self.scopes.pop();
152        }
153    }
154
155    fn declare_scalar(&mut self, name: &str) {
156        if let Some(scope) = self.scopes.last_mut() {
157            scope.declare_scalar(name);
158        }
159    }
160
161    fn declare_array(&mut self, name: &str) {
162        if let Some(scope) = self.scopes.last_mut() {
163            scope.declare_array(name);
164        }
165    }
166
167    fn declare_hash(&mut self, name: &str) {
168        if let Some(scope) = self.scopes.last_mut() {
169            scope.declare_hash(name);
170        }
171    }
172
173    fn declare_sub(&mut self, name: &str) {
174        if let Some(scope) = self.scopes.first_mut() {
175            scope.declare_sub(name);
176        }
177    }
178
179    /// Record that scalar `$name` was bound to an instance of `type`.
180    /// Stored on the innermost active scope so nested blocks shadow
181    /// outer bindings the same way variable scoping already works.
182    fn declare_scalar_type(&mut self, name: &str, ty: &str) {
183        if let Some(scope) = self.scopes.last_mut() {
184            scope.scalar_types.insert(name.to_string(), ty.to_string());
185        }
186    }
187
188    /// Walk scopes outer-to-inner. Returns the Type name `$name` was
189    /// last bound to via a known constructor, if any.
190    fn resolve_scalar_type(&self, name: &str) -> Option<&str> {
191        for s in self.scopes.iter().rev() {
192            if let Some(t) = s.scalar_types.get(name) {
193                return Some(t.as_str());
194            }
195        }
196        None
197    }
198
199    /// If `init` is `Type(...)` or `Type->new(...)` AND the bare tail
200    /// resolves to a declared Type, return that type name. The lint
201    /// only fires when the file declares the Type — same gating rule
202    /// already used by `check_constructor_keys` callers.
203    fn infer_constructor_type(&self, init: &Expr) -> Option<String> {
204        match &init.kind {
205            ExprKind::FuncCall { name, .. } => {
206                let bare = name.rsplit("::").next().unwrap_or(name);
207                if self.type_fields.contains_key(name) {
208                    return Some(name.clone());
209                }
210                if self.type_fields.contains_key(bare) {
211                    return Some(bare.to_string());
212                }
213                None
214            }
215            ExprKind::MethodCall { object, method, .. } if method == "new" => {
216                if let ExprKind::Bareword(n) = &object.kind {
217                    let bare = n.rsplit("::").next().unwrap_or(n);
218                    if self.type_fields.contains_key(n) {
219                        return Some(n.clone());
220                    }
221                    if self.type_fields.contains_key(bare) {
222                        return Some(bare.to_string());
223                    }
224                }
225                None
226            }
227            _ => None,
228        }
229    }
230
231    fn is_scalar_defined(&self, name: &str) -> bool {
232        if is_special_var(name) || is_topic_var(name) {
233            return true;
234        }
235        // Fully-qualified package vars (`$Foo::Bar::x`) are explicit and
236        // satisfy strict-vars by definition — the package name IS the
237        // declaration site.
238        if name.contains("::") {
239            return true;
240        }
241        self.scopes.iter().rev().any(|s| s.scalars.contains(name))
242    }
243
244    fn is_array_defined(&self, name: &str) -> bool {
245        if is_special_var(name) || is_topic_var(name) {
246            return true;
247        }
248        if name.contains("::") {
249            return true;
250        }
251        self.scopes.iter().rev().any(|s| s.arrays.contains(name))
252    }
253
254    fn is_hash_defined(&self, name: &str) -> bool {
255        if is_special_var(name) || is_topic_var(name) {
256            return true;
257        }
258        if name.contains("::") {
259            return true;
260        }
261        self.scopes.iter().rev().any(|s| s.hashes.contains(name))
262    }
263
264    fn is_sub_defined(&self, name: &str) -> bool {
265        // Late static binding: static::method() is always valid (runtime-resolved)
266        if name.starts_with("static::") {
267            return true;
268        }
269        // String-form `eval` and dynamic `require $var` install subs the
270        // static walker can't see. When either appears in the file,
271        // every otherwise-unknown sub call is assumed installed at
272        // runtime — same laxness Perl's `no strict 'subs'` gives.
273        if self.has_dynamic_subs {
274            return true;
275        }
276        // Compiler-generated calls emitted by parser desugaring — not
277        // in the user-facing `%b` builtin set but always valid. Keep
278        // this list literal — every entry must correspond to a real
279        // dispatch arm in `builtins.rs`.
280        if matches!(
281            name,
282            "_thread_par_run"
283                | "__stryke_rust_compile"
284                | "defer__internal"
285                // Parser-level constructor specials — handled by
286                // compiler.rs / vm_helper.rs as if they were built-in,
287                // but don't register through the normal `%b` path.
288                | "deque",
289        ) {
290            return true;
291        }
292        let base = name.rsplit("::").next().unwrap_or(name);
293        if builtins().contains(base) {
294            return true;
295        }
296        self.scopes
297            .iter()
298            .rev()
299            .any(|s| s.subs.contains(name) || s.subs.contains(base))
300    }
301
302    fn error(&mut self, kind: ErrorKind, msg: String, line: usize) {
303        self.errors
304            .push(StrykeError::new(kind, msg, line, &self.file));
305    }
306    /// `analyze` — see implementation.
307    pub fn analyze(mut self, program: &Program) -> StrykeResult<()> {
308        for stmt in &program.statements {
309            self.collect_declarations_stmt(stmt);
310        }
311        // Pre-pass: re-read the source and scan for string-form `eval`
312        // and dynamic `require $var`. The per-stmt declaration walker
313        // above only recurses into block-like statements; subs / loops
314        // would otherwise hide `eval "fn …"` from the dynamic-sub
315        // detector. Source-level regex is hacky but fine for this use
316        // — the alternative is a full visitor over every ExprKind arm.
317        self.scan_dynamic_sub_sources_in_source();
318        for stmt in &program.statements {
319            self.analyze_stmt(stmt);
320        }
321        if let Some(e) = self.errors.into_iter().next() {
322            Err(e)
323        } else {
324            Ok(())
325        }
326    }
327
328    fn collect_declarations_stmt(&mut self, stmt: &Statement) {
329        match &stmt.kind {
330            StmtKind::Package { name } => {
331                self.current_package = name.clone();
332            }
333            StmtKind::SubDecl { name, .. } => {
334                let fqn = if name.contains("::") {
335                    name.clone()
336                } else {
337                    format!("{}::{}", self.current_package, name)
338                };
339                self.declare_sub(name);
340                self.declare_sub(&fqn);
341            }
342            StmtKind::Use { module, imports, version } => {
343                self.declare_sub(module);
344                // `use Foo::Bar` mirrors `require Foo::Bar` for analyzer
345                // purposes: chase the resolved file and merge its sub
346                // declarations into scope so `Foo::Bar::baz()` calls in
347                // the current file don't fire as "Undefined subroutine".
348                // Without this, `use GUI; GUI::mouse_pos()` always lints
349                // red regardless of whether stryke-gui is installed and
350                // resolve_require_path_from_file's L1 3-arm resolver
351                // would have found it. Threading `version` through keeps
352                // the analyzer in sync with `use Module VERSION` pins —
353                // hovers and undefined-sub diagnostics chase the
354                // pinned `<store>/<name>@<version>/` extraction, not
355                // whatever's in the global installed.toml.
356                self.follow_require_versioned(module, version.as_deref());
357                // `use constant NAME => …`, `use constant { A => 1, B => 2 }`,
358                // and `use constant NAME => (1, 2, 3)` install one sub per
359                // NAME. Recognize those name slots so the linter can resolve
360                // the constants the program references later.
361                if module == "constant" {
362                    self.collect_use_constant_names(imports);
363                }
364            }
365            // `require "./lib/foo.stk"` / `require Foo::Bar` — parse the
366            // pulled-in file and register its sub declarations so callers
367            // like `Project::Foo::bar()` are not flagged as undefined.
368            // Postfix-modifier `require … if COND;` lowers to a
369            // `PostfixIf`-wrapped expression inside an Expression statement,
370            // so this walker hits the inner Require either way.
371            StmtKind::Expression(e) => self.collect_required_subs_from_expr(e),
372            StmtKind::Block(b)
373            | StmtKind::StmtGroup(b)
374            | StmtKind::Begin(b)
375            | StmtKind::End(b)
376            | StmtKind::UnitCheck(b)
377            | StmtKind::Check(b)
378            | StmtKind::Init(b) => {
379                for s in b {
380                    self.collect_declarations_stmt(s);
381                }
382            }
383            StmtKind::If {
384                body,
385                elsifs,
386                else_block,
387                ..
388            } => {
389                for s in body {
390                    self.collect_declarations_stmt(s);
391                }
392                for (_, b) in elsifs {
393                    for s in b {
394                        self.collect_declarations_stmt(s);
395                    }
396                }
397                if let Some(b) = else_block {
398                    for s in b {
399                        self.collect_declarations_stmt(s);
400                    }
401                }
402            }
403            StmtKind::ClassDecl { def } => {
404                self.declare_sub(&def.name);
405                for m in &def.methods {
406                    if m.is_static {
407                        self.declare_sub(&format!("{}::{}", def.name, m.name));
408                    }
409                }
410                for sf in &def.static_fields {
411                    self.declare_sub(&format!("{}::{}", def.name, sf.name));
412                }
413                let mut fields: HashSet<String> = HashSet::new();
414                for f in &def.fields {
415                    fields.insert(f.name.clone());
416                }
417                self.type_fields.insert(def.name.clone(), fields);
418                // Instance + static methods for `$self->X` diagnostics.
419                let mut methods: HashSet<String> = HashSet::new();
420                for m in &def.methods {
421                    methods.insert(m.name.clone());
422                }
423                self.type_methods.insert(def.name.clone(), methods);
424                // Parent classes + implemented traits feed the
425                // inheritance/trait walker so `$self->X` resolves via
426                // any reachable type. `class Dog extends Animal impl
427                // Trainable` — both Animal AND Trainable contribute
428                // methods.
429                let mut parents = def.extends.clone();
430                parents.extend(def.implements.iter().cloned());
431                if !parents.is_empty() {
432                    self.type_parents.insert(def.name.clone(), parents);
433                }
434            }
435            StmtKind::StructDecl { def } => {
436                self.declare_sub(&def.name);
437                let mut fields: HashSet<String> = HashSet::new();
438                for f in &def.fields {
439                    fields.insert(f.name.clone());
440                }
441                self.type_fields.insert(def.name.clone(), fields);
442                let mut methods: HashSet<String> = HashSet::new();
443                for m in &def.methods {
444                    methods.insert(m.name.clone());
445                }
446                self.type_methods.insert(def.name.clone(), methods);
447            }
448            StmtKind::EnumDecl { def } => {
449                self.declare_sub(&def.name);
450                for v in &def.variants {
451                    self.declare_sub(&format!("{}::{}", def.name, v.name));
452                }
453                // Variants form the "field" set for diagnostic purposes
454                // (enums don't have fat-comma constructor calls, but
455                // record anyway for completeness).
456                let mut fields: HashSet<String> = HashSet::new();
457                for v in &def.variants {
458                    fields.insert(v.name.clone());
459                }
460                self.type_fields.insert(def.name.clone(), fields);
461            }
462            // Trait declarations contribute their method set so classes
463            // that `impl Trait` can inherit them through the parent
464            // chain. Without this, `Person impl Greetable` with a
465            // default `fn greeting { ... }` on the trait wouldn't
466            // resolve `$p->greeting`.
467            StmtKind::TraitDecl { def } => {
468                self.declare_sub(&def.name);
469                let mut methods: HashSet<String> = HashSet::new();
470                for m in &def.methods {
471                    methods.insert(m.name.clone());
472                }
473                self.type_methods.insert(def.name.clone(), methods);
474                // Empty field set so the type_fields key exists (drives
475                // the constructor-key check + hierarchy walker entry).
476                self.type_fields.entry(def.name.clone()).or_default();
477            }
478            _ => {}
479        }
480    }
481
482    /// Register every NAME slot from a `use constant ...` import list so
483    /// later references parse as defined subs. Handles all three documented
484    /// shapes:
485    ///   use constant NAME => VALUE
486    ///   use constant NAME => (V1, V2, ...)
487    ///   use constant { N1 => V1, N2 => V2, ... }
488    fn collect_use_constant_names(&mut self, imports: &[Expr]) {
489        for imp in imports {
490            match &imp.kind {
491                ExprKind::List(items) => {
492                    let mut i = 0;
493                    while i + 1 < items.len() {
494                        if let Some(name) = static_string_value(&items[i]) {
495                            let fqn = format!("{}::{}", self.current_package, name);
496                            self.declare_sub(&name);
497                            self.declare_sub(&fqn);
498                        }
499                        i += 2;
500                    }
501                }
502                ExprKind::HashRef(pairs) => {
503                    for (k, _) in pairs {
504                        if let Some(name) = static_string_value(k) {
505                            let fqn = format!("{}::{}", self.current_package, name);
506                            self.declare_sub(&name);
507                            self.declare_sub(&fqn);
508                        }
509                    }
510                }
511                _ => {}
512            }
513        }
514    }
515
516    /// Pull sub declarations out of a `require "./path"` / `require Module`
517    /// inside an expression. The analyzer scans the required file's AST so
518    /// callers of imported subs aren't flagged as undefined.
519    ///
520    /// Only static string-literal paths (`require "./lib/foo.stk"`) and
521    /// bareword module specs (`require Foo::Bar`) are followed. Dynamic
522    /// `require $var` is skipped — the analyzer can't know the target.
523    fn collect_required_subs_from_expr(&mut self, expr: &Expr) {
524        match &expr.kind {
525            ExprKind::Require(inner) => {
526                let Some(spec) = static_string_value(inner) else {
527                    return;
528                };
529                self.follow_require(&spec);
530            }
531            ExprKind::PostfixIf { expr: inner, .. }
532            | ExprKind::PostfixUnless { expr: inner, .. } => {
533                self.collect_required_subs_from_expr(inner);
534            }
535            _ => {}
536        }
537    }
538
539    /// Re-read the source file and scan for `eval STRING-form` and
540    /// dynamic `require $var` patterns. When found, flip
541    /// `has_dynamic_subs` so undefined-sub diagnostics are suppressed,
542    /// and regex-extract `fn NAME` declarations from eval string
543    /// literals so explicitly-named subs still resolve cleanly.
544    fn scan_dynamic_sub_sources_in_source(&mut self) {
545        let Ok(src) = std::fs::read_to_string(&self.file) else {
546            return;
547        };
548        // Strip line comments — `# eval "fn foo"` in a comment is not
549        // a real eval. Keep newline positions intact so regex-style
550        // matches still line up; just blank out the comment tail.
551        let mut cleaned = String::with_capacity(src.len());
552        for line in src.split_inclusive('\n') {
553            if let Some(hash) = line.find('#') {
554                let before = &line[..hash];
555                // Heuristic: if the `#` is preceded by an opening
556                // string quote with no closing quote between, it's
557                // inside a string literal — keep the line as-is.
558                let dq = before.matches('"').count();
559                let sq = before.matches('\'').count();
560                if dq % 2 == 0 && sq % 2 == 0 {
561                    cleaned.push_str(before);
562                    if line.ends_with('\n') {
563                        cleaned.push('\n');
564                    }
565                    continue;
566                }
567            }
568            cleaned.push_str(line);
569        }
570        // Dynamic require: `require $var`, `require ($var)`, etc.
571        // The static walker already follows `require "..."` and
572        // `require Foo::Bar`; anything else is opaque.
573        let bytes = cleaned.as_bytes();
574        let mut i = 0;
575        while i + 8 < bytes.len() {
576            if &bytes[i..i + 7] == b"require"
577                && (i == 0 || !is_ident_byte(bytes[i - 1]))
578                && !is_ident_byte(bytes[i + 7])
579            {
580                let mut j = i + 7;
581                while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t' || bytes[j] == b'(')
582                {
583                    j += 1;
584                }
585                if j < bytes.len() && bytes[j] == b'$' {
586                    self.has_dynamic_subs = true;
587                }
588            }
589            i += 1;
590        }
591        // String-form eval: `eval "..."`, `eval '...'`, `eval(...)`
592        // where the inner arg is a quoted literal. Pull every
593        // `fn NAME` / `sub NAME` out of the literal so explicit sub
594        // names are recognized; also set `has_dynamic_subs` when the
595        // string contains interpolation or comes from a variable.
596        let mut i = 0;
597        while i + 5 < bytes.len() {
598            if &bytes[i..i + 4] == b"eval"
599                && (i == 0 || !is_ident_byte(bytes[i - 1]))
600                && !is_ident_byte(bytes[i + 4])
601            {
602                let mut j = i + 4;
603                while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t' || bytes[j] == b'(')
604                {
605                    j += 1;
606                }
607                if j < bytes.len() {
608                    match bytes[j] {
609                        b'"' => {
610                            // Read until matching `"`, handling backslash escapes.
611                            let mut k = j + 1;
612                            let start = k;
613                            while k < bytes.len() && bytes[k] != b'"' {
614                                if bytes[k] == b'\\' && k + 1 < bytes.len() {
615                                    k += 2;
616                                } else {
617                                    k += 1;
618                                }
619                            }
620                            let lit = &cleaned[start..k.min(cleaned.len())];
621                            self.extract_fn_decls_from_str(lit);
622                            // Double-quoted strings interpolate — any
623                            // `$var` / `#{expr}` makes the actual
624                            // sub-name resolution opaque.
625                            if lit.contains('$') || lit.contains("#{") {
626                                self.has_dynamic_subs = true;
627                            }
628                        }
629                        b'\'' => {
630                            let mut k = j + 1;
631                            let start = k;
632                            while k < bytes.len() && bytes[k] != b'\'' {
633                                k += 1;
634                            }
635                            let lit = &cleaned[start..k.min(cleaned.len())];
636                            self.extract_fn_decls_from_str(lit);
637                        }
638                        b'{' => {} // `eval { ... }` block — analyzed normally.
639                        _ => {
640                            // `eval $code` — opaque.
641                            self.has_dynamic_subs = true;
642                        }
643                    }
644                }
645            }
646            i += 1;
647        }
648    }
649
650    /// Extract `fn NAME` / `sub NAME` declarations from a literal source
651    /// snippet. Used to register subs installed by `eval STRING`.
652    fn extract_fn_decls_from_str(&mut self, src: &str) {
653        for token in ["fn", "sub"] {
654            let mut rest = src;
655            while let Some(pos) = rest.find(token) {
656                let before_ok = pos == 0
657                    || !rest
658                        .as_bytes()
659                        .get(pos.saturating_sub(1))
660                        .map(|c| c.is_ascii_alphanumeric() || *c == b'_')
661                        .unwrap_or(false);
662                let after = &rest[pos + token.len()..];
663                let trimmed = after.trim_start();
664                if before_ok && after.len() > trimmed.len() {
665                    let name: String = trimmed
666                        .chars()
667                        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == ':')
668                        .collect();
669                    if !name.is_empty() && !name.starts_with(':') {
670                        self.declare_sub(&name);
671                        if let Some(tail) = name.rsplit("::").next() {
672                            if tail != name {
673                                self.declare_sub(tail);
674                            }
675                        }
676                    }
677                }
678                rest = &rest[pos + token.len()..];
679            }
680        }
681    }
682
683    /// Parse the file named by `spec` (relative to the current analyzed
684    /// file when prefixed with `./` / `../`, otherwise treated as a Perl
685    /// module specifier `Foo::Bar` → `Foo/Bar.pm`) and merge every sub
686    /// declaration it contains into the analyzer's scope. Recurses into
687    /// chained `require`s. Silently skips any path that fails to resolve
688    /// or parse — the runtime will surface the same error if it matters.
689    fn follow_require(&mut self, spec: &str) {
690        self.follow_require_versioned(spec, None);
691    }
692
693    fn follow_require_versioned(&mut self, spec: &str, pin_version: Option<&str>) {
694        let spec = spec.trim();
695        if spec.is_empty() {
696            return;
697        }
698        // Pragma-style requires (`require strict;`) install nothing
699        // user-visible; skip cheaply.
700        if matches!(
701            spec,
702            "strict"
703                | "warnings"
704                | "utf8"
705                | "feature"
706                | "v5"
707                | "threads"
708                | "Thread::Pool"
709                | "Parallel::ForkManager"
710        ) {
711            return;
712        }
713        let Some(target) = self.resolve_require_path_versioned(spec, pin_version) else {
714            return;
715        };
716        let canon = target.canonicalize().unwrap_or(target.clone());
717        if !self.seen_required_files.insert(canon.clone()) {
718            return; // already walked
719        }
720        let Ok(src) = std::fs::read_to_string(&target) else {
721            return;
722        };
723        let file_str = target.to_string_lossy().into_owned();
724        let Ok(program) = crate::parse_module_with_file(&src, &file_str) else {
725            return;
726        };
727        // Same FFI-export pre-pass as the entry-file constructor — when
728        // chasing into `~/.stryke/store/gui@<ver>/lib/GUI.stk`, declare
729        // every `[ffi].exports` symbol from the sibling stryke.toml so
730        // the `gui__mouse_pos("…")` bareword calls inside GUI.stk's
731        // bodies don't fire UndefinedSubroutine.
732        self.declare_ffi_exports_for(&target);
733        // Save and restore the caller's package — required files routinely
734        // contain their own `package …;` declarations that shouldn't leak.
735        let saved_pkg = std::mem::replace(&mut self.current_package, "main".to_string());
736        for stmt in &program.statements {
737            self.collect_declarations_stmt(stmt);
738        }
739        self.current_package = saved_pkg;
740    }
741
742    fn resolve_require_path_versioned(
743        &self,
744        spec: &str,
745        pin_version: Option<&str>,
746    ) -> Option<PathBuf> {
747        resolve_require_path_from_file_versioned(&self.file, spec, pin_version)
748    }
749
750    /// Pre-declare every name in `[ffi].exports` from the nearest
751    /// `stryke.toml` (within 3 directory levels of `file_path`). cdylib
752    /// packages register these as stryke-callable functions at runtime
753    /// via `rust_ffi::load_cdylib` — they have no source declaration in
754    /// the .stk wrapper, so without this pre-pass the analyzer flags
755    /// every `gui__*` / `aws__*` / `redis__*` bareword call inside the
756    /// wrapper as `UndefinedSubroutine`.
757    ///
758    /// Search order:
759    ///   `<file_dir>/stryke.toml`            (rare — .stk at pkg root)
760    ///   `<file_dir>/../stryke.toml`         (standard `lib/X.stk` layout)
761    ///   `<file_dir>/../../stryke.toml`      (nested `lib/Foo/Bar.stk`)
762    ///
763    /// Silent on every miss (no manifest, no `[ffi]` table, parse fail) —
764    /// the entry path is hot (called per analyzed file + per chase target),
765    /// noise would obscure real diagnostics.
766    fn declare_ffi_exports_for(&mut self, file_path: &Path) {
767        let mut dir = file_path.parent().map(Path::to_path_buf);
768        for _ in 0..3 {
769            let Some(d) = dir.as_ref() else { break };
770            let manifest_path = d.join("stryke.toml");
771            if manifest_path.is_file() {
772                if let Ok(m) = crate::pkg::manifest::Manifest::from_path(&manifest_path) {
773                    if let Some(ffi) = m.ffi {
774                        for export in &ffi.exports {
775                            self.declare_sub(export);
776                        }
777                    }
778                }
779                return;
780            }
781            dir = d.parent().map(Path::to_path_buf);
782        }
783    }
784
785    fn analyze_stmt(&mut self, stmt: &Statement) {
786        match &stmt.kind {
787            StmtKind::Package { name } => {
788                self.current_package = name.clone();
789            }
790            StmtKind::My(decls)
791            | StmtKind::Our(decls)
792            | StmtKind::Local(decls)
793            | StmtKind::State(decls)
794            | StmtKind::MySync(decls)
795            | StmtKind::OurSync(decls) => {
796                // `our` / `oursync` inside `package Pkg` declare a
797                // package-global. References from outside the package
798                // use the qualified form `$Pkg::name` — record both
799                // spellings so strict-vars accepts either.
800                let is_package_global =
801                    matches!(stmt.kind, StmtKind::Our(_) | StmtKind::OurSync(_));
802                for d in decls {
803                    match d.sigil {
804                        Sigil::Scalar => {
805                            self.declare_scalar(&d.name);
806                            if is_package_global {
807                                let q = format!("{}::{}", self.current_package, d.name);
808                                self.declare_scalar(&q);
809                            }
810                        }
811                        Sigil::Array => {
812                            self.declare_array(&d.name);
813                            if is_package_global {
814                                let q = format!("{}::{}", self.current_package, d.name);
815                                self.declare_array(&q);
816                            }
817                        }
818                        Sigil::Hash => {
819                            self.declare_hash(&d.name);
820                            if is_package_global {
821                                let q = format!("{}::{}", self.current_package, d.name);
822                                self.declare_hash(&q);
823                            }
824                        }
825                        Sigil::Typeglob => {}
826                    }
827                    if let Some(init) = &d.initializer {
828                        if matches!(d.sigil, Sigil::Scalar) {
829                            if let Some(ty) = self.infer_constructor_type(init) {
830                                self.declare_scalar_type(&d.name, &ty);
831                            }
832                        }
833                        self.analyze_expr(init);
834                    }
835                }
836            }
837            StmtKind::Expression(e) => self.analyze_expr(e),
838            StmtKind::Return(Some(e)) => self.analyze_expr(e),
839            StmtKind::Return(None) => {}
840            StmtKind::If {
841                condition,
842                body,
843                elsifs,
844                else_block,
845            } => {
846                self.analyze_expr(condition);
847                self.push_scope();
848                self.analyze_block(body);
849                self.pop_scope();
850                for (cond, b) in elsifs {
851                    self.analyze_expr(cond);
852                    self.push_scope();
853                    self.analyze_block(b);
854                    self.pop_scope();
855                }
856                if let Some(b) = else_block {
857                    self.push_scope();
858                    self.analyze_block(b);
859                    self.pop_scope();
860                }
861            }
862            StmtKind::Unless {
863                condition,
864                body,
865                else_block,
866            } => {
867                self.analyze_expr(condition);
868                self.push_scope();
869                self.analyze_block(body);
870                self.pop_scope();
871                if let Some(b) = else_block {
872                    self.push_scope();
873                    self.analyze_block(b);
874                    self.pop_scope();
875                }
876            }
877            StmtKind::While {
878                condition,
879                body,
880                continue_block,
881                ..
882            }
883            | StmtKind::Until {
884                condition,
885                body,
886                continue_block,
887                ..
888            } => {
889                self.analyze_expr(condition);
890                self.push_scope();
891                self.analyze_block(body);
892                if let Some(cb) = continue_block {
893                    self.analyze_block(cb);
894                }
895                self.pop_scope();
896            }
897            StmtKind::DoWhile { body, condition } => {
898                self.push_scope();
899                self.analyze_block(body);
900                self.pop_scope();
901                self.analyze_expr(condition);
902            }
903            StmtKind::For {
904                init,
905                condition,
906                step,
907                body,
908                continue_block,
909                ..
910            } => {
911                self.push_scope();
912                if let Some(i) = init {
913                    self.analyze_stmt(i);
914                }
915                if let Some(c) = condition {
916                    self.analyze_expr(c);
917                }
918                if let Some(s) = step {
919                    self.analyze_expr(s);
920                }
921                self.analyze_block(body);
922                if let Some(cb) = continue_block {
923                    self.analyze_block(cb);
924                }
925                self.pop_scope();
926            }
927            StmtKind::Foreach {
928                var,
929                list,
930                body,
931                continue_block,
932                ..
933            } => {
934                self.analyze_expr(list);
935                self.push_scope();
936                self.declare_scalar(var);
937                self.analyze_block(body);
938                if let Some(cb) = continue_block {
939                    self.analyze_block(cb);
940                }
941                self.pop_scope();
942            }
943            StmtKind::SubDecl {
944                name, params, body, ..
945            } => {
946                let fqn = if name.contains("::") {
947                    name.clone()
948                } else {
949                    format!("{}::{}", self.current_package, name)
950                };
951                self.declare_sub(name);
952                self.declare_sub(&fqn);
953                self.push_scope();
954                for p in params {
955                    self.declare_param(p);
956                }
957                self.analyze_block(body);
958                self.pop_scope();
959            }
960            StmtKind::Block(b)
961            | StmtKind::StmtGroup(b)
962            | StmtKind::Begin(b)
963            | StmtKind::End(b)
964            | StmtKind::UnitCheck(b)
965            | StmtKind::Check(b)
966            | StmtKind::Init(b)
967            | StmtKind::Continue(b) => {
968                self.push_scope();
969                self.analyze_block(b);
970                self.pop_scope();
971            }
972            StmtKind::TryCatch {
973                try_block,
974                catch_var,
975                catch_block,
976                finally_block,
977            } => {
978                self.push_scope();
979                self.analyze_block(try_block);
980                self.pop_scope();
981                self.push_scope();
982                self.declare_scalar(catch_var);
983                self.analyze_block(catch_block);
984                self.pop_scope();
985                if let Some(fb) = finally_block {
986                    self.push_scope();
987                    self.analyze_block(fb);
988                    self.pop_scope();
989                }
990            }
991            StmtKind::EvalTimeout { body, .. } => {
992                self.push_scope();
993                self.analyze_block(body);
994                self.pop_scope();
995            }
996            StmtKind::Given { topic, body } => {
997                self.analyze_expr(topic);
998                self.push_scope();
999                self.analyze_block(body);
1000                self.pop_scope();
1001            }
1002            StmtKind::When { cond, body } => {
1003                self.analyze_expr(cond);
1004                self.push_scope();
1005                self.analyze_block(body);
1006                self.pop_scope();
1007            }
1008            StmtKind::DefaultCase { body } => {
1009                self.push_scope();
1010                self.analyze_block(body);
1011                self.pop_scope();
1012            }
1013            StmtKind::LocalExpr {
1014                target,
1015                initializer,
1016            } => {
1017                self.analyze_expr(target);
1018                if let Some(init) = initializer {
1019                    self.analyze_expr(init);
1020                }
1021            }
1022            StmtKind::Goto { target } => {
1023                self.analyze_expr(target);
1024            }
1025            StmtKind::Tie { class, args, .. } => {
1026                self.analyze_expr(class);
1027                for a in args {
1028                    self.analyze_expr(a);
1029                }
1030            }
1031            StmtKind::Use { imports, .. } | StmtKind::No { imports, .. } => {
1032                for e in imports {
1033                    self.analyze_expr(e);
1034                }
1035            }
1036            StmtKind::StructDecl { def } => {
1037                // Walk struct methods with `current_class` set so
1038                // `$self->X` body references resolve against this
1039                // struct's fields + methods.
1040                let prev = self.current_class.take();
1041                self.current_class = Some(def.name.clone());
1042                for m in &def.methods {
1043                    self.push_scope();
1044                    self.declare_scalar("self");
1045                    for p in &m.params {
1046                        self.declare_param(p);
1047                    }
1048                    self.analyze_block(&m.body);
1049                    self.pop_scope();
1050                }
1051                self.current_class = prev;
1052            }
1053            StmtKind::ClassDecl { def } => {
1054                let prev = self.current_class.take();
1055                self.current_class = Some(def.name.clone());
1056                for m in &def.methods {
1057                    if let Some(body) = &m.body {
1058                        self.push_scope();
1059                        self.declare_scalar("self");
1060                        for p in &m.params {
1061                            self.declare_param(p);
1062                        }
1063                        self.analyze_block(body);
1064                        self.pop_scope();
1065                    }
1066                }
1067                self.current_class = prev;
1068            }
1069            StmtKind::EnumDecl { .. }
1070            | StmtKind::TraitDecl { .. }
1071            | StmtKind::FormatDecl { .. }
1072            | StmtKind::AdviceDecl { .. }
1073            | StmtKind::UsePerlVersion { .. }
1074            | StmtKind::UseOverload { .. }
1075            | StmtKind::Last(_)
1076            | StmtKind::Next(_)
1077            | StmtKind::Redo(_)
1078            | StmtKind::Empty => {}
1079        }
1080    }
1081
1082    fn declare_param(&mut self, param: &SubSigParam) {
1083        match param {
1084            SubSigParam::Scalar(name, _, _) => self.declare_scalar(name),
1085            SubSigParam::Array(name, _) => self.declare_array(name),
1086            SubSigParam::Hash(name, _) => self.declare_hash(name),
1087            SubSigParam::ArrayDestruct(elems) => {
1088                for e in elems {
1089                    match e {
1090                        MatchArrayElem::CaptureScalar(n) => self.declare_scalar(n),
1091                        MatchArrayElem::RestBind(n) => self.declare_array(n),
1092                        _ => {}
1093                    }
1094                }
1095            }
1096            SubSigParam::HashDestruct(pairs) => {
1097                for (_, name) in pairs {
1098                    self.declare_scalar(name);
1099                }
1100            }
1101        }
1102    }
1103
1104    fn analyze_block(&mut self, block: &Block) {
1105        for stmt in block {
1106            self.analyze_stmt(stmt);
1107        }
1108    }
1109
1110    /// Check `Type(field => value, …)` / `Type->new(field => value, …)`
1111    /// constructor calls: emit a diagnostic for each fat-comma key
1112    /// that doesn't match a declared field of `type_name`. No-op when
1113    /// `type_name` isn't a known Type (lets ordinary FuncCalls
1114    /// through untouched).
1115    fn check_constructor_keys(&mut self, type_name: &str, args: &[Expr], call_line: usize) {
1116        let bare_tail = type_name.rsplit("::").next().unwrap_or(type_name);
1117        // Pick the resolved class name (qualified or bare). Bail if the
1118        // file doesn't declare any Type by this name.
1119        let resolved = if self.type_fields.contains_key(type_name) {
1120            type_name.to_string()
1121        } else if self.type_fields.contains_key(bare_tail) {
1122            bare_tail.to_string()
1123        } else {
1124            return;
1125        };
1126        // Walk class + parents via `extends`, unioning every declared
1127        // field into one set. `Dog extends Animal { name }` + `Dog
1128        // { breed }` accepts `Dog(name => ..., breed => ...)`.
1129        let mut all_fields: HashSet<String> = HashSet::new();
1130        let mut seen: HashSet<String> = HashSet::new();
1131        let mut queue: Vec<String> = vec![resolved.clone()];
1132        while let Some(c) = queue.pop() {
1133            if !seen.insert(c.clone()) {
1134                continue;
1135            }
1136            if let Some(fs) = self.type_fields.get(&c) {
1137                all_fields.extend(fs.iter().cloned());
1138            }
1139            if let Some(parents) = self.type_parents.get(&c) {
1140                queue.extend(parents.iter().cloned());
1141            }
1142        }
1143        // Detect call style: fat-comma keyed (`Type(k => v, k => v)`)
1144        // vs positional (`Type(1, "title", Priority::High)`). Stryke
1145        // collapses fat-comma to plain args at parse time, so the AST
1146        // shapes are identical. Disambiguate by checking whether the
1147        // FIRST arg matches a declared field name — if not, the call
1148        // is positional and the key check would only generate noise.
1149        let first_arg_is_field = args.first().is_some_and(|a| match &a.kind {
1150            ExprKind::String(s) | ExprKind::Bareword(s) => all_fields.contains(s),
1151            _ => false,
1152        });
1153        let looks_keyed = args.len().is_multiple_of(2)
1154            && first_arg_is_field
1155            && (0..args.len()).step_by(2).all(|i| {
1156                matches!(&args[i].kind, ExprKind::String(_))
1157                    || matches!(&args[i].kind, ExprKind::Bareword(s) if !s.contains("::"))
1158            });
1159        if !looks_keyed {
1160            return;
1161        }
1162        let mut i = 0;
1163        while i < args.len() {
1164            let key_name = match &args[i].kind {
1165                ExprKind::String(s) => Some(s.clone()),
1166                ExprKind::Bareword(s) => Some(s.clone()),
1167                _ => None,
1168            };
1169            if let Some(name) = key_name {
1170                if !all_fields.contains(&name) {
1171                    let line = if args[i].line > 0 {
1172                        args[i].line
1173                    } else {
1174                        call_line
1175                    };
1176                    let bare_for_msg = type_name.rsplit("::").next().unwrap_or(type_name);
1177                    self.error(
1178                        ErrorKind::UndefinedSubroutine,
1179                        format!(
1180                            "Unknown field `{name}` in constructor call to `{bare_for_msg}` — \
1181                             declared fields: {}",
1182                            {
1183                                let mut v: Vec<&String> = all_fields.iter().collect();
1184                                v.sort();
1185                                v.into_iter()
1186                                    .map(String::as_str)
1187                                    .collect::<Vec<_>>()
1188                                    .join(", ")
1189                            }
1190                        ),
1191                        line,
1192                    );
1193                }
1194            }
1195            i += 2;
1196        }
1197    }
1198
1199    /// True when `method` is a field OR method declared on `class_name`
1200    /// OR any ancestor in the `extends` chain. Cycle-guarded so a
1201    /// pathological `A extends B; B extends A` doesn't recur forever.
1202    fn method_resolves_in_hierarchy(&self, class_name: &str, method: &str) -> bool {
1203        // Universal methods inherited from `UNIVERSAL` (Perl) / every
1204        // stryke object. Sourced from `vm_helper.rs` built-in class +
1205        // struct method dispatch — keep in sync when new universal
1206        // methods are added.
1207        //
1208        // - `isa` / `can` / `DOES` / `does` / `VERSION` — Perl UNIVERSAL.
1209        // - `new` / `BUILD` / `DESTROY` / `destroy` — lifecycle hooks.
1210        // - `clone` — deep copy via per-field deep_clone.
1211        // - `with` — functional update returning a new instance with
1212        //   the named fields changed.
1213        // - `to_hash` / `to_hash_rec` / `to_hash_deep` — serialize.
1214        // - `fields` / `methods` / `superclass` — runtime introspection.
1215        if matches!(
1216            method,
1217            "isa"
1218                | "can"
1219                | "DOES"
1220                | "does"
1221                | "VERSION"
1222                | "new"
1223                | "BUILD"
1224                | "DESTROY"
1225                | "destroy"
1226                | "clone"
1227                | "with"
1228                | "to_hash"
1229                | "to_hash_rec"
1230                | "to_hash_deep"
1231                | "fields"
1232                | "methods"
1233                | "superclass"
1234        ) {
1235            return true;
1236        }
1237        let mut seen: HashSet<String> = HashSet::new();
1238        let mut queue: Vec<String> = vec![class_name.to_string()];
1239        while let Some(c) = queue.pop() {
1240            if !seen.insert(c.clone()) {
1241                continue;
1242            }
1243            if self.type_fields.get(&c).is_some_and(|s| s.contains(method)) {
1244                return true;
1245            }
1246            if self
1247                .type_methods
1248                .get(&c)
1249                .is_some_and(|s| s.contains(method))
1250            {
1251                return true;
1252            }
1253            if let Some(parents) = self.type_parents.get(&c) {
1254                queue.extend(parents.iter().cloned());
1255            }
1256        }
1257        false
1258    }
1259
1260    /// Gather every field + method name visible on `class_name` and its
1261    /// ancestors (BFS through `extends`). Used to render the "available:
1262    /// …" suggestion list when a `$self->X` / `$obj->X` lookup fails.
1263    fn collect_hierarchy_members(&self, class_name: &str) -> Vec<String> {
1264        let mut seen: HashSet<String> = HashSet::new();
1265        let mut out: HashSet<String> = HashSet::new();
1266        let mut queue: Vec<String> = vec![class_name.to_string()];
1267        while let Some(c) = queue.pop() {
1268            if !seen.insert(c.clone()) {
1269                continue;
1270            }
1271            if let Some(fs) = self.type_fields.get(&c) {
1272                out.extend(fs.iter().cloned());
1273            }
1274            if let Some(ms) = self.type_methods.get(&c) {
1275                out.extend(ms.iter().cloned());
1276            }
1277            if let Some(parents) = self.type_parents.get(&c) {
1278                queue.extend(parents.iter().cloned());
1279            }
1280        }
1281        let mut v: Vec<String> = out.into_iter().collect();
1282        v.sort();
1283        v
1284    }
1285
1286    fn analyze_expr(&mut self, expr: &Expr) {
1287        match &expr.kind {
1288            // `$#name` — the Perl last-index-of-array form. The parser
1289            // surfaces it as `ScalarVar("#name")`; resolve to the
1290            // underlying `@name` so a defined array satisfies the
1291            // check. Bare `$#` (no name) is the magic "last index of
1292            // $_" form — always defined.
1293            ExprKind::ScalarVar(name)
1294                if self.strict_vars
1295                    && name.len() > 1
1296                    && name.starts_with('#')
1297                    && !self.is_array_defined(&name[1..]) =>
1298            {
1299                self.error(
1300                    ErrorKind::UndefinedVariable,
1301                    format!(
1302                        "Global symbol \"@{}\" requires explicit package name",
1303                        &name[1..]
1304                    ),
1305                    expr.line,
1306                );
1307            }
1308            ExprKind::ScalarVar(name) if name.starts_with('#') => {
1309                // `$#name` with @name defined OR bare `$#` — no-op.
1310            }
1311            ExprKind::ScalarVar(name) if self.strict_vars && !self.is_scalar_defined(name) => {
1312                self.error(
1313                    ErrorKind::UndefinedVariable,
1314                    format!("Global symbol \"${}\" requires explicit package name", name),
1315                    expr.line,
1316                );
1317            }
1318            ExprKind::ArrayVar(name) if self.strict_vars && !self.is_array_defined(name) => {
1319                self.error(
1320                    ErrorKind::UndefinedVariable,
1321                    format!("Global symbol \"@{}\" requires explicit package name", name),
1322                    expr.line,
1323                );
1324            }
1325            ExprKind::HashVar(name) if self.strict_vars && !self.is_hash_defined(name) => {
1326                self.error(
1327                    ErrorKind::UndefinedVariable,
1328                    format!("Global symbol \"%{}\" requires explicit package name", name),
1329                    expr.line,
1330                );
1331            }
1332            ExprKind::ArrayElement { array, index } => {
1333                if self.strict_vars
1334                    && !array.starts_with("__topicstr__")
1335                    && !self.is_array_defined(array)
1336                    && !self.is_scalar_defined(array)
1337                {
1338                    self.error(
1339                        ErrorKind::UndefinedVariable,
1340                        format!(
1341                            "Global symbol \"@{}\" requires explicit package name",
1342                            array
1343                        ),
1344                        expr.line,
1345                    );
1346                }
1347                self.analyze_expr(index);
1348            }
1349            ExprKind::HashElement { hash, key } => {
1350                if self.strict_vars && !self.is_hash_defined(hash) && !self.is_scalar_defined(hash)
1351                {
1352                    self.error(
1353                        ErrorKind::UndefinedVariable,
1354                        format!("Global symbol \"%{}\" requires explicit package name", hash),
1355                        expr.line,
1356                    );
1357                }
1358                self.analyze_expr(key);
1359            }
1360            ExprKind::ArraySlice { array, indices } => {
1361                if self.strict_vars && !self.is_array_defined(array) {
1362                    self.error(
1363                        ErrorKind::UndefinedVariable,
1364                        format!(
1365                            "Global symbol \"@{}\" requires explicit package name",
1366                            array
1367                        ),
1368                        expr.line,
1369                    );
1370                }
1371                for i in indices {
1372                    self.analyze_expr(i);
1373                }
1374            }
1375            ExprKind::HashSlice { hash, keys } => {
1376                if self.strict_vars && !self.is_hash_defined(hash) {
1377                    self.error(
1378                        ErrorKind::UndefinedVariable,
1379                        format!("Global symbol \"%{}\" requires explicit package name", hash),
1380                        expr.line,
1381                    );
1382                }
1383                for k in keys {
1384                    self.analyze_expr(k);
1385                }
1386            }
1387            ExprKind::FuncCall { name, args } => {
1388                if !self.is_sub_defined(name) {
1389                    self.error(
1390                        ErrorKind::UndefinedSubroutine,
1391                        format!("Undefined subroutine &{}", name),
1392                        expr.line,
1393                    );
1394                }
1395                // Constructor-call form `Type(field => value)` — check
1396                // each fat-comma key against the Type's known fields.
1397                self.check_constructor_keys(name, args, expr.line);
1398                for a in args {
1399                    self.analyze_expr(a);
1400                }
1401            }
1402            ExprKind::MethodCall {
1403                object,
1404                method,
1405                args,
1406                ..
1407            } => {
1408                self.analyze_expr(object);
1409                // Method-form constructor `Type->new(field => value)`.
1410                if let ExprKind::Bareword(n) = &object.kind {
1411                    self.check_constructor_keys(n, args, expr.line);
1412                    // Only flag the receiver when the file actually
1413                    // declares some local Types — that's the "user
1414                    // is doing OOP here" signal that justifies typo-
1415                    // catching. Without local Types, every Bareword
1416                    // receiver (`Foo->new`, `IO::Handle->open`, etc.)
1417                    // would be false-positively flagged.
1418                    if !self.type_fields.is_empty()
1419                        && !self.type_fields.contains_key(n)
1420                        && !self.is_sub_defined(n)
1421                    {
1422                        let bare = n.rsplit("::").next().unwrap_or(n);
1423                        if !bare.is_empty()
1424                            && bare.chars().next().is_some_and(|c| c.is_ascii_uppercase())
1425                        {
1426                            self.error(
1427                                ErrorKind::UndefinedSubroutine,
1428                                format!(
1429                                    "Unknown class `{n}` — `{n}->{method}` calls a constructor on a type that isn't declared in this file or its `require`d libs"
1430                                ),
1431                                expr.line,
1432                            );
1433                        }
1434                    }
1435                }
1436                // `$obj->X` when `$obj` was bound to a known Type via
1437                // a constructor expression (`my $p = Point(...)` or
1438                // `my $p = Point->new(...)`). Symmetric to the
1439                // `$self->X` check below — both walk the type's
1440                // field+method sets and flag unknown names.
1441                if let ExprKind::ScalarVar(name) = &object.kind {
1442                    if name != "self" {
1443                        if let Some(class_name) =
1444                            self.resolve_scalar_type(name).map(|s| s.to_string())
1445                        {
1446                            if !self.method_resolves_in_hierarchy(&class_name, method) {
1447                                let suggestions =
1448                                    self.collect_hierarchy_members(&class_name);
1449                                let avail = if suggestions.is_empty() {
1450                                    "(no fields or methods declared)".to_string()
1451                                } else {
1452                                    suggestions.join(", ")
1453                                };
1454                                self.error(
1455                                    ErrorKind::UndefinedSubroutine,
1456                                    format!(
1457                                        "`${name}->{method}` — no field or method `{method}` on `{class_name}`; available: {avail}",
1458                                    ),
1459                                    expr.line,
1460                                );
1461                            }
1462                        }
1463                    }
1464                }
1465                // `$self->X` inside a class/struct body — `X` must
1466                // be a field or method of the enclosing type.
1467                if let ExprKind::ScalarVar(name) = &object.kind {
1468                    if name == "self" {
1469                        if let Some(class_name) = self.current_class.clone() {
1470                            // Walk class + parents via `extends`. Cycle-
1471                            // guarded so a broken `extends A; A extends X`
1472                            // loop can't infinite-loop the linter.
1473                            if !self.method_resolves_in_hierarchy(&class_name, method) {
1474                                let suggestions =
1475                                    self.collect_hierarchy_members(&class_name);
1476                                let avail = if suggestions.is_empty() {
1477                                    "(no fields or methods declared)".to_string()
1478                                } else {
1479                                    suggestions.join(", ")
1480                                };
1481                                self.error(
1482                                    ErrorKind::UndefinedSubroutine,
1483                                    format!(
1484                                        "`$self->{method}` — no field or method `{method}` on `{class_name}`; available: {avail}",
1485                                    ),
1486                                    expr.line,
1487                                );
1488                            }
1489                        }
1490                    }
1491                }
1492                for a in args {
1493                    self.analyze_expr(a);
1494                }
1495            }
1496            ExprKind::IndirectCall { target, args, .. } => {
1497                self.analyze_expr(target);
1498                for a in args {
1499                    self.analyze_expr(a);
1500                }
1501            }
1502            ExprKind::BinOp { left, right, .. } => {
1503                self.analyze_expr(left);
1504                self.analyze_expr(right);
1505            }
1506            ExprKind::UnaryOp { expr: e, .. } => {
1507                self.analyze_expr(e);
1508            }
1509            ExprKind::PostfixOp { expr: e, .. } => {
1510                self.analyze_expr(e);
1511            }
1512            ExprKind::Assign { target, value } => {
1513                if let ExprKind::ScalarVar(name) = &target.kind {
1514                    self.declare_scalar(name);
1515                } else if let ExprKind::ArrayVar(name) = &target.kind {
1516                    self.declare_array(name);
1517                } else if let ExprKind::HashVar(name) = &target.kind {
1518                    self.declare_hash(name);
1519                } else {
1520                    self.analyze_expr(target);
1521                }
1522                self.analyze_expr(value);
1523            }
1524            // `my $x = …` / `our @y = …` / `state %z = …` / `local …` used in
1525            // EXPRESSION position — the canonical case is `while (my $job = ...)
1526            // { … $job … }` and `if (my $row = $db->fetch) { … $row … }`. The
1527            // parser surfaces it as `ExprKind::MyExpr` (see parser.rs:8927); without
1528            // this arm the var is silently dropped from scope tracking and any
1529            // reference in the loop / branch body trips strict-vars. Mirrors the
1530            // `StmtKind::My`/`Our`/`State`/`Local` registration above (qualified
1531            // spelling for `our`, then analyze each initializer).
1532            ExprKind::MyExpr { keyword, decls } => {
1533                let is_package_global = keyword == "our";
1534                for d in decls {
1535                    match d.sigil {
1536                        Sigil::Scalar => {
1537                            self.declare_scalar(&d.name);
1538                            if is_package_global {
1539                                let q = format!("{}::{}", self.current_package, d.name);
1540                                self.declare_scalar(&q);
1541                            }
1542                        }
1543                        Sigil::Array => {
1544                            self.declare_array(&d.name);
1545                            if is_package_global {
1546                                let q = format!("{}::{}", self.current_package, d.name);
1547                                self.declare_array(&q);
1548                            }
1549                        }
1550                        Sigil::Hash => {
1551                            self.declare_hash(&d.name);
1552                            if is_package_global {
1553                                let q = format!("{}::{}", self.current_package, d.name);
1554                                self.declare_hash(&q);
1555                            }
1556                        }
1557                        Sigil::Typeglob => {}
1558                    }
1559                    if let Some(init) = &d.initializer {
1560                        self.analyze_expr(init);
1561                    }
1562                }
1563            }
1564            ExprKind::CompoundAssign { target, value, .. } => {
1565                self.analyze_expr(target);
1566                self.analyze_expr(value);
1567            }
1568            ExprKind::Ternary {
1569                condition,
1570                then_expr,
1571                else_expr,
1572            } => {
1573                self.analyze_expr(condition);
1574                self.analyze_expr(then_expr);
1575                self.analyze_expr(else_expr);
1576            }
1577            ExprKind::List(exprs) | ExprKind::ArrayRef(exprs) => {
1578                for e in exprs {
1579                    self.analyze_expr(e);
1580                }
1581            }
1582            ExprKind::HashRef(pairs) => {
1583                for (k, v) in pairs {
1584                    self.analyze_expr(k);
1585                    self.analyze_expr(v);
1586                }
1587            }
1588            ExprKind::CodeRef { params, body } => {
1589                self.push_scope();
1590                for p in params {
1591                    self.declare_param(p);
1592                }
1593                self.analyze_block(body);
1594                self.pop_scope();
1595            }
1596            ExprKind::ScalarRef(e)
1597            | ExprKind::Deref { expr: e, .. }
1598            | ExprKind::Defined(e)
1599            | ExprKind::Delete(e) => {
1600                self.analyze_expr(e);
1601            }
1602            ExprKind::Exists(e)
1603                // `exists &SUB` and `exists &Pkg::sub` are introspection
1604                // calls — the point is to check whether the sub IS
1605                // defined, so flagging an "undefined" sub here is the
1606                // opposite of helpful. Skip the sub-defined check for
1607                // `SubroutineCodeRef` / `SubroutineRef` payloads;
1608                // everything else (hash keys, array indices) still gets
1609                // the normal analysis.
1610                if !matches!(
1611                    e.kind,
1612                    ExprKind::SubroutineCodeRef(_) | ExprKind::SubroutineRef(_)
1613                ) => {
1614                    self.analyze_expr(e);
1615                }
1616            ExprKind::ArrowDeref { expr, index, kind } => {
1617                self.analyze_expr(expr);
1618                if *kind != DerefKind::Call {
1619                    self.analyze_expr(index);
1620                }
1621            }
1622            ExprKind::Range { from, to, step, .. } => {
1623                self.analyze_expr(from);
1624                self.analyze_expr(to);
1625                if let Some(s) = step {
1626                    self.analyze_expr(s);
1627                }
1628            }
1629            ExprKind::SliceRange { from, to, step } => {
1630                if let Some(f) = from {
1631                    self.analyze_expr(f);
1632                }
1633                if let Some(t) = to {
1634                    self.analyze_expr(t);
1635                }
1636                if let Some(s) = step {
1637                    self.analyze_expr(s);
1638                }
1639            }
1640            ExprKind::InterpolatedString(parts) => {
1641                // Strict-vars policy inside double-quoted interpolations:
1642                // DON'T flag bare `$undef` / `@undef` / `%undef`. Strings
1643                // are commonly used as test descriptions / log messages
1644                // with template-style placeholders that the user doesn't
1645                // intend as hard variable references. Bare `$x + 1` in
1646                // code-context still gets flagged; the string interior
1647                // gets a free pass. Full `#{ EXPR }` blocks (complex
1648                // expressions wrapped in Expr) DO get walked, since
1649                // those are real code — unless the entire expression
1650                // is just a single sigil-var, in which case the same
1651                // pass-through policy applies.
1652                for part in parts {
1653                    match part {
1654                        StringPart::Expr(e) => {
1655                            // Skip the strict-vars check for the simple
1656                            // sigil-var-only shape (`$var` / `@var` / `%var`
1657                            // wrapped in Expr by the parser). For richer
1658                            // expressions (`$x + 1`, fn calls, etc.) walk
1659                            // normally.
1660                            match &e.kind {
1661                                ExprKind::ScalarVar(_)
1662                                | ExprKind::ArrayVar(_)
1663                                | ExprKind::HashVar(_) => {}
1664                                _ => self.analyze_expr(e),
1665                            }
1666                        }
1667                        StringPart::ScalarVar(_)
1668                        | StringPart::ArrayVar(_)
1669                        | StringPart::Literal(_) => {}
1670                    }
1671                }
1672            }
1673            ExprKind::Regex(_, _)
1674            | ExprKind::Substitution { .. }
1675            | ExprKind::Transliterate { .. }
1676            | ExprKind::Match { .. } => {}
1677            ExprKind::HashSliceDeref { container, keys } => {
1678                self.analyze_expr(container);
1679                for k in keys {
1680                    self.analyze_expr(k);
1681                }
1682            }
1683            ExprKind::AnonymousListSlice { source, indices } => {
1684                self.analyze_expr(source);
1685                for i in indices {
1686                    self.analyze_expr(i);
1687                }
1688            }
1689            ExprKind::SubroutineRef(name) | ExprKind::SubroutineCodeRef(name)
1690                if !self.is_sub_defined(name) =>
1691            {
1692                self.error(
1693                    ErrorKind::UndefinedSubroutine,
1694                    format!("Undefined subroutine &{}", name),
1695                    expr.line,
1696                );
1697            }
1698            ExprKind::DynamicSubCodeRef(e) => self.analyze_expr(e),
1699            ExprKind::PostfixIf { expr, condition }
1700            | ExprKind::PostfixUnless { expr, condition }
1701            | ExprKind::PostfixWhile { expr, condition }
1702            | ExprKind::PostfixUntil { expr, condition } => {
1703                self.analyze_expr(expr);
1704                self.analyze_expr(condition);
1705            }
1706            ExprKind::PostfixForeach { expr, list } => {
1707                self.analyze_expr(list);
1708                self.analyze_expr(expr);
1709            }
1710            ExprKind::Do(e) | ExprKind::Eval(e) => {
1711                self.analyze_expr(e);
1712            }
1713            ExprKind::Caller(Some(e)) => {
1714                self.analyze_expr(e);
1715            }
1716            ExprKind::Length(e) => {
1717                self.analyze_expr(e);
1718            }
1719            ExprKind::Print { args, .. }
1720            | ExprKind::Say { args, .. }
1721            | ExprKind::Printf { args, .. } => {
1722                for a in args {
1723                    self.analyze_expr(a);
1724                }
1725            }
1726            ExprKind::Die(args)
1727            | ExprKind::Warn(args)
1728            | ExprKind::Unlink(args)
1729            | ExprKind::Chmod(args)
1730            | ExprKind::System(args)
1731            | ExprKind::Exec(args) => {
1732                for a in args {
1733                    self.analyze_expr(a);
1734                }
1735            }
1736            ExprKind::Push { array, values } | ExprKind::Unshift { array, values } => {
1737                self.analyze_expr(array);
1738                for v in values {
1739                    self.analyze_expr(v);
1740                }
1741            }
1742            ExprKind::Splice {
1743                array,
1744                offset,
1745                length,
1746                replacement,
1747            } => {
1748                self.analyze_expr(array);
1749                if let Some(o) = offset {
1750                    self.analyze_expr(o);
1751                }
1752                if let Some(l) = length {
1753                    self.analyze_expr(l);
1754                }
1755                for r in replacement {
1756                    self.analyze_expr(r);
1757                }
1758            }
1759            ExprKind::MapExpr { block, list, .. } | ExprKind::GrepExpr { block, list, .. } => {
1760                self.push_scope();
1761                self.analyze_block(block);
1762                self.pop_scope();
1763                self.analyze_expr(list);
1764            }
1765            ExprKind::SortExpr { list, .. } => {
1766                self.analyze_expr(list);
1767            }
1768            ExprKind::Open { handle, mode, file } => {
1769                // `open(my $fh, ">", $path)` — `my $fh` is the lexical-
1770                // filehandle declaration Perl idiom. Register the name
1771                // so later `print $fh ...` / `close $fh` lookups pass.
1772                if let ExprKind::OpenMyHandle { name } = &handle.kind {
1773                    self.declare_scalar(name);
1774                } else {
1775                    self.analyze_expr(handle);
1776                }
1777                self.analyze_expr(mode);
1778                if let Some(f) = file {
1779                    self.analyze_expr(f);
1780                }
1781            }
1782            ExprKind::Opendir { handle, path } => {
1783                // `opendir(my $dh, $dir)` — lexical-dirhandle declaration.
1784                if let ExprKind::OpendirMyHandle { name } = &handle.kind {
1785                    self.declare_scalar(name);
1786                } else {
1787                    self.analyze_expr(handle);
1788                }
1789                self.analyze_expr(path);
1790            }
1791            ExprKind::Close(e)
1792            | ExprKind::Pop(e)
1793            | ExprKind::Shift(e)
1794            | ExprKind::Keys(e)
1795            | ExprKind::Values(e)
1796            | ExprKind::Each(e)
1797            | ExprKind::Chdir(e)
1798            | ExprKind::Require(e)
1799            | ExprKind::Ref(e)
1800            | ExprKind::Chomp(e)
1801            | ExprKind::Chop(e)
1802            | ExprKind::Lc(e)
1803            | ExprKind::Uc(e)
1804            | ExprKind::Lcfirst(e)
1805            | ExprKind::Ucfirst(e)
1806            | ExprKind::Abs(e)
1807            | ExprKind::Int(e)
1808            | ExprKind::Sqrt(e)
1809            | ExprKind::Sin(e)
1810            | ExprKind::Cos(e)
1811            | ExprKind::Exp(e)
1812            | ExprKind::Log(e)
1813            | ExprKind::Chr(e)
1814            | ExprKind::Ord(e)
1815            | ExprKind::Hex(e)
1816            | ExprKind::Oct(e)
1817            | ExprKind::Readlink(e)
1818            | ExprKind::Readdir(e)
1819            | ExprKind::Closedir(e)
1820            | ExprKind::Rewinddir(e)
1821            | ExprKind::Telldir(e) => {
1822                self.analyze_expr(e);
1823            }
1824            ExprKind::Exit(Some(e)) | ExprKind::Rand(Some(e)) | ExprKind::Eof(Some(e)) => {
1825                self.analyze_expr(e);
1826            }
1827            ExprKind::Mkdir { path, mode } => {
1828                self.analyze_expr(path);
1829                if let Some(m) = mode {
1830                    self.analyze_expr(m);
1831                }
1832            }
1833            ExprKind::Rename { old, new }
1834            | ExprKind::Link { old, new }
1835            | ExprKind::Symlink { old, new } => {
1836                self.analyze_expr(old);
1837                self.analyze_expr(new);
1838            }
1839            ExprKind::Chown(files) => {
1840                for f in files {
1841                    self.analyze_expr(f);
1842                }
1843            }
1844            ExprKind::Substr {
1845                string,
1846                offset,
1847                length,
1848                replacement,
1849            } => {
1850                self.analyze_expr(string);
1851                self.analyze_expr(offset);
1852                if let Some(l) = length {
1853                    self.analyze_expr(l);
1854                }
1855                if let Some(r) = replacement {
1856                    self.analyze_expr(r);
1857                }
1858            }
1859            ExprKind::Index {
1860                string,
1861                substr,
1862                position,
1863            }
1864            | ExprKind::Rindex {
1865                string,
1866                substr,
1867                position,
1868            } => {
1869                self.analyze_expr(string);
1870                self.analyze_expr(substr);
1871                if let Some(p) = position {
1872                    self.analyze_expr(p);
1873                }
1874            }
1875            ExprKind::Sprintf { format, args } => {
1876                self.analyze_expr(format);
1877                for a in args {
1878                    self.analyze_expr(a);
1879                }
1880            }
1881            ExprKind::Bless { ref_expr, class } => {
1882                self.analyze_expr(ref_expr);
1883                if let Some(c) = class {
1884                    self.analyze_expr(c);
1885                }
1886            }
1887            ExprKind::AlgebraicMatch { subject, arms } => {
1888                self.analyze_expr(subject);
1889                for arm in arms {
1890                    self.check_match_pattern(&arm.pattern, expr.line);
1891                    if let Some(g) = &arm.guard {
1892                        self.analyze_expr(g);
1893                    }
1894                    self.analyze_expr(&arm.body);
1895                }
1896            }
1897            _ => {}
1898        }
1899    }
1900
1901    /// Walk a match arm pattern. The only shape that gets a typo check
1902    /// is `MatchPattern::Value(ExprKind::String("Type::Variant"))` —
1903    /// the parser auto-quotes bareword enum patterns into String, so
1904    /// `Sig::Hup => "..."` arrives here as a String literal, not a
1905    /// FuncCall (which would have already been linted as undefined sub).
1906    /// Other pattern shapes (Regex, Array, Hash, Any, OptionSome) walk
1907    /// their inner exprs for ordinary var-defined-ness checks.
1908    fn check_match_pattern(&mut self, pat: &crate::ast::MatchPattern, line: usize) {
1909        use crate::ast::{MatchArrayElem, MatchHashPair, MatchPattern};
1910        match pat {
1911            MatchPattern::Value(e) => {
1912                if let ExprKind::String(s) = &e.kind {
1913                    self.check_qualified_variant_string(s, line);
1914                }
1915                self.analyze_expr(e);
1916            }
1917            MatchPattern::Array(elems) => {
1918                for el in elems {
1919                    if let MatchArrayElem::Expr(e) = el {
1920                        self.analyze_expr(e);
1921                    }
1922                }
1923            }
1924            MatchPattern::Hash(pairs) => {
1925                for p in pairs {
1926                    match p {
1927                        MatchHashPair::KeyOnly { key } => self.analyze_expr(key),
1928                        MatchHashPair::Capture { key, .. } => self.analyze_expr(key),
1929                    }
1930                }
1931            }
1932            MatchPattern::Any | MatchPattern::Regex { .. } | MatchPattern::OptionSome(_) => {}
1933        }
1934    }
1935
1936    /// `Sig::Term2` arriving as an auto-quoted match-arm pattern. If
1937    /// `Sig` is a known enum and `Term2` isn't one of its variants,
1938    /// emit a typo-catching diagnostic with the available variants
1939    /// listed. Same shape as the `$obj->method` / `Type->new(field => v)`
1940    /// checks already in place.
1941    fn check_qualified_variant_string(&mut self, s: &str, line: usize) {
1942        let Some(idx) = s.rfind("::") else { return };
1943        let type_name = &s[..idx];
1944        let variant = &s[idx + 2..];
1945        if type_name.is_empty() || variant.is_empty() {
1946            return;
1947        }
1948        let type_bare = type_name.rsplit("::").next().unwrap_or(type_name);
1949        let known = self
1950            .type_fields
1951            .get(type_name)
1952            .or_else(|| self.type_fields.get(type_bare));
1953        let Some(variants) = known else { return };
1954        if variants.contains(variant) {
1955            return;
1956        }
1957        let mut available: Vec<&str> = variants.iter().map(String::as_str).collect();
1958        available.sort();
1959        let avail = if available.is_empty() {
1960            "(no variants declared)".to_string()
1961        } else {
1962            available.join(", ")
1963        };
1964        self.error(
1965            ErrorKind::UndefinedSubroutine,
1966            format!(
1967                "`{type_name}::{variant}` — no variant `{variant}` on `{type_name}`; available: {avail}"
1968            ),
1969            line,
1970        );
1971    }
1972}
1973
1974fn is_ident_byte(b: u8) -> bool {
1975    b.is_ascii_alphanumeric() || b == b'_' || b == b':'
1976}
1977
1978fn is_special_var(name: &str) -> bool {
1979    // `main::X` is the qualified form of `X` for every reserved
1980    // variable — `$main::ARGV`, `@main::INC`, `%main::ENV`, `$main::_`,
1981    // etc. (per the Perl Documentation: "certain built-in identifiers
1982    // are forced into the main package"). Strip the prefix and test
1983    // the bare name so the linter doesn't flag the qualified form as
1984    // an undeclared global. Recursing also handles
1985    // `%main::stryke::all` — strip `main::` once, then `stryke::all`
1986    // resolves through the registry's `%stryke::*` reflection family.
1987    if let Some(rest) = name.strip_prefix("main::") {
1988        return is_special_var(rest);
1989    }
1990    if name.len() == 1 {
1991        return true;
1992    }
1993    // Perl `^X`-style special vars: `$^X` (interpreter path),
1994    // `$^O` (OS), `$^V` (version), `$^W` (warnings), `$^T`, `$^R`,
1995    // `$^N`, `$^H`, `$^I`, `$^L`, `$^A`, `$^C`, `$^B`, `$^D`,
1996    // `$^E`, `$^F`, `$^G`, `$^M`, `$^P`, `$^S`, `$^U`, plus the
1997    // `$^{NAME}` long forms (`$^{MATCH}`, `$^{POSTMATCH}`, etc.).
1998    if name.starts_with('^') {
1999        return true;
2000    }
2001    // `$$` — process id. Parser stores it with the sigil included
2002    // (`ScalarVar("$$")`), so the strict-vars check sees a 2-char
2003    // name. Same shape for `$)`, `$(`, `$/`, etc. when carried with
2004    // their literal punctuation as the "name" part.
2005    if name == "$$" {
2006        return true;
2007    }
2008    // Sigil-agnostic check against the canonical SPECIAL_VARS registry in
2009    // builtins.rs (the single source of truth used by `%v` / `%stryke::special_vars`
2010    // and runtime `is_special_var`). The registry stores spellings WITH sigil
2011    // (`%all`, `%limits`, `%stryke::aliases`, `$stryke::VERSION`, …); the
2012    // strict-vars walker calls us with sigil STRIPPED, so we test all three
2013    // sigil prefixes. Catches the reflection hashes (`%all`, `%limits`,
2014    // `%parameters`, `%pc`, `%term`, `%uname`, the `%stryke::*` family,
2015    // `%overload::`), `__FILE__`/`__LINE__`/`__PACKAGE__`/`__SUB__`, and any
2016    // future special-var addition without further edits here.
2017    if crate::builtins::is_special_var(&format!("${}", name))
2018        || crate::builtins::is_special_var(&format!("@{}", name))
2019        || crate::builtins::is_special_var(&format!("%{}", name))
2020        || crate::builtins::is_special_var(name)
2021    {
2022        return true;
2023    }
2024    matches!(
2025        name,
2026        "ARGV"
2027            | "ENV"
2028            | "SIG"
2029            | "INC"
2030            | "AUTOLOAD"
2031            | "STDERR"
2032            | "STDIN"
2033            | "STDOUT"
2034            | "DATA"
2035            | "UNIVERSAL"
2036            | "VERSION"
2037            | "ISA"
2038            | "EXPORT"
2039            | "EXPORT_OK"
2040            // AOP advice-body context vars — declared by the VM at the
2041            // entry of every `before`/`after`/`around` body. Visible
2042            // outside an advice block as ordinary globals (cheap, no
2043            // pollution), so always-defined is the right model.
2044            | "INTERCEPT_NAME"
2045            | "INTERCEPT_ARGS"
2046            | "INTERCEPT_RESULT"
2047            | "INTERCEPT_MS"
2048            | "INTERCEPT_US"
2049            // stryke-VERSION qualifiers + meta-constants commonly
2050            // referenced in module headers and never declared.
2051            | "stryke::VERSION"
2052    )
2053}
2054
2055/// Stryke implicit closure-positional slots — `_0`, `_1`, …, `_99`. These
2056/// are auto-bound inside any block that takes positional args (sort
2057/// comparators, reduce blocks, sub bodies, map/grep blocks) and must never
2058/// be flagged as undeclared by `stryke check` regardless of strict mode.
2059/// Mirrors the scalar exemption at `vm_helper.rs:strict_scalar_exempt`,
2060/// extended uniformly to scalar / array / hash sigils — `$_1`, `@_1[0]`,
2061/// `%_1{k}` are all legitimate topic-var spellings.
2062fn is_topic_var(name: &str) -> bool {
2063    // Stryke block-param grammar (sigil already stripped):
2064    //   `_`                — bare topic
2065    //   `_N`               — Nth positional arg
2066    //   `_<<<<<`           — outer-chain, any depth of `<`
2067    //   `_<N`              — indexed-ascent shortcut
2068    //   `_N<<<<<` / `_N<M` — positional + outer chain combined
2069    // Pattern: `_` then (digits? then chevrons? then digits?), with at
2070    // least one chevron OR digit after `_<` to disambiguate from a
2071    // bare `_<` operator pair.
2072    if !name.starts_with('_') {
2073        return false;
2074    }
2075    let rest = &name[1..];
2076    if rest.is_empty() {
2077        return true; // bare `_`
2078    }
2079    let bytes = rest.as_bytes();
2080    let mut i = 0;
2081    // Optional positional digits.
2082    while i < bytes.len() && bytes[i].is_ascii_digit() {
2083        i += 1;
2084    }
2085    let digits_consumed = i;
2086    if i == bytes.len() {
2087        // `_N` — pure positional. Must have at least one digit.
2088        return digits_consumed > 0;
2089    }
2090    // Optional `<...` outer-chain segment.
2091    if bytes[i] != b'<' {
2092        return false;
2093    }
2094    while i < bytes.len() && bytes[i] == b'<' {
2095        i += 1;
2096    }
2097    // Optional trailing digits (indexed-ascent shortcut).
2098    while i < bytes.len() && bytes[i].is_ascii_digit() {
2099        i += 1;
2100    }
2101    i == bytes.len()
2102}
2103
2104/// Map a `require` spec to a filesystem path. `./path` and `../path` resolve
2105/// against the project root derived from the source file's location. Perl
2106/// convention: scripts under `t/` / `tests/` / `test/` / `spec/` / `xt/`
2107/// sit one level below the project root that holds `lib/`. Any other layout:
2108/// the file's own directory IS the project root. One path computation, no
2109/// walking. Bareword `Foo::Bar` becomes `<root>/lib/Foo/Bar.pm`. Absolute
2110/// paths pass through. Shared by the static analyzer's require-follower
2111/// and the LSP go-to-definition cross-file lookup.
2112pub fn resolve_require_path_from_file(file: &str, spec: &str) -> Option<PathBuf> {
2113    resolve_require_path_from_file_versioned(file, spec, None)
2114}
2115
2116/// Version-aware variant: `pin_version = Some(v)` mirrors the runtime's
2117/// `use Module VERSION` pin — the lockfile / installed-index tiers are
2118/// skipped and we look up `<store>/<name>@<v>/` directly. `None`
2119/// preserves the legacy 2-arm + global-index precedence so plain
2120/// `use Foo` linting is unchanged.
2121pub fn resolve_require_path_from_file_versioned(
2122    file: &str,
2123    spec: &str,
2124    pin_version: Option<&str>,
2125) -> Option<PathBuf> {
2126    let p = Path::new(spec);
2127    if p.is_absolute() {
2128        return p.exists().then(|| p.to_path_buf());
2129    }
2130    let file_dir = Path::new(file)
2131        .parent()
2132        .map(Path::to_path_buf)
2133        .unwrap_or_else(|| PathBuf::from("."));
2134    // Detect project root by walking UP from the source file's
2135    // directory looking for a sibling `lib/`. Typical Perl/CPAN
2136    // layout: project/lib/Foo/Bar.pm with `require "./lib/Foo/Bar.stk"`
2137    // anywhere in the tree resolving back to project/. Falls back to:
2138    // (1) the file's own directory if no ancestor has `lib/`;
2139    // (2) the parent directory when the file sits in `t/tests/test/
2140    // spec/xt` (the classic test layout).
2141    let project_root = find_project_root(&file_dir);
2142    // Also try the file's own directory as a same-dir fallback (e.g.
2143    // siblings in the same folder use `./sibling.stk`).
2144    let candidate_via_root = if spec.starts_with("./") || spec.starts_with("../") {
2145        project_root.join(spec)
2146    } else if spec.contains("::") {
2147        let relpath: PathBuf = PathBuf::from(spec.replace("::", "/")).with_extension("pm");
2148        project_root.join("lib").join(relpath)
2149    } else {
2150        project_root.join(spec)
2151    };
2152    if candidate_via_root.exists() {
2153        return Some(candidate_via_root);
2154    }
2155    // Fallback: resolve `./foo.stk` against the file's own directory
2156    // for the simple sibling-script case.
2157    if spec.starts_with("./") || spec.starts_with("../") {
2158        let direct = file_dir.join(spec);
2159        if direct.exists() {
2160            return Some(direct);
2161        }
2162    }
2163
2164    // Also try the stryke convention `<root>/lib/<First>/<Rest>.stk` for
2165    // `use Foo::Bar::Baz` (the existing arm above uses the Perl `.pm`
2166    // suffix). The runtime resolver at `pkg::commands::resolve_module`
2167    // arm 1 uses `.stk`; mirror it here so the analyzer accepts both.
2168    if !spec.starts_with("./") && !spec.starts_with("../") {
2169        let segments: Vec<&str> = spec.split("::").collect();
2170        if !segments.is_empty() && !segments.iter().any(|s| s.is_empty()) {
2171            let mut local_stk = project_root.join("lib");
2172            for s in &segments[..segments.len() - 1] {
2173                local_stk = local_stk.join(s);
2174            }
2175            local_stk = local_stk.join(format!("{}.stk", segments[segments.len() - 1]));
2176            if local_stk.exists() {
2177                return Some(local_stk);
2178            }
2179        }
2180    }
2181
2182    // ── Arm 1b: use-site version pin (`use Module VERSION`) ───────────
2183    // Direct store lookup, no lockfile / index consult — mirrors
2184    // `pkg::commands::resolve_module`'s pin arm so the linter chases
2185    // the *pinned* version's lib/. Skips silently on miss (analyzer
2186    // is best-effort; runtime is the layer that errors on a missing
2187    // pinned version).
2188    let segments: Vec<&str> = spec.split("::").collect();
2189    if !segments.is_empty() && !segments.iter().any(|s| s.is_empty()) {
2190        if let Some(v) = pin_version {
2191            if let Ok(store) = crate::pkg::store::Store::user_default() {
2192                let pkg_name_lower = segments[0].to_lowercase();
2193                for nm in [pkg_name_lower.clone(), format!("stryke-{}", pkg_name_lower)] {
2194                    let store_pkg = store.package_dir(&nm, v);
2195                    let resolved = build_pkg_lib_path(&store_pkg, &segments);
2196                    if resolved.is_file() {
2197                        return Some(resolved);
2198                    }
2199                }
2200            }
2201            // Pin requested but not found in store. Don't fall through
2202            // — silently substituting a different version would mirror
2203            // the runtime version-disrespect bug in the analyzer.
2204            return None;
2205        }
2206    }
2207    // ── Arm 2: project lockfile → store ───────────────────────────────
2208    // When the script lives in a stryke project, `stryke.lock` pins
2209    // exact versions of every declared dep. Mirrors arm 2 of the
2210    // runtime resolver (`pkg::commands::resolve_module`). The
2211    // `stryke-<name>` fallback bridges `use AWS` (lookup key `"aws"`)
2212    // to a `stryke-aws` lockfile entry — otherwise the linter can't
2213    // chase the import and reports every AWS::* sub as undefined.
2214    if !segments.is_empty() && !segments.iter().any(|s| s.is_empty()) {
2215        if let Some(stryke_root) = crate::pkg::commands::find_project_root(Path::new(file)) {
2216            let lock_path = stryke_root.join(crate::pkg::commands::LOCKFILE_FILE);
2217            if lock_path.is_file() {
2218                if let Ok(lf) = crate::pkg::lockfile::Lockfile::from_path(&lock_path) {
2219                    let pkg_name = segments[0].to_lowercase();
2220                    let prefixed = format!("stryke-{}", pkg_name);
2221                    let entry = lf.find(&pkg_name).or_else(|| lf.find(&prefixed));
2222                    if let Some(entry) = entry {
2223                        if let Ok(store) = crate::pkg::store::Store::user_default() {
2224                            // Lockfile entries record the dep alias
2225                            // (`postgres`) while the store dir uses the
2226                            // canonical name (`stryke-postgres@<ver>/`).
2227                            // Try both. Mirrors runtime resolver's
2228                            // identical fix (see resolve_store_candidates
2229                            // in pkg::commands).
2230                            for store_pkg in store_dir_candidates(&store, &entry.name, &entry.version) {
2231                                let resolved = build_pkg_lib_path(&store_pkg, &segments);
2232                                if resolved.is_file() {
2233                                    return Some(resolved);
2234                                }
2235                            }
2236                        }
2237                    }
2238                }
2239            }
2240        }
2241
2242        // ── Arm 3: outside-project store scan → highest version ───────
2243        // Standalone scripts (no `stryke.toml` ancestor) load the
2244        // newest extraction of the named package from the store. Same
2245        // contract as `pkg::commands::resolve_module`'s arm 3: scan
2246        // `<store>/<canonical>@*/` for the namespace's canonical
2247        // names, pick the highest semver. Mirrors so the linter
2248        // chases the SAME file the runtime will load.
2249        if let Ok(store) = crate::pkg::store::Store::user_default() {
2250            let names = crate::pkg::commands::canonical_store_names_for_namespace(
2251                &store,
2252                &segments[0].to_lowercase(),
2253            );
2254            if let Some((name, version)) =
2255                crate::pkg::commands::scan_store_for_highest_version(&store, &names)
2256            {
2257                let store_pkg = store.package_dir(&name, &version);
2258                let resolved = build_pkg_lib_path(&store_pkg, &segments);
2259                if resolved.is_file() {
2260                    return Some(resolved);
2261                }
2262            }
2263        }
2264    }
2265
2266    None
2267}
2268
2269/// Same candidate list as the runtime's `pkg::commands::resolve_store_candidates`.
2270/// Tries the entry name as-is, then `stryke-<name>` when not already prefixed.
2271/// Covers the lockfile-records-alias / store-dir-uses-canonical split.
2272fn store_dir_candidates(
2273    store: &crate::pkg::store::Store,
2274    name: &str,
2275    version: &str,
2276) -> Vec<PathBuf> {
2277    let mut out = Vec::with_capacity(2);
2278    out.push(store.package_dir(name, version));
2279    if !name.starts_with("stryke-") {
2280        out.push(store.package_dir(&format!("stryke-{}", name), version));
2281    }
2282    out
2283}
2284
2285/// Build the `<store_pkg>/lib/<rest>.stk` path for a `use Foo::Bar::Baz`
2286/// segment list. Mirrors `pkg::commands::resolve_module`'s
2287/// `segments_to_path` logic: `["GUI"]` → `lib/GUI.stk`,
2288/// `["GUI", "Mouse"]` → `lib/Mouse.stk`,
2289/// `["GUI", "Sub", "Mouse"]` → `lib/Sub/Mouse.stk`. The first segment
2290/// is the package name (already used as the index lookup key) and
2291/// drops out of the path for multi-segment specs.
2292fn build_pkg_lib_path(store_pkg: &Path, segments: &[&str]) -> PathBuf {
2293    debug_assert!(!segments.is_empty());
2294    let mut p = store_pkg.join("lib");
2295    if segments.len() == 1 {
2296        p.join(format!("{}.stk", segments[0]))
2297    } else {
2298        for s in &segments[1..segments.len() - 1] {
2299            p = p.join(s);
2300        }
2301        p.join(format!("{}.stk", segments[segments.len() - 1]))
2302    }
2303}
2304
2305/// Walk UP from `start_dir` looking for an ancestor that has a `lib/`
2306/// subdirectory (typical Perl/CPAN project layout). Returns the
2307/// first such ancestor, falling back to the original directory
2308/// (or its parent when it's `t`/`tests`/`test`/`spec`/`xt`).
2309fn find_project_root(start_dir: &Path) -> PathBuf {
2310    let mut cur = start_dir.to_path_buf();
2311    for _ in 0..16 {
2312        // capped depth — pathological infinite-symlink protection
2313        if cur.join("lib").is_dir() {
2314            return cur;
2315        }
2316        match cur.parent() {
2317            Some(p) if p != cur => cur = p.to_path_buf(),
2318            _ => break,
2319        }
2320    }
2321    match start_dir.file_name().and_then(|s| s.to_str()) {
2322        Some("t") | Some("tests") | Some("test") | Some("spec") | Some("xt") => start_dir
2323            .parent()
2324            .map(Path::to_path_buf)
2325            .unwrap_or_else(|| start_dir.to_path_buf()),
2326        _ => start_dir.to_path_buf(),
2327    }
2328}
2329
2330/// If `e` is a literal string / single-segment interpolated string / bareword,
2331/// return its constant text. Used for `require "LITERAL"` / `require Mod::Name`.
2332/// Returns `None` for any dynamic expression — the analyzer can't follow those.
2333pub fn static_string_value(e: &Expr) -> Option<String> {
2334    match &e.kind {
2335        ExprKind::String(s) => Some(s.clone()),
2336        ExprKind::Bareword(s) => Some(s.clone()),
2337        ExprKind::InterpolatedString(parts) => {
2338            // All parts must be literal text — no variable interpolation.
2339            let mut out = String::new();
2340            for part in parts {
2341                match part {
2342                    StringPart::Literal(s) => out.push_str(s),
2343                    _ => return None,
2344                }
2345            }
2346            Some(out)
2347        }
2348        _ => None,
2349    }
2350}
2351/// `analyze_program` — see implementation.
2352pub fn analyze_program(program: &Program, file: &str) -> StrykeResult<()> {
2353    StaticAnalyzer::new(file).analyze(program)
2354}
2355
2356/// Same as [`analyze_program`] but emits strict-vars-style undefined-symbol
2357/// errors only when the source itself opted into strict (`use strict;`).
2358/// `stryke check` calls this with `strict_vars = false` so the lint pass is
2359/// a parse + compile gate, not a strict-vars enforcer.
2360pub fn analyze_program_with_strict(
2361    program: &Program,
2362    file: &str,
2363    strict_vars: bool,
2364) -> StrykeResult<()> {
2365    StaticAnalyzer::with_strict_vars(file, strict_vars).analyze(program)
2366}
2367
2368#[cfg(test)]
2369mod tests {
2370    use super::*;
2371    use crate::parse_with_file;
2372
2373    /// Test helper: run the analyzer with `strict_vars=true` so the
2374    /// undefined-variable detection paths actually fire. The default
2375    /// `analyze_program` entry point is lenient (parse + compile gate
2376    /// for `stryke check` — strict-vars-style errors are gated on the
2377    /// source actually doing `use strict;`); this helper exercises the
2378    /// strict-on path that the rest of the tests below assume.
2379    fn lint(code: &str) -> StrykeResult<()> {
2380        let prog = parse_with_file(code, "test.stk").expect("parse");
2381        analyze_program_with_strict(&prog, "test.stk", true)
2382    }
2383
2384    #[test]
2385    fn undefined_scalar_detected() {
2386        let r = lint("p $undefined");
2387        assert!(r.is_err());
2388        let e = r.unwrap_err();
2389        assert_eq!(e.kind, ErrorKind::UndefinedVariable);
2390        assert!(e.message.contains("$undefined"));
2391    }
2392
2393    #[test]
2394    fn defined_scalar_ok() {
2395        assert!(lint("my $x = 1; p $x").is_ok());
2396    }
2397
2398    #[test]
2399    fn undefined_sub_detected() {
2400        let r = lint("nonexistent_function()");
2401        assert!(r.is_err());
2402        let e = r.unwrap_err();
2403        assert_eq!(e.kind, ErrorKind::UndefinedSubroutine);
2404        assert!(e.message.contains("nonexistent_function"));
2405    }
2406
2407    #[test]
2408    fn defined_sub_ok() {
2409        assert!(lint("fn foo { 1 } foo()").is_ok());
2410    }
2411
2412    #[test]
2413    fn builtin_sub_ok() {
2414        assert!(lint("p 'hello'").is_ok());
2415        assert!(lint("print 'hello'").is_ok());
2416        assert!(lint("my @x = map { $_ * 2 } 1..3").is_ok());
2417    }
2418
2419    #[test]
2420    fn special_vars_ok() {
2421        assert!(lint("p $_").is_ok());
2422        assert!(lint("p @_").is_ok());
2423        assert!(lint("p $a <=> $b").is_ok());
2424    }
2425
2426    /// `my $x = …` / `our $x = …` / `state $x = …` in expression position —
2427    /// canonical case is `while (my $job = ...) { … $job … }`. Before the
2428    /// `ExprKind::MyExpr` arm in `analyze_expr`, the declaration was silently
2429    /// dropped from scope tracking and any body reference tripped
2430    /// strict-vars. Pin all the conditional-expression contexts where the
2431    /// pattern is idiomatic so regressions get caught at unit-test time
2432    /// rather than waiting for the full `examples_strict_lint` sweep.
2433    #[test]
2434    fn my_in_while_condition_scopes_var_for_body() {
2435        assert!(lint("while (my $line = readline(STDIN)) { p $line; }").is_ok());
2436    }
2437
2438    #[test]
2439    fn my_in_if_condition_scopes_var_for_body() {
2440        assert!(lint("if (my $x = 42) { p $x; }").is_ok());
2441    }
2442
2443    #[test]
2444    fn my_in_until_condition_scopes_var_for_body() {
2445        assert!(lint("until (my $done = 1) { p $done; last; }").is_ok());
2446    }
2447
2448    /// `our` in expression position must register BOTH the bare spelling and
2449    /// the package-qualified spelling so a sibling fn can reach the global
2450    /// either way. Mirrors the `StmtKind::Our` branch.
2451    #[test]
2452    fn our_in_expression_position_registers_qualified_spelling() {
2453        assert!(
2454            lint("package Foo; if (our $cfg = 1) { p $cfg; p $Foo::cfg; }").is_ok(),
2455            "expression-position `our` should register both `$cfg` and `$Foo::cfg`"
2456        );
2457    }
2458
2459    /// `MyExpr` scoping logic must work for array and hash sigils too, not
2460    /// just scalar. NOTE: the bytecode compiler currently rejects non-scalar
2461    /// MyExpr (`compiler.rs:8367` — `Unsupported`), so these scripts won't
2462    /// run end-to-end yet. The analyzer pin still holds — it asserts the
2463    /// SCOPING behaviour (decl-in-condition propagates to body) which is
2464    /// correct regardless of code-gen support, and will stay correct when
2465    /// the compiler restriction is eventually lifted.
2466    #[test]
2467    fn my_in_expression_position_array_and_hash() {
2468        assert!(lint("if (my @rows = (1, 2, 3)) { p $_ for @rows; }").is_ok());
2469        assert!(lint("if (my %m = (a => 1)) { p $_ for keys %m; }").is_ok());
2470    }
2471
2472    /// Negative pin: the analyzer must STILL flag a genuinely undefined var
2473    /// even after the MyExpr/sigil-agnostic-special-var changes. Guards
2474    /// against an over-broad allowlist regressing to "everything passes".
2475    #[test]
2476    fn undefined_var_still_flagged_after_relaxations() {
2477        let r = lint("p $deliberately_never_declared");
2478        assert!(r.is_err(), "strict-vars must still catch real undefs");
2479        assert_eq!(r.unwrap_err().kind, ErrorKind::UndefinedVariable);
2480    }
2481
2482    /// Sigil-agnostic `is_special_var` delegation to builtins::SPECIAL_VARS must
2483    /// be a WHITELIST, not a blanket pass. A made-up name that ISN'T in the
2484    /// canonical registry must still trip strict-vars in contexts the analyzer
2485    /// inspects. Pin guards against a future "always return true" regression in
2486    /// the delegation path, which would silently mask typo'd hash / array /
2487    /// scalar names.
2488    ///
2489    /// Scope note: the analyzer currently treats bare assignment (`$x = …`) as
2490    /// auto-vivification (Perl-style — declares on first write) and doesn't
2491    /// recurse into every builtin's arguments (`keys %x` doesn't check `%x`).
2492    /// Those are independent, pre-existing behaviours; this pin uses contexts
2493    /// the analyzer DOES walk — bare scalar/array/hash references — so the
2494    /// delegation negative path is exercised cleanly.
2495    #[test]
2496    fn sigil_agnostic_special_var_does_not_pass_arbitrary_names() {
2497        for src in [
2498            "p $absolutely_not_a_special_var",
2499            "my %x = %totally_made_up_reflection_hash",
2500            "my @y = @arr_that_was_never_declared",
2501        ] {
2502            let r = lint(src);
2503            assert!(
2504                r.is_err(),
2505                "strict-vars must still flag arbitrary undef vars, accepted: {src}"
2506            );
2507            assert_eq!(r.unwrap_err().kind, ErrorKind::UndefinedVariable);
2508        }
2509    }
2510
2511    /// Nested MyExpr — `while (my $a = …) { if (my $b = …) { … $a … $b … } }`
2512    /// is the natural shape for "read a row, parse a field" pipelines. Both
2513    /// outer (`$a`) and inner (`$b`) decls must be visible inside the inner
2514    /// body. Pins the scope-stack arithmetic for the recursive `analyze_block`
2515    /// calls under the `ExprKind::MyExpr` arm — outer-scope vars from a prior
2516    /// MyExpr stay reachable from inside a deeper push_scope/pop_scope frame.
2517    ///
2518    /// Visibility scope note: a MyExpr decl in `if`/`while` condition position
2519    /// is declared in the *surrounding* scope (the condition is analyzed before
2520    /// `push_scope` for the body), so the var remains visible after the block
2521    /// closes. That's the analyzer's current model — Perl scopes it more
2522    /// tightly to the block, but tightening here would require condition-only
2523    /// scope frames. Pin captures the propagation-into-body behaviour that
2524    /// matters for the strict-vars false-positive fix; the post-block leak
2525    /// is left as-is for now.
2526    #[test]
2527    fn nested_my_in_conditions_both_scopes_propagate() {
2528        assert!(
2529            lint(
2530                "while (my $a = readline(STDIN)) { \
2531                   if (my $b = length($a)) { p \"$a → $b\"; } \
2532                 }"
2533            )
2534            .is_ok(),
2535            "nested MyExpr in while+if must scope both decls correctly"
2536        );
2537    }
2538
2539    /// Multi-character reflection hash names (`%all`, `%limits`, `%pc`, the
2540    /// `%stryke::*` family, `%overload::`) and the `__FILE__` / `__LINE__` /
2541    /// `__PACKAGE__` / `__SUB__` script-position pseudo-vars must NOT trip
2542    /// strict-vars. They live in the canonical `SPECIAL_VARS` registry in
2543    /// builtins.rs and are surfaced via `%v` / `%stryke::special_vars`. This
2544    /// pin guards against regression of the sigil-agnostic delegation added
2545    /// to `is_special_var` — without it, `keys %all` and friends fail in the
2546    /// IDE diagnostic path (always strict) and in any script that opts in
2547    /// with `use strict`.
2548    #[test]
2549    fn reflection_and_meta_special_vars_ok_under_strict() {
2550        for src in [
2551            "p scalar keys %all",
2552            "p scalar keys %b",
2553            "p scalar keys %limits",
2554            "p scalar keys %parameters",
2555            "p scalar keys %pc",
2556            "p scalar keys %term",
2557            "p scalar keys %uname",
2558            "p scalar keys %stryke::all",
2559            "p scalar keys %stryke::builtins",
2560            "p scalar keys %stryke::aliases",
2561            "p scalar keys %stryke::categories",
2562            "p scalar keys %stryke::descriptions",
2563            "p scalar keys %stryke::extensions",
2564            "p scalar keys %stryke::keywords",
2565            "p scalar keys %stryke::operators",
2566            "p scalar keys %stryke::perl_compats",
2567            "p scalar keys %stryke::primaries",
2568            "p scalar keys %stryke::special_vars",
2569            "p scalar keys %overload::",
2570            "p $stryke::VERSION",
2571            "p __FILE__",
2572            "p __LINE__",
2573            "p __PACKAGE__",
2574            // `main::X` qualified form of every reserved variable.
2575            // Per the Perl Documentation: "certain built-in identifiers
2576            // are forced into the main package". The linter must not
2577            // flag the qualified spelling as undeclared. Mirrors the
2578            // runtime canonicalization via `strip_main_prefix` in
2579            // `scope.rs`.
2580            "p $main::ARGV",
2581            "p $main::_",
2582            "p $main::!",
2583            "p $main::@",
2584            "p $main::/",
2585            "p $main::0",
2586            "p @main::ARGV",
2587            "p @main::INC",
2588            "p @main::F",
2589            "p %main::ENV",
2590            "p %main::INC",
2591            "p %main::SIG",
2592            "p %main::stryke::all",
2593        ] {
2594            assert!(
2595                lint(src).is_ok(),
2596                "strict-vars false-positive on legitimate stryke special var: {src}"
2597            );
2598        }
2599    }
2600
2601    #[test]
2602    fn foreach_var_in_scope() {
2603        assert!(lint("foreach my $i (1..3) { p $i; }").is_ok());
2604    }
2605
2606    #[test]
2607    fn sub_params_in_scope() {
2608        assert!(lint("fn foo($x) { p $x; } foo(1)").is_ok());
2609    }
2610
2611    #[test]
2612    fn assignment_declares_var() {
2613        assert!(lint("$x = 1; p $x").is_ok());
2614    }
2615
2616    #[test]
2617    fn builtin_inc_ok() {
2618        assert!(lint("my $x = 1; inc($x)").is_ok());
2619    }
2620
2621    #[test]
2622    fn builtin_dec_ok() {
2623        assert!(lint("my $x = 1; dec($x)").is_ok());
2624    }
2625
2626    #[test]
2627    fn builtin_rev_ok() {
2628        assert!(lint("my $s = rev 'hello'").is_ok());
2629    }
2630
2631    #[test]
2632    fn builtin_p_alias_for_say_ok() {
2633        assert!(lint("p 'hello'").is_ok());
2634    }
2635
2636    #[test]
2637    fn builtin_t_thread_ok() {
2638        assert!(lint("t 1 inc inc").is_ok());
2639    }
2640
2641    #[test]
2642    fn thread_with_undefined_var_detected() {
2643        let r = lint("t $undefined inc");
2644        assert!(r.is_err());
2645    }
2646
2647    #[test]
2648    fn try_catch_var_in_scope() {
2649        assert!(lint("try { die 'err'; } catch ($e) { p $e; }").is_ok());
2650    }
2651
2652    #[test]
2653    fn interpolated_string_undefined_var() {
2654        // Policy change (post-test_bugs_phase3_pin): bare `$undef`
2655        // inside `"..."` is a free pass. String interpolation is used
2656        // as template/description text in tests + log messages, and
2657        // false positives on `"got $fh ..."` style test descriptions
2658        // were the dominant noise source. Strict-vars on bare code-
2659        // context references is unchanged.
2660        assert!(
2661            lint(r#"p "hello $undefined""#).is_ok(),
2662            "undefined scalar inside string-interp must NOT flag",
2663        );
2664        // The check still fires in code context.
2665        assert!(lint(r#"use strict; p $undefined"#).is_err());
2666    }
2667
2668    #[test]
2669    fn interpolated_string_defined_var_ok() {
2670        assert!(lint(r#"my $x = 1; p "hello $x""#).is_ok());
2671    }
2672
2673    #[test]
2674    fn coderef_params_in_scope() {
2675        assert!(lint("my $f = fn ($x) { p $x; }; $f->(1)").is_ok());
2676    }
2677
2678    #[test]
2679    fn nested_sub_scope() {
2680        assert!(lint("fn wrap { my $x = 1; fn inner { p $x; } }").is_ok());
2681    }
2682
2683    #[test]
2684    fn hash_element_access_ok() {
2685        assert!(lint("my %h = (a => 1); p $h{a}").is_ok());
2686    }
2687
2688    #[test]
2689    fn array_element_access_ok() {
2690        assert!(lint("my @a = (1, 2, 3); p $a[0]").is_ok());
2691    }
2692
2693    #[test]
2694    fn undefined_hash_detected() {
2695        let r = lint("p $undefined_hash{key}");
2696        assert!(r.is_err());
2697    }
2698
2699    #[test]
2700    fn undefined_array_detected() {
2701        let r = lint("p $undefined_array[0]");
2702        assert!(r.is_err());
2703    }
2704
2705    #[test]
2706    fn map_with_topic_ok() {
2707        assert!(lint("my @x = map { $_ * 2 } 1..3").is_ok());
2708    }
2709
2710    #[test]
2711    fn grep_with_topic_ok() {
2712        assert!(lint("my @x = grep { $_ > 1 } 1..3").is_ok());
2713    }
2714
2715    #[test]
2716    fn sort_with_ab_ok() {
2717        assert!(lint("my @x = sort { $a <=> $b } 1..3").is_ok());
2718    }
2719
2720    #[test]
2721    fn ternary_undefined_var_detected() {
2722        let r = lint("my $x = $undefined ? 1 : 0");
2723        assert!(r.is_err());
2724    }
2725
2726    #[test]
2727    fn binop_undefined_var_detected() {
2728        let r = lint("my $x = 1 + $undefined");
2729        assert!(r.is_err());
2730    }
2731
2732    #[test]
2733    fn postfix_if_undefined_detected() {
2734        let r = lint("p 'x' if $undefined");
2735        assert!(r.is_err());
2736    }
2737
2738    #[test]
2739    fn while_loop_var_ok() {
2740        assert!(lint("my $i = 0; while ($i < 10) { p $i; $i++; }").is_ok());
2741    }
2742
2743    #[test]
2744    fn for_loop_init_var_in_scope() {
2745        assert!(lint("for (my $i = 0; $i < 10; $i++) { p $i; }").is_ok());
2746    }
2747
2748    #[test]
2749    fn given_when_ok() {
2750        assert!(lint("my $x = 1; given ($x) { when (1) { p 'one'; } }").is_ok());
2751    }
2752
2753    #[test]
2754    fn arrow_deref_ok() {
2755        assert!(lint("my $h = { a => 1 }; p $h->{a}").is_ok());
2756    }
2757
2758    #[test]
2759    fn method_call_ok() {
2760        assert!(lint("my $obj = bless {}, 'Foo'; $obj->method()").is_ok());
2761    }
2762
2763    #[test]
2764    fn push_builtin_ok() {
2765        assert!(lint("my @a; push @a, 1, 2, 3").is_ok());
2766    }
2767
2768    #[test]
2769    fn splice_builtin_ok() {
2770        assert!(lint("my @a = (1, 2, 3); splice @a, 1, 1, 'x'").is_ok());
2771    }
2772
2773    #[test]
2774    fn substr_builtin_ok() {
2775        assert!(lint("my $s = 'hello'; p substr($s, 0, 2)").is_ok());
2776    }
2777
2778    #[test]
2779    fn sprintf_builtin_ok() {
2780        assert!(lint("my $s = sprintf('%d', 42)").is_ok());
2781    }
2782
2783    #[test]
2784    fn range_ok() {
2785        assert!(lint("my @a = 1..10").is_ok());
2786    }
2787
2788    #[test]
2789    fn qw_ok() {
2790        assert!(lint("my @a = qw(a b c)").is_ok());
2791    }
2792
2793    #[test]
2794    fn regex_ok() {
2795        assert!(lint("my $x = 'hello'; $x =~ /ell/").is_ok());
2796    }
2797
2798    #[test]
2799    fn anonymous_sub_captures_outer_var() {
2800        assert!(lint("my $x = 1; my $f = fn { p $x; }").is_ok());
2801    }
2802
2803    #[test]
2804    fn state_var_ok() {
2805        assert!(lint("fn Test::counter { state $n = 0; $n++; }").is_ok());
2806    }
2807
2808    #[test]
2809    fn our_var_ok() {
2810        assert!(lint("our $VERSION = '1.0'").is_ok());
2811    }
2812
2813    #[test]
2814    fn local_var_ok() {
2815        assert!(lint("local $/ = undef").is_ok());
2816    }
2817
2818    #[test]
2819    fn chained_method_calls_ok() {
2820        assert!(lint("my $x = Foo->new->bar->baz").is_ok());
2821    }
2822
2823    #[test]
2824    fn list_assignment_ok() {
2825        assert!(lint("my ($a, $b, $c) = (1, 2, 3); p $a + $b + $c").is_ok());
2826    }
2827
2828    #[test]
2829    fn hash_slice_ok() {
2830        assert!(lint("my %h = (a => 1, b => 2); my @v = @h{qw(a b)}").is_ok());
2831    }
2832
2833    #[test]
2834    fn array_slice_ok() {
2835        assert!(lint("my @a = (1, 2, 3, 4); my @b = @a[0, 2]").is_ok());
2836    }
2837
2838    #[test]
2839    fn instance_method_on_typed_var_flags_unknown_method() {
2840        // `my $p = Point(...)` binds `$p` to Point. `$p->dgdd()` is an
2841        // unknown method/field on Point and must be flagged the same
2842        // way `Point->new(dgdd => ...)` and `$self->dgdd` already are.
2843        let r = lint(
2844            "class Point { x : Float\n y : Float\n fn mag_sq { 1 } }\n\
2845             my $p = Point(x => 3, y => 4)\n\
2846             $p->dgdd()",
2847        );
2848        assert!(r.is_err(), "expected error for `$$p->dgdd`");
2849        let e = r.unwrap_err();
2850        assert_eq!(e.kind, ErrorKind::UndefinedSubroutine);
2851        assert!(
2852            e.message.contains("dgdd") && e.message.contains("Point"),
2853            "expected message to name `dgdd` and `Point`, got: {}",
2854            e.message,
2855        );
2856    }
2857
2858    #[test]
2859    fn instance_method_on_typed_var_known_method_ok() {
2860        // Same setup, but `$p->mag_sq` IS a declared method — must pass.
2861        assert!(lint(
2862            "class Point { x : Float\n y : Float\n fn mag_sq { 1 } }\n\
2863                 my $p = Point(x => 3, y => 4)\n\
2864                 $p->mag_sq()"
2865        )
2866        .is_ok());
2867    }
2868
2869    #[test]
2870    fn instance_method_on_typed_var_field_ok() {
2871        // Field access via `->`: `$p->x` reads field `x` on Point.
2872        assert!(lint(
2873            "class Point { x : Float\n y : Float }\n\
2874                 my $p = Point(x => 3, y => 4)\n\
2875                 p $p->x"
2876        )
2877        .is_ok());
2878    }
2879
2880    #[test]
2881    fn strict_never_flags_topic_variants() {
2882        // Stryke topic / block-param family — strict-vars must never
2883        // flag ANY of these as undefined, regardless of sigil or shape.
2884        let cases = [
2885            // Bare topic + positional.
2886            "_", "_0", "_1", "_42", // Outer-chain.
2887            "_<", "_<<<<<", // Indexed-ascent.
2888            "_<3", "_<10", // Positional + outer chain combined.
2889            "_2<", "_2<<<", "_2<5",
2890        ];
2891        for name in cases {
2892            assert!(
2893                super::is_topic_var(name),
2894                "is_topic_var({name:?}) must return true (topic/block-param form)",
2895            );
2896        }
2897        // Run the analyzer on each form (sigiled + bare contexts) to
2898        // ensure no UndefinedVariable error fires.
2899        let src = "use strict\np _ + _1 + _< + _<2 + _<<<<< + _2<<< + _2<5\n";
2900        let prog = crate::parse_with_file(src, "test.stk").expect("parse");
2901        super::analyze_program_with_strict(&prog, "test.stk", true)
2902            .expect("strict-vars must not flag topic-variant block params");
2903    }
2904
2905    #[test]
2906    fn is_topic_var_rejects_non_topic_underscore_names() {
2907        // Anti-cases: names that START with `_` but aren't topic vars.
2908        // The grammar is strict — only `_`, `_N`, `_<...`, `_<N`, `_N<<<`,
2909        // `_N<M` patterns qualify. Anything with a letter after the
2910        // optional digit/chevron run is NOT a topic var.
2911        for bad in [
2912            "_x",     // underscore + letter
2913            "_foo",   // underscore + word
2914            "_3abc",  // digits then letters
2915            "_<bad",  // chevron then letters
2916            "_2<xyz", // positional + chevron + letters
2917            "x_",     // doesn't start with underscore
2918            "__",     // double underscore — not a topic form
2919            "_<<<x",  // chevrons then letters
2920        ] {
2921            assert!(
2922                !super::is_topic_var(bad),
2923                "is_topic_var({bad:?}) must return false (not a topic form)",
2924            );
2925        }
2926    }
2927
2928    #[test]
2929    fn universal_methods_skip_hierarchy_lookup() {
2930        // `isa` / `can` / `DOES` / `new` / `BUILD` / `DESTROY` short-
2931        // circuit the BFS — they work on every class regardless of
2932        // declared method set.
2933        let mut a = super::StaticAnalyzer::new("test.stk");
2934        // Empty class with no methods.
2935        a.type_methods.insert("Empty".to_string(), HashSet::new());
2936        a.type_fields.insert("Empty".to_string(), HashSet::new());
2937        for method in ["isa", "can", "DOES", "does", "new", "BUILD", "DESTROY"] {
2938            assert!(
2939                a.method_resolves_in_hierarchy("Empty", method),
2940                "method `{method}` must resolve on any class via the universal whitelist",
2941            );
2942        }
2943        // A made-up name does NOT short-circuit.
2944        assert!(
2945            !a.method_resolves_in_hierarchy("Empty", "totally_made_up"),
2946            "non-universal unknown method must still be flagged",
2947        );
2948    }
2949
2950    #[test]
2951    fn method_resolves_walks_extends_chain() {
2952        // Dog extends Animal; Animal has `trail`. `$self->trail` on
2953        // Dog must resolve via the parent.
2954        let mut a = super::StaticAnalyzer::new("test.stk");
2955        let mut animal_methods = HashSet::new();
2956        animal_methods.insert("trail".to_string());
2957        a.type_methods.insert("Animal".to_string(), animal_methods);
2958        a.type_fields.insert("Animal".to_string(), HashSet::new());
2959        a.type_methods.insert("Dog".to_string(), HashSet::new());
2960        a.type_fields.insert("Dog".to_string(), HashSet::new());
2961        a.type_parents
2962            .insert("Dog".to_string(), vec!["Animal".to_string()]);
2963        assert!(
2964            a.method_resolves_in_hierarchy("Dog", "trail"),
2965            "`trail` on Dog must resolve via Animal in extends chain",
2966        );
2967        // Method on neither class — still fails.
2968        assert!(
2969            !a.method_resolves_in_hierarchy("Dog", "fly"),
2970            "`fly` on Dog must NOT resolve (absent from Dog AND Animal)",
2971        );
2972    }
2973
2974    #[test]
2975    fn method_resolves_cycle_protected() {
2976        // Pathological `A extends B; B extends A` mutual recursion
2977        // must not infinite-loop.
2978        let mut a = super::StaticAnalyzer::new("test.stk");
2979        a.type_methods.insert("A".to_string(), HashSet::new());
2980        a.type_fields.insert("A".to_string(), HashSet::new());
2981        a.type_methods.insert("B".to_string(), HashSet::new());
2982        a.type_fields.insert("B".to_string(), HashSet::new());
2983        a.type_parents
2984            .insert("A".to_string(), vec!["B".to_string()]);
2985        a.type_parents
2986            .insert("B".to_string(), vec!["A".to_string()]);
2987        // Should return false (method nowhere) without hanging.
2988        assert!(!a.method_resolves_in_hierarchy("A", "missing"));
2989        assert!(!a.method_resolves_in_hierarchy("B", "missing"));
2990    }
2991
2992    #[test]
2993    fn dollar_obj_method_in_string_interp_not_flagged() {
2994        // `"#{ $obj->method }"` inside a string — the interpolation
2995        // is real code, but the method-call shape mustn't false-
2996        // positive when the method name happens to look like a typo
2997        // (since strict-vars-on-by-default skips simple sigil-vars
2998        // in InterpolatedString but DOES walk `#{ EXPR }`).
2999        assert!(lint(
3000            "class P { x: Int = 0\n fn show { $self->x } }\n\
3001                 my $p = P(x => 5)\np \"got #{ $p->show }\""
3002        )
3003        .is_ok(),);
3004    }
3005
3006    #[test]
3007    fn dollar_hash_with_complex_expression_in_string_interp() {
3008        // `"#{ $hash->{key} + 1 }"` — full expression inside #{...}.
3009        // The complex-expression branch DOES walk strict (per the
3010        // current policy), so undefined refs inside still flag.
3011        assert!(
3012            lint(
3013                "use strict\n\
3014                 my %h = (n => 5)\n\
3015                 p \"got #{ $h{n} + 1 }\""
3016            )
3017            .is_ok(),
3018            "defined hash element inside #{{}} must not flag",
3019        );
3020        let r = lint("use strict\np \"got #{ $undef_typo + 1 }\"");
3021        assert!(
3022            r.is_err(),
3023            "complex-expr #{{}} block must still strict-check unknown vars",
3024        );
3025    }
3026
3027    #[test]
3028    fn is_topic_var_accepts_extra_grammar_variants() {
3029        // Edge cases of the grammar not covered by the main test:
3030        for good in [
3031            "_99",         // many positional digits
3032            "_<<<<<<<<<<", // very deep outer chain (10 chevrons)
3033            "_<999",       // multi-digit indexed ascent
3034            "_42<<<",      // 2-digit positional + chevrons
3035            "_42<42",      // 2-digit positional + 2-digit index
3036        ] {
3037            assert!(
3038                super::is_topic_var(good),
3039                "is_topic_var({good:?}) must return true",
3040            );
3041        }
3042    }
3043
3044    #[test]
3045    fn strict_never_flags_sigiled_topic_variants() {
3046        // Same set but with `$` sigil prefix — `is_topic_var` is called
3047        // with the bare name (sigil already stripped by the AST), so
3048        // the bare-name check is what matters.
3049        let src = "use strict\nmy $tot = $_ + $_0 + $_1 + $_< + $_<2 + $_<<<<< + $_2<<< + $_2<5\np $tot\n";
3050        let prog = crate::parse_with_file(src, "test.stk").expect("parse");
3051        super::analyze_program_with_strict(&prog, "test.stk", true)
3052            .expect("strict-vars must not flag sigiled topic-variant block params");
3053    }
3054
3055    #[test]
3056    fn strict_still_flags_undefined_underscore_prefixed_ident() {
3057        // Guardrail: an identifier that merely *starts* with `_` (like
3058        // `$_underscore_name`) is NOT a topic var and SHOULD be flagged.
3059        let r = lint("p $_underscore_name");
3060        assert!(r.is_err(), "expected $_underscore_name to be flagged");
3061    }
3062
3063    #[test]
3064    fn qualified_our_scalar_visible_across_packages() {
3065        // `package Foo; our $x = 1; package main; p $Foo::x` — strict-
3066        // vars must accept `$Foo::x` from main. Regression for the
3067        // false-positive on `oursync $val` in `package Counter`.
3068        assert!(
3069            lint("package Foo\nour $x = 1\npackage main\np $Foo::x").is_ok(),
3070            "qualified `$Foo::x` must resolve to `our $x` declared in package Foo",
3071        );
3072    }
3073
3074    #[test]
3075    fn qualified_oursync_scalar_visible_across_packages() {
3076        // The exact `oursync` form from examples/test_namespaces_pin.stk.
3077        assert!(
3078            lint("package Counter\noursync $val = 0\npackage main\np $Counter::val").is_ok(),
3079            "qualified `$Counter::val` must resolve to `oursync $val` in package Counter",
3080        );
3081    }
3082
3083    #[test]
3084    fn qualified_our_array_visible_across_packages() {
3085        assert!(lint("package Foo\nour @xs = (1,2,3)\npackage main\np @Foo::xs").is_ok());
3086    }
3087
3088    #[test]
3089    fn qualified_our_hash_visible_across_packages() {
3090        assert!(lint("package Foo\nour %h = (a=>1)\npackage main\np %Foo::h").is_ok());
3091    }
3092
3093    #[test]
3094    fn thread_par_run_compiler_generated_call_not_flagged() {
3095        // `~p>` desugars to `_thread_par_run(...)` — the linter must
3096        // not flag this synthetic name as an undefined sub.
3097        assert!(
3098            lint("my @xs = (1,2,3)\nmy @r = ~p> @xs map { _ * 2 }").is_ok(),
3099            "compiler-generated _thread_par_run must be whitelisted",
3100        );
3101    }
3102
3103    #[test]
3104    fn deque_constructor_not_flagged() {
3105        // `deque(...)` is a parser-level constructor handled by
3106        // compiler.rs / vm_helper.rs but not registered in `%all`,
3107        // so it needs an explicit whitelist entry in is_sub_defined.
3108        assert!(
3109            lint("my $dq = deque(1, 2, 3)\np $dq->len").is_ok(),
3110            "deque constructor must not be flagged as undefined sub",
3111        );
3112    }
3113
3114    #[test]
3115    fn defer_block_compiler_generated_call_not_flagged() {
3116        // `defer { BLOCK }` desugars to `defer__internal(fn { BLOCK })`.
3117        // Same whitelist rule as `_thread_par_run`.
3118        assert!(
3119            lint("fn ff { defer { p \"cleanup\" }; 42 }").is_ok(),
3120            "compiler-generated defer__internal must be whitelisted",
3121        );
3122        // Negative guard: an actual unknown `_foo` is still flagged.
3123        let r = lint("use strict; _unknown_helper()");
3124        assert!(r.is_err(), "arbitrary `_foo` must still flag");
3125    }
3126
3127    #[test]
3128    fn aop_intercept_context_vars_not_flagged() {
3129        // `before/after/around` advice bodies see `$INTERCEPT_NAME`,
3130        // `@INTERCEPT_ARGS`, `$INTERCEPT_RESULT`, `$INTERCEPT_MS`,
3131        // `$INTERCEPT_US` as VM-injected always-defined vars.
3132        assert!(lint("before \"fetch\" { p $INTERCEPT_NAME, @INTERCEPT_ARGS }").is_ok());
3133        assert!(
3134            lint("after \"fetch\" { p $INTERCEPT_RESULT, $INTERCEPT_MS, $INTERCEPT_US }").is_ok()
3135        );
3136    }
3137
3138    #[test]
3139    fn string_interpolation_never_flags_undefined_simple_var() {
3140        // Bare `$undef` / `@undef` / `%undef` interpolated inside
3141        // `"..."` is a free pass — strings are used as test
3142        // descriptions / log messages with template placeholders that
3143        // aren't intended as hard variable refs. Bare code-context
3144        // references (`p $undef`) still get flagged.
3145        assert!(
3146            lint("p \"printf $fh writes to STDOUT\"").is_ok(),
3147            "simple $var inside string-interp must not be flagged",
3148        );
3149        assert!(
3150            lint("p \"got @items here\"").is_ok(),
3151            "simple @var inside string-interp must not be flagged",
3152        );
3153        // $#arr-inside-string also gets the free pass.
3154        assert!(
3155            lint("p \"got $#missing_array items\"").is_ok(),
3156            "$#arr-style inside string-interp must not be flagged",
3157        );
3158        // Negative guard: outside strings, the strict-vars check still
3159        // fires.
3160        assert!(
3161            lint("use strict\np $fh").is_err(),
3162            "bare $fh outside string must still flag",
3163        );
3164    }
3165
3166    #[test]
3167    fn string_interp_complex_expr_still_walks_strict() {
3168        // `"#{ $undef + 1 }"` — the `#{EXPR}` block is real code, so
3169        // bare `$undef` reference inside the expression should flag.
3170        let r = lint("use strict\np \"got #{ $undef + 1 }\"");
3171        assert!(
3172            r.is_err(),
3173            "complex expr inside #{{}} must still strict-check vars",
3174        );
3175    }
3176
3177    #[test]
3178    fn qualified_main_var_visible_in_default_package() {
3179        // `our $x = ...` in default package `main` — `$main::x` must
3180        // resolve. Regression for test_bugs_phase3_pin.stk where
3181        // `BEGIN { $main::log_begin .= ... }` was flagged.
3182        assert!(lint("our $log_begin = \"\"\nBEGIN { $main::log_begin .= \"B:\" }").is_ok(),);
3183        assert!(lint("our @items = (1,2)\np @main::items").is_ok());
3184        assert!(lint("our %map = ()\np keys %main::map").is_ok());
3185    }
3186
3187    #[test]
3188    fn dollar_hash_array_last_index_uses_underlying_array() {
3189        // `$#name` is the last-index-of-@name shortcut. Strict-vars
3190        // must check @name (the array), not $#name as a scalar.
3191        assert!(lint("my @arr = (1,2,3); p $#arr").is_ok());
3192        let r = lint("use strict\np $#undefined_array");
3193        assert!(r.is_err(), "$#undefined_array must flag @undefined_array");
3194        assert!(
3195            r.unwrap_err().message.contains("@undefined_array"),
3196            "error must name @undefined_array, not $#undefined_array",
3197        );
3198    }
3199
3200    #[test]
3201    fn match_arm_enum_variant_typo_flagged() {
3202        // Auto-quoted enum patterns: `Sig::Term2 => "..."` arrives
3203        // as MatchPattern::Value(String("Sig::Term2")). When Sig is
3204        // a known enum and Term2 isn't a variant, must flag.
3205        let r = lint(
3206            "enum Sig { Hup, Int, Term, Kill }\n\
3207             fn handle($s) {\n\
3208                 match ($s) {\n\
3209                     Sig::Hup => \"reload\",\n\
3210                     Sig::Term2 => \"drain\",\n\
3211                     Sig::Kill => \"reap\",\n\
3212                 }\n\
3213             }",
3214        );
3215        assert!(r.is_err(), "expected flag on Sig::Term2");
3216        let msg = r.unwrap_err().message;
3217        assert!(
3218            msg.contains("Term2") && msg.contains("Sig"),
3219            "message must name Term2 and Sig: {msg}",
3220        );
3221    }
3222
3223    #[test]
3224    fn match_arm_known_enum_variant_passes() {
3225        // Symmetric guard: real variants must not be flagged.
3226        assert!(lint(
3227            "enum Sig { Hup, Int, Term, Kill }\n\
3228                 fn handle($s) {\n\
3229                     match ($s) {\n\
3230                         Sig::Hup => \"reload\",\n\
3231                         Sig::Int => \"shutdown\",\n\
3232                         Sig::Term => \"drain\",\n\
3233                         Sig::Kill => \"reap\",\n\
3234                     }\n\
3235                 }"
3236        )
3237        .is_ok());
3238    }
3239
3240    #[test]
3241    fn dollar_caret_perl_special_vars_not_flagged() {
3242        // `$^X`, `$^O`, `$^V`, `$^W`, `$^T` etc. — Perl special vars
3243        // prefixed with `^`. All must be treated as always-defined.
3244        for name in ["$^X", "$^O", "$^V", "$^W", "$^T", "$^R", "$^N", "$^H"] {
3245            assert!(
3246                lint(&format!("use strict\np {name}")).is_ok(),
3247                "{name} must not be flagged by strict-vars",
3248            );
3249        }
3250    }
3251
3252    #[test]
3253    fn lexical_filehandle_open_my_var_not_flagged() {
3254        // `open(my $fh, ">", $path)` — `my $fh` inside the call
3255        // declares a lexical scalar. Later `print $fh ...` /
3256        // `close $fh` must see it as defined.
3257        assert!(lint(
3258            "use strict\nmy $efile = \"/tmp/x\"\n\
3259                 open(my $wfh, \">\", $efile) or die\n\
3260                 print $wfh \"line1\\n\"\nclose $wfh"
3261        )
3262        .is_ok(),);
3263    }
3264
3265    #[test]
3266    fn exists_subroutine_ref_does_not_flag_undefined() {
3267        // `exists &Pkg::sub` is introspection — flagging the sub as
3268        // undefined defeats the entire purpose.
3269        assert!(lint(
3270            "package Foo\nfn greet = 1\npackage main\n\
3271                 p exists(&Foo::greet) ? \"y\" : \"n\"\n\
3272                 p exists(&Foo::missing) ? \"y\" : \"n\""
3273        )
3274        .is_ok(),);
3275    }
3276
3277    #[test]
3278    fn universal_methods_resolve_on_any_class() {
3279        // `isa`, `can`, `DOES`, `does`, `VERSION`, lifecycle hooks
3280        // (`new`, `BUILD`, `DESTROY`, `destroy`), and built-in class
3281        // methods (`clone`, `with`, `to_hash`, `to_hash_rec`,
3282        // `to_hash_deep`, `fields`, `methods`, `superclass`) are
3283        // always callable on any class instance — never flag them
3284        // via $obj->X or $self->X.
3285        assert!(lint(
3286            "class Square { side: Float\n fn area { 1 } }\n\
3287                 my $sq = Square->new(side => 5)\n\
3288                 p $sq->isa(\"Square\")\n\
3289                 p $sq->can(\"area\")\n\
3290                 p $sq->DOES(\"Shape\")\n\
3291                 my $cloned = $sq->clone()\n\
3292                 my $changed = $sq->with(side => 9)\n\
3293                 p $sq->to_hash()\n\
3294                 p $sq->fields()"
3295        )
3296        .is_ok(),);
3297    }
3298
3299    #[test]
3300    fn builtin_struct_methods_resolve_on_any_struct() {
3301        // Same whitelist applies to structs: `clone`, `with`,
3302        // `to_hash`, `to_hash_rec`, `to_hash_deep`, `fields`.
3303        assert!(lint(
3304            "struct Point { x: Float\n y: Float }\n\
3305                 my $p = Point(x => 1.0, y => 2.0)\n\
3306                 my $c = $p->clone()\n\
3307                 my $u = $p->with(x => 9.0)\n\
3308                 p $p->to_hash()\n\
3309                 p $p->fields()"
3310        )
3311        .is_ok(),);
3312    }
3313
3314    #[test]
3315    fn class_inheritance_resolves_parent_methods_on_self() {
3316        // `class Dog extends Animal` — `$self->trail` inside Dog's
3317        // body must walk up to Animal and find `trail` there.
3318        assert!(lint(
3319            "class Animal { name: Str = \"\"\n fn trail { \"...\" } }\n\
3320                 class Dog extends Animal {\n\
3321                     breed: Str = \"\"\n\
3322                     fn show { $self->trail }\n\
3323                 }"
3324        )
3325        .is_ok(),);
3326    }
3327
3328    #[test]
3329    fn class_inheritance_resolves_parent_fields_in_constructor() {
3330        // `Dog(name => "Rex", breed => "Lab")` — `name` is on Animal,
3331        // `breed` on Dog. Constructor key check must accept both.
3332        assert!(lint(
3333            "class Animal { name: Str = \"\" }\n\
3334                 class Dog extends Animal { breed: Str = \"\" }\n\
3335                 my $d = Dog(name => \"Rex\", breed => \"Lab\")\n\
3336                 p $d->name"
3337        )
3338        .is_ok(),);
3339    }
3340
3341    #[test]
3342    fn resolve_require_path_finds_lib_root_from_nested_source() {
3343        // Project layout: tmp_root/lib/ai/{matrix,neural_network}.stk.
3344        // From neural_network.stk, `require "./lib/ai/matrix.stk"`
3345        // must resolve to tmp_root/lib/ai/matrix.stk even though the
3346        // current file sits inside `lib/ai/`. Without walking up to
3347        // find the `lib/`-bearing ancestor, the resolver would land
3348        // in `lib/ai/lib/ai/matrix.stk` which doesn't exist.
3349        let tmp = std::env::temp_dir().join(format!("stryke_resolve_test_{}", std::process::id()));
3350        let _ = std::fs::remove_dir_all(&tmp);
3351        std::fs::create_dir_all(tmp.join("lib").join("ai")).unwrap();
3352        let mat = tmp.join("lib").join("ai").join("matrix.stk");
3353        std::fs::write(&mat, "1\n").unwrap();
3354        let nn = tmp.join("lib").join("ai").join("neural_network.stk");
3355        std::fs::write(&nn, "1\n").unwrap();
3356        let resolved =
3357            super::resolve_require_path_from_file(nn.to_str().unwrap(), "./lib/ai/matrix.stk");
3358        assert!(
3359            resolved.as_ref().is_some_and(|p| p == &mat)
3360                || resolved
3361                    .as_ref()
3362                    .is_some_and(|p| { p.canonicalize().ok() == mat.canonicalize().ok() }),
3363            "expected to resolve to {mat:?}, got {resolved:?}",
3364        );
3365        let _ = std::fs::remove_dir_all(&tmp);
3366    }
3367
3368    #[test]
3369    fn resolve_require_path_sibling_in_same_dir() {
3370        // `require "./sibling.stk"` from same-directory script should
3371        // resolve regardless of `lib/` presence.
3372        let tmp =
3373            std::env::temp_dir().join(format!("stryke_resolve_sibling_{}", std::process::id()));
3374        let _ = std::fs::remove_dir_all(&tmp);
3375        std::fs::create_dir_all(&tmp).unwrap();
3376        let sib = tmp.join("sibling.stk");
3377        std::fs::write(&sib, "1\n").unwrap();
3378        let me = tmp.join("me.stk");
3379        std::fs::write(&me, "1\n").unwrap();
3380        let resolved = super::resolve_require_path_from_file(me.to_str().unwrap(), "./sibling.stk");
3381        assert!(
3382            resolved.is_some(),
3383            "expected to resolve ./sibling.stk in same dir, got None",
3384        );
3385        let _ = std::fs::remove_dir_all(&tmp);
3386    }
3387
3388    #[test]
3389    fn positional_constructor_args_not_checked_as_keys() {
3390        // `Task(1, "Setup", Priority::High)` is positional — none of
3391        // the args are field names. Must not flag the String /
3392        // Bareword args as "unknown field".
3393        assert!(lint(
3394            "enum Priority { Low, Medium, High, Critical }\n\
3395                 class Task {\n\
3396                     id: Int\n\
3397                     title: Str = \"\"\n\
3398                     priority: Any = undef\n\
3399                 }\n\
3400                 my $t = Task(1, \"Setup\", Priority::High)"
3401        )
3402        .is_ok(),);
3403    }
3404
3405    #[test]
3406    fn positional_constructor_with_string_value_not_flagged() {
3407        // `Person(\"Alice\", 30)` — String literal is the FIRST arg
3408        // (and would-be field name), but since "Alice" isn't a
3409        // declared field of Person, the call is positional.
3410        assert!(lint(
3411            "class Person { name: Str = \"\"\n age: Int = 0 }\n\
3412                 my $p = Person(\"Alice\", 30)"
3413        )
3414        .is_ok(),);
3415    }
3416
3417    #[test]
3418    fn keyed_constructor_still_flags_typo() {
3419        // Guardrail: `Point(x => 10, yyg => 20)` — `yyg` IS a typo.
3420        // First arg matches a declared field (`x`), so the heuristic
3421        // correctly classifies the call as keyed and the check fires.
3422        let r = lint(
3423            "class Point { x: Float\n y: Float }\n\
3424             my $p = Point(x => 10, yyg => 20)",
3425        );
3426        assert!(r.is_err(), "expected `yyg` typo to be flagged");
3427        assert!(
3428            r.unwrap_err().message.contains("yyg"),
3429            "error must name `yyg` field",
3430        );
3431    }
3432
3433    #[test]
3434    fn dollar_dollar_pid_not_flagged() {
3435        // `$$` is the process ID — always-defined special var.
3436        // Parser stores it as `ScalarVar("$$")` so the strict-vars
3437        // check sees a 2-char name; is_special_var must whitelist.
3438        assert!(lint("use strict\np $$").is_ok());
3439        assert!(lint("use strict\n$$ > 0 ? 1 : 0").is_ok());
3440    }
3441
3442    #[test]
3443    fn class_impl_trait_resolves_default_method() {
3444        // `trait Greetable { fn greeting { "Hello" } }` + `class Person
3445        // impl Greetable { ... }` — `$p->greeting` must resolve via
3446        // the trait's default impl. Regression for
3447        // test_extended_features_pin.stk.
3448        assert!(lint(
3449            "trait Greetable {\n\
3450                     fn greeting { \"Hello\" }\n\
3451                     fn name\n\
3452                 }\n\
3453                 class Person impl Greetable {\n\
3454                     n: Str = \"\"\n\
3455                     fn name { $self->n }\n\
3456                 }\n\
3457                 my $p = Person(n => \"Alice\")\n\
3458                 p $p->greeting()"
3459        )
3460        .is_ok(),);
3461    }
3462
3463    #[test]
3464    fn class_impl_multiple_traits_resolves_methods_from_all() {
3465        assert!(lint(
3466            "trait Greetable { fn greeting { \"hi\" } }\n\
3467                 trait Loggable  { fn log_it { 1 } }\n\
3468                 class Hybrid impl Greetable, Loggable {}\n\
3469                 my $h = Hybrid->new\n\
3470                 p $h->greeting()\n\
3471                 p $h->log_it()"
3472        )
3473        .is_ok(),);
3474    }
3475
3476    #[test]
3477    fn class_impl_trait_still_flags_unknown_method() {
3478        // Guardrail: if the method exists on NEITHER the class NOR
3479        // any implemented trait, still flag.
3480        let r = lint(
3481            "trait Greetable { fn greeting }\n\
3482             class Person impl Greetable { n: Str = \"\" }\n\
3483             my $p = Person->new\n\
3484             p $p->fly()",
3485        );
3486        assert!(r.is_err(), "expected $p->fly to be flagged");
3487    }
3488
3489    #[test]
3490    fn class_inheritance_still_flags_unknown_method() {
3491        // Symmetric guard: a method that exists on NEITHER child nor
3492        // parent must still be flagged.
3493        let r = lint(
3494            "class Animal { fn trail { \"\" } }\n\
3495             class Dog extends Animal {\n\
3496                 fn show { $self->fly }\n\
3497             }",
3498        );
3499        assert!(r.is_err(), "expected $self->fly to be flagged");
3500    }
3501
3502    #[test]
3503    fn instance_method_on_arrow_new_form_typed_var_flags() {
3504        // `my $p = Point->new(x => 3)` also binds `$p` to Point.
3505        let r = lint(
3506            "class Point { x : Float\n y : Float }\n\
3507             my $p = Point->new(x => 3, y => 4)\n\
3508             $p->whatever()",
3509        );
3510        assert!(r.is_err(), "expected error for `$$p->whatever`");
3511    }
3512}