diff --git a/.coverage b/.coverage index 2a51b1e..9ed4c42 100644 Binary files a/.coverage and b/.coverage differ diff --git a/README.md b/README.md index 77c93d4..a6aeca0 100644 --- a/README.md +++ b/README.md @@ -5,29 +5,6 @@ A tool that lurks in the shadows, tracks and analyzes Claude Code sessions provi ![slopometry-logo](assets/slopometry-logo.jpg) -# Customer testimonials - -### Claude Sonnet 4 -![claude sonnet feedback](assets/stop_hook_example.png) -![claude sonnet actioning](assets/stop_hook_action.png) -*"Amazing tool for tracking my own blind spots!"* -— C. Sonnet, main-author - -### Claude Opus -![opus feedback](assets/opus.png) -*"Finally, I can see when I'm overcomplicating things."* -— C. Opus, overpaid, infrequent contributor who insists on having its name in commit history - -### TensorTemplar -*"Previously I had to READ CODE and DECIDE WHEN TO RUN SLASH COMMANDS MYSELF, but now I just periodically prompt 'Cmon, claude, you know what you did...'"* -— TensorTemplar, insignificant idea person for this tool - -### sherbie -*"Let's slop up all the things."* -— sherbie, opinionated SDET - -# Features / FAQ - **NEWS:** **December 2025: for microsoft employees we now support the Galen metric (Python only for now).** @@ -40,22 +17,25 @@ Please stop contacting us with your cries for mercy - this is between you and yo ![galen details](assets/galen_details.png) +# Features / FAQ ### Q: How do i know if claude is lazy today? -A: Eyeball progress based on overall session-vibes +A: Eyeball progress based on overall session-vibes, plan items, todos, how many tokens read/edited etc. ```bash slopometry latest ``` -
- -Will show some metrics since the session start of the newest `claude code` session ![session statistics](assets/session-stat.png) +Worst offenders and overall slop at a glance + ![complexity metrics (CC)](assets/cc.png) - + +**See more examples and FAQ in details below**: +
+ ### Q: I don't need to verify when my tests are passing, right? A: lmao @@ -65,7 +45,7 @@ What clevery ways you ask? Silent exception swallowing upstream ofc! Slopometry forces agents to state the purpose of swallowed exceptions and skipped tests, this is a simple LLM-as-judge call for your RL pipeline (you're welcome) -Here is Opus 4.5, which is writing 90% of your production code by 2026: +Here is Opus 4.5, which is writing 90% of your production code by 2026: ![silent-errors](assets/force-review-silent-errors.png) ![silent-errors2](assets/force-review-silent-errors-2.png) @@ -74,12 +54,14 @@ Don't worry, your customers probably don't read their code either, and their age ### Q: I am a junior and all my colleagues were replaced with AI before I learned good code taste, is this fine? -A: Here are some dumb practices agents love to add, that you should never show to anyone who cares about readable and predictable code: +A: Here are some dumb practices agents love to add, that would typically require justification or should be the exception, not the norm: ![code_smells1](assets/code_smells1.png) ![code_smells2](assets/code_smells2.png) +![code_smells3](assets/unnecessary-abstractions.png) + ### Q: I have been vibe-coding this codebase for a while now and learned prooompt engineering. Clearly the code is better now? A: You're absolutely right (But we verify via code trends for the last ~100 commits anyway): @@ -93,15 +75,18 @@ A: Haha, sure, maybe try a trillion dollar vscode fork, or a simple AST parser t ![blind_spots](assets/blind_spots.png) ### Q: My boss is llm-pilled and asks me to report my progress every 5 minutes, but human rights forbid keylogging in my country, what do I do? -A: export your claude code transcripts and commit them into the codebase! +A: export your claude code transcripts, including plan and todos, and commit them into the codebase! ![evidence](assets/evidence.png) **legal disclaimer**: transcripts are totally not for any kind of distillation, but merely for personal entertainment purposes -And many more undocumented features. Which worked just fine on my machine at some point! +### Q: Are these all the features, how is the RL meme even related? + +A: There are advanced features for temporal and cross-project measurement of slop, but these require reading, thinking and being an adult.
+ # Limitations **Runtime**: Almost all metrics are trend-relative and the first run will do a long code analysis before caching, but if you consider using this tool, you are comfortable with waiting for agents anyway. diff --git a/assets/cc.png b/assets/cc.png index f690dfd..8edb902 100644 Binary files a/assets/cc.png and b/assets/cc.png differ diff --git a/assets/session-stat.png b/assets/session-stat.png index 65b7967..7b91615 100644 Binary files a/assets/session-stat.png and b/assets/session-stat.png differ diff --git a/assets/unnecessary-abstractions.png b/assets/unnecessary-abstractions.png new file mode 100644 index 0000000..9fbd237 Binary files /dev/null and b/assets/unnecessary-abstractions.png differ diff --git a/coverage.xml b/coverage.xml index 02e755e..df360ac 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -16,7 +16,7 @@ - + @@ -232,7 +232,7 @@ - + @@ -375,79 +375,76 @@ - - - + - + + - - + + - + + - - - + - + + + - - - - - - - - + + + + - - + + + - - - - - + + + + + + - - + + - - - - + + + + - + - - - - - + + + + - - - - - - - + + + + + + + + - - + + - - + @@ -456,52 +453,59 @@ - + + - - - - + + + + - - - - + + + + - + - + - + + - - + - + + - + - + + - + + + + + - + @@ -671,7 +675,7 @@ - + @@ -688,7 +692,7 @@ - + @@ -743,32 +747,38 @@ - + - + - + - + - + - + - + - - + + + + + + + + @@ -1545,7 +1555,7 @@ - + @@ -1743,59 +1753,56 @@ - - - - - - - - - - - + + + + + + + + + + - - - + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - + - + @@ -2341,7 +2348,7 @@ - + @@ -2479,23 +2486,23 @@ - + - - + + - - + + - + - + @@ -2508,46 +2515,46 @@ - - - - + + + + - - + + - - + + - - + + - + - - - - - + + + + + - - + + - + - - + + @@ -2558,11 +2565,11 @@ - + - - + + @@ -2570,8 +2577,8 @@ - - + + @@ -2580,8 +2587,8 @@ - - + + @@ -2601,58 +2608,58 @@ - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - + + + + - - - + + + - - + + - - + + @@ -2661,67 +2668,67 @@ - + - - + + - - - - - - - - + + + + + + + + - + - - + + - - + + - - + + - - - + + + - + + - - + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - + + @@ -2732,16 +2739,16 @@ - + + - + - - - + + @@ -2750,170 +2757,170 @@ - - - + + + - - - + + + - - - - - + + + + + - - - + + + - - - + + + - - - + + + - - - + + + + - - - - - + + + + + - + - + - + - + - - - + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - + + + + - - - + + + - - - - - + + + + + - - + + + - - - + + + - + - - - + + + - + - - - - - - - + + + + + + + - - - + + @@ -2921,27 +2928,28 @@ + - + - - - + + + - + - + - + @@ -2949,27 +2957,26 @@ - - - - + + + + - - - + + - - - + + + + - - - + + @@ -2981,88 +2988,90 @@ - - - + + + + - + - - - - + + + + - - - - + + + + - + - + - + - - - + + + - + - - - - + + + + - - - - - - + + + + + - - - + + + - + + - + - - + + - + - + - + - + - - - - - + + + + - - + + + - + + @@ -4136,7 +4145,7 @@ - + @@ -4145,131 +4154,131 @@ - + - - - - - - + + + + + + - + - + - + - - - + + + - + - - - - - + + + + + - + - + - + - + - - - - + + + + - + - + - + - + - + - + - - - - - + + + + + - + - + - + - + - + - - - - + + + + - + - + - + - + - + - + - + - + - + - - + + - + - - - + + + - + - - - + + + - + @@ -4277,43 +4286,43 @@ - + - - - + + + - + - + - + - + - - - + + + - + - + - + - - - + + + - + - + @@ -4325,57 +4334,65 @@ + - + + - + - - - + + + + + - - - - + - + + - + - - + - + + - - + + - - + + - + + + + + + + @@ -4386,312 +4403,307 @@ - - - - - + + - + + + + - + + + + - + - + + - - - - - - - - - - - + - + - - - + - + + + + + + + + + - + + + - - - - + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - + - - - - - - + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - + + + + + + - - - - + + + + + + - - - + - - - - - - - + + - + + + - + + + + - - - + + - - + - - - + + + + + + + + + - - - - - - + + + + + + - - - - - - + + + - + - - - + + + + - - - - - - + + + + + - - - - + + + + - + - - - - - - + + + + + + + + + + + + - - - - - - - + + + - + + + + + - + + + - - - - - - - - - - - - + + + - - - + + + + + + + + + - - - - - - - - - - - - - - + + + - @@ -4702,205 +4714,224 @@ - + + + + + + + + + + + + - - - - - - - + + + - + + - - - - + - - + - - - + + + + - + + + + - + - - - - - - + - - - - + + + - - - + + + + + + + + + + + - - + - - - - - - - - + - + - - - - + + + + + + + + + + + + - - - - - - - + + - - + + + + + + + - + + + + - - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + + + - - - - + + + + - - - - - - - + + - - - + - - - - - - + + + + + + + - - - - - - - + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + - + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index d7c6406..b6d2c51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "slopometry" -version = "20260121-1" +version = "20260121-2" description = "Opinionated code quality metrics for code agents and humans" readme = "README.md" requires-python = ">=3.13" diff --git a/src/slopometry/core/complexity_analyzer.py b/src/slopometry/core/complexity_analyzer.py index 6a92b83..3a9947a 100644 --- a/src/slopometry/core/complexity_analyzer.py +++ b/src/slopometry/core/complexity_analyzer.py @@ -313,6 +313,12 @@ def _calculate_delta( if isinstance(current_metrics, ExtendedComplexityMetrics) and isinstance( baseline_metrics, ExtendedComplexityMetrics ): + common_effort_files = set(baseline_metrics.files_by_effort.keys()) & set(current_metrics.files_by_effort.keys()) + delta.files_effort_changed = { + file_path: current_metrics.files_by_effort[file_path] - baseline_metrics.files_by_effort[file_path] + for file_path in common_effort_files + } + delta.avg_effort_change = current_metrics.average_effort - baseline_metrics.average_effort delta.total_effort_change = current_metrics.total_effort - baseline_metrics.total_effort delta.avg_mi_change = current_metrics.average_mi - baseline_metrics.average_mi @@ -460,6 +466,7 @@ def analyze_extended_complexity(self, directory: Path | None = None) -> Extended files_by_complexity: dict[str, int] = {} files_by_effort: dict[str, float] = {} + files_by_mi: dict[str, float] = {} all_complexities: list[int] = [] files_with_parse_errors: dict[str, str] = {} files_by_token_count: dict[str, int] = {} @@ -492,6 +499,7 @@ def analyze_extended_complexity(self, directory: Path | None = None) -> Extended hal_file_count += 1 total_mi += result.mi + files_by_mi[relative_path] = result.mi mi_file_count += 1 files_by_token_count[relative_path] = result.tokens @@ -548,6 +556,7 @@ def analyze_extended_complexity(self, directory: Path | None = None) -> Extended average_difficulty=average_difficulty, total_mi=total_mi, average_mi=average_mi, + files_by_mi=files_by_mi, total_tokens=total_tokens, average_tokens=average_tokens, max_tokens=max_tokens, diff --git a/src/slopometry/core/coverage_analyzer.py b/src/slopometry/core/coverage_analyzer.py index a5ef2e9..342076b 100644 --- a/src/slopometry/core/coverage_analyzer.py +++ b/src/slopometry/core/coverage_analyzer.py @@ -119,6 +119,7 @@ def _parse_coverage_db(self, db_path: Path) -> CoverageResult: Returns: CoverageResult with parsed data """ + cov = None try: cov = Coverage(data_file=str(db_path)) cov.load() @@ -164,3 +165,9 @@ def _parse_coverage_db(self, db_path: Path) -> CoverageResult: coverage_available=False, error_message=f"Error reading .coverage: {e}", ) + finally: + if cov is not None: + try: + cov._data.close() + except (AttributeError, TypeError): + pass diff --git a/src/slopometry/core/hook_handler.py b/src/slopometry/core/hook_handler.py index 92cad87..2f5f12a 100644 --- a/src/slopometry/core/hook_handler.py +++ b/src/slopometry/core/hook_handler.py @@ -384,7 +384,7 @@ def handle_stop_event(session_id: str, parsed_input: "StopInput | SubagentStopIn # This is stable (based on code state, not session activity) if current_metrics: smell_feedback, has_smells, _ = format_code_smell_feedback( - current_metrics, delta, edited_files, session_id, stats.working_directory + current_metrics, delta, edited_files, session_id, stats.working_directory, stats.context_coverage ) if has_smells: feedback_parts.append(smell_feedback) @@ -467,20 +467,6 @@ def format_context_coverage_feedback(coverage: ContextCoverage) -> str: if len(coverage.blind_spots) > 5: lines.append(f" ... and {len(coverage.blind_spots) - 5} more") - unread_tests: list[str] = [] - for file_cov in coverage.file_coverage: - for test_file in file_cov.test_files: - if test_file not in file_cov.test_files_read and test_file not in unread_tests: - unread_tests.append(test_file) - - if unread_tests: - lines.append("") - lines.append("**RELATED Tests - MUST REVIEW**: These tests correspond to files you edited:") - for test_file in unread_tests[:5]: - lines.append(f" • {truncate_path(test_file, max_width=65)}") - if len(unread_tests) > 5: - lines.append(f" ... and {len(unread_tests) - 5} more") - return "\n".join(lines) @@ -584,6 +570,7 @@ def format_code_smell_feedback( edited_files: set[str] | None = None, session_id: str | None = None, working_directory: str | None = None, + context_coverage: "ContextCoverage | None" = None, ) -> tuple[str, bool, bool]: """Format code smell feedback using get_smells() for direct field access. @@ -593,6 +580,7 @@ def format_code_smell_feedback( edited_files: Set of files edited in this session (for blocking smell filtering) session_id: Session ID for generating the smell-details command working_directory: Path to working directory for import graph analysis + context_coverage: Optional context coverage for detecting unread related tests Returns: Tuple of (formatted feedback string, has_smells, has_blocking_smells) @@ -609,6 +597,17 @@ def format_code_smell_feedback( related_via_imports = _get_related_files_via_imports(edited_files, working_directory) blocking_smells: list[tuple[str, int, int, str, list[str]]] = [] + + if context_coverage: + unread_tests: list[str] = [] + for file_cov in context_coverage.file_coverage: + for test_file in file_cov.test_files: + if test_file not in file_cov.test_files_read and test_file not in unread_tests: + unread_tests.append(test_file) + if unread_tests: + guidance = "BLOCKING: You MUST review these tests to ensure changes are accounted for and necessary coverage is added for new functionality" + blocking_smells.append(("Unread Related Tests", len(unread_tests), 0, guidance, unread_tests)) + other_smells: list[tuple[str, int, int, list[str], str]] = [] smell_changes = delta.get_smell_changes() if delta else {} diff --git a/src/slopometry/core/models.py b/src/slopometry/core/models.py index 7dc6b09..cff6a42 100644 --- a/src/slopometry/core/models.py +++ b/src/slopometry/core/models.py @@ -65,7 +65,7 @@ class SmellDefinition(BaseModel): label="Test Skips", category=SmellCategory.GENERAL, weight=0.10, - guidance="BLOCKING: You MUST present a table with columns [Test Name | Intent] for each skip and ask user to confirm each is still valid", + guidance="BLOCKING: You MUST present a table with columns [Test Name | Intent] for each skip and ask user to confirm skipping is acceptable", count_field="test_skip_count", files_field="test_skip_files", ), @@ -352,6 +352,7 @@ class ComplexityDelta(BaseModel): files_added: list[str] = Field(default_factory=list) files_removed: list[str] = Field(default_factory=list) files_changed: dict[str, int] = Field(default_factory=dict, description="Mapping of filename to complexity delta") + files_effort_changed: dict[str, float] = Field(default_factory=dict, description="Mapping of filename to effort delta") net_files_change: int = Field(default=0, description="Net change in number of files (files_added - files_removed)") avg_complexity_change: float = 0.0 @@ -719,6 +720,7 @@ class ExtendedComplexityMetrics(ComplexityMetrics): total_mi: float average_mi: float = Field(description="Higher is better (0-100 scale)") + files_by_mi: dict[str, float] = Field(default_factory=dict, description="Mapping of filename to MI score") type_hint_coverage: float = Field(default=0.0, description="Percentage of functions/args with type hints (0-100)") docstring_coverage: float = Field( @@ -774,7 +776,7 @@ class ExtendedComplexityMetrics(ComplexityMetrics): test_skip_count: int = SmellField( label="Test Skips", files_field="test_skip_files", - guidance="BLOCKING: You MUST present a table with columns [Test Name | Intent] for each skip and ask user to confirm each is still valid", + guidance="BLOCKING: You MUST present a table with columns [Test Name | Intent] for each skip and ask user to confirm skipping is acceptable", ) swallowed_exception_count: int = SmellField( label="Swallowed Exceptions", diff --git a/src/slopometry/display/formatters.py b/src/slopometry/display/formatters.py index 44e4b60..a9d34f7 100644 --- a/src/slopometry/display/formatters.py +++ b/src/slopometry/display/formatters.py @@ -1,6 +1,7 @@ """Rich formatting utilities for displaying slopometry data.""" import logging +from collections import Counter from datetime import datetime from pathlib import Path @@ -341,7 +342,7 @@ def _display_complexity_metrics( """Display complexity metrics section.""" console.print("\n[bold]Complexity Metrics[/bold]") - overview_table = Table(title="Complexity Overview") + overview_table = Table() overview_table.add_column("Metric", style="cyan") overview_table.add_column("Value", justify="right") @@ -351,16 +352,21 @@ def _display_complexity_metrics( overview_table.add_row("Files analyzed", str(metrics.total_files_analyzed)) overview_table.add_row("[bold]Cyclomatic Complexity[/bold]", "") - overview_table.add_row(" Total complexity", str(metrics.total_complexity)) overview_table.add_row(" Average complexity", f"{metrics.average_complexity:.1f}") overview_table.add_row(" Max complexity", str(metrics.max_complexity)) overview_table.add_row(" Min complexity", str(metrics.min_complexity)) overview_table.add_row("[bold]Halstead Metrics[/bold]", "") overview_table.add_row(" Average volume", f"{metrics.average_volume:.1f}") overview_table.add_row(" Average effort", f"{metrics.average_effort:.2f}") + if metrics.files_by_effort: + max_effort_file, max_effort = max(metrics.files_by_effort.items(), key=lambda x: x[1]) + overview_table.add_row(" Max effort", f"{max_effort:,.0f} ({truncate_path(max_effort_file, max_width=30)})") overview_table.add_row("[bold]Maintainability Index[/bold]", "") overview_table.add_row(" Total MI", f"{metrics.total_mi:.1f}") overview_table.add_row(" File average MI", f"{metrics.average_mi:.1f} (higher is better)") + if metrics.files_by_mi: + min_mi_file, min_mi = min(metrics.files_by_mi.items(), key=lambda x: x[1]) + overview_table.add_row(" Min MI", f"{min_mi:.1f} ({truncate_path(min_mi_file, max_width=30)})") overview_table.add_row("[bold]Token Usage[/bold]", "") overview_table.add_row(" Total Tokens", _format_token_count(metrics.total_tokens)) @@ -393,7 +399,7 @@ def _display_complexity_metrics( overview_table.add_row(" Test Coverage", "[dim]N/A (run pytest first)[/dim]") overview_table.add_row( - " Deprecations", f"[yellow]{metrics.deprecation_count}[/yellow]" if metrics.deprecation_count > 0 else "0" + " Deprecations (excl. runtime)", f"[yellow]{metrics.deprecation_count}[/yellow]" if metrics.deprecation_count > 0 else "0" ) overview_table.add_row("[bold]Code Smells[/bold]", "") @@ -432,6 +438,24 @@ def _display_complexity_metrics( console.print(files_table) + if show_smell_files: + smell_files = metrics.get_smell_files() + file_smell_counts: Counter[str] = Counter() + for files in smell_files.values(): + for f in files: + file_smell_counts[f] += 1 + + if file_smell_counts: + offenders_table = Table(title="Worst Smell Offenders") + offenders_table.add_column("File", style="cyan", no_wrap=True, max_width=55) + offenders_table.add_column("Smell Types", justify="right", width=12) + + sorted_offenders = sorted(file_smell_counts.items(), key=lambda x: x[1], reverse=True)[:10] + for file_path, count in sorted_offenders: + offenders_table.add_row(truncate_path(file_path, max_width=55), str(count)) + + console.print(offenders_table) + if metrics.files_with_parse_errors: errors_table = Table(title="[yellow]Files with Parse Errors[/yellow]") errors_table.add_column("File", style="yellow", no_wrap=True, max_width=55) @@ -461,7 +485,7 @@ def _display_complexity_delta( title += f" - Baseline: {baseline.total_commits_analyzed} commits" console.print(f"\n[bold]{title}[/bold]") - changes_table = Table(title="Overall Changes") + changes_table = Table() changes_table.add_column("Metric", style="cyan") changes_table.add_column("Change", justify="right") if has_baseline: @@ -533,50 +557,50 @@ def _display_complexity_delta( files_removed_table.add_row(f"... and {len(delta.files_removed) - 10} more") console.print(files_removed_table) - if delta.files_changed: - sorted_changes = sorted(delta.files_changed.items(), key=lambda x: abs(x[1]), reverse=True)[:10] + if delta.files_effort_changed: + sorted_changes = sorted(delta.files_effort_changed.items(), key=lambda x: abs(x[1]), reverse=True)[:10] if sorted_changes: - file_changes_table = Table(title="File Complexity Changes") + file_changes_table = Table(title="File Effort Changes") file_changes_table.add_column("File", style="cyan", no_wrap=True, max_width=55) - file_changes_table.add_column("Previous", justify="right", width=10) - file_changes_table.add_column("Current", justify="right", width=10) - file_changes_table.add_column("Change", justify="right", width=10) + file_changes_table.add_column("Previous", justify="right", width=12) + file_changes_table.add_column("Current", justify="right", width=12) + file_changes_table.add_column("Change", justify="right", width=12) + files_by_effort = stats.complexity_metrics.files_by_effort if stats.complexity_metrics else {} for file_path, change in sorted_changes: - files_by_complexity = ( - stats.complexity_metrics.files_by_complexity if stats.complexity_metrics else {} - ) - current_complexity = files_by_complexity.get(file_path, 0) - previous_complexity = current_complexity - change + if file_path not in files_by_effort: + continue + current_effort = files_by_effort[file_path] + previous_effort = current_effort - change change_color = "green" if change < 0 else "red" file_changes_table.add_row( truncate_path(file_path, max_width=55), - str(previous_complexity), - str(current_complexity), - f"[{change_color}]{change:+d}[/{change_color}]", + f"{previous_effort:,.0f}", + f"{current_effort:,.0f}", + f"[{change_color}]{change:+,.0f}[/{change_color}]", ) console.print(file_changes_table) else: - if delta.files_changed: - sorted_changes = sorted(delta.files_changed.items(), key=lambda x: abs(x[1]), reverse=True)[:3] + if delta.files_effort_changed: + sorted_changes = sorted(delta.files_effort_changed.items(), key=lambda x: abs(x[1]), reverse=True)[:3] if sorted_changes: - file_changes_table = Table(title="Top File Complexity Changes") + file_changes_table = Table(title="Top File Effort Changes") file_changes_table.add_column("File", style="cyan", no_wrap=True, max_width=55) - file_changes_table.add_column("Change", justify="right", width=10) + file_changes_table.add_column("Change", justify="right", width=12) for file_path, change in sorted_changes: change_color = "green" if change < 0 else "red" file_changes_table.add_row( - truncate_path(file_path, max_width=55), f"[{change_color}]{change:+d}[/{change_color}]" + truncate_path(file_path, max_width=55), f"[{change_color}]{change:+,.0f}[/{change_color}]" ) console.print(file_changes_table) - has_file_data = delta.files_added or delta.files_removed or len(delta.files_changed) > 3 + has_file_data = delta.files_added or delta.files_removed or len(delta.files_effort_changed) > 3 if has_file_data: console.print("\n[dim]Run with --file-details to see all file changes[/dim]") @@ -1187,7 +1211,7 @@ def display_current_impact_analysis( quality_table.add_row("Test Coverage", "[dim]N/A (run pytest first)[/dim]") dep_style = "red" if metrics.deprecation_count > 0 else "green" - quality_table.add_row("Deprecations", f"[{dep_style}]{metrics.deprecation_count}[/{dep_style}]") + quality_table.add_row("Deprecations (excl. runtime)", f"[{dep_style}]{metrics.deprecation_count}[/{dep_style}]") console.print(quality_table) diff --git a/tests/test_complexity_analyzer.py b/tests/test_complexity_analyzer.py index 8784afb..430a5a0 100644 --- a/tests/test_complexity_analyzer.py +++ b/tests/test_complexity_analyzer.py @@ -144,3 +144,9 @@ def test_analyze_extended_complexity(mock_path): # New type percentage checks assert metrics.any_type_percentage == 10.0 # 2/20 * 100 assert metrics.str_type_percentage == 25.0 # 5/20 * 100 + + # Per-file metrics should be populated + assert "f.py" in metrics.files_by_effort + assert metrics.files_by_effort["f.py"] == 500.0 + assert "f.py" in metrics.files_by_mi + assert metrics.files_by_mi["f.py"] == 80.0 diff --git a/tests/test_hook_handler.py b/tests/test_hook_handler.py index e3c9622..a7fc66a 100644 --- a/tests/test_hook_handler.py +++ b/tests/test_hook_handler.py @@ -506,6 +506,80 @@ def test_format_code_smell_feedback__related_test_file_triggers_blocking(self): assert "test_foo.py" in feedback # test_bar.py (unrelated) only shows in non-edited files section when there are changes + def test_format_code_smell_feedback__unread_tests_are_blocking(self): + """Test that unread related tests trigger blocking when context_coverage provided.""" + metrics = ExtendedComplexityMetrics( + total_complexity=0, + average_complexity=0, + total_volume=0, + total_effort=0, + total_difficulty=0, + average_volume=0, + average_effort=0, + average_difficulty=0, + total_mi=0, + average_mi=0, + ) + + context_coverage = ContextCoverage( + files_edited=["src/foo.py"], + files_read=["src/foo.py"], + file_coverage=[ + FileCoverageStatus( + file_path="src/foo.py", + was_read_before_edit=True, + test_files=["tests/test_foo.py", "tests/test_bar.py"], + test_files_read=[], # None read + ) + ], + ) + + feedback, has_smells, has_blocking = format_code_smell_feedback( + metrics, None, context_coverage=context_coverage + ) + + assert has_smells is True + assert has_blocking is True + assert "Unread Related Tests" in feedback + assert "BLOCKING" in feedback + assert "tests/test_foo.py" in feedback + + def test_format_code_smell_feedback__read_tests_not_blocking(self): + """Test that read tests are not included in unread tests blocking.""" + metrics = ExtendedComplexityMetrics( + total_complexity=0, + average_complexity=0, + total_volume=0, + total_effort=0, + total_difficulty=0, + average_volume=0, + average_effort=0, + average_difficulty=0, + total_mi=0, + average_mi=0, + ) + + context_coverage = ContextCoverage( + files_edited=["src/foo.py"], + files_read=["src/foo.py", "tests/test_foo.py"], + file_coverage=[ + FileCoverageStatus( + file_path="src/foo.py", + was_read_before_edit=True, + test_files=["tests/test_foo.py"], + test_files_read=["tests/test_foo.py"], # Was read + ) + ], + ) + + feedback, has_smells, has_blocking = format_code_smell_feedback( + metrics, None, context_coverage=context_coverage + ) + + assert has_smells is False + assert has_blocking is False + assert "Unread Related Tests" not in feedback + class TestGetRelatedFilesViaImports: """Tests for import graph-based file relationship detection.""" @@ -689,56 +763,16 @@ def test_interpret_mi__does_not_invert(self): class TestFormatContextCoverageFeedback: """Tests for context coverage feedback formatting.""" - def test_format_context_coverage_feedback__shows_unread_tests(self): - """Test that unread test files are listed with guidance.""" + def test_format_context_coverage_feedback__no_tests_section(self): + """Test that context coverage no longer includes tests section (moved to smell feedback).""" coverage = ContextCoverage( files_edited=["src/foo.py"], files_read=["src/foo.py"], - file_coverage=[ - FileCoverageStatus( - file_path="src/foo.py", - was_read_before_edit=True, - test_files=["tests/test_foo.py", "tests/test_bar.py"], - test_files_read=[], # None read - ) - ], - ) - - result = format_context_coverage_feedback(coverage) - - assert "RELATED Tests - MUST REVIEW" in result - assert "tests/test_foo.py" in result - assert "correspond to files you edited" in result - - def test_format_context_coverage_feedback__excludes_read_tests(self): - """Test that read test files are not listed as unreviewed.""" - coverage = ContextCoverage( - files_edited=["src/foo.py"], - files_read=["src/foo.py", "tests/test_foo.py"], file_coverage=[ FileCoverageStatus( file_path="src/foo.py", was_read_before_edit=True, test_files=["tests/test_foo.py"], - test_files_read=["tests/test_foo.py"], # Was read - ) - ], - ) - - result = format_context_coverage_feedback(coverage) - - assert "RELATED Tests - MUST REVIEW" not in result - - def test_format_context_coverage_feedback__no_tests_section_when_empty(self): - """Test that no tests section appears when all tests were read.""" - coverage = ContextCoverage( - files_edited=["src/foo.py"], - files_read=["src/foo.py"], - file_coverage=[ - FileCoverageStatus( - file_path="src/foo.py", - was_read_before_edit=True, - test_files=[], # No related tests test_files_read=[], ) ], @@ -746,7 +780,9 @@ def test_format_context_coverage_feedback__no_tests_section_when_empty(self): result = format_context_coverage_feedback(coverage) - assert "RELATED Tests - MUST REVIEW" not in result + # Unread tests are now handled by format_code_smell_feedback, not here + assert "RELATED Tests" not in result + assert "Unread Related Tests" not in result class TestFormattersInterpretZScore: diff --git a/tests/test_models.py b/tests/test_models.py index 706343c..0f3e0be 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -40,6 +40,8 @@ def test_model_creation_with_values__creates_metrics_when_values_provided(self) average_mi=65.5, total_files_analyzed=10, files_by_complexity={"file1.py": 25, "file2.py": 30}, + files_by_effort={"file1.py": 2500.0, "file2.py": 2500.0}, + files_by_mi={"file1.py": 70.0, "file2.py": 60.0}, ) assert metrics.total_complexity == 150 @@ -48,6 +50,9 @@ def test_model_creation_with_values__creates_metrics_when_values_provided(self) assert metrics.average_mi == 65.5 assert metrics.total_files_analyzed == 10 assert len(metrics.files_by_complexity) == 2 + assert len(metrics.files_by_effort) == 2 + assert len(metrics.files_by_mi) == 2 + assert metrics.files_by_mi["file1.py"] == 70.0 class TestUserStoryStatistics: diff --git a/uv.lock b/uv.lock index d939608..c05c2e8 100644 --- a/uv.lock +++ b/uv.lock @@ -2750,7 +2750,7 @@ wheels = [ [[package]] name = "slopometry" -version = "20260117.post1" +version = "20260121.post1" source = { editable = "." } dependencies = [ { name = "click" },