Skip to content

Commit 585fd4c

Browse files
Merge pull request #47 from TensorTemplar/dry-smell-handling
Dry smell handling
2 parents 22b9246 + fb5007c commit 585fd4c

14 files changed

+405
-407
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "slopometry"
7-
version = "2026.3.4"
7+
version = "2026.3.11"
88
description = "Opinionated code quality metrics for code agents and humans"
99
readme = "README.md"
1010
requires-python = ">=3.13"

src/slopometry/core/complexity_analyzer.py

Lines changed: 20 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import time
66
from concurrent.futures import ProcessPoolExecutor, as_completed
77
from pathlib import Path
8+
from typing import Any
89

910
from slopometry.core.code_analyzer import CodeAnalyzer, _analyze_single_file
1011
from slopometry.core.models.complexity import (
@@ -13,6 +14,7 @@
1314
ExtendedComplexityMetrics,
1415
FileAnalysisResult,
1516
)
17+
from slopometry.core.models.smell import SMELL_REGISTRY
1618
from slopometry.core.python_feature_analyzer import PythonFeatureAnalyzer, _count_loc
1719
from slopometry.core.settings import settings
1820

@@ -214,33 +216,14 @@ def _calculate_delta(
214216
current_metrics.str_type_percentage - baseline_metrics.str_type_percentage
215217
)
216218

217-
delta.orphan_comment_change = current_metrics.orphan_comment_count - baseline_metrics.orphan_comment_count
218-
delta.untracked_todo_change = current_metrics.untracked_todo_count - baseline_metrics.untracked_todo_count
219-
delta.inline_import_change = current_metrics.inline_import_count - baseline_metrics.inline_import_count
220-
delta.dict_get_with_default_change = (
221-
current_metrics.dict_get_with_default_count - baseline_metrics.dict_get_with_default_count
222-
)
223-
delta.hasattr_getattr_change = (
224-
current_metrics.hasattr_getattr_count - baseline_metrics.hasattr_getattr_count
225-
)
226-
delta.nonempty_init_change = current_metrics.nonempty_init_count - baseline_metrics.nonempty_init_count
227-
delta.test_skip_change = current_metrics.test_skip_count - baseline_metrics.test_skip_count
228-
delta.swallowed_exception_change = (
229-
current_metrics.swallowed_exception_count - baseline_metrics.swallowed_exception_count
230-
)
231-
delta.type_ignore_change = current_metrics.type_ignore_count - baseline_metrics.type_ignore_count
232-
delta.dynamic_execution_change = (
233-
current_metrics.dynamic_execution_count - baseline_metrics.dynamic_execution_count
234-
)
235-
delta.single_method_class_change = (
236-
current_metrics.single_method_class_count - baseline_metrics.single_method_class_count
237-
)
238-
delta.deep_inheritance_change = (
239-
current_metrics.deep_inheritance_count - baseline_metrics.deep_inheritance_count
240-
)
241-
delta.passthrough_wrapper_change = (
242-
current_metrics.passthrough_wrapper_count - baseline_metrics.passthrough_wrapper_count
243-
)
219+
for name in SMELL_REGISTRY:
220+
count_field = f"{name}_count"
221+
change_field = f"{name}_change"
222+
setattr(
223+
delta,
224+
change_field,
225+
getattr(current_metrics, count_field) - getattr(baseline_metrics, count_field),
226+
)
244227

245228
return delta
246229

@@ -261,7 +244,8 @@ def _build_files_by_loc(self, python_files: list[Path], target_dir: Path) -> dic
261244
_, code_loc = _count_loc(content)
262245
relative_path = self._get_relative_path(file_path, target_dir)
263246
files_by_loc[relative_path] = code_loc
264-
except (OSError, UnicodeDecodeError):
247+
except (OSError, UnicodeDecodeError) as e:
248+
logger.warning(f"Skipping unreadable file {file_path}: {e}")
265249
continue
266250
return files_by_loc
267251

@@ -433,6 +417,13 @@ def analyze_extended_complexity(self, directory: Path | None = None) -> Extended
433417
any_type_percentage = (feature_stats.any_type_count / total_type_refs * 100.0) if total_type_refs > 0 else 0.0
434418
str_type_percentage = (feature_stats.str_type_count / total_type_refs * 100.0) if total_type_refs > 0 else 0.0
435419

