omnicoach 0.3.0

Lightweight, high-signal static analysis and command linter for Python and Rust
//! Bans dynamic runtime attribute reflection (`getattr`, `hasattr`, `setattr`, `delattr`).

use crate::code_lint::ast::ParsedFile;
use crate::code_lint::contract::{CodeRule, RuleTarget};
use crate::diagnostic::{Diagnostic, Language, RuleName, ViolationTemplate, violation_template};
use crate::rule_declaration::{
    Classification, Consensus, Declaration, Example, FilterListDefaults, ImpactedQuality, ListKind,
    ListOption, Precision, Reference, RuleDoc, RuleOptions, Topic,
};
use std::collections::HashSet;
use std::path::Path;

const BANNED: ListOption = ListOption {
    kind: ListKind::Deny,
    doc: "Reflection functions flagged when called.",
    default: FilterListDefaults {
        base: &[
            "getattr",
            "hasattr",
            "setattr",
            "delattr",
            "vars",
            "builtins.getattr",
            "builtins.hasattr",
            "builtins.setattr",
            "builtins.delattr",
            "builtins.vars",
            "object.__setattr__",
            "object.__getattribute__",
            "object.__delattr__",
            "type.__setattr__",
        ],
        extend: &[],
        remove: &[],
    },
};

const TEMPLATE: ViolationTemplate = violation_template! {
    summary: "An attribute is accessed dynamically with `{callee}()`.",
    rationale: "Runtime reflection erases the attribute's static type to `Any`, hides the reference from refactoring tools, and `hasattr` can swallow exceptions raised by properties.",
    suggestion: "Access the attribute directly on a typed object, use a `Mapping` lookup (`dict.get`), or define a structural `Protocol`.",
};

/// The rule's declaration.
pub const RULE: CodeRule<ListOption> = CodeRule {
    declaration: Declaration {
        name: RuleName("dynamic-attribute-access"),
        template: &TEMPLATE,
        languages: &[Language::Python],
        options: RuleOptions::code_rule(BANNED),
        classification: Classification {
            topics: &[Topic::TYPE_CHECKER_BYPASS],
            precision: Precision::Exact,
            consensus: Consensus::Opinionated,
            impacted_quality: ImpactedQuality::Reliability,
        },
        doc: RuleDoc {
            summary: "Flags `getattr`, `hasattr`, `setattr` and `delattr` calls in Python.",
            what_it_does: indoc::indoc! {r#"
                Flags calls to the built-in reflection functions and dunder equivalents, such as
                `getattr(service, "timeout")`, `hasattr(record, field_name)`, `vars(service)`, or
                `object.__setattr__(self, "replies", [])`."#},
            why_is_this_bad: indoc::indoc! {r#"
                These functions take the attribute name as a runtime string. The type checker cannot
                verify that the attribute exists and usually types the result as `Any`; renaming
                tools and "find references" miss the access; a typo fails only at runtime. `hasattr`
                also returns `False` when a property raises `AttributeError` internally, which hides
                the real bug.

                Access attributes directly on a typed object. For data keyed by runtime strings, use
                a `dict` or `Mapping`; to accept several types that share attributes, declare a
                `Protocol`."#},
            known_problems: None,
            references: &[
                Reference {
                    title: "Python docs: built-in getattr",
                    url: "https://docs.python.org/3/library/functions.html#getattr",
                },
                Reference::NAME_RESOLUTION,
            ],
            examples: &[Example {
                language: Language::Python,
                flagged: indoc::indoc! {r"
                    def is_feature_enabled(flags: FeatureFlags, feature: str) -> bool:
                        return getattr(flags, feature, False)
                "},
                flagged_span: "getattr(flags, feature, False)",
                fixed: indoc::indoc! {r"
                    def is_feature_enabled(flags: Mapping[str, bool], feature: str) -> bool:
                        return flags.get(feature, False)
                "},
            }],
        },
    },
    target: RuleTarget::All,
    check: check_file,
};

fn check_file(
    rule: &CodeRule<ListOption>,
    path: &Path,
    file: &ParsedFile,
    banned_functions: &HashSet<String>,
) -> Vec<Diagnostic> {
    rule.check_banned_calls(path, file, banned_functions)
}

#[cfg(test)]
crate::test_utils::rule_test!(
    RULE,
    {
        Python => {
            pass: [
                direct_attribute_access => r#"
                    value = service.timeout
                "#,
                dict_lookup => r#"
                    email = data.get("email")
                "#,
                custom_receiver_getattr => r#"
                    registry.getattr("key")
                "#,
                custom_receiver_setattr => r#"
                    registry.setattr("key", 1)
                "#,
                custom_receiver_hasattr => r#"
                    registry.hasattr("key")
                "#,
                custom_receiver_delattr => r#"
                    registry.delattr("key")
                "#,
            ],
            fail: [
                unqualified_getattr => r#"
                    val = getattr(service, "timeout", 10)
                "# => r#"getattr(service, "timeout", 10)"#,
                unqualified_hasattr => r#"
                    if hasattr(record, field_name):
                        pass
                "# => r#"hasattr(record, field_name)"#,
                unqualified_setattr => r#"
                    setattr(record, field_name, 42)
                "# => r#"setattr(record, field_name, 42)"#,
                unqualified_delattr => r#"
                    delattr(record, "deprecated_key")
                "# => r#"delattr(record, "deprecated_key")"#,
                builtins_qualified_getattr => r#"
                    builtins.getattr(service, "name")
                "# => r#"builtins.getattr(service, "name")"#,
                builtins_qualified_hasattr => r#"
                    builtins.hasattr(service, "name")
                "# => r#"builtins.hasattr(service, "name")"#,
                builtins_qualified_setattr => r#"
                    builtins.setattr(service, "name", "new_name")
                "# => r#"builtins.setattr(service, "name", "new_name")"#,
                builtins_qualified_delattr => r#"
                    builtins.delattr(service, "name")
                "# => r#"builtins.delattr(service, "name")"#,
                object_dunder_setattr => r#"
                    object.__setattr__(self, "replies", [])
                "# => r#"object.__setattr__(self, "replies", [])"#,
                vars_call => r#"
                    fields = vars(service)
                "# => r#"vars(service)"#,
            ],
        },
    }
);