diff --git a/.coverage b/.coverage index c592316..4ad650f 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 436730c..085ad95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,12 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: slopometry-linux-x64 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for git-dependent tests - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/README.md b/README.md index 8678af6..6268e51 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ And many more undocumented features. Which worked just fine on my machine at som Runtime: Almost all metrics are trend-relative and the first run will do a long code analysis before caching, but if you consider using this tool, you are comfortable with waiting for agents anyway. -Compat: This software was tested mainly on Linux with Python codebases. There are plans to support Rust at some point but not any kind of cursed C++ or other unserious languages like that. I heard someone ran it on MacOS successful once but we met the person on twitter, so YMMV. +Compat: This software was tested mainly on Linux with Python codebases. There are plans to support Rust at some point but not any kind of cursed C++ or other unserious languages like that. I heard someone ran it on MacOS successfully once but we met the person on twitter, so YMMV. Seriously, please do not open PRs with support for any kind of unserious languages. Just fork and pretend you made it. We are ok with that. Thank you. diff --git a/SPONSORS.md b/SPONSORS.md new file mode 100644 index 0000000..287eedb --- /dev/null +++ b/SPONSORS.md @@ -0,0 +1,5 @@ +# Sponsors + +## Infrastructure + +- **[Droidcraft](https://page.droidcraft.org)** - GitHub Actions runners diff --git a/coverage.xml b/coverage.xml index a4e7804..79fffd1 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -16,60 +16,60 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - + + + + + + + - + @@ -78,7 +78,7 @@ - + @@ -87,50 +87,50 @@ - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - + + + + - + @@ -140,51 +140,51 @@ - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - + + @@ -210,7 +210,7 @@ - + @@ -249,367 +249,367 @@ - - - - - - + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + - - - - - - - - - - - - + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + + + - + @@ -626,78 +626,78 @@ - - - - - - - - + + + + + + + + - - - - - - + + + + + + - + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + @@ -773,30 +773,30 @@ - + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + @@ -877,22 +877,22 @@ - - - - - - + + + + + + - - - - - - - - - + + + + + + + + + @@ -1025,28 +1025,28 @@ - - - + + + - - - - + + + + - - + + - - + + - - - - - - + + + + + + @@ -1110,13 +1110,13 @@ - - - + + + - - - + + + @@ -1138,78 +1138,78 @@ - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - - - + + + + + + + + + - + - - - - - - + + + + + + - - - - - - - + + + + + + + - + - - - - - + + + + + - + - + @@ -1219,85 +1219,85 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - + - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - + + + - + - + - - - - - - - + + + + + + + - + - + @@ -1320,71 +1320,71 @@ - - - - - - + + + + + + - - - - - - - - - + + + + + + + + + - - - + + + - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - + + + - - - + + + - - - - - - + + + + + + - + @@ -1392,7 +1392,7 @@ - + @@ -1439,7 +1439,7 @@ - + @@ -1456,309 +1456,297 @@ - + - - - - - - + + - - + + + + + - - - - - - - - + + + + + - - - - - - - - - - + + + + + + + + + - - + + - - - - - - + + + + + - - - + - - - - - - + + + + + + + + - - - - - - + - - + + - - - - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - + - - + - - - - + - + + - + + - - - - - - - - - - - + + + + + + + + + + + + + + - - + - - + - - - - + - + + - - + + + + + + + + + + + + + - - - - + + + + - - - - - - - - - - - + @@ -1766,71 +1754,136 @@ - - - + - - + - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1839,71 +1892,71 @@ - + - - - - - - - + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + @@ -1925,175 +1978,175 @@ - + - + - - - - + + + + - + - + - + - - - + + + - + - + - - - - + + + + - - - - - - - + + + + + + + - + - + - - + + - + - + - - - - - - - + + + + + + + - + - + - - - - - - - + + + + + + + - + - + - + - - - + + + - + - + - - + + - - - - - - + + + + + + - - + + - - - - + + + + - + - + - - + + - - + + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - + @@ -2258,17 +2311,17 @@ - + - + - + - - + + @@ -2482,7 +2535,7 @@ - + @@ -2550,14 +2603,14 @@ - - - - - - - - + + + + + + + + @@ -2578,25 +2631,25 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -2607,11 +2660,11 @@ - + - + - + @@ -2675,13 +2728,13 @@ - - + + - - + + @@ -2695,20 +2748,20 @@ - + - - + + - + - + - + - + @@ -2718,12 +2771,12 @@ - - - + + + - + @@ -2733,12 +2786,12 @@ - - - - - - + + + + + + @@ -2758,17 +2811,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -2808,154 +2861,154 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -2967,40 +3020,40 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + @@ -3015,7 +3068,7 @@ - + @@ -3031,152 +3084,152 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - + + + + + @@ -3184,99 +3237,99 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - + + + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + @@ -3295,9 +3348,9 @@ - - - + + + @@ -3323,285 +3376,321 @@ - - - - - + + + - - - - + + + - - - - + + + + + - - + - + - - - - - + + - + - - - + + + - - + - - + + + + + + + - - - - + - + - - - + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - + + + + + - - + + - - - - + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - + + + + + + + + + + + @@ -3621,9 +3710,9 @@ - - - + + + @@ -3636,7 +3725,7 @@ - + @@ -3658,7 +3747,7 @@ - + @@ -3691,21 +3780,21 @@ - + - + - + @@ -3714,7 +3803,7 @@ - + @@ -3795,7 +3884,7 @@ - + @@ -3870,13 +3959,13 @@ - + - + @@ -3894,7 +3983,7 @@ - + @@ -3902,7 +3991,7 @@ - + @@ -3930,13 +4019,13 @@ - + - + @@ -3947,7 +4036,7 @@ - + @@ -3959,7 +4048,7 @@ - + @@ -3970,7 +4059,7 @@ - + @@ -3981,7 +4070,7 @@ - + @@ -3991,7 +4080,7 @@ - + @@ -4001,7 +4090,7 @@ - + @@ -4039,7 +4128,7 @@ - + @@ -4049,9 +4138,9 @@ - - - + + + @@ -4068,7 +4157,7 @@ - + @@ -4080,7 +4169,7 @@ - + @@ -4129,7 +4218,7 @@ - + @@ -4175,7 +4264,7 @@ - + @@ -4204,7 +4293,7 @@ - + @@ -4219,30 +4308,30 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -4259,7 +4348,7 @@ - + @@ -4289,19 +4378,19 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + @@ -4309,17 +4398,17 @@ - - + + - + - - - + + + @@ -4330,62 +4419,62 @@ - - - - - - + + + + + + - + - - - - - + + + + + - + - + - + - - - - - - - + + + + + + + - - + + - + - + - + - - - - - - + + + + + + - - + + - + - + @@ -4393,102 +4482,102 @@ - + - + - + - + - - + + - + - + - + - + - + - + - + - - - - - - - - - + + + + + + + + + - + - + - + - + - + - + - + - + - + - - - + + + - + - - + + - + - + - + - - + + @@ -4496,11 +4585,11 @@ - - - - - + + + + + @@ -4512,14 +4601,14 @@ - + - + - + @@ -4527,17 +4616,17 @@ - + - + - - - + + + - + @@ -4545,291 +4634,340 @@ - + - + - - - + + + - - - + + + - + - - + + - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + - - - + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - + + + + + + - + - + - + - + - - - - - - - - + + + + + + + + - - + + - + - + - - - - - - - - - - - + + + + + + + + + + + @@ -4837,7 +4975,7 @@ - + @@ -4845,7 +4983,7 @@ - + @@ -4854,7 +4992,7 @@ - + @@ -4862,7 +5000,7 @@ - + @@ -4879,14 +5017,14 @@ - - - - - - - - + + + + + + + + @@ -4910,37 +5048,37 @@ - - - - - - - - - + + + + + + + + + - - + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - + + + + @@ -4977,11 +5115,11 @@ - - - - - + + + + + @@ -5036,8 +5174,8 @@ - - + + @@ -5047,9 +5185,9 @@ - - - + + + @@ -5070,12 +5208,12 @@ - - - - - - + + + + + + @@ -5137,11 +5275,11 @@ - - - - - + + + + + @@ -5193,10 +5331,10 @@ - - - - + + + + @@ -5218,8 +5356,8 @@ - - + + @@ -5237,9 +5375,9 @@ - - - + + + @@ -5256,11 +5394,11 @@ - - - - - + + + + + @@ -5289,9 +5427,9 @@ - - - + + + @@ -5332,9 +5470,9 @@ - - - + + + @@ -5349,9 +5487,9 @@ - - - + + + @@ -5389,10 +5527,10 @@ - - - - + + + + @@ -5411,10 +5549,10 @@ - - - - + + + + @@ -5448,9 +5586,9 @@ - - - + + + @@ -5495,7 +5633,7 @@ - + @@ -5651,102 +5789,102 @@ - + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -5755,37 +5893,37 @@ - - - + + + - + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -5814,7 +5952,7 @@ - + @@ -5826,7 +5964,7 @@ - + @@ -5834,7 +5972,7 @@ - + @@ -5850,9 +5988,9 @@ - - - + + + @@ -6015,17 +6153,17 @@ - + - - - - - - - - + + + + + + + + @@ -6034,11 +6172,11 @@ - - - - - + + + + + @@ -6054,7 +6192,7 @@ - + @@ -6064,7 +6202,7 @@ - + @@ -6073,17 +6211,17 @@ - + - - - - - - - - - + + + + + + + + + @@ -6105,41 +6243,41 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6211,71 +6349,69 @@ - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - + - - - - + + + - + + - - - - - - - + + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + @@ -6283,244 +6419,246 @@ + - - - + + - - - + + + + - - + + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + - - - + - + - - - - - + + - - - - + + + + + + + + + + + + + + + - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - + - - - - - - - - - - - - + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + diff --git a/pyproject.toml b/pyproject.toml index 1308f9e..8897d18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "slopometry" -version = "20260107-1" +version = "20260113-1" description = "Opinionated code quality metrics for code agents and humans" readme = "README.md" requires-python = ">=3.13" diff --git a/src/slopometry/core/hook_handler.py b/src/slopometry/core/hook_handler.py index ac76399..39bb79d 100644 --- a/src/slopometry/core/hook_handler.py +++ b/src/slopometry/core/hook_handler.py @@ -277,6 +277,13 @@ def _get_feedback_cache_path(working_directory: str) -> Path: def _compute_feedback_cache_key(working_directory: str, edited_files: set[str], feedback_hash: str) -> str: """Compute a cache key for the current state. + Uses language-aware change detection to avoid cache invalidation from + non-source file changes (like uv.lock, submodules, build artifacts, etc.). + + The languages parameter defaults to None (all supported languages). + Currently only Python is supported; future languages will be auto-detected + via LanguageDetector when added to the registry. + Args: working_directory: Path to the working directory edited_files: Set of edited file paths @@ -288,14 +295,20 @@ def _compute_feedback_cache_key(working_directory: str, edited_files: set[str], tracker = GitTracker(Path(working_directory)) git_state = tracker.get_git_state() commit_sha = git_state.commit_sha or "unknown" - wt_calculator = WorkingTreeStateCalculator(working_directory) - working_tree_hash = ( - wt_calculator.calculate_working_tree_hash(commit_sha) if git_state.has_uncommitted_changes else "clean" - ) - files_key = ",".join(sorted(edited_files)) + # Use all supported languages (currently Python only) + # Future: could use LanguageDetector to auto-detect project languages + wt_calculator = WorkingTreeStateCalculator(working_directory, languages=None) + + # Only compute working tree hash if there are source file changes + # (not just any file like uv.lock, submodules, or build artifacts) + has_source_changes = bool(wt_calculator._get_modified_source_files_from_git()) + working_tree_hash = wt_calculator.calculate_working_tree_hash(commit_sha) if has_source_changes else "clean" + + files_key = ",".join(sorted(edited_files)) key_parts = f"{commit_sha}:{working_tree_hash}:{files_key}:{feedback_hash}" - return hashlib.sha256(key_parts.encode()).hexdigest()[:16] + # Use blake2b for arm64/amd64 performance + return hashlib.blake2b(key_parts.encode(), digest_size=8).hexdigest() def _is_feedback_cached(working_directory: str, cache_key: str) -> bool: @@ -386,12 +399,17 @@ def handle_stop_event(session_id: str, parsed_input: "StopInput | SubagentStopIn if feedback_parts: feedback = "\n\n".join(feedback_parts) + + # Hash feedback content BEFORE adding session-specific metadata + # This ensures cache hits work across different sessions with same feedback + # Use blake2b for arm64/amd64 performance + feedback_hash = hashlib.blake2b(feedback.encode(), digest_size=8).hexdigest() + feedback += ( f"\n\n---\n**Session**: `{session_id}` | Details: `slopometry solo show {session_id} --smell-details`" ) # Check cache - skip if same feedback was already shown for this state - feedback_hash = hashlib.sha256(feedback.encode()).hexdigest()[:16] cache_key = _compute_feedback_cache_key(stats.working_directory, edited_files, feedback_hash) if _is_feedback_cached(stats.working_directory, cache_key): diff --git a/src/slopometry/core/language_config.py b/src/slopometry/core/language_config.py new file mode 100644 index 0000000..934b231 --- /dev/null +++ b/src/slopometry/core/language_config.py @@ -0,0 +1,186 @@ +"""Language-specific configuration for source file detection and caching. + +This module provides a registry of language configurations that define: +- Source file extensions and git patterns +- Patterns for files to ignore (build artifacts, caches) +- Test file naming conventions + +The design allows easy extension to new languages while keeping type safety. +""" + +from dataclasses import dataclass, field +from pathlib import Path + +from slopometry.core.models import ProjectLanguage + + +@dataclass(frozen=True) +class LanguageConfig: + """Configuration for a programming language's file patterns. + + Attributes: + language: The ProjectLanguage enum value + extensions: File extensions for source files (e.g., [".py"]) + git_patterns: Patterns for git diff commands (e.g., ["*.py"]) + ignore_dirs: Directories to ignore (build artifacts, caches) + test_patterns: Glob patterns for test files + """ + + 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) + + def matches_extension(self, file_path: Path | str) -> bool: + """Check if a file path matches this language's extensions.""" + suffix = Path(file_path).suffix.lower() + return suffix in self.extensions + + +# Language configuration registry +PYTHON_CONFIG = LanguageConfig( + language=ProjectLanguage.PYTHON, + extensions=(".py",), + git_patterns=("*.py",), + ignore_dirs=( + # Virtual environments + "__pycache__", + ".venv", + "venv", + "env", + ".env", + "site-packages", + # Build artifacts (bdist, wheel, egg) + "dist", + "build", + ".eggs", + "*.egg-info", + # Testing caches + ".pytest_cache", + ".tox", + ".nox", + ".hypothesis", + # Type checker / linter caches + ".mypy_cache", + ".ruff_cache", + ".pytype", + # Coverage artifacts + "htmlcov", + ".coverage", + # Jupyter + ".ipynb_checkpoints", + # IDE + ".idea", + ".vscode", + ), + test_patterns=( + "test_*.py", + "*_test.py", + "tests/**/*.py", + ), +) + +# Future language configs can be added here: +# RUST_CONFIG = LanguageConfig( +# language=ProjectLanguage.RUST, +# extensions=(".rs",), +# git_patterns=("*.rs",), +# ignore_dirs=("target",), +# test_patterns=("*_test.rs", "tests/**/*.rs"), +# ) + +# Registry mapping ProjectLanguage to its config +LANGUAGE_CONFIGS: dict[ProjectLanguage, LanguageConfig] = { + ProjectLanguage.PYTHON: PYTHON_CONFIG, +} + + +def get_language_config(language: ProjectLanguage) -> LanguageConfig: + """Get the configuration for a specific language. + + Args: + language: The ProjectLanguage to get config for + + Returns: + LanguageConfig for the specified language + + Raises: + KeyError: If language is not supported + """ + if language not in LANGUAGE_CONFIGS: + raise KeyError(f"No configuration found for language: {language}") + return LANGUAGE_CONFIGS[language] + + +def get_all_supported_configs() -> list[LanguageConfig]: + """Get configurations for all supported languages. + + Returns: + List of all registered LanguageConfig objects + """ + return list(LANGUAGE_CONFIGS.values()) + + +def get_combined_git_patterns(languages: list[ProjectLanguage] | None = None) -> list[str]: + """Get combined git patterns for multiple languages. + + Args: + languages: List of languages to include, or None for all supported + + Returns: + Combined list of git patterns for the specified languages + """ + if languages is None: + configs = get_all_supported_configs() + else: + configs = [get_language_config(lang) for lang in languages] + + patterns: list[str] = [] + for config in configs: + patterns.extend(config.git_patterns) + return patterns + + +def get_combined_ignore_dirs(languages: list[ProjectLanguage] | None = None) -> set[str]: + """Get combined ignore directories for multiple languages. + + Args: + languages: List of languages to include, or None for all supported + + Returns: + Combined set of directories to ignore + """ + if languages is None: + configs = get_all_supported_configs() + else: + configs = [get_language_config(lang) for lang in languages] + + ignore_dirs: set[str] = set() + for config in configs: + ignore_dirs.update(config.ignore_dirs) + return ignore_dirs + + +def should_ignore_path(file_path: Path | str, languages: list[ProjectLanguage] | None = None) -> bool: + """Check if a file path should be ignored based on language configs. + + Args: + file_path: Path to check + languages: List of languages to use for ignore patterns, or None for all + + Returns: + True if the path should be ignored (is in an ignored directory) + """ + ignore_dirs = get_combined_ignore_dirs(languages) + path = Path(file_path) + + # Check if any part of the path is in ignore_dirs + for part in path.parts: + if part in ignore_dirs: + return True + # Handle glob-like patterns (e.g., "*.egg-info") + for ignore_pattern in ignore_dirs: + if ignore_pattern.startswith("*") and part.endswith(ignore_pattern[1:]): + return True + return False diff --git a/src/slopometry/core/settings.py b/src/slopometry/core/settings.py index c593748..aeffe3a 100644 --- a/src/slopometry/core/settings.py +++ b/src/slopometry/core/settings.py @@ -76,6 +76,15 @@ def _ensure_global_config_dir() -> None: backup_existing_settings: bool = True + gitignore_entry: str = Field( + default=".slopometry/", + description="Entry to add to .gitignore on local install", + ) + gitignore_comment: str = Field( + default="# slopometry session cache (auto-generated)", + description="Comment added above gitignore entry", + ) + event_display_limit: int = 50 recent_sessions_limit: int = 10 diff --git a/src/slopometry/core/transcript_token_analyzer.py b/src/slopometry/core/transcript_token_analyzer.py index ee51578..4bf61b9 100644 --- a/src/slopometry/core/transcript_token_analyzer.py +++ b/src/slopometry/core/transcript_token_analyzer.py @@ -1,11 +1,48 @@ """Transcript token analyzer for extracting and categorizing token usage.""" import json +import logging from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field from slopometry.core.models import TokenUsage, ToolType from slopometry.core.plan_analyzer import PlanAnalyzer +logger = logging.getLogger(__name__) + + +class MessageUsage(BaseModel): + """Token usage from an assistant message.""" + + input_tokens: int = 0 + output_tokens: int = 0 + + +class AssistantMessage(BaseModel): + """Assistant message with usage and content.""" + + usage: MessageUsage = Field(default_factory=MessageUsage) + content: list[dict[str, Any]] = Field(default_factory=list) + + +class ToolUseResult(BaseModel): + """Result from a tool use (subagent tokens).""" + + totalTokens: int = 0 # camelCase matches transcript format + + +class TranscriptEvent(BaseModel, extra="allow"): + """A single event from transcript.jsonl. + + Uses extra="allow" to tolerate additional fields from Claude Code. + """ + + type: str | None = None + message: AssistantMessage | None = None + toolUseResult: ToolUseResult | None = None # camelCase matches transcript + class TranscriptTokenAnalyzer: """Analyzes Claude Code transcripts to extract token usage by category.""" @@ -30,51 +67,51 @@ def analyze_transcript(self, transcript_path: Path) -> TokenUsage: with open(transcript_path, encoding="utf-8") as f: for line in f: try: - event = json.loads(line) - except json.JSONDecodeError: + raw_event = json.loads(line) + event = TranscriptEvent.model_validate(raw_event) + except (json.JSONDecodeError, Exception): continue self._process_event(event, usage) - except (OSError, json.JSONDecodeError): - pass + except OSError as e: + logger.warning(f"Failed to read transcript file {transcript_path}: {e}") return usage - def _process_event(self, event: dict, usage: TokenUsage) -> None: + def _process_event(self, event: TranscriptEvent, usage: TokenUsage) -> None: """Process a single transcript event and update token usage. Args: - event: Parsed JSON event from transcript + event: Parsed transcript event usage: TokenUsage to update """ - event_type = event.get("type") - - if event_type == "assistant": + if event.type == "assistant": self._process_assistant_message(event, usage) - elif event_type == "user": + elif event.type == "user": self._process_tool_result(event, usage) - def _process_assistant_message(self, event: dict, usage: TokenUsage) -> None: + def _process_assistant_message(self, event: TranscriptEvent, usage: TokenUsage) -> None: """Process an assistant message to extract and categorize tokens. Args: event: Assistant message event usage: TokenUsage to update """ - message = event.get("message", {}) - msg_usage = message.get("usage", {}) + if not event.message: + return - if not msg_usage: + msg_usage = event.message.usage + if not msg_usage.input_tokens and not msg_usage.output_tokens: return - input_tokens = msg_usage.get("input_tokens", 0) - output_tokens = msg_usage.get("output_tokens", 0) + input_tokens = msg_usage.input_tokens + output_tokens = msg_usage.output_tokens usage.total_input_tokens += input_tokens usage.total_output_tokens += output_tokens - content = message.get("content", []) + content = event.message.content tool_categories = self._get_tool_categories(content) if not tool_categories: @@ -146,7 +183,7 @@ def _classify_tool(self, tool_name: str, tool_input: dict) -> str: return "implementation" - def _process_tool_result(self, event: dict, usage: TokenUsage) -> None: + def _process_tool_result(self, event: TranscriptEvent, usage: TokenUsage) -> None: """Process tool result to capture subagent tokens. Subagent tokens are tracked separately in subagent_tokens field, @@ -157,11 +194,8 @@ def _process_tool_result(self, event: dict, usage: TokenUsage) -> None: event: User message event (containing tool_result) usage: TokenUsage to update """ - tool_use_result = event.get("toolUseResult", {}) - if isinstance(tool_use_result, dict): - total_tokens = tool_use_result.get("totalTokens", 0) - if total_tokens: - usage.subagent_tokens += total_tokens + if event.toolUseResult and event.toolUseResult.totalTokens: + usage.subagent_tokens += event.toolUseResult.totalTokens def analyze_transcript_tokens(transcript_path: Path) -> TokenUsage: diff --git a/src/slopometry/core/working_tree_state.py b/src/slopometry/core/working_tree_state.py index 525afd0..6e13af4 100644 --- a/src/slopometry/core/working_tree_state.py +++ b/src/slopometry/core/working_tree_state.py @@ -5,44 +5,63 @@ from pathlib import Path from slopometry.core.git_tracker import GitTracker +from slopometry.core.language_config import ( + get_combined_git_patterns, + should_ignore_path, +) +from slopometry.core.models import ProjectLanguage class WorkingTreeStateCalculator: """Calculates unique identifiers for working tree states including uncommitted changes.""" - def __init__(self, working_directory: Path | str): + def __init__( + self, + working_directory: Path | str, + languages: list[ProjectLanguage] | None = None, + ): """Initialize calculator for a specific directory. Args: working_directory: Directory to analyze for working tree state + languages: Languages to consider, or None for all supported languages """ self.working_directory = Path(working_directory).resolve() + self.languages = languages def calculate_working_tree_hash(self, commit_sha: str) -> str: """Calculate a hash representing the current working tree state. + Uses two-tier detection: + 1. Get list of potentially modified source files from git (fast) + 2. For each file, use content hash to verify actual changes + + Uses BLAKE2b for hashing - fast on both arm64 and amd64, built into Python. + Args: commit_sha: Current git commit SHA as base Returns: Unique hash representing current working tree state """ - python_files = self._get_python_files() + modified_source_files = self._get_modified_source_files_from_git() hash_components = [commit_sha] - for py_file in sorted(python_files): + for source_file in sorted(modified_source_files): try: - mtime = py_file.stat().st_mtime - rel_path = py_file.relative_to(self.working_directory) - hash_components.append(f"{rel_path}:{mtime}") + # Use content hash, not mtime - filters out touch/checkout false positives + # BLAKE2b with digest_size=8 gives 16 hex chars, fast on arm64/amd64 + content_hash = hashlib.blake2b(source_file.read_bytes(), digest_size=8).hexdigest() + rel_path = source_file.relative_to(self.working_directory) + hash_components.append(f"{rel_path}:{content_hash}") except (OSError, ValueError): continue - hash_components.append(f"file_count:{len(python_files)}") + hash_components.append(f"file_count:{len(modified_source_files)}") combined = "|".join(hash_components) - return hashlib.sha256(combined.encode("utf-8")).hexdigest()[:16] + return hashlib.blake2b(combined.encode("utf-8"), digest_size=8).hexdigest() def _get_python_files(self) -> list[Path]: """Get all Python files in the working directory that would be analyzed. @@ -53,6 +72,64 @@ def _get_python_files(self) -> list[Path]: tracker = GitTracker(self.working_directory) return tracker.get_tracked_python_files() + def _get_modified_source_files_from_git(self) -> list[Path]: + """Get source files with uncommitted changes using git diff (fast first-pass). + + Uses git diff to get modified source files (staged + unstaged) for + configured languages. Filters out files in ignored directories + (build artifacts, caches, etc.). + + This is tier 1 of the two-tier change detection - fast but may include + files that only have mtime changes (which tier 2 content hash filters out). + + Returns: + List of Path objects for source files that git reports as modified + """ + git_patterns = get_combined_git_patterns(self.languages) + files: set[Path] = set() + + for pattern in git_patterns: + try: + # Unstaged changes + result1 = subprocess.run( + ["git", "diff", "--name-only", "--", pattern], + cwd=self.working_directory, + capture_output=True, + text=True, + timeout=10, + ) + # Staged changes + result2 = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--", pattern], + cwd=self.working_directory, + capture_output=True, + text=True, + timeout=10, + ) + + for line in result1.stdout.splitlines() + result2.stdout.splitlines(): + if line.strip(): + rel_path = line.strip() + # Filter out files in ignored directories (build artifacts, caches) + if not should_ignore_path(rel_path, self.languages): + files.add(self.working_directory / rel_path) + except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): + continue + + return list(files) + + def _get_modified_python_files_from_git(self) -> list[Path]: + """Get Python files with uncommitted changes. + + Convenience wrapper for Python-specific detection. + Delegates to _get_modified_source_files_from_git with Python language. + + Returns: + List of Path objects for Python files that git reports as modified + """ + # Use stored languages or default to Python-only for backwards compatibility + return self._get_modified_source_files_from_git() + def get_current_commit_sha(self) -> str | None: """Get current git commit SHA for the working directory. diff --git a/src/slopometry/solo/cli/commands.py b/src/slopometry/solo/cli/commands.py index 54219df..eb7449b 100644 --- a/src/slopometry/solo/cli/commands.py +++ b/src/slopometry/solo/cli/commands.py @@ -55,7 +55,8 @@ def install(global_: bool) -> None: success, message = hook_service.install_hooks(global_) if success: - console.print(f"[green]{message}[/green]") + for line in message.split("\n"): + console.print(f"[green]{line}[/green]") console.print("[cyan]All Claude Code sessions will now be automatically tracked[/cyan]") console.print(f"[dim]Config: {get_default_config_dir()}[/dim]") console.print(f"[dim]Data: {get_default_data_dir()}[/dim]") diff --git a/src/slopometry/solo/services/hook_service.py b/src/slopometry/solo/services/hook_service.py index ea90146..3f925bb 100644 --- a/src/slopometry/solo/services/hook_service.py +++ b/src/slopometry/solo/services/hook_service.py @@ -4,18 +4,85 @@ from datetime import datetime from pathlib import Path +from pydantic import BaseModel, Field + from slopometry.core.settings import get_default_config_dir, get_default_data_dir, settings +class HookCommand(BaseModel): + """A single hook command to execute.""" + + type: str = "command" + command: str + + +class HookConfig(BaseModel): + """Configuration for a hook event handler.""" + + matcher: str | None = None # Only for PreToolUse/PostToolUse + hooks: list[HookCommand] + + +class ClaudeSettingsHooks(BaseModel, extra="allow"): + """Hooks section of Claude Code settings.json. + + Uses extra="allow" to tolerate unknown hook types from future Claude versions. + """ + + PreToolUse: list[HookConfig] = Field(default_factory=list) + PostToolUse: list[HookConfig] = Field(default_factory=list) + Notification: list[HookConfig] = Field(default_factory=list) + Stop: list[HookConfig] = Field(default_factory=list) + SubagentStop: list[HookConfig] = Field(default_factory=list) + + class HookService: """Handles Claude Code hook installation and management.""" - # Commands to whitelist so agents can investigate session details WHITELISTED_COMMANDS = [ "Bash(slopometry solo:*)", "Bash(slopometry solo show:*)", ] + def _update_gitignore(self, working_dir: Path) -> tuple[bool, str | None]: + """Add .slopometry/ to .gitignore if in a git repository. + + Args: + working_dir: The working directory where .gitignore should be created/updated + + Returns: + Tuple of (was_updated, message). message is None if no update was made, + otherwise contains a description of what was done. + """ + git_dir = working_dir / ".git" + if not git_dir.exists(): + return False, None + + gitignore_path = working_dir / ".gitignore" + entry = settings.gitignore_entry + + if gitignore_path.exists(): + try: + content = gitignore_path.read_text() + for line in content.splitlines(): + if line.strip().rstrip("/") == entry.rstrip("/"): + return False, None + except OSError: + return False, None + + try: + if gitignore_path.exists(): + content = gitignore_path.read_text() + if content and not content.endswith("\n"): + content += "\n" + content += f"\n{settings.gitignore_comment}\n{entry}\n" + gitignore_path.write_text(content) + else: + gitignore_path.write_text(f"{settings.gitignore_comment}\n{entry}\n") + return True, f"Added {entry} to .gitignore" + except OSError as e: + return False, f"Warning: Could not update .gitignore: {e}" + def create_hook_configuration(self) -> dict: """Create slopometry hook configuration for Claude Code.""" base_command = settings.hook_command.replace("hook-handler", "hook-{}") @@ -32,6 +99,22 @@ def create_hook_configuration(self) -> dict: "SubagentStop": [{"hooks": [{"type": "command", "command": base_command.format("subagent-stop")}]}], } + @staticmethod + def _is_slopometry_hook_config(config: dict) -> bool: + """Check if a hook config dict contains slopometry hooks. + + Args: + config: Raw hook config dict from settings.json + + Returns: + True if this config contains slopometry hook commands + """ + try: + parsed = HookConfig.model_validate(config) + return any("slopometry hook-" in hook.command for hook in parsed.hooks) + except Exception: + return False + def _ensure_global_directories(self) -> None: """Create global config and data directories if they don't exist.""" config_dir = get_default_config_dir() @@ -78,17 +161,11 @@ def install_hooks(self, global_: bool = False) -> tuple[bool, str]: existing_settings["hooks"][hook_type] = [] existing_settings["hooks"][hook_type] = [ - h - for h in existing_settings["hooks"][hook_type] - if not ( - isinstance(h.get("hooks"), list) - and any("slopometry hook-" in hook.get("command", "") for hook in h.get("hooks", [])) - ) + h for h in existing_settings["hooks"][hook_type] if not self._is_slopometry_hook_config(h) ] existing_settings["hooks"][hook_type].extend(hook_configs) - # Add slopometry commands to permissions.allow so agents can investigate if "permissions" not in existing_settings: existing_settings["permissions"] = {} if "allow" not in existing_settings["permissions"]: @@ -105,8 +182,16 @@ def install_hooks(self, global_: bool = False) -> tuple[bool, str]: except Exception as e: return False, f"Failed to write settings: {e}" + gitignore_message = None + if not global_: + _, gitignore_message = self._update_gitignore(Path.cwd()) + scope = "globally" if global_ else "locally" - return True, f"Slopometry hooks installed {scope} in {settings_file}" + message = f"Slopometry hooks installed {scope} in {settings_file}" + if gitignore_message: + message += f"\n{gitignore_message}" + + return True, message def uninstall_hooks(self, global_: bool = False) -> tuple[bool, str]: """Remove slopometry hooks from Claude Code settings. @@ -134,12 +219,7 @@ def uninstall_hooks(self, global_: bool = False) -> tuple[bool, str]: for hook_type in settings_data["hooks"]: original_length = len(settings_data["hooks"][hook_type]) settings_data["hooks"][hook_type] = [ - h - for h in settings_data["hooks"][hook_type] - if not ( - isinstance(h.get("hooks"), list) - and any("slopometry hook-" in hook.get("command", "") for hook in h.get("hooks", [])) - ) + h for h in settings_data["hooks"][hook_type] if not self._is_slopometry_hook_config(h) ] if len(settings_data["hooks"][hook_type]) < original_length: removed_any = True @@ -149,7 +229,6 @@ def uninstall_hooks(self, global_: bool = False) -> tuple[bool, str]: if not settings_data["hooks"]: del settings_data["hooks"] - # Remove slopometry commands from permissions.allow if "permissions" in settings_data and "allow" in settings_data["permissions"]: settings_data["permissions"]["allow"] = [ cmd for cmd in settings_data["permissions"]["allow"] if cmd not in self.WHITELISTED_COMMANDS @@ -181,13 +260,10 @@ def check_hooks_installed(self, settings_file: Path) -> bool: settings_data = json.load(f) hooks = settings_data.get("hooks", {}) - for hook_type in hooks: - for hook_config in hooks[hook_type]: - if isinstance(hook_config.get("hooks"), list): - for hook in hook_config["hooks"]: - command = hook.get("command", "") - if "slopometry hook-" in command: - return True + for hook_configs in hooks.values(): + for hook_config in hook_configs: + if self._is_slopometry_hook_config(hook_config): + return True return False except (json.JSONDecodeError, KeyError): return False diff --git a/tests/test_baseline_service.py b/tests/test_baseline_service.py new file mode 100644 index 0000000..2cb3278 --- /dev/null +++ b/tests/test_baseline_service.py @@ -0,0 +1,376 @@ +"""Tests for baseline_service.py.""" + +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +from conftest import make_test_metrics + +from slopometry.core.models import ExtendedComplexityMetrics, HistoricalMetricStats, RepoBaseline +from slopometry.summoner.services.baseline_service import ( + BaselineService, + CommitInfo, + _compute_single_delta_task, +) + + +class TestComputeStats: + """Tests for BaselineService._compute_stats.""" + + def test_compute_stats__handles_empty_values(self): + """Test that empty values return zeroed stats.""" + service = BaselineService(db=MagicMock()) + stats = service._compute_stats("test_metric", []) + + assert stats.metric_name == "test_metric" + assert stats.mean == 0.0 + assert stats.std_dev == 0.0 + assert stats.median == 0.0 + assert stats.min_value == 0.0 + assert stats.max_value == 0.0 + assert stats.sample_count == 0 + assert stats.trend_coefficient == 0.0 + + def test_compute_stats__single_value_stdev_zero(self): + """Test that single value has stdev of zero.""" + service = BaselineService(db=MagicMock()) + stats = service._compute_stats("cc_delta", [5.0]) + + assert stats.mean == 5.0 + assert stats.std_dev == 0.0 + assert stats.median == 5.0 + assert stats.min_value == 5.0 + assert stats.max_value == 5.0 + assert stats.sample_count == 1 + + def test_compute_stats__calculates_mean_median_stdev_correctly(self): + """Test that statistics are calculated correctly.""" + service = BaselineService(db=MagicMock()) + # Values: 1, 2, 3, 4, 5 => mean=3, median=3, stdev~1.58 + stats = service._compute_stats("effort_delta", [1.0, 2.0, 3.0, 4.0, 5.0]) + + assert stats.mean == 3.0 + assert stats.median == 3.0 + assert stats.min_value == 1.0 + assert stats.max_value == 5.0 + assert stats.sample_count == 5 + # stdev of [1,2,3,4,5] is sqrt(2) ≈ 1.5811 + assert abs(stats.std_dev - 1.5811) < 0.001 + + def test_compute_stats__metric_name_preserved_in_result(self): + """Test that metric name is preserved in result.""" + service = BaselineService(db=MagicMock()) + stats = service._compute_stats("mi_delta", [10.0, 20.0]) + + assert stats.metric_name == "mi_delta" + + +class TestComputeTrend: + """Tests for BaselineService._compute_trend.""" + + def test_compute_trend__handles_single_value(self): + """Test that single value returns zero trend.""" + service = BaselineService(db=MagicMock()) + trend = service._compute_trend([5.0]) + + assert trend == 0.0 + + def test_compute_trend__handles_empty_list(self): + """Test that empty list returns zero trend.""" + service = BaselineService(db=MagicMock()) + trend = service._compute_trend([]) + + assert trend == 0.0 + + def test_compute_trend__positive_when_increasing(self): + """Test positive trend when values increase over time. + + Note: Values arrive in reverse chronological order (newest first). + So [5, 4, 3, 2, 1] means oldest=1, newest=5, which is increasing. + """ + service = BaselineService(db=MagicMock()) + # Reverse chronological: newest=5, ..., oldest=1 => increasing over time + trend = service._compute_trend([5.0, 4.0, 3.0, 2.0, 1.0]) + + assert trend > 0, f"Expected positive trend, got {trend}" + + def test_compute_trend__negative_when_decreasing(self): + """Test negative trend when values decrease over time. + + Note: Values arrive in reverse chronological order (newest first). + So [1, 2, 3, 4, 5] means oldest=5, newest=1, which is decreasing. + """ + service = BaselineService(db=MagicMock()) + # Reverse chronological: newest=1, ..., oldest=5 => decreasing over time + trend = service._compute_trend([1.0, 2.0, 3.0, 4.0, 5.0]) + + assert trend < 0, f"Expected negative trend, got {trend}" + + def test_compute_trend__zero_when_flat(self): + """Test zero trend when values are constant.""" + service = BaselineService(db=MagicMock()) + trend = service._compute_trend([3.0, 3.0, 3.0, 3.0, 3.0]) + + assert trend == 0.0 + + def test_compute_trend__two_values_computes_slope(self): + """Test that two values correctly compute slope.""" + service = BaselineService(db=MagicMock()) + # [10, 5] in reverse chronological => oldest=5, newest=10 + # chronological: [5, 10] => slope = (10-5)/(1-0) = 5 + trend = service._compute_trend([10.0, 5.0]) + + assert trend == 5.0 + + +class TestGetOrComputeBaseline: + """Tests for BaselineService.get_or_compute_baseline.""" + + def test_get_or_compute_baseline__returns_cached_when_head_unchanged(self, tmp_path: Path): + """Test that cached baseline is returned when HEAD hasn't changed.""" + mock_db = MagicMock() + cached_baseline = RepoBaseline( + repository_path=str(tmp_path), + computed_at=datetime.now(), + head_commit_sha="abc123", + total_commits_analyzed=10, + cc_delta_stats=HistoricalMetricStats( + metric_name="cc_delta", + mean=1.0, + std_dev=0.5, + median=1.0, + min_value=0.0, + max_value=2.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)), + oldest_commit_date=datetime.now(), + newest_commit_date=datetime.now(), + ) + mock_db.get_cached_baseline.return_value = cached_baseline + + service = BaselineService(db=mock_db) + + with patch("slopometry.summoner.services.baseline_service.GitTracker") as MockGitTracker: + mock_git = MockGitTracker.return_value + mock_git._get_current_commit_sha.return_value = "abc123" + + result = service.get_or_compute_baseline(tmp_path) + + 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_flag_set(self, tmp_path: Path): + """Test that baseline is recomputed when recompute=True.""" + mock_db = MagicMock() + mock_db.get_cached_baseline.return_value = None # Shouldn't be called anyway + + 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, recompute=True) + + # get_cached_baseline should NOT be called when recompute=True + mock_db.get_cached_baseline.assert_not_called() + mock_compute.assert_called_once() + + def test_get_or_compute_baseline__saves_to_database(self, tmp_path: Path): + """Test that computed baseline is saved to database.""" + mock_db = MagicMock() + mock_db.get_cached_baseline.return_value = None + + new_baseline = RepoBaseline( + repository_path=str(tmp_path), + computed_at=datetime.now(), + head_commit_sha="abc123", + total_commits_analyzed=5, + cc_delta_stats=HistoricalMetricStats( + metric_name="cc_delta", + mean=0.0, + std_dev=0.0, + median=0.0, + min_value=0.0, + max_value=0.0, + sample_count=0, + trend_coefficient=0.0, + ), + effort_delta_stats=HistoricalMetricStats( + metric_name="effort_delta", + mean=0.0, + std_dev=0.0, + median=0.0, + min_value=0.0, + max_value=0.0, + sample_count=0, + trend_coefficient=0.0, + ), + mi_delta_stats=HistoricalMetricStats( + metric_name="mi_delta", + mean=0.0, + std_dev=0.0, + median=0.0, + min_value=0.0, + max_value=0.0, + sample_count=0, + trend_coefficient=0.0, + ), + current_metrics=ExtendedComplexityMetrics(**make_test_metrics()), + oldest_commit_date=datetime.now(), + newest_commit_date=datetime.now(), + ) + + 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 = new_baseline + + result = service.get_or_compute_baseline(tmp_path) + + assert result == new_baseline + mock_db.save_baseline.assert_called_once_with(new_baseline) + + def test_get_or_compute_baseline__returns_none_if_no_head_commit(self, tmp_path: Path): + """Test that None is returned when no HEAD commit exists.""" + mock_db = MagicMock() + service = BaselineService(db=mock_db) + + with patch("slopometry.summoner.services.baseline_service.GitTracker") as MockGitTracker: + mock_git = MockGitTracker.return_value + mock_git._get_current_commit_sha.return_value = None + + result = service.get_or_compute_baseline(tmp_path) + + assert result is None + + +class TestComputeFullBaseline: + """Tests for BaselineService.compute_full_baseline.""" + + def test_compute_full_baseline__returns_none_with_less_than_2_commits(self, tmp_path: Path): + """Test that None is returned when fewer than 2 commits exist.""" + mock_db = MagicMock() + service = BaselineService(db=mock_db) + + with patch.object(service, "_get_all_commits") as mock_get_commits: + mock_get_commits.return_value = [CommitInfo(sha="abc123", timestamp=datetime.now())] + + result = service.compute_full_baseline(tmp_path) + + assert result is None + + def test_compute_full_baseline__returns_none_when_no_deltas(self, tmp_path: Path): + """Test that None is returned when no deltas could be computed.""" + mock_db = MagicMock() + service = BaselineService(db=mock_db) + + commits = [ + CommitInfo(sha="newest", timestamp=datetime.now()), + CommitInfo(sha="oldest", timestamp=datetime.now()), + ] + + with ( + patch.object(service, "_get_all_commits") as mock_get_commits, + patch.object(service, "_compute_deltas_parallel") as mock_deltas, + patch("slopometry.summoner.services.baseline_service.ComplexityAnalyzer") as MockAnalyzer, + ): + mock_get_commits.return_value = commits + mock_deltas.return_value = [] # No deltas computed + MockAnalyzer.return_value.analyze_extended_complexity.return_value = ExtendedComplexityMetrics( + **make_test_metrics() + ) + + result = service.compute_full_baseline(tmp_path) + + assert result is None + + +class TestComputeSingleDeltaTask: + """Tests for module-level _compute_single_delta_task function.""" + + def test_compute_single_delta_task__returns_zero_delta_when_no_changed_files(self, tmp_path: Path): + """Test that zero delta is returned when no Python files changed.""" + with patch("slopometry.summoner.services.baseline_service.GitTracker") as MockGitTracker: + mock_git = MockGitTracker.return_value + mock_git.get_changed_python_files.return_value = [] + + result = _compute_single_delta_task(tmp_path, "parent_sha", "child_sha") + + assert result is not None + assert result.cc_delta == 0.0 + assert result.effort_delta == 0.0 + assert result.mi_delta == 0.0 + + def test_compute_single_delta_task__returns_none_when_both_dirs_missing(self, tmp_path: Path): + """Test that None is returned when neither parent nor child can be extracted.""" + with patch("slopometry.summoner.services.baseline_service.GitTracker") as MockGitTracker: + mock_git = MockGitTracker.return_value + mock_git.get_changed_python_files.return_value = ["file.py"] + mock_git.extract_specific_files_from_commit.return_value = None + + result = _compute_single_delta_task(tmp_path, "parent_sha", "child_sha") + + assert result is None + + def test_compute_single_delta_task__computes_delta_correctly(self, tmp_path: Path): + """Test that delta is correctly computed as child - parent.""" + parent_metrics = ExtendedComplexityMetrics( + **make_test_metrics(total_complexity=10, total_effort=100.0, total_mi=80.0) + ) + child_metrics = ExtendedComplexityMetrics( + **make_test_metrics(total_complexity=15, total_effort=150.0, total_mi=75.0) + ) + + with ( + patch("slopometry.summoner.services.baseline_service.GitTracker") as MockGitTracker, + patch("slopometry.summoner.services.baseline_service.ComplexityAnalyzer") as MockAnalyzer, + patch("slopometry.summoner.services.baseline_service.shutil.rmtree"), + ): + mock_git = MockGitTracker.return_value + mock_git.get_changed_python_files.return_value = ["file.py"] + mock_git.extract_specific_files_from_commit.side_effect = [ + tmp_path / "parent", + tmp_path / "child", + ] + + mock_analyzer = MockAnalyzer.return_value + mock_analyzer.analyze_extended_complexity.side_effect = [parent_metrics, child_metrics] + + result = _compute_single_delta_task(tmp_path, "parent_sha", "child_sha") + + assert result is not None + assert result.cc_delta == 5 # 15 - 10 + assert result.effort_delta == 50.0 # 150 - 100 + assert result.mi_delta == -5.0 # 75 - 80 diff --git a/tests/test_experiment_orchestrator.py b/tests/test_experiment_orchestrator.py new file mode 100644 index 0000000..1966d41 --- /dev/null +++ b/tests/test_experiment_orchestrator.py @@ -0,0 +1,387 @@ +"""Tests for experiment_orchestrator.py.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from conftest import make_test_metrics + +from slopometry.core.models import ExperimentStatus, ExtendedComplexityMetrics +from slopometry.summoner.services.experiment_orchestrator import ExperimentOrchestrator + + +class TestExperimentOrchestratorInit: + """Tests for ExperimentOrchestrator initialization.""" + + def test_init__creates_dependencies(self, tmp_path: Path): + """Test that orchestrator creates required dependencies.""" + with ( + patch("slopometry.summoner.services.experiment_orchestrator.EventDatabase") as MockDB, + patch("slopometry.summoner.services.experiment_orchestrator.WorktreeManager") as MockWorktree, + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker") as MockGit, + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator") as MockCLI, + ): + orchestrator = ExperimentOrchestrator(tmp_path) + + assert orchestrator.repo_path == tmp_path.resolve() + MockDB.assert_called_once() + MockWorktree.assert_called_once_with(tmp_path.resolve()) + MockGit.assert_called_once_with(tmp_path.resolve()) + MockCLI.assert_called_once() + + +class TestRunSingleExperiment: + """Tests for ExperimentOrchestrator._run_single_experiment.""" + + def test_run_single_experiment__cleans_up_worktree_on_exception(self, tmp_path: Path): + """Test that worktree is cleaned up even when experiment fails.""" + mock_db = MagicMock() + mock_worktree = MagicMock() + mock_worktree.create_experiment_worktree.return_value = tmp_path / "worktree" + + with ( + patch( + "slopometry.summoner.services.experiment_orchestrator.EventDatabase", + return_value=mock_db, + ), + patch( + "slopometry.summoner.services.experiment_orchestrator.WorktreeManager", + return_value=mock_worktree, + ), + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker"), + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator"), + patch("slopometry.summoner.services.experiment_orchestrator.ComplexityAnalyzer") as MockAnalyzer, + ): + # Make analyzer.analyze_extended_complexity raise an exception + MockAnalyzer.return_value.analyze_extended_complexity.side_effect = Exception("Analysis failed") + + orchestrator = ExperimentOrchestrator(tmp_path) + + with pytest.raises(Exception, match="Analysis failed"): + orchestrator._run_single_experiment("exp-123", "start-sha", "target-sha") + + # Verify cleanup was called despite exception + mock_worktree.cleanup_worktree.assert_called_once_with(tmp_path / "worktree") + + def test_run_single_experiment__creates_worktree_and_saves_progress(self, tmp_path: Path): + """Test that experiment creates worktree and saves initial progress.""" + mock_db = MagicMock() + mock_worktree = MagicMock() + worktree_path = tmp_path / "worktree" + mock_worktree.create_experiment_worktree.return_value = worktree_path + + test_metrics = ExtendedComplexityMetrics(**make_test_metrics(total_complexity=50)) + + with ( + patch( + "slopometry.summoner.services.experiment_orchestrator.EventDatabase", + return_value=mock_db, + ), + patch( + "slopometry.summoner.services.experiment_orchestrator.WorktreeManager", + return_value=mock_worktree, + ), + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker"), + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator"), + patch("slopometry.summoner.services.experiment_orchestrator.ComplexityAnalyzer") as MockAnalyzer, + ): + MockAnalyzer.return_value.analyze_extended_complexity.return_value = test_metrics + + orchestrator = ExperimentOrchestrator(tmp_path) + # Mock _simulate_agent_progress to avoid actual simulation + orchestrator._simulate_agent_progress = MagicMock() + + orchestrator._run_single_experiment("exp-123", "start-sha", "target-sha") + + # Verify worktree was created + mock_worktree.create_experiment_worktree.assert_called_once_with("exp-123", "start-sha") + + # Verify worktree path was saved to DB + mock_db.update_experiment_worktree.assert_called_once_with("exp-123", worktree_path) + + # Verify initial progress was saved + mock_db.save_experiment_progress.assert_called() + + # Verify cleanup + mock_worktree.cleanup_worktree.assert_called_once_with(worktree_path) + + +class TestDisplayAggregateProgress: + """Tests for ExperimentOrchestrator.display_aggregate_progress.""" + + def test_display_aggregate_progress__returns_early_when_no_running_experiments(self, tmp_path: Path, capsys): + """Test that display returns early when no experiments are running.""" + mock_db = MagicMock() + mock_db.get_running_experiments.return_value = [] + + with ( + patch( + "slopometry.summoner.services.experiment_orchestrator.EventDatabase", + return_value=mock_db, + ), + patch("slopometry.summoner.services.experiment_orchestrator.WorktreeManager"), + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker"), + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator"), + ): + orchestrator = ExperimentOrchestrator(tmp_path) + orchestrator.display_aggregate_progress() + + captured = capsys.readouterr() + assert "EXPERIMENT PROGRESS" not in captured.out + + +class TestAnalyzeCommitChain: + """Tests for ExperimentOrchestrator.analyze_commit_chain.""" + + def test_analyze_commit_chain__skips_merge_commits(self, tmp_path: Path): + """Test that merge commits are skipped during analysis.""" + mock_db = MagicMock() + mock_db.create_commit_chain.return_value = "chain-123" + + with ( + patch( + "slopometry.summoner.services.experiment_orchestrator.EventDatabase", + return_value=mock_db, + ), + patch("slopometry.summoner.services.experiment_orchestrator.WorktreeManager"), + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker") as MockGit, + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator"), + patch("slopometry.summoner.services.experiment_orchestrator.subprocess.run") as mock_subprocess, + patch("slopometry.summoner.services.experiment_orchestrator.Console") as MockConsole, + ): + mock_console = MockConsole.return_value + + # First call: git rev-list returns commits + # Second+ calls: git rev-list --parents returns parent info + def subprocess_side_effect(*args, **kwargs): + result = MagicMock() + result.returncode = 0 + + cmd = args[0] + if "rev-list" in cmd and "--parents" not in cmd: + # Initial rev-list call + result.stdout = "commit1\ncommit2\nmerge_commit\n" + elif "--parents" in cmd: + # Parent check calls + commit_sha = cmd[-1] + if commit_sha == "merge_commit": + # Merge commit has 2 parents + result.stdout = "merge_commit parent1 parent2" + else: + # Regular commit has 1 parent + result.stdout = f"{commit_sha} single_parent" + return result + + mock_subprocess.side_effect = subprocess_side_effect + + # Mock git_tracker context manager for extract_files_from_commit_ctx + mock_git_tracker = MockGit.return_value + mock_context = MagicMock() + mock_context.__enter__ = MagicMock(return_value=None) # Return None to skip analysis + mock_context.__exit__ = MagicMock(return_value=False) + mock_git_tracker.extract_files_from_commit_ctx.return_value = mock_context + + orchestrator = ExperimentOrchestrator(tmp_path) + orchestrator.analyze_commit_chain("base", "head") + + # Check that console.print was called with merge commit skip message + skip_calls = [c for c in mock_console.print.call_args_list if "Skipping merge commit" in str(c)] + assert len(skip_calls) == 1, "Expected one merge commit to be skipped" + + def test_analyze_commit_chain__handles_empty_commit_range(self, tmp_path: Path): + """Test that empty commit range is handled. + + Note: Due to Python's split behavior, "".split("\\n") returns [""], + not [], so the code will try to process one empty commit. This test + verifies the code doesn't crash in this edge case. + """ + mock_db = MagicMock() + mock_db.create_commit_chain.return_value = "chain-123" + + with ( + patch( + "slopometry.summoner.services.experiment_orchestrator.EventDatabase", + return_value=mock_db, + ), + patch("slopometry.summoner.services.experiment_orchestrator.WorktreeManager"), + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker") as MockGit, + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator"), + patch("slopometry.summoner.services.experiment_orchestrator.subprocess.run") as mock_subprocess, + patch("slopometry.summoner.services.experiment_orchestrator.Console"), + patch("slopometry.summoner.services.experiment_orchestrator.ComplexityAnalyzer"), + ): + + def subprocess_side_effect(*args, **kwargs): + result = MagicMock() + result.returncode = 0 + cmd = args[0] + if "rev-list" in cmd and "--parents" not in cmd: + result.stdout = "" # Empty result leads to [""] after split + elif "--parents" in cmd: + # Empty commit SHA would return just itself + result.stdout = "" + return result + + mock_subprocess.side_effect = subprocess_side_effect + + # Mock git_tracker to return None (skip analysis) + mock_git_tracker = MockGit.return_value + mock_context = MagicMock() + mock_context.__enter__ = MagicMock(return_value=None) + mock_context.__exit__ = MagicMock(return_value=False) + mock_git_tracker.extract_files_from_commit_ctx.return_value = mock_context + + orchestrator = ExperimentOrchestrator(tmp_path) + # Should not crash + orchestrator.analyze_commit_chain("base", "head") + + # Verify chain was created (even with empty commit list) + mock_db.create_commit_chain.assert_called_once() + + def test_analyze_commit_chain__handles_git_operation_error(self, tmp_path: Path): + """Test that git operation errors are handled gracefully.""" + from slopometry.core.git_tracker import GitOperationError + + mock_db = MagicMock() + mock_db.create_commit_chain.return_value = "chain-123" + + with ( + patch( + "slopometry.summoner.services.experiment_orchestrator.EventDatabase", + return_value=mock_db, + ), + patch("slopometry.summoner.services.experiment_orchestrator.WorktreeManager"), + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker") as MockGit, + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator"), + patch("slopometry.summoner.services.experiment_orchestrator.subprocess.run") as mock_subprocess, + patch("slopometry.summoner.services.experiment_orchestrator.Console") as MockConsole, + ): + mock_console = MockConsole.return_value + + # Rev-list returns one commit + def subprocess_side_effect(*args, **kwargs): + result = MagicMock() + result.returncode = 0 + cmd = args[0] + if "rev-list" in cmd and "--parents" not in cmd: + result.stdout = "commit1\n" + elif "--parents" in cmd: + result.stdout = "commit1 parent1" + return result + + mock_subprocess.side_effect = subprocess_side_effect + + # Make extract_files_from_commit_ctx raise GitOperationError + mock_git_tracker = MockGit.return_value + mock_context = MagicMock() + mock_context.__enter__ = MagicMock(side_effect=GitOperationError("Git failed")) + mock_context.__exit__ = MagicMock(return_value=False) + mock_git_tracker.extract_files_from_commit_ctx.return_value = mock_context + + orchestrator = ExperimentOrchestrator(tmp_path) + # Should not raise, should handle gracefully + orchestrator.analyze_commit_chain("base", "head") + + # Check that skipping message was printed + skip_calls = [c for c in mock_console.print.call_args_list if "Skipping commit" in str(c)] + assert len(skip_calls) == 1 + + +class TestRunParallelExperiments: + """Tests for ExperimentOrchestrator.run_parallel_experiments.""" + + def test_run_parallel_experiments__returns_empty_dict_for_empty_input(self, tmp_path: Path): + """Test that empty commit pairs returns empty dict.""" + mock_db = MagicMock() + + with ( + patch( + "slopometry.summoner.services.experiment_orchestrator.EventDatabase", + return_value=mock_db, + ), + patch("slopometry.summoner.services.experiment_orchestrator.WorktreeManager"), + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker"), + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator"), + ): + orchestrator = ExperimentOrchestrator(tmp_path) + result = orchestrator.run_parallel_experiments([]) + + assert result == {} + mock_db.save_experiment_run.assert_not_called() + + def test_run_parallel_experiments__creates_experiments_for_each_pair(self, tmp_path: Path): + """Test that an experiment is created for each commit pair.""" + mock_db = MagicMock() + mock_db.get_running_experiments.return_value = [] + + with ( + patch( + "slopometry.summoner.services.experiment_orchestrator.EventDatabase", + return_value=mock_db, + ), + patch("slopometry.summoner.services.experiment_orchestrator.WorktreeManager"), + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker"), + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator"), + patch("slopometry.summoner.services.experiment_orchestrator.ProcessPoolExecutor") as MockExecutor, + ): + # Mock the executor to immediately complete futures + mock_executor_instance = MagicMock() + mock_future = MagicMock() + mock_future.done.return_value = True + mock_future.result.return_value = None + mock_executor_instance.submit.return_value = mock_future + mock_executor_instance.__enter__ = MagicMock(return_value=mock_executor_instance) + mock_executor_instance.__exit__ = MagicMock(return_value=False) + MockExecutor.return_value = mock_executor_instance + + orchestrator = ExperimentOrchestrator(tmp_path) + commit_pairs = [("start1", "target1"), ("start2", "target2")] + + result = orchestrator.run_parallel_experiments(commit_pairs, max_workers=2) + + # Should have 2 experiments + assert len(result) == 2 + # Should have called save_experiment_run twice + assert mock_db.save_experiment_run.call_count == 2 + # Should have submitted 2 tasks + assert mock_executor_instance.submit.call_count == 2 + + def test_run_parallel_experiments__handles_failed_experiment(self, tmp_path: Path, capsys): + """Test that failed experiments are marked as FAILED.""" + mock_db = MagicMock() + mock_db.get_running_experiments.return_value = [] + + with ( + patch( + "slopometry.summoner.services.experiment_orchestrator.EventDatabase", + return_value=mock_db, + ), + patch("slopometry.summoner.services.experiment_orchestrator.WorktreeManager"), + patch("slopometry.summoner.services.experiment_orchestrator.GitTracker"), + patch("slopometry.summoner.services.experiment_orchestrator.CLICalculator"), + patch("slopometry.summoner.services.experiment_orchestrator.ProcessPoolExecutor") as MockExecutor, + ): + # Mock the executor with a failing future + mock_executor_instance = MagicMock() + mock_future = MagicMock() + mock_future.done.return_value = True + mock_future.result.side_effect = Exception("Experiment failed!") + mock_executor_instance.submit.return_value = mock_future + mock_executor_instance.__enter__ = MagicMock(return_value=mock_executor_instance) + mock_executor_instance.__exit__ = MagicMock(return_value=False) + MockExecutor.return_value = mock_executor_instance + + orchestrator = ExperimentOrchestrator(tmp_path) + result = orchestrator.run_parallel_experiments([("start", "target")]) + + # Experiment should be marked as FAILED + assert len(result) == 1 + experiment = list(result.values())[0] + assert experiment.status == ExperimentStatus.FAILED + + # Should have called update_experiment_run with failed status + mock_db.update_experiment_run.assert_called() + + # Should print error message + captured = capsys.readouterr() + assert "failed" in captured.out.lower() diff --git a/tests/test_feedback_cache.py b/tests/test_feedback_cache.py new file mode 100644 index 0000000..40d6908 --- /dev/null +++ b/tests/test_feedback_cache.py @@ -0,0 +1,479 @@ +"""Tests for feedback cache functionality to prevent repeated feedback display.""" + +import hashlib +import json +import subprocess +import tempfile +import time +from pathlib import Path + +import pytest + +from slopometry.core.hook_handler import ( + _compute_feedback_cache_key, + _get_feedback_cache_path, + _is_feedback_cached, + _save_feedback_cache, +) +from slopometry.core.working_tree_state import WorkingTreeStateCalculator + + +def _init_git_repo(path: Path) -> None: + """Initialize a git repo with initial commit. + + All git config is scoped to the repo (--local) to avoid mutating user environment. + """ + subprocess.run(["git", "init"], cwd=path, capture_output=True, check=True) + # Use --local to ensure config is scoped to this repo only + # Use example.com (RFC 2606 reserved domain for testing) + subprocess.run( + ["git", "config", "--local", "user.email", "test@example.com"], cwd=path, capture_output=True, check=True + ) + subprocess.run(["git", "config", "--local", "user.name", "Test User"], cwd=path, capture_output=True, check=True) + # Disable GPG signing for tests (local scope) + subprocess.run(["git", "config", "--local", "commit.gpgsign", "false"], cwd=path, capture_output=True, check=True) + + +def _commit_all(path: Path, message: str = "commit") -> None: + """Add all files and commit.""" + subprocess.run(["git", "add", "."], cwd=path, capture_output=True) + subprocess.run(["git", "commit", "-m", message], cwd=path, capture_output=True) + + +class TestFeedbackCacheKeyComputation: + """Tests for _compute_feedback_cache_key function.""" + + def test_compute_feedback_cache_key__same_feedback_different_sessions_same_key(self): + """Verify that identical feedback with different session_ids produces same cache key. + + This is the primary bug fix - session_id should not affect the cache key. + """ + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + _commit_all(tmppath) + + # Same feedback content + feedback_content = "Code smells detected: orphan comments" + feedback_hash = hashlib.blake2b(feedback_content.encode(), digest_size=8).hexdigest() + + # Compute cache key + key1 = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + key2 = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key1 == key2, "Same feedback should produce same cache key" + + def test_compute_feedback_cache_key__uv_lock_changes_dont_invalidate(self): + """Verify non-Python file changes (uv.lock) don't cause cache key changes.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Modify uv.lock (non-Python file) + (tmppath / "uv.lock").write_text("some lock content") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key_before == key_after, "uv.lock changes should not invalidate cache" + + def test_compute_feedback_cache_key__pycache_changes_dont_invalidate(self): + """Verify __pycache__/*.pyc files don't affect the cache key.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Create __pycache__ with .pyc file + pycache = tmppath / "__pycache__" + pycache.mkdir() + (pycache / "test.cpython-312.pyc").write_bytes(b"\x00\x00\x00\x00") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key_before == key_after, "__pycache__ should not invalidate cache" + + def test_compute_feedback_cache_key__compiled_extensions_dont_invalidate(self): + """Verify compiled extensions (.so, .pyd) don't affect the cache key.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Create compiled extension files + (tmppath / "module.so").write_bytes(b"\x7fELF") + (tmppath / "module.pyd").write_bytes(b"MZ") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key_before == key_after, "Compiled extensions should not invalidate cache" + + def test_compute_feedback_cache_key__python_content_changes_invalidate(self): + """Verify actual Python code changes invalidate the cache.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Modify Python file content + (tmppath / "test.py").write_text("def foo(): return 42") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key_before != key_after, "Python content changes should invalidate cache" + + def test_compute_feedback_cache_key__empty_edited_files_stable_key(self): + """Verify cache key is stable when no Python files are modified.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + + # Multiple calls with empty edited_files + key1 = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + key2 = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + key3 = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key1 == key2 == key3, "Cache key should be stable" + + +class TestWorkingTreeHashContentBased: + """Tests for content-based working tree hash (not mtime-based).""" + + def test_working_tree_hash__mtime_change_without_content_does_not_invalidate(self): + """Verify that touching a Python file (mtime change only) does NOT invalidate cache.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + py_file = tmppath / "test.py" + py_file.write_text("def foo(): pass") + _commit_all(tmppath) + + # Modify the file to get it tracked as changed by git + py_file.write_text("def foo(): pass\n") + _commit_all(tmppath, "second commit") + + # Now change back to original content + py_file.write_text("def foo(): pass") + + calculator = WorkingTreeStateCalculator(str(tmppath)) + hash1 = calculator.calculate_working_tree_hash("commit1") + + # Touch the file (changes mtime but not content) + time.sleep(0.01) + original_content = py_file.read_text() + py_file.write_text(original_content) # Same content + + hash2 = calculator.calculate_working_tree_hash("commit1") + + # With content-based hashing, same content = same hash + assert hash1 == hash2, "Mtime-only change should NOT invalidate (content-based hash)" + + def test_working_tree_hash__actual_content_change_invalidates(self): + """Verify that actual content changes DO invalidate the hash.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + py_file = tmppath / "test.py" + py_file.write_text("def foo(): pass") + _commit_all(tmppath) + + # Make actual content change + py_file.write_text("def foo(): return 42") + + calculator = WorkingTreeStateCalculator(str(tmppath)) + hash1 = calculator.calculate_working_tree_hash("commit1") + + # Change content again + py_file.write_text("def bar(): return 99") + + hash2 = calculator.calculate_working_tree_hash("commit1") + + assert hash1 != hash2, "Different content should produce different hash" + + +class TestFeedbackCachePersistence: + """Tests for feedback cache persistence.""" + + def test_feedback_cache__persists_across_sessions(self): + """Verify cache file persists and works across multiple calls.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + _commit_all(tmppath) + + cache_key = "test_cache_key_123" + + # First call - should not be cached + assert not _is_feedback_cached(str(tmppath), cache_key) + + # Save to cache + _save_feedback_cache(str(tmppath), cache_key) + + # Second call - should be cached + assert _is_feedback_cached(str(tmppath), cache_key) + + # Verify cache file exists + cache_path = _get_feedback_cache_path(str(tmppath)) + assert cache_path.exists() + cache_data = json.loads(cache_path.read_text()) + assert cache_data["last_key"] == cache_key + + def test_feedback_cache__different_key_not_cached(self): + """Verify that a different cache key is not considered cached.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "test.py").write_text("def foo(): pass") + _commit_all(tmppath) + + # Save one key + _save_feedback_cache(str(tmppath), "key1") + + # Check different key - should not be cached + assert not _is_feedback_cached(str(tmppath), "key2") + + +class TestModifiedPythonFilesDetection: + """Tests for _get_modified_python_files_from_git helper.""" + + def test_get_modified_python_files__detects_staged_changes(self): + """Verify staged Python file changes are detected.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + py_file = tmppath / "test.py" + py_file.write_text("def foo(): pass") + _commit_all(tmppath) + + # Stage a change + py_file.write_text("def foo(): return 42") + subprocess.run(["git", "add", "test.py"], cwd=tmppath, capture_output=True) + + calculator = WorkingTreeStateCalculator(str(tmppath)) + modified = calculator._get_modified_python_files_from_git() + + assert len(modified) == 1 + assert modified[0].name == "test.py" + + def test_get_modified_python_files__detects_unstaged_changes(self): + """Verify unstaged Python file changes are detected.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + py_file = tmppath / "test.py" + py_file.write_text("def foo(): pass") + _commit_all(tmppath) + + # Make unstaged change + py_file.write_text("def foo(): return 42") + + calculator = WorkingTreeStateCalculator(str(tmppath)) + modified = calculator._get_modified_python_files_from_git() + + assert len(modified) == 1 + assert modified[0].name == "test.py" + + def test_get_modified_python_files__ignores_non_python_files(self): + """Verify non-Python file changes are not included.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + py_file = tmppath / "test.py" + py_file.write_text("def foo(): pass") + lock_file = tmppath / "uv.lock" + lock_file.write_text("old lock") + _commit_all(tmppath) + + # Modify both files + py_file.write_text("def foo(): return 42") + lock_file.write_text("new lock") + + calculator = WorkingTreeStateCalculator(str(tmppath)) + modified = calculator._get_modified_python_files_from_git() + + # Should only include Python file + assert len(modified) == 1 + assert modified[0].name == "test.py" + + def test_get_modified_python_files__empty_when_no_changes(self): + """Verify empty list when no Python files are modified.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + py_file = tmppath / "test.py" + py_file.write_text("def foo(): pass") + _commit_all(tmppath) + + # No changes + calculator = WorkingTreeStateCalculator(str(tmppath)) + modified = calculator._get_modified_python_files_from_git() + + assert modified == [] + + +class TestSubmoduleHandling: + """Tests for git submodule handling.""" + + @pytest.mark.skipif( + subprocess.run(["git", "--version"], capture_output=True).returncode != 0, + reason="Git not available", + ) + def test_feedback_cache__submodule_changes_dont_invalidate(self): + """Verify submodule changes don't cause cache misses. + + Note: This test creates a real submodule setup to verify the behavior. + """ + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + + # Create main repo + main_repo = tmppath / "main" + main_repo.mkdir() + _init_git_repo(main_repo) + (main_repo / "main.py").write_text("def main(): pass") + _commit_all(main_repo) + + # Create submodule repo + sub_repo = tmppath / "subrepo" + sub_repo.mkdir() + _init_git_repo(sub_repo) + (sub_repo / "sub.py").write_text("def sub(): pass") + _commit_all(sub_repo) + + # Add submodule to main repo + subprocess.run( + ["git", "submodule", "add", str(sub_repo), "vendor/sub"], + cwd=main_repo, + capture_output=True, + ) + _commit_all(main_repo, "add submodule") + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(main_repo), set(), feedback_hash) + + # Update submodule (creates a change in main repo's git status) + subprocess.run( + ["git", "-C", "vendor/sub", "fetch", "--all"], + cwd=main_repo, + capture_output=True, + ) + + key_after = _compute_feedback_cache_key(str(main_repo), set(), feedback_hash) + + assert key_before == key_after, "Submodule changes should not invalidate cache" + + +class TestNewUntrackedFiles: + """Tests for new untracked Python file handling.""" + + def test_feedback_cache__new_untracked_python_files_invalidate(self): + """Verify that new Python files (even untracked) invalidate cache. + + Note: New untracked files won't appear in git diff, but they will be + detected by git ls-files if not gitignored. The behavior here depends + on whether git considers them "changed" - typically they won't be. + """ + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "existing.py").write_text("def existing(): pass") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Add new untracked Python file + (tmppath / "new_file.py").write_text("def new(): pass") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # New untracked files don't show in git diff, so key should be same + # This is expected behavior - only tracked file changes matter + assert key_before == key_after, "Untracked files don't appear in git diff" + + +class TestBuildArtifactFiltering: + """Tests for build artifact and cache directory filtering.""" + + def test_feedback_cache__dist_directory_ignored(self): + """Verify Python files in dist/ directory don't affect cache.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "src").mkdir() + (tmppath / "src" / "module.py").write_text("def foo(): pass") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Create dist directory with Python file (shouldn't affect cache) + (tmppath / "dist").mkdir() + (tmppath / "dist" / "generated.py").write_text("# Generated") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key_before == key_after, "dist/ directory should be ignored" + + def test_feedback_cache__build_directory_ignored(self): + """Verify Python files in build/ directory don't affect cache.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "src").mkdir() + (tmppath / "src" / "module.py").write_text("def foo(): pass") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Create build directory with Python file (shouldn't affect cache) + (tmppath / "build").mkdir() + (tmppath / "build" / "lib").mkdir() + (tmppath / "build" / "lib" / "module.py").write_text("# Built") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key_before == key_after, "build/ directory should be ignored" + + def test_feedback_cache__egg_info_directory_ignored(self): + """Verify *.egg-info directories don't affect cache.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + _init_git_repo(tmppath) + (tmppath / "src").mkdir() + (tmppath / "src" / "module.py").write_text("def foo(): pass") + _commit_all(tmppath) + + feedback_hash = "feedbackhash1234" + key_before = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + # Create egg-info directory (shouldn't affect cache) + (tmppath / "package.egg-info").mkdir() + (tmppath / "package.egg-info" / "PKG-INFO").write_text("Name: package") + + key_after = _compute_feedback_cache_key(str(tmppath), set(), feedback_hash) + + assert key_before == key_after, "*.egg-info directory should be ignored" diff --git a/tests/test_hook_service.py b/tests/test_hook_service.py index 321a020..f5d65d7 100644 --- a/tests/test_hook_service.py +++ b/tests/test_hook_service.py @@ -21,7 +21,6 @@ def test_install_hooks__writes_local_configuration(tmp_path, monkeypatch): assert "hooks" in data assert "PreToolUse" in data["hooks"] - # Check content of a hook matching_hook = False for item in data["hooks"]["PreToolUse"]: for h in item.get("hooks", []): @@ -35,7 +34,6 @@ def test_install_hooks__is_idempotent_and_preserves_other_hooks(tmp_path, monkey monkeypatch.chdir(tmp_path) service = HookService() - # Pre-populate with another hook settings_dir = tmp_path / ".claude" settings_dir.mkdir() settings_file = settings_dir / "settings.json" @@ -44,13 +42,11 @@ def test_install_hooks__is_idempotent_and_preserves_other_hooks(tmp_path, monkey with open(settings_file, "w") as f: json.dump(initial_data, f) - # Install service.install_hooks(global_=False) with open(settings_file) as f: data = json.load(f) - # Should have both commands = [] for item in data["hooks"]["PreToolUse"]: for h in item.get("hooks", []): @@ -65,19 +61,88 @@ def test_uninstall_hooks__removes_slopometry_hooks_only(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) service = HookService() - # Install first service.install_hooks(global_=False) - - # Uninstall success, msg = service.uninstall_hooks(global_=False) assert success with open(tmp_path / ".claude" / "settings.json") as f: data = json.load(f) - # Should be empty or no slopometry hooks if "hooks" in data: for hook_type, configs in data["hooks"].items(): for config in configs: for h in config.get("hooks", []): assert "slopometry hook-" not in h.get("command", "") + + +def test_update_gitignore__creates_new_file_when_missing(tmp_path, monkeypatch): + """Creates .gitignore with slopometry entry when file doesn't exist.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + + service = HookService() + updated, message = service._update_gitignore(tmp_path) + + assert updated is True + assert ".slopometry/" in message + + gitignore = (tmp_path / ".gitignore").read_text() + assert ".slopometry/" in gitignore + assert "# slopometry" in gitignore + + +def test_update_gitignore__appends_to_existing_file(tmp_path, monkeypatch): + """Appends to existing .gitignore without duplicating.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + (tmp_path / ".gitignore").write_text("*.pyc\n__pycache__/\n") + + service = HookService() + updated, message = service._update_gitignore(tmp_path) + + assert updated is True + gitignore = (tmp_path / ".gitignore").read_text() + assert "*.pyc" in gitignore + assert ".slopometry/" in gitignore + + +def test_update_gitignore__skips_if_already_present(tmp_path, monkeypatch): + """Does not add duplicate entry if already present.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + (tmp_path / ".gitignore").write_text(".slopometry/\n") + + service = HookService() + updated, message = service._update_gitignore(tmp_path) + + assert updated is False + assert message is None + + gitignore = (tmp_path / ".gitignore").read_text() + assert gitignore.count(".slopometry/") == 1 + + +def test_update_gitignore__skips_non_git_directory(tmp_path, monkeypatch): + """Does not create .gitignore in non-git directories.""" + monkeypatch.chdir(tmp_path) + + service = HookService() + updated, message = service._update_gitignore(tmp_path) + + assert updated is False + assert message is None + assert not (tmp_path / ".gitignore").exists() + + +def test_install_hooks__updates_gitignore_for_local_install(tmp_path, monkeypatch): + """Local install should update .gitignore if in git repo.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + + service = HookService() + success, message = service.install_hooks(global_=False) + + assert success is True + assert ".slopometry/" in message + assert (tmp_path / ".gitignore").exists() + assert ".slopometry/" in (tmp_path / ".gitignore").read_text() diff --git a/tests/test_language_config.py b/tests/test_language_config.py new file mode 100644 index 0000000..d76bf24 --- /dev/null +++ b/tests/test_language_config.py @@ -0,0 +1,163 @@ +"""Tests for language configuration module.""" + +from pathlib import Path + +import pytest + +from slopometry.core.language_config import ( + LANGUAGE_CONFIGS, + PYTHON_CONFIG, + LanguageConfig, + get_all_supported_configs, + get_combined_git_patterns, + get_combined_ignore_dirs, + get_language_config, + should_ignore_path, +) +from slopometry.core.models import ProjectLanguage + + +class TestLanguageConfig: + """Tests for LanguageConfig dataclass.""" + + def test_python_config__has_correct_extensions(self): + """Verify Python config has .py extension.""" + assert PYTHON_CONFIG.extensions == (".py",) + + def test_python_config__has_git_patterns(self): + """Verify Python config has *.py git pattern.""" + assert "*.py" in PYTHON_CONFIG.git_patterns + + def test_python_config__has_ignore_dirs(self): + """Verify Python config has common ignore directories.""" + assert "__pycache__" in PYTHON_CONFIG.ignore_dirs + assert ".venv" in PYTHON_CONFIG.ignore_dirs + assert "dist" in PYTHON_CONFIG.ignore_dirs + assert "build" in PYTHON_CONFIG.ignore_dirs + + def test_python_config__has_test_patterns(self): + """Verify Python config has test file patterns.""" + assert "test_*.py" in PYTHON_CONFIG.test_patterns + + def test_matches_extension__python_file(self): + """Verify extension matching for Python files.""" + assert PYTHON_CONFIG.matches_extension("foo.py") + assert PYTHON_CONFIG.matches_extension(Path("src/bar.py")) + assert PYTHON_CONFIG.matches_extension("test.PY") # Case insensitive + + def test_matches_extension__non_python_file(self): + """Verify extension matching rejects non-Python files.""" + assert not PYTHON_CONFIG.matches_extension("foo.rs") + assert not PYTHON_CONFIG.matches_extension("foo.js") + assert not PYTHON_CONFIG.matches_extension("foo.pyc") + + +class TestLanguageConfigRegistry: + """Tests for language config registry functions.""" + + def test_get_language_config__python(self): + """Verify getting Python config from registry.""" + config = get_language_config(ProjectLanguage.PYTHON) + assert config == PYTHON_CONFIG + + def test_get_language_config__unsupported_raises(self): + """Verify getting unsupported language raises KeyError.""" + # Create a fake language enum value for testing + # This simulates what would happen if a language is added to enum but not registry + with pytest.raises(KeyError, match="No configuration found"): + # We can't easily create a fake enum, so test with a manipulated registry + original = LANGUAGE_CONFIGS.copy() + try: + LANGUAGE_CONFIGS.clear() + get_language_config(ProjectLanguage.PYTHON) + finally: + LANGUAGE_CONFIGS.update(original) + + def test_get_all_supported_configs__returns_all(self): + """Verify getting all supported configs.""" + configs = get_all_supported_configs() + assert len(configs) >= 1 + assert PYTHON_CONFIG in configs + + def test_get_combined_git_patterns__all_languages(self): + """Verify combined git patterns include all supported languages.""" + patterns = get_combined_git_patterns(None) + assert "*.py" in patterns + + def test_get_combined_git_patterns__specific_language(self): + """Verify combined git patterns for specific language.""" + patterns = get_combined_git_patterns([ProjectLanguage.PYTHON]) + assert "*.py" in patterns + + def test_get_combined_ignore_dirs__includes_python_dirs(self): + """Verify combined ignore dirs include Python-specific dirs.""" + dirs = get_combined_ignore_dirs(None) + assert "__pycache__" in dirs + assert ".venv" in dirs + assert "dist" in dirs + + +class TestShouldIgnorePath: + """Tests for should_ignore_path function.""" + + def test_should_ignore_path__pycache(self): + """Verify __pycache__ paths are ignored.""" + assert should_ignore_path("__pycache__/foo.pyc") + assert should_ignore_path("src/__pycache__/bar.pyc") + + def test_should_ignore_path__venv(self): + """Verify virtual environment paths are ignored.""" + assert should_ignore_path(".venv/lib/python3.12/site-packages/foo.py") + assert should_ignore_path("venv/bin/activate") + + def test_should_ignore_path__dist(self): + """Verify dist directory is ignored.""" + assert should_ignore_path("dist/package-1.0.0.tar.gz") + assert should_ignore_path("dist/package-1.0.0-py3-none-any.whl") + + def test_should_ignore_path__build(self): + """Verify build directory is ignored.""" + assert should_ignore_path("build/lib/package/module.py") + + def test_should_ignore_path__egg_info(self): + """Verify *.egg-info directories are ignored.""" + assert should_ignore_path("package.egg-info/PKG-INFO") + assert should_ignore_path("my_package.egg-info/SOURCES.txt") + + def test_should_ignore_path__pytest_cache(self): + """Verify pytest cache is ignored.""" + assert should_ignore_path(".pytest_cache/v/cache/lastfailed") + + def test_should_ignore_path__mypy_cache(self): + """Verify mypy cache is ignored.""" + assert should_ignore_path(".mypy_cache/3.12/module.meta.json") + + def test_should_ignore_path__source_file_not_ignored(self): + """Verify normal source files are NOT ignored.""" + assert not should_ignore_path("src/module.py") + assert not should_ignore_path("tests/test_module.py") + assert not should_ignore_path("package/__init__.py") + + def test_should_ignore_path__specific_language(self): + """Verify language-specific ignore works.""" + # Python-specific ignores should work when Python is specified + assert should_ignore_path("__pycache__/foo.py", [ProjectLanguage.PYTHON]) + + +class TestLanguageConfigFrozen: + """Tests for LanguageConfig immutability.""" + + def test_language_config__is_frozen(self): + """Verify LanguageConfig is immutable.""" + with pytest.raises(AttributeError): + PYTHON_CONFIG.language = ProjectLanguage.PYTHON # type: ignore + + def test_language_config__custom_creation(self): + """Verify custom LanguageConfig can be created.""" + custom = LanguageConfig( + language=ProjectLanguage.PYTHON, + extensions=(".custom",), + git_patterns=("*.custom",), + ) + assert custom.extensions == (".custom",) + assert custom.ignore_dirs == () # Default empty tuple diff --git a/tests/test_working_tree_state.py b/tests/test_working_tree_state.py index a1bfb35..2827db7 100644 --- a/tests/test_working_tree_state.py +++ b/tests/test_working_tree_state.py @@ -1,3 +1,4 @@ +import subprocess import tempfile import time from pathlib import Path @@ -6,11 +7,35 @@ from slopometry.core.working_tree_state import WorkingTreeStateCalculator +def _init_git_repo(path: Path) -> None: + """Initialize a git repo with config. + + All git config is scoped to the repo (--local) to avoid mutating user environment. + """ + subprocess.run(["git", "init"], cwd=path, capture_output=True, check=True) + # Use --local to ensure config is scoped to this repo only + # Use example.com (RFC 2606 reserved domain for testing) + subprocess.run( + ["git", "config", "--local", "user.email", "test@example.com"], cwd=path, capture_output=True, check=True + ) + subprocess.run(["git", "config", "--local", "user.name", "Test User"], cwd=path, capture_output=True, check=True) + # Disable GPG signing for tests (local scope) + subprocess.run(["git", "config", "--local", "commit.gpgsign", "false"], cwd=path, capture_output=True, check=True) + + +def _commit_all(path: Path, message: str = "commit") -> None: + """Add all files and commit.""" + subprocess.run(["git", "add", "."], cwd=path, capture_output=True) + subprocess.run(["git", "commit", "-m", message], cwd=path, capture_output=True) + + def test_calculate_working_tree_hash__generates_consistent_hash(): - """Test working tree hash consistency.""" + """Test working tree hash consistency when no files are modified.""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) + _init_git_repo(temp_path) (temp_path / "test.py").write_text("content") + _commit_all(temp_path) calculator = WorkingTreeStateCalculator(temp_dir) hash1 = calculator.calculate_working_tree_hash("commit1") @@ -21,22 +46,70 @@ def test_calculate_working_tree_hash__generates_consistent_hash(): assert len(hash1) == 16 -def test_calculate_working_tree_hash__changes_on_modification(): - """Test hash changes when file modifies.""" +def test_calculate_working_tree_hash__changes_on_content_modification(): + """Test hash changes when file content changes (not just mtime).""" with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) + _init_git_repo(temp_path) f = temp_path / "test.py" f.write_text("content 1") + _commit_all(temp_path) + + # Modify file content (this will show in git diff) + f.write_text("content 2") calculator = WorkingTreeStateCalculator(temp_dir) hash1 = calculator.calculate_working_tree_hash("commit1") - # Force mtime update (some filesystems are fast) + # Change content again + f.write_text("content 3") + + hash2 = calculator.calculate_working_tree_hash("commit1") + assert hash1 != hash2, "Different content should produce different hash" + + +def test_calculate_working_tree_hash__stable_when_no_changes(): + """Test hash is stable when there are no uncommitted changes.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + _init_git_repo(temp_path) + f = temp_path / "test.py" + f.write_text("content") + _commit_all(temp_path) + + calculator = WorkingTreeStateCalculator(temp_dir) + + # Multiple calls with no changes + hash1 = calculator.calculate_working_tree_hash("commit1") + hash2 = calculator.calculate_working_tree_hash("commit1") + hash3 = calculator.calculate_working_tree_hash("commit1") + + assert hash1 == hash2 == hash3, "Hash should be stable with no changes" + + +def test_calculate_working_tree_hash__mtime_only_change_same_hash(): + """Test that mtime-only changes (same content) produce the same hash. + + This verifies content-based hashing instead of mtime-based hashing. + """ + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + _init_git_repo(temp_path) + f = temp_path / "test.py" + f.write_text("content") + _commit_all(temp_path) + + # Modify file to get it tracked, then revert + f.write_text("modified") + calculator = WorkingTreeStateCalculator(temp_dir) + hash1 = calculator.calculate_working_tree_hash("commit1") + + # Write same content (different mtime) time.sleep(0.01) - f.write_text("content 2") + f.write_text("modified") # Same content hash2 = calculator.calculate_working_tree_hash("commit1") - assert hash1 != hash2 + assert hash1 == hash2, "Same content should produce same hash (content-based)" def test_get_python_files__excludes_ignored_directories(): diff --git a/uv.lock b/uv.lock index 358cf8a..edbe85d 100644 --- a/uv.lock +++ b/uv.lock @@ -2836,7 +2836,7 @@ wheels = [ [[package]] name = "slopometry" -version = "20260107.post1" +version = "20260108.post1" source = { editable = "." } dependencies = [ { name = "click" },