From 19a0555b696daf6e5b1fee32571665a8dd07ab96 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Tue, 20 Jan 2026 10:49:33 +0200 Subject: [PATCH 1/2] Address exception swallows. Replace some dict access with model field access instead. Add better large dir guards --- .coverage | Bin 53248 -> 53248 bytes coverage.xml | 2299 +++++++++-------- src/slopometry/core/database.py | 42 +- src/slopometry/core/git_tracker.py | 11 +- src/slopometry/core/models.py | 9 +- src/slopometry/core/project_guard.py | 61 +- src/slopometry/core/project_tracker.py | 11 +- src/slopometry/display/formatters.py | 63 +- src/slopometry/solo/cli/commands.py | 11 +- .../solo/services/session_service.py | 11 +- src/slopometry/summoner/cli/commands.py | 24 +- .../services/experiment_orchestrator.py | 2 +- tests/test_database.py | 88 + 13 files changed, 1449 insertions(+), 1183 deletions(-) diff --git a/.coverage b/.coverage index 2b9fbb7f59cadfacf261768c66a7160ac14b6f67..2a51b1ea7aff90291baab5a02aaf62691fb93bb5 100644 GIT binary patch delta 705 zcmZozz}&Eac>`NRKner@P5v$Xar`>`0(^?RcX(rY?RfQgm3f7EuJH(RzvsTpeTdtQ zTZx;2>owOou47!RoEtgYI7>Lram?jtSgL60pq*)j_l~iu!2r>u?D7|_t&p3HrYK4E7+6518TFEr6^Bekhj>BnM?PFo1Yj^*;7KZ~$rf05gPvqk(~gp`L+(2`I|MAPWz?Z~4diiSfo?IpLBW z=M@<}swaQxz9icG|NMs8Z{OXy*8BH%4%@e==(TNoUv9qL!^Y_A%nk}-HqKw+Y@5ES zhuZ&0kNs!);k?$t`Hc=hyI6jF&t-U^&2Zy+)nYAXhW~5KR|zrvTO$N=2hg8?Oq>4e z*)uohu(5J-Hb+g??K5ZQk7k_cC}KSQ_NiPm^#-syKIAB2V7Q>qAok)K!-jwEpn!1z zg$xieGEH9Hm&|-HU2w8Mf2+JbYe6@2M0G$z)a&oFAKfeay7%?DzyEGyE}0CH5cod5s; delta 690 zcmZozz}&Eac>`NRKrsXVP5v$Xar`>`0(`2x4|$V#oq3IUHFza>Zu5w7f8oB*eU3Yb zTaTNE>pRyiu1j2koclN@b2f3@%-Yh{L(CpoC+#g+c+9H1WS+GGfi&m)UFSd1nO*5 z;@QQ;$-v;;5YNC+$M}GO!5*aV1HvdiAol??NRWZy0EmzQ5g>2?h#lVQ%dgp2c)$9w z+`rA{jdl_s*VM@FVwwV$R$!O_)XD);{SR&=qW}YtUGD(W4mTR4gn{7)ToHuNpa4_? z1T0`KBLfqN3+6O{%mA_!7#IY=HZX#i2*AY90+elFV30?M0a;979>{xQAl3uM2KFa= zpMSR5o4+1xEXU?!UCfN~Q8$^Q|GqWP_{StH01P_~a}ab4&N);e65F#(Z`7$6tpayzfL=B`?us3`~Nkk?b-iD zs{Sw*0ER^a!~cD}3^l9_zhm-4QW+l1|LPXXbYOlc#BB}@``td=f12Ky!N$tT*&H-k zv(J2@ldws{>D;GgvxObN9$;r+C;&4c#0zl-?viYVi2sX0L8<@>2?d}vjFVUOB{PXJ uP8R5ImH#JlK$kD!ts`UB+WYxk)wi#HUuXUOKM-us-TiwT|K=_IJ`MmQudUbs diff --git a/coverage.xml b/coverage.xml index 4b373e8..02e755e 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -16,7 +16,7 @@ - + @@ -232,7 +232,7 @@ - + @@ -400,106 +400,105 @@ + - - - - + + + - + + - - + - - - - - + + + + + - + + - - + + - - + - + - + - + + - + - - - - + + + - - - + + + + - + - - + - + + - + - + - + - + - - - + + + - - @@ -772,7 +771,7 @@ - + @@ -882,162 +881,156 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - + + + + + + + + + + - + + - - - - - - - - - - - - - - + - - - - - + + + + + + - - - - - - - - - + + + + + + + + + + + - - - - - + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - + - + - - - - + + + + + + + - + - - - - + + + + + + + - - + - - - - - - - - - - - - - + + - - - - - + + + + + + + + + + + - + - @@ -1047,283 +1040,302 @@ + + + - - - - - - - - - - - + + + + + + + + + + + + + + + - + - - - - - - + + + - + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + - - + - - - + - - + + + + - - - + - + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - + + + + + + + + - - - - - - + + + + + + + - - - - - + + + + + + - - - - + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - + + + + + + + + + - - - - - - - - - - - + + - - - - - + + + + + + + + + + + + - - - - - - - + + + + + + - - - - - - + + - - - + + + + + + + + - - - - - - - + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + @@ -1333,202 +1345,204 @@ - + + - + - - + - - + + - - - - - - - - - - - + + + + + + + + + + + - + - - + - + + - - - - - - - + + + + + + + - - - - - + + + + + - - - - + + + + - - - - - - + + + + + + - - - + + + - - - - - - + + + + + + - - - - - - - + + + + + + + - - - + + + - - - + + + - - - + + + - + - + - - - - - - - - - - + + + + + + + + + + - - - - + + + - - + + + - + - + - - - - - - - - - - + + + + + + + + + + - - - - + + + + - - - - + + + + - - + + - + - - - - - + + + + + - - - - - - - + + + + + + + - - - - - + + + + + - - - - - - + + + + + - - - + + + + - + - - - - + + + + + + @@ -2954,7 +2968,6 @@ - @@ -2967,33 +2980,33 @@ - - + + - + - + - - + + - - - - + + + + - + - - - + + + - - - + + + @@ -3001,54 +3014,55 @@ - + - + - - + + - + - - + + - + - - + + - - + + - - - + + + - + - - - - + + + + - - - - + + + + - - - + + + - - - + + + + @@ -3155,105 +3169,135 @@ - + - + + - - - - + - - - - - - - + + + + - - - - - - - - + + + + + + + + - + + + + + + + + + + - - - - - - - - - - - - - - - + + - + + + + + + + + + + - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + + - - - + + - - + + - - + + - - - - + + + + - - + + - + - - + + + + @@ -4109,35 +4153,33 @@ - - + - - - - + + + - + - - + + - + - - + + + - - + @@ -4147,10 +4189,10 @@ - - - - + + + + @@ -4159,20 +4201,20 @@ - + - + - + - - - - - + + + + + @@ -4181,13 +4223,13 @@ - + - - - + + + @@ -4195,7 +4237,7 @@ - + @@ -4208,70 +4250,69 @@ - - - - + + + + - - - - + + + + - - - + + + - + - - - - + + + + - + - + - - - - - + + + + + - - - + + + - + - + - @@ -4281,143 +4322,144 @@ + - + - + - - - - - - + + + + + + - + - + - + - + - + - + - + - - - - + + + + - + - + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + + - + - + + - - - + + - + - - - + + + - - - - - + + + + + - - - - + + + + @@ -4426,19 +4468,18 @@ - + - + - - - + + + - - - - + + + @@ -4446,192 +4487,192 @@ + - + - - - - - + + + + + - - - - + + + + - - - + + + + - - - - - - - + + + + + + - + - + - + - - + + - + - + - - - - + + + - - - - + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - + + + + - + - + - - - - + + + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - + - + - - + + + - - + - @@ -4641,13 +4682,13 @@ + - - - - + + + @@ -4658,89 +4699,89 @@ - - - - + + + + + - - - - - + + + + + - - - - - - + + + + + + - + - + - - - - + + + + - + - + - + - + - - - - - - + + + + + + - - - + + + - + - + - + - @@ -4748,93 +4789,93 @@ + - - - + + + - + - - - + + + - + - - - - - - - - - + + + + + + + + + - + - - + + + - - + - + - - - - - - + + + + + + - + - - - + + - + + - + + - - + - + - - - - - - - + + + + + + + - @@ -4842,28 +4883,31 @@ + - + - + - - + + + + - + - + @@ -4949,142 +4993,143 @@ - + - + - - + + - - + + - - + + - + - + - + - + - + - + - + - + - + - - + + - - + + + - + - - - + + + - + - + - + - + - + - + - + - - + - + + - + - + - + - + - + + - - + - + - - - - - + + + + + - + @@ -5093,21 +5138,20 @@ - + - - - - - - + + + + + + - @@ -5117,150 +5161,153 @@ + - + - + - + + - - + - + - - + + - - - - + + + + - + - + - + - + - + - - - - + + + + - + + - - - - + + + - - + + - + - - - - - + + + + + - + - + - + - - - - - + + + + + - - - - - - - + + + + + + + + - - - + + + + - + @@ -5434,47 +5481,49 @@ - + - + - - + + - - - - - - - - - - + + + + + + + + - - - + + + - - + - - - + + - + + + + + + + - + - + @@ -6133,97 +6182,101 @@ - - - - + - + - + - - + + + + + + - - - - + + + - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + - + - + - + - - + + - - - - - - - + + + + + + + + - + + - - - - - + - + + + + + + + + + + diff --git a/src/slopometry/core/database.py b/src/slopometry/core/database.py index dfcd1cd..af3b73f 100644 --- a/src/slopometry/core/database.py +++ b/src/slopometry/core/database.py @@ -428,6 +428,31 @@ def get_session_events(self, session_id: str) -> list[HookEvent]: ) return events + def get_session_basic_info(self, session_id: str) -> tuple[datetime, int] | None: + """Get minimal session info (start_time, total_events) without expensive computations. + + Use this for operations that only need to verify a session exists and show basic info, + like cleanup confirmations. + + Returns: + Tuple of (start_time, total_events) or None if session not found. + """ + with self._get_db_connection() as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + """ + SELECT MIN(timestamp) as start_time, COUNT(*) as total_events + FROM hook_events + WHERE session_id = ? + """, + (session_id,), + ).fetchone() + + if not row or row["total_events"] == 0: + return None + + return datetime.fromisoformat(row["start_time"]), row["total_events"] + def get_session_statistics(self, session_id: str) -> SessionStatistics | None: """Calculate statistics for a session using optimized SQL aggregations. @@ -1005,8 +1030,8 @@ def save_experiment_progress(self, progress: ExperimentProgress) -> None: INSERT INTO experiment_progress ( experiment_id, timestamp, current_metrics, target_metrics, cli_score, complexity_score, halstead_score, maintainability_score, - qpe_score, smell_penalty, effort_tier - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + qpe_score, smell_penalty + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( progress.experiment_id, @@ -1019,7 +1044,6 @@ def save_experiment_progress(self, progress: ExperimentProgress) -> None: progress.maintainability_score, progress.qpe_score, progress.smell_penalty, - progress.effort_tier.value if progress.effort_tier else None, ), ) conn.commit() @@ -1797,6 +1821,18 @@ def get_project_history(self, project_path: str) -> list[LeaderboardEntry]: for row in rows ] + def clear_leaderboard(self) -> int: + """Clear all leaderboard entries. + + Returns: + Number of entries deleted + """ + with self._get_db_connection() as conn: + cursor = conn.execute("SELECT COUNT(*) FROM qpe_leaderboard") + count = cursor.fetchone()[0] + conn.execute("DELETE FROM qpe_leaderboard") + return count + class SessionManager: """Manages sequence numbering for Claude Code sessions.""" diff --git a/src/slopometry/core/git_tracker.py b/src/slopometry/core/git_tracker.py index 7b52bc3..ed8aa60 100644 --- a/src/slopometry/core/git_tracker.py +++ b/src/slopometry/core/git_tracker.py @@ -1,5 +1,6 @@ """Git state tracking for Claude Code sessions.""" +import logging import shutil import subprocess import tarfile @@ -10,6 +11,8 @@ from slopometry.core.models import GitState +logger = logging.getLogger(__name__) + class GitOperationError(Exception): """Raised when a git operation fails unexpectedly. @@ -140,8 +143,8 @@ def _get_current_commit_sha(self) -> str | None: if result.returncode == 0: return result.stdout.strip() - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - pass + except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e: + logger.debug(f"Failed to get current commit SHA: {e}") return None @@ -175,8 +178,8 @@ def get_python_files_from_commit(self, commit_ref: str = "HEAD~1") -> list[str]: python_files = [f for f in all_files if f.endswith(".py")] return python_files - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - pass + except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e: + logger.debug(f"Failed to get Python files from commit {commit_ref}: {e}") return [] diff --git a/src/slopometry/core/models.py b/src/slopometry/core/models.py index 8d84ea2..7dc6b09 100644 --- a/src/slopometry/core/models.py +++ b/src/slopometry/core/models.py @@ -1334,9 +1334,10 @@ class CrossProjectComparison(BaseModel): class LeaderboardEntry(BaseModel): - """A persistent record of a project's QPE score at a specific commit. + """A persistent record of a project's quality score at a specific commit. - Used for tracking QPE scores over time and comparing projects. + Used for cross-project quality comparison. Stores absolute quality (qpe_absolute) + rather than effort-normalized QPE, since effort varies between projects. """ id: int | None = Field(default=None, description="Database ID") @@ -1345,10 +1346,10 @@ class LeaderboardEntry(BaseModel): commit_sha_short: str = Field(description="7-character short git hash") commit_sha_full: str = Field(description="Full git hash for deduplication") measured_at: datetime = Field(default_factory=datetime.now, description="Date of the analyzed commit") - qpe_score: float = Field(description="Quality-per-effort score") + qpe_score: float = Field(description="Absolute quality score (qpe_absolute) for cross-project comparison") mi_normalized: float = Field(description="Maintainability Index normalized to 0-1") smell_penalty: float = Field(description="Penalty from code smells") - adjusted_quality: float = Field(description="MI after smell penalty applied") + adjusted_quality: float = Field(description="MI × (1 - smell_penalty) + bonuses") effort_factor: float = Field(description="log(total_halstead_effort + 1)") total_effort: float = Field(description="Total Halstead Effort") metrics_json: str = Field(description="Full ExtendedComplexityMetrics as JSON") diff --git a/src/slopometry/core/project_guard.py b/src/slopometry/core/project_guard.py index 50d2edc..2ac8fc1 100644 --- a/src/slopometry/core/project_guard.py +++ b/src/slopometry/core/project_guard.py @@ -1,11 +1,30 @@ """Guard against running analysis in directories with multiple projects.""" import logging +import os import subprocess from pathlib import Path logger = logging.getLogger(__name__) +BLOCKED_DIRECTORIES = { + Path.home(), + Path("/"), + Path("/usr"), + Path("/opt"), + Path("/var"), + Path("/tmp"), +} + + +class UnsafeDirectoryError(Exception): + """Raised when analysis is attempted in a blocked directory like home.""" + + def __init__(self, directory: Path, reason: str): + self.directory = directory + self.reason = reason + super().__init__(f"Refusing to analyze {directory}: {reason}") + class MultiProjectError(Exception): """Raised when analysis is attempted in a directory with multiple projects.""" @@ -86,16 +105,56 @@ def scan_dir(path: Path, depth: int) -> None: return projects +def _is_blocked_directory(path: Path) -> str | None: + """Check if path is a blocked directory. + + Returns: + Reason string if blocked, None if allowed + """ + resolved = path.resolve() + + for blocked in BLOCKED_DIRECTORIES: + try: + if resolved == blocked.resolve(): + return f"'{resolved}' is a system/home directory" + except (OSError, ValueError): + continue + + xdg_data = os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share") + xdg_cache = os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache") + xdg_config = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") + + sensitive_paths = [ + Path(xdg_data), + Path(xdg_cache), + Path(xdg_config), + Path.home() / ".local", + ] + + for sensitive in sensitive_paths: + try: + if resolved == sensitive.resolve(): + return f"'{resolved}' is a user data/cache directory" + except (OSError, ValueError): + continue + + return None + + def guard_single_project(root: Path, max_depth: int = 2) -> None: - """Raise MultiProjectError if directory contains multiple projects. + """Raise error if directory is unsafe or contains multiple projects. Args: root: Directory to check max_depth: Maximum directory depth to search Raises: + UnsafeDirectoryError: If directory is blocked (home, /, etc.) MultiProjectError: If multiple git repositories found """ + if reason := _is_blocked_directory(root): + raise UnsafeDirectoryError(root, reason) + projects = detect_multi_project_directory(root, max_depth=max_depth, max_projects=1) if len(projects) > 1: diff --git a/src/slopometry/core/project_tracker.py b/src/slopometry/core/project_tracker.py index a7f574c..3b02e98 100644 --- a/src/slopometry/core/project_tracker.py +++ b/src/slopometry/core/project_tracker.py @@ -1,5 +1,6 @@ """Project identification logic.""" +import logging import subprocess from pathlib import Path @@ -7,6 +8,8 @@ from slopometry.core.models import Project, ProjectSource +logger = logging.getLogger(__name__) + class ProjectTracker: """Determines the project based on git, pyproject.toml, or working directory.""" @@ -51,8 +54,8 @@ def _get_project_from_git(self) -> Project | None: ) if result.returncode == 0 and result.stdout.strip(): return Project(name=result.stdout.strip(), source=ProjectSource.GIT) - except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): - pass + except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError) as e: + logger.error(f"Git operation failed in {self.working_dir}: {e}") return None @@ -67,7 +70,7 @@ def _get_project_from_pyproject(self) -> Project | None: project_name = data.get("project", {}).get("name") if project_name and isinstance(project_name, str): return Project(name=project_name, source=ProjectSource.PYPROJECT) - except (toml.TomlDecodeError, OSError, KeyError, TypeError): - pass + except (toml.TomlDecodeError, OSError, KeyError, TypeError) as e: + logger.error(f"Failed to parse pyproject.toml at {pyproject_path}: {e}") return None diff --git a/src/slopometry/display/formatters.py b/src/slopometry/display/formatters.py index 795a188..44e4b60 100644 --- a/src/slopometry/display/formatters.py +++ b/src/slopometry/display/formatters.py @@ -9,6 +9,9 @@ from slopometry.core.models import ( SMELL_REGISTRY, CompactEvent, + ExperimentDisplayData, + NFPObjectiveDisplayData, + ProgressDisplayData, SmellCategory, TokenUsage, ZScoreInterpretation, @@ -815,7 +818,7 @@ def create_sessions_table(sessions_data: list[dict]) -> Table: return table -def create_experiment_table(experiments_data: list[dict]) -> Table: +def create_experiment_table(experiments_data: list[ExperimentDisplayData]) -> Table: """Create a Rich table for displaying experiment runs.""" table = Table(title="Experiment Runs") table.add_column("ID", style="cyan", no_wrap=True) @@ -827,16 +830,16 @@ def create_experiment_table(experiments_data: list[dict]) -> Table: for exp_data in experiments_data: status_style = ( - "green" if exp_data["status"] == "completed" else "red" if exp_data["status"] == "failed" else "yellow" + "green" if exp_data.status == "completed" else "red" if exp_data.status == "failed" else "yellow" ) table.add_row( - exp_data["id"], - exp_data["repository_name"], - exp_data["commits_display"], - exp_data["start_time"], - exp_data["duration"], - f"[{status_style}]{exp_data['status']}[/]", + exp_data.id, + exp_data.repository_name, + exp_data.commits_display, + exp_data.start_time, + exp_data.duration, + f"[{status_style}]{exp_data.status}[/]", ) return table @@ -865,7 +868,7 @@ def create_user_story_entries_table(entries_data: list, count: int) -> Table: return table -def create_nfp_objectives_table(objectives_data: list[dict]) -> Table: +def create_nfp_objectives_table(objectives_data: list[NFPObjectiveDisplayData]) -> Table: """Create a Rich table for displaying NFP objectives.""" table = Table(title="NFP Objectives") table.add_column("ID", style="cyan", no_wrap=True) @@ -877,12 +880,12 @@ def create_nfp_objectives_table(objectives_data: list[dict]) -> Table: for obj_data in objectives_data: table.add_row( - obj_data["id"], - obj_data["title"], - obj_data["commits"], - str(obj_data["story_count"]), - str(obj_data["complexity"]), - obj_data["created_date"], + obj_data.id, + obj_data.title, + obj_data.commits, + str(obj_data.story_count), + str(obj_data.complexity), + obj_data.created_date, ) return table @@ -909,7 +912,7 @@ def create_features_table(features_data: list[dict]) -> Table: return table -def create_progress_history_table(progress_data: list[dict]) -> Table: +def create_progress_history_table(progress_data: list[ProgressDisplayData]) -> Table: """Create a Rich table for displaying experiment progress history.""" table = Table(title="Progress History") table.add_column("Timestamp", style="cyan") @@ -920,11 +923,11 @@ def create_progress_history_table(progress_data: list[dict]) -> Table: for progress_row in progress_data: table.add_row( - progress_row["timestamp"], - progress_row["cli_score"], - progress_row["complexity_score"], - progress_row["halstead_score"], - progress_row["maintainability_score"], + progress_row.timestamp, + progress_row.cli_score, + progress_row.complexity_score, + progress_row.halstead_score, + progress_row.maintainability_score, ) return table @@ -1465,19 +1468,19 @@ def display_cross_project_comparison(comparison: "CrossProjectComparison") -> No def display_leaderboard(entries: list) -> None: - """Display the QPE leaderboard. + """Display the Quality leaderboard for cross-project comparison. Args: - entries: List of LeaderboardEntry objects, already sorted by QPE + entries: List of LeaderboardEntry objects, already sorted by quality score """ - console.print("\n[bold]QPE Leaderboard[/bold]\n") + console.print("\n[bold]Quality Leaderboard[/bold]\n") table = Table(show_header=True) table.add_column("Rank", justify="right", style="bold") table.add_column("Project", style="cyan") - table.add_column("QPE", justify="right") - table.add_column("Smell", justify="right") table.add_column("Quality", justify="right") + table.add_column("Smell", justify="right") + table.add_column("MI", justify="right") table.add_column("Tokens", justify="right") table.add_column("Effort", justify="right") table.add_column("Commit", justify="center") @@ -1485,7 +1488,7 @@ def display_leaderboard(entries: list) -> None: for rank, entry in enumerate(entries, 1): rank_style = "green" if rank == 1 else "yellow" if rank == 2 else "blue" if rank == 3 else "" - qpe_color = "green" if entry.qpe_score > 0.05 else "yellow" if entry.qpe_score > 0.02 else "red" + qual_color = "green" if entry.qpe_score > 0.6 else "yellow" if entry.qpe_score > 0.4 else "red" smell_color = "green" if entry.smell_penalty < 0.1 else "yellow" if entry.smell_penalty < 0.3 else "red" try: @@ -1501,9 +1504,9 @@ def display_leaderboard(entries: list) -> None: table.add_row( f"[{rank_style}]#{rank}[/{rank_style}]" if rank_style else f"#{rank}", entry.project_name, - f"[{qpe_color}]{entry.qpe_score:.4f}[/{qpe_color}]", + f"[{qual_color}]{entry.qpe_score:.4f}[/{qual_color}]", f"[{smell_color}]{entry.smell_penalty:.3f}[/{smell_color}]", - f"{entry.adjusted_quality:.3f}", + f"{entry.mi_normalized:.3f}", f"[dim]{tokens_str}[/dim]", f"[dim]{effort_str}[/dim]", f"[dim]{entry.commit_sha_short}[/dim]", @@ -1511,4 +1514,4 @@ def display_leaderboard(entries: list) -> None: ) console.print(table) - console.print("\n[dim]Higher QPE = better quality per effort. Use --append to add projects.[/dim]") + console.print("\n[dim]Higher Quality = better absolute code quality. Use --append to add projects.[/dim]") diff --git a/src/slopometry/solo/cli/commands.py b/src/slopometry/solo/cli/commands.py index 960c5eb..09fb0c5 100644 --- a/src/slopometry/solo/cli/commands.py +++ b/src/slopometry/solo/cli/commands.py @@ -141,6 +141,7 @@ def show(session_id: str, smell_details: bool, file_details: bool, pager: bool) baseline, assessment = _compute_session_baseline(stats) def _display() -> None: + assert stats is not None display_session_summary( stats, session_id, @@ -226,6 +227,7 @@ def latest(smell_details: bool, file_details: bool, pager: bool) -> None: baseline, assessment = _compute_session_baseline(stats) def _display() -> None: + assert stats is not None and most_recent is not None display_session_summary( stats, most_recent, @@ -308,14 +310,15 @@ def cleanup(session_id: str | None, all_sessions: bool, yes: bool) -> None: return if session_id: - stats = session_service.get_session_statistics(session_id) - if not stats: + basic_info = session_service.get_session_basic_info(session_id) + if not basic_info: console.print(f"[red]Session {session_id} not found[/red]") return + start_time, total_events = basic_info console.print(f"\n[bold]Session to delete: {session_id}[/bold]") - console.print(f"Start time: {stats.start_time.strftime('%Y-%m-%d %H:%M:%S')}") - console.print(f"Total events: {stats.total_events}") + console.print(f"Start time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}") + console.print(f"Total events: {total_events}") if not yes: confirm = click.confirm("\nAre you sure you want to delete this session?", default=False) diff --git a/src/slopometry/solo/services/session_service.py b/src/slopometry/solo/services/session_service.py index f3c1d80..0a861c4 100644 --- a/src/slopometry/solo/services/session_service.py +++ b/src/slopometry/solo/services/session_service.py @@ -1,5 +1,6 @@ """Session management service for solo-leveler features.""" +from datetime import datetime from pathlib import Path from slopometry.core.database import EventDatabase @@ -32,6 +33,14 @@ def get_session_statistics(self, session_id: str) -> SessionStatistics | None: """Get detailed statistics for a session.""" return self.db.get_session_statistics(session_id) + def get_session_basic_info(self, session_id: str) -> tuple[datetime, int] | None: + """Get minimal session info without expensive computations. + + Returns: + Tuple of (start_time, total_events) or None if session not found. + """ + return self.db.get_session_basic_info(session_id) + def get_most_recent_session(self) -> str | None: """Get the ID of the most recent session.""" sessions = self.list_sessions(limit=1) @@ -51,8 +60,6 @@ def get_sessions_for_display(self, limit: int | None = None) -> list[dict]: sessions_data = [] for summary in summaries: - from datetime import datetime - try: start_time = datetime.fromisoformat(summary["start_time"]) formatted_time = start_time.strftime("%Y-%m-%d %H:%M:%S") diff --git a/src/slopometry/summoner/cli/commands.py b/src/slopometry/summoner/cli/commands.py index 7a3accc..057c49a 100644 --- a/src/slopometry/summoner/cli/commands.py +++ b/src/slopometry/summoner/cli/commands.py @@ -23,7 +23,7 @@ def complete_experiment_id(ctx: click.Context, param: click.Parameter, incomplet try: experiment_service = ExperimentService() experiments = experiment_service.list_experiments() - return [exp["id"] for exp in experiments if exp["id"].startswith(incomplete)] + return [exp.id for exp in experiments if exp.id.startswith(incomplete)] except Exception: return [] @@ -1053,25 +1053,35 @@ def qpe(repo_path: Path | None, output_json: bool) -> None: type=click.Path(exists=True, path_type=Path), help="Add project(s) to the leaderboard. Can be used multiple times.", ) -def compare_projects(append_paths: tuple[Path, ...]) -> None: - """Show QPE leaderboard or add projects to it. +@click.option( + "--reset", + is_flag=True, + help="Clear all leaderboard entries before adding new ones.", +) +def compare_projects(append_paths: tuple[Path, ...], reset: bool) -> None: + """Show Quality leaderboard or add projects to it. Without --append: Shows the current leaderboard ranking. - With --append: Computes QPE for specified project(s), saves to leaderboard, + With --append: Computes quality for specified project(s), saves to leaderboard, and shows updated rankings. + With --reset: Clears existing leaderboard entries first. Example: slopometry summoner compare-projects slopometry summoner compare-projects --append . - slopometry summoner compare-projects -a /path/to/project1 -a /path/to/project2 + slopometry summoner compare-projects --reset -a /path/to/project1 """ from slopometry.core.database import EventDatabase from slopometry.display.formatters import display_leaderboard db = EventDatabase() + if reset: + count = db.clear_leaderboard() + console.print(f"[yellow]Cleared {count} leaderboard entries[/yellow]") + if append_paths: from slopometry.core.complexity_analyzer import ComplexityAnalyzer from slopometry.core.language_guard import check_language_support @@ -1126,7 +1136,7 @@ def compare_projects(append_paths: tuple[Path, ...]) -> None: commit_sha_short=commit_sha_short, commit_sha_full=commit_sha_full, measured_at=commit_date, - qpe_score=qpe_score.qpe, + qpe_score=qpe_score.qpe_absolute, mi_normalized=qpe_score.mi_normalized, smell_penalty=qpe_score.smell_penalty, adjusted_quality=qpe_score.adjusted_quality, @@ -1135,7 +1145,7 @@ def compare_projects(append_paths: tuple[Path, ...]) -> None: metrics_json=metrics.model_dump_json(), ) db.save_leaderboard_entry(entry) - console.print(f"[green]Added {project_path.name} (QPE: {qpe_score.qpe:.4f})[/green]") + console.print(f"[green]Added {project_path.name} (Quality: {qpe_score.qpe_absolute:.4f})[/green]") console.print() diff --git a/src/slopometry/summoner/services/experiment_orchestrator.py b/src/slopometry/summoner/services/experiment_orchestrator.py index 7ffb7ce..fa59d75 100644 --- a/src/slopometry/summoner/services/experiment_orchestrator.py +++ b/src/slopometry/summoner/services/experiment_orchestrator.py @@ -205,7 +205,7 @@ def analyze_commit_chain(self, base_commit: str, head_commit: str) -> None: chain_id = self.db.create_commit_chain(str(self.repo_path), base_commit, head_commit, len(commits)) analyzer = ComplexityAnalyzer(self.repo_path) - previous_metrics = None + previous_metrics: ExtendedComplexityMetrics | None = None previous_coverage: float | None = None cumulative_cc = 0 diff --git a/tests/test_database.py b/tests/test_database.py index 920dd69..bc38f34 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -149,6 +149,50 @@ def test_leaderboard_upsert__updates_existing_project_on_new_commit() -> None: assert leaderboard[0].measured_at == datetime(2024, 6, 1) +def test_clear_leaderboard__removes_all_entries() -> None: + """Test that clear_leaderboard removes all entries and returns count.""" + with tempfile.TemporaryDirectory() as tmp_dir: + db = EventDatabase(db_path=Path(tmp_dir) / "test.db") + + entry1 = LeaderboardEntry( + project_name="project1", + project_path="/path/to/project1", + commit_sha_short="abc1234", + commit_sha_full="abc1234567890", + measured_at=datetime(2024, 1, 1), + qpe_score=0.5, + mi_normalized=0.7, + smell_penalty=0.1, + adjusted_quality=0.63, + effort_factor=10.0, + total_effort=20000.0, + metrics_json="{}", + ) + entry2 = LeaderboardEntry( + project_name="project2", + project_path="/path/to/project2", + commit_sha_short="def5678", + commit_sha_full="def5678901234", + measured_at=datetime(2024, 2, 1), + qpe_score=0.6, + mi_normalized=0.8, + smell_penalty=0.05, + adjusted_quality=0.76, + effort_factor=12.0, + total_effort=25000.0, + metrics_json="{}", + ) + db.save_leaderboard_entry(entry1) + db.save_leaderboard_entry(entry2) + + assert len(db.get_leaderboard()) == 2 + + deleted_count = db.clear_leaderboard() + + assert deleted_count == 2 + assert len(db.get_leaderboard()) == 0 + + def test_list_sessions_by_repository__filters_correctly() -> None: """Sessions should be filtered by working directory.""" with tempfile.TemporaryDirectory() as tmp_dir: @@ -241,3 +285,47 @@ def test_list_sessions_by_repository__respects_limit() -> None: sessions = db.list_sessions_by_repository(Path("/path/to/repo"), limit=2) assert len(sessions) == 2 + + +def test_get_session_basic_info__returns_minimal_info() -> None: + """get_session_basic_info returns just start_time and total_events without expensive computations.""" + with tempfile.TemporaryDirectory() as tmp_dir: + db = EventDatabase(db_path=Path(tmp_dir) / "test.db") + + db.save_event( + HookEvent( + session_id="test-session", + event_type=HookEventType.PRE_TOOL_USE, + sequence_number=1, + working_directory="/path/to/repo", + tool_name="Read", + tool_type=ToolType.READ, + ) + ) + db.save_event( + HookEvent( + session_id="test-session", + event_type=HookEventType.POST_TOOL_USE, + sequence_number=2, + working_directory="/path/to/repo", + tool_name="Write", + tool_type=ToolType.WRITE, + ) + ) + + result = db.get_session_basic_info("test-session") + + assert result is not None + start_time, total_events = result + assert isinstance(start_time, datetime) + assert total_events == 2 + + +def test_get_session_basic_info__returns_none_for_unknown_session() -> None: + """get_session_basic_info returns None for non-existent session.""" + with tempfile.TemporaryDirectory() as tmp_dir: + db = EventDatabase(db_path=Path(tmp_dir) / "test.db") + + result = db.get_session_basic_info("nonexistent-session") + + assert result is None From 5e239bd306d2bf84c6a55074588e4a5056591e56 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Wed, 21 Jan 2026 11:13:57 +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 d77014d..d7c6406 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "slopometry" -version = "20260117-1" +version = "20260121-1" description = "Opinionated code quality metrics for code agents and humans" readme = "README.md" requires-python = ">=3.13"