From c5ab28a946afa8caecff0a38aa22950b4fa5fcc8 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Wed, 11 Mar 2026 08:51:38 +0200 Subject: [PATCH 1/2] rotate smell implementation graph by 90 --- src/slopometry/core/complexity_analyzer.py | 96 ++----- src/slopometry/core/hook_handler.py | 22 +- src/slopometry/core/language_config.py | 18 ++ src/slopometry/core/models/core.py | 103 ++------ src/slopometry/core/models/smell.py | 9 + .../core/python_feature_analyzer.py | 249 +++--------------- src/slopometry/core/working_tree_state.py | 7 +- tests/test_complexity_analyzer.py | 4 + tests/test_feedback_cache.py | 40 +++ tests/test_language_config.py | 35 +++ tests/test_python_feature_analyzer.py | 215 +++++++++++++-- tests/test_smell_registry.py | 10 +- uv.lock | 2 +- 13 files changed, 404 insertions(+), 406 deletions(-) diff --git a/src/slopometry/core/complexity_analyzer.py b/src/slopometry/core/complexity_analyzer.py index 8465bd3..27f95fe 100644 --- a/src/slopometry/core/complexity_analyzer.py +++ b/src/slopometry/core/complexity_analyzer.py @@ -5,6 +5,7 @@ import time from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path +from typing import Any from slopometry.core.code_analyzer import CodeAnalyzer, _analyze_single_file from slopometry.core.models.complexity import ( @@ -13,6 +14,7 @@ ExtendedComplexityMetrics, FileAnalysisResult, ) +from slopometry.core.models.smell import SMELL_REGISTRY from slopometry.core.python_feature_analyzer import PythonFeatureAnalyzer, _count_loc from slopometry.core.settings import settings @@ -214,33 +216,14 @@ def _calculate_delta( current_metrics.str_type_percentage - baseline_metrics.str_type_percentage ) - delta.orphan_comment_change = current_metrics.orphan_comment_count - baseline_metrics.orphan_comment_count - delta.untracked_todo_change = current_metrics.untracked_todo_count - baseline_metrics.untracked_todo_count - delta.inline_import_change = current_metrics.inline_import_count - baseline_metrics.inline_import_count - delta.dict_get_with_default_change = ( - current_metrics.dict_get_with_default_count - baseline_metrics.dict_get_with_default_count - ) - delta.hasattr_getattr_change = ( - current_metrics.hasattr_getattr_count - baseline_metrics.hasattr_getattr_count - ) - delta.nonempty_init_change = current_metrics.nonempty_init_count - baseline_metrics.nonempty_init_count - delta.test_skip_change = current_metrics.test_skip_count - baseline_metrics.test_skip_count - delta.swallowed_exception_change = ( - current_metrics.swallowed_exception_count - baseline_metrics.swallowed_exception_count - ) - delta.type_ignore_change = current_metrics.type_ignore_count - baseline_metrics.type_ignore_count - delta.dynamic_execution_change = ( - current_metrics.dynamic_execution_count - baseline_metrics.dynamic_execution_count - ) - delta.single_method_class_change = ( - current_metrics.single_method_class_count - baseline_metrics.single_method_class_count - ) - delta.deep_inheritance_change = ( - current_metrics.deep_inheritance_count - baseline_metrics.deep_inheritance_count - ) - delta.passthrough_wrapper_change = ( - current_metrics.passthrough_wrapper_count - baseline_metrics.passthrough_wrapper_count - ) + for name in SMELL_REGISTRY: + count_field = f"{name}_count" + change_field = f"{name}_change" + setattr( + delta, + change_field, + getattr(current_metrics, count_field) - getattr(baseline_metrics, count_field), + ) return delta @@ -261,7 +244,8 @@ def _build_files_by_loc(self, python_files: list[Path], target_dir: Path) -> dic _, code_loc = _count_loc(content) relative_path = self._get_relative_path(file_path, target_dir) files_by_loc[relative_path] = code_loc - except (OSError, UnicodeDecodeError): + except (OSError, UnicodeDecodeError) as e: + logger.warning(f"Skipping unreadable file {file_path}: {e}") continue return files_by_loc @@ -433,6 +417,13 @@ def analyze_extended_complexity(self, directory: Path | None = None) -> Extended any_type_percentage = (feature_stats.any_type_count / total_type_refs * 100.0) if total_type_refs > 0 else 0.0 str_type_percentage = (feature_stats.str_type_count / total_type_refs * 100.0) if total_type_refs > 0 else 0.0 + smell_kwargs: dict[str, Any] = {} + for defn in SMELL_REGISTRY.values(): + smell_kwargs[defn.count_field] = getattr(feature_stats, defn.count_field) + smell_kwargs[defn.files_field] = sorted( + [self._get_relative_path(p, target_dir) for p in getattr(feature_stats, defn.files_field)] + ) + return ExtendedComplexityMetrics( total_complexity=total_complexity, average_complexity=average_complexity, @@ -461,58 +452,11 @@ def analyze_extended_complexity(self, directory: Path | None = None) -> Extended files_by_complexity=files_by_complexity, files_by_effort=files_by_effort, files_with_parse_errors=files_with_parse_errors, - orphan_comment_count=feature_stats.orphan_comment_count, - untracked_todo_count=feature_stats.untracked_todo_count, - inline_import_count=feature_stats.inline_import_count, - dict_get_with_default_count=feature_stats.dict_get_with_default_count, - hasattr_getattr_count=feature_stats.hasattr_getattr_count, - nonempty_init_count=feature_stats.nonempty_init_count, - test_skip_count=feature_stats.test_skip_count, - swallowed_exception_count=feature_stats.swallowed_exception_count, - type_ignore_count=feature_stats.type_ignore_count, - dynamic_execution_count=feature_stats.dynamic_execution_count, - orphan_comment_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.orphan_comment_files] - ), - untracked_todo_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.untracked_todo_files] - ), - inline_import_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.inline_import_files] - ), - dict_get_with_default_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.dict_get_with_default_files] - ), - hasattr_getattr_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.hasattr_getattr_files] - ), - nonempty_init_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.nonempty_init_files] - ), - test_skip_files=sorted([self._get_relative_path(p, target_dir) for p in feature_stats.test_skip_files]), - swallowed_exception_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.swallowed_exception_files] - ), - type_ignore_files=sorted([self._get_relative_path(p, target_dir) for p in feature_stats.type_ignore_files]), - dynamic_execution_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.dynamic_execution_files] - ), - single_method_class_count=feature_stats.single_method_class_count, - deep_inheritance_count=feature_stats.deep_inheritance_count, - passthrough_wrapper_count=feature_stats.passthrough_wrapper_count, - single_method_class_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.single_method_class_files] - ), - deep_inheritance_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.deep_inheritance_files] - ), - passthrough_wrapper_files=sorted( - [self._get_relative_path(p, target_dir) for p in feature_stats.passthrough_wrapper_files] - ), total_loc=feature_stats.total_loc, code_loc=feature_stats.code_loc, files_by_loc={ self._get_relative_path(p, target_dir): loc for p, loc in self._build_files_by_loc(python_files, target_dir).items() }, + **smell_kwargs, ) diff --git a/src/slopometry/core/hook_handler.py b/src/slopometry/core/hook_handler.py index fe3f6e3..cecd5c0 100644 --- a/src/slopometry/core/hook_handler.py +++ b/src/slopometry/core/hook_handler.py @@ -367,15 +367,25 @@ def handle_stop_event(session_id: str, parsed_input: "StopInput | SubagentStopIn logger.debug(f"Failed to get modified source files: {e}") edited_files = set() - # Smell feedback is stable (based on code state, not session activity) + # Smell feedback: split into code-based (stable) and context-derived (unstable) + # Context-derived smells (e.g., unread_related_tests) change with every transcript + # read and must NOT be included in the cache hash to avoid repeated triggers if current_metrics: scoped_smells = scope_smells_for_session( current_metrics, delta, edited_files, stats.working_directory, stats.context_coverage ) - smell_feedback, has_smells, _ = format_code_smell_feedback(scoped_smells, session_id, stats.working_directory) - if has_smells: - feedback_parts.append(smell_feedback) - cache_stable_parts.append(smell_feedback) + + code_smells = [s for s in scoped_smells if s.name != "unread_related_tests"] + context_smells = [s for s in scoped_smells if s.name == "unread_related_tests"] + + code_feedback, has_code_smells, _ = format_code_smell_feedback(code_smells, session_id) + if has_code_smells: + feedback_parts.append(code_feedback) + cache_stable_parts.append(code_feedback) + + context_smell_feedback, has_context_smells, _ = format_code_smell_feedback(context_smells, session_id) + if has_context_smells: + feedback_parts.append(context_smell_feedback) # Context coverage - informational but NOT stable (changes with every Read/Glob/Grep) # Excluded from cache hash to avoid invalidation on tool calls @@ -656,14 +666,12 @@ def scope_smells_for_session( def format_code_smell_feedback( scoped_smells: list[ScopedSmell], session_id: str | None = None, - working_directory: str | None = None, ) -> tuple[str, bool, bool]: """Format pre-classified smell data into feedback output. Args: scoped_smells: Pre-classified smells from scope_smells_for_session session_id: Session ID for generating the smell-details command - working_directory: Path to working directory (unused, kept for caller compatibility) Returns: Tuple of (formatted feedback string, has_smells, has_blocking_smells) diff --git a/src/slopometry/core/language_config.py b/src/slopometry/core/language_config.py index 3d4a304..6bcfb9f 100644 --- a/src/slopometry/core/language_config.py +++ b/src/slopometry/core/language_config.py @@ -168,6 +168,24 @@ def get_combined_ignore_dirs(languages: list[ProjectLanguage] | None = None) -> return ignore_dirs +def is_source_file(file_path: Path | str, languages: list[ProjectLanguage] | None = None) -> bool: + """Check if a file path matches a supported source extension. + + Args: + file_path: Path to check + languages: List of languages to match against, or None for all supported + + Returns: + True if the file has a recognized source extension + """ + if languages is None: + configs = get_all_supported_configs() + else: + configs = [get_language_config(lang) for lang in languages] + + return any(config.matches_extension(file_path) for config in configs) + + def should_ignore_path(file_path: Path | str, languages: list[ProjectLanguage] | None = None) -> bool: """Check if a file path should be ignored based on language configs. diff --git a/src/slopometry/core/models/core.py b/src/slopometry/core/models/core.py index a250c35..fbf6039 100644 --- a/src/slopometry/core/models/core.py +++ b/src/slopometry/core/models/core.py @@ -55,6 +55,7 @@ class SmellCounts(BaseModel): deep_inheritance: int = 0 passthrough_wrapper: int = 0 sys_path_manipulation: int = 0 + relative_import: int = 0 class ComplexityMetrics(BaseModel): @@ -131,25 +132,13 @@ class ComplexityDelta(BaseModel): deep_inheritance_change: int = 0 passthrough_wrapper_change: int = 0 sys_path_manipulation_change: int = 0 + relative_import_change: int = 0 def get_smell_changes(self) -> dict[str, int]: """Return smell name to change value mapping for direct access.""" - return { - "orphan_comment": self.orphan_comment_change, - "untracked_todo": self.untracked_todo_change, - "inline_import": self.inline_import_change, - "dict_get_with_default": self.dict_get_with_default_change, - "hasattr_getattr": self.hasattr_getattr_change, - "nonempty_init": self.nonempty_init_change, - "test_skip": self.test_skip_change, - "swallowed_exception": self.swallowed_exception_change, - "type_ignore": self.type_ignore_change, - "dynamic_execution": self.dynamic_execution_change, - "single_method_class": self.single_method_class_change, - "deep_inheritance": self.deep_inheritance_change, - "passthrough_wrapper": self.passthrough_wrapper_change, - "sys_path_manipulation": self.sys_path_manipulation_change, - } + from slopometry.core.models.smell import SMELL_REGISTRY + + return {name: getattr(self, f"{name}_change") for name in SMELL_REGISTRY} class ExtendedComplexityMetrics(ComplexityMetrics): @@ -246,6 +235,10 @@ class ExtendedComplexityMetrics(ComplexityMetrics): default=0, description="sys.path mutations bypass the package system — restructure package boundaries and use absolute imports from installed packages instead", ) + relative_import_count: int = Field( + default=0, + description="Prefer absolute imports for clarity and refactor-safety; relative imports create implicit coupling to package structure", + ) # LOC metrics (for file filtering in QPE) total_loc: int = Field(default=0, description="Total lines of code across all files") @@ -270,6 +263,7 @@ class ExtendedComplexityMetrics(ComplexityMetrics): ) passthrough_wrapper_files: list[str] = Field(default_factory=list, description="Files with pass-through wrappers") sys_path_manipulation_files: list[str] = Field(default_factory=list, description="Files with sys.path mutations") + relative_import_files: list[str] = Field(default_factory=list, description="Files with relative imports") def get_smell_counts(self) -> SmellCounts: """Return smell counts as a typed model for QPE and display.""" @@ -277,80 +271,15 @@ def get_smell_counts(self) -> SmellCounts: def get_smells(self) -> list["SmellData"]: """Return all smell data as structured objects with direct field access.""" - # Import here to avoid circular imports at runtime - from slopometry.core.models.smell import SmellData + from slopometry.core.models.smell import SMELL_REGISTRY, SmellData return [ SmellData( - name="orphan_comment", - count=self.orphan_comment_count, - files=self.orphan_comment_files, - ), - SmellData( - name="untracked_todo", - count=self.untracked_todo_count, - files=self.untracked_todo_files, - ), - SmellData( - name="swallowed_exception", - count=self.swallowed_exception_count, - files=self.swallowed_exception_files, - ), - SmellData( - name="test_skip", - count=self.test_skip_count, - files=self.test_skip_files, - ), - SmellData( - name="type_ignore", - count=self.type_ignore_count, - files=self.type_ignore_files, - ), - SmellData( - name="dynamic_execution", - count=self.dynamic_execution_count, - files=self.dynamic_execution_files, - ), - SmellData( - name="inline_import", - count=self.inline_import_count, - files=self.inline_import_files, - ), - SmellData( - name="dict_get_with_default", - count=self.dict_get_with_default_count, - files=self.dict_get_with_default_files, - ), - SmellData( - name="hasattr_getattr", - count=self.hasattr_getattr_count, - files=self.hasattr_getattr_files, - ), - SmellData( - name="nonempty_init", - count=self.nonempty_init_count, - files=self.nonempty_init_files, - ), - SmellData( - name="single_method_class", - count=self.single_method_class_count, - files=self.single_method_class_files, - ), - SmellData( - name="deep_inheritance", - count=self.deep_inheritance_count, - files=self.deep_inheritance_files, - ), - SmellData( - name="passthrough_wrapper", - count=self.passthrough_wrapper_count, - files=self.passthrough_wrapper_files, - ), - SmellData( - name="sys_path_manipulation", - count=self.sys_path_manipulation_count, - files=self.sys_path_manipulation_files, - ), + name=defn.internal_name, + count=getattr(self, defn.count_field), + files=getattr(self, defn.files_field), + ) + for defn in SMELL_REGISTRY.values() ] def get_smell_files(self) -> dict[str, list[str]]: diff --git a/src/slopometry/core/models/smell.py b/src/slopometry/core/models/smell.py index 6e17b7f..e98d738 100644 --- a/src/slopometry/core/models/smell.py +++ b/src/slopometry/core/models/smell.py @@ -156,6 +156,15 @@ class SmellDefinition(BaseModel): count_field="sys_path_manipulation_count", files_field="sys_path_manipulation_files", ), + "relative_import": SmellDefinition( + internal_name="relative_import", + label="Relative Imports", + category=SmellCategory.PYTHON, + weight=0.03, + guidance="Prefer absolute imports for clarity and refactor-safety; relative imports create implicit coupling to package structure", + count_field="relative_import_count", + files_field="relative_import_files", + ), } diff --git a/src/slopometry/core/python_feature_analyzer.py b/src/slopometry/core/python_feature_analyzer.py index cc4c15b..32e3b4c 100644 --- a/src/slopometry/core/python_feature_analyzer.py +++ b/src/slopometry/core/python_feature_analyzer.py @@ -9,10 +9,11 @@ import tokenize from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path +from typing import Any from pydantic import BaseModel, ConfigDict, Field -from slopometry.core.models.smell import SmellField +from slopometry.core.models.smell import SMELL_REGISTRY, SmellField from slopometry.core.settings import settings logger = logging.getLogger(__name__) @@ -105,6 +106,11 @@ class FeatureStats(BaseModel): files_field="sys_path_manipulation_files", guidance="sys.path mutations bypass the package system — restructure package boundaries and use absolute imports from installed packages instead", ) + relative_import_count: int = SmellField( + label="Relative Imports", + files_field="relative_import_files", + guidance="Prefer absolute imports for clarity and refactor-safety; relative imports create implicit coupling to package structure", + ) total_loc: int = Field(default=0, description="Total lines of code") code_loc: int = Field(default=0, description="Non-blank, non-comment lines (for QPE file filtering)") @@ -123,6 +129,7 @@ class FeatureStats(BaseModel): deep_inheritance_files: set[str] = Field(default_factory=set) passthrough_wrapper_files: set[str] = Field(default_factory=set) sys_path_manipulation_files: set[str] = Field(default_factory=set) + relative_import_files: set[str] = Field(default_factory=set) def _count_loc(content: str) -> tuple[int, int]: @@ -159,6 +166,23 @@ def _analyze_single_file_features(file_path: Path) -> FeatureStats | None: total_loc, code_loc = _count_loc(content) path_str = str(file_path) + # 4 smells come from non-AST analysis; rest from FeatureVisitor + non_ast_counts: dict[str, int] = { + "orphan_comment_count": orphan_comments, + "untracked_todo_count": untracked_todos, + "type_ignore_count": type_ignores, + "nonempty_init_count": nonempty_init, + } + + smell_kwargs: dict[str, Any] = {} + for defn in SMELL_REGISTRY.values(): + if defn.count_field in non_ast_counts: + count = non_ast_counts[defn.count_field] + else: + count = getattr(ast_stats, defn.count_field) + smell_kwargs[defn.count_field] = count + smell_kwargs[defn.files_field] = {path_str} if count > 0 else set() + return FeatureStats( functions_count=ast_stats.functions_count, classes_count=ast_stats.classes_count, @@ -171,36 +195,9 @@ def _analyze_single_file_features(file_path: Path) -> FeatureStats | None: any_type_count=ast_stats.any_type_count, str_type_count=ast_stats.str_type_count, deprecations_count=ast_stats.deprecations_count, - orphan_comment_count=orphan_comments, - untracked_todo_count=untracked_todos, - inline_import_count=ast_stats.inline_import_count, - dict_get_with_default_count=ast_stats.dict_get_with_default_count, - hasattr_getattr_count=ast_stats.hasattr_getattr_count, - nonempty_init_count=nonempty_init, - test_skip_count=ast_stats.test_skip_count, - swallowed_exception_count=ast_stats.swallowed_exception_count, - type_ignore_count=type_ignores, - dynamic_execution_count=ast_stats.dynamic_execution_count, - single_method_class_count=ast_stats.single_method_class_count, - deep_inheritance_count=ast_stats.deep_inheritance_count, - passthrough_wrapper_count=ast_stats.passthrough_wrapper_count, - sys_path_manipulation_count=ast_stats.sys_path_manipulation_count, total_loc=total_loc, code_loc=code_loc, - orphan_comment_files={path_str} if orphan_comments > 0 else set(), - untracked_todo_files={path_str} if untracked_todos > 0 else set(), - inline_import_files={path_str} if ast_stats.inline_import_count > 0 else set(), - dict_get_with_default_files={path_str} if ast_stats.dict_get_with_default_count > 0 else set(), - hasattr_getattr_files={path_str} if ast_stats.hasattr_getattr_count > 0 else set(), - nonempty_init_files={path_str} if nonempty_init > 0 else set(), - test_skip_files={path_str} if ast_stats.test_skip_count > 0 else set(), - swallowed_exception_files={path_str} if ast_stats.swallowed_exception_count > 0 else set(), - type_ignore_files={path_str} if type_ignores > 0 else set(), - dynamic_execution_files={path_str} if ast_stats.dynamic_execution_count > 0 else set(), - single_method_class_files={path_str} if ast_stats.single_method_class_count > 0 else set(), - deep_inheritance_files={path_str} if ast_stats.deep_inheritance_count > 0 else set(), - passthrough_wrapper_files={path_str} if ast_stats.passthrough_wrapper_count > 0 else set(), - sys_path_manipulation_files={path_str} if ast_stats.sys_path_manipulation_count > 0 else set(), + **smell_kwargs, ) @@ -339,155 +336,13 @@ def _analyze_files_parallel(self, files: list[Path], max_workers: int | None = N return results - def _analyze_file(self, file_path: Path) -> FeatureStats: - """Analyze a single Python file.""" - try: - content = file_path.read_text(encoding="utf-8") - tree = ast.parse(content, filename=str(file_path)) - except Exception as e: - logger.debug(f"Skipping unparseable file {file_path}: {e}") - return FeatureStats() - - visitor = FeatureVisitor() - visitor.visit(tree) - ast_stats = visitor.stats - - is_test_file = file_path.name.startswith("test_") or "/tests/" in str(file_path) - orphan_comments, untracked_todos, type_ignores = self._analyze_comments(content, is_test_file) - nonempty_init = 1 if self._is_nonempty_init(file_path, tree) else 0 - total_loc, code_loc = _count_loc(content) - path_str = str(file_path) - - return FeatureStats( - functions_count=ast_stats.functions_count, - classes_count=ast_stats.classes_count, - docstrings_count=ast_stats.docstrings_count, - args_count=ast_stats.args_count, - annotated_args_count=ast_stats.annotated_args_count, - returns_count=ast_stats.returns_count, - annotated_returns_count=ast_stats.annotated_returns_count, - total_type_references=ast_stats.total_type_references, - any_type_count=ast_stats.any_type_count, - str_type_count=ast_stats.str_type_count, - deprecations_count=ast_stats.deprecations_count, - orphan_comment_count=orphan_comments, - untracked_todo_count=untracked_todos, - inline_import_count=ast_stats.inline_import_count, - dict_get_with_default_count=ast_stats.dict_get_with_default_count, - hasattr_getattr_count=ast_stats.hasattr_getattr_count, - nonempty_init_count=nonempty_init, - test_skip_count=ast_stats.test_skip_count, - swallowed_exception_count=ast_stats.swallowed_exception_count, - type_ignore_count=type_ignores, - dynamic_execution_count=ast_stats.dynamic_execution_count, - single_method_class_count=ast_stats.single_method_class_count, - deep_inheritance_count=ast_stats.deep_inheritance_count, - passthrough_wrapper_count=ast_stats.passthrough_wrapper_count, - sys_path_manipulation_count=ast_stats.sys_path_manipulation_count, - total_loc=total_loc, - code_loc=code_loc, - orphan_comment_files={path_str} if orphan_comments > 0 else set(), - untracked_todo_files={path_str} if untracked_todos > 0 else set(), - inline_import_files={path_str} if ast_stats.inline_import_count > 0 else set(), - dict_get_with_default_files={path_str} if ast_stats.dict_get_with_default_count > 0 else set(), - hasattr_getattr_files={path_str} if ast_stats.hasattr_getattr_count > 0 else set(), - nonempty_init_files={path_str} if nonempty_init > 0 else set(), - test_skip_files={path_str} if ast_stats.test_skip_count > 0 else set(), - swallowed_exception_files={path_str} if ast_stats.swallowed_exception_count > 0 else set(), - type_ignore_files={path_str} if type_ignores > 0 else set(), - dynamic_execution_files={path_str} if ast_stats.dynamic_execution_count > 0 else set(), - single_method_class_files={path_str} if ast_stats.single_method_class_count > 0 else set(), - deep_inheritance_files={path_str} if ast_stats.deep_inheritance_count > 0 else set(), - passthrough_wrapper_files={path_str} if ast_stats.passthrough_wrapper_count > 0 else set(), - sys_path_manipulation_files={path_str} if ast_stats.sys_path_manipulation_count > 0 else set(), - ) - - def _is_nonempty_init(self, file_path: Path, tree: ast.Module) -> bool: - """Check if file is __init__.py with implementation code (beyond imports/__all__). - - Acceptable content in __init__.py: - - Imports (Import, ImportFrom) - - __all__ assignment - - Module docstring - - Pass statements - - Implementation code (flagged as smell): - - Function definitions - - Class definitions - - Other assignments (except __all__) - - Other expressions - """ - if file_path.name != "__init__.py": - return False - - for node in tree.body: - if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant): - if isinstance(node.value.value, str): - continue - - if isinstance(node, ast.Import | ast.ImportFrom): - continue - - if isinstance(node, ast.Pass): - continue - - if isinstance(node, ast.Assign): - if any(isinstance(target, ast.Name) and target.id == "__all__" for target in node.targets): - continue - - return True - - return False - - def _analyze_comments(self, content: str, is_test_file: bool = False) -> tuple[int, int, int]: - """Analyze comments in source code using tokenize. - - Args: - content: Source code content - is_test_file: If True, skip orphan comment detection (tests need explanatory comments) - - Returns: - Tuple of (orphan_comment_count, untracked_todo_count, type_ignore_count) - """ - orphan_comments = 0 - untracked_todos = 0 - type_ignores = 0 - - todo_pattern = re.compile(r"\b(TODO|FIXME|XXX|HACK)\b", re.IGNORECASE) - url_pattern = re.compile(r"https?://") - ticket_pattern = re.compile(r"([A-Z]+-\d+|#\d+)") - justification_pattern = re.compile( - r"#\s*(NOTE|REASON|WARNING|WORKAROUND|IMPORTANT|CAVEAT|HACK|NB|PERF|SAFETY|COMPAT):", - re.IGNORECASE, - ) - type_ignore_pattern = re.compile(r"#\s*type:\s*ignore") - - try: - tokens = tokenize.generate_tokens(io.StringIO(content).readline) - for tok in tokens: - if tok.type == tokenize.COMMENT: - comment_text = tok.string - - is_todo = bool(todo_pattern.search(comment_text)) - has_url = bool(url_pattern.search(comment_text)) - is_justification = bool(justification_pattern.search(comment_text)) - is_type_ignore = bool(type_ignore_pattern.search(comment_text)) - - if is_type_ignore: - type_ignores += 1 - elif is_todo: - has_ticket = bool(ticket_pattern.search(comment_text)) - if not has_ticket and not has_url: - untracked_todos += 1 - elif not has_url and not is_justification and not is_test_file: - orphan_comments += 1 - except tokenize.TokenError as e: - logger.debug(f"Tokenize error during comment analysis: {e}") - - return orphan_comments, untracked_todos, type_ignores - def _merge_stats(self, s1: FeatureStats, s2: FeatureStats) -> FeatureStats: """Merge two stats objects.""" + smell_kwargs: dict[str, Any] = {} + for defn in SMELL_REGISTRY.values(): + smell_kwargs[defn.count_field] = getattr(s1, defn.count_field) + getattr(s2, defn.count_field) + smell_kwargs[defn.files_field] = getattr(s1, defn.files_field) | getattr(s2, defn.files_field) + return FeatureStats( functions_count=s1.functions_count + s2.functions_count, classes_count=s1.classes_count + s2.classes_count, @@ -500,36 +355,9 @@ def _merge_stats(self, s1: FeatureStats, s2: FeatureStats) -> FeatureStats: any_type_count=s1.any_type_count + s2.any_type_count, str_type_count=s1.str_type_count + s2.str_type_count, deprecations_count=s1.deprecations_count + s2.deprecations_count, - orphan_comment_count=s1.orphan_comment_count + s2.orphan_comment_count, - untracked_todo_count=s1.untracked_todo_count + s2.untracked_todo_count, - inline_import_count=s1.inline_import_count + s2.inline_import_count, - dict_get_with_default_count=s1.dict_get_with_default_count + s2.dict_get_with_default_count, - hasattr_getattr_count=s1.hasattr_getattr_count + s2.hasattr_getattr_count, - nonempty_init_count=s1.nonempty_init_count + s2.nonempty_init_count, - test_skip_count=s1.test_skip_count + s2.test_skip_count, - swallowed_exception_count=s1.swallowed_exception_count + s2.swallowed_exception_count, - type_ignore_count=s1.type_ignore_count + s2.type_ignore_count, - dynamic_execution_count=s1.dynamic_execution_count + s2.dynamic_execution_count, - single_method_class_count=s1.single_method_class_count + s2.single_method_class_count, - deep_inheritance_count=s1.deep_inheritance_count + s2.deep_inheritance_count, - passthrough_wrapper_count=s1.passthrough_wrapper_count + s2.passthrough_wrapper_count, - sys_path_manipulation_count=s1.sys_path_manipulation_count + s2.sys_path_manipulation_count, total_loc=s1.total_loc + s2.total_loc, code_loc=s1.code_loc + s2.code_loc, - orphan_comment_files=s1.orphan_comment_files | s2.orphan_comment_files, - untracked_todo_files=s1.untracked_todo_files | s2.untracked_todo_files, - inline_import_files=s1.inline_import_files | s2.inline_import_files, - dict_get_with_default_files=s1.dict_get_with_default_files | s2.dict_get_with_default_files, - hasattr_getattr_files=s1.hasattr_getattr_files | s2.hasattr_getattr_files, - nonempty_init_files=s1.nonempty_init_files | s2.nonempty_init_files, - test_skip_files=s1.test_skip_files | s2.test_skip_files, - swallowed_exception_files=s1.swallowed_exception_files | s2.swallowed_exception_files, - type_ignore_files=s1.type_ignore_files | s2.type_ignore_files, - dynamic_execution_files=s1.dynamic_execution_files | s2.dynamic_execution_files, - single_method_class_files=s1.single_method_class_files | s2.single_method_class_files, - deep_inheritance_files=s1.deep_inheritance_files | s2.deep_inheritance_files, - passthrough_wrapper_files=s1.passthrough_wrapper_files | s2.passthrough_wrapper_files, - sys_path_manipulation_files=s1.sys_path_manipulation_files | s2.sys_path_manipulation_files, + **smell_kwargs, ) @@ -561,6 +389,7 @@ def __init__(self): self.deep_inheritances = 0 self.passthrough_wrappers = 0 self.sys_path_manipulations = 0 + self.relative_imports = 0 @property def stats(self) -> FeatureStats: @@ -586,6 +415,7 @@ def stats(self) -> FeatureStats: deep_inheritance_count=self.deep_inheritances, passthrough_wrapper_count=self.passthrough_wrappers, sys_path_manipulation_count=self.sys_path_manipulations, + relative_import_count=self.relative_imports, ) def _collect_type_names(self, node: ast.AST | None) -> None: @@ -928,12 +758,17 @@ def _is_inert_statement(self, stmt: ast.stmt) -> bool: """Check if a statement has no observable side effects. Inert statements: pass, continue, break, simple assignments, - augmented assignments (+=, etc.), and type annotations. + augmented assignments (+=, etc.), type annotations, and bare + constants (Ellipsis, string literals). """ if isinstance(stmt, ast.Pass | ast.Continue | ast.Break): return True if isinstance(stmt, ast.Assign | ast.AugAssign | ast.AnnAssign): return True + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant): + value = stmt.value.value + if value is ... or isinstance(value, str): + return True return False def visit_Import(self, node: ast.Import) -> None: @@ -943,9 +778,11 @@ def visit_Import(self, node: ast.Import) -> None: self.generic_visit(node) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: - """Track inline imports (not at module level, not in TYPE_CHECKING).""" + """Track inline imports and relative imports.""" if self._scope_depth > 0 and not self._in_type_checking_block: self.inline_imports += 1 + if node.level > 0: + self.relative_imports += 1 self.generic_visit(node) def _is_type_checking_guard(self, node: ast.If) -> bool: diff --git a/src/slopometry/core/working_tree_state.py b/src/slopometry/core/working_tree_state.py index 2afb2f0..8b5a8ea 100644 --- a/src/slopometry/core/working_tree_state.py +++ b/src/slopometry/core/working_tree_state.py @@ -10,6 +10,7 @@ from slopometry.core.git_tracker import GitTracker from slopometry.core.language_config import ( get_combined_git_patterns, + is_source_file, should_ignore_path, ) from slopometry.core.models.hook import ProjectLanguage @@ -113,8 +114,10 @@ def _get_modified_source_files_from_git(self) -> list[Path]: for line in result1.stdout.splitlines() + result2.stdout.splitlines(): if line.strip(): rel_path = line.strip() - # Filter out files in ignored directories (build artifacts, caches) - if not should_ignore_path(rel_path, self.languages): + # Filter out non-source files and ignored directories + if is_source_file(rel_path, self.languages) and not should_ignore_path( + rel_path, self.languages + ): files.add(self.working_directory / rel_path) except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): continue diff --git a/tests/test_complexity_analyzer.py b/tests/test_complexity_analyzer.py index d8e7921..736e913 100644 --- a/tests/test_complexity_analyzer.py +++ b/tests/test_complexity_analyzer.py @@ -169,6 +169,10 @@ def test_analyze_extended_complexity(mock_path): mock_feature_stats.deep_inheritance_files = [] mock_feature_stats.passthrough_wrapper_count = 0 mock_feature_stats.passthrough_wrapper_files = [] + mock_feature_stats.sys_path_manipulation_count = 0 + mock_feature_stats.sys_path_manipulation_files = [] + mock_feature_stats.relative_import_count = 0 + mock_feature_stats.relative_import_files = [] mock_feature_stats.total_loc = 100 mock_feature_stats.code_loc = 80 mock_features.analyze_directory.return_value = mock_feature_stats diff --git a/tests/test_feedback_cache.py b/tests/test_feedback_cache.py index 8e122fb..546faa9 100644 --- a/tests/test_feedback_cache.py +++ b/tests/test_feedback_cache.py @@ -600,3 +600,43 @@ def test_feedback_cache__gitignore_modification_does_not_invalidate(): key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) assert key_before == key_after, ".gitignore modifications should not invalidate cache" + + +def test_feedback_cache__env_file_changes_dont_invalidate(): + """Verify .env file changes don't cause cache invalidation.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + (tmppath / ".env").write_text("SECRET=old") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Modify .env (tracked but non-source) + (tmppath / ".env").write_text("SECRET=new") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key_before == key_after, ".env changes should not invalidate cache" + + +def test_feedback_cache__markdown_changes_dont_invalidate(): + """Verify .md file changes don't cause cache invalidation.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + (tmppath / "README.md").write_text("# Old readme") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Modify markdown (tracked but non-source) + (tmppath / "README.md").write_text("# New readme with changes") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key_before == key_after, ".md changes should not invalidate cache" diff --git a/tests/test_language_config.py b/tests/test_language_config.py index be47366..0386371 100644 --- a/tests/test_language_config.py +++ b/tests/test_language_config.py @@ -12,6 +12,7 @@ get_combined_git_patterns, get_combined_ignore_dirs, get_language_config, + is_source_file, should_ignore_path, ) from slopometry.core.models.hook import ProjectLanguage @@ -144,6 +145,40 @@ def test_should_ignore_path__specific_language(self): assert should_ignore_path("__pycache__/foo.py", [ProjectLanguage.PYTHON]) +class TestIsSourceFile: + """Tests for is_source_file function.""" + + def test_is_source_file__python_file(self): + """Verify .py files are recognized as source.""" + assert is_source_file("src/module.py") + + def test_is_source_file__rust_file(self): + """Verify .rs files are recognized as source.""" + assert is_source_file("src/main.rs") + + def test_is_source_file__env_file_excluded(self): + """Verify .env files are NOT source files.""" + assert not is_source_file(".env") + assert not is_source_file(".env.local") + + def test_is_source_file__markdown_excluded(self): + """Verify .md files are NOT source files.""" + assert not is_source_file("README.md") + assert not is_source_file("docs/guide.md") + + def test_is_source_file__config_files_excluded(self): + """Verify common config files are NOT source files.""" + assert not is_source_file("pyproject.toml") + assert not is_source_file("Cargo.toml") + assert not is_source_file(".gitignore") + assert not is_source_file("uv.lock") + + def test_is_source_file__specific_language(self): + """Verify language-specific filtering works.""" + assert is_source_file("module.py", [ProjectLanguage.PYTHON]) + assert not is_source_file("main.rs", [ProjectLanguage.PYTHON]) + + class TestLanguageConfigFrozen: """Tests for LanguageConfig immutability.""" diff --git a/tests/test_python_feature_analyzer.py b/tests/test_python_feature_analyzer.py index 8400a24..2570bf2 100644 --- a/tests/test_python_feature_analyzer.py +++ b/tests/test_python_feature_analyzer.py @@ -8,7 +8,12 @@ import pytest -from slopometry.core.python_feature_analyzer import FeatureStats, FeatureVisitor, PythonFeatureAnalyzer +from slopometry.core.python_feature_analyzer import ( + FeatureStats, + FeatureVisitor, + PythonFeatureAnalyzer, + _analyze_comments_standalone, +) # Frozen commit for baseline testing against this repository FROZEN_COMMIT = "0b6215b" @@ -362,8 +367,7 @@ def foo(): x = 1 # Another orphan comment return x """ - analyzer = PythonFeatureAnalyzer() - orphan_count, untracked_todos, _ = analyzer._analyze_comments(code) + orphan_count, untracked_todos, _ = _analyze_comments_standalone(code) assert orphan_count == 2 assert untracked_todos == 0 @@ -378,8 +382,7 @@ def foo(): # HACK: workaround for issue pass """ - analyzer = PythonFeatureAnalyzer() - orphan_count, untracked_todos, _ = analyzer._analyze_comments(code) + orphan_count, untracked_todos, _ = _analyze_comments_standalone(code) assert orphan_count == 0 # All are untracked since they have no ticket refs or URLs @@ -393,8 +396,7 @@ def foo(): # Reference: http://python.org/pep-8 pass """ - analyzer = PythonFeatureAnalyzer() - orphan_count, untracked_todos, _ = analyzer._analyze_comments(code) + orphan_count, untracked_todos, _ = _analyze_comments_standalone(code) assert orphan_count == 0 assert untracked_todos == 0 @@ -407,8 +409,7 @@ def foo(): # FIXME: broken pass """ - analyzer = PythonFeatureAnalyzer() - orphan_count, untracked_todos, _ = analyzer._analyze_comments(code) + orphan_count, untracked_todos, _ = _analyze_comments_standalone(code) assert untracked_todos == 2 @@ -420,8 +421,7 @@ def foo(): # FIXME ABC-456: fix the bug pass """ - analyzer = PythonFeatureAnalyzer() - orphan_count, untracked_todos, _ = analyzer._analyze_comments(code) + orphan_count, untracked_todos, _ = _analyze_comments_standalone(code) assert untracked_todos == 0 @@ -433,8 +433,7 @@ def foo(): # FIXME #456 fix the bug pass """ - analyzer = PythonFeatureAnalyzer() - orphan_count, untracked_todos, _ = analyzer._analyze_comments(code) + orphan_count, untracked_todos, _ = _analyze_comments_standalone(code) assert untracked_todos == 0 @@ -446,8 +445,7 @@ def foo(): # FIXME: tracked at http://jira.example.com/PROJ-456 pass """ - analyzer = PythonFeatureAnalyzer() - orphan_count, untracked_todos, _ = analyzer._analyze_comments(code) + orphan_count, untracked_todos, _ = _analyze_comments_standalone(code) assert untracked_todos == 0 @@ -462,8 +460,7 @@ def foo(): # See https://example.com (has URL, not orphan) pass """ - analyzer = PythonFeatureAnalyzer() - orphan_count, untracked_todos, _ = analyzer._analyze_comments(code) + orphan_count, untracked_todos, _ = _analyze_comments_standalone(code) assert orphan_count == 2 # Module comment + "Regular comment" assert untracked_todos == 1 # Only "TODO: untracked todo" @@ -590,6 +587,80 @@ def inner(): assert visitor.inline_imports == 1 +class TestRelativeImportDetection: + """Tests for relative import detection (from . / from .. patterns).""" + + def test_visit_import_from__detects_relative_import(self) -> None: + """from . import utils is a relative import (level=1).""" + code = """ +from . import utils +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.relative_imports == 1 + + def test_visit_import_from__detects_parent_relative_import(self) -> None: + """from .. import config is a relative import (level=2).""" + code = """ +from .. import config +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.relative_imports == 1 + + def test_visit_import_from__detects_deep_relative_import(self) -> None: + """from ...pkg import mod is a relative import (level=3).""" + code = """ +from ...pkg import mod +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.relative_imports == 1 + + def test_visit_import_from__ignores_absolute_import(self) -> None: + """from os.path import join is an absolute import (level=0).""" + code = """ +from os.path import join +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.relative_imports == 0 + + def test_visit_import_from__counts_multiple_relative_imports(self) -> None: + """Multiple relative imports are each counted.""" + code = """ +from . import a +from .. import b +from .sub import c +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.relative_imports == 3 + + def test_visit_import_from__relative_import_inside_function_counts_both(self) -> None: + """A relative import inside a function counts as both relative and inline.""" + code = """ +def foo(): + from . import utils +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.relative_imports == 1 + assert visitor.inline_imports == 1 + + class TestFeatureStatsMergeCodeSmells: """Tests for FeatureStats merging of code smell fields.""" @@ -874,6 +945,107 @@ def test_visit_try__ignores_except_with_self_logger(self) -> None: assert visitor.swallowed_exceptions == 0 + def test_visit_try__detects_ellipsis_in_except(self) -> None: + """except: ... (Ellipsis) is semantically identical to pass and should be flagged.""" + code = """ +try: + risky() +except Exception: + ... +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.swallowed_exceptions == 1 + + def test_visit_try__detects_bare_string_in_except(self) -> None: + """except with only a bare string literal has no side effect.""" + code = """ +try: + risky() +except Exception: + "this error is intentionally ignored" +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.swallowed_exceptions == 1 + + def test_visit_try__detects_ellipsis_with_assignment_in_except(self) -> None: + """except with assignment and ellipsis is all inert.""" + code = """ +try: + risky() +except Exception: + ignored = True + ... +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.swallowed_exceptions == 1 + + def test_visit_try__ignores_except_with_if_statement(self) -> None: + """ast.If is not inert, so except with only an if block is not flagged.""" + code = """ +try: + risky() +except Exception: + if flag: + pass +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.swallowed_exceptions == 0 + + def test_visit_try__ignores_except_with_for_loop(self) -> None: + """ast.For is not inert, so except with a for loop is not flagged.""" + code = """ +try: + risky() +except Exception: + for x in errors: + pass +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.swallowed_exceptions == 0 + + def test_visit_try__ignores_except_with_del(self) -> None: + """ast.Delete is not inert, so except with del is not flagged.""" + code = """ +try: + risky() +except Exception: + del error_ref +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.swallowed_exceptions == 0 + + def test_visit_try__ignores_except_with_warnings_warn(self) -> None: + """warnings.warn() is a function call and should not be flagged.""" + code = """ +try: + risky() +except Exception: + warnings.warn("Something failed") +""" + tree = ast.parse(code) + visitor = FeatureVisitor() + visitor.visit(tree) + + assert visitor.swallowed_exceptions == 0 + class TestTypeIgnoreDetection: """Tests for type: ignore comment detection.""" @@ -884,8 +1056,7 @@ def test_analyze_comments__detects_type_ignore(self) -> None: def foo(x): # type: ignore return x """ - analyzer = PythonFeatureAnalyzer() - _, _, type_ignores = analyzer._analyze_comments(code) + _, _, type_ignores = _analyze_comments_standalone(code) assert type_ignores == 1 @@ -894,8 +1065,7 @@ def test_analyze_comments__detects_type_ignore_with_code(self) -> None: code = """ y = untyped_func() # type: ignore[no-untyped-call] """ - analyzer = PythonFeatureAnalyzer() - _, _, type_ignores = analyzer._analyze_comments(code) + _, _, type_ignores = _analyze_comments_standalone(code) assert type_ignores == 1 @@ -907,8 +1077,7 @@ def foo(x): # type: ignore y = untyped_func() # type: ignore[no-untyped-call] """ - analyzer = PythonFeatureAnalyzer() - _, _, type_ignores = analyzer._analyze_comments(code) + _, _, type_ignores = _analyze_comments_standalone(code) assert type_ignores == 2 diff --git a/tests/test_smell_registry.py b/tests/test_smell_registry.py index 6773d9a..2d2fd26 100644 --- a/tests/test_smell_registry.py +++ b/tests/test_smell_registry.py @@ -15,7 +15,7 @@ class TestSmellRegistry: """Tests for SMELL_REGISTRY completeness and consistency.""" - def test_smell_registry__has_all_14_smells(self) -> None: + def test_smell_registry__has_all_15_smells(self) -> None: """Verify all expected smells are in the registry.""" expected_smells = { "orphan_comment", @@ -33,6 +33,7 @@ def test_smell_registry__has_all_14_smells(self) -> None: "deep_inheritance", "passthrough_wrapper", "sys_path_manipulation", + "relative_import", } assert set(SMELL_REGISTRY.keys()) == expected_smells @@ -72,6 +73,7 @@ def test_smell_registry__python_category_smells(self) -> None: "deep_inheritance", "passthrough_wrapper", "sys_path_manipulation", + "relative_import", } for name in python_smells: assert SMELL_REGISTRY[name].category == SmellCategory.PYTHON @@ -98,7 +100,7 @@ def test_get_smells_by_category__returns_general_smells(self) -> None: def test_get_smells_by_category__returns_python_smells(self) -> None: """Verify get_smells_by_category returns all PYTHON smells.""" python = get_smells_by_category(SmellCategory.PYTHON) - assert len(python) == 8 # 4 original + 3 abstraction smells + sys_path_manipulation + assert len(python) == 9 # 4 original + 3 abstraction smells + sys_path_manipulation + relative_import assert all(d.category == SmellCategory.PYTHON for d in python) def test_get_smells_by_category__sorted_by_weight_descending(self) -> None: @@ -173,7 +175,7 @@ def metrics_with_smells(self) -> ExtendedComplexityMetrics: def test_get_smells__returns_all_smell_data(self, metrics_with_smells: ExtendedComplexityMetrics) -> None: """Verify get_smells returns SmellData for all smells.""" smells = metrics_with_smells.get_smells() - assert len(smells) == 14 # 10 original + 3 abstraction smells + sys_path_manipulation + assert len(smells) == 15 # 10 original + 3 abstraction smells + sys_path_manipulation + relative_import assert all(isinstance(s, SmellData) for s in smells) def test_get_smells__includes_correct_counts(self, metrics_with_smells: ExtendedComplexityMetrics) -> None: @@ -223,7 +225,7 @@ def test_get_smell_changes__returns_all_smell_changes(self) -> None: test_skip_change=0, ) changes = delta.get_smell_changes() - assert len(changes) == 14 # 10 original + 3 abstraction smells + sys_path_manipulation + assert len(changes) == 15 # 10 original + 3 abstraction smells + sys_path_manipulation + relative_import assert changes["orphan_comment"] == 2 assert changes["swallowed_exception"] == -1 assert changes["test_skip"] == 0 diff --git a/uv.lock b/uv.lock index d0e5ab5..722872f 100644 --- a/uv.lock +++ b/uv.lock @@ -2749,7 +2749,7 @@ wheels = [ [[package]] name = "slopometry" -version = "2026.2.24.1" +version = "2026.3.4" source = { editable = "." } dependencies = [ { name = "click" }, From fb5007c60229363a81a3dfaa8db1d068d77a70d8 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Wed, 11 Mar 2026 08:52:15 +0200 Subject: [PATCH 2/2] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 187ea0e..640c1d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "slopometry" -version = "2026.3.4" +version = "2026.3.11" description = "Opinionated code quality metrics for code agents and humans" readme = "README.md" requires-python = ">=3.13"