crapkit 0.7.4__tar.gz → 0.7.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. {crapkit-0.7.4/src/crapkit.egg-info → crapkit-0.7.6}/PKG-INFO +46 -14
  2. {crapkit-0.7.4 → crapkit-0.7.6}/README.md +44 -12
  3. {crapkit-0.7.4 → crapkit-0.7.6}/pyproject.toml +2 -2
  4. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/scoring.py +45 -4
  6. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/mcp_server.py +20 -30
  7. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/score.py +89 -24
  8. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/store.py +1 -1
  9. {crapkit-0.7.4 → crapkit-0.7.6/src/crapkit.egg-info}/PKG-INFO +46 -14
  10. {crapkit-0.7.4 → crapkit-0.7.6}/LICENSE +0 -0
  11. {crapkit-0.7.4 → crapkit-0.7.6}/setup.cfg +0 -0
  12. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/__main__.py +0 -0
  13. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/_analysis_pool.py +0 -0
  14. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/_mcp_stdio.py +0 -0
  15. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/_process_family.py +0 -0
  16. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/_process_owner.py +0 -0
  17. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/_pygdefer.py +0 -0
  18. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/_windows_job.py +0 -0
  19. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/analyze.py +0 -0
  20. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cache.py +0 -0
  21. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/churn.py +0 -0
  22. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/churn_cache.py +0 -0
  23. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/churn_log.py +0 -0
  24. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/__init__.py +0 -0
  25. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/_shared.py +0 -0
  26. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/admin.py +0 -0
  27. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/analyses.py +0 -0
  28. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/claude_hook.py +0 -0
  29. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/maintenance.py +0 -0
  30. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/parser.py +0 -0
  31. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/queue.py +0 -0
  32. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/ratchet_cmds.py +0 -0
  33. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/reports.py +0 -0
  34. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/cli/verifying.py +0 -0
  35. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/config.py +0 -0
  36. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/config_contract.py +0 -0
  37. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/coupling.py +0 -0
  38. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/coupling_cache.py +0 -0
  39. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/coverage_istanbul.py +0 -0
  40. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/coverage_py.py +0 -0
  41. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/covstream.py +0 -0
  42. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/diffparse.py +0 -0
  43. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/digest.py +0 -0
  44. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/doctor.py +0 -0
  45. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/dup.py +0 -0
  46. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/errors.py +0 -0
  47. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/gitio.py +0 -0
  48. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/gitpaths.py +0 -0
  49. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/hook.py +0 -0
  50. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/invocation.py +0 -0
  51. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/junitparse.py +0 -0
  52. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/keys.py +0 -0
  53. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/lanes.py +0 -0
  54. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/lizardcognitive.py +0 -0
  55. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/lizardpowershell.py +0 -0
  56. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/lizardpython.py +0 -0
  57. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/lizardrust.py +0 -0
  58. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/lizardshell.py +0 -0
  59. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/lizardtypescript.py +0 -0
  60. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/locks.py +0 -0
  61. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/logs.py +0 -0
  62. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/merge.py +0 -0
  63. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/mutate.py +0 -0
  64. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/mutate_pool.py +0 -0
  65. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/override.py +0 -0
  66. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/packet.py +0 -0
  67. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/procs.py +0 -0
  68. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/ratchet.py +0 -0
  69. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/ratchet_report.py +0 -0
  70. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/ratchetfile.py +0 -0
  71. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/records.py +0 -0
  72. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/report.py +0 -0
  73. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/repotext.py +0 -0
  74. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/resources.py +0 -0
  75. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/retention.py +0 -0
  76. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/rootfind.py +0 -0
  77. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/sarif.py +0 -0
  78. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/sarifio.py +0 -0
  79. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/scaffold.py +0 -0
  80. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/snapshot.py +0 -0
  81. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/uncovered.py +0 -0
  82. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/universe.py +0 -0
  83. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/verify.py +0 -0
  84. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/watch.py +0 -0
  85. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit/worklist.py +0 -0
  86. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit.egg-info/SOURCES.txt +0 -0
  87. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit.egg-info/dependency_links.txt +0 -0
  88. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit.egg-info/entry_points.txt +0 -0
  89. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit.egg-info/requires.txt +0 -0
  90. {crapkit-0.7.4 → crapkit-0.7.6}/src/crapkit.egg-info/top_level.txt +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.7.4
3
+ Version: 0.7.6
4
4
  Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
5
5
  Author: Jean-Francois Gagne
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/JeanFrancoisGagne/crapkit
8
- Project-URL: Documentation, https://jeanfrancoisgagne.github.io/crapkit/handbook.html
8
+ Project-URL: Documentation, https://www.jfgagne.com/crapkit/handbook.html
9
9
  Project-URL: Changelog, https://github.com/JeanFrancoisGagne/crapkit/blob/main/CHANGELOG.md
10
10
  Project-URL: Issues, https://github.com/JeanFrancoisGagne/crapkit/issues
11
11
  Project-URL: Source, https://github.com/JeanFrancoisGagne/crapkit
@@ -111,6 +111,9 @@ crapkit ratchet seed
111
111
  git add crapkit.toml crapkit-ratchet.tsv .gitignore
112
112
  ```
113
113
 
114
+ Not a Python repo? `uvx crapkit init` runs the same commands and adds nothing to your
115
+ manifest: see [A repo that is not Python](#a-repo-that-is-not-python).
116
+
114
117
  `init` detects pytest, Vitest and Jest from the repository's own files. Review the
115
118
  generated config before running its commands. When detection leaves a commented
116
119
  lane, fill it in using the [lane recipes](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md).
@@ -182,6 +185,28 @@ pip install git+https://github.com/JeanFrancoisGagne/crapkit.git
182
185
  pip install .
183
186
  ```
184
187
 