420+
smell_kwargs: dict[str, Any] = {}
421+
for defn in SMELL_REGISTRY.values():
422+
smell_kwargs[defn.count_field] = getattr(feature_stats, defn.count_field)
423+
smell_kwargs[defn.files_field] = sorted(
424+
[self._get_relative_path(p, target_dir) for p in getattr(feature_stats, defn.files_field)]
425+
)
426+
436427
return ExtendedComplexityMetrics(
437428
total_complexity=total_complexity,
438429
average_complexity=average_complexity,
@@ -461,58 +452,11 @@ def analyze_extended_complexity(self, directory: Path | None = None) -> Extended
461452
files_by_complexity=files_by_complexity,
462453
files_by_effort=files_by_effort,
463454
files_with_parse_errors=files_with_parse_errors,
464-
orphan_comment_count=feature_stats.orphan_comment_count,
465-
untracked_todo_count=feature_stats.untracked_todo_count,
466-
inline_import_count=feature_stats.inline_import_count,
467-
dict_get_with_default_count=feature_stats.dict_get_with_default_count,
468-
hasattr_getattr_count=feature_stats.hasattr_getattr_count,
469-
nonempty_init_count=feature_stats.nonempty_init_count,
470-
test_skip_count=feature_stats.test_skip_count,
471-
swallowed_exception_count=feature_stats.swallowed_exception_count,
472-
type_ignore_count=feature_stats.type_ignore_count,
473-
dynamic_execution_count=feature_stats.dynamic_execution_count,
474-
orphan_comment_files=sorted(
475-
[self._get_relative_path(p, target_dir) for p in feature_stats.orphan_comment_files]
476-
),
477-
untracked_todo_files=sorted(
478-
[self._get_relative_path(p, target_dir) for p in feature_stats.untracked_todo_files]
479-
),
480-
inline_import_files=sorted(
481-
[self._get_relative_path(p, target_dir) for p in feature_stats.inline_import_files]
482-
),
483-
dict_get_with_default_files=sorted(
484-
[self._get_relative_path(p, target_dir) for p in feature_stats.dict_get_with_default_files]
485-
),
486-
hasattr_getattr_files=sorted(
487-
[self._get_relative_path(p, target_dir) for p in feature_stats.hasattr_getattr_files]
488-
),
489-
nonempty_init_files=sorted(
490-
[self._get_relative_path(p, target_dir) for p in feature_stats.nonempty_init_files]
491-
),
492-
test_skip_files=sorted([self._get_relative_path(p, target_dir) for p in feature_stats.test_skip_files]),
493-
swallowed_exception_files=sorted(
494-
[self._get_relative_path(p, target_dir) for p in feature_stats.swallowed_exception_files]
495-
),
496-
type_ignore_files=sorted([self._get_relative_path(p, target_dir) for p in feature_stats.type_ignore_files]),
497-
dynamic_execution_files=sorted(
498-
[self._get_relative_path(p, target_dir) for p in feature_stats.dynamic_execution_files]
499-
),
500-
single_method_class_count=feature_stats.single_method_class_count,
501-
deep_inheritance_count=feature_stats.deep_inheritance_count,
502-
passthrough_wrapper_count=feature_stats.passthrough_wrapper_count,
503-
single_method_class_files=sorted(
504-
[self._get_relative_path(p, target_dir) for p in feature_stats.single_method_class_files]
505-
),
506-
deep_inheritance_files=sorted(
507-
[self._get_relative_path(p, target_dir) for p in feature_stats.deep_inheritance_files]
508-
),
509-
passthrough_wrapper_files=sorted(
510-
[self._get_relative_path(p, target_dir) for p in feature_stats.passthrough_wrapper_files]
511-
),
512455
total_loc=feature_stats.total_loc,
513456
code_loc=feature_stats.code_loc,
514457
files_by_loc={
515458
self._get_relative_path(p, target_dir): loc
516459
for p, loc in self._build_files_by_loc(python_files, target_dir).items()
517460
},
461+
**smell_kwargs,
518462
)

