Skip to content

Commit 938ea87

Browse files
authored
Merge pull request #5 from ak4code/claude/hopeful-curie-8ax1e4
Support Django string references and test-only code detection
2 parents 0f97d62 + 5b2fe10 commit 938ea87

15 files changed

Lines changed: 316 additions & 12 deletions

File tree

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,13 @@ dc --kind function,method # только функции и методы
5555
файл `.dc.toml` в корне проекта, секция `[tool.dc]` в `pyproject.toml`.
5656
Незнакомые ключи считаются ошибкой — опечатка не отключит настройку молча.
5757

58-
По умолчанию из анализа исключаются `.venv` (включая окружения `uv`),
59-
`migrations`, `tests` и скрытые директории; правила `.gitignore`
60-
учитываются автоматически.
58+
По умолчанию из анализа полностью исключаются `.venv` (включая окружения
59+
`uv`), `migrations` и скрытые директории; правила `.gitignore` учитываются
60+
автоматически. Тестовые файлы (пакет `tests`, а также `test_*.py`,
61+
`*_test.py`, `tests.py`, `conftest.py`) не исключаются, а
62+
анализируются только как источник ссылок: код, используемый исключительно
63+
в тестах, не считается мертвым, но сами тесты в отчет не попадают. Чтобы
64+
исключить тесты полностью, добавьте `tests` в `exclude_directories`.
6165

