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