diff --git a/.coverage b/.coverage
index fefd126..0e87332 100644
Binary files a/.coverage and b/.coverage differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 29db8a6..791b670 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,6 +38,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Full history for baseline computation and current-impact
- name: Set up uv
uses: astral-sh/setup-uv@v4
@@ -107,3 +109,81 @@ jobs:
});
}
+ - name: Run current-impact check
+ id: impact
+ run: |
+ echo "json=$(slopometry summoner current-impact --json --no-pager | jq -c)" >> $GITHUB_OUTPUT
+
+ - name: Comment impact on PR
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const impact = ${{ steps.impact.outputs.json }};
+
+ if (impact.error) {
+ core.warning(`Current-impact skipped: ${impact.error}`);
+ return;
+ }
+
+ const category = impact.impact_category.replace(/_/g, ' ');
+ const smells = impact.smell_advantages
+ .filter(s => s.weighted_delta !== 0)
+ .sort((a, b) => Math.abs(b.weighted_delta) - Math.abs(a.weighted_delta))
+ .map(s => `| ${s.smell_name.replace(/_/g, ' ')} | ${s.baseline_count} | ${s.candidate_count} | ${s.weighted_delta > 0 ? '+' : ''}${s.weighted_delta.toFixed(4)} |`)
+ .join('\n');
+
+ const source = impact.source === 'previous_commit'
+ ? `Previous commit (${impact.analyzed_commit_sha} vs ${impact.base_commit_sha})`
+ : 'Uncommitted changes';
+
+ const body = `## 📈 Slopometry Impact Report
+
+ **Impact: ${category.toUpperCase()}** (score: ${impact.impact_score.toFixed(3)})
+
+ | Metric | Delta | Description |
+ |--------|-------|-------------|
+ | QPE | ${impact.qpe_delta >= 0 ? '+' : ''}${impact.qpe_delta.toFixed(4)} | Quality-Per-Effort change |
+ | MI | ${impact.mi_delta >= 0 ? '+' : ''}${impact.mi_delta.toFixed(3)} | Maintainability Index change |
+ | CC | ${impact.cc_delta >= 0 ? '+' : ''}${impact.cc_delta.toFixed(3)} | Cyclomatic Complexity change |
+ | Effort | ${impact.effort_delta >= 0 ? '+' : ''}${impact.effort_delta.toFixed(1)} | Halstead Effort change |
+
+ | | Count |
+ |---|---|
+ | Changed files | ${impact.changed_files_count} |
+ | Blind spots | ${impact.blind_spots_count} |
+
+
+ Smell Advantage Breakdown
+
+ | Smell | Baseline | Current | Weighted Delta |
+ |-------|----------|---------|----------------|
+ ${smells || '| (no smell changes) | | | |'}
+
+
+
+ > Source: ${source}`;
+
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+
+ const existing = comments.find(c => c.body.includes('Slopometry Impact Report'));
+
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body
+ });
+ }
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 4493e74..89b13ef 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -156,7 +156,7 @@ The experiment tracking feature includes:
- **Experiment Orchestrator**: Coordinates parallel experiment execution
### Important Notes
-- All `radon` calls use `uvx radon` to ensure proper uv environment execution
+- Complexity analysis uses `rust-code-analysis` (via `rust-code-analysis` Python package), not the abandoned `radon` tool
- Experiments create temporary git worktrees for isolation
- CLI scores: 1.0 = perfect match, <0 = overshooting target (prevents overfitting)
- Database handles duplicate analysis runs gracefully
diff --git a/coverage.xml b/coverage.xml
index 1ddbc63..5d100cc 100644
--- a/coverage.xml
+++ b/coverage.xml
@@ -1,5 +1,5 @@
-
+
@@ -16,7 +16,7 @@
-
+
@@ -506,7 +506,7 @@
-
+
@@ -715,7 +715,7 @@
-
+
@@ -821,7 +821,7 @@
-
+
@@ -834,543 +834,584 @@
-
+
-
-
+
+
-
-
+
+
-
+
-
+
-
+
-
-
-
+
+
+
-
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
+
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
-
-
+
+
+
-
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
+
-
-
+
+
+
+
+
+
+
+
+
-
+
-
+
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
-
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
-
+
-
-
-
-
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
-
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
-
+
+
-
-
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
-
+
+
-
-
-
-
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2094,53 +2135,53 @@
-
-
-
+
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
+
@@ -2193,6 +2234,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2245,7 +2305,7 @@
-
+
@@ -2422,53 +2482,86 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
-
+
-
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -2525,13 +2618,17 @@
+
+
-
-
+
+
+
+
@@ -2541,13 +2638,13 @@
-
+
@@ -2562,24 +2659,24 @@
-
-
-
-
-
+
+
+
-
-
+
+
+
+
@@ -2588,69 +2685,69 @@
-
-
-
-
-
-
-
+
+
+
-
+
+
-
+
+
+
+
-
-
-
-
+
+
-
+
-
-
-
+
+
+
-
-
+
+
-
+
+
-
+
-
-
+
-
+
+
+
@@ -2658,97 +2755,97 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
+
-
-
-
+
-
+
-
+
-
+
-
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
@@ -2762,522 +2859,497 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
-
-
-
-
+
+
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
+
-
-
+
-
-
+
-
-
-
+
+
+
+
+
-
-
+
+
-
-
+
+
+
+
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
+
+
+
-
-
+
-
+
-
+
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
+
+
-
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
-
-
-
-
-
+
+
+
+
+
-
+
+
-
-
-
-
-
-
-
+
+
+
+
+
-
+
+
-
+
-
-
-
-
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
+
+
-
-
-
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
-
+
-
-
-
-
-
-
+
+
+
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
+
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
-
+
+
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
+
-
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
+
+
-
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
+
+
-
+
-
-
+
+
-
+
-
+
-
+
-
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
+
@@ -4476,7 +4548,7 @@
-
+
@@ -4485,7 +4557,7 @@
-
+
@@ -4494,55 +4566,53 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
+
+
-
-
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
+
-
+
-
+
+
-
-
-
-
+
+
+
-
+
-
+
-
+
@@ -4551,105 +4621,105 @@
-
+
+
-
-
-
-
+
+
+
-
+
-
+
-
+
-
+
+
-
-
-
-
+
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
-
+
-
+
-
-
-
-
-
+
+
+
+
+
-
+
-
+
-
-
-
-
+
+
+
+
@@ -4657,7 +4727,6 @@
-
@@ -4671,372 +4740,374 @@
+
-
+
-
+
-
+
-
-
+
+
+
+
-
-
-
-
+
+
-
+
+
-
-
+
-
+
-
+
-
+
+
-
-
+
+
-
-
+
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
+
-
+
-
+
-
+
-
-
-
-
+
+
+
+
-
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
-
+
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
+
+
-
-
+
+
-
-
+
-
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
-
+
-
+
-
-
-
+
+
+
-
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
-
-
-
+
+
+
+
-
+
-
+
-
-
-
-
+
+
+
+
-
@@ -5047,12 +5118,12 @@
+
-
+
-
-
-
+
+
@@ -5064,88 +5135,88 @@
-
-
-
+
+
+
+
-
+
-
-
+
+
-
-
-
+
+
-
+
+
-
+
-
+
-
+
-
+
-
+
-
-
-
-
+
+
+
+
-
+
-
+
-
+
-
+
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
+
-
+
-
+
-
@@ -5154,46 +5225,46 @@
+
-
-
-
+
+
+
-
+
-
-
-
-
+
+
+
+
-
-
-
+
+
-
-
-
+
+
+
+
-
-
-
+
+
-
-
+
+
@@ -5201,70 +5272,70 @@
+
-
-
-
-
-
+
+
+
+
+
-
+
-
-
-
+
+
+
-
-
+
+
-
+
-
+
-
-
-
-
+
+
+
+
-
+
-
-
-
-
+
+
+
-
+
+
-
-
-
+
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
@@ -5273,52 +5344,54 @@
+
-
+
-
+
-
+
-
-
-
-
-
+
+
+
+
-
-
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
+
@@ -5733,7 +5806,7 @@
-
+
@@ -5907,49 +5980,50 @@
-
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
-
+
+
+
-
+
-
+
@@ -6161,120 +6235,123 @@
-
-
-
-
-
-
-
-
+
+
-
-
+
-
-
+
+
+
-
+
+
+
-
+
-
+
-
-
-
-
-
+
+
+
-
+
-
+
-
+
-
+
-
-
+
-
-
-
+
+
+
+
-
+
+
-
-
+
-
-
-
+
+
+
+
+
+
-
+
-
-
-
-
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -6283,206 +6360,198 @@
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
-
+
-
+
-
-
-
-
-
+
+
-
-
+
+
-
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
+
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
-
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
+
-
-
-
-
+
+
+
+
+
-
+
+
-
+
-
-
-
-
-
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
+
-
-
-
-
-
+
+
+
-
-
+
+
-
+
-
-
-
+
+
+
@@ -6491,62 +6560,64 @@
-
-
-
-
-
+
-
-
+
-
-
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
+
+
+
+
+
+
@@ -6554,203 +6625,238 @@
-
-
-
-
-
-
-
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
-
+
-
+
-
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
+
+
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -6762,56 +6868,55 @@
-
-
+
-
+
-
-
+
+
-
+
-
-
+
+
-
+
-
+
-
+
-
-
+
+
-
+
-
+
-
-
+
+
-
+
-
-
+
+
@@ -6822,153 +6927,154 @@
-
-
+
+
-
-
+
+
-
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
-
-
+
+
+
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
-
+
+
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
-
+
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+
-
-
-
-
+
-
+
+
+
-
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -7606,37 +7712,37 @@
-
-
+
+
-
-
+
+
-
+
-
+
-
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
+
-
-
-
-
+
+
+
+
-
+
@@ -7645,27 +7751,28 @@
-
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
-
+
-
-
-
-
+
+
+
+
+
+
@@ -7767,101 +7874,103 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
-
-
-
+
+
+
-
+
-
-
+
+
+
-
+
-
-
-
-
-
-
+
+
+
+
-
-
+
+
-
+
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
-
-
-
+
+
-
+
+
-
-
+
-
+
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
-
+
@@ -7871,74 +7980,75 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
+
-
+
-
+
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
+
-
+
-
+
-
-
-
-
+
+
+
+
-
-
+
+
+
diff --git a/domain-refactoring.rfc b/domain-refactoring.rfc
new file mode 100644
index 0000000..7100216
--- /dev/null
+++ b/domain-refactoring.rfc
@@ -0,0 +1,326 @@
+# RFC: Domain Modeling Audit & Restructuring
+
+**Status**: Audit complete, implementation pending
+**Date**: 2026-02-10
+**Scope**: `src/slopometry/` — all domain models, data access, and display layers
+
+## Context
+
+The codebase grew iteratively, resulting in tangled file hierarchy, oversized files (`database.py` at 1841 lines, `formatters.py` at 1663 lines), and domain objects scattered across files without consistent locality. This audit identifies:
+1. Informal data objects that should be Pydantic BaseModels
+2. All existing BaseModels and their locations
+3. Locality issues (models far from their consumers)
+4. Duplicated/overlapping logic stemming from this confusion
+
+---
+
+## 1. Existing Pydantic BaseModels — Inventory
+
+### `src/slopometry/core/models.py` (1758 lines, 48+ models)
+
+This single file contains ALL core domain models. Models below grouped by domain concern:
+
+| Model | Lines | Domain Concern |
+|-------|-------|---------------|
+| `SmellDefinition` | 26-37 | Code quality / smells |
+| `ResolvedBaselineStrategy` | 234-261 | Baseline computation |
+| `Project` | 278-282 | Session tracking |
+| `GitState` | 347-354 | Session tracking |
+| `HookEvent` | 357-374 | Hook event ingestion |
+| `TokenCountError` | 377-383 | Error handling |
+| `CacheUpdateError` | 386-393 | Error handling |
+| `FileAnalysisResult` | 396-406 | Complexity analysis |
+| `ComplexityMetrics` | 409-431 | Complexity analysis |
+| `ComplexityDelta` | 434-501 | Complexity analysis |
+| `TodoItem` | 504-509 | Plan tracking |
+| `PlanStep` | 512-528 | Plan tracking |
+| `TokenUsage` | 531-561 | Token analysis |
+| `SessionMetadata` | 564-576 | Session tracking |
+| `PlanEvolution` | 579-597 | Plan tracking |
+| `CompactEvent` | 600-613 | Transcript analysis |
+| `SavedCompact` | 617-628 | Transcript analysis |
+| `SessionStatistics` | 631-655 | Session tracking |
+| `PreToolUseInput` | 658-666 | Hook input parsing |
+| `PostToolUseInput` | 669-681 | Hook input parsing |
+| `NotificationInput` | 684-692 | Hook input parsing |
+| `StopInput` | 695-702 | Hook input parsing |
+| `SubagentStopInput` | 705-712 | Hook input parsing |
+| `HookOutput` | 718-727 | Hook output |
+| `UserStory` | 739-748 | Experiment/NFP |
+| `NextFeaturePrediction` | 751-780 | Experiment/NFP |
+| `ScopedSmell` | 783-794 | Code quality / smells |
+| `SmellData` | 797-824 | Code quality / smells |
+| `ExtendedComplexityMetrics` | 827-1041 | Complexity analysis |
+| `SmellCounts` | 1434-1457 | Code quality / smells |
+| `ExperimentRun` | 1044-1058 | Experiments |
+| `ExperimentProgress` | 1061-1079 | Experiments |
+| `CommitComplexitySnapshot` | 1082-1090 | Complexity evolution |
+| `CommitChain` | 1093-1101 | Complexity evolution |
+| `ComplexityEvolution` | 1104-1113 | Complexity evolution |
+| `MergeCommit` | 1116-1122 | Git / features |
+| `FeatureBoundary` | 1125-1139 | Git / features |
+| `UserStoryEntry` | 1142-1165 | Dataset / user stories |
+| `UserStoryStatistics` | 1168-1175 | Dataset / user stories |
+| `UserStoryDisplayData` | 1178-1186 | Display DTOs |
+| `ExperimentDisplayData` | 1189-1197 | Display DTOs |
+| `ProgressDisplayData` | 1200-1207 | Display DTOs |
+| `NFPObjectiveDisplayData` | 1210-1218 | Display DTOs |
+| `CodeQualityCache` | 1221-1235 | Caching |
+| `ImpactAssessment` | 1398-1431 | Impact analysis |
+| `HistoricalMetricStats` | 1248-1260 | Baseline statistics |
+| `GalenMetrics` | 1267-1311 | Productivity metrics |
+| `RepoBaseline` | 1314-1351 | Baseline |
+| `QPEScore` | 1460-1475 | QPE scoring |
+| `SmellAdvantage` | 1478-1509 | GRPO comparison |
+| `ImplementationComparison` | 1512-1551 | GRPO comparison |
+| `ProjectQPEResult` | 1554-1560 | Cross-project |
+| `CrossProjectComparison` | 1563-1572 | Cross-project |
+| `LeaderboardEntry` | 1575-1593 | Leaderboard |
+| `StagedChangesAnalysis` | 1596-1610 | Impact analysis (deprecated) |
+| `CurrentChangesAnalysis` | 1613-1657 | Impact analysis |
+| `FileCoverageStatus` | 1659-1690 | Context coverage |
+| `ContextCoverage` | 1693-1738 | Context coverage |
+| `LanguageGuardResult` | 1741-1757 | Language detection |
+
+### Models in other files (scattered)
+
+| Model | File | Lines | Domain Concern |
+|-------|------|-------|---------------|
+| `CompactBoundary` | `core/compact_analyzer.py` | 19-28 | Transcript parsing |
+| `CompactSummary` | `core/compact_analyzer.py` | 32-38 | Transcript parsing |
+| `TranscriptMetadata` | `core/transcript_token_analyzer.py` | 16-20 | Transcript parsing |
+| `MessageUsage` | `core/transcript_token_analyzer.py` | 76-79 | Token analysis |
+| `AssistantMessage` | `core/transcript_token_analyzer.py` | 83+ | Token analysis |
+| `SlopometrySettings` | `core/settings.py` | ~50-279 | Configuration |
+
+---
+
+## 2. Domain Modeling Gaps — Informal Data Objects
+
+### A. Raw `dict` used where models should exist
+
+| Location | Pattern | What it represents | Severity |
+|----------|---------|-------------------|----------|
+| `compact_analyzer.py:27` | `compactMetadata: dict \| None` | Compact boundary metadata (trigger, preTokens) | Medium — known keys accessed via `.get()` at lines 102-103 |
+| `compact_analyzer.py:38` | `message: dict \| None` | Compact summary message with `content` field | Medium — accessed via `.get("content")` at line 107 |
+| `context_coverage_analyzer.py:203-231` | Raw `dict` event parsing | Transcript JSONL events (tool_name, tool_input, message.content) | High — heavy `.get()` and `isinstance` soup |
+| `transcript_token_analyzer.py:48-63` | Raw `dict` event parsing | Transcript events (version, gitBranch, type, message.model) | High — same pattern of `.get()` / `isinstance` chains |
+| `plan_analyzer.py:76,106` | `tool_input.get("todos")`, `tool_input.get("file_path")` | Tool input structures for TodoWrite and Write | Medium — known shapes |
+| `hook_handler.py:177-179` | `parsed_input.tool_response.get("duration_ms")` etc. | Post-tool response fields (duration, exit_code, error) | Medium — `PostToolUseInput.tool_response` is typed as `dict[str, Any]` |
+| `database.py:712` | `metadata.get("tool_input", {})` | Stored event metadata blob | Medium — the metadata column is a JSON dump of varying shapes |
+| `coverage_analyzer.py:77-86` | `root.get("line-rate")`, `class_elem.get("filename")` | XML coverage data parsed to dicts | Low — external format, dict is reasonable |
+
+### B. `getattr` used on typed models (modeling gap)
+
+| Location | Expression | Why it's a gap |
+|----------|-----------|---------------|
+| `formatters.py:416` | `getattr(smell_counts, defn.internal_name)` | Iterating `SMELL_REGISTRY` and using string key to access `SmellCounts` fields — `SmellCounts` should expose an iteration method |
+| `formatters.py:1490` | `getattr(qpe_score.smell_counts, name)` | Same pattern in QPE detail display |
+| `qpe_calculator.py:157-158` | `getattr(baseline.smell_counts, name)` / `getattr(candidate.smell_counts, name)` | Same — iterating registry and reflecting into SmellCounts |
+
+**Root cause**: `SmellCounts` has 14 explicit fields but no `__getitem__` or iteration method, forcing callers to use `getattr` when iterating by smell name.
+
+### C. Tuple returns instead of named models
+
+| Location | Return type | What it represents |
+|----------|------------|-------------------|
+| `database.py:428` | `tuple[datetime, int] \| None` | Session basic info (start_time, total_events) |
+| `database.py:632` | `tuple[ExtendedComplexityMetrics \| None, ComplexityDelta \| None]` | Session complexity result pair |
+| `database.py:755-757` | Same tuple return | `calculate_extended_complexity_metrics` |
+
+### D. `.get()` with defaults on external data (justified)
+
+These are at system boundaries parsing external formats (transcript JSONL, XML coverage) and are **not** modeling gaps — they're dealing with genuinely untyped external data. However, the transcript JSONL events appear frequently enough that a `TranscriptEvent` model would help.
+
+---
+
+## 3. Locality Analysis — Models vs. Consumers
+
+### Problem: `models.py` is a 1758-line "God Module"
+
+All 48+ models live in one file regardless of which subsystem consumes them. This creates:
+- **No locality**: Display DTOs (`UserStoryDisplayData`, `ExperimentDisplayData`, etc.) are defined 1000+ lines away from `formatters.py`
+- **Circular coupling risk**: Everything imports from `models.py`, and `models.py` must forward-reference types
+- **Cognitive load**: Finding a model requires scanning 1758 lines
+
+### Proposed Model Grouping by Domain
+
+| Domain Module | Models to include | Primary consumers |
+|--------------|-------------------|-------------------|
+| `models/hook_events.py` | `HookEvent`, `HookEventType`, `ToolType`, `PreToolUseInput`, `PostToolUseInput`, `NotificationInput`, `StopInput`, `SubagentStopInput`, `HookInputUnion`, `HookOutput` | `hook_handler.py`, `database.py` |
+| `models/session.py` | `SessionStatistics`, `SessionMetadata`, `GitState`, `Project`, `ProjectSource`, `AgentTool` | `database.py`, `session_service.py`, `formatters.py` |
+| `models/complexity.py` | `FileAnalysisResult`, `ComplexityMetrics`, `ExtendedComplexityMetrics`, `ComplexityDelta`, `ComplexityEvolution`, `CommitComplexitySnapshot`, `CommitChain` | `complexity_analyzer.py`, `database.py`, `qpe_calculator.py` |
+| `models/smells.py` | `SmellDefinition`, `SmellCategory`, `SmellCounts`, `SmellData`, `ScopedSmell`, `SMELL_REGISTRY`, `SmellField`, `get_smell_label`, `get_smells_by_category` | `python_feature_analyzer.py`, `formatters.py`, `qpe_calculator.py`, `hook_handler.py` |
+| `models/quality.py` | `QPEScore`, `SmellAdvantage`, `ImpactAssessment`, `ImpactCategory`, `ZScoreInterpretation`, `HistoricalMetricStats`, `GalenMetrics`, `RepoBaseline`, `ResolvedBaselineStrategy`, `BaselineStrategy` | `qpe_calculator.py`, `impact_calculator.py`, `baseline_service.py`, `formatters.py` |
+| `models/experiments.py` | `ExperimentRun`, `ExperimentProgress`, `ExperimentStatus`, `FeatureBoundary`, `MergeCommit`, `NextFeaturePrediction`, `UserStory` | `experiment_orchestrator.py`, `nfp_service.py` |
+| `models/dataset.py` | `UserStoryEntry`, `UserStoryStatistics` | `user_story_service.py`, `database.py` |
+| `models/analysis.py` | `StagedChangesAnalysis`, `CurrentChangesAnalysis`, `ImplementationComparison`, `ProjectQPEResult`, `CrossProjectComparison`, `LeaderboardEntry` | `current_impact_service.py`, `implementation_comparator.py`, `formatters.py` |
+| `models/coverage.py` | `FileCoverageStatus`, `ContextCoverage`, `PlanEvolution`, `PlanStep`, `TodoItem`, `TokenUsage`, `CompactEvent`, `SavedCompact` | `context_coverage_analyzer.py`, `plan_analyzer.py`, `compact_analyzer.py` |
+| `models/display.py` | `UserStoryDisplayData`, `ExperimentDisplayData`, `ProgressDisplayData`, `NFPObjectiveDisplayData` | `formatters.py` only |
+| `models/common.py` | `TokenCountError`, `CacheUpdateError`, `CodeQualityCache`, `ProjectLanguage`, `LanguageGuardResult` | Various |
+
+With a `models/__init__.py` that re-exports everything for backward compatibility during transition.
+
+---
+
+## 4. Duplicated / Overlapping Logic
+
+### A. `getattr` iteration on SmellCounts (3 locations)
+
+**Files**: `formatters.py:416`, `formatters.py:1490`, `qpe_calculator.py:157-158`
+
+**Fix**: Add `SmellCounts.iter_counts() -> Iterator[tuple[str, int]]` method:
+```python
+def iter_counts(self) -> Iterator[tuple[str, int]]:
+ """Yield (smell_name, count) for each smell."""
+ for name in SMELL_REGISTRY:
+ yield name, getattr(self, name) # centralized, single getattr location
+```
+
+### B. Galen metrics calculation in formatters.py
+
+**`formatters.py:94-119`** — `_calculate_galen_metrics_from_baseline()` does business logic (token arithmetic) that belongs in the `GalenMetrics` model.
+
+**`models.py:1286-1311`** — `GalenMetrics.calculate()` exists but takes `(tokens_changed, period_days)` — different parameters.
+
+**Fix**: Add `GalenMetrics.from_baseline(baseline, current_tokens)` classmethod to `GalenMetrics` and delete the formatters function.
+
+### C. Plan evolution wiring in database.py
+
+**`database.py:685-725`** — `_calculate_plan_evolution()` queries events, then feeds them into `PlanAnalyzer`. The database is doing orchestration that belongs in a service layer.
+
+**`database.py:727-753`** — `_calculate_context_coverage()` wraps `ContextCoverageAnalyzer` — pure passthrough.
+
+These methods make `database.py` a God Object mixing persistence with analysis orchestration.
+
+### D. Complexity metric calculation in database.py
+
+**`database.py:755-789`** — `calculate_extended_complexity_metrics()` instantiates `ComplexityAnalyzer` and `GitTracker` to do analysis. This is analysis orchestration, not data access.
+
+---
+
+## 5. Recommended Action Plan
+
+### Phase 1: Fix modeling gaps (no file moves)
+
+1. **Add `SmellCounts.iter_counts()`** — eliminate all 3 `getattr` call sites
+ - `src/slopometry/core/models.py`
+ - `src/slopometry/display/formatters.py` (2 sites)
+ - `src/slopometry/summoner/services/qpe_calculator.py` (1 site)
+
+2. **Add `GalenMetrics.from_baseline()` classmethod** — move business logic out of formatters
+ - `src/slopometry/core/models.py`
+ - `src/slopometry/display/formatters.py` (delete `_calculate_galen_metrics_from_baseline`)
+
+3. **Create `CompactMetadata` model** to replace `dict` in `CompactBoundary.compactMetadata`
+ - `src/slopometry/core/compact_analyzer.py`
+
+4. **Create `ToolResponse` model** to replace the `dict[str, Any]` response extraction pattern
+ - `src/slopometry/core/models.py` (add model)
+ - `src/slopometry/core/hook_handler.py` (use instead of `.get()` on dict)
+
+5. **Create `SessionBasicInfo` named model** to replace `tuple[datetime, int]`
+ - `src/slopometry/core/database.py:428`
+
+### Phase 2: Extract analysis orchestration from database.py
+
+6. **Delete `database._calculate_plan_evolution()`** — move to a session enrichment service
+7. **Delete `database._calculate_context_coverage()`** — same
+8. **Move `database.calculate_extended_complexity_metrics()`** — same
+
+This reduces `database.py` to pure persistence + caching.
+
+### Phase 3: Split models.py into domain modules
+
+9. Create `src/slopometry/core/models/` package with submodules per domain concern (see table in section 3)
+10. Keep `models/__init__.py` re-exporting everything for backward compatibility
+11. Move display DTOs to `models/display.py`
+
+### Phase 4: Split formatters.py by display concern
+
+12. Split into `display/session_display.py`, `display/impact_display.py`, `display/experiment_display.py`, etc.
+
+---
+
+## 6. Methodology — Subagent Dispatch for Domain Modeling Audits
+
+This section documents the reproducible approach used to produce this audit.
+
+### Step 1: Parallel Exploration (3 concurrent Explore agents)
+
+Three `Task(subagent_type=Explore)` agents launched in a **single message** (parallel execution):
+
+**Agent 1 — "Find all Pydantic BaseModels"**
+```
+Prompt: Find ALL Pydantic BaseModel subclasses in the repo. For each, report:
+1. Class name, file path, line number
+2. Fields (names and types)
+3. Whether it's domain model, settings/config, or DTO
+Also look for TypedDict, NamedTuple, dataclass, or plain dict patterns
+acting as quasi-models but NOT using BaseModel.
+Search thoroughly in all .py files under src/ and tests/.
+```
+
+**Agent 2 — "Find informal data objects"**
+```
+Prompt: Find all places where data is passed using informal structures
+instead of proper domain models:
+1. Raw dicts constructed and passed between functions (especially dict literals with known keys)
+2. Tuples returned from functions and unpacked by callers
+3. Uses of hasattr, getattr, .get() with defaults on objects (indicating missing model definitions)
+4. isinstance checks suggesting polymorphic domain objects need proper modeling
+5. SQL query results used as raw tuples/dicts instead of mapped to models
+6. Any TypedDict, NamedTuple, or dataclass usage
+For each finding, note file path, line number, and what kind of data is being represented.
+Focus on src/slopometry/. Check database.py, formatters.py, and all service files carefully.
+```
+
+**Agent 3 — "Map file responsibilities and deps"**
+```
+Prompt: Map responsibility and dependency structure. For each .py file in src/slopometry/:
+1. What the file is responsible for (brief summary)
+2. What imports it makes from other files in the project
+3. What other files import from it
+4. Identify files that are "too large" (too many responsibilities)
+Pay special attention to:
+- database.py — what different concerns does it handle?
+- formatters.py — what different concerns does it handle?
+- Any service files mixing data access with business logic
+Also identify duplicated logic patterns across files.
+```
+
+### Step 2: Targeted File Reads (verify agent findings)
+
+After agents returned, read critical files directly to verify and detail findings:
+- `models.py` (full read — 1758 lines) — verified all BaseModel definitions
+- `database.py` (sections: lines 1-100, 240-440, 540-790) — verified dict patterns, tuple returns, orchestration methods
+- `formatters.py` (sections: lines 1-100, 94-154, 410-430, 1040-1070, 1480-1500) — verified getattr patterns, business logic in display code
+- `qpe_calculator.py:140-180` — verified getattr on SmellCounts
+- `compact_analyzer.py:1-132` — verified dict-typed fields
+- `transcript_token_analyzer.py:1-85` — verified raw dict parsing
+- `context_coverage_analyzer.py:200-240` — verified .get() chains
+
+### Step 3: Grep for pattern inventory
+
+Single `Grep` call across entire `src/slopometry/`:
+```
+pattern: \.get\(|getattr\(|hasattr\(|isinstance\(
+glob: *.py
+output_mode: content
+```
+This produced the exhaustive list of all informal access patterns, cross-referenced against agent findings to ensure nothing was missed.
+
+### Step 4: Glob for file inventory
+
+```
+pattern: src/slopometry/**/*.py
+```
+Established the complete file list (54 .py files) to verify agents covered all relevant files.
+
+### Key Design Decisions in the Methodology
+
+1. **3 agents with orthogonal focus areas** — avoids duplicate work while covering: (a) what models exist, (b) what's missing, (c) how files relate
+2. **Agents first, then targeted reads** — agents surface which files matter most; targeted reads verify specific line-level details
+3. **Single grep for pattern inventory** — comprehensive sweep that agents can miss due to context window limits
+4. **Domain grouping table** — derived by mapping each model to its primary consumer files (from Agent 3's dependency map), then clustering by shared consumers
+5. **Severity classification** — `.get()` on external data (transcript JSONL, XML) classified as justified; `.get()`/`getattr` on internal typed models classified as modeling gaps
diff --git a/src/slopometry/core/database.py b/src/slopometry/core/database.py
index 477288c..3b190df 100644
--- a/src/slopometry/core/database.py
+++ b/src/slopometry/core/database.py
@@ -11,6 +11,7 @@
from slopometry.core.migrations import MigrationRunner
logger = logging.getLogger(__name__)
+from slopometry.core.display_models import SessionDisplayData
from slopometry.core.models import (
ComplexityDelta,
ContextCoverage,
@@ -129,7 +130,8 @@ def _create_tables(self) -> None:
worktree_path TEXT,
start_time TEXT NOT NULL,
end_time TEXT,
- status TEXT NOT NULL
+ status TEXT NOT NULL,
+ nfp_objective_id TEXT REFERENCES nfp_objectives(id)
)
""")
@@ -244,23 +246,6 @@ def _create_tables(self) -> None:
)
""")
- try:
- conn.execute("""
- ALTER TABLE experiment_runs
- ADD COLUMN nfp_objective_id TEXT
- REFERENCES nfp_objectives(id)
- """)
- except sqlite3.OperationalError:
- pass
-
- try:
- conn.execute("""
- ALTER TABLE complexity_evolution
- ADD COLUMN test_coverage_percent REAL
- """)
- except sqlite3.OperationalError:
- pass
-
conn.execute("CREATE INDEX IF NOT EXISTS idx_experiment_runs_status ON experiment_runs(status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_progress_cli ON experiment_progress(experiment_id, cli_score)")
conn.execute(
@@ -392,7 +377,8 @@ def get_session_events(self, session_id: str) -> list[HookEvent]:
try:
git_state_data = json.loads(row["git_state"])
git_state = GitState.model_validate(git_state_data)
- except (json.JSONDecodeError, ValueError):
+ except (json.JSONDecodeError, ValueError) as e:
+ logger.debug("Corrupt git_state JSON in session row: %s", e)
git_state = None
working_directory = row["working_directory"] or "Unknown"
@@ -839,18 +825,18 @@ def list_sessions_by_repository(self, repository_path: Path, limit: int | None =
rows = conn.execute(query, params).fetchall()
return [row[0] for row in rows]
- def get_sessions_summary(self, limit: int | None = None) -> list[dict]:
+ def get_sessions_summary(self, limit: int | None = None) -> list[SessionDisplayData]:
"""Get lightweight session summaries for list display."""
with self._get_db_connection() as conn:
query = """
- SELECT
+ SELECT
session_id,
MIN(timestamp) as start_time,
COUNT(*) as total_events,
COUNT(DISTINCT tool_type) as tools_used,
project_name,
project_source
- FROM hook_events
+ FROM hook_events
WHERE session_id IS NOT NULL
GROUP BY session_id, project_name, project_source
ORDER BY MIN(timestamp) DESC
@@ -863,14 +849,14 @@ def get_sessions_summary(self, limit: int | None = None) -> list[dict]:
summaries = []
for row in rows:
summaries.append(
- {
- "session_id": row[0],
- "start_time": row[1],
- "total_events": row[2],
- "tools_used": row[3] if row[3] is not None else 0,
- "project_name": row[4],
- "project_source": row[5],
- }
+ SessionDisplayData(
+ session_id=row[0],
+ start_time=str(row[1]),
+ total_events=row[2],
+ tools_used=row[3] if row[3] is not None else 0,
+ project_name=row[4],
+ project_source=row[5],
+ )
)
return summaries
@@ -1575,7 +1561,8 @@ def get_cached_baseline(self, repository_path: str, head_commit_sha: str) -> Rep
current_metrics_json,
oldest_commit_date, newest_commit_date, oldest_commit_tokens,
strategy_json,
- qpe_stats_json, current_qpe_json
+ qpe_stats_json, current_qpe_json,
+ qpe_weight_version
FROM repo_baselines
WHERE repository_path = ? AND head_commit_sha = ?
""",
@@ -1639,6 +1626,7 @@ def get_cached_baseline(self, repository_path: str, head_commit_sha: str) -> Rep
strategy=strategy,
qpe_stats=qpe_stats,
current_qpe=current_qpe,
+ qpe_weight_version=row[29],
)
def save_baseline(self, baseline: RepoBaseline) -> None:
@@ -1658,8 +1646,9 @@ def save_baseline(self, baseline: RepoBaseline) -> None:
current_metrics_json,
oldest_commit_date, newest_commit_date, oldest_commit_tokens,
strategy_json,
- qpe_stats_json, current_qpe_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ qpe_stats_json, current_qpe_json,
+ qpe_weight_version
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
baseline.repository_path,
@@ -1691,6 +1680,7 @@ def save_baseline(self, baseline: RepoBaseline) -> None:
strategy_json,
qpe_stats_json,
current_qpe_json,
+ baseline.qpe_weight_version,
),
)
conn.commit()
@@ -1707,8 +1697,9 @@ def save_leaderboard_entry(self, entry: LeaderboardEntry) -> None:
INSERT INTO qpe_leaderboard (
project_name, project_path, commit_sha_short, commit_sha_full,
measured_at, qpe_score, mi_normalized, smell_penalty,
- adjusted_quality, effort_factor, total_effort, metrics_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ adjusted_quality, effort_factor, total_effort, metrics_json,
+ qpe_weight_version
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_path) DO UPDATE SET
project_name = excluded.project_name,
commit_sha_short = excluded.commit_sha_short,
@@ -1720,7 +1711,8 @@ def save_leaderboard_entry(self, entry: LeaderboardEntry) -> None:
adjusted_quality = excluded.adjusted_quality,
effort_factor = excluded.effort_factor,
total_effort = excluded.total_effort,
- metrics_json = excluded.metrics_json
+ metrics_json = excluded.metrics_json,
+ qpe_weight_version = excluded.qpe_weight_version
""",
(
entry.project_name,
@@ -1735,6 +1727,7 @@ def save_leaderboard_entry(self, entry: LeaderboardEntry) -> None:
entry.effort_factor,
entry.total_effort,
entry.metrics_json,
+ entry.qpe_weight_version,
),
)
conn.commit()
@@ -1746,7 +1739,8 @@ def get_leaderboard(self) -> list[LeaderboardEntry]:
"""
SELECT id, project_name, project_path, commit_sha_short, commit_sha_full,
measured_at, qpe_score, mi_normalized, smell_penalty,
- adjusted_quality, effort_factor, total_effort, metrics_json
+ adjusted_quality, effort_factor, total_effort, metrics_json,
+ qpe_weight_version
FROM qpe_leaderboard
ORDER BY qpe_score DESC
"""
@@ -1767,6 +1761,7 @@ def get_leaderboard(self) -> list[LeaderboardEntry]:
effort_factor=row[10],
total_effort=row[11],
metrics_json=row[12],
+ qpe_weight_version=row[13],
)
for row in rows
]
@@ -1778,7 +1773,8 @@ def get_project_history(self, project_path: str) -> list[LeaderboardEntry]:
"""
SELECT id, project_name, project_path, commit_sha_short, commit_sha_full,
measured_at, qpe_score, mi_normalized, smell_penalty,
- adjusted_quality, effort_factor, total_effort, metrics_json
+ adjusted_quality, effort_factor, total_effort, metrics_json,
+ qpe_weight_version
FROM qpe_leaderboard
WHERE project_path = ?
ORDER BY measured_at DESC
@@ -1801,6 +1797,7 @@ def get_project_history(self, project_path: str) -> list[LeaderboardEntry]:
effort_factor=row[10],
total_effort=row[11],
metrics_json=row[12],
+ qpe_weight_version=row[13],
)
for row in rows
]
@@ -1832,7 +1829,8 @@ def get_next_sequence_number(self, session_id: str) -> int:
try:
current_seq = int(seq_file.read_text().strip())
next_seq = current_seq + 1
- except (ValueError, FileNotFoundError):
+ except (ValueError, FileNotFoundError) as e:
+ logger.debug("Corrupt or missing sequence file for session %s, resetting to 1: %s", session_id, e)
next_seq = 1
else:
next_seq = 1
diff --git a/src/slopometry/core/display_models.py b/src/slopometry/core/display_models.py
new file mode 100644
index 0000000..1d4c532
--- /dev/null
+++ b/src/slopometry/core/display_models.py
@@ -0,0 +1,71 @@
+"""Display models for presenting data in tables and CLI output.
+
+These models are simple data containers for formatting and presentation,
+used primarily by formatters.py and CLI commands.
+"""
+
+from pydantic import BaseModel, Field
+
+
+class UserStoryDisplayData(BaseModel):
+ """Display data for user story entries in tables."""
+
+ entry_id: str = Field(description="Short ID of the entry")
+ date: str = Field(description="Formatted creation date")
+ commits: str = Field(description="Short commit range display")
+ rating: str = Field(description="Formatted rating display")
+ model: str = Field(description="Model used for generation")
+ repository: str = Field(description="Repository name")
+
+
+class SessionDisplayData(BaseModel):
+ """Display data for session entries in tables."""
+
+ session_id: str = Field(description="Session identifier")
+ start_time: str = Field(description="Formatted session start time")
+ total_events: int = Field(description="Total number of events in session")
+ tools_used: int = Field(description="Number of unique tools used")
+ project_name: str | None = Field(description="Project name if detected")
+ project_source: str | None = Field(description="Project source/path")
+
+
+class FeatureDisplayData(BaseModel):
+ """Display data for feature boundary entries in tables."""
+
+ feature_id: str = Field(description="Short feature identifier")
+ feature_message: str = Field(description="Feature title/message")
+ commits_display: str = Field(description="Base → Head commit display")
+ best_entry_id: str = Field(description="Best user story entry ID or 'N/A'")
+ merge_message: str = Field(description="Merge commit message")
+
+
+class ExperimentDisplayData(BaseModel):
+ """Display data for experiment runs in tables."""
+
+ id: str = Field(description="Experiment ID")
+ repository_name: str = Field(description="Name of the repository")
+ commits_display: str = Field(description="Formatted commit range (e.g., 'abc123 → def456')")
+ start_time: str = Field(description="Formatted start time")
+ duration: str = Field(description="Formatted duration or 'Running...'")
+ status: str = Field(description="Current status (running, completed, failed)")
+
+
+class ProgressDisplayData(BaseModel):
+ """Display data for experiment progress rows."""
+
+ timestamp: str = Field(description="Formatted timestamp (HH:MM:SS)")
+ cli_score: str = Field(description="Formatted CLI score")
+ complexity_score: str = Field(description="Formatted complexity score")
+ halstead_score: str = Field(description="Formatted Halstead score")
+ maintainability_score: str = Field(description="Formatted maintainability score")
+
+
+class NFPObjectiveDisplayData(BaseModel):
+ """Display data for NFP objectives in tables."""
+
+ id: str = Field(description="Objective ID")
+ title: str = Field(description="Objective title")
+ commits: str = Field(description="Formatted commit range")
+ story_count: int = Field(description="Number of associated user stories")
+ complexity: int = Field(description="Complexity metric")
+ created_date: str = Field(description="Formatted creation date")
diff --git a/src/slopometry/core/language_config.py b/src/slopometry/core/language_config.py
index d1809f5..ffa3458 100644
--- a/src/slopometry/core/language_config.py
+++ b/src/slopometry/core/language_config.py
@@ -8,14 +8,14 @@
The design allows easy extension to new languages while keeping type safety.
"""
-from dataclasses import dataclass, field
from pathlib import Path
-from slopometry.core.models import ProjectLanguage
+from pydantic import BaseModel, Field
+from slopometry.core.language_models import ProjectLanguage
-@dataclass(frozen=True)
-class LanguageConfig:
+
+class LanguageConfig(BaseModel):
"""Configuration for a programming language's file patterns.
Attributes:
@@ -26,11 +26,13 @@ class LanguageConfig:
test_patterns: Glob patterns for test files
"""
- language: ProjectLanguage
+ model_config = {"frozen": True}
+
+ language: "ProjectLanguage"
extensions: tuple[str, ...]
git_patterns: tuple[str, ...]
- ignore_dirs: tuple[str, ...] = field(default_factory=tuple)
- test_patterns: tuple[str, ...] = field(default_factory=tuple)
+ ignore_dirs: tuple[str, ...] = Field(default_factory=tuple)
+ test_patterns: tuple[str, ...] = Field(default_factory=tuple)
def matches_extension(self, file_path: Path | str) -> bool:
"""Check if a file path matches this language's extensions."""
diff --git a/src/slopometry/core/language_detector.py b/src/slopometry/core/language_detector.py
index 5a0b7fe..c5a46f4 100644
--- a/src/slopometry/core/language_detector.py
+++ b/src/slopometry/core/language_detector.py
@@ -4,7 +4,7 @@
import subprocess
from pathlib import Path
-from slopometry.core.models import ProjectLanguage
+from slopometry.core.language_models import ProjectLanguage
logger = logging.getLogger(__name__)
diff --git a/src/slopometry/core/language_guard.py b/src/slopometry/core/language_guard.py
index fcd588f..ddbee76 100644
--- a/src/slopometry/core/language_guard.py
+++ b/src/slopometry/core/language_guard.py
@@ -3,7 +3,7 @@
from pathlib import Path
from slopometry.core.language_detector import LanguageDetector
-from slopometry.core.models import LanguageGuardResult, ProjectLanguage
+from slopometry.core.language_models import LanguageGuardResult, ProjectLanguage
def check_language_support(
diff --git a/src/slopometry/core/language_models.py b/src/slopometry/core/language_models.py
new file mode 100644
index 0000000..35d0273
--- /dev/null
+++ b/src/slopometry/core/language_models.py
@@ -0,0 +1,31 @@
+"""Language-related models for complexity analysis features."""
+
+from enum import Enum
+
+from pydantic import BaseModel, Field
+
+
+class ProjectLanguage(str, Enum):
+ """Supported languages for complexity analysis."""
+
+ PYTHON = "python"
+ RUST = "rust"
+
+
+class LanguageGuardResult(BaseModel):
+ """Result of language guard check for complexity analysis features."""
+
+ allowed: bool = Field(description="Whether the required language is available for analysis")
+ required_language: ProjectLanguage = Field(description="The language required by the feature")
+ detected_supported: set[ProjectLanguage] = Field(
+ default_factory=set, description="Languages detected in repo that are supported"
+ )
+ detected_unsupported: set[str] = Field(
+ default_factory=set, description="Language names detected but not supported (e.g., 'Rust', 'Go')"
+ )
+
+ def format_warning(self) -> str | None:
+ """Return warning message if unsupported languages found, else None."""
+ if not self.detected_unsupported:
+ return None
+ return f"Found {', '.join(sorted(self.detected_unsupported))} files but analysis not yet supported"
diff --git a/src/slopometry/core/migrations.py b/src/slopometry/core/migrations.py
index 04d7ca5..3ca86b7 100644
--- a/src/slopometry/core/migrations.py
+++ b/src/slopometry/core/migrations.py
@@ -385,6 +385,62 @@ def up(self, conn: sqlite3.Connection) -> None:
raise
+class Migration011AddQPEWeightVersionColumn(Migration):
+ """Add qpe_weight_version column to qpe_leaderboard and repo_baselines.
+
+ Tracks which QPE_WEIGHT_VERSION was used to compute cached scores.
+ Entries with NULL or mismatched versions trigger a warning and recomputation.
+ """
+
+ @property
+ def version(self) -> str:
+ return "011"
+
+ @property
+ def description(self) -> str:
+ return "Add qpe_weight_version column to qpe_leaderboard and repo_baselines"
+
+ def up(self, conn: sqlite3.Connection) -> None:
+ for table in ("qpe_leaderboard", "repo_baselines"):
+ cursor = conn.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table}'")
+ if not cursor.fetchone():
+ continue
+ try:
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN qpe_weight_version TEXT")
+ except sqlite3.OperationalError as e:
+ if "duplicate column name" not in str(e).lower():
+ raise
+
+
+class Migration012AddNFPObjectiveToExperimentRuns(Migration):
+ """Add nfp_objective_id column to experiment_runs.
+
+ Previously added via a try/except ALTER TABLE in _ensure_tables.
+ Moved to a proper migration for consistency.
+ """
+
+ @property
+ def version(self) -> str:
+ return "012"
+
+ @property
+ def description(self) -> str:
+ return "Add nfp_objective_id column to experiment_runs"
+
+ def up(self, conn: sqlite3.Connection) -> None:
+ cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='experiment_runs'")
+ if not cursor.fetchone():
+ return
+ try:
+ conn.execute("""
+ ALTER TABLE experiment_runs
+ ADD COLUMN nfp_objective_id TEXT REFERENCES nfp_objectives(id)
+ """)
+ except sqlite3.OperationalError as e:
+ if "duplicate column name" not in str(e).lower():
+ raise
+
+
class MigrationRunner:
"""Manages database migrations."""
@@ -401,6 +457,8 @@ def __init__(self, db_path: Path):
Migration008FixLeaderboardUniqueConstraint(),
Migration009AddBaselineStrategyColumn(),
Migration010AddBaselineQPEColumns(),
+ Migration011AddQPEWeightVersionColumn(),
+ Migration012AddNFPObjectiveToExperimentRuns(),
]
@contextmanager
diff --git a/src/slopometry/core/models.py b/src/slopometry/core/models.py
index fd16c9d..33c2e60 100644
--- a/src/slopometry/core/models.py
+++ b/src/slopometry/core/models.py
@@ -43,7 +43,7 @@ class SmellDefinition(BaseModel):
internal_name="orphan_comment",
label="Orphan Comments",
category=SmellCategory.GENERAL,
- weight=0.02,
+ weight=0.01,
guidance="Make sure inline code comments add meaningful information about non-obvious design tradeoffs or explain tech debt or performance implications. Consider if these could be docstrings or field descriptors instead",
count_field="orphan_comment_count",
files_field="orphan_comment_files",
@@ -97,7 +97,7 @@ class SmellDefinition(BaseModel):
internal_name="inline_import",
label="Inline Imports",
category=SmellCategory.PYTHON,
- weight=0.02,
+ weight=0.01,
guidance="Verify if these can be moved to the top of the file (except TYPE_CHECKING guards)",
count_field="inline_import_count",
files_field="inline_import_files",
@@ -261,13 +261,6 @@ def resolved_must_be_concrete(cls, v: BaselineStrategy) -> BaselineStrategy:
return v
-class ProjectLanguage(str, Enum):
- """Supported languages for complexity analysis."""
-
- PYTHON = "python"
- RUST = "rust"
-
-
class ProjectSource(str, Enum):
"""Source of project identification."""
@@ -1175,49 +1168,6 @@ class UserStoryStatistics(BaseModel):
rating_distribution: dict[str, int] = Field(description="Distribution of ratings")
-class UserStoryDisplayData(BaseModel):
- """Display data for user story entries in tables."""
-
- entry_id: str = Field(description="Short ID of the entry")
- date: str = Field(description="Formatted creation date")
- commits: str = Field(description="Short commit range display")
- rating: str = Field(description="Formatted rating display")
- model: str = Field(description="Model used for generation")
- repository: str = Field(description="Repository name")
-
-
-class ExperimentDisplayData(BaseModel):
- """Display data for experiment runs in tables."""
-
- id: str = Field(description="Experiment ID")
- repository_name: str = Field(description="Name of the repository")
- commits_display: str = Field(description="Formatted commit range (e.g., 'abc123 → def456')")
- start_time: str = Field(description="Formatted start time")
- duration: str = Field(description="Formatted duration or 'Running...'")
- status: str = Field(description="Current status (running, completed, failed)")
-
-
-class ProgressDisplayData(BaseModel):
- """Display data for experiment progress rows."""
-
- timestamp: str = Field(description="Formatted timestamp (HH:MM:SS)")
- cli_score: str = Field(description="Formatted CLI score")
- complexity_score: str = Field(description="Formatted complexity score")
- halstead_score: str = Field(description="Formatted Halstead score")
- maintainability_score: str = Field(description="Formatted maintainability score")
-
-
-class NFPObjectiveDisplayData(BaseModel):
- """Display data for NFP objectives in tables."""
-
- id: str = Field(description="Objective ID")
- title: str = Field(description="Objective title")
- commits: str = Field(description="Formatted commit range")
- story_count: int = Field(description="Number of associated user stories")
- complexity: int = Field(description="Complexity metric")
- created_date: str = Field(description="Formatted creation date")
-
-
class CodeQualityCache(BaseModel):
"""Cached code quality metrics for a specific session/repository/commit combination."""
@@ -1350,6 +1300,11 @@ class RepoBaseline(BaseModel):
"Used for cache invalidation: strategy mismatch with current settings triggers recomputation.",
)
+ qpe_weight_version: str | None = Field(
+ default=None,
+ description="QPE_WEIGHT_VERSION at time of computation. None = pre-versioning entry.",
+ )
+
class ZScoreInterpretation(str, Enum):
"""Human-readable interpretation of Z-score values."""
@@ -1591,6 +1546,10 @@ class LeaderboardEntry(BaseModel):
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")
+ qpe_weight_version: str | None = Field(
+ default=None,
+ description="QPE_WEIGHT_VERSION at time of computation. None = pre-versioning entry.",
+ )
class StagedChangesAnalysis(BaseModel):
@@ -1656,6 +1615,49 @@ class CurrentChangesAnalysis(BaseModel):
)
+class CurrentImpactSummary(BaseModel):
+ """Compact JSON output of current-impact analysis for CI consumption.
+
+ Extracts the essential fields from CurrentChangesAnalysis, omitting
+ the large nested baseline and full metrics objects.
+ """
+
+ source: AnalysisSource = Field(description="Whether analyzing uncommitted changes or previous commit")
+ analyzed_commit_sha: str | None = Field(
+ default=None, description="SHA of the analyzed commit (when source is previous_commit)"
+ )
+ base_commit_sha: str | None = Field(
+ default=None, description="SHA of the base commit (when source is previous_commit)"
+ )
+ impact_score: float = Field(description="Weighted composite impact score")
+ impact_category: ImpactCategory = Field(description="Human-readable impact category")
+ qpe_delta: float = Field(description="Change in QPE score")
+ cc_delta: float = Field(description="Change in cyclomatic complexity")
+ effort_delta: float = Field(description="Change in Halstead effort")
+ mi_delta: float = Field(description="Change in maintainability index")
+ changed_files_count: int = Field(description="Number of changed code files")
+ blind_spots_count: int = Field(description="Number of dependent files not in changed set")
+ smell_advantages: list["SmellAdvantage"] = Field(default_factory=list, description="Per-smell advantage breakdown")
+
+ @staticmethod
+ def from_analysis(analysis: "CurrentChangesAnalysis") -> "CurrentImpactSummary":
+ """Create compact summary from full analysis."""
+ return CurrentImpactSummary(
+ source=analysis.source,
+ analyzed_commit_sha=analysis.analyzed_commit_sha,
+ base_commit_sha=analysis.base_commit_sha,
+ impact_score=analysis.assessment.impact_score,
+ impact_category=analysis.assessment.impact_category,
+ qpe_delta=analysis.assessment.qpe_delta,
+ cc_delta=analysis.assessment.cc_delta,
+ effort_delta=analysis.assessment.effort_delta,
+ mi_delta=analysis.assessment.mi_delta,
+ changed_files_count=len(analysis.changed_files),
+ blind_spots_count=len(analysis.blind_spots),
+ smell_advantages=analysis.smell_advantages,
+ )
+
+
class FileCoverageStatus(BaseModel):
"""Coverage status for a single edited file showing what context was read."""
@@ -1736,22 +1738,3 @@ def has_gaps(self) -> bool:
or self.overall_dependents_coverage < 100
or bool(self.blind_spots)
)
-
-
-class LanguageGuardResult(BaseModel):
- """Result of language guard check for complexity analysis features."""
-
- allowed: bool = Field(description="Whether the required language is available for analysis")
- required_language: ProjectLanguage = Field(description="The language required by the feature")
- detected_supported: set[ProjectLanguage] = Field(
- default_factory=set, description="Languages detected in repo that are supported"
- )
- detected_unsupported: set[str] = Field(
- default_factory=set, description="Language names detected but not supported (e.g., 'Rust', 'Go')"
- )
-
- def format_warning(self) -> str | None:
- """Return warning message if unsupported languages found, else None."""
- if not self.detected_unsupported:
- return None
- return f"Found {', '.join(sorted(self.detected_unsupported))} files but analysis not yet supported"
diff --git a/src/slopometry/core/working_tree_state.py b/src/slopometry/core/working_tree_state.py
index 6e13af4..4253306 100644
--- a/src/slopometry/core/working_tree_state.py
+++ b/src/slopometry/core/working_tree_state.py
@@ -9,7 +9,7 @@
get_combined_git_patterns,
should_ignore_path,
)
-from slopometry.core.models import ProjectLanguage
+from slopometry.core.language_models import ProjectLanguage
class WorkingTreeStateCalculator:
diff --git a/src/slopometry/display/formatters.py b/src/slopometry/display/formatters.py
index 97af6cd..e7ea3d9 100644
--- a/src/slopometry/display/formatters.py
+++ b/src/slopometry/display/formatters.py
@@ -7,14 +7,18 @@
from rich.table import Table
+from slopometry.core.display_models import (
+ ExperimentDisplayData,
+ FeatureDisplayData,
+ NFPObjectiveDisplayData,
+ ProgressDisplayData,
+ SessionDisplayData,
+)
from slopometry.core.models import (
SMELL_REGISTRY,
BaselineStrategy,
CompactEvent,
- ExperimentDisplayData,
ImplementationComparison,
- NFPObjectiveDisplayData,
- ProgressDisplayData,
SmellAdvantage,
SmellCategory,
TokenUsage,
@@ -820,7 +824,7 @@ def _format_coverage_ratio(read: int, total: int) -> str:
return f"[{color}]{read}/{total}[/{color}]"
-def create_sessions_table(sessions_data: list[dict]) -> Table:
+def create_sessions_table(sessions_data: list[SessionDisplayData]) -> Table:
"""Create a Rich table for displaying session list."""
table = Table(title="Recent Sessions")
table.add_column("Session ID", style="cyan")
@@ -831,16 +835,14 @@ def create_sessions_table(sessions_data: list[dict]) -> Table:
for session_data in sessions_data:
project_display = (
- f"{session_data['project_name']} ({session_data['project_source']})"
- if session_data["project_name"]
- else "N/A"
+ f"{session_data.project_name} ({session_data.project_source})" if session_data.project_name else "N/A"
)
table.add_row(
- session_data["session_id"],
+ session_data.session_id,
project_display,
- session_data["start_time"],
- str(session_data["total_events"]),
- str(session_data["tools_used"]),
+ session_data.start_time,
+ str(session_data.total_events),
+ str(session_data.tools_used),
)
return table
@@ -917,7 +919,7 @@ def create_nfp_objectives_table(objectives_data: list[NFPObjectiveDisplayData])
return table
-def create_features_table(features_data: list[dict]) -> Table:
+def create_features_table(features_data: list[FeatureDisplayData]) -> Table:
"""Create a Rich table for displaying detected features."""
table = Table(title="Detected Features", show_lines=True)
table.add_column("Feature ID", style="blue", no_wrap=True, width=10)
@@ -928,11 +930,11 @@ def create_features_table(features_data: list[dict]) -> Table:
for feature_data in features_data:
table.add_row(
- feature_data["feature_id"],
- feature_data["feature_message"],
- feature_data["commits_display"],
- feature_data["best_entry_id"],
- feature_data["merge_message"],
+ feature_data.feature_id,
+ feature_data.feature_message,
+ feature_data.commits_display,
+ feature_data.best_entry_id,
+ feature_data.merge_message,
)
return table
diff --git a/src/slopometry/solo/services/session_service.py b/src/slopometry/solo/services/session_service.py
index 0a861c4..73eb3af 100644
--- a/src/slopometry/solo/services/session_service.py
+++ b/src/slopometry/solo/services/session_service.py
@@ -4,6 +4,7 @@
from pathlib import Path
from slopometry.core.database import EventDatabase
+from slopometry.core.display_models import SessionDisplayData
from slopometry.core.models import SessionStatistics
@@ -54,27 +55,27 @@ def cleanup_all_sessions(self) -> tuple[int, int, int]:
"""Clean up all session data."""
return self.db.cleanup_all_sessions()
- def get_sessions_for_display(self, limit: int | None = None) -> list[dict]:
+ def get_sessions_for_display(self, limit: int | None = None) -> list[SessionDisplayData]:
"""Get session summaries formatted for display."""
summaries = self.db.get_sessions_summary(limit=limit)
sessions_data = []
for summary in summaries:
try:
- start_time = datetime.fromisoformat(summary["start_time"])
+ start_time = datetime.fromisoformat(summary.start_time)
formatted_time = start_time.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
- formatted_time = summary["start_time"] or "Unknown"
+ formatted_time = summary.start_time or "Unknown"
sessions_data.append(
- {
- "session_id": summary["session_id"],
- "project_name": summary["project_name"],
- "project_source": summary["project_source"],
- "start_time": formatted_time,
- "total_events": summary["total_events"],
- "tools_used": summary["tools_used"],
- }
+ SessionDisplayData(
+ session_id=summary.session_id,
+ project_name=summary.project_name,
+ project_source=summary.project_source,
+ start_time=formatted_time,
+ total_events=summary.total_events,
+ tools_used=summary.tools_used,
+ )
)
return sessions_data
diff --git a/src/slopometry/summoner/cli/commands.py b/src/slopometry/summoner/cli/commands.py
index fd82d6a..46c0e2b 100644
--- a/src/slopometry/summoner/cli/commands.py
+++ b/src/slopometry/summoner/cli/commands.py
@@ -132,7 +132,7 @@ def compare_subtrees(prefix_a: str, prefix_b: str, ref: str, repo_path: Path | N
repo_path = Path.cwd()
from slopometry.core.language_guard import check_language_support
- from slopometry.core.models import ProjectLanguage
+ from slopometry.core.language_models import ProjectLanguage
from slopometry.summoner.services.implementation_comparator import (
SubtreeExtractionError,
compare_subtrees,
@@ -194,7 +194,7 @@ def compare_subtrees(prefix_a: str, prefix_b: str, ref: str, repo_path: Path | N
def run_experiments(commits: int, max_workers: int, repo_path: Path | None) -> None:
"""Run parallel experiments across git commits to track and analyze code complexity evolution patterns."""
from slopometry.core.language_guard import check_language_support
- from slopometry.core.models import ProjectLanguage
+ from slopometry.core.language_models import ProjectLanguage
from slopometry.summoner.services.experiment_service import ExperimentService
if repo_path is None:
@@ -240,7 +240,7 @@ def run_experiments(commits: int, max_workers: int, repo_path: Path | None) -> N
def analyze_commits(start: str | None, end: str | None, repo_path: Path | None) -> None:
"""Analyze complexity evolution across a chain of commits."""
from slopometry.core.language_guard import check_language_support
- from slopometry.core.models import ProjectLanguage
+ from slopometry.core.language_models import ProjectLanguage
from slopometry.summoner.services.experiment_service import ExperimentService
if repo_path is None:
@@ -375,20 +375,30 @@ def _show_commit_range_baseline_comparison(repo_path: Path, start: str, end: str
help="Show detailed file lists (blind spots)",
)
@click.option("--pager/--no-pager", default=True, help="Use pager for long output (like less)")
+@click.option(
+ "--json",
+ "output_json",
+ is_flag=True,
+ help="Output compact JSON for CI consumption",
+)
def current_impact(
repo_path: Path | None,
recompute_baseline: bool,
max_workers: int,
file_details: bool,
pager: bool,
+ output_json: bool,
) -> None:
- """Analyze impact of uncommitted changes against repository baseline.
+ """Analyze impact of changes against repository baseline.
Computes a baseline from your entire commit history, then evaluates
- whether your uncommitted changes represent above-average or below-average
+ whether your changes represent above-average or below-average
quality impact compared to typical commits in this repository.
- Analyzes all uncommitted changes (staged + unstaged).
+ Analyzes uncommitted changes when present, otherwise compares
+ the latest commit against its parent (HEAD vs HEAD~1).
+
+ Use --json for machine-readable output in CI pipelines.
Impact categories:
- SIGNIFICANT_IMPROVEMENT: > 1.0 std dev better than average
@@ -405,11 +415,15 @@ def current_impact(
try:
guard_single_project(repo_path)
except MultiProjectError as e:
- console.print(f"[red]Error:[/red] {e}")
+ if output_json:
+ print(f'{{"error": "{e.project_count} git repositories found. Run from within a specific project."}}')
+ else:
+ console.print(f"[red]Error:[/red] {e}")
return
from slopometry.core.language_guard import check_language_support
- from slopometry.core.models import ProjectLanguage
+ from slopometry.core.language_models import ProjectLanguage
+ from slopometry.core.models import CurrentImpactSummary
from slopometry.core.working_tree_extractor import WorkingTreeExtractor
from slopometry.display.formatters import display_current_impact_analysis
from slopometry.summoner.services.baseline_service import BaselineService
@@ -417,9 +431,13 @@ def current_impact(
guard = check_language_support(repo_path, ProjectLanguage.PYTHON)
if warning := guard.format_warning():
- console.print(f"[dim]{warning}[/dim]")
+ if not output_json:
+ console.print(f"[dim]{warning}[/dim]")
if not guard.allowed:
- console.print("[yellow]current-impact requires Python files for complexity analysis.[/yellow]")
+ if output_json:
+ print('{"error": "No code files detected in repository"}')
+ else:
+ console.print("[yellow]current-impact requires Python files for complexity analysis.[/yellow]")
return
extractor = WorkingTreeExtractor(repo_path)
@@ -431,46 +449,65 @@ def current_impact(
if not changed_files:
if not extractor.has_uncommitted_changes():
- console.print("[yellow]No uncommitted changes found. Falling back to analyzing previous commit.[/yellow]")
+ logger.info("No uncommitted tracked code files found, analyzing previous commit (HEAD vs HEAD~1)")
analyze_previous = True
else:
- console.print("[yellow]No uncommitted Python files found.[/yellow]")
- console.print("[dim]Modify some Python files and try again.[/dim]")
+ if output_json:
+ print('{"error": "No code files found in uncommitted changes"}')
+ else:
+ console.print("[yellow]No uncommitted Python files found.[/yellow]")
+ console.print("[dim]Modify some Python files and try again.[/dim]")
return
- console.print(f"Repository: {repo_path}")
+ if not output_json:
+ console.print(f"Repository: {repo_path}")
+ console.print("\n[yellow]Computing repository baseline...[/yellow]")
- console.print("\n[yellow]Computing repository baseline...[/yellow]")
baseline = baseline_service.get_or_compute_baseline(
repo_path, recompute=recompute_baseline, max_workers=max_workers
)
if not baseline:
- console.print("[red]Failed to compute baseline.[/red]")
- console.print("[dim]Ensure the repository has at least 2 commits.[/dim]")
+ if output_json:
+ print('{"error": "Failed to compute baseline. Ensure the repository has at least 2 commits."}')
+ else:
+ console.print("[red]Failed to compute baseline.[/red]")
+ console.print("[dim]Ensure the repository has at least 2 commits.[/dim]")
return
- console.print(f"[green]✓ Baseline computed from {baseline.total_commits_analyzed} commits[/green]")
+ if not output_json:
+ console.print(f"[green]✓ Baseline computed from {baseline.total_commits_analyzed} commits[/green]")
try:
if analyze_previous:
- console.print("\n[yellow]Analyzing previous commit...[/yellow]")
+ if not output_json:
+ console.print("\n[yellow]Analyzing previous commit...[/yellow]")
analysis = current_impact_service.analyze_previous_commit(repo_path, baseline)
if not analysis:
- console.print("[red]Failed to analyze previous commit.[/red]")
- console.print("[dim]Ensure the repository has at least 2 commits with Python changes.[/dim]")
+ if output_json:
+ print(
+ '{"error": "Failed to analyze previous commit. Ensure at least 2 commits with code changes."}'
+ )
+ else:
+ console.print("[red]Failed to analyze previous commit.[/red]")
+ console.print("[dim]Ensure the repository has at least 2 commits with Python changes.[/dim]")
return
- console.print(f"Changed Python files in previous commit: {len(analysis.changed_files)}")
+ if not output_json:
+ console.print(f"Changed Python files in previous commit: {len(analysis.changed_files)}")
else:
- console.print("[bold]Analyzing uncommitted changes impact[/bold]")
- console.print(f"Changed Python files: {len(changed_files)}")
- console.print("\n[yellow]Analyzing uncommitted changes...[/yellow]")
+ if not output_json:
+ console.print("[bold]Analyzing uncommitted changes impact[/bold]")
+ console.print(f"Changed Python files: {len(changed_files)}")
+ console.print("\n[yellow]Analyzing uncommitted changes...[/yellow]")
analysis = current_impact_service.analyze_uncommitted_changes(repo_path, baseline)
if not analysis:
- console.print("[red]Failed to analyze uncommitted changes.[/red]")
+ if output_json:
+ print('{"error": "Failed to analyze uncommitted changes"}')
+ else:
+ console.print("[red]Failed to analyze uncommitted changes.[/red]")
return
try:
@@ -485,14 +522,21 @@ def current_impact(
except Exception as e:
logger.debug(f"Coverage analysis failed (optional): {e}")
- if pager:
+ if output_json:
+ summary = CurrentImpactSummary.from_analysis(analysis)
+ print(summary.model_dump_json(indent=2))
+ elif pager:
with console.pager(styles=True):
display_current_impact_analysis(analysis, show_file_details=file_details)
else:
display_current_impact_analysis(analysis, show_file_details=file_details)
except Exception as e:
- console.print(f"[red]Failed to analyze changes: {e}[/red]")
+ if output_json:
+ escaped_msg = str(e).replace('"', '\\"')
+ print(f'{{"error": "{escaped_msg}"}}')
+ else:
+ console.print(f"[red]Failed to analyze changes: {e}[/red]")
sys.exit(1)
@@ -1079,7 +1123,7 @@ def qpe(repo_path: Path | None, output_json: bool) -> None:
return
from slopometry.core.language_guard import check_language_support
- from slopometry.core.models import ProjectLanguage
+ from slopometry.core.language_models import ProjectLanguage
guard = check_language_support(repo_path, ProjectLanguage.PYTHON)
if warning := guard.format_warning():
@@ -1153,6 +1197,7 @@ def compare_projects(append_paths: tuple[Path, ...], reset: bool) -> None:
"""
from slopometry.core.database import EventDatabase
from slopometry.display.formatters import display_leaderboard
+ from slopometry.summoner.services.qpe_calculator import QPE_WEIGHT_VERSION
db = EventDatabase()
@@ -1219,6 +1264,7 @@ def compare_projects(append_paths: tuple[Path, ...], reset: bool) -> None:
effort_factor=math.log(metrics.average_effort + 1),
total_effort=metrics.total_effort,
metrics_json=metrics.model_dump_json(),
+ qpe_weight_version=QPE_WEIGHT_VERSION,
)
db.save_leaderboard_entry(entry)
console.print(f"[green]Added {project_path.name} (Quality: {qpe_score.qpe:.4f})[/green]")
@@ -1231,6 +1277,15 @@ def compare_projects(append_paths: tuple[Path, ...], reset: bool) -> None:
console.print("[dim]Leaderboard is empty. Use --append to add projects.[/dim]")
sys.exit(0)
+ stale_entries = [e for e in leaderboard if e.qpe_weight_version != QPE_WEIGHT_VERSION]
+ if stale_entries:
+ stale_names = ", ".join(e.project_name for e in stale_entries)
+ console.print(
+ f"[bold yellow]WARNING: {len(stale_entries)} leaderboard entries were computed with "
+ f"outdated QPE weights and may be inaccurate: {stale_names}[/bold yellow]"
+ )
+ console.print("[yellow]Re-run with --append to recompute these entries.[/yellow]\n")
+
display_leaderboard(leaderboard)
diff --git a/src/slopometry/summoner/services/baseline_service.py b/src/slopometry/summoner/services/baseline_service.py
index 27756df..03bc4c5 100644
--- a/src/slopometry/summoner/services/baseline_service.py
+++ b/src/slopometry/summoner/services/baseline_service.py
@@ -4,11 +4,12 @@
import shutil
import subprocess
from concurrent.futures import ProcessPoolExecutor, as_completed
-from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from statistics import mean, median, stdev
+from pydantic import BaseModel
+
from slopometry.core.complexity_analyzer import ComplexityAnalyzer
from slopometry.core.database import EventDatabase
from slopometry.core.git_tracker import GitOperationError, GitTracker
@@ -19,21 +20,19 @@
ResolvedBaselineStrategy,
)
from slopometry.core.settings import settings
-from slopometry.summoner.services.qpe_calculator import calculate_qpe
+from slopometry.summoner.services.qpe_calculator import QPE_WEIGHT_VERSION, calculate_qpe
logger = logging.getLogger(__name__)
-@dataclass
-class CommitInfo:
+class CommitInfo(BaseModel):
"""Commit SHA and timestamp."""
sha: str
timestamp: datetime
-@dataclass
-class CommitDelta:
+class CommitDelta(BaseModel):
"""Metrics delta between two consecutive commits."""
cc_delta: float
@@ -138,7 +137,14 @@ def get_or_compute_baseline(
if not recompute:
cached = self.db.get_cached_baseline(str(repo_path), head_sha)
if cached and cached.qpe_stats is not None:
- if self._is_cache_strategy_compatible(cached):
+ if cached.qpe_weight_version != QPE_WEIGHT_VERSION:
+ logger.warning(
+ "Cached baseline was computed with QPE weight version %s (current: %s). "
+ "Recomputing with updated weights.",
+ cached.qpe_weight_version or "unknown",
+ QPE_WEIGHT_VERSION,
+ )
+ elif self._is_cache_strategy_compatible(cached):
return cached
baseline = self.compute_full_baseline(repo_path, max_workers=max_workers)
@@ -231,6 +237,7 @@ def compute_full_baseline(self, repo_path: Path, max_workers: int = 4) -> RepoBa
qpe_stats=self._compute_stats("qpe_delta", qpe_deltas),
current_qpe=current_qpe,
strategy=strategy,
+ qpe_weight_version=QPE_WEIGHT_VERSION,
)
def _resolve_strategy(self, repo_path: Path) -> ResolvedBaselineStrategy:
diff --git a/src/slopometry/summoner/services/experiment_service.py b/src/slopometry/summoner/services/experiment_service.py
index c433a4e..cc9b7a6 100644
--- a/src/slopometry/summoner/services/experiment_service.py
+++ b/src/slopometry/summoner/services/experiment_service.py
@@ -4,7 +4,7 @@
from pathlib import Path
from slopometry.core.database import EventDatabase
-from slopometry.core.models import ExperimentDisplayData, ProgressDisplayData
+from slopometry.core.display_models import ExperimentDisplayData, ProgressDisplayData
class ExperimentService:
diff --git a/src/slopometry/summoner/services/llm_service.py b/src/slopometry/summoner/services/llm_service.py
index e7584ba..3a6597c 100644
--- a/src/slopometry/summoner/services/llm_service.py
+++ b/src/slopometry/summoner/services/llm_service.py
@@ -4,6 +4,7 @@
from pathlib import Path
from slopometry.core.database import EventDatabase
+from slopometry.core.display_models import FeatureDisplayData
from slopometry.core.settings import settings
@@ -92,8 +93,10 @@ def get_feature_boundaries(self, repo_path: Path, limit: int = 20) -> list:
finally:
os.chdir(original_dir)
- def prepare_features_data_for_display(self, features: list) -> list[dict]:
+ def prepare_features_data_for_display(self, features: list) -> list[FeatureDisplayData]:
"""Prepare feature boundaries data for display formatting."""
+
+
features_data = []
for feature in features:
base_short = feature.base_commit[:8]
@@ -103,13 +106,13 @@ def prepare_features_data_for_display(self, features: list) -> list[dict]:
best_entry_short = best_entry_id[:8] if best_entry_id else "N/A"
features_data.append(
- {
- "feature_id": feature.short_id,
- "feature_message": feature.feature_message,
- "commits_display": f"{base_short} → {head_short}",
- "merge_message": feature.merge_message,
- "best_entry_id": best_entry_short,
- }
+ FeatureDisplayData(
+ feature_id=feature.short_id,
+ feature_message=feature.feature_message,
+ commits_display=f"{base_short} → {head_short}",
+ merge_message=feature.merge_message,
+ best_entry_id=best_entry_short,
+ )
)
return features_data
diff --git a/src/slopometry/summoner/services/nfp_service.py b/src/slopometry/summoner/services/nfp_service.py
index c03c44d..d944b93 100644
--- a/src/slopometry/summoner/services/nfp_service.py
+++ b/src/slopometry/summoner/services/nfp_service.py
@@ -1,7 +1,8 @@
"""NFP (Next Feature Prediction) service for summoner features."""
from slopometry.core.database import EventDatabase
-from slopometry.core.models import NextFeaturePrediction, NFPObjectiveDisplayData
+from slopometry.core.display_models import NFPObjectiveDisplayData
+from slopometry.core.models import NextFeaturePrediction
class NFPService:
diff --git a/src/slopometry/summoner/services/qpe_calculator.py b/src/slopometry/summoner/services/qpe_calculator.py
index 733b0fa..c9a3526 100644
--- a/src/slopometry/summoner/services/qpe_calculator.py
+++ b/src/slopometry/summoner/services/qpe_calculator.py
@@ -14,6 +14,11 @@
from pathlib import Path
from slopometry.core.complexity_analyzer import ComplexityAnalyzer
+
+# Bump this when SMELL_REGISTRY weights or QPE formula parameters change.
+# Used to detect stale cached QPE scores computed with old weights.
+QPE_WEIGHT_VERSION = "2"
+
from slopometry.core.models import (
SMELL_REGISTRY,
CrossProjectComparison,
@@ -80,9 +85,7 @@ def calculate_qpe(metrics: ExtendedComplexityMetrics) -> QPEScore:
else 0.0
)
type_bonus = (
- settings.qpe_type_coverage_bonus
- if metrics.type_hint_coverage >= settings.qpe_type_coverage_threshold
- else 0.0
+ settings.qpe_type_coverage_bonus if metrics.type_hint_coverage >= settings.qpe_type_coverage_threshold else 0.0
)
docstring_bonus = (
settings.qpe_docstring_coverage_bonus
diff --git a/src/slopometry/summoner/services/user_story_service.py b/src/slopometry/summoner/services/user_story_service.py
index 2e052f6..faff5a0 100644
--- a/src/slopometry/summoner/services/user_story_service.py
+++ b/src/slopometry/summoner/services/user_story_service.py
@@ -6,7 +6,8 @@
import click
from slopometry.core.database import EventDatabase
-from slopometry.core.models import UserStoryDisplayData, UserStoryEntry, UserStoryStatistics
+from slopometry.core.display_models import UserStoryDisplayData
+from slopometry.core.models import UserStoryEntry, UserStoryStatistics
from slopometry.core.settings import settings
from slopometry.display.console import console
diff --git a/tests/test_baseline_service.py b/tests/test_baseline_service.py
index adb787d..59a8301 100644
--- a/tests/test_baseline_service.py
+++ b/tests/test_baseline_service.py
@@ -16,10 +16,54 @@
)
from slopometry.summoner.services.baseline_service import (
BaselineService,
+ CommitDelta,
CommitInfo,
_compute_single_delta_task,
_parse_commit_log,
)
+from slopometry.summoner.services.qpe_calculator import QPE_WEIGHT_VERSION
+
+
+class TestCommitInfo:
+ """Tests for CommitInfo model."""
+
+ def test_commit_info__creation_with_required_fields(self) -> None:
+ """Test creating CommitInfo with required fields."""
+ commit = CommitInfo(sha="abc123", timestamp=datetime.now())
+ assert commit.sha == "abc123"
+ assert isinstance(commit.timestamp, datetime)
+
+ def test_commit_info__round_trips_json(self) -> None:
+ """Test JSON serialization round-trip."""
+ ts = datetime(2025, 1, 15, 12, 30, 0)
+ commit = CommitInfo(sha="test-sha", timestamp=ts)
+ json_str = commit.model_dump_json()
+ restored = CommitInfo.model_validate_json(json_str)
+ assert restored == commit
+
+
+class TestCommitDelta:
+ """Tests for CommitDelta model."""
+
+ def test_commit_delta__creation_with_all_fields(self) -> None:
+ """Test creating CommitDelta with all fields."""
+ delta = CommitDelta(
+ cc_delta=5.0,
+ effort_delta=100.0,
+ mi_delta=-2.5,
+ qpe_delta=0.01,
+ )
+ assert delta.cc_delta == 5.0
+ assert delta.effort_delta == 100.0
+ assert delta.mi_delta == -2.5
+ assert delta.qpe_delta == 0.01
+
+ def test_commit_delta__round_trips_json(self) -> None:
+ """Test JSON serialization round-trip."""
+ delta = CommitDelta(cc_delta=1.0, effort_delta=50.0, mi_delta=-1.0, qpe_delta=0.005)
+ json_str = delta.model_dump_json()
+ restored = CommitDelta.model_validate_json(json_str)
+ assert restored == delta
class TestComputeStats:
@@ -134,7 +178,7 @@ def test_compute_trend__two_values_computes_slope(self):
class TestGetOrComputeBaseline:
"""Tests for BaselineService.get_or_compute_baseline."""
- def test_get_or_compute_baseline__returns_cached_when_head_unchanged(self, tmp_path: Path):
+ def test_get_or_compute_baseline__returns_cached_when_head_unchanged(self, tmp_path: Path) -> None:
"""Test that cached baseline is returned when HEAD hasn't changed."""
mock_db = MagicMock()
cached_baseline = RepoBaseline(
@@ -198,6 +242,7 @@ def test_get_or_compute_baseline__returns_cached_when_head_unchanged(self, tmp_p
merge_ratio=0.25,
total_commits_sampled=200,
),
+ qpe_weight_version=QPE_WEIGHT_VERSION,
)
mock_db.get_cached_baseline.return_value = cached_baseline
@@ -212,6 +257,85 @@ def test_get_or_compute_baseline__returns_cached_when_head_unchanged(self, tmp_p
assert result == cached_baseline
mock_db.get_cached_baseline.assert_called_once_with(str(tmp_path.resolve()), "abc123")
+ def test_get_or_compute_baseline__recomputes_when_weight_version_stale(self, tmp_path: Path) -> None:
+ """Test that baseline is recomputed when cached QPE weight version differs."""
+ mock_db = MagicMock()
+ cached_baseline = RepoBaseline(
+ repository_path=str(tmp_path),
+ head_commit_sha="abc123",
+ total_commits_analyzed=10,
+ cc_delta_stats=HistoricalMetricStats(
+ metric_name="cc_delta",
+ mean=5.0,
+ std_dev=2.0,
+ median=5.0,
+ min_value=0.0,
+ max_value=10.0,
+ sample_count=10,
+ trend_coefficient=0.1,
+ ),
+ effort_delta_stats=HistoricalMetricStats(
+ metric_name="effort_delta",
+ mean=100.0,
+ std_dev=50.0,
+ median=100.0,
+ min_value=0.0,
+ max_value=200.0,
+ sample_count=10,
+ trend_coefficient=0.2,
+ ),
+ mi_delta_stats=HistoricalMetricStats(
+ metric_name="mi_delta",
+ mean=-0.5,
+ std_dev=0.25,
+ median=-0.5,
+ min_value=-1.0,
+ max_value=0.0,
+ sample_count=10,
+ trend_coefficient=-0.05,
+ ),
+ current_metrics=ExtendedComplexityMetrics(**make_test_metrics(total_complexity=100)),
+ qpe_stats=HistoricalMetricStats(
+ metric_name="qpe_delta",
+ mean=0.001,
+ std_dev=0.005,
+ median=0.001,
+ min_value=-0.01,
+ max_value=0.02,
+ sample_count=10,
+ trend_coefficient=0.0,
+ ),
+ current_qpe=QPEScore(
+ qpe=0.45,
+ mi_normalized=0.5,
+ smell_penalty=0.1,
+ adjusted_quality=0.45,
+ smell_counts={},
+ ),
+ strategy=ResolvedBaselineStrategy(
+ requested=BaselineStrategy.AUTO,
+ resolved=BaselineStrategy.MERGE_ANCHORED,
+ merge_ratio=0.25,
+ total_commits_sampled=200,
+ ),
+ qpe_weight_version="1", # Old version
+ )
+ mock_db.get_cached_baseline.return_value = cached_baseline
+
+ service = BaselineService(db=mock_db)
+
+ with (
+ patch("slopometry.summoner.services.baseline_service.GitTracker") as MockGitTracker,
+ patch.object(service, "compute_full_baseline") as mock_compute,
+ ):
+ mock_git = MockGitTracker.return_value
+ mock_git._get_current_commit_sha.return_value = "abc123"
+ mock_compute.return_value = None
+
+ service.get_or_compute_baseline(tmp_path)
+
+ mock_compute.assert_called_once()
+
def test_get_or_compute_baseline__recomputes_when_flag_set(self, tmp_path: Path):
"""Test that baseline is recomputed when recompute=True."""
mock_db = MagicMock()
diff --git a/tests/test_current_impact_service.py b/tests/test_current_impact_service.py
index 04722ae..7976800 100644
--- a/tests/test_current_impact_service.py
+++ b/tests/test_current_impact_service.py
@@ -1,3 +1,4 @@
+import json
import logging
import subprocess
from datetime import datetime
@@ -7,7 +8,18 @@
from slopometry.core.complexity_analyzer import ComplexityAnalyzer
from slopometry.core.database import EventDatabase
-from slopometry.core.models import AnalysisSource, HistoricalMetricStats, RepoBaseline
+from slopometry.core.git_tracker import GitOperationError, GitTracker
+from slopometry.core.models import (
+ AnalysisSource,
+ CurrentChangesAnalysis,
+ CurrentImpactSummary,
+ ExtendedComplexityMetrics,
+ HistoricalMetricStats,
+ ImpactAssessment,
+ ImpactCategory,
+ RepoBaseline,
+ SmellAdvantage,
+)
from slopometry.summoner.services.current_impact_service import CurrentImpactService
@@ -229,8 +241,6 @@ def test_analyze_previous_commit__logs_debug_on_git_operation_error(
"""Test that analyze_previous_commit logs debug messages on GitOperationError."""
assert real_baseline is not None, "Baseline computation failed"
- from slopometry.core.git_tracker import GitOperationError, GitTracker
-
def mock_get_changed_python_files(self, parent_sha, child_sha):
raise GitOperationError("Simulated git diff failure")
@@ -320,3 +330,158 @@ def test_analyze_uncommitted_changes__cache_invalidated_on_file_change(
cached_logged = any("Cached metrics for current-impact" in record.message for record in caplog.records)
assert not using_cache_logged, "Changed file should invalidate cache"
assert cached_logged, "Should cache new metrics after recomputation"
+
+
+class TestCurrentImpactSummary:
+ """Tests for the compact CurrentImpactSummary model."""
+
+ @staticmethod
+ def _make_metrics() -> ExtendedComplexityMetrics:
+ return ExtendedComplexityMetrics(
+ total_volume=0.0,
+ total_effort=0.0,
+ total_difficulty=0.0,
+ average_volume=0.0,
+ average_effort=0.0,
+ average_difficulty=0.0,
+ total_mi=0.0,
+ average_mi=0.0,
+ )
+
+ def test_from_analysis__maps_all_fields(self):
+ """Test that from_analysis correctly maps fields from full analysis."""
+ metrics = self._make_metrics()
+ dummy_stats = HistoricalMetricStats(
+ metric_name="test",
+ mean=0.0,
+ std_dev=1.0,
+ median=0.0,
+ min_value=-1.0,
+ max_value=1.0,
+ sample_count=10,
+ )
+ baseline = RepoBaseline(
+ repository_path="/tmp/repo",
+ last_commit_hash="abc123",
+ analysis_timestamp=datetime.now(),
+ current_metrics=metrics,
+ head_commit_sha="abc123",
+ total_commits_analyzed=10,
+ cc_delta_stats=dummy_stats,
+ effort_delta_stats=dummy_stats,
+ mi_delta_stats=dummy_stats,
+ )
+ assessment = ImpactAssessment(
+ cc_z_score=0.5,
+ effort_z_score=-0.3,
+ mi_z_score=0.8,
+ impact_score=0.65,
+ impact_category=ImpactCategory.MINOR_IMPROVEMENT,
+ cc_delta=-2.0,
+ effort_delta=-100.0,
+ mi_delta=3.5,
+ qpe_delta=0.02,
+ qpe_z_score=0.4,
+ )
+ smell_adv = SmellAdvantage(
+ smell_name="orphan_comment",
+ baseline_count=10,
+ candidate_count=8,
+ weight=0.01,
+ weighted_delta=-0.02,
+ )
+ analysis = CurrentChangesAnalysis(
+ repository_path="/tmp/repo",
+ source=AnalysisSource.PREVIOUS_COMMIT,
+ analyzed_commit_sha="abc12345",
+ base_commit_sha="def67890",
+ changed_files=["src/a.py", "src/b.py", "src/c.py"],
+ current_metrics=metrics,
+ baseline_metrics=metrics,
+ assessment=assessment,
+ baseline=baseline,
+ blind_spots=["src/d.py"],
+ smell_advantages=[smell_adv],
+ )
+
+ summary = CurrentImpactSummary.from_analysis(analysis)
+
+ assert summary.source == AnalysisSource.PREVIOUS_COMMIT
+ assert summary.analyzed_commit_sha == "abc12345"
+ assert summary.base_commit_sha == "def67890"
+ assert summary.impact_score == 0.65
+ assert summary.impact_category == ImpactCategory.MINOR_IMPROVEMENT
+ assert summary.qpe_delta == 0.02
+ assert summary.cc_delta == -2.0
+ assert summary.effort_delta == -100.0
+ assert summary.mi_delta == 3.5
+ assert summary.changed_files_count == 3
+ assert summary.blind_spots_count == 1
+ assert len(summary.smell_advantages) == 1
+ assert summary.smell_advantages[0].smell_name == "orphan_comment"
+
+ def test_from_analysis__serializes_to_json(self):
+ """Test that summary serializes to valid JSON with expected keys."""
+ metrics = self._make_metrics()
+ dummy_stats = HistoricalMetricStats(
+ metric_name="test",
+ mean=0.0,
+ std_dev=1.0,
+ median=0.0,
+ min_value=-1.0,
+ max_value=1.0,
+ sample_count=10,
+ )
+ baseline = RepoBaseline(
+ repository_path="/tmp/repo",
+ last_commit_hash="abc123",
+ analysis_timestamp=datetime.now(),
+ current_metrics=metrics,
+ head_commit_sha="abc123",
+ total_commits_analyzed=10,
+ cc_delta_stats=dummy_stats,
+ effort_delta_stats=dummy_stats,
+ mi_delta_stats=dummy_stats,
+ )
+ assessment = ImpactAssessment(
+ cc_z_score=0.0,
+ effort_z_score=0.0,
+ mi_z_score=0.0,
+ impact_score=0.0,
+ impact_category=ImpactCategory.NEUTRAL,
+ cc_delta=0.0,
+ effort_delta=0.0,
+ mi_delta=0.0,
+ qpe_delta=0.0,
+ qpe_z_score=0.0,
+ )
+ analysis = CurrentChangesAnalysis(
+ repository_path="/tmp/repo",
+ changed_files=["src/a.py"],
+ current_metrics=metrics,
+ baseline_metrics=metrics,
+ assessment=assessment,
+ baseline=baseline,
+ )
+
+ summary = CurrentImpactSummary.from_analysis(analysis)
+ json_str = summary.model_dump_json(indent=2)
+ parsed = json.loads(json_str)
+
+ expected_keys = {
+ "source",
+ "analyzed_commit_sha",
+ "base_commit_sha",
+ "impact_score",
+ "impact_category",
+ "qpe_delta",
+ "cc_delta",
+ "effort_delta",
+ "mi_delta",
+ "changed_files_count",
+ "blind_spots_count",
+ "smell_advantages",
+ }
+ assert set(parsed.keys()) == expected_keys
+ assert parsed["source"] == "uncommitted_changes"
+ assert parsed["impact_category"] == "neutral"
diff --git a/tests/test_database.py b/tests/test_database.py
index 9f93212..8aee7ed 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -145,6 +145,50 @@ def test_leaderboard_upsert__updates_existing_project_on_new_commit() -> None:
assert leaderboard[0].measured_at == datetime(2024, 6, 1)
+def test_leaderboard_save__round_trips_qpe_weight_version() -> None:
+ """Test that qpe_weight_version is persisted and retrieved from the leaderboard."""
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ db = EventDatabase(db_path=Path(tmp_dir) / "test.db")
+
+ entry_with_version = LeaderboardEntry(
+ project_name="versioned-project",
+ project_path="/test/versioned",
+ commit_sha_short="aaa1111",
+ commit_sha_full="aaa1111222233334444",
+ measured_at=datetime(2024, 1, 1),
+ qpe_score=0.6,
+ mi_normalized=0.7,
+ smell_penalty=0.05,
+ adjusted_quality=0.65,
+ effort_factor=1.0,
+ total_effort=500.0,
+ metrics_json="{}",
+ qpe_weight_version="2",
+ )
+ entry_without_version = LeaderboardEntry(
+ project_name="legacy-project",
+ project_path="/test/legacy",
+ commit_sha_short="bbb2222",
+ commit_sha_full="bbb2222333344445555",
+ measured_at=datetime(2024, 1, 1),
+ qpe_score=0.5,
+ mi_normalized=0.6,
+ smell_penalty=0.1,
+ adjusted_quality=0.54,
+ effort_factor=1.0,
+ total_effort=600.0,
+ metrics_json="{}",
+ )
+ db.save_leaderboard_entry(entry_with_version)
+ db.save_leaderboard_entry(entry_without_version)
+
+ leaderboard = db.get_leaderboard()
+ by_name = {e.project_name: e for e in leaderboard}
+
+ assert by_name["versioned-project"].qpe_weight_version == "2"
+ assert by_name["legacy-project"].qpe_weight_version is None
+
+
def test_clear_leaderboard__removes_all_entries() -> None:
"""Test that clear_leaderboard removes all entries and returns count."""
with tempfile.TemporaryDirectory() as tmp_dir:
diff --git a/tests/test_language_config.py b/tests/test_language_config.py
index d76bf24..d4ae44c 100644
--- a/tests/test_language_config.py
+++ b/tests/test_language_config.py
@@ -3,6 +3,7 @@
from pathlib import Path
import pytest
+from pydantic import ValidationError
from slopometry.core.language_config import (
LANGUAGE_CONFIGS,
@@ -14,7 +15,7 @@
get_language_config,
should_ignore_path,
)
-from slopometry.core.models import ProjectLanguage
+from slopometry.core.language_models import ProjectLanguage
class TestLanguageConfig:
@@ -149,7 +150,7 @@ class TestLanguageConfigFrozen:
def test_language_config__is_frozen(self):
"""Verify LanguageConfig is immutable."""
- with pytest.raises(AttributeError):
+ with pytest.raises(ValidationError):
PYTHON_CONFIG.language = ProjectLanguage.PYTHON # type: ignore
def test_language_config__custom_creation(self):
diff --git a/tests/test_language_guard.py b/tests/test_language_guard.py
index 0921ab1..4fe0e9b 100644
--- a/tests/test_language_guard.py
+++ b/tests/test_language_guard.py
@@ -9,7 +9,7 @@
LanguageDetector,
)
from slopometry.core.language_guard import check_language_support
-from slopometry.core.models import LanguageGuardResult, ProjectLanguage
+from slopometry.core.language_models import LanguageGuardResult, ProjectLanguage
class TestLanguageDetector:
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 56d258f..eac0e80 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -5,6 +5,10 @@
from slopometry.core.migrations import MigrationRunner
+# Derive expected count from the runner itself so adding a migration doesn't
+# require updating every assertion in these tests.
+EXPECTED_MIGRATION_COUNT = len(MigrationRunner(Path("/dev/null")).migrations)
+
class TestMigrations:
"""Test database migration functionality."""
@@ -27,7 +31,7 @@ def test_migration_001__adds_transcript_path_column_and_index(self):
applied = runner.run_migrations()
- assert len(applied) == 10
+ assert len(applied) == EXPECTED_MIGRATION_COUNT
assert any("001" in migration and "transcript_path" in migration for migration in applied)
assert any("002" in migration and "code quality cache" in migration for migration in applied)
assert any("003" in migration and "working_tree_hash" in migration for migration in applied)
@@ -65,12 +69,12 @@ def test_migration_runner__idempotent_execution(self):
applied_first = runner.run_migrations()
applied_second = runner.run_migrations()
- assert len(applied_first) == 10
+ assert len(applied_first) == EXPECTED_MIGRATION_COUNT
assert len(applied_second) == 0
status = runner.get_migration_status()
- assert status["total"] == 10
- assert len(status["applied"]) == 10
+ assert status["total"] == EXPECTED_MIGRATION_COUNT
+ assert len(status["applied"]) == EXPECTED_MIGRATION_COUNT
assert len(status["pending"]) == 0
def test_migration_runner__tracks_migration_status(self):
@@ -95,12 +99,12 @@ def test_migration_runner__tracks_migration_status(self):
status_after = runner.get_migration_status()
- assert status_before["total"] == 10
+ assert status_before["total"] == EXPECTED_MIGRATION_COUNT
assert len(status_before["applied"]) == 0
- assert len(status_before["pending"]) == 10
+ assert len(status_before["pending"]) == EXPECTED_MIGRATION_COUNT
- assert status_after["total"] == 10
- assert len(status_after["applied"]) == 10
+ assert status_after["total"] == EXPECTED_MIGRATION_COUNT
+ assert len(status_after["applied"]) == EXPECTED_MIGRATION_COUNT
assert len(status_after["pending"]) == 0
migration_001 = next((m for m in status_after["applied"] if m["version"] == "001"), None)
@@ -126,7 +130,7 @@ def test_migration_001__handles_existing_column_gracefully(self):
applied = runner.run_migrations()
- assert len(applied) == 10
+ assert len(applied) == EXPECTED_MIGRATION_COUNT
with runner._get_db_connection() as conn:
cursor = conn.execute("PRAGMA table_info(hook_events)")
diff --git a/tests/test_models.py b/tests/test_models.py
index 71161b1..32f3b98 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -3,6 +3,11 @@
import pytest
from pydantic import ValidationError
+from slopometry.core.display_models import (
+ FeatureDisplayData,
+ SessionDisplayData,
+ UserStoryDisplayData,
+)
from slopometry.core.models import (
BaselineStrategy,
ContextCoverage,
@@ -12,7 +17,6 @@
QPEScore,
ResolvedBaselineStrategy,
SmellAdvantage,
- UserStoryDisplayData,
UserStoryStatistics,
)
@@ -299,3 +303,100 @@ def test_implementation_comparison__round_trips_json() -> None:
restored = ImplementationComparison.model_validate_json(json_str)
assert restored.prefix_a == comparison.prefix_a
assert len(restored.smell_advantages) == 1
+
+
+class TestSessionDisplayData:
+ """Test the session display data model."""
+
+ def test_model_creation__creates_display_data_when_values_provided(self) -> None:
+ """Test creating display data model when values provided."""
+ display_data = SessionDisplayData(
+ session_id="abc12345",
+ start_time="2025-07-18 13:06",
+ total_events=50,
+ tools_used=12,
+ project_name="my-project",
+ project_source="git",
+ )
+
+ assert display_data.session_id == "abc12345"
+ assert display_data.start_time == "2025-07-18 13:06"
+ assert display_data.total_events == 50
+ assert display_data.tools_used == 12
+ assert display_data.project_name == "my-project"
+ assert display_data.project_source == "git"
+
+ def test_model_creation__handles_none_project(self) -> None:
+ """Test that project_name and project_source can be None."""
+ display_data = SessionDisplayData(
+ session_id="abc12345",
+ start_time="2025-07-18 13:06",
+ total_events=10,
+ tools_used=5,
+ project_name=None,
+ project_source=None,
+ )
+
+ assert display_data.project_name is None
+ assert display_data.project_source is None
+
+ def test_model_round_trip__serializes_and_deserializes(self) -> None:
+ """Test JSON serialization round-trip."""
+ display_data = SessionDisplayData(
+ session_id="session-xyz",
+ start_time="2025-01-01 12:00",
+ total_events=25,
+ tools_used=8,
+ project_name="test-project",
+ project_source="pyproject",
+ )
+
+ json_str = display_data.model_dump_json()
+ restored = SessionDisplayData.model_validate_json(json_str)
+ assert restored == display_data
+
+
+class TestFeatureDisplayData:
+ """Test the feature display data model."""
+
+ def test_model_creation__creates_display_data_when_values_provided(self) -> None:
+ """Test creating display data model when values provided."""
+ display_data = FeatureDisplayData(
+ feature_id="feat-001",
+ feature_message="Add user authentication",
+ commits_display="abc123 → def456",
+ best_entry_id="entry-789",
+ merge_message="feat: implement login system",
+ )
+
+ assert display_data.feature_id == "feat-001"
+ assert display_data.feature_message == "Add user authentication"
+ assert display_data.commits_display == "abc123 → def456"
+ assert display_data.best_entry_id == "entry-789"
+ assert display_data.merge_message == "feat: implement login system"
+
+ def test_model_creation__handles_na_best_entry(self) -> None:
+ """Test that best_entry_id can be 'N/A' when no user story exists."""
+ display_data = FeatureDisplayData(
+ feature_id="feat-002",
+ feature_message="Refactor core module",
+ commits_display="xyz123 → abc456",
+ best_entry_id="N/A",
+ merge_message="refactor: clean up codebase",
+ )
+
+ assert display_data.best_entry_id == "N/A"
+
+ def test_model_round_trip__serializes_and_deserializes(self) -> None:
+ """Test JSON serialization round-trip."""
+ display_data = FeatureDisplayData(
+ feature_id="feat-003",
+ feature_message="New feature implementation",
+ commits_display="000111 → 222333",
+ best_entry_id="entry-555",
+ merge_message="feat: implement something great",
+ )
+
+ json_str = display_data.model_dump_json()
+ restored = FeatureDisplayData.model_validate_json(json_str)
+ assert restored == display_data
diff --git a/tests/test_qpe_calculator.py b/tests/test_qpe_calculator.py
index 308c432..ae950fb 100644
--- a/tests/test_qpe_calculator.py
+++ b/tests/test_qpe_calculator.py
@@ -107,7 +107,6 @@ def test_calculate_qpe__smell_penalty_saturates_with_sigmoid(self):
def test_calculate_qpe__spreading_smells_does_not_reduce_penalty(self):
"""Test that spreading smells across files doesn't reduce penalty (anti-gaming fix)."""
-
# Same smells, 1 file
metrics_concentrated = ExtendedComplexityMetrics(
**make_test_metrics(
@@ -553,7 +552,6 @@ def test_qpe_calculator__real_codebase_produces_consistent_results(self, repo_pa
analyzer = ComplexityAnalyzer(working_directory=repo_path)
metrics = analyzer.analyze_extended_complexity()
-
qpe_score = calculate_qpe(metrics)
# QPE should be positive for a working codebase
@@ -586,7 +584,6 @@ def test_display_qpe_score__renders_without_error(self, repo_path: Path) -> None
analyzer = ComplexityAnalyzer(working_directory=repo_path)
metrics = analyzer.analyze_extended_complexity()
-
qpe_score = calculate_qpe(metrics)
# Capture output to verify no errors
@@ -624,7 +621,6 @@ def test_qpe_calculator__handles_empty_codebase_gracefully(self, tmp_path: Path)
analyzer = ComplexityAnalyzer(working_directory=tmp_path)
metrics = analyzer.analyze_extended_complexity()
-
qpe_score = calculate_qpe(metrics)
# Should handle gracefully (might return 0 but shouldn't crash)
@@ -641,7 +637,6 @@ def test_qpe_at_known_checkpoint__has_expected_characteristics(self, repo_path:
analyzer = ComplexityAnalyzer(working_directory=repo_path)
metrics = analyzer.analyze_extended_complexity()
-
qpe_score = calculate_qpe(metrics)
# Documented expectations for slopometry codebase quality
diff --git a/tests/test_sessions_performance.py b/tests/test_sessions_performance.py
index 68600a0..7f451c9 100644
--- a/tests/test_sessions_performance.py
+++ b/tests/test_sessions_performance.py
@@ -52,22 +52,22 @@ def test_get_sessions_for_display__uses_single_query(self):
assert len(sessions_data) == 2
- assert sessions_data[0]["session_id"] == "session-002" # Started later
- assert sessions_data[1]["session_id"] == "session-001" # Started earlier
+ assert sessions_data[0].session_id == "session-002" # Started later
+ assert sessions_data[1].session_id == "session-001" # Started earlier
session1 = sessions_data[1]
- assert session1["session_id"] == "session-001"
- assert session1["project_name"] == "project-a"
- assert session1["project_source"] == "git"
- assert session1["total_events"] == 5
- assert session1["tools_used"] == 3 # bash, read, write
+ assert session1.session_id == "session-001"
+ assert session1.project_name == "project-a"
+ assert session1.project_source == "git"
+ assert session1.total_events == 5
+ assert session1.tools_used == 3 # bash, read, write
session2 = sessions_data[0]
- assert session2["session_id"] == "session-002"
- assert session2["project_name"] == "project-b"
- assert session2["project_source"] == "pyproject"
- assert session2["total_events"] == 3
- assert session2["tools_used"] == 2 # grep, ls
+ assert session2.session_id == "session-002"
+ assert session2.project_name == "project-b"
+ assert session2.project_source == "pyproject"
+ assert session2.total_events == 3
+ assert session2.tools_used == 2 # grep, ls
def test_get_sessions_for_display__respects_limit(self):
"""Test that limit parameter works correctly."""
@@ -97,7 +97,7 @@ def test_get_sessions_for_display__respects_limit(self):
assert len(limited_sessions) == 3
expected_sessions = ["session-004", "session-003", "session-002"]
- actual_sessions = [s["session_id"] for s in limited_sessions]
+ actual_sessions = [s.session_id for s in limited_sessions]
assert actual_sessions == expected_sessions
def test_get_sessions_for_display__handles_empty_database(self):
@@ -133,4 +133,4 @@ def test_get_sessions_for_display__handles_null_tool_types(self):
sessions_data = service.get_sessions_for_display()
assert len(sessions_data) == 1
- assert sessions_data[0]["tools_used"] == 0
+ assert sessions_data[0].tools_used == 0
diff --git a/tests/test_user_story_service.py b/tests/test_user_story_service.py
index 7bd36a6..57227a2 100644
--- a/tests/test_user_story_service.py
+++ b/tests/test_user_story_service.py
@@ -5,7 +5,8 @@
import pytest
-from slopometry.core.models import UserStoryDisplayData, UserStoryEntry, UserStoryStatistics
+from slopometry.core.display_models import UserStoryDisplayData
+from slopometry.core.models import UserStoryEntry, UserStoryStatistics
from slopometry.summoner.services.user_story_service import UserStoryService