From 73ed2860a7d9042286e4a5a42ff0b33721fa8470 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 18:23:40 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D0=BE=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B8=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20=D0=BD=D0=B0=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=20=D0=BF=D0=BE=20=D0=B2=D0=B8=D0=B4=D1=83=20?= =?UTF-8?q?=D1=81=D1=83=D1=89=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Методы под @property, @cached_property и аксессорами setter/getter/ deleter читаются как атрибуты, в том числе из шаблонов Django, и больше не помечаются мертвым кодом. Новая опция -k/--kind фильтрует отчет по видам сущностей (function, class, method, variable) с повторением или перечислением через запятую; код завершения учитывает отфильтрованные находки. https://claude.ai/code/session_01Rq2uroXF5kGeRgWa9e3afM --- README.md | 5 ++ src/heuristics.rs | 30 +++++++++++ src/main.rs | 63 ++++++++++++++++++++-- src/pipeline/extract.rs | 6 ++- tests/command_line_interface.rs | 49 +++++++++++++++++ tests/dead_code_detection.rs | 6 +++ tests/fixtures/demo_project/shop/models.py | 12 +++++ 7 files changed, 167 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 165ad5d..735e5e9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ cargo build --release ./target/release/dc # запуск в корне анализируемого проекта ./target/release/dc --target-path /path/to/project --verbose ./target/release/dc --format json > report.json +./target/release/dc --kind function,method # только функции и методы ``` Параметры командной строки: @@ -23,6 +24,7 @@ cargo build --release | `-t, --target-path` | `.` | Корневая директория проекта | | `-c, --config-path` | автопоиск | Путь к файлу конфигурации | | `-f, --format` | `text` | Формат отчета: `text` или `json` | +| `-k, --kind` | все виды | Фильтр находок: `function`, `class`, `method`, `variable`; повторение или перечисление через запятую | | `-v, --verbose` | выкл. | Вывод статистики анализа | Коды завершения (для интеграции с CI): @@ -92,6 +94,9 @@ extra_dynamic_names = ["called_from_template"] - **Хуки фреймворков** — методы с префиксами `validate_`, `clean_`, `get_`, `perform_`, `has_`, `test_` вызываются Django, DRF и Pytest по соглашению и не считаются мертвым кодом. +- **Свойства** — методы под `@property`, `@cached_property` и аксессорами + `@имя.setter` / `@имя.getter` / `@имя.deleter` читаются как атрибуты + (в том числе из шаблонов Django) и не считаются мертвым кодом. - **Классы под управлением фреймворка** — методы классов, унаследованных от баз `Serializer`, `ViewSet`, `View`, `Permission`, `Form`, `Admin`, `Middleware`, `TestCase` и подобных, вызываются фреймворком по контракту; diff --git a/src/heuristics.rs b/src/heuristics.rs index 15ef477..c012cba 100644 --- a/src/heuristics.rs +++ b/src/heuristics.rs @@ -82,6 +82,14 @@ const FRAMEWORK_DRIVEN_BASE_MARKERS: &[&str] = &[ "Command", ]; +/// Последние сегменты декораторов, превращающих метод в свойство. +/// +/// Свойства читаются как атрибуты: из шаблонов Django, админки +/// и сериализаторов. Такие обращения не видны статическому анализу, +/// поэтому свойства не считаются мертвым кодом. +const PROPERTY_DECORATOR_SEGMENTS: &[&str] = + &["property", "cached_property", "setter", "getter", "deleter"]; + /// Имена классов, обнаруживаемых Django по соглашению. const IMPLICIT_CLASS_NAMES: &[&str] = &["Meta", "Media", "DoesNotExist", "MultipleObjectsReturned"]; @@ -190,6 +198,17 @@ pub fn is_framework_driven_base(superclasses_text: &str) -> bool { .any(|marker| superclasses_text.contains(marker)) } +/// Проверяет, превращает ли декоратор метод в свойство. +/// +/// Учитываются `property`, `functools.cached_property` и аксессоры +/// `@имя.setter`, `@имя.getter`, `@имя.deleter`. +/// +/// :param normalized_decorator: Нормализованное точечное имя декоратора. +/// :return: Признак декоратора свойства. +pub fn is_property_decorator(normalized_decorator: &str) -> bool { + PROPERTY_DECORATOR_SEGMENTS.contains(&last_dotted_segment(normalized_decorator)) +} + /// Проверяет обнаружение класса фреймворком Django по соглашению. /// /// :param class_name: Простое имя класса. @@ -290,6 +309,17 @@ mod tests { assert!(!is_implicit_method_name("unused_helper")); } + #[test] + fn property_decorators_are_recognized() { + assert!(is_property_decorator("property")); + assert!(is_property_decorator("functools.cached_property")); + assert!(is_property_decorator("cached_property")); + assert!(is_property_decorator("price.setter")); + assert!(is_property_decorator("price.deleter")); + assert!(!is_property_decorator("staticmethod")); + assert!(!is_property_decorator("classmethod")); + } + #[test] fn framework_driven_bases_are_recognized() { assert!(is_framework_driven_base("(serializers.ModelSerializer)")); diff --git a/src/main.rs b/src/main.rs index eacd234..0fb757d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,10 +3,13 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; -use clap::Parser; +use clap::{Parser, ValueEnum}; use mimalloc::MiMalloc; -use dc::{load_configuration, render_report, run_analysis, AnalysisReport, DcError, ReportFormat}; +use dc::{ + load_configuration, render_report, run_analysis, AnalysisReport, DcError, EntityKind, + ReportFormat, +}; #[global_allocator] static GLOBAL: MiMalloc = MiMalloc; @@ -38,11 +41,40 @@ struct CommandLineArguments { #[arg(short, long, value_enum, default_value_t = ReportFormat::Text)] format: ReportFormat, + /// Виды сущностей в отчете. По умолчанию выводятся все виды. + /// Допускает повторение и перечисление через запятую. + #[arg(short, long = "kind", value_enum, value_delimiter = ',')] + kinds: Vec, + /// Вывод дополнительной статистики анализа. #[arg(short, long, default_value_t = false)] verbose: bool, } +/// Вид сущности для фильтрации находок из командной строки. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum FindingKindArgument { + /// Функции уровня модуля. + Function, + /// Классы. + Class, + /// Методы классов. + Method, + /// Переменные уровня модуля. + Variable, +} + +impl From for EntityKind { + fn from(kind_argument: FindingKindArgument) -> Self { + match kind_argument { + FindingKindArgument::Function => EntityKind::Function, + FindingKindArgument::Class => EntityKind::Class, + FindingKindArgument::Method => EntityKind::Method, + FindingKindArgument::Variable => EntityKind::Variable, + } + } +} + /// Инициализирует процесс анализа и выводит результаты. /// /// :return: Статус завершения программы. @@ -85,7 +117,32 @@ fn execute(command_line_arguments: &CommandLineArguments) -> Result = requested_kinds + .iter() + .map(|kind_argument| EntityKind::from(*kind_argument)) + .collect(); + analysis_report + .findings + .retain(|finding| allowed_kinds.contains(&finding.entity_kind)); } /// Выводит предупреждения о пропущенных файлах в поток ошибок. diff --git a/src/pipeline/extract.rs b/src/pipeline/extract.rs index fbd0049..1940867 100644 --- a/src/pipeline/extract.rs +++ b/src/pipeline/extract.rs @@ -379,8 +379,12 @@ impl<'source> EntityExtractor<'source> { if heuristics::is_test_function_name(simple_name) { return true; } + let is_property = decorator_names + .iter() + .any(|decorator| heuristics::is_property_decorator(decorator)); entity_kind == EntityKind::Method - && (heuristics::is_implicit_method_name(simple_name) + && (is_property + || heuristics::is_implicit_method_name(simple_name) || self.is_inside_framework_driven_class()) } EntityKind::Variable => false, diff --git a/tests/command_line_interface.rs b/tests/command_line_interface.rs index 5adc0e5..30d9f18 100644 --- a/tests/command_line_interface.rs +++ b/tests/command_line_interface.rs @@ -43,6 +43,55 @@ fn json_format_produces_parseable_output() { assert!(parsed["findings"].is_array()); } +#[test] +fn kind_filter_limits_report_to_requested_kinds() { + let fixture = fixture_project_path(); + let output = run_dc(&[ + "--target-path", + fixture.to_str().unwrap(), + "--kind", + "variable", + ]); + + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("LEGACY_FLAG"), "{stdout}"); + assert!(!stdout.contains("abandoned_view"), "{stdout}"); + assert!(!stdout.contains("unused_helper"), "{stdout}"); +} + +#[test] +fn kind_filter_accepts_comma_separated_values() { + let fixture = fixture_project_path(); + let output = run_dc(&[ + "--target-path", + fixture.to_str().unwrap(), + "--kind", + "function,method", + ]); + + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("abandoned_view"), "{stdout}"); + assert!(stdout.contains("unused_helper"), "{stdout}"); + assert!(!stdout.contains("LEGACY_FLAG"), "{stdout}"); +} + +#[test] +fn kind_filter_without_matches_produces_exit_code_zero() { + let fixture = fixture_project_path(); + let output = run_dc(&[ + "--target-path", + fixture.to_str().unwrap(), + "--kind", + "class", + ]); + + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Мертвый код не найден"), "{stdout}"); +} + #[test] fn missing_target_directory_produces_exit_code_two() { let output = run_dc(&["--target-path", "/nonexistent/project"]); diff --git a/tests/dead_code_detection.rs b/tests/dead_code_detection.rs index e5f4268..7c4e4be 100644 --- a/tests/dead_code_detection.rs +++ b/tests/dead_code_detection.rs @@ -40,6 +40,12 @@ fn detects_dead_code_and_respects_django_heuristics() { !contains("shop.models.Product.display_price"), "{dead_names:?}" ); + // Свойства модели читаются из шаблонов и не считаются мертвыми. + assert!( + !contains("shop.models.Product.availability_label"), + "{dead_names:?}" + ); + assert!(!contains("shop.models.Product.slug"), "{dead_names:?}"); // Задача Celery и ее вспомогательная функция живые. assert!(!contains("shop.tasks.refresh_catalog"), "{dead_names:?}"); assert!( diff --git a/tests/fixtures/demo_project/shop/models.py b/tests/fixtures/demo_project/shop/models.py index 8f43a65..972d2ac 100644 --- a/tests/fixtures/demo_project/shop/models.py +++ b/tests/fixtures/demo_project/shop/models.py @@ -1,3 +1,5 @@ +from functools import cached_property + from django.db import models @@ -5,6 +7,16 @@ class Product(models.Model): title = models.CharField(max_length=255) price = models.DecimalField(max_digits=10, decimal_places=2) + @property + def availability_label(self) -> str: + """Используется только в шаблоне product_detail.html.""" + return "В наличии" + + @cached_property + def slug(self) -> str: + """Используется только в шаблоне product_list.html.""" + return self.title.lower().replace(" ", "-") + def display_price(self) -> str: """ Возвращает цену товара для отображения в админке.