188
+ ### A repo that is not Python
189
+
190
+ crapkit is a command-line tool, never a dependency of the code it scores. A TypeScript,
191
+ Go or Rust repo adds nothing to its own manifest. With [uv](https://docs.astral.sh/uv/)
192
+ on the machine, `uvx` fetches crapkit into a cache of its own and runs it:
193
+
194
+ ```
195
+ $ uvx crapkit init
196
+ wrote crapkit.toml with 1 scope(s): src
197
+ detected 1 lane(s) from this repo's own files: js - next: run `crapkit coverage`
198
+ added to .gitignore: .crapkit/
199
+
200
+ $ uvx crapkit coverage
201
+ $ uvx crapkit worklist
202
+ ```
203
+
204
+ The lane still runs your own test runner, so Vitest or Jest and its coverage package come
205
+ from the repo's `node_modules` as they do today. `uv tool install crapkit` or
206
+ `pipx install crapkit` puts a `crapkit` command on PATH once, which is what the
207
+ [commit gate](#the-gate) and the Claude Code plugin call. uv brings its own Python when
208
+ the machine has none.
209
+
185
210
  Requires Python 3.11 or newer and Git on PATH. The CLI has one runtime dependency,
186
211
  `lizard>=1.24.0`; a package mirror needs both distributions. Install into the environment
187
212
  you intend to use, then check `crapkit --version`. The `pip install -e ".[dev]"` under
@@ -199,7 +224,7 @@ files. Review those commands before running Crapkit in a repository you do not t
199
224
 
200
225
  ```
201
226
  $ crapkit --version
202
- crapkit 0.7.4
227
+ crapkit 0.7.6
203
228
  ```
204
229
 
205
230
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -455,7 +480,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
455
480
  repos:
456
481
  - repo: https://github.com/JeanFrancoisGagne/crapkit
457
482
  # crapkit's release step rewrites this line to the tag it just cut
458
- rev: v0.7.4
483
+ rev: v0.7.6
459
484
  hooks:
460
485
  - id: crapkit-gate
461
486
  ```
@@ -563,7 +588,7 @@ to a workflow, and every input has a default:
563
588
  - uses: actions/checkout@v4
564
589
  with:
565
590
  fetch-depth: 0
566
- - uses: JeanFrancoisGagne/crapkit@v0.7.4
591
+ - uses: JeanFrancoisGagne/crapkit@v0.7.6
567
592
  ```
568
593
 
569
594
  The whole job those four lines sit in:
@@ -583,7 +608,7 @@ jobs:
583
608
  with:
584
609
  python-version: "3.12" # the interpreter the install below lands in
585
610
  - run: pip install -e ".[dev]" # whatever your lanes need to run
586
- - uses: JeanFrancoisGagne/crapkit@v0.7.4
611
+ - uses: JeanFrancoisGagne/crapkit@v0.7.6
587
612
  with:
588
613
  gate: "false"
589
614
  ```
@@ -654,7 +679,7 @@ closes it. A verify that passed is one line: `**verify passed.** Run 2 against b
654
679
  The rows are the ranked worklist for the files the pull request changed, worst first,
655
680
  `top` of them, with the rows a finding names listed first. `risk` is ccn times churn
656
681
  weight, the number `crapkit worklist` ranks on, and `remedy` is the run's own verdict for
657
- that function: `decompose`, `add-tests` or `ok`. `(accepted debt)` marks a function the
682
+ that function: `decompose`, `split-lines`, `add-tests` or `ok`. `(accepted debt)` marks a function the
658
683
  committed ratchet carries a mark for, so an untouched `legacy_router` does not read like
659
684
  the pull request's own new function. A pull request that touches no ranked function gets
660
685
  the heading and no table.
@@ -830,6 +855,7 @@ The coverage summary counts all four as `measured` / `untested` / `no_lane` / `c
830
855
  | Remedy | Condition | Action |
831
856
  |---|---|---|
832
857
  | `decompose` | `ccn > ceiling` | Split it. No amount of coverage clears this. |
858
+ | `split-lines` | `ccn <= ceiling`, `crap > ceiling`, and another function shares its source lines | Put each definition on its own lines, then measure again. Coverage cannot tell functions on one line apart, so the score stays at uncovered whatever the tests do. |
833
859
  | `add-tests` | `ccn <= ceiling` and `crap > ceiling` | Cover the branches. |
834
860
  | `ok` | `crap <= ceiling` | Nothing. |
835
861
 
@@ -1268,12 +1294,12 @@ export function classify(row: Row): string {
1268
1294
  ```
1269
1295
  $ crapkit rescore src/grade.ts --gate
1270
1296
  rescore vs run 1 @ 8bfbe613fcd (coverage STALE, complexity fresh)
1271
- ccn cov crap remedy function
1272
- 5 0% 30.0 add-tests src/grade.ts:22 band ( score )
1273
- 5 0% 30.0 add-tests src/grade.ts:38 demote ( letter , row Row )
1274
- 4 0% 20.0 add-tests src/grade.ts:8 penalty ( attempts , late )
1275
- 4 45% 6.7 add-tests src/grade.ts:48 classify ( row Row )
1276
- 4 75% 4.2 ok src/grade.ts:59 average ( scores Array )
1297
+ ccn cov crap remedy function
1298
+ 5 0% 30.0 add-tests src/grade.ts:22 band ( score )
1299
+ 5 0% 30.0 add-tests src/grade.ts:38 demote ( letter , row Row )
1300
+ 4 0% 20.0 add-tests src/grade.ts:8 penalty ( attempts , late )
1301
+ 4 45% 6.7 add-tests src/grade.ts:48 classify ( row Row )
1302
+ 4 75% 4.2 ok src/grade.ts:59 average ( scores Array )
1277
1303
  ```
1278
1304
 
1279
1305
  Exit 0: every piece is at or under 6. The `crap` column is loud because its coverage half
@@ -1340,7 +1366,7 @@ with no debt.
1340
1366
 
1341
1367
  | Page | Covers |
1342
1368
  |---|---|
1343
- | [The handbook](https://jeanfrancoisgagne.github.io/crapkit/handbook.html) | **Start here for anything deeper.** The illustrated handbook: what crapkit is, how every piece works, and where each command earns its keep. Also at [docs/handbook.html](https://jeanfrancoisgagne.github.io/crapkit/handbook.html), self-contained, so it opens straight from a clone. |
1369
+ | [The handbook](https://www.jfgagne.com/crapkit/handbook.html) | **Start here for anything deeper.** The illustrated handbook: what crapkit is, how every piece works, and where each command earns its keep. Also at [docs/handbook.html](https://www.jfgagne.com/crapkit/handbook.html), self-contained, so it opens straight from a clone. |
1344
1370
  | [docs/adoption.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
1345
1371
  | [docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md) | Every `crapkit.toml` key: type, default, and what it does. |
1346
1372
  | [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md) | The lane model, vitest and jest and pytest recipes, artifact reuse, flake retest, containers. |
@@ -1371,6 +1397,12 @@ line arms the complexity gate. See
1371
1397
  for development and [the verified implementation report](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/architecture/2026-09-07-implementation/REPORT.md)
1372
1398
  for complete Windows source and Linux wheel results, focused benchmarks and their limits.
1373
1399
 
1400
+ ## Maintainer and project background
1401
+
1402
+ crapkit is created and maintained by [Jean-François Gagné](https://www.jfgagne.com/).
1403
+ Read the [project background](https://www.jfgagne.com/projects/crapkit/) for the
1404
+ problem it addresses and how it fits into his work on software and AI.
1405
+
1374
1406
  ## License
1375
1407
 
1376
1408
  MIT. See [LICENSE](https://github.com/JeanFrancoisGagne/crapkit/blob/main/LICENSE).
@@ -70,6 +70,9 @@ crapkit ratchet seed
70
70
  git add crapkit.toml crapkit-ratchet.tsv .gitignore
71
71
  ```
72
72
 
73
+ Not a Python repo? `uvx crapkit init` runs the same commands and adds nothing to your
74
+ manifest: see [A repo that is not Python](#a-repo-that-is-not-python).
75
+
73
76
  `init` detects pytest, Vitest and Jest from the repository's own files. Review the
74
77
  generated config before running its commands. When detection leaves a commented
75
78
  lane, fill it in using the [lane recipes](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md).
@@ -141,6 +144,28 @@ pip install git+https://github.com/JeanFrancoisGagne/crapkit.git
141
144
  pip install .
142
145
  ```
143
146
 
147
+ ### A repo that is not Python
148
+
149
+ crapkit is a command-line tool, never a dependency of the code it scores. A TypeScript,
150
+ Go or Rust repo adds nothing to its own manifest. With [uv](https://docs.astral.sh/uv/)
151
+ on the machine, `uvx` fetches crapkit into a cache of its own and runs it:
152
+
153
+ ```
154
+ $ uvx crapkit init
155
+ wrote crapkit.toml with 1 scope(s): src
156
+ detected 1 lane(s) from this repo's own files: js - next: run `crapkit coverage`
157
+ added to .gitignore: .crapkit/
158
+
159
+ $ uvx crapkit coverage
160
+ $ uvx crapkit worklist
161
+ ```
162
+
163
+ The lane still runs your own test runner, so Vitest or Jest and its coverage package come
164
+ from the repo's `node_modules` as they do today. `uv tool install crapkit` or
165
+ `pipx install crapkit` puts a `crapkit` command on PATH once, which is what the
166
+ [commit gate](#the-gate) and the Claude Code plugin call. uv brings its own Python when
167
+ the machine has none.
168
+
144
169
  Requires Python 3.11 or newer and Git on PATH. The CLI has one runtime dependency,
145
170
  `lizard>=1.24.0`; a package mirror needs both distributions. Install into the environment
146
171
  you intend to use, then check `crapkit --version`. The `pip install -e ".[dev]"` under
@@ -158,7 +183,7 @@ files. Review those commands before running Crapkit in a repository you do not t
158
183
 
159
184
  ```
160
185
  $ crapkit --version
161
- crapkit 0.7.4
186
+ crapkit 0.7.6
162
187
  ```
163
188
 
164
189
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -414,7 +439,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
414
439
  repos:
415
440
  - repo: https://github.com/JeanFrancoisGagne/crapkit
416
441
  # crapkit's release step rewrites this line to the tag it just cut
417
- rev: v0.7.4
442
+ rev: v0.7.6
418
443
  hooks:
419
444
  - id: crapkit-gate
420
445
  ```
@@ -522,7 +547,7 @@ to a workflow, and every input has a default:
522
547
  - uses: actions/checkout@v4
523
548
  with:
524
549
  fetch-depth: 0
525
- - uses: JeanFrancoisGagne/crapkit@v0.7.4
550
+ - uses: JeanFrancoisGagne/crapkit@v0.7.6
526
551
  ```
527
552
 
528
553
  The whole job those four lines sit in:
@@ -542,7 +567,7 @@ jobs:
542
567
  with:
543
568
  python-version: "3.12" # the interpreter the install below lands in
544
569
  - run: pip install -e ".[dev]" # whatever your lanes need to run
545
- - uses: JeanFrancoisGagne/crapkit@v0.7.4
570
+ - uses: JeanFrancoisGagne/crapkit@v0.7.6
546
571
  with:
547
572
  gate: "false"
548
573
  ```
@@ -613,7 +638,7 @@ closes it. A verify that passed is one line: `**verify passed.** Run 2 against b
613
638
  The rows are the ranked worklist for the files the pull request changed, worst first,
614
639
  `top` of them, with the rows a finding names listed first. `risk` is ccn times churn
615
640
  weight, the number `crapkit worklist` ranks on, and `remedy` is the run's own verdict for
616
- that function: `decompose`, `add-tests` or `ok`. `(accepted debt)` marks a function the
641
+ that function: `decompose`, `split-lines`, `add-tests` or `ok`. `(accepted debt)` marks a function the
617
642
  committed ratchet carries a mark for, so an untouched `legacy_router` does not read like
618
643
  the pull request's own new function. A pull request that touches no ranked function gets
619
644
  the heading and no table.
@@ -789,6 +814,7 @@ The coverage summary counts all four as `measured` / `untested` / `no_lane` / `c
789
814
  | Remedy | Condition | Action |
790
815
  |---|---|---|
791
816
  | `decompose` | `ccn > ceiling` | Split it. No amount of coverage clears this. |
817
+ | `split-lines` | `ccn <= ceiling`, `crap > ceiling`, and another function shares its source lines | Put each definition on its own lines, then measure again. Coverage cannot tell functions on one line apart, so the score stays at uncovered whatever the tests do. |
792
818
  | `add-tests` | `ccn <= ceiling` and `crap > ceiling` | Cover the branches. |
793
819
  | `ok` | `crap <= ceiling` | Nothing. |
794
820
 
@@ -1227,12 +1253,12 @@ export function classify(row: Row): string {
1227
1253
  ```
1228
1254
  $ crapkit rescore src/grade.ts --gate
1229
1255
  rescore vs run 1 @ 8bfbe613fcd (coverage STALE, complexity fresh)
1230
- ccn cov crap remedy function
1231
- 5 0% 30.0 add-tests src/grade.ts:22 band ( score )
1232
- 5 0% 30.0 add-tests src/grade.ts:38 demote ( letter , row Row )
1233
- 4 0% 20.0 add-tests src/grade.ts:8 penalty ( attempts , late )
1234
- 4 45% 6.7 add-tests src/grade.ts:48 classify ( row Row )
1235
- 4 75% 4.2 ok src/grade.ts:59 average ( scores Array )
1256
+ ccn cov crap remedy function
1257
+ 5 0% 30.0 add-tests src/grade.ts:22 band ( score )
1258
+ 5 0% 30.0 add-tests src/grade.ts:38 demote ( letter , row Row )
1259
+ 4 0% 20.0 add-tests src/grade.ts:8 penalty ( attempts , late )
1260
+ 4 45% 6.7 add-tests src/grade.ts:48 classify ( row Row )
1261
+ 4 75% 4.2 ok src/grade.ts:59 average ( scores Array )
1236
1262
  ```
1237
1263
 
1238
1264
  Exit 0: every piece is at or under 6. The `crap` column is loud because its coverage half
@@ -1299,7 +1325,7 @@ with no debt.
1299
1325
 
1300
1326
  | Page | Covers |
1301
1327
  |---|---|
1302
- | [The handbook](https://jeanfrancoisgagne.github.io/crapkit/handbook.html) | **Start here for anything deeper.** The illustrated handbook: what crapkit is, how every piece works, and where each command earns its keep. Also at [docs/handbook.html](https://jeanfrancoisgagne.github.io/crapkit/handbook.html), self-contained, so it opens straight from a clone. |
1328
+ | [The handbook](https://www.jfgagne.com/crapkit/handbook.html) | **Start here for anything deeper.** The illustrated handbook: what crapkit is, how every piece works, and where each command earns its keep. Also at [docs/handbook.html](https://www.jfgagne.com/crapkit/handbook.html), self-contained, so it opens straight from a clone. |
1303
1329
  | [docs/adoption.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
1304
1330
  | [docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md) | Every `crapkit.toml` key: type, default, and what it does. |
1305
1331
  | [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md) | The lane model, vitest and jest and pytest recipes, artifact reuse, flake retest, containers. |
@@ -1330,6 +1356,12 @@ line arms the complexity gate. See
1330
1356
  for development and [the verified implementation report](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/architecture/2026-09-07-implementation/REPORT.md)
1331
1357
  for complete Windows source and Linux wheel results, focused benchmarks and their limits.
1332
1358
 
1359
+ ## Maintainer and project background
1360
+
1361
+ crapkit is created and maintained by [Jean-François Gagné](https://www.jfgagne.com/).
1362
+ Read the [project background](https://www.jfgagne.com/projects/crapkit/) for the
1363
+ problem it addresses and how it fits into his work on software and AI.
1364
+
1333
1365
  ## License
1334
1366
 
1335
1367
  MIT. See [LICENSE](https://github.com/JeanFrancoisGagne/crapkit/blob/main/LICENSE).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crapkit"
7
- version = "0.7.4"
7
+ version = "0.7.6"
8
8
  description = "Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { text = "MIT" }
@@ -49,7 +49,7 @@ dependencies = [
49
49
 
50
50
  [project.urls]
51
51
  Homepage = "https://github.com/JeanFrancoisGagne/crapkit"
52
- Documentation = "https://jeanfrancoisgagne.github.io/crapkit/handbook.html"
52
+ Documentation = "https://www.jfgagne.com/crapkit/handbook.html"
53
53
  Changelog = "https://github.com/JeanFrancoisGagne/crapkit/blob/main/CHANGELOG.md"
54
54
  Issues = "https://github.com/JeanFrancoisGagne/crapkit/issues"
55
55
  Source = "https://github.com/JeanFrancoisGagne/crapkit"
@@ -1,2 +1,2 @@
1
1
  """crapkit: deterministic CRAP-score framework."""
2
- __version__ = "0.7.4"
2
+ __version__ = "0.7.6"
@@ -281,7 +281,7 @@ def _scored_run(root: Path, cfg, lanes, *, reuse_artifacts: bool, reuse_unchange
281
281
  `git` is the caller's GitFacts when it already has one — verify asks for the
282
282
  dirty set before this runs, and that answer is the one the lanes must see too.
283
283
  """
284
- from ..score import score_rows
284
+ from ..score import SharedSpanFold, score_rows
285
285
 
286
286
  git = git or GitFacts(root)
287
287
  commit, rows, corpus, cache_hits, tool_versions = _build_inventory(root, cfg, git)
@@ -294,14 +294,55 @@ def _scored_run(root: Path, cfg, lanes, *, reuse_artifacts: bool, reuse_unchange
294
294
  # Only scopes a SUCCESSFUL lane covers count as measured; a failed lane's
295
295
  # scopes fall back to no-lane flags rather than reading as untested code.
296
296
  lane_scopes = {s for lane in succeeded for s in lane.scopes}
297
+ shared_spans = SharedSpanFold()
297
298
  scored = score_rows(rows, coverage_by_path, lane_scopes=lane_scopes, target=cfg.target,
298
299
  scope_targets=cfg.scope_targets,
299
- cc_only_scopes=cfg.coverage_optional_scopes)
300
+ cc_only_scopes=cfg.coverage_optional_scopes,
301
+ shared_spans=shared_spans)
302
+ _note_shared_spans(shared_spans, cfg)
300
303
  test_failures = {f for prov in provenance.values() for f in prov.get("failures", ())}
301
304
  return _ScoredRun(commit, scored, provenance, lane_errors, test_failures, tool_versions,
302
305
  corpus, cache_hits, dead_lines)
303
306
 
304
307
 
308
+ _SPANS_NAMED = 3
309
+
310
+
311
+ def _over_target_at_zero(members, cfg):
312
+ """The worst function on each shared span that its ceiling can fail.
313
+
314
+ Every function on a shared span scores as uncovered, and a function of
315
+ complexity 2 scores 6 at zero coverage, so no ceiling of 6 can fail it.
316
+ The count carries those; the named lines are the ones worth splitting.
317
+ """
318
+ from ..score import crap
319
+
320
+ for span in members:
321
+ worst = max(span, key=lambda row: row.ccn)
322
+ if crap(worst.ccn, 0.0) > cfg.ceiling_of(worst.scope):
323
+ yield worst
324
+
325
+
326
+ def _note_shared_spans(fold, cfg) -> None:
327
+ """Name the source line spans more than one function declares.
328
+
329
+ Loud but not fatal, the shape `_note_unanalyzable` settled for unreadable
330
+ files: the join cannot tell whose coverage is whose on such a span, and
331
+ ending the run over it left the consumer repo with no coverage run at all.
332
+ """
333
+ if not fold.sites:
334
+ return
335
+ over = sorted(_over_target_at_zero(fold.sites, cfg), key=lambda row: (-row.ccn, row.path))
336
+ tail = f"; {len(over)} of them hold a function over its target:" if over else ""
337
+ print(f"crapkit: {len(fold.sites)} source line span(s) hold more than one function; "
338
+ "coverage cannot say whose is whose, so those functions score as uncovered, and "
339
+ f"splitting the definitions onto separate lines measures them{tail}", file=sys.stderr)
340
+ for row in over[:_SPANS_NAMED]:
341
+ print(f"crapkit: {row.path}:{row.start} (complexity {row.ccn})", file=sys.stderr)
342
+ if len(over) > _SPANS_NAMED:
343
+ print(f"crapkit: ... and {len(over) - _SPANS_NAMED} more", file=sys.stderr)
344
+
345
+
305
346
  def _run_kind(lanes, cfg, failures) -> str:
306
347
  """A --lane subset or a run with failed lanes must never serve as the
307
348
  verify baseline: its lane set differs from what verify runs, so every
@@ -776,6 +817,6 @@ def cmd_rescore(args: argparse.Namespace) -> int:
776
817
  def _print_rescore_table(overlay, latest: dict) -> None:
777
818
  """The refactor loop's view: fresh ccn, worst first, stale cov labeled."""
778
819
  print(f"rescore vs run {latest['id']} @ {latest['commit'][:11]} (coverage STALE, complexity fresh)")
779
- print(f" {'ccn':>4} {'cov':>5} {'crap':>8} {'remedy':10} function")
820
+ print(f" {'ccn':>4} {'cov':>5} {'crap':>8} {'remedy':11} function")
780
821
  for r in sorted(overlay, key=lambda x: (-x.ccn, x.path, x.start)):
781
- print(f" {r.ccn:>4} {r.cov:>5.0%} {r.crap:>8.1f} {r.remedy:10} {r.path}:{r.start} {r.long_name}")
822
+ print(f" {r.ccn:>4} {r.cov:>5.0%} {r.crap:>8.1f} {r.remedy:11} {r.path}:{r.start} {r.long_name}")
@@ -30,6 +30,15 @@ _NAME_DESCRIPTION = ("the bare identifier (classify, or route for a Rust "
30
30
  "printed (classify( score , late )); both resolve, exact "
31
31
  "match first")
32
32
 
33
+ # The scorer's remedy vocabulary, said once: a structured result carrying a value
34
+ # its outputSchema does not list is rejected whole by a validating client.
35
+ _REMEDIES = ("decompose", "split-lines", "add-tests", "ok")
36
+ _REMEDY_DESCRIPTION = ("decompose (ccn over ceiling), split-lines (another function shares its "
37
+ "source lines, so coverage cannot tell them apart and no test lowers the "
38
+ "score until the definitions sit on separate lines), add-tests (coverage "
39
+ "short) or ok (nothing left to do)")
40
+ _REMEDY = {"type": "string", "description": _REMEDY_DESCRIPTION, "enum": _REMEDIES}
41
+
33
42
  # The partition a large repo needs before `top` means anything: one --scope
34
43
  # per element, exact names as declared in crapkit.toml.
35
44
  _SCOPE = {
@@ -63,10 +72,7 @@ _PACKET_PROPERTIES = {'scope': {'type': 'string', 'description': 'the declared s
63
72
  'measure this span',
64
73
  'enum': ('measured', 'untested', 'no-lane', 'cc-only')},
65
74
  'crap': {'type': 'number', 'description': 'the score: ccn^2 x (1 - cov)^3 + ccn'},
66
- 'remedy': {'type': 'string',
67
- 'description': 'decompose (ccn over ceiling), add-tests (coverage short) or ok '
68
- '(nothing left to do)',
69
- 'enum': ('decompose', 'add-tests', 'ok')},
75
+ 'remedy': _REMEDY,
70
76
  'target': {'type': 'integer', 'description': "this scope's effective ccn ceiling"},
71
77
  'commits': {'type': 'integer', 'description': 'commits touching the file in the churn window'},
72
78
  'authors': {'type': 'integer', 'description': 'distinct authors of those commits'},
@@ -112,9 +118,9 @@ _WORKLIST_ITEM = {'type': 'object',
112
118
  'inventory-only run',
113
119
  'enum': ('measured', 'untested', 'no-lane', 'cc-only', None)},
114
120
  'remedy': {'type': 'string',
115
- 'description': 'decompose, add-tests or ok; only decompose and '
116
- 'add-tests rows with a lane reach get_next_item',
117
- 'enum': ('decompose', 'add-tests', 'ok')},
121
+ 'description': 'decompose, split-lines, add-tests or ok; every row '
122
+ 'but ok reaches get_next_item when a lane measures it',
123
+ 'enum': _REMEDIES},
118
124
  'crap': {'type': ('number', 'null'),
119
125
  'description': 'the score from the ranked run; null on an inventory-only '
120
126
  'run'},
@@ -469,9 +475,9 @@ TOOLS: tuple[dict, ...] = (
469
475
  "value get_next_item prints")},
470
476
  "remedy": {
471
477
  "type": "string",
472
- "description": ("decompose, add-tests or ok: the branch the session takes; "
473
- "scored.remedy carries the same value"),
474
- "enum": ("decompose", "add-tests", "ok")},
478
+ "description": ("decompose, split-lines, add-tests or ok: the branch the "
479
+ "session takes; scored.remedy carries the same value"),
480
+ "enum": _REMEDIES},
475
481
  "target": {
476
482
  "type": "integer",
477
483
  "description": ("this scope's effective ccn ceiling, the same value as "
@@ -527,11 +533,7 @@ TOOLS: tuple[dict, ...] = (
527
533
  "crap": {
528
534
  "type": "number",
529
535
  "description": "the score: ccn^2 x (1 - cov)^3 + ccn"},
530
- "remedy": {
531
- "type": "string",
532
- "description": ("decompose (ccn over ceiling), add-tests (coverage short) or "
533
- "ok (nothing left to do)"),
534
- "enum": ("decompose", "add-tests", "ok")}}},
536
+ "remedy": _REMEDY}},
535
537
  "source": {
536
538
  "type": "string",
537
539
  "description": ("the function's own text, start to end inclusive, newlines intact: "
@@ -590,11 +592,7 @@ TOOLS: tuple[dict, ...] = (
590
592
  "crap": {
591
593
  "type": "number",
592
594
  "description": "score"},
593
- "remedy": {
594
- "type": "string",
595
- "description": ("decompose (ccn over ceiling), add-tests (coverage short) "
596
- "or ok (nothing left to do)"),
597
- "enum": ("decompose", "add-tests", "ok")}}}},
595
+ "remedy": _REMEDY}}},
598
596
  "file_totals": {
599
597
  "type": "object",
600
598
  "description": "the file rolled up",
@@ -1342,11 +1340,7 @@ TOOLS: tuple[dict, ...] = (
1342
1340
  "crap": {
1343
1341
  "type": "number",
1344
1342
  "description": "score from fresh ccn and baseline cov"},
1345
- "remedy": {
1346
- "type": "string",
1347
- "description": ("decompose (ccn over ceiling), add-tests (coverage short) "
1348
- "or ok (nothing left to do)"),
1349
- "enum": ("decompose", "add-tests", "ok")},
1343
+ "remedy": _REMEDY,
1350
1344
  "stale_coverage": {
1351
1345
  "type": "boolean",
1352
1346
  "description": ("always true: complexity is the working tree's, coverage "
@@ -1393,11 +1387,7 @@ TOOLS: tuple[dict, ...] = (
1393
1387
  "crap": {
1394
1388
  "type": "number",
1395
1389
  "description": "score"},
1396
- "remedy": {
1397
- "type": "string",
1398
- "description": ("decompose (ccn over ceiling), add-tests "
1399
- "(coverage short) or ok (nothing left to do)"),
1400
- "enum": ("decompose", "add-tests", "ok")},
1390
+ "remedy": _REMEDY,
1401
1391
  "key_name": {
1402
1392
  "type": "string",
1403
1393
  "description": "the ratchet key form of the name"},
@@ -128,13 +128,20 @@ def _best_match(row: InventoryRow, candidates: list[FnCoverage]) -> FnCoverage |
128
128
  return best
129
129
 
130
130
 
131
- def _remedy(ccn: int, score: float, ceiling: int) -> str:
131
+ def _remedy(ccn: int, score: float, ceiling: int, shared_span: bool = False) -> str:
132
+ """The first thing that can lower this score. A function sharing its source
133
+ line span with another scores as uncovered whatever its tests do, so
134
+ add-tests there is advice nobody can follow: splitting the definitions is,
135
+ and the run after the split says whether tests are still owed."""
132
136
  if ccn > ceiling:
133
137
  return "decompose"
134
- return "ok" if score <= ceiling else "add-tests"
138
+ if score <= ceiling:
139
+ return "ok"
140
+ return "split-lines" if shared_span else "add-tests"
135
141
 
136
142
 
137
- def _finish(row, cov: float, flag: str, *, target: int, scope_targets) -> ScoredRow:
143
+ def _finish(row, cov: float, flag: str, *, target: int, scope_targets,
144
+ shared_span: bool = False) -> ScoredRow:
138
145
  # cc-only is the pre-commit hook's rule: crap IS ccn, so _remedy can only
139
146
  # answer ok or decompose. Feeding it cov=0 through the formula would say
140
147
  # add-tests about code no test can reach.
@@ -146,7 +153,7 @@ def _finish(row, cov: float, flag: str, *, target: int, scope_targets) -> Scored
146
153
  # built a throwaway dict per row and looked every field up by name.
147
154
  return ScoredRow(row[0], row[1], row[2], row[3], row[4], row[5], row[6], row[7],
148
155
  row[8], row[9], row[10],
149
- cov, flag, score, _remedy(row[7], score, ceiling), row[11], row[12])
156
+ cov, flag, score, _remedy(row[7], score, ceiling, shared_span), row[11], row[12])
150
157
 
151
158
 
152
159
  def _nearest_overlay(row, candidates) -> list:
@@ -277,35 +284,87 @@ def overlay_stale_coverage(
277
284
  if r.flag == "measured":
278
285
  by_key.setdefault((r.path, r.long_name), []).append(r)
279
286
 
287
+ shared = _shared_source_spans(rows, lane_scopes, cc_only_scopes)
280
288
  scored = []
281
289
  for row in rows:
282
- cov, flag = (_cov_without_join(row, lane_scopes, cc_only_scopes)
283
- or _named_overlay_cov(row, by_key, positions))
284
- scored.append(_finish(row, cov, flag, target=target, scope_targets=scope_targets))
290
+ verdict = _cov_without_join(row, lane_scopes, cc_only_scopes)
291
+ cov, flag = verdict or _named_overlay_cov(row, by_key, positions)
292
+ scored.append(_finish(row, cov, flag, target=target, scope_targets=scope_targets,
293
+ shared_span=_on_shared_span(row, verdict, shared)))
285
294
  return scored
286
295
 
287
296
 
297
+ class SharedSpanFold:
298
+ """The source line spans more than one function declares in one run.
299
+
300
+ The join cannot tell whose coverage is whose there, so each such function
301
+ scores as uncovered and the run names the spans instead of ending. Filled
302
+ while scoring; the caller reports it once.
303
+ """
304
+
305
+ def __init__(self) -> None:
306
+ self.sites: list[list[InventoryRow]] = []
307
+
308
+ def add(self, members: list[InventoryRow]) -> None:
309
+ self.sites.append(members)
310
+
311
+
312
+ def _identity(row) -> tuple:
313
+ """What separates two functions on one span: the name, and the occurrence
314
+ that tells sibling callbacks on a line apart."""
315
+ return row.long_name, row.occurrence
316
+
317
+
318
+ def _join_member(members: list, row) -> None:
319
+ """A third function on the span joins its members; another copy of one that
320
+ is already there (the same function in a second scope) does not."""
321
+ if all(_identity(member) != _identity(row) for member in members):
322
+ members.append(row)
323
+
324
+
288
325
  def _shared_source_spans(rows, lane_scopes: set, cc_only_scopes) -> dict:
289
- seen, collisions = {}, {}
326
+ """span -> the distinct functions declaring it, for spans more than one does."""
327
+ first, collisions = {}, {}
290
328
  for row in rows:
291
329
  if _cov_without_join(row, lane_scopes, cc_only_scopes) is not None:
292
330
  continue
293
331
  span = row.path, row.start, row.end
294
- identity = row.long_name, row.occurrence
295
- if seen.setdefault(span, identity) != identity:
296
- collisions[span] = row
332
+ seen = first.setdefault(span, row)
333
+ if _identity(seen) != _identity(row):
334
+ _join_member(collisions.setdefault(span, [seen]), row)
297
335
  return collisions
298
336
 
299
337
 
300
- def _check_coverage_spans(rows, coverage_by_path: dict, start_index: dict,
301
- lane_scopes: set, cc_only_scopes) -> None:
302
- for row in _shared_source_spans(rows, lane_scopes, cc_only_scopes).values():
303
- _, flag = _span_join_cov(row, coverage_by_path, start_index)
304
- if flag == "measured":
305
- raise ToolError(
306
- f"coverage for {row.path}:{row.start} cannot distinguish functions "
307
- "with the same line span; split their definitions onto separate "
308
- "lines and regenerate coverage")
338
+ def _ambiguous_spans(shared: dict, coverage_by_path: dict, start_index: dict) -> dict:
339
+ """The shared spans a measurement would speak about, so the join would hand
340
+ every function on the span one function's number.
341
+
342
+ Through 0.7.4 this raised and ended the run. One consumer repo holds 591
343
+ shared spans, 459 of them measured, so a run died on the first one it met
344
+ and the repo never finished a coverage run at all. Refusing the ambiguous
345
+ number is the specified behaviour; ending the run over it was not, the
346
+ shape _note_unanalyzable settled for unreadable files.
347
+ """
348
+ ambiguous = {}
349
+ for span, members in shared.items():
350
+ if _span_join_cov(members[0], coverage_by_path, start_index)[1] == "measured":
351
+ ambiguous[span] = members
352
+ return ambiguous
353
+
354
+
355
+ def _joined_cov(row, ambiguous: dict, coverage_by_path: dict,
356
+ start_index: dict) -> tuple[float, str]:
357
+ """Uncovered on an ambiguous span, never the neighbour's number: the honest
358
+ floor for a function whose measurement cannot be told from another's."""
359
+ if (row.path, row.start, row.end) in ambiguous:
360
+ return 0.0, "untested"
361
+ return _span_join_cov(row, coverage_by_path, start_index)
362
+
363
+
364
+ def _on_shared_span(row, verdict, shared: dict) -> bool:
365
+ """A row some lane measures, on a span it shares. Measured yet or not: tests
366
+ would only make the span measured, and then it scores as uncovered."""
367
+ return verdict is None and (row.path, row.start, row.end) in shared
309
368
 
310
369
 
311
370
  def score_rows(
@@ -316,12 +375,18 @@ def score_rows(
316
375
  target: int = 6,
317
376
  scope_targets: dict[str, int] | None = None,
318
377
  cc_only_scopes: frozenset[str] = frozenset(),
378
+ shared_spans: SharedSpanFold | None = None,
319
379
  ) -> list[ScoredRow]:
320
380
  start_index = _start_index(coverage_by_path)
321
- _check_coverage_spans(rows, coverage_by_path, start_index, lane_scopes, cc_only_scopes)
381
+ shared = _shared_source_spans(rows, lane_scopes, cc_only_scopes)
382
+ ambiguous = _ambiguous_spans(shared, coverage_by_path, start_index)
383
+ for members in ambiguous.values():
384
+ if shared_spans is not None:
385
+ shared_spans.add(members)
322
386
  scored = []
323
387
  for r in rows:
324
- cov, flag = (_cov_without_join(r, lane_scopes, cc_only_scopes)
325
- or _span_join_cov(r, coverage_by_path, start_index))
326
- scored.append(_finish(r, cov, flag, target=target, scope_targets=scope_targets))
388
+ verdict = _cov_without_join(r, lane_scopes, cc_only_scopes)
389
+ cov, flag = verdict or _joined_cov(r, ambiguous, coverage_by_path, start_index)
390
+ scored.append(_finish(r, cov, flag, target=target, scope_targets=scope_targets,
391
+ shared_span=_on_shared_span(r, verdict, shared)))
327
392
  return scored
@@ -70,7 +70,7 @@ _CODE_DDL = """CREATE TABLE IF NOT EXISTS {table} (
70
70
  # and a store is a file people copy between machines. A name from outside this
71
71
  # list is still stored, at a code minted after these.
72
72
  _CODE_SEEDS = {"flags": ("measured", "untested", "no-lane", "cc-only"),
73
- "remedies": ("ok", "add-tests", "decompose")}
73
+ "remedies": ("ok", "add-tests", "decompose", "split-lines")}
74
74
 
75
75
  _SCHEMA = f"""
76
76
  CREATE TABLE IF NOT EXISTS runs (
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.7.4
3
+ Version: 0.7.6
4
4
  Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
5
5
  Author: Jean-Francois Gagne
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/JeanFrancoisGagne/crapkit
8
- Project-URL: Documentation, https://jeanfrancoisgagne.github.io/crapkit/handbook.html
8
+ Project-URL: Documentation, https://www.jfgagne.com/crapkit/handbook.html
9
9
  Project-URL: Changelog, https://github.com/JeanFrancoisGagne/crapkit/blob/main/CHANGELOG.md
10
10
  Project-URL: Issues, https://github.com/JeanFrancoisGagne/crapkit/issues
11
11
  Project-URL: Source, https://github.com/JeanFrancoisGagne/crapkit
@@ -111,6 +111,9 @@ crapkit ratchet seed
111
111
  git add crapkit.toml crapkit-ratchet.tsv .gitignore
112
112
  ```
113
113
 
114
+ Not a Python repo? `uvx crapkit init` runs the same commands and adds nothing to your
115
+ manifest: see [A repo that is not Python](#a-repo-that-is-not-python).
116
+
114
117
  `init` detects pytest, Vitest and Jest from the repository's own files. Review the
115
118
  generated config before running its commands. When detection leaves a commented
116
119
  lane, fill it in using the [lane recipes](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md).
@@ -182,6 +185,28 @@ pip install git+https://github.com/JeanFrancoisGagne/crapkit.git
182
185
  pip install .
183
186
  ```
184
187
 
188
+ ### A repo that is not Python
189
+
190
+ crapkit is a command-line tool, never a dependency of the code it scores. A TypeScript,
191
+ Go or Rust repo adds nothing to its own manifest. With [uv](https://docs.astral.sh/uv/)
192
+ on the machine, `uvx` fetches crapkit into a cache of its own and runs it:
193
+
194
+ ```
195
+ $ uvx crapkit init
196
+ wrote crapkit.toml with 1 scope(s): src
197
+ detected 1 lane(s) from this repo's own files: js - next: run `crapkit coverage`
198
+ added to .gitignore: .crapkit/
199
+
200
+ $ uvx crapkit coverage
201
+ $ uvx crapkit worklist
202
+ ```
203
+
204
+ The lane still runs your own test runner, so Vitest or Jest and its coverage package come
205
+ from the repo's `node_modules` as they do today. `uv tool install crapkit` or
206
+ `pipx install crapkit` puts a `crapkit` command on PATH once, which is what the
207
+ [commit gate](#the-gate) and the Claude Code plugin call. uv brings its own Python when
208
+ the machine has none.
209
+
185
210
  Requires Python 3.11 or newer and Git on PATH. The CLI has one runtime dependency,
186
211
  `lizard>=1.24.0`; a package mirror needs both distributions. Install into the environment
187
212
  you intend to use, then check `crapkit --version`. The `pip install -e ".[dev]"` under
@@ -199,7 +224,7 @@ files. Review those commands before running Crapkit in a repository you do not t
199
224
 
200
225
  ```
201
226
  $ crapkit --version
202
- crapkit 0.7.4
227
+ crapkit 0.7.6
203
228
  ```
204
229
 
205
230
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -455,7 +480,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
455
480
  repos:
456
481
  - repo: https://github.com/JeanFrancoisGagne/crapkit
457
482
  # crapkit's release step rewrites this line to the tag it just cut
458
- rev: v0.7.4
483
+ rev: v0.7.6
459
484
  hooks:
460
485
  - id: crapkit-gate
461
486
  ```
@@ -563,7 +588,7 @@ to a workflow, and every input has a default:
563
588
  - uses: actions/checkout@v4
564
589
  with:
565
590
  fetch-depth: 0
566
- - uses: JeanFrancoisGagne/crapkit@v0.7.4
591
+ - uses: JeanFrancoisGagne/crapkit@v0.7.6
567
592
  ```
568
593
 
569
594
  The whole job those four lines sit in:
@@ -583,7 +608,7 @@ jobs:
583
608
  with:
584
609
  python-version: "3.12" # the interpreter the install below lands in
585
610
  - run: pip install -e ".[dev]" # whatever your lanes need to run
586
- - uses: JeanFrancoisGagne/crapkit@v0.7.4
611
+ - uses: JeanFrancoisGagne/crapkit@v0.7.6
587
612
  with:
588
613
  gate: "false"
589
614
  ```
@@ -654,7 +679,7 @@ closes it. A verify that passed is one line: `**verify passed.** Run 2 against b
654
679
  The rows are the ranked worklist for the files the pull request changed, worst first,
655
680
  `top` of them, with the rows a finding names listed first. `risk` is ccn times churn
656
681
  weight, the number `crapkit worklist` ranks on, and `remedy` is the run's own verdict for
657
- that function: `decompose`, `add-tests` or `ok`. `(accepted debt)` marks a function the
682
+ that function: `decompose`, `split-lines`, `add-tests` or `ok`. `(accepted debt)` marks a function the
658
683
  committed ratchet carries a mark for, so an untouched `legacy_router` does not read like
659
684
  the pull request's own new function. A pull request that touches no ranked function gets
660
685
  the heading and no table.
@@ -830,6 +855,7 @@ The coverage summary counts all four as `measured` / `untested` / `no_lane` / `c
830
855
  | Remedy | Condition | Action |
831
856
  |---|---|---|
832
857
  | `decompose` | `ccn > ceiling` | Split it. No amount of coverage clears this. |
858
+ | `split-lines` | `ccn <= ceiling`, `crap > ceiling`, and another function shares its source lines | Put each definition on its own lines, then measure again. Coverage cannot tell functions on one line apart, so the score stays at uncovered whatever the tests do. |
833
859
  | `add-tests` | `ccn <= ceiling` and `crap > ceiling` | Cover the branches. |
834
860
  | `ok` | `crap <= ceiling` | Nothing. |
835
861
 
@@ -1268,12 +1294,12 @@ export function classify(row: Row): string {
1268
1294
  ```
1269
1295
  $ crapkit rescore src/grade.ts --gate
1270
1296
  rescore vs run 1 @ 8bfbe613fcd (coverage STALE, complexity fresh)
1271
- ccn cov crap remedy function
1272
- 5 0% 30.0 add-tests src/grade.ts:22 band ( score )
1273
- 5 0% 30.0 add-tests src/grade.ts:38 demote ( letter , row Row )
1274
- 4 0% 20.0 add-tests src/grade.ts:8 penalty ( attempts , late )
1275
- 4 45% 6.7 add-tests src/grade.ts:48 classify ( row Row )
1276
- 4 75% 4.2 ok src/grade.ts:59 average ( scores Array )
1297
+ ccn cov crap remedy function
1298
+ 5 0% 30.0 add-tests src/grade.ts:22 band ( score )
1299
+ 5 0% 30.0 add-tests src/grade.ts:38 demote ( letter , row Row )
1300
+ 4 0% 20.0 add-tests src/grade.ts:8 penalty ( attempts , late )
1301
+ 4 45% 6.7 add-tests src/grade.ts:48 classify ( row Row )
1302
+ 4 75% 4.2 ok src/grade.ts:59 average ( scores Array )
1277
1303
  ```
1278
1304
 
1279
1305
  Exit 0: every piece is at or under 6. The `crap` column is loud because its coverage half
@@ -1340,7 +1366,7 @@ with no debt.
1340
1366
 
1341
1367
  | Page | Covers |
1342
1368
  |---|---|
1343
- | [The handbook](https://jeanfrancoisgagne.github.io/crapkit/handbook.html) | **Start here for anything deeper.** The illustrated handbook: what crapkit is, how every piece works, and where each command earns its keep. Also at [docs/handbook.html](https://jeanfrancoisgagne.github.io/crapkit/handbook.html), self-contained, so it opens straight from a clone. |
1369
+ | [The handbook](https://www.jfgagne.com/crapkit/handbook.html) | **Start here for anything deeper.** The illustrated handbook: what crapkit is, how every piece works, and where each command earns its keep. Also at [docs/handbook.html](https://www.jfgagne.com/crapkit/handbook.html), self-contained, so it opens straight from a clone. |
1344
1370
  | [docs/adoption.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
1345
1371
  | [docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md) | Every `crapkit.toml` key: type, default, and what it does. |
1346
1372
  | [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md) | The lane model, vitest and jest and pytest recipes, artifact reuse, flake retest, containers. |
@@ -1371,6 +1397,12 @@ line arms the complexity gate. See
1371
1397
  for development and [the verified implementation report](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/architecture/2026-09-07-implementation/REPORT.md)
1372
1398
  for complete Windows source and Linux wheel results, focused benchmarks and their limits.
1373
1399
 
1400
+ ## Maintainer and project background
1401
+
1402
+ crapkit is created and maintained by [Jean-François Gagné](https://www.jfgagne.com/).
1403
+ Read the [project background](https://www.jfgagne.com/projects/crapkit/) for the
1404
+ problem it addresses and how it fits into his work on software and AI.
1405
+
1374
1406
  ## License
1375
1407
 
1376
1408
  MIT. See [LICENSE](https://github.com/JeanFrancoisGagne/crapkit/blob/main/LICENSE).
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes