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