src/slopometry/core/hook_handler.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -367,15 +367,25 @@ def handle_stop_event(session_id: str, parsed_input: "StopInput | SubagentStopIn
367367
logger.debug(f"Failed to get modified source files: {e}")
368368
edited_files = set()
369369

370-
# Smell feedback is stable (based on code state, not session activity)
370+
# Smell feedback: split into code-based (stable) and context-derived (unstable)
371+
# Context-derived smells (e.g., unread_related_tests) change with every transcript
372+
# read and must NOT be included in the cache hash to avoid repeated triggers
371373
if current_metrics:
372374
scoped_smells = scope_smells_for_session(
373375
current_metrics, delta, edited_files, stats.working_directory, stats.context_coverage
374376
)
375-
smell_feedback, has_smells, _ = format_code_smell_feedback(scoped_smells, session_id, stats.working_directory)
376-
if has_smells:
377-
feedback_parts.append(smell_feedback)
378-
cache_stable_parts.append(smell_feedback)
377+
378+
code_smells = [s for s in scoped_smells if s.name != "unread_related_tests"]
379+
context_smells = [s for s in scoped_smells if s.name == "unread_related_tests"]
380+
381+
code_feedback, has_code_smells, _ = format_code_smell_feedback(code_smells, session_id)
382+
if has_code_smells:
383+
feedback_parts.append(code_feedback)
384+
cache_stable_parts.append(code_feedback)
385+
386+
context_smell_feedback, has_context_smells, _ = format_code_smell_feedback(context_smells, session_id)
387+
if has_context_smells:
388+
feedback_parts.append(context_smell_feedback)
379389

380390
# Context coverage - informational but NOT stable (changes with every Read/Glob/Grep)
381391
# Excluded from cache hash to avoid invalidation on tool calls
@@ -656,14 +666,12 @@ def scope_smells_for_session(
656666
def format_code_smell_feedback(
657667
scoped_smells: list[ScopedSmell],
658668
session_id: str | None = None,
659-
working_directory: str | None = None,
660669
) -> tuple[str, bool, bool]:
661670
"""Format pre-classified smell data into feedback output.
662671
663672
Args:
664673
scoped_smells: Pre-classified smells from scope_smells_for_session
665674
session_id: Session ID for generating the smell-details command
666-
working_directory: Path to working directory (unused, kept for caller compatibility)
667675
668676
Returns:
669677
Tuple of (formatted feedback string, has_smells, has_blocking_smells)

src/slopometry/core/language_config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,24 @@ def get_combined_ignore_dirs(languages: list[ProjectLanguage] | None = None) ->
168168
return ignore_dirs
169169

170170

171+
def is_source_file(file_path: Path | str, languages: list[ProjectLanguage] | None = None) -> bool:
172+
"""Check if a file path matches a supported source extension.
173+
174+
Args:
175+
file_path: Path to check
176+
languages: List of languages to match against, or None for all supported
177+
178+
Returns:
179+
True if the file has a recognized source extension
180+
"""
181+
if languages is None:
182+
configs = get_all_supported_configs()
183+
else:
184+
configs = [get_language_config(lang) for lang in languages]
185+
186+
return any(config.matches_extension(file_path) for config in configs)
187+
188+
171189
def should_ignore_path(file_path: Path | str, languages: list[ProjectLanguage] | None = None) -> bool:
172190
"""Check if a file path should be ignored based on language configs.
173191

src/slopometry/core/models/core.py

Lines changed: 16 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class SmellCounts(BaseModel):
5555
deep_inheritance: int = 0
5656
passthrough_wrapper: int = 0
5757
sys_path_manipulation: int = 0
58+
relative_import: int = 0
5859

5960

6061
class ComplexityMetrics(BaseModel):
@@ -131,25 +132,13 @@ class ComplexityDelta(BaseModel):
131132
deep_inheritance_change: int = 0
132133
passthrough_wrapper_change: int = 0
133134
sys_path_manipulation_change: int = 0
135+
relative_import_change: int = 0
134136

135137
def get_smell_changes(self) -> dict[str, int]:
136138
"""Return smell name to change value mapping for direct access."""
137-
return {
138-
"orphan_comment": self.orphan_comment_change,
139-
"untracked_todo": self.untracked_todo_change,
140-
"inline_import": self.inline_import_change,
141-
"dict_get_with_default": self.dict_get_with_default_change,
142-
"hasattr_getattr": self.hasattr_getattr_change,
143-
"nonempty_init": self.nonempty_init_change,
144-
"test_skip": self.test_skip_change,
145-
"swallowed_exception": self.swallowed_exception_change,
146-
"type_ignore": self.type_ignore_change,
147-
"dynamic_execution": self.dynamic_execution_change,
148-
"single_method_class": self.single_method_class_change,
149-
"deep_inheritance": self.deep_inheritance_change,
150-
"passthrough_wrapper": self.passthrough_wrapper_change,
151-
"sys_path_manipulation": self.sys_path_manipulation_change,
152-
}
139+
from slopometry.core.models.smell import SMELL_REGISTRY
140+
141+
return {name: getattr(self, f"{name}_change") for name in SMELL_REGISTRY}
153142

154143

155144
class ExtendedComplexityMetrics(ComplexityMetrics):
@@ -246,6 +235,10 @@ class ExtendedComplexityMetrics(ComplexityMetrics):
246235
default=0,
247236
description="sys.path mutations bypass the package system — restructure package boundaries and use absolute imports from installed packages instead",
248237
)
238+
relative_import_count: int = Field(
239+
default=0,
240+
description="Prefer absolute imports for clarity and refactor-safety; relative imports create implicit coupling to package structure",
241+
)
249242

250243
# LOC metrics (for file filtering in QPE)
251244
total_loc: int = Field(default=0, description="Total lines of code across all files")
@@ -270,87 +263,23 @@ class ExtendedComplexityMetrics(ComplexityMetrics):
270263
)
271264
passthrough_wrapper_files: list[str] = Field(default_factory=list, description="Files with pass-through wrappers")
272265
sys_path_manipulation_files: list[str] = Field(default_factory=list, description="Files with sys.path mutations")
266+
relative_import_files: list[str] = Field(default_factory=list, description="Files with relative imports")
273267

