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