6266
```toml
6367
# .dc.toml (или те же ключи в [tool.dc] файла pyproject.toml)
@@ -92,6 +96,16 @@ extra_framework_base_markers = ["Repository", "Actor"]
9296
- **Строковые ссылки** — строки из `getattr`, `setattr`, `hasattr`,
9397
`delattr` и `importlib.import_module` образуют пул динамических ссылок;
9498
совпадающие по имени сущности считаются используемыми.
99+
- **Точечные строковые пути** — любая строка вида `app.views.handler`
100+
(не менее двух сегментов, каждый — корректный идентификатор Python)
101+
считается ссылкой на код и добавляется в пул динамических ссылок. Это
102+
покрывает повсеместные для Django строковые ссылки: `handler404`,
103+
значения настроек DRF (`EXCEPTION_HANDLER`, `DEFAULT_SCHEMA_CLASS`),
104+
строковые константы динамических импортов. Версии (`1.2.3`), пути файлов
105+
и человекочитаемый текст ссылками не считаются.
106+
- **Тесты как источник ссылок** — тестовые файлы анализируются ради
107+
ссылок на код проекта, поэтому функции, вызываемые только из тестов,
108+
не считаются мертвыми; сущности самих тестов в отчет не попадают.
95109
- **Маршрутизация** — представления из вызовов `path`, `re_path`, `url`
96110
(включая строковые ссылки `myapp.views.my_view`) помечаются точками входа.
97111
- **Админка** — строковые значения `list_display`, `list_filter`, `actions`,

src/config.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,7 @@ pub struct AnalyzerConfiguration {
4242
impl Default for AnalyzerConfiguration {
4343
fn default() -> Self {
4444
Self {
45-
exclude_directories: vec![
46-
".venv".to_string(),
47-
"migrations".to_string(),
48-
"tests".to_string(),
49-
],
45+
exclude_directories: vec![".venv".to_string(), "migrations".to_string()],
5046
extra_entry_point_decorators: Vec::new(),
5147
extra_dynamic_names: Vec::new(),
5248
extra_framework_base_markers: Vec::new(),
@@ -155,7 +151,8 @@ mod tests {
155151
assert!(configuration
156152
.exclude_directories
157153
.contains(&"migrations".to_string()));
158-
assert!(configuration
154+
// Тесты не исключаются полностью: они нужны как источник ссылок.
155+
assert!(!configuration
159156
.exclude_directories
160157
.contains(&"tests".to_string()));
161158
}

src/heuristics.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,74 @@ pub fn last_dotted_segment(dotted_name: &str) -> &str {
293293
dotted_name.rsplit('.').next().unwrap_or(dotted_name)
294294
}
295295

296+
/// Проверяет, похож ли строковый литерал на точечную ссылку Python.
297+
///
298+
/// Django и его экосистема повсеместно ссылаются на код строками:
299+
/// `handler404 = 'app.views.handler'`, `'EXCEPTION_HANDLER':
300+
/// 'api.exception_handler.custom_handler'`, динамические импорты вида
301+
/// `'dms.schema_filters.reviews_endpoints_filter'`. Такие строки невозможно
302+
/// отличить от обычного текста без эвристики, поэтому ссылкой считается
303+
/// строка не короче двух сегментов, каждый из которых — корректный
304+
/// идентификатор Python. Это отсекает версии (`1.2.3`), пути файлов
305+
/// и человекочитаемый текст, оставляя пути импорта и обращения к атрибутам.
306+
///
307+
/// :param literal_value: Содержимое строкового литерала.
308+
/// :return: Признак строки, похожей на точечную ссылку на код.
309+
pub fn looks_like_dotted_reference(literal_value: &str) -> bool {
310+
let mut segment_count = 0usize;
311+
for segment in literal_value.split('.') {
312+
if !is_python_identifier(segment) {
313+
return false;
314+
}
315+
segment_count += 1;
316+
}
317+
segment_count >= 2
318+
}
319+
320+
/// Проверяет, является ли сегмент корректным идентификатором Python.
321+
///
322+
/// :param segment: Проверяемый сегмент точечного имени.
323+
/// :return: Признак корректного идентификатора Python.
324+
fn is_python_identifier(segment: &str) -> bool {
325+
let mut characters = segment.chars();
326+
match characters.next() {
327+
// Python 3 допускает не-ASCII идентификаторы (`обработчик`),
328+
// поэтому используются Unicode-категории, а не ASCII-проверки.
329+
Some(first_character) if first_character == '_' || first_character.is_alphabetic() => {}
330+
_ => return false,
331+
}
332+
characters.all(|character| character == '_' || character.is_alphanumeric())
333+
}
334+
335+
/// Проверяет принадлежность файла к тестам по соглашениям Pytest и Django.
336+
///
337+
/// Тестами считаются файлы внутри пакета `tests`, а также файлы
338+
/// `test_*.py`, `*_test.py`, `tests.py` и `conftest.py`. Директория с
339+
/// именем `test` в единственном числе не считается тестовой: это слишком
340+
/// частое имя продуктового пакета, и пометка скрыла бы реальный мертвый
341+
/// код. Тестовые файлы анализируются только как источник ссылок: код,
342+
/// используемый исключительно в тестах, не считается мертвым, а сами
343+
/// тесты не попадают в отчет.
344+
///
345+
/// :param file_path: Путь к анализируемому файлу.
346+
/// :return: Признак тестового файла.
347+
pub fn is_test_file_path(file_path: &Path) -> bool {
348+
let inside_tests_package = file_path
349+
.components()
350+
.filter_map(|component| component.as_os_str().to_str())
351+
.any(|component| component == "tests");
352+
if inside_tests_package {
353+
return true;
354+
}
355+
let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else {
356+
return false;
357+
};
358+
file_name == "tests.py"
359+
|| file_name == "conftest.py"
360+
|| (file_name.starts_with("test_") && file_name.ends_with(".py"))
361+
|| file_name.ends_with("_test.py")
362+
}
363+
296364
#[cfg(test)]
297365
mod tests {
298366
use super::*;
@@ -386,4 +454,38 @@ mod tests {
386454
)));
387455
assert!(!is_management_command_path(Path::new("shop/views.py")));
388456
}
457+
458+
#[test]
459+
fn dotted_references_are_recognized_in_strings() {
460+
assert!(looks_like_dotted_reference(
461+
"mis_service.api.exception_handler.custom_exception_handler"
462+
));
463+
assert!(looks_like_dotted_reference("app.views.handler"));
464+
assert!(looks_like_dotted_reference("_private.module._name"));
465+
// Не-ASCII идентификаторы допустимы в Python 3.
466+
assert!(looks_like_dotted_reference("проект.views.обработчик"));
467+
// Одного сегмента недостаточно: слишком много ложных совпадений.
468+
assert!(!looks_like_dotted_reference("custom_exception_handler"));
469+
// Версии и пути файлов не являются ссылками на код.
470+
assert!(!looks_like_dotted_reference("1.2.3"));
471+
assert!(!looks_like_dotted_reference("app.views.1handler"));
472+
assert!(!looks_like_dotted_reference("product-list"));
473+
assert!(!looks_like_dotted_reference("module..name"));
474+
assert!(!looks_like_dotted_reference("some text.with spaces"));
475+
assert!(!looks_like_dotted_reference(""));
476+
}
477+
478+
#[test]
479+
fn test_files_are_recognized() {
480+
assert!(is_test_file_path(Path::new("shop/tests/test_views.py")));
481+
assert!(is_test_file_path(Path::new("shop/tests.py")));
482+
assert!(is_test_file_path(Path::new("conftest.py")));
483+
assert!(is_test_file_path(Path::new("shop/test_models.py")));
484+
assert!(is_test_file_path(Path::new("shop/models_test.py")));
485+
assert!(!is_test_file_path(Path::new("shop/models.py")));
486+
assert!(!is_test_file_path(Path::new("shop/latest.py")));
487+
// Каталог `test` в единственном числе — частое имя продуктового
488+
// пакета, поэтому тестовым не считается.
489+
assert!(!is_test_file_path(Path::new("app/test/factories.py")));
490+
}
389491
}

