Skip to content

Commit a2a6740

Browse files
authored
Merge pull request #3 from ak4code/claude/festive-brown-6fynyg
Свойства моделей и фильтр находок по виду сущности
2 parents 4f215f6 + 73ed286 commit a2a6740

7 files changed

Lines changed: 167 additions & 4 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ cargo build --release
1414
./target/release/dc # запуск в корне анализируемого проекта
1515
./target/release/dc --target-path /path/to/project --verbose
1616
./target/release/dc --format json > report.json
17+
./target/release/dc --kind function,method # только функции и методы
1718
```
1819

1920
Параметры командной строки:
@@ -23,6 +24,7 @@ cargo build --release
2324
| `-t, --target-path` | `.` | Корневая директория проекта |
2425
| `-c, --config-path` | автопоиск | Путь к файлу конфигурации |
2526
| `-f, --format` | `text` | Формат отчета: `text` или `json` |
27+
| `-k, --kind` | все виды | Фильтр находок: `function`, `class`, `method`, `variable`; повторение или перечисление через запятую |
2628
| `-v, --verbose` | выкл. | Вывод статистики анализа |
2729

2830
Коды завершения (для интеграции с CI):
@@ -92,6 +94,9 @@ extra_dynamic_names = ["called_from_template"]
9294
- **Хуки фреймворков** — методы с префиксами `validate_`, `clean_`,
9395
`get_`, `perform_`, `has_`, `test_` вызываются Django, DRF и Pytest
9496
по соглашению и не считаются мертвым кодом.
97+
- **Свойства** — методы под `@property`, `@cached_property` и аксессорами
98+
`@имя.setter` / `@имя.getter` / `@имя.deleter` читаются как атрибуты
99+
(в том числе из шаблонов Django) и не считаются мертвым кодом.
95100
- **Классы под управлением фреймворка** — методы классов, унаследованных
96101
от баз `Serializer`, `ViewSet`, `View`, `Permission`, `Form`, `Admin`,
97102
`Middleware`, `TestCase` и подобных, вызываются фреймворком по контракту;

src/heuristics.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ const FRAMEWORK_DRIVEN_BASE_MARKERS: &[&str] = &[
8282
"Command",
8383
];
8484

85+
/// Последние сегменты декораторов, превращающих метод в свойство.
86+
///
87+
/// Свойства читаются как атрибуты: из шаблонов Django, админки
88+
/// и сериализаторов. Такие обращения не видны статическому анализу,
89+
/// поэтому свойства не считаются мертвым кодом.
90+
const PROPERTY_DECORATOR_SEGMENTS: &[&str] =
91+
&["property", "cached_property", "setter", "getter", "deleter"];
92+
8593
/// Имена классов, обнаруживаемых Django по соглашению.
8694
const IMPLICIT_CLASS_NAMES: &[&str] = &["Meta", "Media", "DoesNotExist", "MultipleObjectsReturned"];
8795

@@ -190,6 +198,17 @@ pub fn is_framework_driven_base(superclasses_text: &str) -> bool {
190198
.any(|marker| superclasses_text.contains(marker))
191199
}
192200

201+
/// Проверяет, превращает ли декоратор метод в свойство.
202+
///
203+
/// Учитываются `property`, `functools.cached_property` и аксессоры
204+
/// `@имя.setter`, `@имя.getter`, `@имя.deleter`.
205+
///
206+
/// :param normalized_decorator: Нормализованное точечное имя декоратора.
207+
/// :return: Признак декоратора свойства.
208+
pub fn is_property_decorator(normalized_decorator: &str) -> bool {
209+
PROPERTY_DECORATOR_SEGMENTS.contains(&last_dotted_segment(normalized_decorator))
210+
}
211+
193212
/// Проверяет обнаружение класса фреймворком Django по соглашению.
194213
///
195214
/// :param class_name: Простое имя класса.
@@ -290,6 +309,17 @@ mod tests {
290309
assert!(!is_implicit_method_name("unused_helper"));
291310
}
292311

312+
#[test]
313+
fn property_decorators_are_recognized() {
314+
assert!(is_property_decorator("property"));
315+
assert!(is_property_decorator("functools.cached_property"));
316+
assert!(is_property_decorator("cached_property"));
317+
assert!(is_property_decorator("price.setter"));
318+
assert!(is_property_decorator("price.deleter"));
319+
assert!(!is_property_decorator("staticmethod"));
320+
assert!(!is_property_decorator("classmethod"));
321+
}
322+
293323
#[test]
294324
fn framework_driven_bases_are_recognized() {
295325
assert!(is_framework_driven_base("(serializers.ModelSerializer)"));

src/main.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
use std::path::{Path, PathBuf};
44
use std::process::ExitCode;
55

6-
use clap::Parser;
6+
use clap::{Parser, ValueEnum};
77
use mimalloc::MiMalloc;
88

9-
use dc::{load_configuration, render_report, run_analysis, AnalysisReport, DcError, ReportFormat};
9+
use dc::{
10+
load_configuration, render_report, run_analysis, AnalysisReport, DcError, EntityKind,
11+
ReportFormat,
12+
};
1013

1114
#[global_allocator]
1215
static GLOBAL: MiMalloc = MiMalloc;
@@ -38,11 +41,40 @@ struct CommandLineArguments {
3841
#[arg(short, long, value_enum, default_value_t = ReportFormat::Text)]
3942
format: ReportFormat,
4043

44+
/// Виды сущностей в отчете. По умолчанию выводятся все виды.
45+
/// Допускает повторение и перечисление через запятую.
46+
#[arg(short, long = "kind", value_enum, value_delimiter = ',')]
47+
kinds: Vec<FindingKindArgument>,
48+
4149
/// Вывод дополнительной статистики анализа.
4250
#[arg(short, long, default_value_t = false)]
4351
verbose: bool,
4452
}
4553

54+
/// Вид сущности для фильтрации находок из командной строки.
55+
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
56+
enum FindingKindArgument {
57+
/// Функции уровня модуля.
58+
Function,
59+
/// Классы.
60+
Class,
61+
/// Методы классов.
62+
Method,
63+
/// Переменные уровня модуля.
64+
Variable,
65+
}
66+
67+
impl From<FindingKindArgument> for EntityKind {
68+
fn from(kind_argument: FindingKindArgument) -> Self {
69+
match kind_argument {
70+
FindingKindArgument::Function => EntityKind::Function,
71+
FindingKindArgument::Class => EntityKind::Class,
72+
FindingKindArgument::Method => EntityKind::Method,
73+
FindingKindArgument::Variable => EntityKind::Variable,
74+
}
75+
}
76+
}
77+
4678
/// Инициализирует процесс анализа и выводит результаты.
4779
///
4880
/// :return: Статус завершения программы.
@@ -85,7 +117,32 @@ fn execute(command_line_arguments: &CommandLineArguments) -> Result<AnalysisRepo
85117
}
86118
let analyzer_configuration =
87119
load_configuration(command_line_arguments.config_path.as_deref(), target_path)?;
88-
Ok(run_analysis(target_path, &analyzer_configuration))
120+
let mut analysis_report = run_analysis(target_path, &analyzer_configuration);
121+
filter_findings_by_kind(&mut analysis_report, &command_line_arguments.kinds);
122+
Ok(analysis_report)
123+
}
124+
125+
/// Оставляет в отчете только находки запрошенных видов.
126+
///
127+
/// Пустой список видов означает отсутствие фильтрации. Код завершения
128+
/// программы определяется уже отфильтрованными находками.
129+
///
130+
/// :param analysis_report: Итоговый отчет анализа.
131+
/// :param requested_kinds: Виды сущностей из аргументов командной строки.
132+
fn filter_findings_by_kind(
133+
analysis_report: &mut AnalysisReport,
134+
requested_kinds: &[FindingKindArgument],
135+
) {
136+
if requested_kinds.is_empty() {
137+
return;
138+
}
139+
let allowed_kinds: Vec<EntityKind> = requested_kinds
140+
.iter()
141+
.map(|kind_argument| EntityKind::from(*kind_argument))
142+
.collect();
143+
analysis_report
144+
.findings
145+
.retain(|finding| allowed_kinds.contains(&finding.entity_kind));
89146
}
90147

91148
/// Выводит предупреждения о пропущенных файлах в поток ошибок.

src/pipeline/extract.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,8 +379,12 @@ impl<'source> EntityExtractor<'source> {
379379
if heuristics::is_test_function_name(simple_name) {
380380
return true;
381381
}
382+
let is_property = decorator_names
383+
.iter()
384+
.any(|decorator| heuristics::is_property_decorator(decorator));
382385
entity_kind == EntityKind::Method
383-
&& (heuristics::is_implicit_method_name(simple_name)
386+
&& (is_property
387+
|| heuristics::is_implicit_method_name(simple_name)
384388
|| self.is_inside_framework_driven_class())
385389
}
386390
EntityKind::Variable => false,

tests/command_line_interface.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,55 @@ fn json_format_produces_parseable_output() {
4343
assert!(parsed["findings"].is_array());
4444
}
4545

46+
#[test]
47+
fn kind_filter_limits_report_to_requested_kinds() {
48+
let fixture = fixture_project_path();
49+
let output = run_dc(&[
50+
"--target-path",
51+
fixture.to_str().unwrap(),
52+
"--kind",
53+
"variable",
54+
]);
55+
56+
assert_eq!(output.status.code(), Some(1));
57+
let stdout = String::from_utf8_lossy(&output.stdout);
58+
assert!(stdout.contains("LEGACY_FLAG"), "{stdout}");
59+
assert!(!stdout.contains("abandoned_view"), "{stdout}");
60+
assert!(!stdout.contains("unused_helper"), "{stdout}");
61+
}
62+
63+
#[test]
64+
fn kind_filter_accepts_comma_separated_values() {
65+
let fixture = fixture_project_path();
66+
let output = run_dc(&[
67+
"--target-path",
68+
fixture.to_str().unwrap(),
69+
"--kind",
70+
"function,method",
71+
]);
72+
73+
assert_eq!(output.status.code(), Some(1));
74+
let stdout = String::from_utf8_lossy(&output.stdout);
75+
assert!(stdout.contains("abandoned_view"), "{stdout}");
76+
assert!(stdout.contains("unused_helper"), "{stdout}");
77+
assert!(!stdout.contains("LEGACY_FLAG"), "{stdout}");
78+
}
79+
80+
#[test]
81+
fn kind_filter_without_matches_produces_exit_code_zero() {
82+
let fixture = fixture_project_path();
83+
let output = run_dc(&[
84+
"--target-path",
85+
fixture.to_str().unwrap(),
86+
"--kind",
87+
"class",
88+
]);
89+
90+
assert_eq!(output.status.code(), Some(0));
91+
let stdout = String::from_utf8_lossy(&output.stdout);
92+
assert!(stdout.contains("Мертвый код не найден"), "{stdout}");
93+
}
94+
4695
#[test]
4796
fn missing_target_directory_produces_exit_code_two() {
4897
let output = run_dc(&["--target-path", "/nonexistent/project"]);

tests/dead_code_detection.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ fn detects_dead_code_and_respects_django_heuristics() {
4040
!contains("shop.models.Product.display_price"),
4141
"{dead_names:?}"
4242
);
43+
// Свойства модели читаются из шаблонов и не считаются мертвыми.
44+
assert!(
45+
!contains("shop.models.Product.availability_label"),
46+
"{dead_names:?}"
47+
);
48+
assert!(!contains("shop.models.Product.slug"), "{dead_names:?}");
4349
// Задача Celery и ее вспомогательная функция живые.
4450
assert!(!contains("shop.tasks.refresh_catalog"), "{dead_names:?}");
4551
assert!(

tests/fixtures/demo_project/shop/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1+
from functools import cached_property
2+
13
from django.db import models
24

35

46
class Product(models.Model):
57
title = models.CharField(max_length=255)
68
price = models.DecimalField(max_digits=10, decimal_places=2)
79

10+
@property
11+
def availability_label(self) -> str:
12+
"""Используется только в шаблоне product_detail.html."""
13+
return "В наличии"
14+
15+
@cached_property
16+
def slug(self) -> str:
17+
"""Используется только в шаблоне product_list.html."""
18+
return self.title.lower().replace(" ", "-")
19+
820
def display_price(self) -> str:
921
"""
1022
Возвращает цену товара для отображения в админке.

0 commit comments

Comments
 (0)