Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # только функции и методы
```

Параметры командной строки:
Expand All @@ -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):
Expand Down Expand Up @@ -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` и подобных, вызываются фреймворком по контракту;
Expand Down
30 changes: 30 additions & 0 deletions src/heuristics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down Expand Up @@ -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: Простое имя класса.
Expand Down Expand Up @@ -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)"));
Expand Down
63 changes: 60 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<FindingKindArgument>,

/// Вывод дополнительной статистики анализа.
#[arg(short, long, default_value_t = false)]
verbose: bool,
}

/// Вид сущности для фильтрации находок из командной строки.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum FindingKindArgument {
/// Функции уровня модуля.
Function,
/// Классы.
Class,
/// Методы классов.
Method,
/// Переменные уровня модуля.
Variable,
}

impl From<FindingKindArgument> 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: Статус завершения программы.
Expand Down Expand Up @@ -85,7 +117,32 @@ fn execute(command_line_arguments: &CommandLineArguments) -> Result<AnalysisRepo
}
let analyzer_configuration =
load_configuration(command_line_arguments.config_path.as_deref(), target_path)?;
Ok(run_analysis(target_path, &analyzer_configuration))
let mut analysis_report = run_analysis(target_path, &analyzer_configuration);
filter_findings_by_kind(&mut analysis_report, &command_line_arguments.kinds);
Ok(analysis_report)
}

/// Оставляет в отчете только находки запрошенных видов.
///
/// Пустой список видов означает отсутствие фильтрации. Код завершения
/// программы определяется уже отфильтрованными находками.
///
/// :param analysis_report: Итоговый отчет анализа.
/// :param requested_kinds: Виды сущностей из аргументов командной строки.
fn filter_findings_by_kind(
analysis_report: &mut AnalysisReport,
requested_kinds: &[FindingKindArgument],
) {
if requested_kinds.is_empty() {
return;
}
let allowed_kinds: Vec<EntityKind> = requested_kinds
.iter()
.map(|kind_argument| EntityKind::from(*kind_argument))
.collect();
analysis_report
.findings
.retain(|finding| allowed_kinds.contains(&finding.entity_kind));
}

/// Выводит предупреждения о пропущенных файлах в поток ошибок.
Expand Down
6 changes: 5 additions & 1 deletion src/pipeline/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions tests/command_line_interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
6 changes: 6 additions & 0 deletions tests/dead_code_detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
12 changes: 12 additions & 0 deletions tests/fixtures/demo_project/shop/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
from functools import cached_property

from django.db import models


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:
"""
Возвращает цену товара для отображения в админке.
Expand Down
Loading