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