src/model.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ pub struct FileAnalysis {
7373
pub scoped_references: Vec<ScopedReference>,
7474
/// Пул динамических строковых ссылок.
7575
pub dynamic_references: Vec<String>,
76+
/// Признак файла, анализируемого только как источник ссылок.
77+
///
78+
/// Тестовые файлы поддерживают живым код, используемый лишь в тестах,
79+
/// но их собственные сущности не считаются мертвым кодом.
80+
pub is_reference_only: bool,
7681
}
7782

7883
/// Файл, пропущенный при анализе из-за ошибки чтения или парсинга.

src/pipeline/extract.rs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,24 @@ pub fn analyze_python_file(
6868
reason: "парсер tree-sitter не построил синтаксическое дерево".to_string(),
6969
})?;
7070

71+
// Признак тестового файла определяется по пути относительно корня
72+
// проекта. Если префикс снять не удалось, файл считается обычным:
73+
// сканировать абсолютный путь нельзя — каталог-предок с именем `tests`
74+
// выше корня иначе пометил бы весь проект как источник ссылок.
75+
let is_reference_only = file_path
76+
.strip_prefix(project_root)
77+
.map(heuristics::is_test_file_path)
78+
.unwrap_or(false);
7179
let mut entity_extractor = EntityExtractor::new(
7280
&source_code,
7381
file_path,
7482
compute_module_path(file_path, project_root),
7583
configuration,
7684
);
7785
entity_extractor.visit_node(syntax_tree.root_node());
78-
Ok(entity_extractor.into_analysis())
86+
let mut file_analysis = entity_extractor.into_analysis();
87+
file_analysis.is_reference_only = is_reference_only;
88+
Ok(file_analysis)
7989
}
8090

