Skip to main content

mollify_parse/
lib.rs

1//! # mollify-parse
2//!
3//! Python parsing for Mollify. **Parser abstraction** so the rest of the engine
4//! never touches the concrete parser directly.
5//!
6//! ## ADR-0001: full-fidelity ruff AST
7//! Built on Astral's `ruff_python_parser` / `ruff_python_ast` (pinned crates.io release) —
8//! the same battle-tested, error-resilient parser that powers `ruff`. The types
9//! below (`ParsedModule`, `Definition`, `Import`, …) are parser-agnostic, so the
10//! concrete parser remains an implementation detail confined to this crate.
11
12use camino::Utf8Path;
13use ruff_python_ast::token::TokenKind;
14use ruff_python_ast::visitor::{walk_expr, walk_stmt, Visitor};
15use ruff_python_ast::{
16    Expr, ExprContext, Parameters, Stmt, StmtClassDef, StmtFunctionDef, StmtImport, StmtImportFrom,
17};
18use ruff_python_parser::parse_module;
19use ruff_source_file::LineIndex;
20use ruff_text_size::{Ranged, TextRange, TextSize};
21use std::collections::{HashMap, HashSet};
22
23#[derive(Debug, thiserror::Error)]
24pub enum ParseError {
25    #[error("failed to initialize the Python grammar")]
26    Grammar,
27    #[error("parser produced no tree for {0}")]
28    NoTree(String),
29}
30
31/// What a top-level definition is, for dead-code granularity.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DefKind {
34    Function,
35    Class,
36    /// A module-level name binding (assignment target).
37    Variable,
38}
39
40/// A symbol defined at module scope.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Definition {
43    pub name: String,
44    pub kind: DefKind,
45    pub line: u32,
46    pub end_line: u32,
47    /// Convention: names starting with `_` are private by default.
48    pub private_by_convention: bool,
49    /// Decorator paths applied to this def, normalized to the callable path
50    /// without call args, e.g. `app.route`, `pytest.fixture`, `staticmethod`.
51    pub decorators: Vec<String>,
52}
53
54/// An `import` / `from ... import ...` statement.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Import {
57    /// The module path, e.g. `os.path` or `mypkg.sub`. Empty for relative dots
58    /// captured in `relative_dots`.
59    pub module: String,
60    /// Number of leading dots in a relative import (`from . import x` -> 1).
61    pub relative_dots: u8,
62    /// Imported names (`from m import a, b` -> [a, b]). Empty for `import m`.
63    pub names: Vec<String>,
64    /// Local names this statement binds, honoring aliases: `import a.b` -> [a];
65    /// `import a.b as c` -> [c]; `from m import x as y` -> [y]. Empty for `*`.
66    pub bindings: Vec<String>,
67    /// True for `from m import *`.
68    pub is_star: bool,
69    /// True if guarded by `if TYPE_CHECKING:` / `if False:` — a deliberate
70    /// type-only import that must never be flagged as unused.
71    pub type_checking_only: bool,
72    /// Per-binding: true when written with a redundant alias (`import x as x`,
73    /// `from m import y as y`) — the PEP 484 explicit re-export convention, so
74    /// the binding is public API even with zero in-module uses.
75    pub redundant: Vec<bool>,
76    /// True if the statement sits in a `try:` body / `except:` handler, or a
77    /// module-level `if`/`else` arm — availability probes and platform
78    /// switches (`try: import x / except: …`, `if flag: from y import z`).
79    /// Removing a guarded arm changes behavior, so this is never a certain fix.
80    pub in_try: bool,
81    pub line: u32,
82    /// Last line of the statement (inclusive) — multi-line `from x import (…)`
83    /// spans several lines, and a `# noqa` on any of them counts.
84    pub end_line: u32,
85}
86
87/// Per-function complexity metrics (cyclomatic + cognitive) and type-annotation
88/// coverage.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct FunctionComplexity {
91    pub name: String,
92    pub line: u32,
93    /// Last line of the function (inclusive) — for coverage range checks.
94    pub end_line: u32,
95    /// McCabe cyclomatic complexity (1 + decision points).
96    pub cyclomatic: u32,
97    /// SonarSource-style cognitive complexity (nesting-weighted).
98    pub cognitive: u32,
99    /// Parameters excluding `self`/`cls`.
100    pub params_total: u32,
101    /// Of those, how many carry a type annotation.
102    pub params_annotated: u32,
103    /// Whether the function has a `-> T` return annotation.
104    pub return_annotated: bool,
105}
106
107/// A potential security issue detected syntactically (a *candidate*, per the
108/// candidate-producer/verifier split — never a confirmed vulnerability).
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct SecurityHit {
111    /// Stable rule id, e.g. `dangerous-eval`, `subprocess-shell-true`.
112    pub rule: &'static str,
113    pub line: u32,
114    pub detail: String,
115}
116
117/// A single call expression's callee text and 1-based line.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct CallSite {
120    pub callee: String,
121    pub line: u32,
122}
123
124/// An unused local binding within a function scope.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ScopeFinding {
127    pub name: String,
128    pub line: u32,
129    /// True for a parameter, false for a local-variable assignment.
130    pub is_param: bool,
131}
132
133/// A class and, per method, the set of `self.<attr>` it touches — the input to
134/// the LCOM* cohesion metric. Also carries member + base metadata for unused
135/// class-member / unused enum-member detection.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct ClassInfo {
138    pub name: String,
139    pub line: u32,
140    pub end_line: u32,
141    /// True if this is private by convention (`_Name`).
142    pub is_private: bool,
143    /// Decorator paths on the class (`dataclass`, `runtime_checkable`, …).
144    pub decorators: Vec<String>,
145    /// Base-class paths as written (`Enum`, `enum.IntEnum`, `BaseModel`, …).
146    pub bases: Vec<String>,
147    /// True if a base resolves to an `enum`-family class (Enum/IntEnum/…).
148    pub is_enum: bool,
149    /// `(method_name, set-of-instance-attributes-it-references)`.
150    pub methods: Vec<(String, Vec<String>)>,
151    /// Declared members: methods and class-level attribute/constant assignments.
152    pub members: Vec<ClassMember>,
153}
154
155/// One member declared directly in a class body (a method or a class-level
156/// attribute / enum value).
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct ClassMember {
159    pub name: String,
160    pub line: u32,
161    pub end_line: u32,
162    /// True for a `def`, false for a class-level assignment (attribute/constant).
163    pub is_method: bool,
164    pub is_private: bool,
165    /// Decorator paths (`property`, `staticmethod`, `abstractmethod`, …).
166    pub decorators: Vec<String>,
167}
168
169/// A statement that can never execute because it follows an unconditional
170/// terminator (`return`/`raise`/`break`/`continue`/`sys.exit()`) in the same
171/// block.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct UnreachableCode {
174    pub line: u32,
175    /// The terminator that makes it unreachable, e.g. `return`, `raise`.
176    pub after: &'static str,
177}
178
179/// A **private type** (`_Name`) referenced in the signature of a *public*
180/// function/method — an API-hygiene leak (callers can't name the type).
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct TypeLeak {
183    /// `func` or `Class.method`.
184    pub function: String,
185    /// The private type name referenced (`_Internal`).
186    pub type_name: String,
187    pub line: u32,
188    /// True if the leak is in the return annotation (else a parameter).
189    pub is_return: bool,
190}
191
192/// The parsed view of one Python module that the graph builds on.
193#[derive(Debug, Clone)]
194pub struct ParsedModule {
195    pub path: camino::Utf8PathBuf,
196    pub definitions: Vec<Definition>,
197    pub imports: Vec<Import>,
198    /// Imports nested inside function/class bodies (lazy/deferred imports). Kept
199    /// separate from `imports` so module-scope unused-import analysis is
200    /// unaffected, while dependency-usage and reachability can still see them.
201    pub nested_imports: Vec<Import>,
202    pub calls: Vec<CallSite>,
203    pub functions: Vec<FunctionComplexity>,
204    pub security_hits: Vec<SecurityHit>,
205    pub dunder_all: Option<Vec<String>>,
206    /// True when `__all__` exists but part of it is not statically knowable
207    /// (`__all__ = sub.__all__ + [...]`, `.extend(func())`). `dunder_all`
208    /// then holds only the literal names that could be collected, so
209    /// "not in `__all__`" is no longer evidence of certainty.
210    pub dunder_all_dynamic: bool,
211    pub used_names: Vec<String>,
212    pub local_uses: Vec<String>,
213    /// Names accessed as an attribute (`obj.attr`, `self.attr`, `Class.attr`) —
214    /// the precise "member used" signal for unused class / enum members (sorted,
215    /// deduped). Distinct from `local_uses`, which also mixes in bare/store names
216    /// that would otherwise mask an unused attribute via its own definition.
217    pub attr_accessed: Vec<String>,
218    /// Module-level names referenced by a **resolved** free load — i.e. a
219    /// `Name` in load context whose scope resolution reaches module/global scope
220    /// (not shadowed by a function-local binding, and not an attribute access).
221    /// This is the precise signal for whether a top-level symbol is used
222    /// internally, replacing coarse token-frequency counting. Sorted + deduped.
223    pub module_used: Vec<String>,
224    pub ignores: Vec<(u32, String)>,
225    pub scope_findings: Vec<ScopeFinding>,
226    pub classes: Vec<ClassInfo>,
227    /// Statements that can never execute (follow a terminator in their block).
228    pub unreachable: Vec<UnreachableCode>,
229    /// Private types leaked through public function/method signatures.
230    pub type_leaks: Vec<TypeLeak>,
231    pub name_counts: HashMap<String, u32>,
232    pub has_dynamic_sink: bool,
233    /// True if the module has a top-level `if __name__ == "__main__":` guard —
234    /// it's a runnable script, hence a reachability root.
235    pub has_main_guard: bool,
236    /// Relative `.py` paths named by a string literal (`"hooks/export.py"`,
237    /// `"schema.py"`). A filename passed to a plugin loader is not an import,
238    /// but it is how the file is loaded. Sorted and deduped.
239    pub path_literals: Vec<String>,
240    pub halstead_volume: f64,
241    had_errors: bool,
242}
243
244impl ParsedModule {
245    /// Whether the parser reported syntax errors (we still extract best-effort).
246    pub fn had_errors(&self) -> bool {
247        self.had_errors
248    }
249}
250
251/// A reusable parser handle. The ruff parser is stateless (a free function), so
252/// this is a zero-sized handle kept for API stability and ergonomic call sites.
253#[derive(Default)]
254pub struct PyParser;
255
256impl PyParser {
257    pub fn new() -> Result<Self, ParseError> {
258        Ok(Self)
259    }
260
261    /// Parse and extract the module view.
262    pub fn parse(&mut self, path: &Utf8Path, source: &str) -> Result<ParsedModule, ParseError> {
263        let li = LineIndex::from_source_text(source);
264        let mut m = ParsedModule {
265            path: path.to_owned(),
266            definitions: Vec::new(),
267            imports: Vec::new(),
268            nested_imports: Vec::new(),
269            calls: Vec::new(),
270            functions: Vec::new(),
271            security_hits: Vec::new(),
272            dunder_all: None,
273            dunder_all_dynamic: false,
274            used_names: Vec::new(),
275            local_uses: Vec::new(),
276            attr_accessed: Vec::new(),
277            module_used: Vec::new(),
278            ignores: Vec::new(),
279            scope_findings: Vec::new(),
280            classes: Vec::new(),
281            unreachable: Vec::new(),
282            type_leaks: Vec::new(),
283            name_counts: HashMap::new(),
284            has_dynamic_sink: false,
285            has_main_guard: false,
286            path_literals: Vec::new(),
287            halstead_volume: 0.0,
288            had_errors: false,
289        };
290
291        let parsed = match parse_module(source) {
292            Ok(p) => p,
293            Err(_) => {
294                // Catastrophic parse failure: return an empty best-effort view.
295                m.had_errors = true;
296                return Ok(m);
297            }
298        };
299        m.had_errors = !parsed.errors().is_empty();
300        let module = parsed.syntax();
301
302        // Token-derived data (mirrors the old "every identifier token" model):
303        // name occurrence counts, used-name set, Halstead volume, ignores, and a
304        // per-position Name index for scope frequency.
305        let mut name_tokens: Vec<(TextSize, &str)> = Vec::new();
306        let mut h_total_ops = 0u64;
307        let mut h_total_oprs = 0u64;
308        let mut h_ops: HashSet<TokenKind> = HashSet::new();
309        let mut h_oprs: HashSet<&str> = HashSet::new();
310        for tok in parsed.tokens() {
311            let kind = tok.kind();
312            let text = &source[tok.range()];
313            if kind == TokenKind::Name {
314                *m.name_counts.entry(text.to_string()).or_insert(0) += 1;
315                m.used_names.push(text.to_string());
316                name_tokens.push((tok.range().start(), text));
317            }
318            if kind == TokenKind::Comment {
319                let line = line1(&li, tok.range().start());
320                if let Some(rules) = parse_ignore_comment(text) {
321                    for r in rules {
322                        m.ignores.push((line, r));
323                    }
324                }
325                if let Some(rules) = parse_noqa_comment(text) {
326                    for r in rules {
327                        m.ignores.push((line, r));
328                    }
329                }
330            }
331            // Halstead classification.
332            if is_operand(kind) {
333                h_total_oprs += 1;
334                h_oprs.insert(text);
335            } else if !kind.is_trivia()
336                && !matches!(
337                    kind,
338                    TokenKind::Newline
339                        | TokenKind::Indent
340                        | TokenKind::Dedent
341                        | TokenKind::EndOfFile
342                )
343            {
344                h_total_ops += 1;
345                h_ops.insert(kind);
346            }
347        }
348        m.used_names.sort();
349        m.used_names.dedup();
350        let vocab = (h_ops.len() + h_oprs.len()) as f64;
351        let length = (h_total_ops + h_total_oprs) as f64;
352        m.halstead_volume = if vocab <= 1.0 {
353            0.0
354        } else {
355            length * vocab.log2()
356        };
357
358        // Top-level definitions / imports / __all__ / module vars.
359        scan_top_level(&module.body, &li, false, &mut m);
360
361        // Lazy/deferred imports inside function & class bodies (collected
362        // separately — see `nested_imports`).
363        let mut nested = NestedImportVisitor {
364            li: &li,
365            depth: 0,
366            out: Vec::new(),
367        };
368        for stmt in &module.body {
369            nested.visit_stmt(stmt);
370        }
371        m.nested_imports = nested.out;
372
373        // Calls, dynamic sinks, security candidates (whole-tree walk).
374        let mut main = MainVisitor {
375            li: &li,
376            m: &mut m,
377            sanitized_idents: vec![HashSet::new()],
378        };
379        for stmt in &module.body {
380            main.visit_stmt(stmt);
381        }
382        m.path_literals.sort();
383        m.path_literals.dedup();
384
385        // Identifiers used outside import statements (for unused-import), plus
386        // the set of attribute-accessed names (for unused class/enum members).
387        let mut lu = LocalUseVisitor {
388            uses: Vec::new(),
389            attrs: Vec::new(),
390        };
391        for stmt in &module.body {
392            lu.visit_stmt(stmt);
393        }
394        lu.uses.sort();
395        lu.uses.dedup();
396        m.local_uses = lu.uses;
397        lu.attrs.sort();
398        lu.attrs.dedup();
399        m.attr_accessed = lu.attrs;
400
401        // Scope/binding resolution: which module-level names are referenced by a
402        // free load that resolves to module scope (not a shadowing local).
403        let mut res = Resolver {
404            scopes: Vec::new(),
405            used: HashSet::new(),
406        };
407        for stmt in &module.body {
408            res.visit_stmt(stmt);
409        }
410        let mut mu: Vec<String> = res.used.into_iter().collect();
411        mu.sort();
412        m.module_used = mu;
413
414        // Per-function complexity, per-function scope analysis, per-class cohesion.
415        let mut defs = DefVisitor {
416            funcs: Vec::new(),
417            classes: Vec::new(),
418        };
419        for stmt in &module.body {
420            defs.visit_stmt(stmt);
421        }
422        for f in &defs.funcs {
423            m.functions.push(function_complexity(f, &li));
424            analyze_scope(f, &name_tokens, &mut m.scope_findings, &li);
425        }
426        m.functions.sort_by_key(|f| f.line);
427        m.scope_findings.sort_by_key(|s| s.line);
428        for c in &defs.classes {
429            m.classes.push(class_info(c, &li));
430        }
431        m.classes.sort_by_key(|c| c.line);
432
433        // Unreachable code: statements following an unconditional terminator in
434        // any block (whole-tree walk over suites).
435        let mut ur = UnreachableVisitor {
436            li: &li,
437            out: Vec::new(),
438        };
439        ur.scan(&module.body);
440        for stmt in &module.body {
441            ur.visit_stmt(stmt);
442        }
443        ur.out.sort_by_key(|u| u.line);
444        ur.out.dedup();
445        m.unreachable = ur.out;
446
447        // Private-type leaks through public function/method signatures.
448        scan_type_leaks(&module.body, &li, &mut m.type_leaks);
449        m.type_leaks
450            .sort_by(|a, b| a.line.cmp(&b.line).then(a.type_name.cmp(&b.type_name)));
451        m.type_leaks.dedup();
452
453        // Import-based weak-cipher candidates (needs the parsed import list).
454        security_imports(&mut m);
455        m.security_hits
456            .sort_by(|a, b| a.line.cmp(&b.line).then(a.rule.cmp(b.rule)));
457        m.security_hits
458            .dedup_by(|a, b| a.rule == b.rule && a.line == b.line);
459
460        // A suppression comment on ANY line of a multi-line import statement
461        // counts for the whole statement: mollify reports the finding at the
462        // statement's first line, while ruff anchors F401 at the alias — so
463        // users legitimately write `name,  # noqa: F401` inside the parens
464        // (seen live in astropy's test suite).
465        let mut span_ignores: Vec<(u32, String)> = Vec::new();
466        for (line, rule) in &m.ignores {
467            for imp in &m.imports {
468                if *line > imp.line && *line <= imp.end_line {
469                    span_ignores.push((imp.line, rule.clone()));
470                }
471            }
472        }
473        m.ignores.extend(span_ignores);
474        Ok(m)
475    }
476}
477
478// ---------------------------------------------------------------------------
479// Helpers
480// ---------------------------------------------------------------------------
481
482const DYNAMIC_SINKS: &[&str] = &["getattr", "setattr", "eval", "exec", "__import__"];
483
484/// 1-based line for a byte offset.
485fn line1(li: &LineIndex, off: TextSize) -> u32 {
486    li.line_index(off).get() as u32
487}
488
489/// 1-based line of the last byte covered by `range` (for inclusive end lines).
490fn end_line1(li: &LineIndex, range: TextRange) -> u32 {
491    let end = range.end();
492    if end > range.start() {
493        line1(li, end.checked_sub(TextSize::from(1)).unwrap_or(end))
494    } else {
495        line1(li, end)
496    }
497}
498
499/// Whether a token kind is a Halstead "operand" (identifier or literal).
500fn is_operand(kind: TokenKind) -> bool {
501    matches!(
502        kind,
503        TokenKind::Name
504            | TokenKind::Int
505            | TokenKind::Float
506            | TokenKind::Complex
507            | TokenKind::String
508            | TokenKind::FStringStart
509            | TokenKind::FStringMiddle
510            | TokenKind::FStringEnd
511            | TokenKind::True
512            | TokenKind::False
513            | TokenKind::None
514    )
515}
516
517/// Render an attribute/name expression to a dotted path (`os.path.join`).
518fn expr_path(e: &Expr) -> Option<String> {
519    match e {
520        Expr::Name(n) => Some(n.id.as_str().to_string()),
521        Expr::Attribute(a) => Some(format!("{}.{}", expr_path(&a.value)?, a.attr.as_str())),
522        _ => None,
523    }
524}
525
526/// The decorator's normalized callable path (strip any call arguments).
527fn decorator_path(e: &Expr) -> Option<String> {
528    match e {
529        Expr::Call(c) => expr_path(&c.func),
530        other => expr_path(other),
531    }
532}
533
534fn is_private(name: &str) -> bool {
535    name.starts_with('_')
536}
537
538// ---------------------------------------------------------------------------
539// Top-level scan: definitions, imports, __all__, module vars.
540// ---------------------------------------------------------------------------
541
542fn scan_top_level(stmts: &[Stmt], li: &LineIndex, type_checking: bool, m: &mut ParsedModule) {
543    for stmt in stmts {
544        match stmt {
545            Stmt::FunctionDef(f) => m.definitions.push(Definition {
546                private_by_convention: is_private(f.name.as_str()),
547                name: f.name.to_string(),
548                kind: DefKind::Function,
549                // The full range includes decorators; point `line` at the `def`.
550                line: line1(li, f.name.range().start()),
551                end_line: end_line1(li, f.range()),
552                decorators: f
553                    .decorator_list
554                    .iter()
555                    .filter_map(|d| decorator_path(&d.expression))
556                    .collect(),
557            }),
558            Stmt::ClassDef(c) => m.definitions.push(Definition {
559                private_by_convention: is_private(c.name.as_str()),
560                name: c.name.to_string(),
561                kind: DefKind::Class,
562                line: line1(li, c.name.range().start()),
563                end_line: end_line1(li, c.range()),
564                decorators: c
565                    .decorator_list
566                    .iter()
567                    .filter_map(|d| decorator_path(&d.expression))
568                    .collect(),
569            }),
570            Stmt::Import(i) => parse_import(i, li, &mut m.imports),
571            Stmt::ImportFrom(i) => {
572                let mut imp = parse_import_from(i, li);
573                imp.type_checking_only = type_checking;
574                m.imports.push(imp);
575            }
576            Stmt::Assign(a) => {
577                if let [Expr::Name(target)] = a.targets.as_slice() {
578                    let name = target.id.as_str();
579                    if name == "__all__" {
580                        match string_list(&a.value) {
581                            Some(items) => m.dunder_all = Some(items),
582                            // `__all__ = sub.__all__ + [...]`: unknowable.
583                            None => m.dunder_all_dynamic = true,
584                        }
585                    } else {
586                        m.definitions.push(Definition {
587                            private_by_convention: is_private(name),
588                            name: name.to_string(),
589                            kind: DefKind::Variable,
590                            line: line1(li, a.range().start()),
591                            end_line: end_line1(li, a.range()),
592                            decorators: Vec::new(),
593                        });
594                    }
595                }
596            }
597            Stmt::AnnAssign(a) => {
598                if let Expr::Name(target) = &*a.target {
599                    let name = target.id.as_str();
600                    if name == "__all__" {
601                        if let Some(v) = &a.value {
602                            match string_list(v) {
603                                Some(items) => m.dunder_all = Some(items),
604                                None => m.dunder_all_dynamic = true,
605                            }
606                        }
607                    } else {
608                        m.definitions.push(Definition {
609                            private_by_convention: is_private(name),
610                            name: name.to_string(),
611                            kind: DefKind::Variable,
612                            line: line1(li, a.range().start()),
613                            end_line: end_line1(li, a.range()),
614                            decorators: Vec::new(),
615                        });
616                    }
617                }
618            }
619            // `__all__ += [...]` extends the export list. When the base was
620            // dynamic (`__all__ = sub.__all__ + …`), keep the literal names we
621            // CAN see (they are exports) and remember the list is partial via
622            // `dunder_all_dynamic` — "not in __all__" then proves nothing.
623            Stmt::AugAssign(a) => {
624                if let Expr::Name(t) = &*a.target {
625                    if t.id.as_str() == "__all__" {
626                        match string_list(&a.value) {
627                            Some(items) => match &mut m.dunder_all {
628                                Some(all) => all.extend(items),
629                                None => m.dunder_all = Some(items),
630                            },
631                            None => {
632                                m.dunder_all = None;
633                                m.dunder_all_dynamic = true;
634                            }
635                        }
636                    }
637                }
638            }
639            // `__all__.extend([...])` / `__all__.append('x')` — same policy.
640            Stmt::Expr(e) => {
641                if let Expr::Call(c) = &*e.value {
642                    match expr_path(&c.func).as_deref() {
643                        Some("__all__.extend") => {
644                            match c.arguments.args.first().and_then(string_list) {
645                                Some(items) => match &mut m.dunder_all {
646                                    Some(all) => all.extend(items),
647                                    None => m.dunder_all = Some(items),
648                                },
649                                None => {
650                                    m.dunder_all = None;
651                                    m.dunder_all_dynamic = true;
652                                }
653                            }
654                        }
655                        Some("__all__.append") => match c.arguments.args.first() {
656                            Some(Expr::StringLiteral(s)) => match &mut m.dunder_all {
657                                Some(all) => all.push(s.value.to_str().to_string()),
658                                None => m.dunder_all = Some(vec![s.value.to_str().to_string()]),
659                            },
660                            _ => {
661                                m.dunder_all = None;
662                                m.dunder_all_dynamic = true;
663                            }
664                        },
665                        _ => {}
666                    }
667                }
668            }
669            // Recurse into top-level guards for conditional imports/defs.
670            Stmt::If(i) => {
671                if is_main_guard(&i.test) {
672                    m.has_main_guard = true;
673                }
674                // Only the if-body executes under `if TYPE_CHECKING:`; the
675                // elif/else clauses are the runtime branches. Conversely,
676                // `if not TYPE_CHECKING:` makes the *else* the type-only side.
677                let body_tc = type_checking || is_type_checking_guard(&i.test);
678                let else_tc = type_checking || is_not_type_checking_guard(&i.test);
679                let before = m.imports.len();
680                scan_top_level(&i.body, li, body_tc, m);
681                for imp in m.imports[before..].iter_mut() {
682                    if body_tc {
683                        imp.type_checking_only = true;
684                    } else {
685                        // A module-level conditional import is the try/except
686                        // availability probe spelled with `if` (scipy:
687                        // `if _has_uarray: from uarray import _Function`),
688                        // or a platform switch — removing one arm changes
689                        // behavior, so it must never be a certain fix.
690                        imp.in_try = true;
691                    }
692                }
693                for clause in &i.elif_else_clauses {
694                    let before = m.imports.len();
695                    scan_top_level(&clause.body, li, else_tc, m);
696                    for imp in m.imports[before..].iter_mut() {
697                        if else_tc {
698                            imp.type_checking_only = true;
699                        } else {
700                            imp.in_try = true;
701                        }
702                    }
703                }
704            }
705            Stmt::Try(t) => {
706                // Imports in the `try:` body or an `except:` handler are the
707                // availability-probe idiom; mark them so unused-import analysis
708                // never grades them certain. The `else:`/`finally:` suites run
709                // unconditionally and carry no probe semantics.
710                let before = m.imports.len();
711                scan_top_level(&t.body, li, type_checking, m);
712                for h in &t.handlers {
713                    let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
714                    scan_top_level(&eh.body, li, type_checking, m);
715                }
716                for imp in m.imports[before..].iter_mut() {
717                    imp.in_try = true;
718                }
719                scan_top_level(&t.orelse, li, type_checking, m);
720                scan_top_level(&t.finalbody, li, type_checking, m);
721            }
722            // These suites also execute at module import time.
723            Stmt::With(w) => scan_top_level(&w.body, li, type_checking, m),
724            Stmt::For(f) => {
725                scan_top_level(&f.body, li, type_checking, m);
726                scan_top_level(&f.orelse, li, type_checking, m);
727            }
728            Stmt::While(w) => {
729                scan_top_level(&w.body, li, type_checking, m);
730                scan_top_level(&w.orelse, li, type_checking, m);
731            }
732            Stmt::Match(mt) => {
733                for case in &mt.cases {
734                    scan_top_level(&case.body, li, type_checking, m);
735                }
736            }
737            _ => {}
738        }
739    }
740}
741
742/// Collects imports nested inside function/class bodies. `depth` tracks how many
743/// function/class scopes deep we are; `depth > 0` means the import is lazy.
744struct NestedImportVisitor<'a> {
745    li: &'a LineIndex,
746    depth: u32,
747    out: Vec<Import>,
748}
749
750impl<'a> Visitor<'a> for NestedImportVisitor<'a> {
751    fn visit_stmt(&mut self, stmt: &'a Stmt) {
752        match stmt {
753            Stmt::FunctionDef(_) | Stmt::ClassDef(_) => {
754                self.depth += 1;
755                walk_stmt(self, stmt);
756                self.depth -= 1;
757            }
758            Stmt::Import(i) if self.depth > 0 => {
759                parse_import(i, self.li, &mut self.out);
760                walk_stmt(self, stmt);
761            }
762            Stmt::ImportFrom(i) if self.depth > 0 => {
763                self.out.push(parse_import_from(i, self.li));
764                walk_stmt(self, stmt);
765            }
766            _ => walk_stmt(self, stmt),
767        }
768    }
769}
770
771/// `if __name__ == "__main__":` (either operand order) — the module is a
772/// runnable script.
773fn is_main_guard(test: &Expr) -> bool {
774    let Expr::Compare(c) = test else {
775        return false;
776    };
777    let Some((left, op, right)) = c.as_single() else {
778        return false;
779    };
780    if *op != ruff_python_ast::CmpOp::Eq {
781        return false;
782    }
783    let is_name = |e: &Expr| matches!(e, Expr::Name(n) if n.id.as_str() == "__name__");
784    let is_main_str =
785        |e: &Expr| matches!(e, Expr::StringLiteral(s) if s.value.to_str() == "__main__");
786    (is_name(left) && is_main_str(right)) || (is_main_str(left) && is_name(right))
787}
788
789/// `if TYPE_CHECKING:` / `if typing.TYPE_CHECKING:` / `if False:` guard.
790/// Exact match only — `MY_TYPE_CHECKING_OVERRIDE` is not a guard.
791fn is_type_checking_guard(test: &Expr) -> bool {
792    if let Expr::BooleanLiteral(b) = test {
793        return !b.value; // `if False:`
794    }
795    expr_path(test)
796        .map(|p| p == "TYPE_CHECKING" || p.ends_with(".TYPE_CHECKING"))
797        .unwrap_or(false)
798}
799
800/// `if not TYPE_CHECKING:` — the body is the runtime branch; the else clause
801/// is the type-only side.
802fn is_not_type_checking_guard(test: &Expr) -> bool {
803    if let Expr::UnaryOp(u) = test {
804        return matches!(u.op, ruff_python_ast::UnaryOp::Not) && is_type_checking_guard(&u.operand);
805    }
806    false
807}
808
809fn parse_import(i: &StmtImport, li: &LineIndex, out: &mut Vec<Import>) {
810    let line = line1(li, i.range().start());
811    let end_line = end_line1(li, i.range());
812    for alias in &i.names {
813        let module = alias.name.as_str().to_string();
814        let redundant = matches!(&alias.asname, Some(a) if a.as_str() == alias.name.as_str());
815        let binding = match &alias.asname {
816            Some(a) => a.as_str().to_string(),
817            None => module.split('.').next().unwrap_or(&module).to_string(),
818        };
819        if !module.is_empty() {
820            let bindings = if binding.is_empty() {
821                vec![]
822            } else {
823                vec![binding]
824            };
825            out.push(Import {
826                module,
827                relative_dots: 0,
828                names: vec![],
829                redundant: vec![redundant; bindings.len()],
830                bindings,
831                is_star: false,
832                type_checking_only: false,
833                in_try: false,
834                line,
835                end_line,
836            });
837        }
838    }
839}
840
841fn parse_import_from(i: &StmtImportFrom, li: &LineIndex) -> Import {
842    let line = line1(li, i.range().start());
843    let end_line = end_line1(li, i.range());
844    let module = i.module.as_ref().map(|m| m.to_string()).unwrap_or_default();
845    let mut names = Vec::new();
846    let mut bindings = Vec::new();
847    let mut redundant = Vec::new();
848    let mut is_star = false;
849    for alias in &i.names {
850        let name = alias.name.as_str();
851        if name == "*" {
852            is_star = true;
853            continue;
854        }
855        names.push(name.to_string());
856        redundant.push(matches!(&alias.asname, Some(a) if a.as_str() == name));
857        bindings.push(match &alias.asname {
858            Some(a) => a.as_str().to_string(),
859            None => name.to_string(),
860        });
861    }
862    Import {
863        module,
864        relative_dots: i.level.min(u8::MAX as u32) as u8,
865        names,
866        bindings,
867        redundant,
868        is_star,
869        type_checking_only: false,
870        in_try: false,
871        line,
872        end_line,
873    }
874}
875
876/// Extract a list/tuple of string-literal values (for `__all__`).
877fn string_list(e: &Expr) -> Option<Vec<String>> {
878    let elts = match e {
879        Expr::List(l) => &l.elts,
880        Expr::Tuple(t) => &t.elts,
881        _ => return None,
882    };
883    Some(
884        elts.iter()
885            .filter_map(|el| match el {
886                Expr::StringLiteral(s) => Some(s.value.to_str().to_string()),
887                _ => None,
888            })
889            .collect(),
890    )
891}
892
893// ---------------------------------------------------------------------------
894// Complexity
895// ---------------------------------------------------------------------------
896
897fn function_complexity(f: &StmtFunctionDef, li: &LineIndex) -> FunctionComplexity {
898    let (params_total, params_annotated) = count_params(&f.parameters);
899    let mut cv = CycloVisitor { count: 0 };
900    for s in &f.body {
901        cv.visit_stmt(s);
902    }
903    FunctionComplexity {
904        name: f.name.to_string(),
905        // The full range includes decorators; point `line` at the `def`.
906        line: line1(li, f.name.range().start()),
907        end_line: end_line1(li, f.range()),
908        cyclomatic: 1 + cv.count,
909        cognitive: cog_stmts(&f.body, 0),
910        params_total,
911        params_annotated,
912        return_annotated: f.returns.is_some(),
913    }
914}
915
916fn count_params(params: &Parameters) -> (u32, u32) {
917    let positional: Vec<_> = params
918        .posonlyargs
919        .iter()
920        .chain(params.args.iter())
921        .collect();
922    let mut total = 0u32;
923    let mut annotated = 0u32;
924    for (idx, p) in positional.iter().enumerate() {
925        let name = p.parameter.name.as_str();
926        if idx == 0 && (name == "self" || name == "cls") {
927            continue;
928        }
929        total += 1;
930        if p.parameter.annotation.is_some() {
931            annotated += 1;
932        }
933    }
934    for p in &params.kwonlyargs {
935        total += 1;
936        if p.parameter.annotation.is_some() {
937            annotated += 1;
938        }
939    }
940    (total, annotated.min(total))
941}
942
943/// Cyclomatic decision-point counter; does not descend into nested scopes.
944struct CycloVisitor {
945    count: u32,
946}
947impl<'a> Visitor<'a> for CycloVisitor {
948    fn visit_stmt(&mut self, stmt: &'a Stmt) {
949        match stmt {
950            Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return, // attributed separately
951            Stmt::If(i) => {
952                self.count += 1 + i
953                    .elif_else_clauses
954                    .iter()
955                    .filter(|c| c.test.is_some())
956                    .count() as u32;
957            }
958            Stmt::For(_) | Stmt::While(_) => self.count += 1,
959            Stmt::Try(t) => self.count += t.handlers.len() as u32,
960            Stmt::Assert(_) => self.count += 1,
961            Stmt::Match(mt) => self.count += mt.cases.len() as u32,
962            _ => {}
963        }
964        walk_stmt(self, stmt);
965    }
966    fn visit_expr(&mut self, expr: &'a Expr) {
967        match expr {
968            Expr::BoolOp(b) => self.count += (b.values.len() as u32).saturating_sub(1),
969            Expr::If(_) => self.count += 1, // ternary
970            Expr::ListComp(c) => self.count += comp_points(&c.generators),
971            Expr::SetComp(c) => self.count += comp_points(&c.generators),
972            Expr::DictComp(c) => self.count += comp_points(&c.generators),
973            Expr::Generator(c) => self.count += comp_points(&c.generators),
974            _ => {}
975        }
976        walk_expr(self, expr);
977    }
978}
979
980fn comp_points(gens: &[ruff_python_ast::Comprehension]) -> u32 {
981    gens.iter().map(|g| 1 + g.ifs.len() as u32).sum()
982}
983
984/// Cognitive complexity (nesting-weighted approximation of the SonarSource model).
985fn cog_stmts(stmts: &[Stmt], nesting: u32) -> u32 {
986    stmts.iter().map(|s| cog_stmt(s, nesting)).sum()
987}
988
989fn cog_stmt(s: &Stmt, nesting: u32) -> u32 {
990    match s {
991        Stmt::FunctionDef(_) | Stmt::ClassDef(_) => 0,
992        Stmt::If(i) => {
993            let mut c = 1 + nesting + cog_cond(&i.test);
994            c += cog_stmts(&i.body, nesting + 1);
995            for clause in &i.elif_else_clauses {
996                c += 1; // elif/else: flat increment
997                if let Some(t) = &clause.test {
998                    c += cog_cond(t);
999                }
1000                c += cog_stmts(&clause.body, nesting + 1);
1001            }
1002            c
1003        }
1004        Stmt::For(f) => {
1005            1 + nesting + cog_stmts(&f.body, nesting + 1) + cog_stmts(&f.orelse, nesting + 1)
1006        }
1007        Stmt::While(w) => {
1008            1 + nesting
1009                + cog_cond(&w.test)
1010                + cog_stmts(&w.body, nesting + 1)
1011                + cog_stmts(&w.orelse, nesting + 1)
1012        }
1013        Stmt::With(w) => cog_stmts(&w.body, nesting),
1014        Stmt::Try(t) => {
1015            let mut c = cog_stmts(&t.body, nesting);
1016            for h in &t.handlers {
1017                let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1018                c += 1 + nesting + cog_stmts(&eh.body, nesting + 1);
1019            }
1020            c += cog_stmts(&t.orelse, nesting) + cog_stmts(&t.finalbody, nesting);
1021            c
1022        }
1023        Stmt::Match(mt) => {
1024            let mut c = 0;
1025            for case in &mt.cases {
1026                c += 1 + nesting + cog_stmts(&case.body, nesting + 1);
1027            }
1028            c
1029        }
1030        Stmt::Expr(e) => cog_cond(&e.value),
1031        Stmt::Return(r) => r.value.as_ref().map(|v| cog_cond(v)).unwrap_or(0),
1032        Stmt::Assign(a) => cog_cond(&a.value),
1033        Stmt::AugAssign(a) => cog_cond(&a.value),
1034        Stmt::AnnAssign(a) => a.value.as_ref().map(|v| cog_cond(v)).unwrap_or(0),
1035        _ => 0,
1036    }
1037}
1038
1039/// Count boolean operators (+1 each) and ternaries within a condition expr.
1040fn cog_cond(e: &Expr) -> u32 {
1041    let mut v = CondVisitor { count: 0 };
1042    v.visit_expr(e);
1043    v.count
1044}
1045struct CondVisitor {
1046    count: u32,
1047}
1048impl<'a> Visitor<'a> for CondVisitor {
1049    fn visit_expr(&mut self, expr: &'a Expr) {
1050        match expr {
1051            Expr::BoolOp(b) => self.count += (b.values.len() as u32).saturating_sub(1),
1052            Expr::If(_) => self.count += 1,
1053            _ => {}
1054        }
1055        walk_expr(self, expr);
1056    }
1057}
1058
1059// ---------------------------------------------------------------------------
1060// Scope analysis: unused locals / parameters.
1061// ---------------------------------------------------------------------------
1062
1063const SCOPE_DYNAMIC: &[&str] = &["locals", "vars", "globals", "eval", "exec"];
1064
1065fn analyze_scope(
1066    f: &StmtFunctionDef,
1067    name_tokens: &[(TextSize, &str)],
1068    out: &mut Vec<ScopeFinding>,
1069    li: &LineIndex,
1070) {
1071    // Name-token frequency within the function's byte range (binding site + uses).
1072    let range = f.range();
1073    let mut freq: HashMap<&str, u32> = HashMap::new();
1074    for (off, text) in name_tokens {
1075        if *off >= range.start() && *off < range.end() {
1076            *freq.entry(*text).or_insert(0) += 1;
1077        }
1078    }
1079    if SCOPE_DYNAMIC.iter().any(|d| freq.contains_key(*d)) {
1080        return;
1081    }
1082
1083    // global/nonlocal-declared names are not locals.
1084    let mut gv = GlobalVisitor {
1085        names: HashSet::new(),
1086    };
1087    for s in &f.body {
1088        gv.visit_stmt(s);
1089    }
1090    let declared_global = gv.names;
1091
1092    let decorated = !f.decorator_list.is_empty();
1093    let fname = f.name.as_str();
1094    let is_dunder = fname.starts_with("__") && fname.ends_with("__");
1095    let stub = is_stub_body(&f.body);
1096
1097    if !decorated && !is_dunder && !stub {
1098        let positional: Vec<_> = f
1099            .parameters
1100            .posonlyargs
1101            .iter()
1102            .chain(f.parameters.args.iter())
1103            .collect();
1104        for (idx, p) in positional.iter().enumerate() {
1105            let name = p.parameter.name.as_str();
1106            if idx == 0 && (name == "self" || name == "cls") {
1107                continue;
1108            }
1109            if name.starts_with('_') || declared_global.contains(name) {
1110                continue;
1111            }
1112            if freq.get(name).copied().unwrap_or(0) == 1 {
1113                out.push(ScopeFinding {
1114                    line: line1(li, p.parameter.range().start()),
1115                    name: name.to_string(),
1116                    is_param: true,
1117                });
1118            }
1119        }
1120        for p in &f.parameters.kwonlyargs {
1121            let name = p.parameter.name.as_str();
1122            if name.starts_with('_') || declared_global.contains(name) {
1123                continue;
1124            }
1125            if freq.get(name).copied().unwrap_or(0) == 1 {
1126                out.push(ScopeFinding {
1127                    line: line1(li, p.parameter.range().start()),
1128                    name: name.to_string(),
1129                    is_param: true,
1130                });
1131            }
1132        }
1133    }
1134
1135    // Unused local variables: top-level `name = expr` whose name occurs once.
1136    for stmt in &f.body {
1137        if let Stmt::Assign(a) = stmt {
1138            if let [Expr::Name(target)] = a.targets.as_slice() {
1139                let name = target.id.as_str();
1140                if name == "_" || declared_global.contains(name) {
1141                    continue;
1142                }
1143                if freq.get(name).copied().unwrap_or(0) == 1 {
1144                    out.push(ScopeFinding {
1145                        line: line1(li, a.range().start()),
1146                        name: name.to_string(),
1147                        is_param: false,
1148                    });
1149                }
1150            }
1151        }
1152    }
1153}
1154
1155struct GlobalVisitor {
1156    names: HashSet<String>,
1157}
1158impl<'a> Visitor<'a> for GlobalVisitor {
1159    fn visit_stmt(&mut self, stmt: &'a Stmt) {
1160        match stmt {
1161            Stmt::Global(g) => {
1162                for n in &g.names {
1163                    self.names.insert(n.as_str().to_string());
1164                }
1165            }
1166            Stmt::Nonlocal(g) => {
1167                for n in &g.names {
1168                    self.names.insert(n.as_str().to_string());
1169                }
1170            }
1171            _ => {}
1172        }
1173        walk_stmt(self, stmt);
1174    }
1175}
1176
1177/// Is a function body a stub (only `pass`, `...`, a docstring, or `raise ...`)?
1178fn is_stub_body(body: &[Stmt]) -> bool {
1179    body.iter().all(|s| match s {
1180        Stmt::Pass(_) => true,
1181        Stmt::Raise(_) => true,
1182        Stmt::Expr(e) => matches!(&*e.value, Expr::StringLiteral(_) | Expr::EllipsisLiteral(_)),
1183        _ => false,
1184    })
1185}
1186
1187// ---------------------------------------------------------------------------
1188// Classes / cohesion.
1189// ---------------------------------------------------------------------------
1190
1191fn class_info(c: &StmtClassDef, li: &LineIndex) -> ClassInfo {
1192    let mut methods = Vec::new();
1193    let mut members: Vec<ClassMember> = Vec::new();
1194    for stmt in &c.body {
1195        match stmt {
1196            Stmt::FunctionDef(f) => {
1197                methods.push((f.name.to_string(), self_attrs(f)));
1198                members.push(ClassMember {
1199                    name: f.name.to_string(),
1200                    // The full range includes decorators; point at the `def`.
1201                    line: line1(li, f.name.range().start()),
1202                    end_line: end_line1(li, f.range()),
1203                    is_method: true,
1204                    is_private: is_private(f.name.as_str()),
1205                    decorators: f
1206                        .decorator_list
1207                        .iter()
1208                        .filter_map(|d| decorator_path(&d.expression))
1209                        .collect(),
1210                });
1211            }
1212            Stmt::Assign(a) => {
1213                if let [Expr::Name(t)] = a.targets.as_slice() {
1214                    members.push(class_attr_member(t.id.as_str(), a.range(), li));
1215                }
1216            }
1217            Stmt::AnnAssign(a) => {
1218                if let Expr::Name(t) = &*a.target {
1219                    members.push(class_attr_member(t.id.as_str(), a.range(), li));
1220                }
1221            }
1222            _ => {}
1223        }
1224    }
1225    let bases: Vec<String> = c
1226        .arguments
1227        .as_ref()
1228        .map(|args| args.args.iter().filter_map(expr_path).collect())
1229        .unwrap_or_default();
1230    let is_enum = bases.iter().any(|b| {
1231        let last = b.rsplit('.').next().unwrap_or(b);
1232        matches!(
1233            last,
1234            "Enum" | "IntEnum" | "StrEnum" | "Flag" | "IntFlag" | "ReprEnum" | "EnumMeta"
1235        )
1236    });
1237    ClassInfo {
1238        name: c.name.to_string(),
1239        // The full range includes decorators; point `line` at the `class`.
1240        line: line1(li, c.name.range().start()),
1241        end_line: end_line1(li, c.range()),
1242        is_private: is_private(c.name.as_str()),
1243        decorators: c
1244            .decorator_list
1245            .iter()
1246            .filter_map(|d| decorator_path(&d.expression))
1247            .collect(),
1248        bases,
1249        is_enum,
1250        methods,
1251        members,
1252    }
1253}
1254
1255fn class_attr_member(name: &str, range: TextRange, li: &LineIndex) -> ClassMember {
1256    ClassMember {
1257        name: name.to_string(),
1258        line: line1(li, range.start()),
1259        end_line: end_line1(li, range),
1260        is_method: false,
1261        is_private: is_private(name),
1262        decorators: Vec::new(),
1263    }
1264}
1265
1266// ---------------------------------------------------------------------------
1267// Unreachable code: statements after an unconditional terminator in a block.
1268// ---------------------------------------------------------------------------
1269
1270struct UnreachableVisitor<'li> {
1271    li: &'li LineIndex,
1272    out: Vec<UnreachableCode>,
1273}
1274impl<'li> UnreachableVisitor<'li> {
1275    /// Inspect one suite (block) for a terminator followed by more statements.
1276    fn scan(&mut self, body: &[Stmt]) {
1277        for (i, stmt) in body.iter().enumerate() {
1278            if let Some(term) = terminator_kind(stmt) {
1279                if let Some(next) = body.get(i + 1) {
1280                    // Ignore a lone trailing string (rare) — still report code.
1281                    self.out.push(UnreachableCode {
1282                        line: line1(self.li, next.range().start()),
1283                        after: term,
1284                    });
1285                }
1286                break; // first terminator in the block is enough
1287            }
1288        }
1289    }
1290}
1291impl<'a, 'li> Visitor<'a> for UnreachableVisitor<'li> {
1292    fn visit_stmt(&mut self, stmt: &'a Stmt) {
1293        // Scan every nested suite, then recurse.
1294        match stmt {
1295            Stmt::FunctionDef(f) => self.scan(&f.body),
1296            Stmt::ClassDef(c) => self.scan(&c.body),
1297            Stmt::If(i) => {
1298                self.scan(&i.body);
1299                for c in &i.elif_else_clauses {
1300                    self.scan(&c.body);
1301                }
1302            }
1303            Stmt::For(f) => {
1304                self.scan(&f.body);
1305                self.scan(&f.orelse);
1306            }
1307            Stmt::While(w) => {
1308                self.scan(&w.body);
1309                self.scan(&w.orelse);
1310            }
1311            Stmt::With(w) => self.scan(&w.body),
1312            Stmt::Try(t) => {
1313                self.scan(&t.body);
1314                for h in &t.handlers {
1315                    let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1316                    self.scan(&eh.body);
1317                }
1318                self.scan(&t.orelse);
1319                self.scan(&t.finalbody);
1320            }
1321            Stmt::Match(mt) => {
1322                for case in &mt.cases {
1323                    self.scan(&case.body);
1324                }
1325            }
1326            _ => {}
1327        }
1328        walk_stmt(self, stmt);
1329    }
1330}
1331
1332/// If `stmt` unconditionally exits its block, return the terminator label.
1333fn terminator_kind(stmt: &Stmt) -> Option<&'static str> {
1334    match stmt {
1335        Stmt::Return(_) => Some("return"),
1336        Stmt::Raise(_) => Some("raise"),
1337        Stmt::Break(_) => Some("break"),
1338        Stmt::Continue(_) => Some("continue"),
1339        Stmt::Expr(e) if is_noreturn_call(&e.value) => Some("exit call"),
1340        _ => None,
1341    }
1342}
1343
1344/// `sys.exit(...)`, `os._exit(...)`, `exit(...)`, `quit(...)` — process-ending.
1345fn is_noreturn_call(e: &Expr) -> bool {
1346    if let Expr::Call(c) = e {
1347        if let Some(p) = expr_path(&c.func) {
1348            // Exact paths only — avoids treating a user method `self.exit()` as
1349            // process-ending.
1350            return matches!(p.as_str(), "sys.exit" | "os._exit" | "exit" | "quit");
1351        }
1352    }
1353    false
1354}
1355
1356// ---------------------------------------------------------------------------
1357// Private-type leaks: a public function/method exposing a `_Private` type.
1358// ---------------------------------------------------------------------------
1359
1360/// A type name is "private by convention" if it starts with a single underscore
1361/// (but is not a dunder like `__init__`).
1362fn is_private_type(name: &str) -> bool {
1363    name.starts_with('_') && !(name.starts_with("__") && name.ends_with("__"))
1364}
1365
1366fn scan_type_leaks(body: &[Stmt], li: &LineIndex, out: &mut Vec<TypeLeak>) {
1367    // `_T = TypeVar(...)` and friends are *intentionally* private type params,
1368    // not API leaks — collect and exclude them.
1369    let mut typevars: HashSet<String> = HashSet::new();
1370    collect_typevars(body, &mut typevars);
1371    for stmt in body {
1372        match stmt {
1373            Stmt::FunctionDef(f) if !is_private(f.name.as_str()) => {
1374                collect_fn_leaks(None, f, li, &typevars, out);
1375            }
1376            Stmt::ClassDef(c) if !is_private(c.name.as_str()) => {
1377                for s in &c.body {
1378                    if let Stmt::FunctionDef(f) = s {
1379                        if !is_private(f.name.as_str()) {
1380                            collect_fn_leaks(Some(c.name.as_str()), f, li, &typevars, out);
1381                        }
1382                    }
1383                }
1384            }
1385            _ => {}
1386        }
1387    }
1388}
1389
1390/// Collect names bound to `TypeVar`/`ParamSpec`/`TypeVarTuple` (anywhere),
1391/// including under `if TYPE_CHECKING:`-style guards and try blocks.
1392fn collect_typevars(body: &[Stmt], out: &mut HashSet<String>) {
1393    for stmt in body {
1394        match stmt {
1395            Stmt::Assign(a) => {
1396                if let (Some(Expr::Name(t)), Expr::Call(c)) = (a.targets.first(), &*a.value) {
1397                    if let Some(p) = expr_path(&c.func) {
1398                        let last = p.rsplit('.').next().unwrap_or(&p);
1399                        if matches!(last, "TypeVar" | "ParamSpec" | "TypeVarTuple") {
1400                            out.insert(t.id.as_str().to_string());
1401                        }
1402                    }
1403                }
1404            }
1405            Stmt::If(i) => {
1406                collect_typevars(&i.body, out);
1407                for clause in &i.elif_else_clauses {
1408                    collect_typevars(&clause.body, out);
1409                }
1410            }
1411            Stmt::Try(t) => {
1412                collect_typevars(&t.body, out);
1413                for h in &t.handlers {
1414                    let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1415                    collect_typevars(&eh.body, out);
1416                }
1417                collect_typevars(&t.orelse, out);
1418                collect_typevars(&t.finalbody, out);
1419            }
1420            _ => {}
1421        }
1422    }
1423}
1424
1425fn collect_fn_leaks(
1426    class: Option<&str>,
1427    f: &StmtFunctionDef,
1428    li: &LineIndex,
1429    typevars: &HashSet<String>,
1430    out: &mut Vec<TypeLeak>,
1431) {
1432    let qualified = match class {
1433        Some(c) => format!("{c}.{}", f.name),
1434        None => f.name.to_string(),
1435    };
1436    let push_leaks = |ann: &Expr, line: u32, is_return: bool, out: &mut Vec<TypeLeak>| {
1437        let mut idents = Vec::new();
1438        annotation_idents(ann, &mut idents);
1439        for id in idents {
1440            if is_private_type(&id) && !typevars.contains(&id) {
1441                out.push(TypeLeak {
1442                    function: qualified.clone(),
1443                    type_name: id,
1444                    line,
1445                    is_return,
1446                });
1447            }
1448        }
1449    };
1450    for p in f
1451        .parameters
1452        .posonlyargs
1453        .iter()
1454        .chain(f.parameters.args.iter())
1455        .chain(f.parameters.kwonlyargs.iter())
1456    {
1457        if let Some(ann) = &p.parameter.annotation {
1458            push_leaks(ann, line1(li, p.parameter.range().start()), false, out);
1459        }
1460    }
1461    if let Some(r) = &f.returns {
1462        // Point at the `def` line, not the first decorator.
1463        push_leaks(r, line1(li, f.name.range().start()), true, out);
1464    }
1465}
1466
1467/// Collect type-name identifiers referenced in an annotation expression,
1468/// descending through subscripts/unions/strings (`Optional[_Foo]`, `_A | _B`,
1469/// `"_Forward"`, `mod._Priv`).
1470fn annotation_idents(e: &Expr, out: &mut Vec<String>) {
1471    match e {
1472        Expr::Name(n) => out.push(n.id.as_str().to_string()),
1473        Expr::Attribute(a) => {
1474            annotation_idents(&a.value, out);
1475            out.push(a.attr.as_str().to_string());
1476        }
1477        Expr::Subscript(s) => {
1478            annotation_idents(&s.value, out);
1479            annotation_idents(&s.slice, out);
1480        }
1481        Expr::Tuple(t) => t.elts.iter().for_each(|el| annotation_idents(el, out)),
1482        Expr::List(l) => l.elts.iter().for_each(|el| annotation_idents(el, out)),
1483        Expr::BinOp(b) => {
1484            annotation_idents(&b.left, out);
1485            annotation_idents(&b.right, out);
1486        }
1487        Expr::StringLiteral(s) => {
1488            for tok in identifier_tokens(s.value.to_str()) {
1489                out.push(tok);
1490            }
1491        }
1492        _ => {}
1493    }
1494}
1495
1496fn self_attrs(f: &StmtFunctionDef) -> Vec<String> {
1497    let mut v = SelfAttrVisitor {
1498        attrs: std::collections::BTreeSet::new(),
1499    };
1500    for s in &f.body {
1501        v.visit_stmt(s);
1502    }
1503    v.attrs.into_iter().collect()
1504}
1505
1506struct SelfAttrVisitor {
1507    attrs: std::collections::BTreeSet<String>,
1508}
1509impl<'a> Visitor<'a> for SelfAttrVisitor {
1510    fn visit_expr(&mut self, expr: &'a Expr) {
1511        if let Expr::Attribute(a) = expr {
1512            if let Expr::Name(obj) = &*a.value {
1513                if obj.id.as_str() == "self" || obj.id.as_str() == "cls" {
1514                    self.attrs.insert(a.attr.as_str().to_string());
1515                }
1516            }
1517        }
1518        walk_expr(self, expr);
1519    }
1520}
1521
1522// ---------------------------------------------------------------------------
1523// Collect definitions of nested functions/classes (whole tree).
1524// ---------------------------------------------------------------------------
1525
1526struct DefVisitor<'a> {
1527    funcs: Vec<&'a StmtFunctionDef>,
1528    classes: Vec<&'a StmtClassDef>,
1529}
1530impl<'a> Visitor<'a> for DefVisitor<'a> {
1531    fn visit_stmt(&mut self, stmt: &'a Stmt) {
1532        match stmt {
1533            Stmt::FunctionDef(f) => self.funcs.push(f),
1534            Stmt::ClassDef(c) => self.classes.push(c),
1535            _ => {}
1536        }
1537        walk_stmt(self, stmt);
1538    }
1539}
1540
1541// ---------------------------------------------------------------------------
1542// Local uses (identifiers outside import statements + string annotations).
1543// ---------------------------------------------------------------------------
1544
1545struct LocalUseVisitor {
1546    uses: Vec<String>,
1547    /// Attribute names accessed (`obj.attr`) — the "member used" signal.
1548    attrs: Vec<String>,
1549}
1550impl<'a> Visitor<'a> for LocalUseVisitor {
1551    fn visit_stmt(&mut self, stmt: &'a Stmt) {
1552        // Import bindings are not "uses".
1553        if matches!(stmt, Stmt::Import(_) | Stmt::ImportFrom(_)) {
1554            return;
1555        }
1556        // String forward-ref annotations: extract identifier tokens.
1557        if let Stmt::AnnAssign(a) = stmt {
1558            collect_annotation_strings(&a.annotation, &mut self.uses);
1559            // A quoted TypeAlias *value* is type syntax too:
1560            // `_P: TypeAlias = 'partial[Any] | partialmethod[Any]'` uses
1561            // `partial`/`partialmethod` (type checkers — and pydantic at
1562            // runtime — evaluate the string). Found live on pydantic, where
1563            // the import was wrongly certain + auto-fixable.
1564            if is_type_alias_annotation(&a.annotation) {
1565                if let Some(v) = &a.value {
1566                    collect_annotation_strings(v, &mut self.uses);
1567                }
1568            }
1569        }
1570        if let Stmt::FunctionDef(f) = stmt {
1571            if let Some(r) = &f.returns {
1572                collect_annotation_strings(r, &mut self.uses);
1573            }
1574            for p in f
1575                .parameters
1576                .posonlyargs
1577                .iter()
1578                .chain(f.parameters.args.iter())
1579                .chain(f.parameters.kwonlyargs.iter())
1580            {
1581                if let Some(ann) = &p.parameter.annotation {
1582                    collect_annotation_strings(ann, &mut self.uses);
1583                }
1584            }
1585        }
1586        walk_stmt(self, stmt);
1587    }
1588    fn visit_expr(&mut self, expr: &'a Expr) {
1589        match expr {
1590            Expr::Name(n) => self.uses.push(n.id.as_str().to_string()),
1591            Expr::Attribute(a) => {
1592                self.uses.push(a.attr.as_str().to_string());
1593                self.attrs.push(a.attr.as_str().to_string());
1594            }
1595            // `cast("dict[int, Foo]", x)`: the string is a type expression
1596            // referencing `Foo` — removing Foo's import breaks type checking.
1597            // Found live on lmcache, where `fix --apply` introduced four F821s.
1598            Expr::Call(c) => {
1599                let is_cast = expr_path(&c.func)
1600                    .map(|p| p == "cast" || p.ends_with(".cast"))
1601                    .unwrap_or(false);
1602                if is_cast {
1603                    if let Some(first) = c.arguments.args.first() {
1604                        collect_annotation_strings(first, &mut self.uses);
1605                    }
1606                }
1607            }
1608            _ => {}
1609        }
1610        walk_expr(self, expr);
1611    }
1612}
1613
1614/// `TypeAlias` / `typing.TypeAlias` / `typing_extensions.TypeAlias` as an
1615/// AnnAssign annotation — marks the assigned value as type syntax.
1616fn is_type_alias_annotation(e: &Expr) -> bool {
1617    expr_path(e)
1618        .map(|p| p == "TypeAlias" || p.ends_with(".TypeAlias"))
1619        .unwrap_or(false)
1620}
1621
1622/// Pull identifier-like tokens out of any string literal inside an annotation
1623/// expression (`x: "Foo"`, `List["pkg.Bar"]`), plus referenced Names.
1624fn collect_annotation_strings(e: &Expr, out: &mut Vec<String>) {
1625    match e {
1626        Expr::StringLiteral(s) => {
1627            for tok in identifier_tokens(s.value.to_str()) {
1628                out.push(tok);
1629            }
1630        }
1631        Expr::Subscript(s) => {
1632            collect_annotation_strings(&s.value, out);
1633            collect_annotation_strings(&s.slice, out);
1634        }
1635        Expr::Tuple(t) => {
1636            for el in &t.elts {
1637                collect_annotation_strings(el, out);
1638            }
1639        }
1640        Expr::List(l) => {
1641            for el in &l.elts {
1642                collect_annotation_strings(el, out);
1643            }
1644        }
1645        Expr::BinOp(b) => {
1646            collect_annotation_strings(&b.left, out);
1647            collect_annotation_strings(&b.right, out);
1648        }
1649        _ => {}
1650    }
1651}
1652
1653fn identifier_tokens(s: &str) -> Vec<String> {
1654    let mut out = Vec::new();
1655    let mut cur = String::new();
1656    let flush = |cur: &mut String, out: &mut Vec<String>| {
1657        if !cur.is_empty() && !cur.chars().next().unwrap().is_ascii_digit() {
1658            out.push(std::mem::take(cur));
1659        } else {
1660            cur.clear();
1661        }
1662    };
1663    for ch in s.chars() {
1664        if ch.is_ascii_alphanumeric() || ch == '_' {
1665            cur.push(ch);
1666        } else {
1667            flush(&mut cur, &mut out);
1668        }
1669    }
1670    flush(&mut cur, &mut out);
1671    out
1672}
1673
1674// ---------------------------------------------------------------------------
1675// Scope/binding resolution.
1676//
1677// A real (if compact) LEGB resolver: it tracks a stack of *function* scopes,
1678// each with its statically-determined local bindings (Python's rule: a name
1679// assigned anywhere in a function body is local to it, unless declared
1680// `global`). A `Name` load resolves to module/global scope when no enclosing
1681// function scope binds it. `global x` forces module resolution; `nonlocal x`
1682// binds to an enclosing function (treated as local-here so it never bubbles to
1683// module). Class bodies are transparent to nested functions, matching Python.
1684// ---------------------------------------------------------------------------
1685
1686struct FnScope {
1687    locals: HashSet<String>,
1688    globals: HashSet<String>,
1689}
1690
1691struct Resolver {
1692    scopes: Vec<FnScope>,
1693    used: HashSet<String>,
1694}
1695
1696impl Resolver {
1697    fn resolve_load(&mut self, name: &str) {
1698        for s in self.scopes.iter().rev() {
1699            if s.globals.contains(name) {
1700                self.used.insert(name.to_string()); // `global` → module binding
1701                return;
1702            }
1703            if s.locals.contains(name) {
1704                return; // bound by an enclosing function scope
1705            }
1706        }
1707        // Not bound by any function scope → module/global scope.
1708        self.used.insert(name.to_string());
1709    }
1710
1711    fn enter_function(&mut self, f: &StmtFunctionDef) {
1712        let mut bv = BindingVisitor {
1713            locals: HashSet::new(),
1714            globals: HashSet::new(),
1715        };
1716        for p in param_names(&f.parameters) {
1717            bv.locals.insert(p);
1718        }
1719        for stmt in &f.body {
1720            bv.visit_stmt(stmt);
1721        }
1722        // `global` names are not locals.
1723        for g in &bv.globals {
1724            bv.locals.remove(g);
1725        }
1726        self.scopes.push(FnScope {
1727            locals: bv.locals,
1728            globals: bv.globals,
1729        });
1730    }
1731
1732    /// Parameter defaults and annotations (and the return annotation) evaluate
1733    /// in the *enclosing* scope, before the function/lambda scope exists.
1734    fn visit_signature_exprs(&mut self, params: &Parameters) {
1735        for p in params
1736            .posonlyargs
1737            .iter()
1738            .chain(params.args.iter())
1739            .chain(params.kwonlyargs.iter())
1740        {
1741            if let Some(d) = &p.default {
1742                self.visit_expr(d);
1743            }
1744            if let Some(a) = &p.parameter.annotation {
1745                self.visit_expr(a);
1746            }
1747        }
1748        if let Some(v) = &params.vararg {
1749            if let Some(a) = &v.annotation {
1750                self.visit_expr(a);
1751            }
1752        }
1753        if let Some(k) = &params.kwarg {
1754            if let Some(a) = &k.annotation {
1755                self.visit_expr(a);
1756            }
1757        }
1758    }
1759}
1760
1761impl<'a> Visitor<'a> for Resolver {
1762    fn visit_stmt(&mut self, stmt: &'a Stmt) {
1763        match stmt {
1764            Stmt::FunctionDef(f) => {
1765                // Decorators / default values / annotations resolve in the
1766                // current scope (visited before the function scope is pushed).
1767                for d in &f.decorator_list {
1768                    self.visit_expr(&d.expression);
1769                }
1770                self.visit_signature_exprs(&f.parameters);
1771                if let Some(r) = &f.returns {
1772                    self.visit_expr(r);
1773                }
1774                self.enter_function(f);
1775                for stmt in &f.body {
1776                    self.visit_stmt(stmt);
1777                }
1778                self.scopes.pop();
1779            }
1780            Stmt::ClassDef(c) => {
1781                for d in &c.decorator_list {
1782                    self.visit_expr(&d.expression);
1783                }
1784                if let Some(args) = &c.arguments {
1785                    for a in args.args.iter() {
1786                        self.visit_expr(a);
1787                    }
1788                    for kw in args.keywords.iter() {
1789                        self.visit_expr(&kw.value);
1790                    }
1791                }
1792                // Class body is transparent (its bindings are Stores, not loads).
1793                for stmt in &c.body {
1794                    self.visit_stmt(stmt);
1795                }
1796            }
1797            _ => walk_stmt(self, stmt),
1798        }
1799    }
1800
1801    fn visit_expr(&mut self, expr: &'a Expr) {
1802        match expr {
1803            Expr::Name(n) => {
1804                if matches!(n.ctx, ExprContext::Load) {
1805                    self.resolve_load(n.id.as_str());
1806                }
1807            }
1808            Expr::Lambda(l) => {
1809                let mut locals = HashSet::new();
1810                if let Some(params) = &l.parameters {
1811                    // Defaults resolve in the enclosing scope, not the lambda's.
1812                    self.visit_signature_exprs(params);
1813                    for p in param_names(params) {
1814                        locals.insert(p);
1815                    }
1816                }
1817                self.scopes.push(FnScope {
1818                    locals,
1819                    globals: HashSet::new(),
1820                });
1821                self.visit_expr(&l.body);
1822                self.scopes.pop();
1823            }
1824            _ => walk_expr(self, expr),
1825        }
1826    }
1827}
1828
1829/// Collect a function scope's local bindings (Store names, nested def/class
1830/// names, `global`/`nonlocal` declarations) without descending into nested
1831/// function/class/lambda scopes.
1832struct BindingVisitor {
1833    locals: HashSet<String>,
1834    globals: HashSet<String>,
1835}
1836impl<'a> Visitor<'a> for BindingVisitor {
1837    fn visit_stmt(&mut self, stmt: &'a Stmt) {
1838        match stmt {
1839            Stmt::FunctionDef(f) => {
1840                self.locals.insert(f.name.to_string());
1841            }
1842            Stmt::ClassDef(c) => {
1843                self.locals.insert(c.name.to_string());
1844            }
1845            Stmt::Global(g) => {
1846                for n in &g.names {
1847                    self.globals.insert(n.to_string());
1848                }
1849            }
1850            Stmt::Nonlocal(g) => {
1851                for n in &g.names {
1852                    // nonlocal binds to an enclosing function — never module.
1853                    self.locals.insert(n.to_string());
1854                }
1855            }
1856            _ => walk_stmt(self, stmt),
1857        }
1858    }
1859    fn visit_expr(&mut self, expr: &'a Expr) {
1860        match expr {
1861            Expr::Name(n) if matches!(n.ctx, ExprContext::Store) => {
1862                self.locals.insert(n.id.as_str().to_string());
1863            }
1864            // Don't descend into nested scopes: their bindings aren't ours.
1865            // Python 3 comprehensions have their own scope, so their targets
1866            // are not locals here either. (Their iterables/conditions do
1867            // evaluate in this scope, but they only load, never bind.)
1868            Expr::Lambda(_)
1869            | Expr::ListComp(_)
1870            | Expr::SetComp(_)
1871            | Expr::DictComp(_)
1872            | Expr::Generator(_) => {}
1873            _ => walk_expr(self, expr),
1874        }
1875    }
1876}
1877
1878fn param_names(params: &Parameters) -> Vec<String> {
1879    let mut out = Vec::new();
1880    for p in params
1881        .posonlyargs
1882        .iter()
1883        .chain(params.args.iter())
1884        .chain(params.kwonlyargs.iter())
1885    {
1886        out.push(p.parameter.name.as_str().to_string());
1887    }
1888    if let Some(v) = &params.vararg {
1889        out.push(v.name.as_str().to_string());
1890    }
1891    if let Some(k) = &params.kwarg {
1892        out.push(k.name.as_str().to_string());
1893    }
1894    out
1895}
1896
1897// ---------------------------------------------------------------------------
1898// Calls, dynamic sinks, security (whole tree).
1899// ---------------------------------------------------------------------------
1900
1901struct MainVisitor<'a, 'm> {
1902    li: &'a LineIndex,
1903    m: &'m mut ParsedModule,
1904    /// Names quote-sanitized (`s = s.replace("'", "''")`) in the current
1905    /// function. Frame 0 is module scope; each function or class pushes one.
1906    sanitized_idents: Vec<HashSet<String>>,
1907}
1908impl<'a, 'm> Visitor<'a> for MainVisitor<'a, 'm> {
1909    fn visit_stmt(&mut self, stmt: &'a Stmt) {
1910        if matches!(stmt, Stmt::FunctionDef(_) | Stmt::ClassDef(_)) {
1911            self.sanitized_idents.push(HashSet::new());
1912            walk_stmt(self, stmt);
1913            self.sanitized_idents.pop();
1914            return;
1915        }
1916        match stmt {
1917            Stmt::Assign(a) => {
1918                if expr_is_quote_replace(&a.value) {
1919                    if let Some(set) = self.sanitized_idents.last_mut() {
1920                        for t in &a.targets {
1921                            if let Expr::Name(n) = t {
1922                                set.insert(n.id.to_string());
1923                            }
1924                        }
1925                    }
1926                }
1927                if let [Expr::Name(t)] = a.targets.as_slice() {
1928                    security_secret(t.id.as_str(), &a.value, a.range(), self.li, self.m);
1929                }
1930            }
1931            Stmt::AnnAssign(a) => {
1932                if let (Expr::Name(t), Some(v)) = (&*a.target, &a.value) {
1933                    security_secret(t.id.as_str(), v, a.range(), self.li, self.m);
1934                }
1935            }
1936            Stmt::If(i) => {
1937                // `if "'" in target: raise` rejects the quote and leaves the
1938                // name safe for a later identifier interpolation.
1939                if block_aborts(&i.body) {
1940                    if let Some(set) = self.sanitized_idents.last_mut() {
1941                        note_quote_rejection(&i.test, set);
1942                    }
1943                }
1944            }
1945            Stmt::Try(t) => {
1946                // try/except/pass (B110): a broad handler that silently swallows
1947                // errors. Only flag bare `except:` or `except Exception/BaseException`.
1948                for h in &t.handlers {
1949                    let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1950                    let broad = match &eh.type_ {
1951                        None => true,
1952                        Some(ty) => expr_path(ty)
1953                            .map(|p| {
1954                                matches!(
1955                                    p.rsplit('.').next().unwrap_or(&p),
1956                                    "Exception" | "BaseException"
1957                                )
1958                            })
1959                            .unwrap_or(false),
1960                    };
1961                    if broad && eh.body.iter().all(|s| matches!(s, Stmt::Pass(_))) {
1962                        self.m.security_hits.push(SecurityHit {
1963                            rule: "try-except-pass",
1964                            line: line1(self.li, eh.range().start()),
1965                            detail:
1966                                "broad `except: pass` silently swallows errors; log or handle them"
1967                                    .into(),
1968                        });
1969                    }
1970                }
1971            }
1972            _ => {}
1973        }
1974        walk_stmt(self, stmt);
1975    }
1976    fn visit_expr(&mut self, expr: &'a Expr) {
1977        if let Expr::StringLiteral(s) = expr {
1978            if let Some(path) = py_path_literal(s.value.to_str()) {
1979                self.m.path_literals.push(path);
1980            }
1981        }
1982        if let Expr::Call(c) = expr {
1983            let callee = expr_path(&c.func).unwrap_or_default();
1984            if !callee.is_empty() {
1985                if DYNAMIC_SINKS.contains(&callee.as_str()) || callee.starts_with("importlib") {
1986                    self.m.has_dynamic_sink = true;
1987                }
1988                self.m.calls.push(CallSite {
1989                    callee: callee.clone(),
1990                    line: line1(self.li, c.func.range().start()),
1991                });
1992            }
1993            // `importlib.import_module("pkg.hooks.evaluate")` and a project
1994            // `load_hook("pkg.hooks.export")` name a real module. Record it as
1995            // a lazy import so reachability sees the plugin.
1996            if let Some(module) = loaded_module_literal(&callee, c) {
1997                let line = line1(self.li, c.range().start());
1998                self.m.nested_imports.push(Import {
1999                    module,
2000                    relative_dots: 0,
2001                    names: Vec::new(),
2002                    bindings: Vec::new(),
2003                    is_star: false,
2004                    type_checking_only: false,
2005                    redundant: Vec::new(),
2006                    in_try: false,
2007                    line,
2008                    end_line: line,
2009                });
2010            }
2011            let sanitized = self.sanitized_idents.last().cloned().unwrap_or_default();
2012            security_call(
2013                c,
2014                &callee,
2015                line1(self.li, c.range().start()),
2016                self.m,
2017                &sanitized,
2018            );
2019        }
2020        walk_expr(self, expr);
2021    }
2022}
2023
2024const SECRET_NAMES: &[&str] = &[
2025    "password",
2026    "passwd",
2027    "secret",
2028    "token",
2029    "api_key",
2030    "apikey",
2031    "access_key",
2032    "secret_key",
2033    "private_key",
2034    "auth_token",
2035];
2036
2037fn security_secret(
2038    name: &str,
2039    value: &Expr,
2040    range: TextRange,
2041    li: &LineIndex,
2042    m: &mut ParsedModule,
2043) {
2044    let lname = name.to_ascii_lowercase();
2045    if !SECRET_NAMES.iter().any(|s| lname.contains(s)) {
2046        return;
2047    }
2048    if let Expr::StringLiteral(s) = value {
2049        let val = s.value.to_str();
2050        // `apiKey = "apiKey"` is an enum/schema token, not a credential.
2051        if val.eq_ignore_ascii_case(name) {
2052            return;
2053        }
2054        // A URL, or a sentence, is not a token. `_TOKEN_URL = "https://..."`
2055        // and an ImportError message about the secretstorage package both
2056        // contain a secret word in the name only.
2057        if val.starts_with("http://")
2058            || val.starts_with("https://")
2059            || val.contains(char::is_whitespace)
2060        {
2061            return;
2062        }
2063        if val.len() >= 4 && !val.contains("${") && !val.eq_ignore_ascii_case("changeme") {
2064            m.security_hits.push(SecurityHit {
2065                rule: "hardcoded-secret",
2066                line: line1(li, range.start()),
2067                detail: format!("`{name}` assigned a hardcoded string literal"),
2068            });
2069        }
2070    }
2071}
2072
2073const WEAK_CIPHERS: &[&str] = &[
2074    "DES",
2075    "DES3",
2076    "TripleDES",
2077    "ARC2",
2078    "RC2",
2079    "ARC4",
2080    "RC4",
2081    "Blowfish",
2082    "IDEA",
2083    "CAST",
2084    "XOR",
2085];
2086
2087fn kwarg_bool(c: &ruff_python_ast::ExprCall, name: &str, want: bool) -> bool {
2088    c.arguments
2089        .find_keyword(name)
2090        .map(|kw| matches!(&kw.value, Expr::BooleanLiteral(b) if b.value == want))
2091        .unwrap_or(false)
2092}
2093
2094fn has_kwarg(c: &ruff_python_ast::ExprCall, name: &str) -> bool {
2095    c.arguments.find_keyword(name).is_some()
2096}
2097
2098fn first_positional_is_string(c: &ruff_python_ast::ExprCall) -> bool {
2099    matches!(c.arguments.args.first(), Some(Expr::StringLiteral(_)))
2100}
2101
2102/// A relative Python path named by a string literal: `hooks/export.py` or
2103/// `schema.py`. Absolute paths, `..`, and `__init__.py` are not plugin files.
2104fn py_path_literal(text: &str) -> Option<String> {
2105    let text = text.trim().strip_prefix("./").unwrap_or(text.trim());
2106    if text.is_empty()
2107        || text.starts_with('/')
2108        || text.contains('\\')
2109        || text.contains("..")
2110        || text.contains('\n')
2111        || !text.ends_with(".py")
2112        || text == "__init__.py"
2113        || text.ends_with("/__init__.py")
2114    {
2115        return None;
2116    }
2117    let ok = text.split('/').all(|seg| {
2118        !seg.is_empty()
2119            && seg
2120                .chars()
2121                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
2122    });
2123    if ok {
2124        Some(text.to_string())
2125    } else {
2126        None
2127    }
2128}
2129
2130/// A string-literal module path passed to `importlib.import_module`,
2131/// `__import__`, or a callable named `load_hook` / `load_plugin`.
2132fn loaded_module_literal(callee: &str, call: &ruff_python_ast::ExprCall) -> Option<String> {
2133    let leaf = callee.rsplit('.').next().unwrap_or(callee);
2134    let loader = matches!(
2135        leaf,
2136        "import_module" | "__import__" | "load_hook" | "load_plugin"
2137    );
2138    if !loader {
2139        return None;
2140    }
2141    let arg = call.arguments.args.first()?;
2142    let Expr::StringLiteral(s) = arg else {
2143        return None;
2144    };
2145    let module = s.value.to_str().trim();
2146    if module.split('.').all(|seg| {
2147        let mut chars = seg.chars();
2148        match chars.next() {
2149            Some(c) if c.is_ascii_alphabetic() || c == '_' => {
2150                chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2151            }
2152            _ => false,
2153        }
2154    }) {
2155        Some(module.to_string())
2156    } else {
2157        None
2158    }
2159}
2160
2161/// `if "'" in name: raise` / `return`. The body aborts, so a later use of
2162/// `name` has already rejected a quote. A check that only logs does not.
2163fn block_aborts(body: &[Stmt]) -> bool {
2164    !body.is_empty()
2165        && body
2166            .iter()
2167            .all(|s| matches!(s, Stmt::Raise(_) | Stmt::Return(_)))
2168}
2169
2170fn note_quote_rejection(test: &Expr, set: &mut HashSet<String>) {
2171    match test {
2172        Expr::BoolOp(b) => {
2173            for v in &b.values {
2174                note_quote_rejection(v, set);
2175            }
2176        }
2177        Expr::Compare(c) => {
2178            if let Some((left, op, right)) = c.as_single() {
2179                if *op == ruff_python_ast::CmpOp::In && string_contains_quote(left) {
2180                    if let Expr::Name(n) = right {
2181                        set.insert(n.id.to_string());
2182                    }
2183                }
2184            }
2185        }
2186        _ => {}
2187    }
2188}
2189
2190/// `name = name.replace("'", "''")` (or any `.replace` whose first argument
2191/// is a quote). The assignment target is the sanitized identifier.
2192fn expr_is_quote_replace(expr: &Expr) -> bool {
2193    let Expr::Call(c) = expr else {
2194        return false;
2195    };
2196    let Expr::Attribute(attr) = c.func.as_ref() else {
2197        return false;
2198    };
2199    if attr.attr.as_str() != "replace" {
2200        return false;
2201    }
2202    c.arguments.args.first().is_some_and(string_contains_quote)
2203}
2204
2205fn string_contains_quote(expr: &Expr) -> bool {
2206    let Expr::StringLiteral(s) = expr else {
2207        return false;
2208    };
2209    let text = s.value.to_str();
2210    text.contains('\'') || text.contains('"')
2211}
2212
2213/// An f-string whose holes are all names quote-sanitized in this function,
2214/// and whose static SQL is identifier DDL (`ATTACH` / `COPY` / `CREATE`)
2215/// rather than a value predicate (`WHERE` / `VALUES` / `SET`).
2216fn is_sanitized_identifier_sql(arg: &Expr, sanitized: &HashSet<String>) -> bool {
2217    let Expr::FString(f) = arg else {
2218        return false;
2219    };
2220    let mut static_sql = String::new();
2221    for el in f.value.elements() {
2222        match el {
2223            ruff_python_ast::InterpolatedStringElement::Literal(lit) => {
2224                static_sql.push_str(&lit.value);
2225            }
2226            ruff_python_ast::InterpolatedStringElement::Interpolation(interp) => {
2227                let Expr::Name(n) = interp.expression.as_ref() else {
2228                    return false;
2229                };
2230                if !sanitized.contains(n.id.as_str()) {
2231                    return false;
2232                }
2233            }
2234        }
2235    }
2236    let upper = static_sql.to_ascii_uppercase();
2237    let identifier = upper.contains("ATTACH") || upper.contains("COPY") || upper.contains("CREATE");
2238    let value = upper.contains("WHERE") || upper.contains("VALUES") || upper.contains(" SET ");
2239    identifier && !value
2240}
2241
2242/// `cursor.execute` / `con.execute`, not `tester.execute`. The receiver's last
2243/// segment is a database handle: `cursor`, `cur`, `connection`, `conn`, `con`,
2244/// `db`, `session`, or `engine`, or a name ending in those longer words.
2245fn db_handle_call(path: &str) -> bool {
2246    let Some((recv, _)) = path.rsplit_once('.') else {
2247        return false;
2248    };
2249    let name = recv.rsplit('.').next().unwrap_or(recv).to_ascii_lowercase();
2250    matches!(
2251        name.as_str(),
2252        "cursor" | "cur" | "connection" | "conn" | "con" | "db" | "session" | "engine"
2253    ) || name.ends_with("cursor")
2254        || name.ends_with("connection")
2255        || name.ends_with("_conn")
2256        || name.ends_with("_db")
2257        || name.ends_with("session")
2258        || name.ends_with("engine")
2259}
2260
2261fn is_dynamic_string(arg: &Expr) -> bool {
2262    match arg {
2263        Expr::FString(_) => true,
2264        Expr::BinOp(_) => true,
2265        Expr::Call(c) => expr_path(&c.func)
2266            .map(|p| p.ends_with(".format"))
2267            .unwrap_or(false),
2268        _ => false,
2269    }
2270}
2271
2272/// Does any argument reference `.MODE_ECB`?
2273fn args_reference_ecb(c: &ruff_python_ast::ExprCall) -> bool {
2274    let refs = |e: &Expr| {
2275        expr_path(e)
2276            .map(|p| p.contains("MODE_ECB"))
2277            .unwrap_or(false)
2278    };
2279    c.arguments.args.iter().any(refs) || c.arguments.keywords.iter().any(|k| refs(&k.value))
2280}
2281
2282fn security_call(
2283    c: &ruff_python_ast::ExprCall,
2284    f: &str,
2285    line: u32,
2286    m: &mut ParsedModule,
2287    sanitized_idents: &HashSet<String>,
2288) {
2289    let last = f.rsplit('.').next().unwrap_or(f);
2290    let mut hit = |rule: &'static str, detail: String| {
2291        m.security_hits.push(SecurityHit { rule, line, detail });
2292    };
2293
2294    // Only the *builtins* eval/exec/compile — bare names, or explicitly via
2295    // `builtins.`. Matching any trailing `.exec`/`.eval` segment falsely flagged
2296    // ORM/driver methods like SQLModel's `session.exec(select(...))` (CWE-95 FP).
2297    if matches!(
2298        f,
2299        "eval" | "exec" | "compile" | "builtins.eval" | "builtins.exec" | "builtins.compile"
2300    ) && !first_positional_is_string(c)
2301    {
2302        hit(
2303            "dangerous-eval",
2304            format!("`{f}` on a non-literal expression executes dynamic code"),
2305        );
2306    }
2307    if f == "yaml.load" && !has_kwarg(c, "Loader") {
2308        hit(
2309            "unsafe-yaml-load",
2310            "yaml.load without an explicit Loader= is unsafe; use yaml.safe_load".into(),
2311        );
2312    }
2313    if matches!(
2314        f,
2315        "pickle.load"
2316            | "pickle.loads"
2317            | "cPickle.load"
2318            | "cPickle.loads"
2319            | "marshal.load"
2320            | "marshal.loads"
2321            | "dill.load"
2322            | "dill.loads"
2323            | "shelve.open"
2324            | "jsonpickle.decode"
2325    ) {
2326        hit(
2327            "unsafe-deserialization",
2328            format!("`{f}` can execute arbitrary code on untrusted input"),
2329        );
2330    }
2331    if matches!(
2332        last,
2333        "call" | "run" | "Popen" | "check_output" | "check_call"
2334    ) && kwarg_bool(c, "shell", true)
2335    {
2336        hit(
2337            "subprocess-shell-true",
2338            "subprocess call with shell=True risks shell injection".into(),
2339        );
2340    }
2341    if matches!(f, "os.system" | "os.popen" | "os.popen2" | "os.popen3") {
2342        hit(
2343            "subprocess-shell-true",
2344            format!("`{f}` runs a command through the shell; prefer subprocess with an argv list"),
2345        );
2346    }
2347    if kwarg_bool(c, "verify", false) {
2348        hit(
2349            "tls-verify-disabled",
2350            "TLS certificate verification disabled (verify=False)".into(),
2351        );
2352    }
2353    if f == "ssl._create_unverified_context" {
2354        hit(
2355            "tls-verify-disabled",
2356            "ssl._create_unverified_context disables certificate validation".into(),
2357        );
2358    }
2359    if matches!(f, "hashlib.md5" | "hashlib.sha1" | "md5.new") {
2360        hit(
2361            "weak-hash",
2362            format!("`{f}` is a weak hash; use sha256+ (or pass usedforsecurity=False)"),
2363        );
2364    }
2365    if WEAK_CIPHERS.contains(&last) {
2366        hit(
2367            "weak-cipher",
2368            format!("`{f}` is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305"),
2369        );
2370    }
2371    if args_reference_ecb(c) {
2372        hit(
2373            "weak-cipher",
2374            "ECB mode leaks plaintext structure; use an authenticated mode (GCM)".into(),
2375        );
2376    }
2377    if matches!(
2378        f,
2379        "random.random"
2380            | "random.randint"
2381            | "random.randrange"
2382            | "random.choice"
2383            | "random.getrandbits"
2384    ) {
2385        hit(
2386            "insecure-random",
2387            format!("`{f}` is not cryptographically secure; use the `secrets` module for tokens"),
2388        );
2389    }
2390    let sql_method = matches!(last, "raw" | "extra")
2391        || (matches!(last, "execute" | "executemany" | "executescript") && db_handle_call(f));
2392    if sql_method {
2393        if let Some(arg) = c.arguments.args.first() {
2394            if is_dynamic_string(arg) && !is_sanitized_identifier_sql(arg, sanitized_idents) {
2395                hit(
2396                    "sql-injection",
2397                    format!(
2398                        "`{last}(...)` builds SQL from a dynamic string; use parameterized queries"
2399                    ),
2400                );
2401            }
2402        }
2403    }
2404    if matches!(
2405        f,
2406        "requests.get"
2407            | "requests.post"
2408            | "requests.put"
2409            | "requests.delete"
2410            | "requests.patch"
2411            | "requests.head"
2412            | "requests.request"
2413    ) && !has_kwarg(c, "timeout")
2414    {
2415        hit(
2416            "request-without-timeout",
2417            format!("`{f}` without a timeout= can block indefinitely"),
2418        );
2419    }
2420    // Flask/Bottle debug server (B201): `app.run(debug=True)` ships the
2421    // interactive debugger (RCE) in production.
2422    if last == "run" && kwarg_bool(c, "debug", true) {
2423        hit(
2424            "flask-debug-true",
2425            "running a web app with debug=True exposes the interactive debugger".into(),
2426        );
2427    }
2428    // Jinja2 without autoescaping (B701): `Environment(autoescape=False)` (or the
2429    // implicit default) risks XSS.
2430    if last == "Environment" && kwarg_bool(c, "autoescape", false) {
2431        hit(
2432            "jinja2-autoescape-false",
2433            "Jinja2 Environment with autoescape=False risks XSS; enable autoescaping".into(),
2434        );
2435    }
2436}
2437
2438fn security_imports(m: &mut ParsedModule) {
2439    let mut hits: Vec<SecurityHit> = Vec::new();
2440    for imp in m.imports.iter().chain(m.nested_imports.iter()) {
2441        let from_crypto = imp.module.contains("Crypto") || imp.module.contains("cryptography");
2442        if !from_crypto {
2443            continue;
2444        }
2445        for name in &imp.names {
2446            if WEAK_CIPHERS.contains(&name.as_str()) {
2447                hits.push(SecurityHit {
2448                    rule: "weak-cipher",
2449                    line: imp.line,
2450                    detail: format!(
2451                        "`{name}` (imported from `{}`) is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305",
2452                        imp.module
2453                    ),
2454                });
2455            }
2456        }
2457        if imp.names.is_empty() {
2458            if let Some(seg) = imp.module.rsplit('.').next() {
2459                if WEAK_CIPHERS.contains(&seg) {
2460                    hits.push(SecurityHit {
2461                        rule: "weak-cipher",
2462                        line: imp.line,
2463                        detail: format!(
2464                            "`{}` is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305",
2465                            imp.module
2466                        ),
2467                    });
2468                }
2469            }
2470        }
2471    }
2472    m.security_hits.extend(hits);
2473}
2474
2475/// Parse a `# mollify: ignore[rule1,rule2]` comment into suppressed rule ids.
2476/// Trailing text after the closing bracket (e.g. `-- reason`) is allowed.
2477/// Map a flake8 `# noqa` comment to the mollify rules it suppresses. These are
2478/// author-deliberate exceptions that predate mollify, so honoring them kills a
2479/// whole class of false positives (`from hello import app  # noqa: F401`).
2480/// Scope is intentionally narrow — only the unused-binding rules that flake8's
2481/// F401/F841 correspond to; a blanket `# noqa` suppresses both. Other codes are
2482/// other tools' semantics and are not interpreted.
2483fn parse_noqa_comment(text: &str) -> Option<Vec<String>> {
2484    let t = text.trim_start_matches('#').trim();
2485    if t.len() < 4 || !t.is_char_boundary(4) || !t[..4].eq_ignore_ascii_case("noqa") {
2486        return None;
2487    }
2488    let rest = t[4..].trim_start();
2489    if rest.is_empty() || rest.starts_with('#') {
2490        return Some(vec!["unused-import".into(), "unused-variable".into()]);
2491    }
2492    let codes = rest.strip_prefix(':')?;
2493    let mut rules = Vec::new();
2494    for code in codes.split([',', ' ', '#']).map(str::trim) {
2495        match code.to_ascii_uppercase().as_str() {
2496            "F401" => rules.push("unused-import".to_string()),
2497            "F841" => rules.push("unused-variable".to_string()),
2498            _ => {}
2499        }
2500    }
2501    if rules.is_empty() {
2502        None
2503    } else {
2504        Some(rules)
2505    }
2506}
2507
2508fn parse_ignore_comment(text: &str) -> Option<Vec<String>> {
2509    let t = text.trim_start_matches('#').trim();
2510    let rest = t.strip_prefix("mollify:")?.trim();
2511    let rest = rest.strip_prefix("ignore")?.trim();
2512    if let Some(inner) = rest
2513        .strip_prefix('[')
2514        .and_then(|r| r.find(']').map(|i| &r[..i]))
2515    {
2516        let rules: Vec<String> = inner
2517            .split(',')
2518            .map(|s| s.trim().to_string())
2519            .filter(|s| !s.is_empty())
2520            .collect();
2521        if rules.is_empty() {
2522            Some(vec!["*".into()])
2523        } else {
2524            Some(rules)
2525        }
2526    } else if rest.is_empty() {
2527        Some(vec!["*".into()])
2528    } else {
2529        None
2530    }
2531}
2532
2533#[cfg(test)]
2534mod tests {
2535    use super::*;
2536
2537    fn parse(src: &str) -> ParsedModule {
2538        let mut p = PyParser::new().unwrap();
2539        p.parse(Utf8Path::new("m.py"), src).unwrap()
2540    }
2541
2542    #[test]
2543    fn extracts_functions_and_classes() {
2544        let m = parse("def foo():\n    pass\n\nclass Bar:\n    pass\n");
2545        let names: Vec<_> = m.definitions.iter().map(|d| d.name.as_str()).collect();
2546        assert!(names.contains(&"foo"));
2547        assert!(names.contains(&"Bar"));
2548    }
2549
2550    #[test]
2551    fn private_convention_detected() {
2552        let m = parse("def _helper():\n    pass\n");
2553        assert!(m.definitions[0].private_by_convention);
2554    }
2555
2556    #[test]
2557    fn detects_expanded_security_rules() {
2558        let m = parse(
2559            "app.run(debug=True)\nenv = Environment(autoescape=False)\ntry:\n    risky()\nexcept Exception:\n    pass\n",
2560        );
2561        let rules: Vec<_> = m.security_hits.iter().map(|h| h.rule).collect();
2562        assert!(rules.contains(&"flask-debug-true"), "got {rules:?}");
2563        assert!(rules.contains(&"jinja2-autoescape-false"), "got {rules:?}");
2564        assert!(rules.contains(&"try-except-pass"), "got {rules:?}");
2565        // A narrow `except ValueError: pass` must NOT be flagged.
2566        let narrow = parse("try:\n    x()\nexcept ValueError:\n    pass\n");
2567        assert!(!narrow
2568            .security_hits
2569            .iter()
2570            .any(|h| h.rule == "try-except-pass"));
2571    }
2572
2573    #[test]
2574    fn extracts_imports() {
2575        let m = parse("import os\nfrom a.b import c, d\nfrom . import e\nfrom x import *\n");
2576        assert!(m.imports.iter().any(|i| i.module == "os"));
2577        let frm = m.imports.iter().find(|i| i.module == "a.b").unwrap();
2578        assert_eq!(frm.names, vec!["c", "d"]);
2579        assert!(m.imports.iter().any(|i| i.relative_dots == 1));
2580        assert!(m.imports.iter().any(|i| i.is_star));
2581    }
2582
2583    #[test]
2584    fn extracts_dunder_all() {
2585        let m = parse("__all__ = ['foo', 'bar']\n");
2586        assert_eq!(m.dunder_all, Some(vec!["foo".into(), "bar".into()]));
2587    }
2588
2589    #[test]
2590    fn detects_security_candidates() {
2591        let m = parse("import subprocess\npassword = \"hunter2xyz\"\nsubprocess.run(cmd, shell=True)\neval(user_input)\n");
2592        let rules: Vec<_> = m.security_hits.iter().map(|h| h.rule).collect();
2593        assert!(rules.contains(&"hardcoded-secret"), "got {rules:?}");
2594        assert!(rules.contains(&"subprocess-shell-true"), "got {rules:?}");
2595        assert!(rules.contains(&"dangerous-eval"), "got {rules:?}");
2596        let ok = parse("eval(\"1+1\")\n");
2597        assert!(!ok.security_hits.iter().any(|h| h.rule == "dangerous-eval"));
2598    }
2599
2600    #[test]
2601    fn dangerous_eval_only_matches_builtins_not_methods() {
2602        // Methods named exec/eval on ORMs/drivers (SQLModel session.exec, etc.)
2603        // must NOT be flagged — that was the v0.1.2 CWE-95 false positive.
2604        for src in [
2605            "session.exec(select(Item))\n",
2606            "conn.exec(query)\n",
2607            "obj.eval(expr)\n",
2608            "db.compile(stmt)\n",
2609        ] {
2610            let m = parse(src);
2611            assert!(
2612                !m.security_hits.iter().any(|h| h.rule == "dangerous-eval"),
2613                "method call wrongly flagged: {src}"
2614            );
2615        }
2616        // Bare builtins on a non-literal are still flagged.
2617        for src in [
2618            "exec(code)\n",
2619            "eval(user_input)\n",
2620            "compile(src, '<s>', 'exec')\n",
2621        ] {
2622            let m = parse(src);
2623            assert!(
2624                m.security_hits.iter().any(|h| h.rule == "dangerous-eval"),
2625                "builtin not flagged: {src}"
2626            );
2627        }
2628    }
2629
2630    #[test]
2631    fn detects_weak_cipher_imports() {
2632        let m = parse(
2633            "from Crypto.Cipher import DES as pycrypto_des\n\
2634             from Cryptodome.Cipher import ARC4 as ax\n\
2635             cipher = pycrypto_des.new(key, pycrypto_des.MODE_CTR)\n\
2636             c2 = ax.new(key)\n",
2637        );
2638        let cipher_hits: Vec<_> = m
2639            .security_hits
2640            .iter()
2641            .filter(|h| h.rule == "weak-cipher")
2642            .collect();
2643        assert_eq!(
2644            cipher_hits.len(),
2645            2,
2646            "expected DES + ARC4 imports flagged, got {:?}",
2647            m.security_hits
2648        );
2649        let lines: Vec<u32> = cipher_hits.iter().map(|h| h.line).collect();
2650        assert!(lines.contains(&1) && lines.contains(&2), "lines {lines:?}");
2651    }
2652
2653    #[test]
2654    fn detects_weak_cipher_direct_constructor_and_ecb() {
2655        let m = parse(
2656            "from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher\n\
2657             c = Cipher(algorithms.ARC4(key), mode=None)\n",
2658        );
2659        assert!(
2660            m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2661            "expected ARC4 constructor flagged, got {:?}",
2662            m.security_hits
2663        );
2664        let ecb = parse("from Crypto.Cipher import AES\nc = AES.new(key, AES.MODE_ECB)\n");
2665        assert!(
2666            ecb.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2667            "expected ECB mode flagged, got {:?}",
2668            ecb.security_hits
2669        );
2670    }
2671
2672    #[test]
2673    fn strong_cipher_and_modes_not_flagged() {
2674        let m = parse(
2675            "from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher\n\
2676             c = Cipher(algorithms.AES(key), modes.GCM(iv))\n",
2677        );
2678        assert!(
2679            !m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2680            "AES-GCM should not be flagged, got {:?}",
2681            m.security_hits
2682        );
2683        let unrelated = parse("from myapp.utils import DES\nDES.do_thing()\n");
2684        assert!(
2685            !unrelated
2686                .security_hits
2687                .iter()
2688                .any(|h| h.rule == "weak-cipher"),
2689            "non-crypto `DES` import should not be flagged, got {:?}",
2690            unrelated.security_hits
2691        );
2692    }
2693
2694    #[test]
2695    fn counts_type_annotations() {
2696        let m = parse("def f(a: int, b) -> int:\n    return a\n\nclass C:\n    def m(self, x: int):\n        return x\n");
2697        let f = m.functions.iter().find(|f| f.name == "f").unwrap();
2698        assert_eq!(f.params_total, 2);
2699        assert_eq!(f.params_annotated, 1);
2700        assert!(f.return_annotated);
2701        let mm = m.functions.iter().find(|f| f.name == "m").unwrap();
2702        assert_eq!(mm.params_total, 1, "self should be excluded");
2703        assert_eq!(mm.params_annotated, 1);
2704        assert!(!mm.return_annotated);
2705    }
2706
2707    #[test]
2708    fn computes_complexity() {
2709        let m = parse("def f(x):\n    if x:\n        for i in range(x):\n            if i and x:\n                return i\n    return 0\n");
2710        let f = m.functions.iter().find(|f| f.name == "f").unwrap();
2711        assert!(f.cyclomatic >= 4, "cyclo {:?}", f.cyclomatic);
2712        assert!(f.cognitive >= 3, "cog {:?}", f.cognitive);
2713    }
2714
2715    #[test]
2716    fn captures_decorators() {
2717        let m = parse("import app\n@app.route('/x')\ndef view():\n    return 1\n");
2718        let d = m.definitions.iter().find(|d| d.name == "view").unwrap();
2719        assert!(
2720            d.decorators.iter().any(|x| x == "app.route"),
2721            "got {:?}",
2722            d.decorators
2723        );
2724    }
2725
2726    #[test]
2727    fn detects_dynamic_sink() {
2728        let m = parse("x = getattr(obj, 'attr')\n");
2729        assert!(m.has_dynamic_sink);
2730        let m2 = parse("y = 1 + 2\n");
2731        assert!(!m2.has_dynamic_sink);
2732    }
2733
2734    #[test]
2735    fn conditional_import_seen() {
2736        let m = parse("try:\n    import fast\nexcept ImportError:\n    import slow as fast\n");
2737        assert!(m.imports.iter().any(|i| i.module == "fast"));
2738    }
2739
2740    #[test]
2741    fn scope_resolution_excludes_shadows_and_attributes() {
2742        // `helper` is defined at module scope but never *loaded* there: the only
2743        // references are a function-local binding (a shadow) and an attribute
2744        // access (`obj.helper`). Token counting would call it "used"; scope
2745        // resolution correctly does not.
2746        let m = parse(
2747            "def helper():\n    pass\n\ndef f():\n    helper = 1\n    return helper\n\nobj.helper()\n",
2748        );
2749        assert!(
2750            !m.module_used.iter().any(|s| s == "helper"),
2751            "module_used should exclude shadowed/attribute `helper`: {:?}",
2752            m.module_used
2753        );
2754        // A genuine free load that resolves to module scope IS captured.
2755        let m2 = parse("def g():\n    pass\n\ng()\n");
2756        assert!(
2757            m2.module_used.iter().any(|s| s == "g"),
2758            "{:?}",
2759            m2.module_used
2760        );
2761        // `global` forces module resolution: the RHS load of `counter` binds to
2762        // the module-level name even though it is assigned inside the function.
2763        let m3 =
2764            parse("counter = 0\n\ndef bump():\n    global counter\n    counter = counter + 1\n");
2765        assert!(
2766            m3.module_used.iter().any(|s| s == "counter"),
2767            "{:?}",
2768            m3.module_used
2769        );
2770        // Without `global`, the same assignment makes `counter` a local shadow.
2771        let m4 = parse("counter = 0\n\ndef bump():\n    counter = counter + 1\n");
2772        assert!(
2773            !m4.module_used.iter().any(|s| s == "counter"),
2774            "{:?}",
2775            m4.module_used
2776        );
2777    }
2778
2779    #[test]
2780    fn scope_resolution_sees_defaults_and_annotations() {
2781        // Defaults, parameter annotations, and return annotations evaluate in
2782        // the enclosing (module) scope — they are genuine uses.
2783        let m = parse("DEFAULT = 5\nMyType = int\ndef f(x=DEFAULT) -> MyType: ...\n");
2784        assert!(
2785            m.module_used.iter().any(|s| s == "DEFAULT"),
2786            "{:?}",
2787            m.module_used
2788        );
2789        assert!(
2790            m.module_used.iter().any(|s| s == "MyType"),
2791            "{:?}",
2792            m.module_used
2793        );
2794        let m2 = parse("MyType = int\ndef g(x: MyType): ...\n");
2795        assert!(
2796            m2.module_used.iter().any(|s| s == "MyType"),
2797            "{:?}",
2798            m2.module_used
2799        );
2800        // Lambda parameter defaults too.
2801        let m3 = parse("DEFAULT = 5\ng = lambda x=DEFAULT: x\n");
2802        assert!(
2803            m3.module_used.iter().any(|s| s == "DEFAULT"),
2804            "{:?}",
2805            m3.module_used
2806        );
2807    }
2808
2809    #[test]
2810    fn imports_inside_module_level_suites_seen() {
2811        let m = parse(
2812            "from contextlib import suppress\n\
2813             with suppress(ImportError):\n    import ujson\n\
2814             for _i in range(1):\n    import for_mod\n\
2815             while cond():\n    import while_mod\n\
2816             match val:\n    case 1:\n        import match_mod\n",
2817        );
2818        for want in ["ujson", "for_mod", "while_mod", "match_mod"] {
2819            assert!(
2820                m.imports.iter().any(|i| i.module == want),
2821                "missing {want}: {:?}",
2822                m.imports
2823            );
2824        }
2825    }
2826
2827    #[test]
2828    fn type_checking_marks_body_not_else() {
2829        let m = parse(
2830            "from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n    import a\nelse:\n    import b\n",
2831        );
2832        let a = m.imports.iter().find(|i| i.module == "a").unwrap();
2833        let b = m.imports.iter().find(|i| i.module == "b").unwrap();
2834        assert!(a.type_checking_only);
2835        assert!(!b.type_checking_only, "else branch is the runtime branch");
2836        // `if not TYPE_CHECKING:` inverts the branches.
2837        let m2 = parse(
2838            "from typing import TYPE_CHECKING\nif not TYPE_CHECKING:\n    import rt\nelse:\n    import tc\n",
2839        );
2840        let rt = m2.imports.iter().find(|i| i.module == "rt").unwrap();
2841        let tc = m2.imports.iter().find(|i| i.module == "tc").unwrap();
2842        assert!(!rt.type_checking_only);
2843        assert!(tc.type_checking_only);
2844    }
2845
2846    #[test]
2847    fn type_checking_guard_is_exact() {
2848        let fp = parse("if MY_TYPE_CHECKING_OVERRIDE:\n    from x import y\n");
2849        assert!(
2850            !fp.imports
2851                .iter()
2852                .find(|i| i.module == "x")
2853                .unwrap()
2854                .type_checking_only,
2855            "substring match must not treat this as a guard"
2856        );
2857        let ok = parse("import typing\nif typing.TYPE_CHECKING:\n    from x import y\n");
2858        assert!(
2859            ok.imports
2860                .iter()
2861                .find(|i| i.module == "x")
2862                .unwrap()
2863                .type_checking_only
2864        );
2865    }
2866
2867    #[test]
2868    fn comprehension_targets_are_not_function_locals() {
2869        // Python 3 comprehensions have their own scope: `item` here does not
2870        // shadow the module-level binding for the trailing `return item`.
2871        let m =
2872            parse("item = 1\ndef f(items):\n    xs = [item for item in items]\n    return item\n");
2873        assert!(
2874            m.module_used.iter().any(|s| s == "item"),
2875            "{:?}",
2876            m.module_used
2877        );
2878    }
2879
2880    #[test]
2881    fn dunder_all_mutations() {
2882        let m = parse("__all__ = ['a']\n__all__ += ['b']\n");
2883        assert_eq!(m.dunder_all, Some(vec!["a".into(), "b".into()]));
2884        let m2 = parse("__all__ = ['a']\n__all__.extend(['b', 'c'])\n__all__.append('d')\n");
2885        assert_eq!(
2886            m2.dunder_all,
2887            Some(vec!["a".into(), "b".into(), "c".into(), "d".into()])
2888        );
2889        // Non-literal mutations make the list unknowable — None, not a wrong
2890        // partial list.
2891        let m3 = parse("__all__ = ['a']\n__all__ += make()\n");
2892        assert_eq!(m3.dunder_all, None);
2893        let m4 = parse("__all__ = ['a']\n__all__.extend(names)\n");
2894        assert_eq!(m4.dunder_all, None);
2895        let m5 = parse("__all__ = ['a']\n__all__.append(name)\n");
2896        assert_eq!(m5.dunder_all, None);
2897    }
2898
2899    #[test]
2900    fn decorated_def_line_points_at_def() {
2901        let m = parse("import app\n\n@app.route('/x')\ndef view() -> _Priv:\n    return 1\n");
2902        let d = m.definitions.iter().find(|d| d.name == "view").unwrap();
2903        assert_eq!(d.line, 4, "decorator on line 3, def on line 4");
2904        assert_eq!(d.end_line, 5, "end_line keeps the full range");
2905        let f = m.functions.iter().find(|f| f.name == "view").unwrap();
2906        assert_eq!(f.line, 4);
2907        let leak = m
2908            .type_leaks
2909            .iter()
2910            .find(|l| l.type_name == "_Priv")
2911            .unwrap();
2912        assert_eq!(leak.line, 4);
2913        let m2 = parse("@decorate\nclass C:\n    @property\n    def p(self):\n        return 1\n");
2914        let c = m2.classes.iter().find(|c| c.name == "C").unwrap();
2915        assert_eq!(c.line, 2);
2916        let p = c.members.iter().find(|mb| mb.name == "p").unwrap();
2917        assert_eq!(p.line, 4);
2918        let cd = m2.definitions.iter().find(|d| d.name == "C").unwrap();
2919        assert_eq!(cd.line, 2);
2920    }
2921
2922    #[test]
2923    fn typevar_under_guard_not_a_leak() {
2924        let m = parse(
2925            "from typing import TYPE_CHECKING, TypeVar\nif TYPE_CHECKING:\n    _T = TypeVar('_T')\ndef f(x: _T) -> _T: ...\n",
2926        );
2927        assert!(m.type_leaks.is_empty(), "{:?}", m.type_leaks);
2928        let m2 = parse(
2929            "try:\n    _P = ParamSpec('_P')\nexcept ImportError:\n    pass\ndef g(x: _P): ...\n",
2930        );
2931        assert!(m2.type_leaks.is_empty(), "{:?}", m2.type_leaks);
2932    }
2933
2934    #[test]
2935    fn comment_parsers_fuzz_no_panic() {
2936        // Deterministic xorshift64 fuzz over the hand-written comment parsers
2937        // (multi-byte chars land at arbitrary offsets — slicing must never
2938        // panic on a char boundary).
2939        let mut state = 0x0123_4567_89AB_CDEFu64;
2940        let mut next = move || {
2941            state ^= state << 13;
2942            state ^= state >> 7;
2943            state ^= state << 17;
2944            state
2945        };
2946        let alphabet: &[&str] = &[
2947            "#", "n", "o", "q", "a", "N", "Q", "A", ":", ",", " ", "F", "4", "0", "1", "8",
2948            "mollify", "ignore", "[", "]", "ß", "é", "—", "\t",
2949        ];
2950        for _ in 0..4000u32 {
2951            let len = (next() % 40) as usize;
2952            let mut s = String::from("#");
2953            for _ in 0..len {
2954                s.push_str(alphabet[(next() as usize) % alphabet.len()]);
2955            }
2956            let _ = parse_noqa_comment(&s);
2957            let _ = parse_ignore_comment(&s);
2958        }
2959    }
2960
2961    #[test]
2962    fn noqa_comments_map_to_unused_binding_rules() {
2963        // Blanket noqa silences both unused-binding rules on that line.
2964        assert_eq!(
2965            parse_noqa_comment("# noqa"),
2966            Some(vec!["unused-import".into(), "unused-variable".into()])
2967        );
2968        assert_eq!(
2969            parse_noqa_comment("#NOQA"),
2970            Some(vec!["unused-import".into(), "unused-variable".into()])
2971        );
2972        // Coded noqa maps only the codes that correspond to our rules.
2973        assert_eq!(
2974            parse_noqa_comment("# noqa: F401"),
2975            Some(vec!["unused-import".into()])
2976        );
2977        assert_eq!(
2978            parse_noqa_comment("# noqa: E501, F841"),
2979            Some(vec!["unused-variable".into()])
2980        );
2981        // Foreign codes and noqa-ish prose are not ours to interpret.
2982        assert_eq!(parse_noqa_comment("# noqa: E501"), None);
2983        assert_eq!(parse_noqa_comment("# noqable"), None);
2984        assert_eq!(parse_noqa_comment("# see noqa docs"), None);
2985        // End-to-end: the wsgi entry-point idiom lands in `ignores`.
2986        let m = parse("from hello import app  # noqa: F401\n");
2987        assert!(
2988            m.ignores.contains(&(1, "unused-import".into())),
2989            "{:?}",
2990            m.ignores
2991        );
2992    }
2993
2994    #[test]
2995    fn redundant_alias_and_try_body_imports_are_marked() {
2996        let m = parse(
2997            "from sansio import State as State\nfrom sansio import Blueprint as Sansio\ntry:\n    import fast_json\nexcept ImportError:\n    import json as fast_json\nimport os\n",
2998        );
2999        let state = m.imports.iter().find(|i| i.bindings == ["State"]).unwrap();
3000        assert_eq!(state.redundant, vec![true]);
3001        let aliased = m.imports.iter().find(|i| i.bindings == ["Sansio"]).unwrap();
3002        assert_eq!(aliased.redundant, vec![false]);
3003        let probe = m.imports.iter().find(|i| i.module == "fast_json").unwrap();
3004        assert!(probe.in_try, "try-body import not marked: {probe:?}");
3005        let fallback = m.imports.iter().find(|i| i.module == "json").unwrap();
3006        assert!(fallback.in_try, "except-handler import not marked");
3007        let plain = m.imports.iter().find(|i| i.module == "os").unwrap();
3008        assert!(!plain.in_try);
3009        std::assert!(!plain.redundant.iter().any(|r| *r));
3010    }
3011
3012    #[test]
3013    fn ignore_comment_allows_trailing_text() {
3014        assert_eq!(
3015            parse_ignore_comment("# mollify: ignore[dead-code]  -- migrating soon"),
3016            Some(vec!["dead-code".into()])
3017        );
3018        assert_eq!(
3019            parse_ignore_comment("# mollify: ignore[a, b] reason"),
3020            Some(vec!["a".into(), "b".into()])
3021        );
3022        let m = parse("x = 1  # mollify: ignore[dead-code] -- reason\n");
3023        assert!(
3024            m.ignores.contains(&(1, "dead-code".into())),
3025            "{:?}",
3026            m.ignores
3027        );
3028    }
3029
3030    #[test]
3031    fn nested_weak_cipher_import_flagged() {
3032        let m = parse("def f():\n    from Crypto.Cipher import DES\n    return DES\n");
3033        assert!(
3034            m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
3035            "nested import must be scanned: {:?}",
3036            m.security_hits
3037        );
3038    }
3039}