From d8af3f7b58a41749793e87cce2df5da7ef223e21 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Fri, 13 Feb 2026 14:22:36 +0200 Subject: [PATCH 1/6] Add versioning to QPE --- .coverage | Bin 53248 -> 53248 bytes .github/workflows/ci.yml | 80 + coverage.xml | 7085 +++++++++-------- domain-refactoring.rfc | 326 + src/slopometry/core/database.py | 53 +- src/slopometry/core/migrations.py | 58 + src/slopometry/core/models.py | 58 +- src/slopometry/summoner/cli/commands.py | 102 +- .../summoner/services/baseline_service.py | 12 +- .../summoner/services/qpe_calculator.py | 5 + tests/test_baseline_service.py | 60 +- tests/test_current_impact_service.py | 133 +- tests/test_database.py | 44 + tests/test_migrations.py | 22 +- 14 files changed, 4465 insertions(+), 3573 deletions(-) create mode 100644 domain-refactoring.rfc diff --git a/.coverage b/.coverage index fefd126ac391ca4701d6294a8ca49a78fdfce30f..bb79cfa24d7a1e8e7a2883318937de361f76fe92 100644 GIT binary patch delta 2320 zcmYKg32YQq^v&$-&g}W8+jhH!Z5c|hElb%H6)GtmD2h=u2u9lyWl5Ey9BoVkx>{xy z2mw5CBhfT!H8DoxQCUT8)K-ZXvEq@K));6sN}@EysI+d6@x4D=c-jBo@!$2|dw+M$ zUWeK1=-aG2O2n@+cj>Aqw#3qRHmyxx-&81WT%7J)vq`FPfRWzXRI6;+vTN(TySHq~ zG&lA6L`P}bvtg}A>~O79I-R9)Zh>G*v{g{cNfKXk4>_%s@3e1`>&i-)nwUvMVz3cqfe~flUzNp{*^D{l0m+$N_ z99Jdu?tHpc(vyAbo|$tGjej(87Poe*5nE~l#o+xPq~XS;6OW+LW)BFR0ATAA)N{_* z_rr7DE}#yoBx#^);3LnHBBgMal>Po7W*RB&KOLlprI+R*yQ(rd^L9c&-zv=;T;!MM z@2*s(b18YxjAcz*ZjD?G3j9qX2~ui1yP4Xu!I3>N@_OUU)WrC?0mGV|nL1Bcm4+SD zTVd4KwO;NS+V;YXHDy^RM%pdo$JaB)rT6~3@ZP|WV;0WO%BiuDbK$nWbl}laBit6= zw|8tOm4@om@VwPw_X9&7?z7s?d~>0_aVpbpoVxUre~Rb}?bhe3rk*GsJ%cK~4|nvG zJl;(cJ^aSzwFgtPhU;t?-f2(3vdqw-hEsLZR@?BAc!3R&wj@0L0lMg#9am2Urk|&l4_yJm6;`qj%um)s{24c)KC988nK&?} z$B)Gem1Gxtg|O^HTCKRR)bB+iQr>_6bTj%C=6jG`5f+;Rqh5M!+QqyThDeiY;zXvL zZp>xBCz5S|i$RLTOD|!cFM~~*Itj360w;*E2Z8+9L!^P(Nc0@cJ^9JYwaroKS04-9 zt%9c9q);V7-s~N&6BGvBi#Q`VYdjo}Z05qCT(>>DIMQ&Z#Q_bK)P1tk?LKgOb_ZIt zfDc6F*y_>%9_7YK!!(brE;LMPoSibz7icGE6&hnwHt9$s-*_Pq_?y5?Pz*YL*P$GN z6k!GBN_HADbLO0p@O5yJ&g0$U zv@5^Ag?`Lkn%y8;g}qlsLDvB6bYylSNflR z7DO@|RPhYuy5>O?4g3o1JJ!pdWlym^EXsakS=Pb2+3CPvY3<2y8FR6Mz_08Sdxafj z>zO|=$yNm{_AYyqJ;JuL<$*u&AHb17oGK>Fk8%{P=EzsY(ZWiO7FBRmT+We}$$w!i F{y(1iuq^-p delta 2481 zcmX|D3se(V8oo1mKPM0nkVgWDK!DaaP=sW~6(2nnp(@}(V_gkLpkb?k8bluifQRNel3g$~QdkTjhnKNpedYBrRjrKu1$c3;X@ z@lo`yj$62qOGB6i>>7pwRd<&ZMxgX!I`oY%epA<+9}4}2NV+brI2T4y7nQ*%>Y`tE zUn)M3ULs>&0N8)9e(W=>7>gA8gnU7tV6{Ljpz-JVKk&cc-Ql(Jj`397Dei4G>%nRKiqV-HXR7OWQ%cC_0 zFtAUw*nd*)Gkv%u6>@K+mRX*Wv)(q%nB&M~#~ibGcVY_UH}g>?EffGy{p|9M-EtJH z+5!k?a|~o`;JPF>J{cNqjBi)hv89mP3=k9oB1C(o1P_5C5JfD9CMyzAQ`4#{IgpMt z^yCH-0b%inL?ls~DDEl+3lI&?Y;&%^4cNlXCeAXb2$PnH2_!TTipyogRfr;k3P%Tk zy)}V>aI|4dy>3e*YoO76!()=!h9j)hdWQjFwtVRu@yvbli#YtD^VP!fJQQ6S52O4l zT*GobXs7kQyjtfgsdkfBX!?=a^DalfvU-o7+|J^IS(;+voO@qwCwV1I3SDF(w1UY7 zEO4w(l^YAn4sDQ7OVgQNkMictcUzKi zmyTgq{WJH~E({O=D9Fb+nqJM(rpLi3CrdDIMkqi;0F^=~<${w3ff@k@oCv(Uu0xO< zB&ca|+!F{E^feO+YM^YJsDI4>*J}%6A_4#$D4|XWKptg6(5M+?QLPJ(^>ALi^OH;k z`v3YAV|$$qAVjgS6bUKARUsfmlra5Vf~3Yk=G_F%xktO^+>c4pOV*Ye%Dcs8$LXFO zJ&%_RZ2Iii9mhX2&^`W;$cAbCB zn7!uS+u?MGYx>IE)3vu1?Y5W3JS-HZDvdKfSTZ!NHtq04#D_p)<8qDvZ_0hw1B}o{ zokEVgkY1mlhx69xR13JA8!w_v=$eBt!$tDNn=2$UjL}O@-m^8`cSolEBz+)2)4qpM zJ=#d~c3H2<{XUO#!i19nzi*1df!QJ2;(lP`AAMV{!+sx)50a7`pTjy)R@qHj$($y9 zuFX&4_JerW`SQAd-LYHS{F$3hxm9Cjl~IlLjw-55RIPG9C)Fo@E}Q4eF7klo8@o#G z>sH}+=Y}(Ulx$a#U*-9lqimLdy^(*bvNhHoZN~jyzdv?W#-AluyS@T?JeNkhaIt!w zJ?hi%$kp~keK|d~M}|y~4kwxpMwHoJ{|8v*Ry@K2#`xg^P>zrZxDN+Q|?2 z96r1cOFA5f53z(iO1I4T!Qv1)yw_hE$A$NKk!Xp~R(Tw@TIvePLTz+T=jY%hM>&IN z8623=Np6fc7r4Hq-hgDzbw@b;^wFzxb>z+O3}hP#|fxbn)wu;5zaGJ`zl`uuC+i`k$8U@k>I(4|Q0YbnHOb2vg{{c1L zkcpVNa1rG+LE@AEceibS+7L*m<*X@bTHC30%+y_&Fh`#yNz0_6uJsW|TG7M+dfI&? z3)G|mvztdSjBCL)atF0@MAYFR0tx~Kf}b9EDWIgsDUurSdjUWU%mUEiZvfZ&@fQTs zPmi1=o5P_B1#IX8RZ{9A;JpH}KaHOv06GiU$YPe4+(yy;s=?DpG!u3-E6ivO9FnH= z<{&eHALJO@b=K#wO=V{@pz^5LSnARshdn;Je$S;B`}Ci7JxL>+_`R`7$#h4(&zO4I z8E<~>3D;YEt*HG~?^Bl9;4dhkQ>_uQyv#J7fk$PnA8auBXp8dLpOd110D&-b20TFk zfj+79`~TEi?|I^B&+$<2j(VidQ!PoP!6Y_^dSzC*FCEyOV?9@A?yOv_E};BTK)20| zl%V`Xl(GsjmJT2;1@qfso*yv_$SBk$ZTc;&Bxd%F3fPedG+M)?W!cliOYi!l@tW&vLML1 J#chri{{v`fmQnx! 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/coverage.xml b/coverage.xml index 1ddbc63..2fb2be3 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -16,9 +16,9 @@ - + - + @@ -31,47 +31,47 @@ - - + + - - - - - + + + + + - - - - - - - - - + + + + + + + + + - - - + + + - - - - - - - - + + + + + + + + - + - + - + @@ -81,41 +81,41 @@ - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + @@ -128,17 +128,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -150,7 +150,7 @@ - + @@ -178,53 +178,53 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - + + + + + + + + + + - - + + - + @@ -256,32 +256,32 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - + + + - - + + - + @@ -297,13 +297,13 @@ - - + + - - - + + + @@ -317,43 +317,43 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -399,114 +399,114 @@ - - - - - - - + + + + + + + - + - - - - - - + + + + + + - - - - - - - - - + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -516,223 +516,223 @@ - - - - + + + + - - - - + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - + + + + + - + - - - + + + - - + + - - - + + + - - - - - - - + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - + + + + + + + + - + - - - - - - - + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - + + + + + - + - + - - - - - - - - - - + + + + + + + + + + - - - - + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + - - - - - - - + + + + + + + - - - - - - + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - + @@ -752,76 +752,76 @@ - + - - - - - - - + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - + + + + + + - + @@ -838,288 +838,293 @@ - - - - - - - - + + + + + + + + - - - - - - + + + + + + - + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + - - - - - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - + - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + - - + + + + - @@ -1132,248 +1137,237 @@ - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - + + + - - - - - + + + - + + + - - - - - + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + - + + + - - + - + + + + + + + - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + - - - - - - - - - - - + + + + + - - - + - - - + + + + + + - + + - - - + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + - - - - - - - - + + + + + + + + - - - + - + @@ -1390,62 +1384,62 @@ - + - - - + + + - - - - - + + + + + - - - - - - - - + + + + + + + + - + - - - - - + + + + + - - - - - - + + + + + + - + - - - - + + + + - + - + @@ -1457,47 +1451,47 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - + + + + - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + - - - - + + + + @@ -1506,102 +1500,102 @@ - - - + + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - + - + - - - - - - + + + + + + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - + - + - + @@ -1611,32 +1605,32 @@ - - - - - + + + + + - - - - - - - - + + + + + + + + - - - + + + - + @@ -1655,74 +1649,74 @@ - - + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - + - - - + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + @@ -1730,12 +1724,12 @@ - + - - - - + + + + @@ -1747,64 +1741,64 @@ - - - + + + - - - - - - - - - + + + + + + + + + - - - - - - + + + + + + - - - + + + - - - - - + + + + + - - - - - - - - - + + + + + + + + + - + - + - + @@ -1816,129 +1810,129 @@ - + - - - - - + + + + + - - - + + + - - + + - + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - + + + + + + @@ -2072,17 +2066,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -2090,7 +2084,7 @@ - + @@ -2104,46 +2098,46 @@ - - + + - - - + + + - + - - - - - - - + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - + + + + + + + + + - + @@ -2155,24 +2149,24 @@ - + - - - - - - - - - - + + + + + + + + + + - - - - - + + + + + @@ -2181,19 +2175,19 @@ - + - - - + + + - + @@ -2207,45 +2201,45 @@ - - - + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - + + - + @@ -2267,208 +2261,241 @@ - + - + - - - - + + + + - + - + - + - - - + + + - + - + - - - - + + + + - - - - - - - + + + + + + + - + - + - - + + - + - + - - - - - - - + + + + + + + - + - + - - - - - - - + + + + + + + - + - + - + - - - + + + - + - + - - + + - - - - - - + + + + + + - + - + - - - - - + + + + + - + - + - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + - - - - - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -2494,10 +2521,10 @@ - - + + - + @@ -2513,9 +2540,9 @@ - - - + + + @@ -2666,7 +2693,7 @@ - + @@ -2692,17 +2719,17 @@ - + - + - + - - + + @@ -2855,16 +2882,16 @@ - + - + - + - + @@ -2914,11 +2941,11 @@ - + - + - + @@ -2994,7 +3021,7 @@ - + @@ -3062,14 +3089,14 @@ - - - - - - - - + + + + + + + + @@ -3085,59 +3112,55 @@ - - - + - - + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + + - - - + + - - - - - - + + + + + + + - - - - + + - @@ -3147,43 +3170,43 @@ + + - - - - + + + + + + - - + - + - - - - - - - - - - - - + + + + + + + + + + + - + - - - + + + + - - - - - + + @@ -3192,95 +3215,117 @@ + + - - + + + - - - - - + + + + - + - - + + + + - - - - + - + - - - - - - - - + + + + + + - - - - - - + - - + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + - + - - + + + - - - - - - + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -3294,82 +3339,82 @@ - - - - - - - + + + + + + + - - + + - - - - - + + + + + - - - - - - - + + + + + + + - - - + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - + + + + + @@ -3387,7 +3432,7 @@ - + @@ -3403,107 +3448,107 @@ - - - + + + - - + + - + - - - - - + + + + + - + - - - - + + + + - + - + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - + + + + - - - - - - - + + + + + + + - + - + - - - - - - - + + + + + + + - + @@ -3514,39 +3559,39 @@ - + - - - - - + + + + + - - - - - - - + + + + + + + - + - - - - - - - - - - - + + + + + + + + + + + - + @@ -3606,59 +3651,59 @@ - - - - + + + + - - - + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - + - - - + + + @@ -3670,39 +3715,39 @@ - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + - + @@ -3736,164 +3781,164 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - + + - + - - - + + + - - + + - - - - - - + + + + + + - - - - + + + + - - - - - + + + + + - + - - - - - - + + + + + + - + @@ -3901,35 +3946,35 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - + + + + - - - + + + - - - + + + @@ -3937,107 +3982,107 @@ - - - - - - + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + - - - - - - - - - + + + + + + + + + - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - + + + + + - - - - + + + + - - - + + + - - - - + + + + - + - - - - + + + + - - - - - + + + + + - - - + + + - - - + + + - - - - - + + + + + - + @@ -4048,28 +4093,28 @@ - - - - + + + + - - - - - - + + + + + + - + - + - + @@ -4127,7 +4172,7 @@ - + @@ -4156,24 +4201,24 @@ - - + + - + - - + + - + - + - + @@ -4183,28 +4228,28 @@ - - - - - + + + + + - + - - + + - - - - - - + + + + + + - + @@ -4220,34 +4265,34 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4262,94 +4307,94 @@ - - + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - + + + + - + - - + + - - - - - - - - - - + + + + + + + + + + - - - - - + + + + + - - - + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + + - - + + - + @@ -4359,50 +4404,50 @@ - + - - - - + + + + - - + + - - + + - - + + - - + + - - + + - - - - - - + + + + + + - - - - + + + + - - + + - - - + + + @@ -4410,7 +4455,7 @@ - + @@ -4421,62 +4466,62 @@ - - + + - - - - - - - + + + + + + + - - - + + + - - + + - - - - - - - - - - - + + + + + + + + + + + - + - + - - - - - - - + + + + + + + - - - - - - - + + + + + + + - + @@ -4485,7 +4530,7 @@ - + @@ -4497,8 +4542,8 @@ - - + + @@ -5032,7 +5077,7 @@ - + @@ -5217,34 +5262,34 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + @@ -5323,9 +5368,9 @@ - + - + @@ -5647,95 +5692,95 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + - + @@ -5752,7 +5797,7 @@ - + @@ -5760,47 +5805,47 @@ - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + + + - + @@ -5808,92 +5853,92 @@ - - - + + + - - + + - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + - - - - + + + + - - + + - - - - + + + + - - - - - + + + + + - + - - - - + + + + - - - - - - - - + + + + + + + + - - - + + + - - + + - - - - + + + + - - - + + + @@ -5907,7 +5952,7 @@ - + @@ -5916,7 +5961,7 @@ - + @@ -5933,23 +5978,23 @@ - - - - - - + + + + + + - - + + - + - + @@ -6014,7 +6059,7 @@ - + @@ -6092,31 +6137,31 @@ - - - - + + + + - - + + - - - - - - - - - - - - + + + + + + + + + + + + - - - + + + @@ -6161,160 +6206,150 @@ - - - - - - - - + + - - + - - + + - + + - - + + - + + - + + - - + - - - + - + - + + - + - + - - - - + + + + + + - + - - + - - - + + + + + - + + - - + + - + - - - - + + - - - - + + + + + + + - + + + + + + - + + - + - - - - - - - - - - - - - - - - - - - - + + + + + + - - + @@ -6322,437 +6357,478 @@ + + + + + + + + + - - - - - - + + + + + + + - + - + - - - - - - + + - - - - - - + + + + + - - + + + - + + + + - - + + - - - - + + + + + + + + + + + - + - - - + + - + - + - - + - - - - - - - - - - + + + + - - - + - + - - - + + + + - + + - - - + + - - - - - - + + + + + + + - + - - - - - + + + - - + + - + - - - - - - - - + + + + + + + + - + - - - - - - + - - - - - + + + - - + - - - + + + + + + + + + + - + + - - - - + + + + + + + - - - - - + + + - - + - + - - - - - - - + + + + - - - - - + + + + + + + - - + + - + + + - - - - - + + + + + + + + + + + + + + + + + + + - + - + - - + - - - - - - - - - - - - - - - - - - - - + + + + + + - + - + - - - - + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - + - + - - - - - + + + + + + + + + - - + - + + - - - + - - - - - - - - - - - - + + + + - - - + - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + @@ -6781,197 +6857,199 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - - - - - - - - - - + + + + + + + + + + + - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + - - + - + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + - + - - + + - - - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -6981,144 +7059,144 @@ - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - + + + + - + - - - - - - - - - - - + + + + + + + + + + + - - + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + + @@ -7128,35 +7206,35 @@ - - - - - - - - - - + + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - + + + + + - + @@ -7176,52 +7254,52 @@ - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + @@ -7231,9 +7309,9 @@ - - - + + + @@ -7247,36 +7325,36 @@ - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7386,41 +7464,41 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - + + + + + + + + @@ -7429,11 +7507,11 @@ - - - - - + + + + + @@ -7449,7 +7527,7 @@ - + @@ -7459,7 +7537,7 @@ - + @@ -7468,7 +7546,7 @@ - + @@ -7477,8 +7555,8 @@ - - + + @@ -7500,48 +7578,48 @@ - + - - - - - - - - - + + + + + + + + + - - - - - - + + + + + + - - - - - + + + + + - - - - - - - - - + + + + + + + + + - + @@ -7557,46 +7635,46 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - + + + + + + + + + + + - + @@ -7668,7 +7746,7 @@ - + @@ -7684,7 +7762,7 @@ - + @@ -7709,23 +7787,23 @@ - - - + + + - - - + + + - - - + + + @@ -7803,65 +7881,66 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + - + @@ -7873,75 +7952,75 @@ - + - - - - - + + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - + + - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + - - + + - - - + + + - + - + @@ -7950,42 +8029,42 @@ - + - - - - + + + + - - + + - - + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + 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..49d5d76 100644 --- a/src/slopometry/core/database.py +++ b/src/slopometry/core/database.py @@ -129,7 +129,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 +245,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 +376,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" @@ -1575,7 +1560,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 +1625,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 +1645,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 +1679,7 @@ def save_baseline(self, baseline: RepoBaseline) -> None: strategy_json, qpe_stats_json, current_qpe_json, + baseline.qpe_weight_version, ), ) conn.commit() @@ -1707,8 +1696,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 +1710,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 +1726,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 +1738,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 +1760,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 +1772,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 +1796,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 +1828,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/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..a369002 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", @@ -1350,6 +1350,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 +1596,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 +1665,51 @@ 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.""" diff --git a/src/slopometry/summoner/cli/commands.py b/src/slopometry/summoner/cli/commands.py index fd82d6a..48e71ff 100644 --- a/src/slopometry/summoner/cli/commands.py +++ b/src/slopometry/summoner/cli/commands.py @@ -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,14 @@ 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.models import CurrentImpactSummary, ProjectLanguage 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 +430,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 +448,63 @@ 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 +519,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) @@ -1153,6 +1194,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 +1261,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 +1274,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..5ef2981 100644 --- a/src/slopometry/summoner/services/baseline_service.py +++ b/src/slopometry/summoner/services/baseline_service.py @@ -19,7 +19,7 @@ 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__) @@ -138,7 +138,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 +238,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/qpe_calculator.py b/src/slopometry/summoner/services/qpe_calculator.py index 733b0fa..75c007b 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, diff --git a/tests/test_baseline_service.py b/tests/test_baseline_service.py index adb787d..e2cc63b 100644 --- a/tests/test_baseline_service.py +++ b/tests/test_baseline_service.py @@ -20,6 +20,7 @@ _compute_single_delta_task, _parse_commit_log, ) +from slopometry.summoner.services.qpe_calculator import QPE_WEIGHT_VERSION class TestComputeStats: @@ -134,7 +135,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 +199,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 +214,62 @@ 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..c99044a 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,120 @@ 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_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)") From cba18dc39138c07d27d00ca426f156fd46b2123b Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Fri, 13 Feb 2026 14:56:34 +0200 Subject: [PATCH 2/6] domain: convert lazy dict/d dataclasses to explicit BaseModels - baseline_service: CommitInfo, CommitDelta -> BaseModel - language_config: LanguageConfig -> frozen BaseModel - models: add SessionDisplayData, FeatureDisplayData - database: get_sessions_summary -> list[SessionDisplayData] - formatters: use explicit display models, drop list[dict] - session_service: get_sessions_for_display -> list[SessionDisplayData] - llm_service: prepare_features_data_for_display -> list[FeatureDisplayData] - tests: coverage for new models Co-authored-by: Minimax M2.1 @minimax.com --- src/slopometry/core/database.py | 23 ++--- src/slopometry/core/language_config.py | 14 +-- src/slopometry/core/models.py | 21 ++++ src/slopometry/display/formatters.py | 28 +++--- .../solo/services/session_service.py | 24 ++--- .../summoner/services/baseline_service.py | 9 +- .../summoner/services/llm_service.py | 18 ++-- tests/test_baseline_service.py | 46 +++++++++ tests/test_language_config.py | 3 +- tests/test_models.py | 99 +++++++++++++++++++ tests/test_sessions_performance.py | 28 +++--- 11 files changed, 243 insertions(+), 70 deletions(-) diff --git a/src/slopometry/core/database.py b/src/slopometry/core/database.py index 49d5d76..2a8dd58 100644 --- a/src/slopometry/core/database.py +++ b/src/slopometry/core/database.py @@ -31,6 +31,7 @@ QPEScore, RepoBaseline, ResolvedBaselineStrategy, + SessionDisplayData, SessionStatistics, ToolType, UserStory, @@ -824,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 @@ -848,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 diff --git a/src/slopometry/core/language_config.py b/src/slopometry/core/language_config.py index d1809f5..94448b1 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 pydantic import BaseModel, Field + from slopometry.core.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/models.py b/src/slopometry/core/models.py index a369002..938f813 100644 --- a/src/slopometry/core/models.py +++ b/src/slopometry/core/models.py @@ -1186,6 +1186,27 @@ class UserStoryDisplayData(BaseModel): 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.""" diff --git a/src/slopometry/display/formatters.py b/src/slopometry/display/formatters.py index 97af6cd..a9c2284 100644 --- a/src/slopometry/display/formatters.py +++ b/src/slopometry/display/formatters.py @@ -12,9 +12,11 @@ BaselineStrategy, CompactEvent, ExperimentDisplayData, + FeatureDisplayData, ImplementationComparison, NFPObjectiveDisplayData, ProgressDisplayData, + SessionDisplayData, SmellAdvantage, SmellCategory, TokenUsage, @@ -820,7 +822,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 +833,16 @@ 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"] + 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..c9c2caf 100644 --- a/src/slopometry/solo/services/session_service.py +++ b/src/slopometry/solo/services/session_service.py @@ -4,7 +4,7 @@ from pathlib import Path from slopometry.core.database import EventDatabase -from slopometry.core.models import SessionStatistics +from slopometry.core.models import SessionDisplayData, SessionStatistics class SessionService: @@ -54,27 +54,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/services/baseline_service.py b/src/slopometry/summoner/services/baseline_service.py index 5ef2981..26fa808 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, Field + from slopometry.core.complexity_analyzer import ComplexityAnalyzer from slopometry.core.database import EventDatabase from slopometry.core.git_tracker import GitOperationError, GitTracker @@ -24,16 +25,14 @@ 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 diff --git a/src/slopometry/summoner/services/llm_service.py b/src/slopometry/summoner/services/llm_service.py index e7584ba..a51da7b 100644 --- a/src/slopometry/summoner/services/llm_service.py +++ b/src/slopometry/summoner/services/llm_service.py @@ -92,8 +92,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.""" + from slopometry.core.models import FeatureDisplayData + features_data = [] for feature in features: base_short = feature.base_commit[:8] @@ -103,13 +105,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/tests/test_baseline_service.py b/tests/test_baseline_service.py index e2cc63b..8eb6fd9 100644 --- a/tests/test_baseline_service.py +++ b/tests/test_baseline_service.py @@ -4,6 +4,9 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest +from pydantic import ValidationError + from conftest import make_test_metrics from slopometry.core.models import ( @@ -16,6 +19,7 @@ ) from slopometry.summoner.services.baseline_service import ( BaselineService, + CommitDelta, CommitInfo, _compute_single_delta_task, _parse_commit_log, @@ -23,6 +27,48 @@ 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: """Tests for BaselineService._compute_stats.""" diff --git a/tests/test_language_config.py b/tests/test_language_config.py index d76bf24..11c0de2 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, @@ -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_models.py b/tests/test_models.py index 71161b1..5a7c725 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,10 +7,12 @@ BaselineStrategy, ContextCoverage, ExtendedComplexityMetrics, + FeatureDisplayData, FileCoverageStatus, ImplementationComparison, QPEScore, ResolvedBaselineStrategy, + SessionDisplayData, SmellAdvantage, UserStoryDisplayData, UserStoryStatistics, @@ -299,3 +301,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_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 From 9b2b9ad1abddaa761b1b73c7e9a5777ef592b41e Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Fri, 13 Feb 2026 15:53:36 +0200 Subject: [PATCH 3/6] domain: co-locate language models into dedicated module - Create language_models.py with ProjectLanguage and LanguageGuardResult - Remove both from monolithic models.py - Update all imports across 6 source files and 2 test files - All 615 tests pass Co-authored-by: Minimax M2.1 --- src/slopometry/core/language_config.py | 2 +- src/slopometry/core/language_detector.py | 2 +- src/slopometry/core/language_guard.py | 2 +- src/slopometry/core/language_models.py | 31 +++++++++++++++++++++++ src/slopometry/core/models.py | 26 ------------------- src/slopometry/core/working_tree_state.py | 2 +- src/slopometry/summoner/cli/commands.py | 8 +++--- tests/test_language_config.py | 2 +- tests/test_language_guard.py | 2 +- 9 files changed, 41 insertions(+), 36 deletions(-) create mode 100644 src/slopometry/core/language_models.py diff --git a/src/slopometry/core/language_config.py b/src/slopometry/core/language_config.py index 94448b1..ffa3458 100644 --- a/src/slopometry/core/language_config.py +++ b/src/slopometry/core/language_config.py @@ -12,7 +12,7 @@ from pydantic import BaseModel, Field -from slopometry.core.models import ProjectLanguage +from slopometry.core.language_models import ProjectLanguage class LanguageConfig(BaseModel): 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/models.py b/src/slopometry/core/models.py index 938f813..213448a 100644 --- a/src/slopometry/core/models.py +++ b/src/slopometry/core/models.py @@ -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.""" @@ -1811,22 +1804,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/summoner/cli/commands.py b/src/slopometry/summoner/cli/commands.py index 48e71ff..70fa98e 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: @@ -1120,7 +1120,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(): diff --git a/tests/test_language_config.py b/tests/test_language_config.py index 11c0de2..d4ae44c 100644 --- a/tests/test_language_config.py +++ b/tests/test_language_config.py @@ -15,7 +15,7 @@ get_language_config, should_ignore_path, ) -from slopometry.core.models import ProjectLanguage +from slopometry.core.language_models import ProjectLanguage class TestLanguageConfig: 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: From 3d6c453a69b733c9e878640e2b2013ac9d3bb00c Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Fri, 13 Feb 2026 16:07:24 +0200 Subject: [PATCH 4/6] docs: update CLAUDE.md - remove outdated radon reference Replaced outdated `radon` reference with `rust-code-analysis` guidance. Co-authored-by: Minimax M2.1 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 239f88411b21f86fb34cbbc550e875a6d226a720 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Fri, 13 Feb 2026 16:21:45 +0200 Subject: [PATCH 5/6] domain: decouple display models into display_models.py - Create display_models.py with SessionDisplayData, FeatureDisplayData, ExperimentDisplayData, ProgressDisplayData, NFPObjectiveDisplayData, UserStoryDisplayData - Remove all display models from monolithic models.py - Update imports across 10 source files and 2 test files - All 608 tests pass Co-authored-by: Minimax M2.1 list: def prepare_features_data_for_display(self, features: list) -> list[FeatureDisplayData]: """Prepare feature boundaries data for display formatting.""" - from slopometry.core.models import FeatureDisplayData + from slopometry.core.display_models import FeatureDisplayData features_data = [] for feature in features: 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/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_models.py b/tests/test_models.py index 5a7c725..32f3b98 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,18 +3,20 @@ import pytest from pydantic import ValidationError +from slopometry.core.display_models import ( + FeatureDisplayData, + SessionDisplayData, + UserStoryDisplayData, +) from slopometry.core.models import ( BaselineStrategy, ContextCoverage, ExtendedComplexityMetrics, - FeatureDisplayData, FileCoverageStatus, ImplementationComparison, QPEScore, ResolvedBaselineStrategy, - SessionDisplayData, SmellAdvantage, - UserStoryDisplayData, UserStoryStatistics, ) 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 From 254e1767322f231e8ac36078cd1b05b240759762 Mon Sep 17 00:00:00 2001 From: TensorTemplar Date: Fri, 13 Feb 2026 16:27:14 +0200 Subject: [PATCH 6/6] HITL formatting --- .coverage | Bin 53248 -> 53248 bytes coverage.xml | 8251 +++++++++-------- src/slopometry/core/database.py | 2 +- src/slopometry/core/models.py | 4 +- src/slopometry/display/formatters.py | 4 +- src/slopometry/summoner/cli/commands.py | 7 +- .../summoner/services/baseline_service.py | 2 +- .../summoner/services/llm_service.py | 3 +- .../summoner/services/qpe_calculator.py | 4 +- tests/test_baseline_service.py | 46 +- tests/test_current_impact_service.py | 76 +- tests/test_qpe_calculator.py | 5 - 12 files changed, 4243 insertions(+), 4161 deletions(-) diff --git a/.coverage b/.coverage index bb79cfa24d7a1e8e7a2883318937de361f76fe92..0e873320cf2e5984f0d7884fdcc31c9759bdbdd2 100644 GIT binary patch delta 4251 zcmZ8k30xD`x<50SnPjre1d^~X0b~cE+E#qMnpBWY7R9Av3xunvRWRJPLJQRl+T|^^ z+Rp3swf2_uZhEa16+sK7gkOK}+H0%$Zmk&N!h5&(QDyZhOB0fL-wZ;pJ-=}N=ljmL zop1hgLO8pOv&&8q=3k~^uOs$zwvJ6IepI%u> zE&yE&q!)u}4BM^NofR)uy-@f3<|^!b~NU;wk4Sy_8Lk57xBsI^S7eX39nmu1ZzJb((G~1Z> z^TSCUNWqftXuh8G2Wd9$SRu)qR{3Fm{F+r8NUda5nYqMobM^CSo2@l9mAkfxwPmw# zo<)3Ifq`VeMgut)^1BLjm*#|t$W4INv; zj}`i13LYye!sO7MjTps$QM`m7C{kl2>}T+?#afa8KT`6mDW<7h8Hm#JRBUeutk$$E1L=!w7n`bno9b6^L^+{XJVAZH)-yxQZ)IOo#p>^v za!rD)jt*9Qt-421sy~q$S+uH*AypK;Q{ISIlV{{E;uEZ&EXUqZFTpR!qfftM#Q4yX zwPe~_0*DZ`HjO`+>Cdy747QMuSi6khRq`BfEm;Y?G?2;jHg_|PSQ1~kDFZkeh9&Zr zb-83h9sy?%Ba9c5;)B=B@iM-EfXDXJjvkBS&Fd@4*wqA3Q`FzhWb{9y=7RZTVnG?% z3>ou?=B;c7i-yFDfuRO6DxUy`&ZP(gmKyoAXH39V>oYmP)#CD%z)~IXRlBhQxLUih zlni~uM|_dynHuu*Eg&mR1{V@Qs<|YEc?L3Q4FQDxNh6Y0O$L?|Kw42NmhR3ACIcSn zKtaA9GeGy&k@{5xkao_0G+#&R9?3CFUM=R&H$AtKe{Yo^={N6jFq78)jKo?v?PB1t zo@7@Nz+o})t^$9I0rw50CYu2M&Lz_pGMJi=EGr>ZSp;x+PJg^mjVYn88Ng#LM!#EL z1Y|BQ-$=?ci6(m9Y@kR5#I~$k3Up>k@@WF-JQo5eO%ngW21@>r)U7oYhmFsrP_>Bt zU$%$+kX_3LF&;*v@oG+J7HA0d1N9yCE%jyfK2@{oRaL1fSNWClBjrJ5m2$Omu`-!1 zrEx`{;y)Fv{G$AC}-lXF&|5#YYrSw;0=2S5pfsi790g2H~ z`Y`uI?_T+py%TLy-AM!efle{s4#YWnk9k~ElkYqoVf$w6{*SLN$MMHvAj+122Ko5K zld|(e$Ll;BYbJ!FGP~=+;qj3kOU<)hp;)d)4`k-s?@d(fJ|!I0M}td}AHJPZAUS%u zbKA0Tv}tQ*l$e^5yN7ztICY~nBVFvvZj%eUvG>{Jb$>utFKp645yzVM&A9JN5aI}%IaD) z@x1+?jW!O08bH5CoF9(JxdocuBc7Z>XaGJkQJo=9h=VmFtsJm&%!fz5E=fB#jZcaB zq+!-Z!m*>1`km1z+L(3qf(cKIAkr~<-GYYqE^U~c_*M|y!tT=cvR0jA(SV-Gsmf=f1x9A%4QZ?cTn4VL}inOA;GWpTfAQ z+mpA>I8(kXRh<&r3@%=!UuAV*EYzqn4ojT|i4I@2*ZA=iF(f55X)>~bj z=bYhRrCM)2`O8lHbJvE$-;xhbOq92bjp%ASH%;8#-DN&$8yM5X&U-ZH=zqE~cnJM|S(LwMLN-)Eg)_pPwDibP z^2Wzws%x!Zy6$=f;)+k!WbqT3WG38aoF`0P&(zrMZkyoqdIm+j^5D8`rqYH6)Grm7 zj$B&q5vBy8s5yw_V#RvOWDTaDG+O92{8SQs|E_Dy4UkJC|Vk1R)O_gxrqu)4t_fBr>^}R9D_Uxpy z`*g2wGc|2%HFZYH#`?Fux%oEo4OP?)ZCGDv7n+-vP-rAL$ni~eYh~{#wIBr5wch-V zt7EBpmoOr17M?uP)OBscC#Ttbt@!ZtxZAl@SJ$T-K8dtFqyKkG#wjfI!It{Z1}ybu zwO@@Ykwbms2@D8+AHSs#*gr0Wi?5ts2hBCg9BwP+RB@xsh?yuaLA8leJtc`$xVZEv{;mkj5~8BlP# z1&#*%L_GJIyoppojr)`l?qz8FuOd(4>9ZpS;?0+iPv+MNpZ#~ckO+n4!nMT}A(T^1 z4=_!ch)cbcgI2&xc?d0eTz3dmX8Cex}e+|h!j2dl8vmeuG~ z??m=`3i(Q{>tfMNX)Ll_h%au|Vv6xTg|Bl)%_zhJ%k~X#GkJ!$HG7+O%1N5uztKGe z;aZ1WY}ia5b65D+NB{_vh@j z1s5CQe3+<&p|y!~t@vJQ;m<6e6#gI!d7!iWyI|S?I|^h3H`w8%4-NwOKO7HNJs27g zjxs+86_5lOr-$8^PzuhUVd!xBwi*-8KZT9@elW3$@wYuA1A|MKlCb%bzN2)!0o!4l z@(}iCKfY68|Mi)x$=uk5n}3hI+fwi8Nc{a+l;dwx279mP2tIuO{8hQ5-J6p`h);@9 zxT$1Sy9%ly?0t>h9@&g3e6A`$4*B$sRJe8!hhN?4_5SgE{nXW|lTX3>DhO6r-Be9v zj0}p%)wyj+PLn4E4UgSCcYO4V$7XWGFgPLxMqL|mbqp?A`FE6F!N$$hU;9;F%%Ks2 z@p0q+H=tXr!*fHU2*SW<5#90L`;_~&4xjge_5|wd7UzOad zVkHP$AVENk1b)#H!0&A_rq(DyQltb6A|znLC5Q@>fC;5+qG?cwBnAdckQgL^GEf34 zKmwIP0;67n1f2w=zXXJz1h`g$NLGRnMuK>a1aWE!f>jbkC?!zS6!AEumnlesrZKss aG*A-gNeL8$1Ui`n{?fVp4bj delta 3900 zcmZ8k3vd@4n5|?~t8D=vLi(Hf9=$~SN_#Jp~KXKdcTa`=7^U5lP zbG_#3aQWm{1W(#0WhhgjhJ%=s&46~2U@#pS{!;YFcca5;t?Esiw)J$?rt zI<)_Fj&EK-+AAx_`^z=5ZAA;;JP*Vg_ubvQ_T1UkUC{sI@6RQLE0%Kkq<2LHajy(= zd0?=RtY2y3Tmi-Ak)3r5$tx?q`jg6 zXM#f|*}1|Z;}vOK2GJ^4b8{fvTFzuiT&>i(bO=*S7FPwi2#M8{lAl%igf#FYJ6lWC z?R{O_AY4@#6dt;AU~6CBw)U-^J2r3c-UTzar^3uawnR_0ABR9%4pKdNzuLelAT45Z zW=()ghVXSb33O>}502U(4uX`;q&2l68~~}D*$>u+I6rKml<;){>;paB_ad^UE|v3= zw)$dnuu&w>*LksLI^H63sm_lLkP2CZ`g$*?gJnI|KnJ<6C?ucO`>_g=3Q_^dYVcwA zto^+WUQQv8HdJ93=yF+3YGW#vK~k91(Wr6~6srb{pi2i`DY?|>!%mPA6{u_SbC|R@ zt>grDHfNeV*a5Sc`S-4R0{E4R91-=X!n=kv*V3QClC7ms0xt@(7Y7S{g$PBWdMlRwyp%| zmX7mo6KL66OMq@+E|>JSmIeBE_1)FowYzZD^2r(b|UUMO^FaUhCHCx>3anCxlN$v}M%baF zHh`1X2$YNo<`y7i2>a@VSPJ;v0)+Hn?{v(? z1sZ-&xqz*V_wM>TbAD8u_}V606^GySKr^ zxyd7URAHrApxlHM)y5EZRS1-uAayjlIT;S921`u>Y99@iS@;9CGB7sLdhx(C?kY$*;pkLwUJJwj#jx`H< z2_D-4U3g9oSX9h;r7tL%AlD#{8gdX8L=J|?@MhBmGx=ltipvp-QXMh_S)iPs;wrX8 zrhv`{TQL*VhPlZX&Va$Kp^0x!@zVw-n2gYfJhBVsz-(J!Kpa2u98Dw94&EGsf(HIi zLbKarf_;|3Z*@vhl!zC$(lIB;kaOZ{#~W|tCL##vaq3}Brw&5{32e$l(*sLqr2!=$ zTNr^5_ifQ97I+b7ARdfR$F++J0Btugbas^HY{HXhq~gJ(Xc0OO(#Wxk(@KEzHvhu=cZlz! zybC$70>zmEIvYtgfXSZmnG``{?fT4M1`TEjX9~@xWj7O)L>5R?4bh>8BEkfWXPbp= z8wBJ33kNdQLHo`+5rp)_L}EF%O=LM!pc@Nz(BCMUVh2L+K66n(+9NU@9*d z9oWblKzji^C1mCXLAyG4V8Ie-g{No#`m%#$qFoLAP!5sj@kUR|ak`R~Y2dNnk zMedN)(#$v%Fs;OB`QT{5CS@O%oc|252mez#zKZ5n8-4bn+6sn zAqFLs?o+*xX*e=`!b|VE6^VL4UY0X3b*O`b*aoX};&j=&AY#yYVW4{|9={%!O&V*2 zqrR*Ov8_7g&T7ZR;nA1MrzWpnJa;S_zcDrW1w}(R?YQ2F&bxOvhzCw?d1fj;8IK4hph)a4_C=>MvtG<&#HxqQ|eCQ77qmtSA2G zWs{GloIeF7?l-p`NPA>2y^*J1ZmGCGb|V^fo=rxt_H>|yM@~Ml^my_0c-QHZd+yos z)xU~fp8}sNeMQNqw{;XHJ5C>{*4V}2Cq@_hZt56g}yD!aZedfIcx*xVpxV;?sJ7FmZEVlU5lNG01TLIiJ@soX122@B;gvLP; zN*JJ)4R7$9ZRVYIjXeB|$Y!|8QoTgC9#kf7cOIs3As!EGK^$W7*;4j&T+myISWX5e z^z>m39HrP@B9`TS>nFB>$42BvytIFLM5Cml53{!RFAqd*dQw1B*2C+)pA96+XliKF zDU-&A4Iqx)4#))5$b$D4=%KNQWi(gnM|CsqOc3CrFqOsDflFY6NuovV1SY_u0xSal zLC)`l!mZr|`m`l)5<0N5}B6WZvfl!piYSQdzf!H4v_WfcnG z+96Ij=P5-JkkgT=I1(2_OhV+m0gZfkYFX5M4zW%vB zq4()~^%L6H`dYnGFVZvg%leppR{um#)jRb~@C(nW`?b&XIf8Sol| yG?hWNn?aVsAlbzrEHh9fEXXn^GQsZzNS8VK`v#H_1qNOR0}oGmBumd7EczcZuPh1x diff --git a/coverage.xml b/coverage.xml index 2fb2be3..5d100cc 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -16,9 +16,9 @@ - + - + @@ -31,47 +31,47 @@ - - + + - - - - - + + + + + - - - - - - - - - + + + + + + + + + - - - + + + - - - - - - - - + + + + + + + + - + - + - + @@ -81,41 +81,41 @@ - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + @@ -128,17 +128,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -150,7 +150,7 @@ - + @@ -178,53 +178,53 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - + + + + + + + + + + - - + + - + @@ -256,32 +256,32 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - + + + - - + + - + @@ -297,13 +297,13 @@ - - + + - - - + + + @@ -317,43 +317,43 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -399,114 +399,114 @@ - - - - - - - + + + + + + + - + - - - - - - + + + + + + - - - - - - - - - + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -516,223 +516,223 @@ - - - - + + + + - - - - + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - + + + + + - + - - - + + + - - + + - - - + + + - - - - - - - + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - + + + + + + + + - + - - - - - - - + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - + + + + + - + - + - - - - - - - - - - + + + + + + + + + + - - - - + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + - - - - - - - + + + + + + + - - - - + + + + - + - - - - - - - - - + + + + + + + + + - + @@ -752,76 +752,76 @@ - + - - - - - - - + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - + + + + + + - + @@ -834,283 +834,283 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - + - + - + - - - - - - - - - + + + + + + + + + - - + + - - - - - - - - - + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + @@ -1120,254 +1120,301 @@ - - + + - + - - + + - + - - + + - - - + + + - - - + + + - - - + + + - - - + + + - + - - - - + + + + - + - - - + + + - + - + - - + + - - + + - - - + + + - - + + - - - + + + - + - - + + - - - - - + + + + + - + - - + + - - + + - - - + + + - - - + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - + - - - + + + - + - - - + + + - - + + - + - - - + + + - - - + + + - + - - - + + + - + - - - + + + - - - + + + - + - - + + - + - + - + - + - - - + + + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -1384,114 +1431,114 @@ - + - - - + + + - - - - - + + + + + - - - - - - - - + + + + + + + + - + - - - - - + + + + + - - - - - - + + + + + + - + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - - - - - - - + + + + + + + + - - - - - - - + + + + + + + - - - - + + + + @@ -1500,102 +1547,102 @@ - - - + + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - + - + - - - - - - + + + + + + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - + - + - + @@ -1605,32 +1652,32 @@ - - - - - + + + + + - - - - - - - - + + + + + + + + - - - + + + - + @@ -1649,74 +1696,74 @@ - - + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + @@ -1724,12 +1771,12 @@ - + - - - - + + + + @@ -1741,64 +1788,64 @@ - - - + + + - - - - - - - - - + + + + + + + + + - - - - - - + + + + + + - - - + + + - - - - - + + + + + - - - - - - - - - + + + + + + + + + - + - + - + @@ -1810,129 +1857,129 @@ - + - - - - - + + + + + - - - + + + - - + + - + - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - + + + + + + @@ -2066,17 +2113,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -2084,60 +2131,60 @@ - + - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -2149,24 +2196,24 @@ - + - - - - - - - - - - + + + + + + + + + + - - - - - + + + + + @@ -2175,19 +2222,38 @@ - + - - - + + + + + + + + + + + + + + + + + + + + + + - + @@ -2201,45 +2267,45 @@ - - - + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - + + - + @@ -2261,192 +2327,192 @@ - + - + - - - - + + + + - + - + - + - - - + + + - + - + - - - - + + + + - - - - - - - + + + + + + + - + - + - - + + - + - + - - - - - - - + + + + + + + - + - + - - - - - - - + + + + + + + - + - + - + - - - + + + - + - + - - + + - - - - - - + + + + + + - + - + - - - - - + + + + + - + - + - - - - - - + + + + + + - + - + - - - - - - + + + + + + - + - + - - - - - - - + + + + + + + @@ -2454,48 +2520,48 @@ - - - - + + + + - + - + - - + + - - + + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - + @@ -2521,10 +2587,10 @@ - - + + - + @@ -2540,9 +2606,9 @@ - - - + + + @@ -2552,13 +2618,17 @@ + + - - + + + + @@ -2568,13 +2638,13 @@ - + @@ -2589,24 +2659,24 @@ - - - - - + + + - - + + + + @@ -2615,69 +2685,69 @@ - - - - - - - + + + - + + - + + + + - - - - + + - + - - - + + + - - + + - + + - + - - + - + + + @@ -2685,97 +2755,97 @@ - - - - - - - - - + + + + + + + - - - - - + + + + + - + + - - - + - - + + - - + + - + - - - + + + + + + - - - - + + + + - - - - - - - + + + + + + + - - - - + + + + - - - - + + + + @@ -2789,543 +2859,500 @@ - - - - - - - - + + + + + - - + + + - - - - + + - - + + + - - + + - - + + - - - + + + + - - + - - + - - - + + + + + - - + + - - + + + + - - - - + - - - - - - - - - - + + + + + + + + + + + + - - + - - - + + + - - + - - + + - - + + - - - - + + + + + + - - - - - - - - + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - + + + + + - - - + + + + + - + - - + + - + - - + + - - + + - - - - - + + + + + - + + - - - - - - - + + + + + - + + - + - - - - - + + + + + - - + + - - + + - - - - + + - - - + + + - + - - - - - - - - - - - - - - + + + + + + - - - - - - + + + + - + - - - - - - - - - + + + + + + + + - - - - - - + + + + + + + + + + + + + + + - - + + + + - - + - - - - - - - + + + + + + + + - - - - - - - - - - - + + + + + + + + + - + + - - - - - - + + + - - - - - - - - + + + - - - - - - - - - - - - - + - - + + + + + - - - - - + + + + + + + + + + + + + + + + - - - + + + + + - + - + + - - + + + - - - - - - - + + + + - - - - - - - + + + + - + + + + - - - - - - - + + + + - + + + + + - - + + + + - - + - - - + + + - + + + + + + + + + + + - + - - + - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + @@ -3339,82 +3366,82 @@ - - - - - - - + + + + + + + - - + + - - - - - + + + + + - - - - - - - + + + + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - + + + + + @@ -3432,7 +3459,7 @@ - + @@ -3448,107 +3475,107 @@ - - - + + + - - + + - + - - - - - + + + + + - + - - - - + + + + - + - + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - + + + + - - - - - - - + + + + + + + - + - + - - - - - - - + + + + + + + - + @@ -3559,39 +3586,39 @@ - + - - - - - + + + + + - - - - - - - + + + + + + + - + - - - - - - - - - - - + + + + + + + + + + + - + @@ -3651,59 +3678,59 @@ - - - - + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - + - - - + + + @@ -3715,39 +3742,39 @@ - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + - + @@ -3781,164 +3808,164 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - + + - + - - - + + + - - + + - - - - - - + + + + + + - - - - + + + + - - - - - + + + + + - + - - - - - - + + + + + + - + @@ -3946,35 +3973,35 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - + + + + - - - + + + - - - + + + @@ -3982,107 +4009,107 @@ - - - - - - + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + - - - - - - - - - + + + + + + + + + - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - + + + + + - - - - + + + + - - - + + + - - - - + + + + - + - - - - + + + + - - - - - + + + + + - - - + + + - - - + + + - - - - - + + + + + - + @@ -4093,28 +4120,28 @@ - - - - + + + + - - - - - - + + + + + + - + - + - + @@ -4172,7 +4199,7 @@ - + @@ -4201,24 +4228,24 @@ - - + + - + - - + + - + - + - + @@ -4228,28 +4255,28 @@ - - - - - + + + + + - + - - - - - - - - - + + + + + + + + + - + @@ -4265,34 +4292,34 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4307,94 +4334,94 @@ - - + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - + + + + - + - - + + - - - - - - - - - - + + + + + + + + + + - - - - - + + + + + - - - + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + + - - + + - + @@ -4404,50 +4431,50 @@ - + - - - - + + + + - - + + - - + + - - + + - - + + - - + + - - - - - - + + + + + + - - - - + + + + - - + + - - - + + + @@ -4455,7 +4482,7 @@ - + @@ -4463,65 +4490,65 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - - + + - - - - - - - - - - - + + + + + + + + + + + - + - + - - - - - - - + + + + + + + - - - - - - - + + + + + + + - + @@ -4530,7 +4557,7 @@ - + @@ -4539,55 +4566,53 @@ - - - - - - - - + + + + + + - + - + + - - + - + - - - - - - + + + + + + - + - + - + - + + - - - - + + + - + - + - + @@ -4596,105 +4621,105 @@ - + + - - - - + + + - + - + - + - + + - - - - + + + - + - + - + - + - + - + - + - - - - + + + + - - - - - + + + + + - - - - - + + + + + - + - - - - - + + + + + - + - + - - - - - + + + + + - + - + - - - - + + + + @@ -4702,7 +4727,6 @@ - @@ -4716,372 +4740,374 @@ + - + - + - + - - + + + + - - - - + + - + + - - + - + - + - + + - - + + - - + - + + - - + - - - - - + + + + + + - - + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + + - + - + - + - + - - - - + + + + - + + + - - - - - - - + + + + + - - - - - + + + + + - + - + - + - - - - - - - - - + + + + + + + + + + - - - - - - - + + + + + + - - - - - + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - + - + + - - + + - - + - - - - - + + + + - - - - - + + + + + + - - - - - + + + + + + + - + - - - - + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - + - + - + - - - + + + - + - - - - - - - - - - - + + + + + + + + + + + - - - + + - - - + + + + - + - + - - - - + + + + - @@ -5092,12 +5118,12 @@ + - + - - - + + @@ -5109,88 +5135,88 @@ - - - + + + + - + - - + + - - - + + - + + - + - + - + - + - + - - - - + + + + - + - + - + - + - - - - - + + + + + - - - + + + - + - + - + - @@ -5199,46 +5225,46 @@ + - - - + + + - + - - - - + + + + - - - + + - - - + + + + - - - + + - - + + @@ -5246,70 +5272,70 @@ + - - - - - + + + + + - + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + - + - - - - - - + + + + + + - @@ -5318,59 +5344,61 @@ + - + - + - + - - - - - + + + + - - + + + - + - - - - - + + + + + - - - + + + - - + + + - + - + @@ -5692,95 +5720,95 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + - + @@ -5797,7 +5825,7 @@ - + @@ -5805,47 +5833,47 @@ - + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + + + - + @@ -5853,92 +5881,92 @@ - - - + + + - - + + - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + - - - - + + + + - - + + - - - - + + + + - - - - - + + + + + - + - - - - + + + + - - - - - - - - + + + + + + + + - - - + + + - - + + - - - - + + + + - - - + + + @@ -5952,49 +5980,50 @@ - + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - + + + - + - + @@ -6059,7 +6088,7 @@ - + @@ -6137,31 +6166,31 @@ - - - - + + + + - - + + - - - - - - - - - - - - + + + + + + + + + + + + - - - + + + @@ -6223,62 +6252,61 @@ - + - + - + - + - + - + - + - + - - + + - + - + - + - + - - - + + - + - + @@ -6287,75 +6315,74 @@ - + - + - + - + - - - - + + + + - + - - - - - + + + + + - + - + - + - + - + - + - - - + + - - - - + + + + + - + - + - + - @@ -6375,192 +6402,192 @@ + - + - + - + - + - + - - + + + - - + - - - - + + + - - - + + + + - + - + - + - + + - - + - + - - - + + + - + - - + + - + - + - + - - - - - - + + + + + + - - + + - + - + - + - - - - - - + + + + + + - + - + - - - - - + + + + + - + - + - + - - - + + - - - + + + + - + - + - + - + - + + - - + - - - - - - + + + + + + - @@ -6580,204 +6607,205 @@ + - + - + - + - - - - - - + + + + + + - + - + - - - - - - + + + + + + - + - + - + - + - + - + - + - - - + + - - - + + + + - + - + - + - + - - - - - - + + + + + + - + - + - + - + - + - - + + - - - - - - - + + + + + + + - + - + - + - + - - - - + + + + - - + + - + - + - - + + - - + + - + - - - - - - - - - - + + + + + + + + + + @@ -6786,34 +6814,33 @@ - + - + - + - + - - - + + + - + - - - + + + - @@ -6821,14 +6848,17 @@ + + + - + - + @@ -6838,188 +6868,187 @@ - - + - + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + - - - + + + - + - + - + @@ -7027,29 +7056,28 @@ - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - + - + @@ -7059,30 +7087,30 @@ - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - + + + + + + + + - + @@ -7100,103 +7128,103 @@ - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - + + + + - + - - - - - - - - - - - + + + + + + + + + + + - - + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + + @@ -7206,35 +7234,35 @@ - - - - - - - - - - + + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - + + + + + - + @@ -7254,52 +7282,52 @@ - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + @@ -7309,9 +7337,9 @@ - - - + + + @@ -7325,36 +7353,36 @@ - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -7464,41 +7492,41 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - + + + + + + + + @@ -7507,11 +7535,11 @@ - - - - - + + + + + @@ -7527,7 +7555,7 @@ - + @@ -7537,7 +7565,7 @@ - + @@ -7546,7 +7574,7 @@ - + @@ -7555,8 +7583,8 @@ - - + + @@ -7578,48 +7606,48 @@ - + - - - - - - - - - + + + + + + + + + - - - - - - + + + + + + - - - - - + + + + + - - - - - - - - - + + + + + + + + + - + @@ -7635,46 +7663,46 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - + + + + + + + + + + + - + @@ -7684,37 +7712,37 @@ - - + + - - + + - + - + - + - - - + + + - - + + - - + + - + - - - - + + + + - + @@ -7723,30 +7751,31 @@ - - - - - + + + + - - - - + + + + - + - - - - + + + + + + - + @@ -7762,7 +7791,7 @@ - + @@ -7787,23 +7816,23 @@ - - - + + + - - - + + + - - - + + + @@ -7845,43 +7874,44 @@ - - + + - - + + - - + + - - + + - - - - + + + + - - - + + + - + - - + + + - + @@ -7891,56 +7921,56 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - - - - - - - - + + + + + + + - + @@ -7950,77 +7980,78 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + - + @@ -8029,42 +8060,42 @@ - + - - - - + + + + - - + + - - + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/src/slopometry/core/database.py b/src/slopometry/core/database.py index 74aa490..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, @@ -36,7 +37,6 @@ UserStory, UserStoryEntry, ) -from slopometry.core.display_models import SessionDisplayData from slopometry.core.plan_analyzer import PlanAnalyzer from slopometry.core.settings import settings diff --git a/src/slopometry/core/models.py b/src/slopometry/core/models.py index f1ca4e9..33c2e60 100644 --- a/src/slopometry/core/models.py +++ b/src/slopometry/core/models.py @@ -1637,9 +1637,7 @@ class CurrentImpactSummary(BaseModel): 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" - ) + smell_advantages: list["SmellAdvantage"] = Field(default_factory=list, description="Per-smell advantage breakdown") @staticmethod def from_analysis(analysis: "CurrentChangesAnalysis") -> "CurrentImpactSummary": diff --git a/src/slopometry/display/formatters.py b/src/slopometry/display/formatters.py index 517791c..e7ea3d9 100644 --- a/src/slopometry/display/formatters.py +++ b/src/slopometry/display/formatters.py @@ -835,9 +835,7 @@ def create_sessions_table(sessions_data: list[SessionDisplayData]) -> 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, diff --git a/src/slopometry/summoner/cli/commands.py b/src/slopometry/summoner/cli/commands.py index 70fa98e..46c0e2b 100644 --- a/src/slopometry/summoner/cli/commands.py +++ b/src/slopometry/summoner/cli/commands.py @@ -422,7 +422,8 @@ def current_impact( return from slopometry.core.language_guard import check_language_support - from slopometry.core.models import CurrentImpactSummary, 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 @@ -485,7 +486,9 @@ def current_impact( if not analysis: if output_json: - print('{"error": "Failed to analyze previous commit. Ensure at least 2 commits with code changes."}') + 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]") diff --git a/src/slopometry/summoner/services/baseline_service.py b/src/slopometry/summoner/services/baseline_service.py index 26fa808..03bc4c5 100644 --- a/src/slopometry/summoner/services/baseline_service.py +++ b/src/slopometry/summoner/services/baseline_service.py @@ -8,7 +8,7 @@ from pathlib import Path from statistics import mean, median, stdev -from pydantic import BaseModel, Field +from pydantic import BaseModel from slopometry.core.complexity_analyzer import ComplexityAnalyzer from slopometry.core.database import EventDatabase diff --git a/src/slopometry/summoner/services/llm_service.py b/src/slopometry/summoner/services/llm_service.py index 24a3971..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 @@ -94,7 +95,7 @@ def get_feature_boundaries(self, repo_path: Path, limit: int = 20) -> list: def prepare_features_data_for_display(self, features: list) -> list[FeatureDisplayData]: """Prepare feature boundaries data for display formatting.""" - from slopometry.core.display_models import FeatureDisplayData + features_data = [] for feature in features: diff --git a/src/slopometry/summoner/services/qpe_calculator.py b/src/slopometry/summoner/services/qpe_calculator.py index 75c007b..c9a3526 100644 --- a/src/slopometry/summoner/services/qpe_calculator.py +++ b/src/slopometry/summoner/services/qpe_calculator.py @@ -85,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/tests/test_baseline_service.py b/tests/test_baseline_service.py index 8eb6fd9..59a8301 100644 --- a/tests/test_baseline_service.py +++ b/tests/test_baseline_service.py @@ -4,9 +4,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest -from pydantic import ValidationError - from conftest import make_test_metrics from slopometry.core.models import ( @@ -269,28 +266,51 @@ def test_get_or_compute_baseline__recomputes_when_weight_version_stale(self, tmp 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, + 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, + 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, + 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, + 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={}, + qpe=0.45, + mi_normalized=0.5, + smell_penalty=0.1, + adjusted_quality=0.45, + smell_counts={}, ), strategy=ResolvedBaselineStrategy( requested=BaselineStrategy.AUTO, diff --git a/tests/test_current_impact_service.py b/tests/test_current_impact_service.py index c99044a..7976800 100644 --- a/tests/test_current_impact_service.py +++ b/tests/test_current_impact_service.py @@ -338,17 +338,27 @@ class TestCurrentImpactSummary: @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, + 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, + 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", @@ -362,14 +372,23 @@ def test_from_analysis__maps_all_fields(self): 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, + 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, + smell_name="orphan_comment", + baseline_count=10, + candidate_count=8, + weight=0.01, + weighted_delta=-0.02, ) analysis = CurrentChangesAnalysis( repository_path="/tmp/repo", @@ -405,8 +424,13 @@ 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, + 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", @@ -420,9 +444,15 @@ def test_from_analysis__serializes_to_json(self): 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, + 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( @@ -439,10 +469,18 @@ def test_from_analysis__serializes_to_json(self): 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", + "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" 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