8191
/// Вычисляет точечный путь модуля по расположению файла.
@@ -161,9 +171,13 @@ impl<'source> EntityExtractor<'source> {
161171

162172
/// Завершает обход и возвращает результат анализа файла.
163173
///
174+
/// Признак `is_reference_only` проставляется вызывающей стороной,
175+
/// располагающей путем файла относительно корня проекта.
176+
///
164177
/// :return: Результат анализа файла.
165178
fn into_analysis(self) -> FileAnalysis {
166179
FileAnalysis {
180+
is_reference_only: false,
167181
module_path: self.module_path,
168182
entities: self.entities,
169183
scoped_references: self.references.into_iter().collect(),
@@ -206,10 +220,30 @@ impl<'source> EntityExtractor<'source> {
206220
"call" => self.process_call(current_node),
207221
"assignment" => self.process_assignment(current_node),
208222
"identifier" => self.record_reference(self.node_text(current_node)),
223+
"string" => self.process_string_literal(current_node),
209224
_ => self.visit_children(current_node),
210225
}
211226
}
212227

228+
/// Обрабатывает строковый литерал.
229+
///
230+
/// Строка, похожая на точечную ссылку на код (`app.views.handler`),
231+
/// добавляется в пул динамических ссылок: так покрываются строковые
232+
/// настройки Django, обработчики и динамические импорты. Потомки
233+
/// обходятся, чтобы сохранить ссылки из интерполяций f-строк.
234+
///
235+
/// :param string_node: Узел `string`.
236+
fn process_string_literal(&mut self, string_node: Node) {
237+
// Проверка выполняется по заимствованному срезу: аллокация строки
238+
// нужна лишь при совпадении, а не на каждом литерале файла.
239+
if let Some(literal_text) = self.string_literal_text(string_node) {
240+
if heuristics::looks_like_dotted_reference(literal_text) {
241+
self.dynamic_references.insert(literal_text.to_string());
242+
}
243+
}
244+
self.visit_children(string_node);
245+
}
246+
213247
/// Обходит все дочерние узлы текущего узла.
214248
///
215249
/// :param current_node: Текущий узел дерева tree-sitter.
@@ -611,13 +645,21 @@ impl<'source> EntityExtractor<'source> {
611645
/// :param node: Проверяемый узел дерева.
612646
/// :return: Содержимое строки либо `None` для других видов узлов.
613647
fn string_literal_value(&self, node: Node) -> Option<String> {
648+
self.string_literal_text(node).map(str::to_string)
649+
}
650+
651+
/// Возвращает содержимое строкового литерала без копирования.
652+
///
653+
/// :param node: Проверяемый узел дерева.
654+
/// :return: Срез содержимого строки либо `None` для других видов узлов.
655+
fn string_literal_text(&self, node: Node) -> Option<&'source str> {
614656
if node.kind() != "string" {
615657
return None;
616658
}
617659
let mut tree_cursor = node.walk();
618660
for child_node in node.children(&mut tree_cursor) {
619661
if child_node.kind() == "string_content" {
620-
return Some(self.node_text(child_node).to_string());
662+
return Some(self.node_text(child_node));
621663
}
622664
}
623665
None

src/pipeline/reachability.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub fn find_unreachable_entities<'analysis>(
2626
let mut scope_nodes: HashMap<&str, NodeIndex> = HashMap::new();
2727
let mut name_index: HashMap<&str, Vec<NodeIndex>> = HashMap::new();
2828
let mut entities_by_node: HashMap<NodeIndex, &CodeEntity> = HashMap::new();
29+
let mut reference_only_nodes: HashSet<NodeIndex> = HashSet::new();
2930

3031
for file_analysis in file_analyses {
3132
let module_node = dependency_graph.add_node(file_analysis.module_path.as_str());
@@ -41,6 +42,9 @@ pub fn find_unreachable_entities<'analysis>(
4142
.or_default()
4243
.push(entity_node);
4344
entities_by_node.insert(entity_node, code_entity);
45+
if file_analysis.is_reference_only {
46+
reference_only_nodes.insert(entity_node);
47+
}
4448
}
4549
}
4650

@@ -65,7 +69,9 @@ pub fn find_unreachable_entities<'analysis>(
6569

6670
let mut unreachable_entities: Vec<&CodeEntity> = entities_by_node
6771
.iter()
68-
.filter(|(node_index, _)| !reachable_nodes.contains(node_index))
72+
.filter(|(node_index, _)| {
73+
!reachable_nodes.contains(node_index) && !reference_only_nodes.contains(node_index)
74+
})
6975
.map(|(_, code_entity)| *code_entity)
7076
.collect();
7177
unreachable_entities.sort_by(|first, second| {
@@ -136,6 +142,9 @@ fn add_containment_edges(
136142
///
137143
/// Точечные строки вида `myapp.views.my_view` разрешаются до простого
138144
/// имени функции. Имена из конфигурации пользователя дополняют пул.
145+
/// Строки из тестовых файлов в пул не попадают: тест, упоминающий имя
146+
/// строкой, не должен оживлять мертвый продуктовый код (реальные ссылки
147+
/// из тестов учитываются графом по именам, а не через этот пул).
139148
///
140149
/// :param file_analyses: Результаты анализа файлов.
141150
/// :param configuration: Конфигурация анализатора.
@@ -148,6 +157,7 @@ fn build_dynamic_reference_pool<'analysis>(
148157
let configured_names = configuration.extra_dynamic_names.iter().map(String::as_str);
149158
let extracted_names = file_analyses
150159
.iter()
160+
.filter(|file_analysis| !file_analysis.is_reference_only)
151161
.flat_map(|file_analysis| file_analysis.dynamic_references.iter().map(String::as_str));
152162
for dynamic_name in configured_names.chain(extracted_names) {
153163
dynamic_reference_pool.insert(dynamic_name);

0 commit comments

Comments
 (0)