274268
def get_smell_counts(self) -> SmellCounts:
275269
"""Return smell counts as a typed model for QPE and display."""
276270
return SmellCounts(**{smell.name: smell.count for smell in self.get_smells()})
277271

278272
def get_smells(self) -> list["SmellData"]:
279273
"""Return all smell data as structured objects with direct field access."""
280-
# Import here to avoid circular imports at runtime
281-
from slopometry.core.models.smell import SmellData
274+
from slopometry.core.models.smell import SMELL_REGISTRY, SmellData
282275

283276
return [
284277
SmellData(
285-
name="orphan_comment",
286-
count=self.orphan_comment_count,
287-
files=self.orphan_comment_files,
288-
),
289-
SmellData(
290-
name="untracked_todo",
291-
count=self.untracked_todo_count,
292-
files=self.untracked_todo_files,
293-
),
294-
SmellData(
295-
name="swallowed_exception",
296-
count=self.swallowed_exception_count,
297-
files=self.swallowed_exception_files,
298-
),
299-
SmellData(
300-
name="test_skip",
301-
count=self.test_skip_count,
302-
files=self.test_skip_files,
303-
),
304-
SmellData(
305-
name="type_ignore",
306-
count=self.type_ignore_count,
307-
files=self.type_ignore_files,
308-
),
309-
SmellData(
310-
name="dynamic_execution",
311-
count=self.dynamic_execution_count,
312-
files=self.dynamic_execution_files,
313-
),
314-
SmellData(
315-
name="inline_import",
316-
count=self.inline_import_count,
317-
files=self.inline_import_files,
318-
),
319-
SmellData(
320-
name="dict_get_with_default",
321-
count=self.dict_get_with_default_count,
322-
files=self.dict_get_with_default_files,
323-
),
324-
SmellData(
325-
name="hasattr_getattr",
326-
count=self.hasattr_getattr_count,
327-
files=self.hasattr_getattr_files,
328-
),
329-
SmellData(
330-
name="nonempty_init",
331-
count=self.nonempty_init_count,
332-
files=self.nonempty_init_files,
333-
),
334-
SmellData(
335-
name="single_method_class",
336-
count=self.single_method_class_count,
337-
files=self.single_method_class_files,
338-
),
339-
SmellData(
340-
name="deep_inheritance",
341-
count=self.deep_inheritance_count,
342-
files=self.deep_inheritance_files,
343-
),
344-
SmellData(
345-
name="passthrough_wrapper",
346-
count=self.passthrough_wrapper_count,
347-
files=self.passthrough_wrapper_files,
348-
),
349-
SmellData(
350-
name="sys_path_manipulation",
351-
count=self.sys_path_manipulation_count,
352-
files=self.sys_path_manipulation_files,
353-
),
278+
name=defn.internal_name,
279+
count=getattr(self, defn.count_field),
280+
files=getattr(self, defn.files_field),
281+
)
282+
for defn in SMELL_REGISTRY.values()
354283
]
355284

356285
def get_smell_files(self) -> dict[str, list[str]]:

src/slopometry/core/models/smell.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ class SmellDefinition(BaseModel):
156156
count_field="sys_path_manipulation_count",
157157
files_field="sys_path_manipulation_files",
158158
),
159+
"relative_import": SmellDefinition(
160+
internal_name="relative_import",
161+
label="Relative Imports",
162+
category=SmellCategory.PYTHON,
163+
weight=0.03,
164+
guidance="Prefer absolute imports for clarity and refactor-safety; relative imports create implicit coupling to package structure",
165+
count_field="relative_import_count",
166+
files_field="relative_import_files",
167+
),
159168
}
160169

161170

0 commit comments

Comments
 (0)