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