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