crapkit 0.8.0__tar.gz → 0.8.1__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 (142) hide show
  1. crapkit-0.8.1/PKG-INFO +2102 -0
  2. crapkit-0.8.1/README.md +2005 -0
  3. crapkit-0.8.1/pyproject.toml +277 -0
  4. crapkit-0.8.1/setup.py +129 -0
  5. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/__init__.py +1 -1
  6. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/_analysis_pool.py +42 -5
  7. crapkit-0.8.1/src/crapkit/_build.json +1 -0
  8. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/_mcp_stdio.py +7 -2
  9. crapkit-0.8.1/src/crapkit/_package.py +35 -0
  10. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/_process_family.py +22 -3
  11. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/_process_owner.py +140 -15
  12. crapkit-0.8.1/src/crapkit/agent_fields.py +1810 -0
  13. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/analyze.py +328 -88
  14. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/churn.py +22 -10
  15. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/churn_cache.py +26 -17
  16. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/churn_commits.py +12 -11
  17. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/churn_log.py +176 -81
  18. crapkit-0.8.1/src/crapkit/cli/__init__.py +48 -0
  19. crapkit-0.8.1/src/crapkit/cli/_shared.py +644 -0
  20. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cli/admin.py +1183 -229
  21. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cli/analyses.py +96 -26
  22. crapkit-0.8.1/src/crapkit/cli/claude_hook.py +938 -0
  23. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cli/maintenance.py +2 -1
  24. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cli/parser.py +318 -59
  25. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cli/queue.py +239 -58
  26. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cli/ratchet_cmds.py +367 -39
  27. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cli/reports.py +174 -75
  28. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cli/scoring.py +263 -117
  29. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cli/verifying.py +733 -196
  30. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/config.py +192 -306
  31. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/config_contract.py +15 -8
  32. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/coupling.py +24 -9
  33. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/coupling_cache.py +32 -19
  34. crapkit-0.8.1/src/crapkit/coverage_istanbul.py +569 -0
  35. crapkit-0.8.1/src/crapkit/coverage_py.py +557 -0
  36. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/covstream.py +30 -11
  37. crapkit-0.8.1/src/crapkit/diffparse.py +282 -0
  38. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/digest.py +72 -23
  39. crapkit-0.8.1/src/crapkit/doctor.py +1115 -0
  40. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/dup.py +254 -35
  41. crapkit-0.8.1/src/crapkit/errors.py +79 -0
  42. crapkit-0.8.1/src/crapkit/gitio.py +1502 -0
  43. crapkit-0.8.1/src/crapkit/gitpaths.py +109 -0
  44. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/hook.py +58 -18
  45. crapkit-0.8.1/src/crapkit/invariants.py +556 -0
  46. crapkit-0.8.1/src/crapkit/invocation.py +249 -0
  47. crapkit-0.8.1/src/crapkit/istanbul_lines.py +177 -0
  48. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/junitparse.py +46 -23
  49. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/keys.py +83 -2
  50. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/lane_changes.py +50 -23
  51. crapkit-0.8.1/src/crapkit/lane_command.py +711 -0
  52. crapkit-0.8.1/src/crapkit/lane_freshness.py +575 -0
  53. crapkit-0.8.1/src/crapkit/lane_outputs.py +226 -0
  54. crapkit-0.8.1/src/crapkit/lane_results.py +261 -0
  55. crapkit-0.8.1/src/crapkit/lane_sources.py +141 -0
  56. crapkit-0.8.1/src/crapkit/lane_stamps.py +310 -0
  57. crapkit-0.8.1/src/crapkit/lanes.py +1141 -0
  58. crapkit-0.8.1/src/crapkit/languages.py +29 -0
  59. crapkit-0.8.1/src/crapkit/launchers.py +186 -0
  60. crapkit-0.8.1/src/crapkit/lizardclike.py +1570 -0
  61. crapkit-0.8.1/src/crapkit/lizardcognitive.py +2465 -0
  62. crapkit-0.8.1/src/crapkit/lizardgolike.py +480 -0
  63. crapkit-0.8.1/src/crapkit/lizardjava.py +325 -0
  64. crapkit-0.8.1/src/crapkit/lizardlinecomment.py +75 -0
  65. crapkit-0.8.1/src/crapkit/lizardpowershell.py +1007 -0
  66. crapkit-0.8.1/src/crapkit/lizardrust.py +440 -0
  67. crapkit-0.8.1/src/crapkit/lizardshell.py +922 -0
  68. crapkit-0.8.1/src/crapkit/lizardswift.py +543 -0
  69. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/lizardtypescript.py +4 -2
  70. crapkit-0.8.1/src/crapkit/marks_history.py +146 -0
  71. crapkit-0.8.1/src/crapkit/mcp_server.py +1302 -0
  72. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/merge.py +7 -0
  73. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/mutate.py +146 -38
  74. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/mutate_pool.py +76 -32
  75. crapkit-0.8.1/src/crapkit/named.py +22 -0
  76. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/override.py +59 -9
  77. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/packet.py +111 -33
  78. crapkit-0.8.1/src/crapkit/plaintext.py +55 -0
  79. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/procs.py +46 -13
  80. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/ratchet.py +91 -25
  81. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/ratchet_report.py +41 -20
  82. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/ratchetfile.py +59 -15
  83. crapkit-0.8.1/src/crapkit/repopath.py +532 -0
  84. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/report.py +28 -9
  85. crapkit-0.8.1/src/crapkit/repotext.py +424 -0
  86. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/resources.py +6 -3
  87. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/sarif.py +20 -3
  88. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/scaffold.py +96 -48
  89. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/score.py +161 -32
  90. crapkit-0.8.1/src/crapkit/sourcelines.py +42 -0
  91. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/store.py +197 -67
  92. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/uncovered.py +78 -51
  93. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/universe.py +95 -30
  94. crapkit-0.8.1/src/crapkit/userhome.py +51 -0
  95. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/verify.py +117 -22
  96. crapkit-0.8.1/src/crapkit/watch.py +223 -0
  97. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/worklist.py +53 -14
  98. crapkit-0.8.1/src/crapkit.egg-info/PKG-INFO +2102 -0
  99. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit.egg-info/SOURCES.txt +23 -0
  100. crapkit-0.8.1/src/crapkit.egg-info/requires.txt +70 -0
  101. crapkit-0.8.0/PKG-INFO +0 -1439
  102. crapkit-0.8.0/README.md +0 -1398
  103. crapkit-0.8.0/pyproject.toml +0 -109
  104. crapkit-0.8.0/src/crapkit/cli/__init__.py +0 -10
  105. crapkit-0.8.0/src/crapkit/cli/_shared.py +0 -394
  106. crapkit-0.8.0/src/crapkit/cli/claude_hook.py +0 -493
  107. crapkit-0.8.0/src/crapkit/coverage_istanbul.py +0 -335
  108. crapkit-0.8.0/src/crapkit/coverage_py.py +0 -287
  109. crapkit-0.8.0/src/crapkit/diffparse.py +0 -97
  110. crapkit-0.8.0/src/crapkit/doctor.py +0 -539
  111. crapkit-0.8.0/src/crapkit/errors.py +0 -30
  112. crapkit-0.8.0/src/crapkit/gitio.py +0 -838
  113. crapkit-0.8.0/src/crapkit/gitpaths.py +0 -39
  114. crapkit-0.8.0/src/crapkit/invocation.py +0 -45
  115. crapkit-0.8.0/src/crapkit/lane_command.py +0 -187
  116. crapkit-0.8.0/src/crapkit/lanes.py +0 -1386
  117. crapkit-0.8.0/src/crapkit/lizardcognitive.py +0 -495
  118. crapkit-0.8.0/src/crapkit/lizardpowershell.py +0 -288
  119. crapkit-0.8.0/src/crapkit/lizardrust.py +0 -137
  120. crapkit-0.8.0/src/crapkit/lizardshell.py +0 -389
  121. crapkit-0.8.0/src/crapkit/mcp_server.py +0 -1778
  122. crapkit-0.8.0/src/crapkit/repotext.py +0 -52
  123. crapkit-0.8.0/src/crapkit/watch.py +0 -112
  124. crapkit-0.8.0/src/crapkit.egg-info/PKG-INFO +0 -1439
  125. crapkit-0.8.0/src/crapkit.egg-info/requires.txt +0 -13
  126. {crapkit-0.8.0 → crapkit-0.8.1}/LICENSE +0 -0
  127. {crapkit-0.8.0 → crapkit-0.8.1}/setup.cfg +0 -0
  128. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/__main__.py +0 -0
  129. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/_pygdefer.py +0 -0
  130. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/_windows_job.py +0 -0
  131. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/cache.py +0 -0
  132. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/coverage_format.py +0 -0
  133. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/lizardpython.py +0 -0
  134. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/locks.py +0 -0
  135. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/logs.py +0 -0
  136. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/records.py +0 -0
  137. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/rootfind.py +0 -0
  138. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/sarifio.py +0 -0
  139. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit/snapshot.py +0 -0
  140. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit.egg-info/dependency_links.txt +0 -0
  141. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit.egg-info/entry_points.txt +0 -0
  142. {crapkit-0.8.0 → crapkit-0.8.1}/src/crapkit.egg-info/top_level.txt +0 -0
crapkit-0.8.1/PKG-INFO ADDED
@@ -0,0 +1,2102 @@
1
+ Metadata-Version: 2.4
2
+ Name: crapkit
3
+ Version: 0.8.1
4
+ Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
5
+ Author: Jean-Francois Gagne
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/JeanFrancoisGagne/crapkit
8
+ Project-URL: Documentation, https://www.jfgagne.com/crapkit/handbook.html
9
+ Project-URL: Changelog, https://github.com/JeanFrancoisGagne/crapkit/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/JeanFrancoisGagne/crapkit/issues
11
+ Project-URL: Source, https://github.com/JeanFrancoisGagne/crapkit
12
+ Keywords: complexity,coverage,crap,code-quality,technical-debt,cyclomatic,ratchet,pre-commit,claude-code,mcp,mutation-testing,static-analysis
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Software Development :: Quality Assurance
25
+ Classifier: Topic :: Software Development :: Testing
26
+ Classifier: Topic :: Software Development :: Version Control :: Git
27
+ Requires-Python: >=3.11
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: lizard<1.25,>=1.24.0
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8; extra == "dev"
33
+ Requires-Dist: pytest-cov>=5; extra == "dev"
34
+ Requires-Dist: pytest-xdist>=3.7; extra == "dev"
35
+ Requires-Dist: coverage>=7.13.1; extra == "dev"
36
+ Requires-Dist: PyYAML>=6; extra == "dev"
37
+ Requires-Dist: Pillow; extra == "dev"
38
+ Provides-Extra: py
39
+ Requires-Dist: pytest-cov>=5; extra == "py"
40
+ Requires-Dist: coverage>=7.13.1; extra == "py"
41
+ Provides-Extra: accuracy-push
42
+ Requires-Dist: hypothesis==6.168.1; extra == "accuracy-push"
43
+ Requires-Dist: radon==6.0.1; extra == "accuracy-push"
44
+ Requires-Dist: mccabe==0.7.0; extra == "accuracy-push"
45
+ Requires-Dist: complexipy==8.0.1; extra == "accuracy-push"
46
+ Requires-Dist: coverage==7.16.1; extra == "accuracy-push"
47
+ Requires-Dist: unidiff==1.0.1; extra == "accuracy-push"
48
+ Requires-Dist: junitparser==5.0.3; extra == "accuracy-push"
49
+ Requires-Dist: numpy==2.4.6; extra == "accuracy-push"
50
+ Requires-Dist: filelock==4.0.3; extra == "accuracy-push"
51
+ Requires-Dist: html5lib==1.1; extra == "accuracy-push"
52
+ Requires-Dist: jsonschema==4.26.0; extra == "accuracy-push"
53
+ Requires-Dist: rfc3986==2.0.0; extra == "accuracy-push"
54
+ Requires-Dist: tree-sitter==0.26.0; extra == "accuracy-push"
55
+ Requires-Dist: tree-sitter-bash==0.25.1; extra == "accuracy-push"
56
+ Requires-Dist: tree-sitter-c==0.24.2; extra == "accuracy-push"
57
+ Requires-Dist: tree-sitter-cpp==0.23.4; extra == "accuracy-push"
58
+ Requires-Dist: tree-sitter-go==0.25.0; extra == "accuracy-push"
59
+ Requires-Dist: tree-sitter-java==0.23.5; extra == "accuracy-push"
60
+ Requires-Dist: tree-sitter-objc==3.0.2; extra == "accuracy-push"
61
+ Requires-Dist: tree-sitter-rust==0.24.2; extra == "accuracy-push"
62
+ Requires-Dist: tree-sitter-swift==0.7.3; extra == "accuracy-push"
63
+ Requires-Dist: tree-sitter-zig==1.1.2; extra == "accuracy-push"
64
+ Provides-Extra: accuracy
65
+ Requires-Dist: hypothesis==6.168.1; extra == "accuracy"
66
+ Requires-Dist: radon==6.0.1; extra == "accuracy"
67
+ Requires-Dist: mccabe==0.7.0; extra == "accuracy"
68
+ Requires-Dist: complexipy==8.0.1; extra == "accuracy"
69
+ Requires-Dist: coverage==7.16.1; extra == "accuracy"
70
+ Requires-Dist: unidiff==1.0.1; extra == "accuracy"
71
+ Requires-Dist: junitparser==5.0.3; extra == "accuracy"
72
+ Requires-Dist: numpy==2.4.6; extra == "accuracy"
73
+ Requires-Dist: filelock==4.0.3; extra == "accuracy"
74
+ Requires-Dist: html5lib==1.1; extra == "accuracy"
75
+ Requires-Dist: jsonschema==4.26.0; extra == "accuracy"
76
+ Requires-Dist: rfc3986==2.0.0; extra == "accuracy"
77
+ Requires-Dist: pylint==4.0.9; extra == "accuracy"
78
+ Requires-Dist: pydriller==2.12; extra == "accuracy"
79
+ Requires-Dist: pygit2==1.20.1; extra == "accuracy"
80
+ Requires-Dist: networkx==3.6.1; extra == "accuracy"
81
+ Requires-Dist: mlxtend==0.25.0; extra == "accuracy"
82
+ Requires-Dist: slipcover==1.1.0; extra == "accuracy"
83
+ Requires-Dist: diff-cover==10.6.0; extra == "accuracy"
84
+ Requires-Dist: pytest-reportlog==1.0.0; extra == "accuracy"
85
+ Requires-Dist: mutmut==3.8.0; extra == "accuracy"
86
+ Requires-Dist: tree-sitter==0.26.0; extra == "accuracy"
87
+ Requires-Dist: tree-sitter-bash==0.25.1; extra == "accuracy"
88
+ Requires-Dist: tree-sitter-c==0.24.2; extra == "accuracy"
89
+ Requires-Dist: tree-sitter-cpp==0.23.4; extra == "accuracy"
90
+ Requires-Dist: tree-sitter-go==0.25.0; extra == "accuracy"
91
+ Requires-Dist: tree-sitter-java==0.23.5; extra == "accuracy"
92
+ Requires-Dist: tree-sitter-objc==3.0.2; extra == "accuracy"
93
+ Requires-Dist: tree-sitter-rust==0.24.2; extra == "accuracy"
94
+ Requires-Dist: tree-sitter-swift==0.7.3; extra == "accuracy"
95
+ Requires-Dist: tree-sitter-zig==1.1.2; extra == "accuracy"
96
+ Dynamic: license-file
97
+
98
+ # crapkit
99
+
100
+ <!-- mcp-name: io.github.JeanFrancoisGagne/crapkit -->
101
+
102
+ [![ci](https://github.com/JeanFrancoisGagne/crapkit/actions/workflows/ci.yml/badge.svg)](https://github.com/JeanFrancoisGagne/crapkit/actions/workflows/ci.yml)
103
+ [![PyPI](https://img.shields.io/pypi/v/crapkit)](https://pypi.org/project/crapkit/)
104
+ [![Python](https://img.shields.io/pypi/pyversions/crapkit)](https://pypi.org/project/crapkit/)
105
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/JeanFrancoisGagne/crapkit/blob/main/LICENSE)
106
+ [![crapkit MCP server](https://glama.ai/mcp/servers/JeanFrancoisGagne/crapkit/badges/score.svg)](https://glama.ai/mcp/servers/JeanFrancoisGagne/crapkit)
107
+
108
+ ![crapkit init, coverage and worklist --top 5 on a small Python repo, then a shell heredoc adding a function at ccn 7: the per-edit advisory reports it and exits 2, and the commit gate refuses the staged file with exit 6](https://raw.githubusercontent.com/JeanFrancoisGagne/crapkit/main/docs/demo.gif)
109
+
110
+ crapkit scores every function in your repo on complexity times uncovered risk, ranks the
111
+ worst ones by how often the file changes, and blocks commits that add more. It reads
112
+ Python, TypeScript, TSX, JavaScript, Swift, Go, Rust, shell, PowerShell, C and C++,
113
+ Objective-C, Vue, Java and Zig through [lizard](https://github.com/terryyin/lizard), and
114
+ joins per-function branch coverage from the istanbul or coverage.py artifact your own test
115
+ command already writes. JSON commands use sorted keys and a versioned schema for
116
+ scripts, coding agents and the optional MCP server.
117
+
118
+ ```
119
+ CRAP = ccn^2 * (1 - cov)^3 + ccn
120
+ ```
121
+
122
+ The name is not ours: C.R.A.P. (Change Risk Anti-Patterns) was coined for crap4j by
123
+ Alberto Savoia and Bob Evans in 2007.
124
+
125
+ `ccn` is the smaller of standard and modified cyclomatic complexity, both read off one
126
+ lizard pass. `cov` is branch coverage inside the function's span; with no branches it
127
+ falls back to statement coverage, and with no statements to invoked-or-not, so a
128
+ half-executed straight-line function never reads as fully covered.
129
+
130
+ **Above the ceiling, coverage cannot save you. Decompose.** At the default ceiling of 6, a
131
+ function at ccn 7 with 100% coverage still scores 7 and still fails the gate. The only
132
+ move that clears it is splitting the function.
133
+
134
+ **Why 6 and not 30.** crap4j's conventional threshold of 30 is a CRAP score: it lets an
135
+ untested `ccn 5` through (25 + 5 = 30) and a fully covered `ccn 30` too. crapkit's default
136
+ is a complexity ceiling, because coverage can at best collapse CRAP to `ccn`, and a
137
+ function you cannot cover past `ccn 6` is one you decompose. Set `target = 30` in
138
+ `crapkit.toml` if you want the crap4j number. A repo with existing debt does not need to:
139
+ `ratchet seed` marks today's over-ceiling functions at today's score, the gate then judges
140
+ only the functions a change touches, and marks may only fall, so adoption never starts with
141
+ a wall of red. Next to crap4py, radon, xenon, wily and SonarQube:
142
+ [docs/comparison.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/comparison.md).
143
+
144
+ crapkit scores **git-tracked files only**. Source you have not `git add`ed is invisible to
145
+ it; `init` and `verify` name the first three such files they find.
146
+
147
+ | Start with | When |
148
+ |---|---|
149
+ | [Install](#install) and [the 60-second start](#the-60-second-start) | You want the first score in an existing Git repository. |
150
+ | [Python](#quickstart-python) or [TypeScript](#quickstart-typescript) quickstart | You want a worked example from setup through a passing verify. |
151
+ | [Adoption](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adoption.md) | You need to choose scopes, wire tests or introduce a ratchet to existing debt. |
152
+ | [Upgrading](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md) | You already have saved runs, ratchet marks or an installed plugin. |
153
+ | [Subcommands](#subcommands) and [JSON/MCP reference](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md) | You are scripting commands or connecting a coding agent. |
154
+
155
+ ---
156
+
157
+ ## The 60-second start
158
+
159
+ ```
160
+ pip install crapkit
161
+ cd your-repo
162
+ crapkit init # write crapkit.toml and ignore measurement output
163
+ crapkit doctor # check scopes, test commands and coverage dependencies
164
+ crapkit coverage # runs the lane, joins coverage, stores a scored run
165
+ crapkit worklist # the ranked risk map
166
+ crapkit ratchet seed
167
+ git add crapkit.toml crapkit-ratchet.tsv .gitignore
168
+ git commit -m "adopt crapkit"
169
+ crapkit verify # the first passing verdict
170
+ ```
171
+
172
+ Not a Python repo? `uvx crapkit init` runs the same commands and adds nothing to your
173
+ manifest: see [A repo that is not Python](#a-repo-that-is-not-python). A Python repo
174
+ skips uvx and installs crapkit as above, or with `uv tool install` or `pipx install`; that
175
+ section says why. pip stops with `externally-managed-environment`? See
176
+ [When pip refuses](#when-pip-refuses).
177
+
178
+ `coverage` measures a commit, so a fresh `git init` repo needs its first commit before
179
+ it. Until then it exits 4 and says so:
180
+
181
+ ```
182
+ $ crapkit coverage
183
+ crapkit: the git repository at /repo has no commit yet: crapkit measures a commit, so make the first one (git add, then git commit) and run it again
184
+ ```
185
+
186
+ `init` detects pytest, Vitest and Jest from the repository's own files. Review the
187
+ generated config before running its commands. When detection leaves a commented
188
+ lane, fill it in using the [lane recipes](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md).
189
+ Commit the adoption files, then run `crapkit verify` to establish a passing verdict.
190
+ Install the [commit gate](#the-gate) when the config and ratchet are ready.
191
+ Each step prints the next one: `init` names `coverage`, `coverage` names `worklist`,
192
+ `worklist` names `ratchet seed`, and `ratchet seed` names the commit and `verify`.
193
+
194
+ `coverage` scores, `worklist` ranks:
195
+
196
+ ```
197
+ $ crapkit coverage
198
+ run 1 @ fae4db93108: 2 functions scored: 2 measured, 1 over ceiling 6, CRAP load 41.0, grade F
199
+ -> next: crapkit worklist
200
+
201
+ $ crapkit worklist
202
+ worklist @ fae4db93108 (run 1, floor ccn>=5, churn 12mo) - 1 of 1 active (worklist_top 50), 0 dormant
203
+ risk 14.0 ccn 14 crap 38.5 cov 50% 1c/1a calc/grade.py:7 classify( score , attempts , late , bonus )
204
+ no crapkit-ratchet.tsv yet: seed marks each function over its ceiling at today's score, and from then on a mark may only fall
205
+ -> next: crapkit ratchet seed
206
+ ```
207
+
208
+ `risk 14.0` is ccn times a churn weight of one: a one-commit repo has no spread of commits
209
+ to weight, so each commit counts once and the ranking is complexity order until the
210
+ history grows ([Risk](#risk-what-ranks-the-worklist)). `crap 38.5` and `cov 50%` are the
211
+ score and the coverage behind it.
212
+
213
+ `ratchet seed` signs today's debt at today's score. From then on marks only ever fall, so
214
+ the repo can get better and never worse while you burn it down.
215
+
216
+ **One thing stops most first runs: the coverage plugin.** `init` writes a lane that shells
217
+ out to your own test runner, and the runner needs its coverage package installed:
218
+ `pytest-cov` for pytest, `@vitest/coverage-v8` (pinned to your vitest major) for vitest.
219
+ Without it the lane produces no artifact and `coverage` exits 5 quoting the runner's own
220
+ error. A pytest lane also needs coverage.py 7.13.1 or newer, which writes the function
221
+ start lines crapkit reads; an older one fails the lane at exit 5. For pytest, `init` probes the python its lane will run and prints the install
222
+ command when `pytest_cov` is missing; `pip install "crapkit[py]"` pulls the plugin
223
+ alongside crapkit when the two share a venv. On a Windows PATH holding only the `py`
224
+ launcher it writes `py`, not a `python3` the lane could never run, and when cmd.exe cannot
225
+ start the interpreter at all (exit 9009, the Store alias) it names that instead of guessing
226
+ at pytest-cov. A repo that pins no lockfile and carries its own `.venv` gets that venv's
227
+ interpreter in the lane, when that interpreter can import pytest, rather than whichever
228
+ python the shell answers with. The two quickstarts below walk a real repo end to end.
229
+
230
+ **On Windows a lane command is read by cmd.exe**, the shell that will run it, not by sh.
231
+ Double quotes are the portable quoting. A single-quoted value is refused at config load
232
+ with exit 3, because cmd.exe would hand pytest five words and the lane would write no
233
+ artifact:
234
+
235
+ ```
236
+ # the lane in crapkit.toml
237
+ command = "python -m pytest -m 'not live and not perf' --cov=calc --cov-branch --cov-report=json:.crapkit/cov/py.json"
238
+
239
+ $ crapkit doctor
240
+ crapkit: lane 'py': positional argument 'live' narrows a full-suite coverage run; drop it, attach it to the flag it belongs to (-n8, --numprocesses=8), or set full_suite = false deliberately (cmd.exe does not treat ' as a quote: write the value in double quotes); a suite whose testpaths cannot be collected in one process needs one lane per testpath, each with full_suite = false and its own artifact
241
+ ```
242
+
243
+ Write it `-m "not live and not perf"`. Carets, `&&` and `|` segments, redirections and
244
+ empty quoted arguments all read the way the shell reads them, so a chained lane
245
+ (`cd tests && python -m pytest --cov ...`) is checked one segment at a time. `doctor` reads
246
+ a lane the same way, and FAILs one whose runner will not start.
247
+
248
+ ## Install
249
+
250
+ ```
251
+ pip install crapkit
252
+ ```
253
+
254
+ That is the release on [PyPI](https://pypi.org/project/crapkit/). For the unreleased tip
255
+ of `main`, or from a local clone (run at the clone root):
256
+
257
+ ```
258
+ pip install git+https://github.com/JeanFrancoisGagne/crapkit.git
259
+ pip install .
260
+ ```
261
+
262
+ Commits on `main` between two releases carry the same version string, and pip keeps an
263
+ installed crapkit whose version matches, so running the git line again after a new
264
+ commit leaves the old code in place. This line replaces it with the tip:
265
+
266
+ ```
267
+ pip install --force-reinstall --no-deps git+https://github.com/JeanFrancoisGagne/crapkit.git
268
+ ```
269
+
270
+ `pip install .` in a clone rebuilds on every run and needs no flag.
271
+
272
+ ### When pip refuses
273
+
274
+ Debian 12, Ubuntu 23.04 and later, Homebrew and uv mark the Python they install as
275
+ theirs (PEP 668), and pip refuses to write into it with
276
+ `error: externally-managed-environment`:
277
+
278
+ ```
279
+ $ pip install crapkit
280
+ error: externally-managed-environment
281
+ ```
282
+
283
+ Give crapkit an environment of its own. Either line installs it into one and puts a
284
+ `crapkit` command in `~/.local/bin` (`%USERPROFILE%\.local\bin` on Windows), the
285
+ command the agent plugins start:
286
+
287
+ ```
288
+ pipx install crapkit
289
+ uv tool install crapkit
290
+ ```
291
+
292
+ When that directory is not on PATH yet, the installer says so and names the fix:
293
+ `pipx ensurepath`, or `uv tool update-shell`. Open a new shell after either. The
294
+ `python` on PATH does not hold this install, and the [commit hook](#the-gate) runs the
295
+ `crapkit` command before it tries `python`, so it reaches either install as printed.
296
+
297
+ A Python repo can carry crapkit in its own venv instead, beside the test dependencies
298
+ its lane runs:
299
+
300
+ ```sh
301
+ python3 -m venv .venv
302
+ . .venv/bin/activate
303
+ pip install "crapkit[py]"
304
+ ```
305
+
306
+ The same from cmd.exe on Windows:
307
+
308
+ ```bat
309
+ python -m venv .venv
310
+ .venv\Scripts\activate
311
+ pip install "crapkit[py]"
312
+ ```
313
+
314
+ `--break-system-packages` makes pip write into the system Python anyway, where the next
315
+ OS package update can break crapkit or the OS's own tools. None of the routes above
316
+ needs it.
317
+
318
+ ### A repo that is not Python
319
+
320
+ crapkit is a command-line tool, never a dependency of the code it scores. A TypeScript,
321
+ Go or Rust repo adds nothing to its own manifest. With [uv](https://docs.astral.sh/uv/)
322
+ on the machine, `uvx` fetches crapkit into a cache of its own and runs it:
323
+
324
+ ```
325
+ $ uvx crapkit init
326
+ wrote crapkit.toml with 1 scope(s): src
327
+ detected 1 lane(s) from this repo's own files: js - next: run `uvx crapkit coverage`
328
+ added to .gitignore: .crapkit/
329
+
330
+ $ uvx crapkit coverage
331
+ $ uvx crapkit worklist
332
+ ```
333
+
334
+ uvx puts no `crapkit` on PATH, so every next step crapkit prints under uvx starts with
335
+ `uvx crapkit`. The lane still runs your own test runner, so Vitest or Jest and its coverage
336
+ package come from the repo's `node_modules` as they do today. `uv tool install crapkit` or
337
+ `pipx install crapkit` puts a `crapkit` command on PATH once, which is what the
338
+ [commit gate](#the-gate) and the Claude Code plugin call. The ratchet's merge driver takes
339
+ either form; [the git merge driver](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md#the-git-merge-driver)
340
+ gives the uvx line. uv brings its own Python when the machine has none.
341
+
342
+ A Python repo installs crapkit instead: in the environment its suite runs in
343
+ (`pip install "crapkit[py]"`), or with `uv tool install crapkit` or `pipx install crapkit`.
344
+ uvx puts its own environment first on the PATH the lane inherits, so the lane's `python`
345
+ starts uv's cached interpreter, which holds neither the suite's packages nor pytest-cov.
346
+ `init` then says that interpreter cannot import pytest_cov, and installing pytest-cov into
347
+ uv's cache fixes nothing the suite needs.
348
+
349
+ Requires Python 3.11 or newer and Git on PATH. On an older Python pip stops with
350
+ `No matching distribution found for crapkit`, and `uvx crapkit` runs crapkit on a Python
351
+ 3.11 or newer that uv finds or downloads. The CLI has one runtime dependency,
352
+ `lizard>=1.24.0,<1.25`; a package mirror needs both distributions. Install into the environment
353
+ you intend to use, then check `crapkit --version`. The `pip install -e ".[dev,accuracy-push]"` under
354
+ [Development](#development) is a different thing: it adds the test extras, for people
355
+ changing crapkit.
356
+
357
+ `pip install --user crapkit` puts the `crapkit` command in your user scripts directory:
358
+ `~/.local/bin` on Linux, `~/Library/Python/3.12/bin` for a python.org Python 3.12 on
359
+ macOS, `%APPDATA%\Python\Python312\Scripts` for Python 3.12 on Windows. When that
360
+ directory is not on PATH, pip ends its install with `WARNING: The script crapkit is
361
+ installed in '...' which is not on PATH`. Add the directory it names to PATH and open a
362
+ new shell. Until then the shell answers `crapkit` with `command not found` (`is not
363
+ recognized` on Windows), and the Claude Code plugin lists its server as
364
+ `Failed to connect`.
365
+
366
+ Python projects can install `pip install "crapkit[py]"` in their test environment to
367
+ include pytest-cov and subprocess-capable coverage.py. A separate tool installation
368
+ still needs the coverage plugin in the environment that runs the suite.
369
+
370
+ Analysis and scoring run locally and send no telemetry. Configured lane, mutation
371
+ and alert commands run with your permissions and can contact services or change
372
+ files. Review those commands before running crapkit in a repository you do not trust
373
+ ([SECURITY.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/SECURITY.md)).
374
+
375
+ ```
376
+ $ crapkit --version
377
+ crapkit 0.8.1
378
+ ```
379
+
380
+ `python -m crapkit` works identically to the console script and is what to use from a
381
+ source checkout. A next step crapkit prints names `crapkit` when PATH finds this
382
+ installation's console script, and otherwise the interpreter running it, spelled with
383
+ forward slashes (`C:/venv/Scripts/python.exe -m crapkit coverage`) so Git Bash, cmd.exe
384
+ and PowerShell all run it as printed. A segment that holds a space is quoted on its own
385
+ (`C:/"Program Files"/Python312/python.exe -m crapkit coverage`), so the line never opens
386
+ with a quote and runs in PowerShell too. One case loses cmd.exe: a venv's `python.exe` in
387
+ a spaced directory with no 8.3 short name, where you put the whole path in double quotes
388
+ by hand ([docs/adr/0003](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adr/0003-a-pasted-command-never-opens-with-a-quote.md)).
389
+ Every subcommand accepts `--repo PATH` (default: the nearest `crapkit.toml`
390
+ at or above the current directory, so a monorepo workspace finds the root's), and with it
391
+ you never have to `cd` into the repo you are scoring; [Subcommands](#subcommands) shows
392
+ where the flag goes. A leading `~` in PATH is your home directory, also in cmd.exe and in
393
+ an MCP client's `args`, where no shell expands it.
394
+
395
+ ## Upgrading
396
+
397
+ Keep the CLI and plugin versions aligned, measure fresh coverage after upgrading,
398
+ and review any ratchet identity refusal before reseeding. The current reader is
399
+ analysis version 13. It reads a coverage.py function's span from the `start_line`
400
+ coverage.py 7.13.1 and newer write, and refuses a report without one. It scores a source
401
+ saved as UTF-16 and keys an identifier that holds one of the five bytes cp1252 leaves
402
+ undefined by its name. It moves numbers in every language but Python and some of
403
+ Python's `cognitive` and `nesting`, puts JavaScript and TypeScript coverage on the function
404
+ that owns it, and computes CRAP alike on every platform. So every marks file re-seeds once. Version 11, in 0.8.0, renamed once each Python def with a PEP 695
405
+ type parameter list and each def nested three or more deep, and listed a def whose body
406
+ sits on its colon line; `crapkit ratchet prune` drops the marks left under the old names.
407
+ Older JavaScript and TypeScript callback marks can require a reviewed mapping.
408
+ Follow the [upgrade guide](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md)
409
+ for the upgrade line each installer takes (pip, pip --user, pipx, uv tool, uvx and a git
410
+ install), saved state, portable records and Windows launcher locks.
411
+ [CHANGELOG.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/CHANGELOG.md) says
412
+ what each release changed.
413
+
414
+ ### Upgrading from 0.8.0 to 0.8.1
415
+
416
+ 1. Install coverage.py 7.13.1 or newer where each Python lane runs:
417
+ `pip install -U "coverage>=7.13.1"`, or `pip install -U "crapkit[py]"` when crapkit
418
+ shares the suite's venv.
419
+ 2. Upgrade crapkit in every clone, then re-seed each repo once: `crapkit coverage`,
420
+ `crapkit ratchet prune`, `crapkit ratchet seed`. When a failed verify pins the baseline,
421
+ pass `--baseline N` to prune and to seed.
422
+ 3. Commit the re-seed together with every pin that runs crapkit: the CI install pin, the
423
+ Action's `uses:` pin and the pre-commit `rev`. 0.8.1's `verify` refuses marks stamped
424
+ with analysis version 11, and 0.8.0's refuses marks stamped 13.
425
+ 4. Keep `{python}` and `{python:DIR}` out of a committed `crapkit.toml` until every clone
426
+ runs 0.8.1: 0.8.0 hands the token to the shell as written, and the lane fails.
427
+
428
+ Until the re-seed, `crapkit verify` refuses the marks 0.8.0 stamped, at exit 3:
429
+
430
+ crapkit: ratchet marks were recorded under [crapkit-analysis=11 lizard=1.24.0] but this run measures [crapkit-analysis=13 lizard=1.24.0] - CRAP scores are not comparable across metric versions; run `crapkit coverage`, then `crapkit ratchet prune`, then re-baseline with `crapkit ratchet seed`
431
+
432
+ The upgrade guide lists [every step in
433
+ order](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md#080-to-081-in-order),
434
+ and [analysis version
435
+ 13](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md#analysis-version-13)
436
+ says what moves. A metric-stamp refusal from an older upgrade is quoted under [analysis
437
+ version 8](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md#analysis-version-8).
438
+
439
+ ### The exe lock on Windows
440
+
441
+ A running `crapkit.exe mcp` holds its launcher open, and each installer meets that lock
442
+ its own way. From pip 23.3 on, pip succeeds: it moves the busy `crapkit.exe` aside and
443
+ installs the new release, and the running server keeps serving the old code until you
444
+ restart its client. pipx does the same when it installs through pip. An older pip, such
445
+ as the 22.3.1 a Python 3.11.2 venv ships, fails with `ERROR: Could not install packages
446
+ due to an OSError: [WinError 5] Access is denied` and puts the old release back: run
447
+ `python -m pip install --upgrade pip` first, or stop the server as for uv. `uv tool
448
+ upgrade crapkit` fails with `os error 32`. `uv tool install crapkit@latest`, and `pipx
449
+ upgrade crapkit` when pipx installs through uv, fail with `Access is denied. (os error
450
+ 5)`; after that `uv tool install` the `crapkit` command fails with `ModuleNotFoundError:
451
+ No module named 'crapkit'` until the install runs again. After any of those failures,
452
+ stop the server or its agent session, rerun the same command, then restart the client. The
453
+ [Windows upgrade procedure](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md#windows-launcher-locks)
454
+ lists what each installer printed.
455
+
456
+ ### Removing crapkit
457
+
458
+ Take the commit hook and the merge driver out before the package: both call crapkit.
459
+ After `pip uninstall crapkit` alone, the sh hook Route 1 and Route 2 write keeps judging
460
+ every commit through `uvx crapkit` on a machine with uv, with whichever crapkit release uv
461
+ has cached or can download, and on a machine without uv stops every commit on
462
+ `No module named crapkit`, or on `exec: python: not found` where there is no `python` at
463
+ all, as on a Debian, Ubuntu or macOS that has only `python3`. The PowerShell hook Route 1 and the handbook write names the launcher
464
+ that `pip uninstall crapkit` deletes, so it stops every commit on
465
+ `No such file or directory`, with uv or without it.
466
+ Every merge that touches `crapkit-ratchet.tsv` conflicts after the driver's
467
+ `crapkit: not found`. The upgrade guide's
468
+ [Removing crapkit](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md#removing-crapkit)
469
+ lists each piece, in a repo and on the machine.
470
+
471
+ ## The Claude Code plugin
472
+
473
+ ```
474
+ claude plugin marketplace add JeanFrancoisGagne/crapkit --sparse .claude-plugin plugin
475
+ claude plugin install crapkit@crapkit
476
+ ```
477
+
478
+ Two commands, installed once per user, and every repo on the machine gets it. The plugin
479
+ ships three skills, the read-side MCP server, and one advisory PostToolUse hook that names
480
+ any function an edit pushed over its ceiling. Claude reaches two of the skills by itself,
481
+ `crapkit` and `crapkit-recover`; the third you type, as `/crapkit:crapkit-onboard`, because
482
+ wiring a repo up happens once and its description has no business in every turn's window.
483
+ It adds no files to your repo, and it needs the crapkit CLI on PATH.
484
+
485
+ The hook is one shell command, `crapkit claude-hook --protocol 1`, so it runs as written in
486
+ any Claude Code version with plugin support, and in the other agents that load Claude Code
487
+ plugins: Cursor (which imports them), GitHub Copilot CLI and VS Code. Claude Code gets the
488
+ advisory on stderr with exit 2 and wakes the model with it. Those three read exit 2 as a
489
+ deny, a message for the user alone, or a blocking error, so there the hook exits 0 and
490
+ hands the model the same lines as added context
491
+ ([other harnesses](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#other-harnesses)).
492
+
493
+ `--sparse .claude-plugin plugin` checks out the two small directories the plugin ships
494
+ from. Without it Claude Code clones the whole repository under a 120-second clone
495
+ timeout, which one measured add ran out of. A marketplace added without `--sparse`
496
+ keeps that full clone; `claude plugin marketplace remove crapkit` drops it and uninstalls
497
+ the plugin, and the two lines above put both back.
498
+
499
+ A repo with no `crapkit.toml` costs a silent no-op per edit: 68 ms on Windows through the
500
+ `crapkit.exe` launcher, where a bare `python -c pass` took 32 ms on the same machine. Every
501
+ edit starts it, whatever the file type; an edit to a type crapkit does not measure stops at
502
+ a suffix check before any config is read. On Windows, Claude Code starts the hook through
503
+ Git Bash, whose own startup comes on top, and runs it async, so no edit waits for it. After
504
+ upgrading the CLI, refresh the marketplace before updating the installed plugin:
505
+
506
+ ```
507
+ claude plugin marketplace update crapkit
508
+ claude plugin update crapkit@crapkit --scope user
509
+ crapkit doctor --plugin-root
510
+ ```
511
+
512
+ The installed plugin moves only at a release. `claude plugin update` compares version
513
+ strings, and main carries the last release's version until the next one, so between
514
+ releases it reports the plugin up to date. Restart existing Claude Code sessions to apply
515
+ the plugin update. The check above compares installed files with the CLI on PATH; it does
516
+ not reload a running session. When they disagree it prints the commands for the side that
517
+ is behind, and when `claude plugin update` answered "already at the latest version" over
518
+ files main has moved past, it prints the uninstall and install lines that replace them. The update above is for a user-scope
519
+ install. For one made with `--scope project` or `--scope local`, doctor names that scope and
520
+ the project directory to run it in; for a plugin installed from a marketplace you added as a
521
+ local directory, which Claude Code loads in place, it names `git -C <that directory> pull`.
522
+
523
+ A headless `claude -p` has no one to grant a tool call, so it refuses every crapkit call
524
+ and the model reads `Claude requested permissions to use ..., but you haven't granted it
525
+ yet.` Pass `--allowedTools mcp__plugin_crapkit_crapkit` for the plugin's server, or
526
+ `--allowedTools mcp__crapkit` for one you added yourself.
527
+
528
+ The hook registers on `Edit|Write`, which is every write that names a file. An agent that
529
+ writes its source through a shell heredoc names none, so a `Bash` event is judged off the
530
+ working tree instead. That half is yours to register, because it costs two
531
+ git spawns per shell call. Add a second PostToolUse entry to your own settings, same
532
+ command, matcher `Bash`:
533
+
534
+ ```json
535
+ {
536
+ "hooks": {
537
+ "PostToolUse": [
538
+ {
539
+ "matcher": "Bash",
540
+ "hooks": [
541
+ { "type": "command", "command": "crapkit claude-hook --protocol 1", "timeout": 20 }
542
+ ]
543
+ }
544
+ ]
545
+ }
546
+ }
547
+ ```
548
+
549
+ The cost is one `git rev-parse --show-toplevel` and one `git status --porcelain -z -uall`
550
+ per shell call in any git repo, whether or not crapkit measures it: about 50 ms together
551
+ on crapkit's own checkout on Windows, and more on a bigger tree. What comes back is the dirty or
552
+ untracked `*.py` files written in the last 12 seconds, 25 at most, each judged the way an
553
+ edit is. Python only, so a TypeScript or Go repo pays the two spawns and hears nothing.
554
+
555
+ The hook remembers, per Claude Code session, the bytes each judgement read: one small
556
+ record per file under `.git/crapkit/claude-hook/<session_id>/`, and a session idle for 7
557
+ days is pruned when another one starts. A recent file whose bytes match its record is
558
+ skipped, so a `touch`, a rewrite of the same bytes, or a test run right after an `Edit`
559
+ does not repeat an advisory the session already heard. Content that arrives with an old
560
+ mtime is never judged by the `Bash` half: a file moved or copied in (`mv`, `cp -p`, an
561
+ unpacked archive), or one a long command wrote well before it returned. The commit gate
562
+ still judges those.
563
+
564
+ ### Codex
565
+
566
+ Codex 0.131.0 or newer installs the same marketplace's plugin through its own manager;
567
+ 0.130.0 has no `codex plugin add`:
568
+
569
+ ```
570
+ codex plugin marketplace add https://github.com/JeanFrancoisGagne/crapkit.git --ref v0.8.1 --sparse .claude-plugin --sparse plugin
571
+ codex plugin add crapkit@crapkit
572
+ ```
573
+
574
+ Use the three skills and MCP server in Codex. Codex loads no crapkit hook: the plugin's
575
+ Codex manifest leaves hooks out, because Codex reports an edit as `apply_patch` patch text,
576
+ which the advisory does not read. Codex offers `crapkit` and `crapkit-recover` to the model
577
+ by itself; `crapkit-onboard` stays out of the model's list until you type
578
+ `$crapkit:crapkit-onboard`, as Claude Code waits for `/crapkit:crapkit-onboard`. A plugin
579
+ from 0.8.0 or earlier ships no Codex manifest: Codex 0.156.1 lists its hooks as untrusted
580
+ PostToolUse hooks that run a bare `crapkit`, and they should stay untrusted.
581
+
582
+ `--ref` pins the marketplace to this release's tag, and crapkit's release step rewrites
583
+ it to the tag it cuts. Codex 0.156.1 checks every Git marketplace each time it starts
584
+ and reinstalls the marketplace's plugins when it moved. Added without a ref, the marketplace
585
+ follows main, and a push to main moves the plugin past the CLI you installed from PyPI
586
+ with no command from you. `--sparse` fetches the two small directories the plugin ships
587
+ from rather than the whole repository.
588
+
589
+ A marketplace added at a tag stays there: `codex plugin marketplace upgrade` keeps it
590
+ at that tag. After upgrading the CLI, remove the marketplace, add it at the new tag,
591
+ and install the plugin again. The listing's `--json` needs Codex 0.137.0; drop it on an
592
+ older Codex.
593
+
594
+ ```
595
+ codex plugin marketplace remove crapkit
596
+ codex plugin marketplace add https://github.com/JeanFrancoisGagne/crapkit.git --ref v0.8.1 --sparse .claude-plugin --sparse plugin
597
+ codex plugin add crapkit@crapkit
598
+ codex plugin list --marketplace crapkit --json
599
+ ```
600
+
601
+ The installed plugin moves only at a release. Removing the marketplace keeps it, and it
602
+ stays at the old version until `codex plugin add` installs the new one. The same four
603
+ lines move a marketplace added without `--ref` onto the tag.
604
+
605
+ Check the installed Codex plugin with `crapkit doctor --plugin-root`: with no PATH it
606
+ reads Codex's plugin cache when Claude Code has no install, and PATH names one install
607
+ under `~/.codex/plugins/cache/crapkit/crapkit/`. A gap there names these refresh lines,
608
+ never a `claude` command.
609
+ See [plugin upgrades](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md#plugin-and-mcp-clients)
610
+ for choosing that path and starting a fresh MCP session. A runtime with a skills
611
+ directory but no compatible marketplace can copy `plugin/skills/*` into it instead:
612
+ `~/.claude/skills` for Claude Code, `$CODEX_HOME/skills` (`~/.codex/skills` by default)
613
+ for Codex, `~/.gemini/skills` for Gemini CLI. Gemini CLI lists `crapkit-onboard` to its
614
+ model with the other two; `gemini skills disable crapkit-onboard --scope user` takes it out
615
+ once the repo is adopted.
616
+
617
+ ### Other agents
618
+
619
+ Every other agent starts `crapkit mcp` from its own config file, and each one reads its own
620
+ key and fields. The `mcpServers` block Cursor takes starts nothing in OpenCode, Amp or VS
621
+ Code's `.vscode/mcp.json`, and none of them says so; in Gemini CLI it connects, but a
622
+ headless `gemini -p` offers the model none of the tools without `"trust": true`.
623
+ [Wiring crapkit into your agent](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/harnesses.md)
624
+ gives the block for each of 27 agents, Gemini CLI, Goose, Zed and Continue among them, with
625
+ the file it goes in and where the agent starts the server.
626
+
627
+ ## Languages
628
+
629
+ 14 languages, two coverage parsers. Coverage joins where a parser exists; everything else
630
+ scores on complexity alone.
631
+
632
+ | Language | Files | Coverage |
633
+ |---|---|---|
634
+ | Python | `.py` | coverage.py |
635
+ | TypeScript | `.ts` | istanbul |
636
+ | TSX | `.tsx` | istanbul |
637
+ | JavaScript | `.js` `.jsx` `.mjs` `.cjs` | istanbul |
638
+ | Vue | `.vue` | istanbul, when your vitest run reports on `.vue` files |
639
+ | Swift | `.swift` | none: cc-only |
640
+ | Go | `.go` | none: cc-only |
641
+ | Rust | `.rs` | none: cc-only |
642
+ | shell | `.sh` `.bash` | none: cc-only |
643
+ | PowerShell | `.ps1` `.psm1` | none: cc-only |
644
+ | C and C++ | `.c` `.cc` `.cpp` `.cxx` `.h` `.hpp` | none: cc-only |
645
+ | Objective-C | `.m` `.mm` | none: cc-only |
646
+ | Java | `.java` | none: cc-only |
647
+ | Zig | `.zig` | none: cc-only |
648
+
649
+ A cc-only scope declares `coverage_optional = true`, scores `crap = ccn`, and needs no
650
+ lane. Nothing about it is provisional: the ceiling still binds and the gate still refuses
651
+ a function over it. Add a coverage lane the day a parser exists and the same scope starts
652
+ joining coverage.
653
+
654
+ `crapkit init` writes that key itself, on every scope whose languages all lack a parser,
655
+ and leaves it off any scope a lane could still measure. So the 60-second start above runs
656
+ unchanged on a Go, Rust or shell repo: `crapkit coverage` scores it with no lane at all,
657
+ and that run is the baseline `worklist`, `next-item`, `ratchet seed` and `verify` read.
658
+
659
+ Shell, PowerShell and Rust run on crapkit's own readers. lizard ships none for shell or
660
+ PowerShell, so crapkit counts their functions itself, and reads the command inside a
661
+ quoted `"$(...)"` as code: its `&&`, `||`, `-and` and `-or` count as they do written
662
+ bare. lizard's Rust reader scores a 7-arm `match` as ccn 2 (filed as lizard #494), so crapkit counts each non-wildcard arm like a C
663
+ `case`. It also reads a Rust signature, a closure's empty `||`, a let-else and a `for`
664
+ that is no loop the way Rust means them (see
665
+ [per-language
666
+ gotchas](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md#per-language-gotchas)),
667
+ and retires each correction the day upstream fixes it. It lists `#[inline] fn f() {`
668
+ written on one line, which lizard took for a C preprocessor line. The cognitive column charges a
669
+ `match` once, the way Sonar charges a `switch`. The Rust, shell and PowerShell readers count each `match`, `case` or `switch` arm in the modified column too, so both columns agree and the arms are gated: a seven-arm `match` gates at `ccn` 8, where lizard's modified count, and so the gated `ccn`, of a C `switch` with seven cases is 2.
670
+
671
+ Go and Zig read through crapkit's subclasses of lizard's readers, which end a signature
672
+ where the language does. A function type such as `var cb func(int) error` opens no
673
+ function, a result type's braces are not the body, and a parameter of function type counts
674
+ once.
675
+
676
+ Python and Swift read through crapkit's subclasses of lizard's readers too. lizard's Python
677
+ reader ends a def at the first `)` of a signature that runs past it, so crapkit reads the
678
+ signature to the body's colon. lizard's Swift reader takes `super.init(...)`, `r.get()` and
679
+ `Socket(protocol: p)` for declarations and `#fileID` for the start of a preprocessor line,
680
+ which hid the functions after them, and lists no function named by a raw identifier with a
681
+ space in it (``func `keeps onboarding if offline`()``), so crapkit reads each one as the
682
+ name or literal it is. It also reads the expression in a string's `\( )` as code, which
683
+ lizard read as part of the string.
684
+
685
+ C, C++, Objective-C and Java run on lizard's readers with crapkit's fixes on top. lizard
686
+ hid every function after some constructs, named rows after an attribute or a macro, and
687
+ left unnamed and array parameters out of `params`; the
688
+ [per-language notes](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md#per-language-gotchas)
689
+ list what crapkit reads differently.
690
+
691
+ Expression arrows in arrays and argument lists are measured separately. In TypeScript,
692
+ wrap an arrow body in parentheses when it contains `<` before a comma, such as
693
+ `x => (pair<T,U>(x))` or `x => (x < 0)`. Without that delimiter, analysis refuses
694
+ the file because this reader cannot distinguish type arguments from an expression
695
+ separator. A run scores the refused file as zero functions and names it, and every gate
696
+ refuses it once a change touches it ([The gate](#the-gate)). Generic arrow parameter
697
+ declarations remain supported.
698
+
699
+ Functions on the same line have separate occurrence identifiers. Existing ratchet
700
+ marks with ambiguous old identities require a reviewed mapping; see
701
+ [same-line function identity](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md#same-line-function-identity).
702
+
703
+ ## The gate
704
+
705
+ Use the advisory while editing, the gate when committing, and `verify` for the
706
+ full verdict. The preview and hooks differ in what their available evidence can prove:
707
+
708
+ | Surface | Fires | Power |
709
+ |---|---|---|
710
+ | `crapkit claude-hook` | after an agent's edit lands | **advisory.** Names the breach to the agent: on stderr in Claude Code, as added context in Cursor, Copilot CLI and VS Code. Blocks nothing, because PostToolUse runs after the write |
711
+ | `crapkit rescore FILE --gate` | when you ask, after the first coverage run | **preview.** A stricter preview of the commit gate, sub-second, before you stage: a ratchet mark pardons a function only while its CRAP is at or under the mark. With no run behind it, exit 1 and `no snapshot` |
712
+ | `crapkit hook-precommit` | `git commit`, or by hand | **blocks.** The hook exits 6; git reports 1. Inside a commit it judges the staged blobs only, so it costs the size of the commit and needs no coverage. Run outside a commit with nothing staged, as `pre-commit run --all-files` does, it judges every tracked file and fails on committed debt no ratchet mark covers; `crapkit coverage`, then `crapkit ratchet seed`, records that debt |
713
+ | `crapkit verify` | before you push, and in CI | **the verdict.** Gate, ratchet, new test failures, diff coverage, against the trusted baseline |
714
+
715
+ Both hooks pardon a function that the marks file in the working tree carries a mark for,
716
+ whether or not that mark is staged or committed, so touching signed debt never refuses a
717
+ commit. In CI the file on disk is the committed one, so `verify` there fails a function
718
+ whose mark never reached a commit. `verify` is also what fails a mark that rises. Since 0.4.5
719
+ its gate pardons a touched function whose fresh CRAP sits **at or under** its mark, the
720
+ rule `rescore --gate` already applied; push it past the mark and the gate fires again. The
721
+ pre-commit hook still pardons on the mark's existence alone, on purpose: a staged blob has
722
+ no coverage, so there is no fresh CRAP to compare against. It reports how many it pardoned
723
+ on stderr (`staged function(s) carry a ratchet mark and were not gated`), and says the same
724
+ about a staged file no `[[scope]]` claims, so a new top-level directory cannot go ungated
725
+ in silence.
726
+
727
+ Every gate refuses a changed file no reader could read, exit 6, because it judged none of
728
+ the functions in it: `crapkit gate: 1 staged file(s) could not be read, so no function in
729
+ them was judged:`, then `UNREAD src/a.ts: ...` with the reader's reason, then what to
730
+ do. A TypeScript expression-arrow body with `<` before a comma is the common case; wrap the
731
+ body in parentheses or a block. `claude-hook` names the same file after the edit, and an
732
+ override grants nothing while one is in the change. `crapkit coverage` scores such a file
733
+ as zero functions and names it on stderr, and `crapkit doctor` WARNs about each one the
734
+ newest run could not read, so you meet the file before the commit gate refuses it. A file
735
+ the change never touched still passes, so an old unanalyzable file blocks nothing until
736
+ someone edits it.
737
+
738
+ **The crapkit root can sit below the Git top.** A config in `packages/api` gates
739
+ that package's staged files as project-relative paths such as `app/m.py`. A CI step
740
+ starts at the top, where no `crapkit.toml` is, so Route 4 and
741
+ [the GitHub Action](#the-github-action) take `working-directory: packages/api`. Any
742
+ command other than the hook that runs at the top exits 3, and the refusal names the root
743
+ below it:
744
+
745
+ ```
746
+ crapkit: no crapkit.toml at /repo - nothing to analyze; crapkit.toml sits below it in packages/api: pass --repo packages/api
747
+ ```
748
+
749
+ [Path and root rules](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md#file-paths-and-root-discovery)
750
+ also cover absolute arguments, literal filenames and Git diff settings. Routes 1 to 3
751
+ below need no `--repo` in such a monorepo when you run them at the git top. Git runs the
752
+ hook there, and with no `crapkit.toml` at the top the gate runs in each crapkit root below
753
+ that owns a staged file and names paths from the top (`packages/api/app/m.py`). A commit
754
+ that stages nothing under any `crapkit.toml`, a docs-only commit or any commit in a repo
755
+ armed before `crapkit init`, passes with one note on stderr. To pin the gate to one root
756
+ instead, end each `hook-precommit` line of the hook below with `--repo packages/api`,
757
+ as in `exec python -m crapkit hook-precommit --repo packages/api`, and Route 3 adds
758
+ `args: [--repo, packages/api]` under `id: crapkit-gate`. Route 4's `crapkit verify` takes
759
+ `--repo packages/api`.
760
+
761
+ Route 1 and Route 2 write this hook body. It runs the first of three that the hook's
762
+ PATH offers: the `crapkit` command a pipx, uv tool or venv install puts there, then
763
+ `uvx crapkit`, then `python -m crapkit`:
764
+
765
+ command -v crapkit >/dev/null 2>&1 && exec crapkit hook-precommit
766
+ command -v uvx >/dev/null 2>&1 && exec uvx crapkit hook-precommit
767
+ exec python -m crapkit hook-precommit
768
+
769
+ The uvx line is what gates a machine that runs crapkit only through `uvx`. It runs the
770
+ release uv fetched first, or the newest one when uv has none, which need not be the
771
+ release the rest of your team runs. It also keeps the gate running after
772
+ `pip uninstall crapkit` on any machine with uv, so take the hook out before the package
773
+ ([Removing crapkit](#removing-crapkit)).
774
+
775
+ Route 1's PowerShell form writes the launcher's own path instead, as that route explains.
776
+ Route 3's framework writes a hook of its own that runs `crapkit hook-precommit` from the
777
+ environment it installs crapkit into, and Route 4 gates in CI with no hook.
778
+
779
+ Git hands the hook the PATH of whatever ran `git commit`. A terminal with your venv
780
+ activated passes the venv on; an IDE or GUI client you did not start from that terminal
781
+ does not. When that PATH reaches no `crapkit` command, no `uvx` and no `python` that
782
+ imports crapkit, git refuses every commit. With no `python` at all, as on a Debian,
783
+ Ubuntu or macOS that has only `python3`, the hook exits 127 with
784
+ `exec: python: not found`. With a `python` that does not import crapkit, such as a system
785
+ python, it exits 1 with `No module named crapkit`. Spell the path out for that client:
786
+ `exec /path/to/venv/bin/crapkit hook-precommit`, or `Scripts/crapkit.exe` on Windows.
787
+
788
+ **Run `git config core.hooksPath` before you pick a route.** When it prints a directory,
789
+ set globally or by husky, lefthook or another hook manager, git runs hooks from there
790
+ and never reads `.git/hooks`: Route 1 arms nothing, and husky sets the path back over
791
+ Route 2's on the next `npm install`. Add the gate to the pre-commit file that directory
792
+ already runs. Under husky, that is `crapkit hook-precommit` as a line of its own in
793
+ `.husky/pre-commit`, with no `exec`: husky runs the file under `sh -e`, so the gate's
794
+ exit 6 stops the commit, and the lines after it still run when it passes.
795
+
796
+ ### Route 1: `.git/hooks/pre-commit` (local, not committed)
797
+
798
+ ```sh
799
+ hook="$(git rev-parse --git-common-dir)/hooks/pre-commit"
800
+ cat > "$hook" <<'EOF'
801
+ #!/bin/sh
802
+ command -v crapkit >/dev/null 2>&1 && exec crapkit hook-precommit
803
+ command -v uvx >/dev/null 2>&1 && exec uvx crapkit hook-precommit
804
+ exec python -m crapkit hook-precommit
805
+ EOF
806
+ chmod +x "$hook"
807
+ ```
808
+
809
+ `git rev-parse --git-common-dir` finds the repository's `.git` directory from any
810
+ subdirectory and from a linked worktree, where `.git` is a file and `cat >
811
+ .git/hooks/pre-commit` fails with `Directory nonexistent`. Every worktree runs the hook
812
+ kept there. It ignores `core.hooksPath` on purpose: `--git-path hooks` follows a global
813
+ hooks path, and would write this repo's gate into every repo on the machine.
814
+
815
+ The same hook from PowerShell. Windows PowerShell 5.1 puts a byte-order mark in front of
816
+ the shebang, UTF-16 from `>` and `Out-File` and UTF-8 from `Out-File -Encoding utf8`,
817
+ and git then refuses every commit with `error: cannot spawn .git/hooks/pre-commit: No
818
+ such file or directory` (measured on git 2.43 for Windows) without ever running the
819
+ gate. PowerShell 7 writes no mark, and `Set-Content -Encoding ascii` writes none in
820
+ either. Git runs the hook with its own `sh`, so the launcher's path is spelled with
821
+ forward slashes and quoted, and no `chmod` is needed on Windows:
822
+
823
+ ```powershell
824
+ $crapkit = (Get-Command crapkit -ErrorAction Stop).Source -replace '\\', '/'
825
+ $hook = "$(git rev-parse --git-common-dir)/hooks/pre-commit"
826
+ Set-Content -Path $hook -Encoding ascii -NoNewline -Value "#!/bin/sh`nexec '$crapkit' hook-precommit`n"
827
+ ```
828
+
829
+ This hook names the `crapkit` your shell finds today, so a GUI client that never saw your
830
+ venv runs the same one. It has no uvx line: while that path is gone, as after you move the
831
+ install or `pip uninstall crapkit`, every commit stops on
832
+ `.git/hooks/pre-commit: line 2: .../crapkit.exe: No such file or directory`. Paste the
833
+ block again after you move or rebuild that install.
834
+
835
+ `Get-Command` stops the block when no `crapkit` is on PATH (a uvx-only machine): install
836
+ one with `uv tool install crapkit` or `pipx install crapkit`, or write the `sh` form above
837
+ from Git Bash.
838
+
839
+ `crapkit doctor` warns when the hook file git would spawn starts with a byte-order mark,
840
+ and when a `core.hooksPath` set elsewhere (a global one, or husky's `.husky/_`) sends git
841
+ away from `.git/hooks`, where git then runs no hook at all.
842
+
843
+ PowerShell 5.1's `>` writes UTF-16 behind the same mark wherever it saves a file, and crapkit
844
+ reads each such file as far as the program that owns it can:
845
+
846
+ - A source file saved that way scores its functions as its UTF-8 twin does. git diffs it as
847
+ binary, so `crapkit doctor` names it in a note.
848
+ - A `.gitignore` in UTF-16 is one git cannot read. `init` names it on stderr with the fix and
849
+ leaves it as it was.
850
+ - A root `package.json` in UTF-16 is one `init` will not guess a lane from: it exits 3 before
851
+ it writes any file, naming the file and the fix.
852
+ - The marks file (`crapkit-ratchet.tsv`) saved that way reads as its rows, and a write puts
853
+ it back in UTF-16 behind its mark and in its own line endings. A mark whose name
854
+ holds a cp1252 byte reads with that byte as U+FFFD, and `ratchet seed`, `prune`, `move`,
855
+ `merge`, `verify`'s tighten and both overrides refuse at exit 3, naming the byte, rather
856
+ than save U+FFFD in place of the name:
857
+ [how the marks file is read](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md#how-the-file-is-read).
858
+
859
+ `Set-Content -Encoding utf8` saves any of them as UTF-8.
860
+
861
+ ### Route 2: a committed hooks directory
862
+
863
+ The whole route, from a repo that has no `githooks/` yet:
864
+
865
+ ```sh
866
+ mkdir -p githooks
867
+ cat > githooks/pre-commit <<'EOF'
868
+ #!/bin/sh
869
+ command -v crapkit >/dev/null 2>&1 && exec crapkit hook-precommit
870
+ command -v uvx >/dev/null 2>&1 && exec uvx crapkit hook-precommit
871
+ exec python -m crapkit hook-precommit
872
+ EOF
873
+ chmod +x githooks/pre-commit
874
+ printf 'githooks/pre-commit text eol=lf\n' >> .gitattributes
875
+ git add .gitattributes githooks/pre-commit
876
+ git update-index --chmod=+x githooks/pre-commit
877
+ git commit -m "add crapkit gate hook"
878
+ git config core.hooksPath githooks
879
+ ```
880
+
881
+ PowerShell stops that block at its heredoc, before any hook is written, and the next
882
+ commit goes through ungated. From PowerShell, the same route:
883
+
884
+ ```powershell
885
+ New-Item -ItemType Directory -Force githooks | Out-Null
886
+ Set-Content -Path githooks/pre-commit -Encoding ascii -NoNewline -Value "#!/bin/sh`ncommand -v crapkit >/dev/null 2>&1 && exec crapkit hook-precommit`ncommand -v uvx >/dev/null 2>&1 && exec uvx crapkit hook-precommit`nexec python -m crapkit hook-precommit`n"
887
+ Add-Content -Path .gitattributes -Encoding ascii -Value 'githooks/pre-commit text eol=lf'
888
+ git add .gitattributes githooks/pre-commit
889
+ git update-index --chmod=+x githooks/pre-commit
890
+ git commit -m "add crapkit gate hook"
891
+ git config core.hooksPath githooks
892
+ ```
893
+
894
+ The committed hook keeps the PATH lookup rather than one machine's launcher path, since
895
+ every clone runs it.
896
+
897
+ **The `--chmod` goes between the `add` and the `commit`.** It writes the executable bit to
898
+ the index, so a commit that already happened does not carry it: run it after and `git
899
+ ls-tree HEAD` still says `100644`, which is a hook Unix checkouts silently skip. The
900
+ `.gitattributes` line is the harder half of the same failure: under Windows' default
901
+ `core.autocrlf` the hook checks out CRLF and `#!/bin/sh\r` dies on Linux and macOS with a
902
+ bad-interpreter error. `crapkit doctor` warns when a file under `core.hooksPath` is not
903
+ `100755` in the index and prints the `update-index` line for it.
904
+
905
+ Git will not read a hooks path out of a committed file, so that `git config` line belongs
906
+ in your CONTRIBUTING setup steps. Every clone arms the gate with it. `crapkit doctor` names
907
+ a clone that skipped it:
908
+
909
+ ```
910
+ WARN githooks/pre-commit runs crapkit's gate, but core.hooksPath is unset and git runs .git/hooks/pre-commit, so every commit here skips the gate without a word; run `git config core.hooksPath githooks` in this repo, or call crapkit hook-precommit from .git/hooks/pre-commit
911
+ ```
912
+
913
+ After husky's `npm install` takes `core.hooksPath` back to `.husky/_`, the same WARN names
914
+ `.husky/pre-commit` as the file to call crapkit from.
915
+
916
+ ### Route 3: the pre-commit framework
917
+
918
+ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
919
+ `.pre-commit-config.yaml`:
920
+
921
+ ```yaml
922
+ repos:
923
+ - repo: https://github.com/JeanFrancoisGagne/crapkit
924
+ # crapkit's release step rewrites this line to the tag it just cut
925
+ rev: v0.8.1
926
+ hooks:
927
+ - id: crapkit-gate
928
+ ```
929
+
930
+ That file arms nothing on its own. The framework writes `.git/hooks/pre-commit` when you
931
+ tell it to, and until then `git commit` runs no gate and says nothing:
932
+
933
+ ```sh
934
+ pip install pre-commit
935
+ pre-commit install
936
+ ```
937
+
938
+ `pre-commit install` is the line every clone needs, the way Route 2 needs its
939
+ `git config core.hooksPath` line. [prek](https://github.com/j178/prek) reads the same
940
+ `.pre-commit-config.yaml`: `prek install` arms the hook in its place, and the gate
941
+ refuses and accepts the same commits. `crapkit doctor` WARNs on a config naming `crapkit-gate`
942
+ while the hook git runs is not the one the framework writes.
943
+
944
+ `pre-commit run --all-files`, the form pre-commit.ci and pre-commit/action run, starts no
945
+ commit and stages nothing. Outside a commit, with nothing staged, the hook judges every
946
+ tracked file instead of a staged diff that is empty: a function over its ceiling fails
947
+ unless the marks file in the working tree marks it, which in CI is the committed file. So
948
+ a breach committed from a clone that never ran `pre-commit install` fails the CI job.
949
+ Inside a commit the hook judges only what is staged, as before. It tells the two apart by
950
+ `GIT_INDEX_FILE`, which git sets for the hooks a commit runs. For a CI gate that also
951
+ weighs coverage and the marks, use Route 4.
952
+
953
+ `rev` is a git ref pre-commit resolves against that remote. Pin a release tag, not a
954
+ branch: `pre-commit autoupdate` only moves between tags, and a moving `main` would change
955
+ your gate under you.
956
+
957
+ ### Route 4: CI
958
+
959
+ A CI job runs on a fresh clone, which has no `.crapkit/` store, so bare `crapkit verify`
960
+ exits 1. Running `coverage` first would make the PR's own tree the baseline, a gate that
961
+ can never fail. The portable baseline is the mechanism:
962
+
963
+ ```
964
+ # on the default branch, after a passing verify: commit this file
965
+ crapkit verify --emit-baseline crapkit-baseline.tsv
966
+
967
+ # in the PR job, against the committed baseline
968
+ crapkit verify --baseline-tsv crapkit-baseline.tsv --github
969
+ ```
970
+
971
+ `--github` emits `::error file=...` annotations that land on the PR diff; `--sarif PATH`
972
+ writes SARIF 2.1.0 for code-scanning upload. Refresh the committed baseline whenever the
973
+ default branch's verify passes.
974
+
975
+ The file carries each lane's test count and failure list on its first line, so the PR job
976
+ forgives a test that already failed on the default branch and warns when a lane runs fewer
977
+ tests. A file written by crapkit 0.8.0 or older carries neither: against it every failing
978
+ test is a new failure, exit 8, and verify says so and names the line that rewrites it:
979
+
980
+ ```
981
+ warning: the baseline file crapkit-baseline.tsv holds no test results, so every test failure counts as new and no suite size is compared; write it again with `crapkit verify --emit-baseline crapkit-baseline.tsv` to carry them
982
+ ```
983
+
984
+ Two things the job has to do before those lines run. **Install crapkit**, `pip install
985
+ crapkit`, and pin the version the way Route 3 pins `rev`: an unpinned install moves your
986
+ gate on whatever day a release lands. **Fetch the whole history.** `actions/checkout`
987
+ clones one commit by default, `verify` reads the diff against the baseline's commit out of
988
+ git, and a shallow clone does not have that commit:
989
+
990
+ ```
991
+ $ crapkit verify --baseline-tsv crapkit-baseline.tsv
992
+ crapkit: baseline commit a74260f321f is not an ancestor of HEAD in this shallow clone, which does not hold it; set fetch-depth: 0 on the checkout or run git fetch --unshallow
993
+ ```
994
+
995
+ That is exit 4 on a `git clone --depth 1` of a repo whose baseline verifies at full depth.
996
+ On a full clone that holds the commit, the same exit names the branch that holds it, or,
997
+ when no branch does, blames a rebase or an amend that rewrote history, and asks for a
998
+ fresh baseline instead. A clone that does not hold it at all, because `.crapkit/` came
999
+ from another clone or from a CI cache keyed on a branch, says so and names the fetch:
1000
+
1001
+ ```
1002
+ $ crapkit verify
1003
+ crapkit: baseline commit a74260f321f is not in this clone, so git cannot say whether it is behind HEAD; fetch it with `git fetch origin a74260f321f4e0b9d2c61a8f3e57d0c1b2a9e8f7`, or run `crapkit coverage` here for a baseline this clone holds
1004
+ ```
1005
+
1006
+ `verify --base` and `hook-precommit --base` look up the fork point with `git merge-base`,
1007
+ and in the shallow clone above they refuse with exit 4 and the same fix:
1008
+
1009
+ ```
1010
+ $ crapkit hook-precommit --base c47a37b1df69c434ba42eec5979ddad03d2bf1e4
1011
+ crapkit: git merge-base c47a37b1df69c434ba42eec5979ddad03d2bf1e4 HEAD failed in /home/runner/work/app/app: fatal: Not a valid commit name c47a37b1df69c434ba42eec5979ddad03d2bf1e4; this shallow clone does not hold every commit: set fetch-depth: 0 on the checkout or run git fetch --unshallow
1012
+ ```
1013
+
1014
+ When the clone holds both commits but not the one they fork from, the line reads
1015
+ `no merge base between REF and HEAD in ROOT`, followed by the same fix.
1016
+ `ratchet report --enforce` with `debt_max_age_months` or `repayment_min_per_30d` set
1017
+ refuses in a shallow clone, exit 4 with the same fix: every mark reads 0 days old there and
1018
+ no repayment shows, so an age limit a full clone fails would pass and a repayment quota a
1019
+ full clone passes would fail.
1020
+ Set `fetch-depth: 0` on the checkout step, which is what crapkit's own
1021
+ [.github/workflows/ci.yml](https://github.com/JeanFrancoisGagne/crapkit/blob/main/.github/workflows/ci.yml) does.
1022
+
1023
+ The whole PR job, on GitHub Actions:
1024
+
1025
+ ```yaml
1026
+ on: pull_request
1027
+ jobs:
1028
+ crapkit:
1029
+ runs-on: ubuntu-latest
1030
+ steps:
1031
+ - uses: actions/checkout@v4
1032
+ with:
1033
+ fetch-depth: 0 # verify needs the baseline's commit
1034
+ - uses: actions/setup-python@v5
1035
+ with:
1036
+ python-version: "3.12"
1037
+ - run: pip install "crapkit==0.8.1"
1038
+ - run: pip install -e ".[dev]" # your own test dependencies
1039
+ - run: crapkit verify --baseline-tsv crapkit-baseline.tsv --github
1040
+ ```
1041
+
1042
+ The second install is the one people leave out. `verify` reruns your lanes, so the job
1043
+ needs whatever your test command needs: the coverage plugin, `npm ci`, a database, all of
1044
+ it. Without them the lane writes no artifact and `verify` exits 5 quoting the runner's own
1045
+ error, which is a broken job and not a verdict.
1046
+
1047
+ ### What a refusal looks like
1048
+
1049
+ ```
1050
+ $ git commit -m "add route"
1051
+ crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
1052
+ ccn 7 app/m.py:9 route( a , b , c , d )
1053
+ decompose before committing (coverage cannot save a function above the target).
1054
+ ```
1055
+
1056
+ That commit exited **1**, not 6. Git collapses any failed hook to 1, so 6 is a code you
1057
+ only ever see by running the hook yourself: `crapkit hook-precommit` exits 6 on a
1058
+ violation and 0 otherwise. The stderr block above is the same either way.
1059
+
1060
+ `CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full
1061
+ three-record audit: an alert line through `alert_command`, a ratchet entry staged into the
1062
+ commit, and a row in the override log. All three land or nothing does, and an unset
1063
+ `alert_command` refuses the override outright. See
1064
+ [docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md#overrides-and-the-audit-trail).
1065
+
1066
+ ## The GitHub Action
1067
+
1068
+ [action.yml](https://github.com/JeanFrancoisGagne/crapkit/blob/main/action.yml) at this repository's root is a composite action, so a reviewer
1069
+ sees crapkit's numbers on the pull request without installing anything. Four lines add it
1070
+ to a workflow, and every input has a default:
1071
+
1072
+ ```yaml
1073
+ - uses: actions/checkout@v4
1074
+ with:
1075
+ fetch-depth: 0
1076
+ - uses: JeanFrancoisGagne/crapkit@v0.8.1
1077
+ ```
1078
+
1079
+ The whole job those four lines sit in:
1080
+
1081
+ ```yaml
1082
+ on: pull_request
1083
+ jobs:
1084
+ crapkit:
1085
+ runs-on: ubuntu-latest
1086
+ permissions:
1087
+ contents: read # actions/checkout clones the repository
1088
+ pull-requests: write # the comment
1089
+ steps:
1090
+ - uses: actions/checkout@v4
1091
+ with:
1092
+ fetch-depth: 0 # the diff, and verify's baseline commit
1093
+ - uses: actions/setup-python@v5
1094
+ with:
1095
+ python-version: "3.12" # the interpreter the install below lands in
1096
+ - run: pip install -e ".[dev]" # whatever your lanes need to run
1097
+ - uses: JeanFrancoisGagne/crapkit@v0.8.1
1098
+ with:
1099
+ gate: "false"
1100
+ ```
1101
+
1102
+ That `pip install` step is the one people leave out, and it is the same one Route 4 above
1103
+ names: the action installs crapkit and nothing else, so your lanes still need whatever
1104
+ your test command needs. Without it the lane writes no artifact and the comment says so.
1105
+
1106
+ `fetch-depth: 0` is the other one. `actions/checkout` clones a single commit; the action
1107
+ reads the pull request's changed files out of git and `verify` reads the diff against the
1108
+ baseline's commit. A shallow clone lacks the base commit, so git refuses the diff: the step
1109
+ logs git's error, and the comment ranks the whole repository and says why under its
1110
+ heading, quoting git's first line and naming `fetch-depth: 0`. A shallow clone also
1111
+ flattens every number counted from history: churn sees one commit per file, so the
1112
+ worklist ranks on ccn alone, and a
1113
+ ratchet mark's age reads as 0 days. `worklist`, `next-item`, `brief` and `ratchet report`
1114
+ print one line on stderr and carry `shallow: true` in their JSON (`false` in a full clone),
1115
+ and the comment repeats the worklist's line above its table:
1116
+
1117
+ ```
1118
+ warning: churn counts read only the commits this clone holds; this shallow clone does not hold every commit: set fetch-depth: 0 on the checkout or run git fetch --unshallow
1119
+ ```
1120
+
1121
+ `brief` names churn counts and mark ages in that line, and `ratchet report` mark ages and
1122
+ repayments.
1123
+
1124
+ The action installs crapkit from `$GITHUB_ACTION_PATH`, which is its own checkout of the
1125
+ ref you pinned in `uses:`. So a pin left at last month's tag scores your tree with last
1126
+ month's crapkit rather than with whatever released since, and pinning a tag is the whole
1127
+ version policy; the snippets above name the current release. Move the pin in the same commit
1128
+ that re-seeds the marks under a new analysis version: an Action one release behind the marks
1129
+ refuses them with exit 3 on every pull request
1130
+ ([a team's upgrade](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md#a-team-upgrades-every-reader-before-the-re-seed-lands)).
1131
+
1132
+ ### What the comment looks like
1133
+
1134
+ One comment per pull request, edited in place on every push. A hidden
1135
+ `<!-- crapkit-action -->` line opens it, and the next run edits the first comment that
1136
+ starts with that line, so a fifteen-push branch carries one comment and not fifteen. A
1137
+ reviewer's reply that quotes the comment does not start with it and is left alone. When
1138
+ GitHub answers a page of the comment list with an error, the step edits the crapkit
1139
+ comment it found before the error. When it found none, it lists the comments once more
1140
+ before it posts fresh; if that listing fails too, it posts a fresh comment, so the pull
1141
+ request still gets this push's verdict, and the job log says the listing failed twice.
1142
+ On a `push` event there is no pull request to carry it, and the same text goes to the job
1143
+ log instead.
1144
+
1145
+ Rendered from three saved payloads: a pull request that adds an untested `route()` (ccn 8)
1146
+ beside a ratchet-marked `legacy_router()`, in a repository whose `diff_uncovered_max` is 3.
1147
+ The payloads are under `tests/fixtures/action_comment/`, and the unit suite pins this block
1148
+ to their render:
1149
+
1150
+ ```markdown
1151
+ <!-- crapkit-action -->
1152
+
1153
+ ## crapkit
1154
+
1155
+ 4 functions in 2 files, 2 over ceiling 6, CRAP load 149.59, grade F.
1156
+
1157
+ **verify failed, exit 6: complexity gate.**
1158
+
1159
+ - gate: `app/calc.py:34` `route( a , b , c , d )` ccn 8, cov 0%, crap 72.0 -> decompose
1160
+ - uncovered lines in `app/calc.py`: 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45
1161
+
1162
+ Run 3 against baseline 1, 1 changed file (`app/calc.py`): 1 gate violation, 0 ratchet regressions, 0 new test failures, 11 uncovered changed lines.
1163
+
1164
+ ### Worklist: 1 changed file
1165
+
1166
+ | File | Function | ccn | risk | remedy |
1167
+ |---|---|---:|---:|---|
1168
+ | `app/calc.py:34` | `route( a , b , c , d )` | 8 | 4.0 | decompose |
1169
+ | `app/calc.py:19` | `legacy_router( a , b , c , d , e )` | 8 | 4.0 | decompose (accepted debt) |
1170
+ ```
1171
+
1172
+ The first line is the run `crapkit coverage` wrote: functions and files, how many sit over
1173
+ the ceiling (`over ceiling 6`, or `over their ceilings (6; reports 12, util 4)` when scopes
1174
+ set their own), CRAP load and grade, then ``; scope `src` scored no function: it claims no
1175
+ file`` for each scope that scored nothing, and a failed lane's first error line appended as
1176
+ `; lane 'js' failed: ...`. When `coverage --json` died before a summary, the line quotes the
1177
+ error object it printed instead: `` `crapkit coverage` exited 5: lane 'py' cannot import
1178
+ pytest-cov; pip install pytest-cov. ``
1179
+
1180
+ The verdict opens with the exit code and the rule it stands for (`complexity gate`,
1181
+ `ratchet regressions`, `new test failures`, `diff-coverage ceiling N`), then one bullet per
1182
+ finding: each gate violation with its function, ccn, coverage, CRAP and remedy; each
1183
+ changed file no reader could read, as `` - unread: `src/a.ts`, so the gate judged none of
1184
+ its functions: `` and the reader's reason; each ratchet regression as recorded -> fresh;
1185
+ each new test failure by id; and the first twenty uncovered changed lines, one bullet per
1186
+ file, with a count of the rest. Each kind prints fifty bullets at most, then `- and 1450
1187
+ more new test failures; `crapkit verify` lists them all`. The counts line closes it,
1188
+ naming up to three of the files verify judged and counting the rest:
1189
+ ``1 changed file (`app/calc.py`)``. An unread file fails the gate as a function over the ceiling does, so the counts line counts
1190
+ it among the gate violations and says how many were files:
1191
+ `1 gate violation (1 unread file)`. A verify that passed is one line: `**verify passed.**
1192
+ Run 2 against baseline 1, 7 changed files (`a.py`, `b.py`, `c.py` and 4 more).` A verify
1193
+ older than 0.8.1 lists no `changed_paths`, and its line gives the count alone. A lane that recorded no test results (it declares no `results_artifact`)
1194
+ adds ``New test failures went unchecked in lane `py`: it recorded no test results.`` to
1195
+ that line, and a new failure in a lane whose baseline recorded no failure list gets a
1196
+ bullet of its own, ``- lane `py`: the baseline recorded no failure list, so its new failures
1197
+ may predate this change``: the fork point's lane declared no `results_artifact`, so nothing
1198
+ can tell the pull request's failures from older ones, and verify still counts them.
1199
+
1200
+ GitHub refuses a comment over 65,536 characters, and a pull request then gets no comment
1201
+ at all. A body that still runs over (long names under a large `top`) is cut at the last
1202
+ line that fits, the marker line still first, and ends with `the comment stopped at
1203
+ GitHub's 65,536-character limit; the job log above holds the whole text.` The step prints
1204
+ the whole comment to the job log before it posts.
1205
+
1206
+ The rows are the ranked worklist for the files the pull request changed, worst first,
1207
+ `top` of them, with the rows a finding names listed first. `risk` is ccn times churn
1208
+ weight, the number `crapkit worklist` ranks on, and `remedy` is the run's own verdict for
1209
+ that function: `decompose`, `split-lines`, `add-tests` or `ok`. `(accepted debt)` marks a function the
1210
+ committed ratchet carries a mark for, so an untouched `legacy_router` does not read like
1211
+ the pull request's own new function. A pull request that touches no ranked function gets
1212
+ the heading and `No ranked function in these files.` When `crapkit worklist` wrote no
1213
+ ranking, the table's place says why rather than reading as an empty one: the error it
1214
+ printed (`` `crapkit worklist` exited 1: no snapshot in ...: run `crapkit coverage` first. ``),
1215
+ or, when it printed nothing, `` `crapkit worklist` printed no ranking; its error is in the
1216
+ job log. ``
1217
+
1218
+ The two file counts describe the same diff, counted twice. `39 changed files` is
1219
+ `git diff --name-only --relative base.sha...HEAD` run in `working-directory`, the
1220
+ branch's own commits under it, and it is what the table is filtered to; the step's log
1221
+ names up to three of them. The count on the verdict line is what `verify` measured from
1222
+ the same fork point. With `delta: "false"` the second one is 0, because there is nothing
1223
+ behind the checkout to measure from.
1224
+
1225
+ ### The inputs
1226
+
1227
+ | Input | Default | What it does |
1228
+ |---|---|---|
1229
+ | `gate` | `"false"` | `"true"` exits with `crapkit verify`'s own code, so a finding fails the check. On a pull request with `delta` on it also exits 1 when the base run was not made, because a verdict with no base run judged no changed function. Anything else exits 0 and the comment is the whole output |
1230
+ | `delta` | `"true"` | scores the pull request's base commit first, so the verdict covers the commits the pull request adds. Costs a second lane run; `"false"` scores the checkout alone, and the verdict then judges no changed function |
1231
+ | `top` | `"5"` | worklist rows rendered in the table. An empty value renders 5. A value that is not a whole number (`"ten"`, `"5.0"`, `"-1"`) renders 5 and puts a warning naming the input on the run's summary page |
1232
+ | `python-version` | `"3.12"` | the interpreter `actions/setup-python` installs crapkit into. Match it to the version your own setup-python step named, or the lanes run on an interpreter your dependencies never reached |
1233
+ | `working-directory` | `"."` | the directory that holds `crapkit.toml`, relative to the checkout. Every step that runs crapkit or lists the changed files runs there, and the base run scores the same directory at the fork point. Set it when `crapkit.toml` sits below the repository top: at the top, `crapkit coverage` finds no `crapkit.toml` and exits 3 |
1234
+
1235
+ On a monorepo whose `crapkit.toml` sits in `packages/api`, the crapkit step takes
1236
+ `working-directory: packages/api` under `with:`, and the job's own `pip install -e
1237
+ ".[dev]"` step takes the same key at step level, because `.[dev]` names that package's
1238
+ `pyproject.toml` and the top has none. The changed files are then listed from
1239
+ `packages/api`, the way the worklist names them, and a change elsewhere in the
1240
+ repository is not part of the table. Point it at the directory that holds
1241
+ `crapkit.toml` itself, not at one below it. The input is new in 0.8.1: an action pinned
1242
+ to an older tag warns `Unexpected input(s) 'working-directory'` and scores the top.
1243
+
1244
+ `gate: "false"` is the default on purpose. A team adopts the action before it has decided
1245
+ which findings should stop a merge, and a check that fails on day one gets turned off on
1246
+ day two.
1247
+
1248
+ ### What the verdict line covers
1249
+
1250
+ On a pull request, the commits the pull request adds. The action scores the fork point
1251
+ first, then the checkout, then runs `crapkit verify --base <fork>`, which measures the
1252
+ diff from there and takes the fork point's run as its baseline. So the gate judges the
1253
+ functions in the diff a reviewer is reading, and a repository that was already over its
1254
+ ceiling before the branch started does not fail every pull request that touches it.
1255
+
1256
+ The fork point is `git merge-base` of `base.sha` and HEAD, not `base.sha` itself.
1257
+ `base.sha` is the base branch's tip when the event fired, so a base branch that moved
1258
+ after the branch forked carries commits HEAD never saw, and a run there would be neither
1259
+ the baseline verify wants nor a diff anyone is reviewing.
1260
+
1261
+ The base run happens in a detached worktree under `RUNNER_TEMP`, in the same
1262
+ `working-directory` inside it, and its store is copied over the checkout's so both runs
1263
+ sit in one place. The cost is **two lane runs on a pull request**: your suite runs once
1264
+ at the fork point and once on the checkout. Set `delta: "false"` to skip the base run,
1265
+ and the verdict falls back to the checkout against its own run, which reports the tree's
1266
+ own health and judges no changed function. The comment says so in place of
1267
+ `verify passed`:
1268
+
1269
+ ```markdown
1270
+ **verify judged no changed function:** the base run was not made (no base commit). Run 2 against baseline 2, 0 changed files.
1271
+ ```
1272
+
1273
+ Three things leave the base run unmade: a shallow clone that does not hold the fork
1274
+ point, a fork point older than your `crapkit.toml`, and a lane that will not run against
1275
+ that tree. The step logs `crapkit base scoring exited N` and writes the reason to
1276
+ `crapkit-base.reason` in the words the comment then quotes, `shallow clone does not hold
1277
+ the fork point of <sha>; set fetch-depth: 0 on the checkout`, `no usable crapkit.toml at
1278
+ the fork point <sha>: ...`, or `lane failed at the fork point <sha>: ...` with the first
1279
+ `lane '<name>' FAILED:` line crapkit printed, so a warning from a lane that passed never
1280
+ stands in for the lane that failed. A crapkit that died printing nothing reads `crapkit
1281
+ coverage exited <code> and printed nothing`. The verdict falls back the same way `delta: "false"` does, and the
1282
+ ratchet still runs, so exit 7 there is a finding. What differs is the job's status. With
1283
+ `gate: "true"` on a pull request whose base run was attempted and not made, the exit step
1284
+ exits 1 and prints the reason, because `actions/checkout`'s default depth-1 clone would
1285
+ otherwise turn every pull request into a green check that judged nothing. A `push` event
1286
+ and `delta: "false"` never attempt the base run, so they keep verify's own code; a `push`
1287
+ has no base commit and no pull request to comment on.
1288
+
1289
+ One requirement the base run adds: the lane has to measure the tree it runs in. A lane
1290
+ that reaches an installed copy of your package instead of the checkout will measure the
1291
+ pull request's code while standing on the base commit, and the two runs then describe the
1292
+ same tree. `crapkit verify` refuses a run whose artifact names files outside the tree
1293
+ (exit 5), which catches the loud version of this; a lane pinned to a path outside the
1294
+ worktree is the quiet one. Point the lane at the tree, or set `delta: "false"`.
1295
+
1296
+ `--reuse-artifacts` is what keeps each of those runs to one pass of your suite. `coverage`
1297
+ ran the lanes moments earlier on that tree, and verify parses those artifacts rather than
1298
+ running the whole suite a second time for the same numbers.
1299
+
1300
+ Which is why `crapkit coverage` has to exit 0 for there to be a verdict. When it exits
1301
+ anything else (a lane that failed, an artifact it refused), the action does not run
1302
+ `verify`: on a runner that keeps its workspace between jobs (`clean: false`), a
1303
+ `verify --reuse-artifacts` over a failed measurement read the artifact the dead lane had
1304
+ left from an earlier run, passed over it, and made that run the trusted baseline. The
1305
+ comment then carries coverage's failure in place of the verdict:
1306
+
1307
+ ```markdown
1308
+ **no verdict: `crapkit coverage` exited 5 (lane 'py' failed: lane 'py' wrote no artifact on its last attempt; the .crapkit/cov/py.json on disk predates it); verify did not run.**
1309
+ ```
1310
+
1311
+ The parenthesis is the first line of the lane failure the summary carries. When every
1312
+ lane failed, `coverage` prints an error object instead of a summary and the lane errors
1313
+ are only in the job log, and the line says so: `(every lane failed (1 of 1); the lane
1314
+ errors are in the job log)`. When `coverage` printed nothing at all, it crashed or was
1315
+ killed before it could: `(it printed no run summary, so it crashed or was killed before
1316
+ scoring; its error is in the job log)`. When `coverage` refused files a scope takes whose
1317
+ names are not UTF-8, the parenthesis names the first and counts the rest, and one bullet
1318
+ under the line names each file the error object lists in `unread_files`:
1319
+ ``- refused: `src/caf\xe9.py`: its name is not UTF-8, ...``. When the directory coverage
1320
+ ran in holds no `crapkit.toml`, the parenthesis names that directory and ends ``set the
1321
+ action's `working-directory` input to the directory that holds crapkit.toml``. With
1322
+ `gate: "true"` the job exits with coverage's code.
1323
+
1324
+ The other gate that judges a delta is the portable baseline in [Route 4](#route-4-ci):
1325
+ commit `crapkit-baseline.tsv` on the default branch and run `crapkit verify --baseline-tsv
1326
+ crapkit-baseline.tsv` in a step of your own. It needs no second lane run, and it needs
1327
+ someone to keep that file current.
1328
+
1329
+ The comment is posted with `gh api` and the job's own `GITHUB_TOKEN`, which needs
1330
+ `pull-requests: write`, and `contents: read` for the checkout once the job names any
1331
+ permission: naming one sets every other to none. Two things it cannot do: a pull request
1332
+ from a fork gets a read-only token, so the POST is a 403 there, and a self-hosted runner
1333
+ without the `gh` CLI on PATH posts nothing. In both cases the step stays green, logs gh's
1334
+ exit code, and leaves the rendered text in the job log.
1335
+
1336
+ ## Subcommands
1337
+
1338
+ `crapkit clean --dry-run --json` previews abandoned temporary mutation recovery. See
1339
+ [resource policies](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md)
1340
+ for shared analysis workers, process lifetime and bounded logs.
1341
+
1342
+ Every subcommand takes `--repo PATH`, and the flag goes **after** the subcommand. Without
1343
+ it the root is the nearest `crapkit.toml` at or above the current directory
1344
+ ([ADR 0002](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adr/0002-configuration-is-found-upward-nearest-wins.md)): from a
1345
+ monorepo workspace `crapkit worklist` reads the root configuration that claims the
1346
+ workspace, says `crapkit: using crapkit.toml at /repo` on stderr, and reads a relative path
1347
+ argument from where you stand. `claude-hook` is the one exception: it has no `--repo`,
1348
+ because it takes its root from the file named in the hook payload it reads.
1349
+
1350
+ ```
1351
+ $ crapkit worklist --repo /path/to/repo --scope util --top 1
1352
+ worklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) - 1 of 3 active (--top 1), 0 dormant
1353
+ risk 5.4 ccn 5 crap 30.0 cov 0% 5c/1a util/stats.py:1 bucket( value , low , high )
1354
+ -> next: crapkit next-item
1355
+ ```
1356
+
1357
+ Before it, argparse reads the path as the subcommand name and exits 2 without ever
1358
+ mentioning `--repo`:
1359
+
1360
+ ```
1361
+ $ crapkit --repo /path/to/repo worklist --top 1
1362
+ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from 'inventory', 'coverage', ...)
1363
+ ```
1364
+
1365
+ `--json` prints one sorted-keys JSON object on stdout, always carrying a `schema` field.
1366
+
1367
+ | Command | What it does |
1368
+ |---|---|
1369
+ | `clean [--dry-run] [--json]` | Recovers abandoned temporary mutation worktrees. Preserves active runs and intentional mutation pools. `--dry-run` reports planned recoveries. It leaves test evidence alone: crapkit's own development runner, `tools/testing/run.py`, prunes `.crapkit/test-runs/` with `--retention-days` and `--retention-count`. `--json` keeps `test_runs` with every array empty. |
1370
+ | `init` | Sniffs tracked source into per-directory scopes, writes a self-validated starter `crapkit.toml` whose lanes report into `.crapkit/cov/`, and appends `.crapkit/` plus each runner's own droppings to `.gitignore`. Writes a live `[[lane]]` when it can detect the test runner, otherwise a commented template. Never rewrites an existing config: over one, it adds only the `.gitignore` entries an earlier run left out. |
1371
+ | `doctor [--show-files] [--json] [--tune] [--plugin-root [PATH]]` | Checks the config still describes the repo: unknown keys (with the accepted spellings), zero-file scopes, tracked source no scope claims, scopes no lane covers, lane cwds and commands that no longer resolve, lizard importable, oversized files. It FAILs a lane whose runner does not resolve or that the shell cannot start, a `pytest --cov` lane whose coverage.py is older than 7.13.1, and a marks file a newer crapkit or lizard stamped. It WARNs on lane, hook and marks-file settings a later command refuses or skips, such as a scope a lane measures with no `[crapkit.scoped_tests]` template behind it, and names each `crapkit` launcher on PATH with its version when there are two or more. `--show-files` lists every file each scope matched. `--json` gives each lane a `refusal`, the sentence `--reuse-artifacts` would refuse its artifact with, or `null`. `--tune` prints suggested parallelism knobs and writes nothing. `--plugin-root PATH` reads no repo at all: it checks an installed [plugin](https://github.com/JeanFrancoisGagne/crapkit/tree/main/plugin) against the `crapkit` on PATH on both version and hook `--protocol`, one line per disagreement, each naming the command that closes it. Every check and warning: [docs/commands.md: doctor](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/commands.md#doctor). The JSON form: [docs/agent-json.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#doctor---json). |
1372
+ | `inventory [--db PATH] [--export PATH] [--json]` | One lizard pass over every in-scope file into a SQLite snapshot run, cached by content hash. `--db` is the only way to point crapkit at a store outside `.crapkit/`, and only this command accepts it. |
1373
+ | `coverage [--lane NAME] [--reuse-artifacts] [--reuse-unchanged] [--export PATH] [--sarif PATH] [--github] [--json]` | Runs the lanes, joins branch coverage onto a fresh inventory, writes a scored run. A failed lane is recorded, not fatal: its scopes fall back to `no-lane` and the run is typed `partial`, so it can never serve as a baseline. `--reuse-unchanged` reuses a lane whose proof still holds and says what that proof leaves out; `--reuse-artifacts` reads the saved files and warns, naming up to three files whose bytes moved since the lane measured them. A failed attempt's leftover stays refused while it holds the same bytes: a `touch` does not lift the refusal, new bytes do. See [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md). |
1374
+ | `verify [--baseline ID \| --base REF \| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--no-tighten] [--sarif PATH] [--github] [--json]` | The full verdict against the trusted baseline: gate on touched functions, ratchet, no new test failures, optional diff-coverage ceiling. The three baseline selectors are mutually exclusive; `--baseline ID` also bypasses the taint rule ([The trusted baseline](#the-trusted-baseline)), and `--baseline-tsv` reads a commit-stamped file so a fresh clone verifies with no store. `--no-tighten` passes the verdict without rewriting the ratchet. Findings a dirty tree produced are tagged `dirty` and counted apart. A line under the verdict names the first three changed files, `--json` lists them all as `changed_paths`, and an untracked source file inside a scope is named on stderr as not judged. A changed file no reader could read fails the gate, exit 6, as an `UNREAD` finding (`unread_files` in `--json`), and `--override` grants nothing while one is present. It reads each istanbul artifact once for coverage, dead lines and its digest, and skips the artifact walk on an empty diff; skipping the whole run on an unchanged tree was measured and rejected, because a key made of HEAD plus the dirty names cannot see a second edit to a file that was already dirty. |
1375
+ | `worklist [--top N] [--scope NAME] [--batches N] [--json]` | The risk map: every admitted function ranked by `ccn * churn weight`, floored by `worklist_floor`, with hot simple code and anything over its ceiling admitted past that floor. It ranks finished rows and `no-lane` rows too, marked `ok` and `no-lane`, so it never empties; `next-item` carries the stop condition. Every row carries the function's `crap` and `cov` off the ranked run and its `ratchet_mark` when the marks file in the working tree signs for it, and the header counts the active rows the cap hid: `50 of 3980 active (worklist_top 50)`. `--scope NAME` (repeatable) is exact, not a substring; a name no `[[scope]]` declares is a configuration error, exit 3, naming the declared scopes. `--batches N` **adds** a `batches[]` view cutting the active list into at most N file-disjoint batches with co-changing files kept together, off the same cached pairs `coupling` reads; the normal keys stay. Files go out largest summed `risk` first, each to the batch with the least `risk` so far (LPT scheduling). The text ends with the command to run next: `coverage` when the ranked run cannot serve as a baseline, `ratchet seed` while the repo has no marks file, `next-item` after that. |
1376
+ | `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG, and reads a path fragment the way a [file argument](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md#file-paths-and-root-discovery) is read, so `./pkg/legacy` is `pkg/legacy`, while a function name keeps its case. `--scope NAME` (repeatable) is exact, not a substring, and a name no `[[scope]]` declares is a configuration error, exit 3, naming the declared scopes. `--claim` holds what it hands out so a second session skips it. Every item carries a `handle`, the name form that survives the edit the item asks for. [docs/agent-json.md: next-item](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#next-item) gives the tie order, `stale`, `scored_changes` and every field. |
1377
+ | `claims [list \| release PATH NAME \| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. A claim taken before analysis version 11 on a nested Python def also answers to the name that version gives the def. |
1378
+ | `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, `(anonymous)#N` for a function printed `(anonymous)` counting the file's anonymous functions from the top, or `NAME#2` for the second of several functions a file gives one name to. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store and one duplication pass over the snapshot for the whole batch (batch of 5: 11.8 s to 5.2 s, output byte-identical to five separate calls). |
1379
+ | `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. It also takes the function's start line, the form `brief` takes, which is how you open one printed `(anonymous)`. `--history` adds the commits that touched it (`git log -L` over the span the run measured, carried through your uncommitted edits onto HEAD's lines), each carrying its message `body` as committed, and says so when the span holds only uncommitted lines or git cannot answer; `--tests` adds the tests that covered it, which needs coverage.py contexts turned on ([recipe](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md#test-attribution-for-explain---tests)), and withholds them with the same note whenever the file's dark lines are withheld. `--json` emits the same content as one `schema` 1 object. |
1380
+ | `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. A function on a line span another one shares, and a Python def whose body starts on the line its signature ends, scores untested, as the coverage run scores it. A function the run holds no row for (added or renamed since) or one in a scope no lane measures prints `-` for its cov and ends `(coverage not measured)`; `--json` marks it `unmeasured: true` and keeps its `cov`, `crap` and `remedy`. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions whose CRAP sits at or under their ratchet mark, and exits 6. It also exits 6 on a changed file no reader could read, listed under `gate.unread_files` in `--json`. A marked function past its mark is gated; the pre-commit hook pardons on the mark's existence instead, because a staged blob has no coverage to score. |
1381
+ | `ratchet seed \| prune \| merge \| move \| report [--baseline ID] [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it, and the prune line names up to three renames it followed; when this clone lacks the commit the renames start from and a marked file left before the oldest commit it holds, prune exits 4 and writes nothing), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. `seed` ends by naming the commit and `verify` that follow it. `seed` and `prune` take `--baseline ID` to read a named run instead of verify's pick, refused for the reasons `verify --baseline` refuses one. See [docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md). |
1382
+ | `runs [list \| prune [--keep N]] [--json]` | Run history, and retention. `list` marks the run `verify` compares against today `baseline`, and prints `verdict=-` for a run that produces no verdict rather than one that failed. See [The trusted baseline](#the-trusted-baseline). `--keep` (default 5) is a floor on the newest trusted runs, not a cap: the digest pair, every passing verify baseline, every run an override names, and the newest non-hook run are kept too. `prune` VACUUMs afterwards. |
1383
+ | `overrides [--json]` | The override audit trail: who granted what, when, and why. |
1384
+ | `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. It reads a per-run rollup table rather than rescanning every scored row, and fills that table for any run missing one, so it writes to the store (best effort: a read-only `.crapkit/` costs the speed, not the command). |
1385
+ | `digest [--alert]` | The delta between the two newest runs with identical lane sets. Silent when nothing changed. Past the totals line it names up to five functions of each kind: those whose CRAP rose by more than 0.01, largest rise first; new functions over their ceiling, highest CRAP first; and functions that were over their ceiling and dropped by more than 0.01, largest drop first. Moves and scores equal to 4 decimal places list by path. An over-ceiling function the older run holds no row for reads `new over ceiling` when that run scored its scope, and `newly scored over ceiling in scope NAME` when it scored nothing there: a scope added to `crapkit.toml` between the two runs brings old code in, and that is not new debt. `--alert` pipes the body to `alert_command` on stdin. Plain lines, never JSON. |
1386
+ | `report [--out PATH]` | One self-contained HTML page written to `.crapkit/report.html` (or `--out PATH`, repo-relative, or an absolute path you name), with the path printed on stdout. It renders what `worklist --json` and `trend --json` already answer at their defaults: the ranked worklist capped at `worklist_top`, the per-scope grades off the newest run, the trend series, and a banner naming every stale lane and, from `scored_changes`, how many files the run scored changed since. It measures nothing and opens no network connection. Every row carries the function's CRAP and coverage, and prints the `crapkit explain` call for the rest: dark lines, history, the mark. It reads the same per-run rollups `trend` does, and writes them on the same terms. |
1387
+ | `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. Ties have a stable order across hash seeds. A positive `--top` bounds retained candidates and output; dense inputs still require pair comparisons. Each function is shingled from its own lines: the lines of a function nested in it, past that function's first line, are the nested function's. A function and its nested closure never pair. Blank lines and comment lines stay out, and so does a Python line starting with three quotes. Code after a block comment's closer is code (`/* tag */ acc += 1;`, `*/ x = a`). `s = "http://x"; /* note` opens a block comment and `x = 1; // see /* here` does not. The reader knows plain strings and character literals only, so it misreads `r"C:\"`, `r#"a"b"#`, `R"(a"b)"`, `/"/`, `re = /a /* b/;` and `echo a#b <# note`: [docs/commands.md: duplication](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/commands.md#duplication) gives the comment reader's rules and what each of these lines does. |
1388
+ | `coupling [--min-support N] [--min-confidence F] [--top N] [--json]` | File pairs that keep landing in the same commits. Defaults: `--min-support 5` shared commits, `--min-confidence 0.5` max-direction ratio, `--top 50`. Bulk commits never couple pairs, and a young repo returns nothing at the default support. Pairs rank by support times confidence, highest first, and pairs that tie rank by their paths. The ranked pairs are cached in `.crapkit/coupling-cache-v2.json`, keyed on HEAD, the churn window, today's UTC date, the path format, the clone's history depth and a digest of the tracked set, and shared with `brief` and `worklist --batches` (warm: 1.05 s to 0.11 s on a 72k-commit repo). The date is part of that key, so the first run after midnight UTC rebuilds the pairs on an unchanged HEAD, and gets the same pairs: the window ends at HEAD's commit date, not today's. The depth is part of it too, so `git fetch --unshallow` rebuilds them the same day. `--top` reads the cache, because it truncates that same order; `--min-support` or `--min-confidence` off their defaults ask a wider question than the file answers, so they bypass it and recompute. |
1389
+ | `mutate [--files F ...] [--max-mutants N] [--drop-pool] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. Both lists pass through the scored corpus first, the same predicate `coverage` uses: a file outside it is named on stderr and never mutated, `--json` lists it under `outside_corpus`, and when nothing is left stdout says `nothing to mutate` at exit 0. `--max-mutants` (default 100) caps the run, and `mutants` in `--json` is the capped count. A mutant whose suite timed out, or exited 5 and ran no test, counts as killed, and `--json` also counts it under `timed_out` or `no_verdict`, each a count inside `killed`. The summary counts the no-verdict ones on a line of its own, `no verdict: N of the K killed ran no test (exit 5), so no test caught them`. Shell and PowerShell files are refused by name on stderr, and some operators make no mutant in some languages: [docs/commands.md: mutate](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/commands.md#mutate). Every worker uses a kept worktree, including one; see [mutation worktrees](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md#mutation-worktrees). `--drop-pool` removes them and exits. |
1390
+ | `test-scoped FILE ...` | Runs each owning scope's `[crapkit.scoped_tests]` template on the files (quoted, longest-prefix scope wins). A file outside every scope runs only from a `test`, `tests` or `__tests__` directory, under the one scope that declares a template. A template with no `{files}` runs as written, which is how a scope whose tests live outside its own paths runs its whole suite. Exit code only; a nonzero runner exits 1. |
1391
+ | `hook-precommit [--base REF]` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation, and on a staged file no reader could read, named on an `UNREAD` line with the reader's reason; `CRAPKIT_OVERRIDE_REASON` grants nothing while such a file is staged. `--base REF` compares the index with the merge base of REF and HEAD, the form a CI checkout runs. Outside a commit with nothing staged, as under `pre-commit run --all-files`, it judges every tracked file. With no `crapkit.toml` at or above where it runs, it gates in each root below that owns a staged file. |
1392
+ | `claude-hook [--protocol N]` | Reads one PostToolUse payload from stdin, as Claude Code, Copilot CLI, Cursor or VS Code sends it, and judges each file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed, and `hook-precommit` stays the enforcement point. The advisory goes on stderr with exit 2 for Claude Code, and as one JSON object on stdout with exit 0 for Copilot CLI, Cursor and VS Code, which read exit 2 otherwise. An unscoped file, or one with no `crapkit.toml` above it, exits 0 in silence. A `Bash` event judges the working tree instead, where you register a `Bash` matcher ([The Claude Code plugin](#the-claude-code-plugin)). It opens no snapshot, and the one thing it writes is that session's record of judged bytes, under the git directory. What it prints and when it stays silent: [docs/commands.md: claude-hook](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/commands.md#claude-hook). |
1393
+ | `watch [--interval SECONDS] [--cycles N]` | Rescores the files your scopes claim when their content changes (default every 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). Each poll lists the tracked and untracked files under the scope paths again, so a file created while it runs is rescored too. A file whose mtime moved is read and rescored only when its bytes differ, so a touch or an editor saving the same bytes rescores nothing; an edit written under the file's old mtime (`cp -p`, `touch -r`) is not seen, the limit the analysis cache shares. `--cycles N` polls exactly N times and exits 0; without it the loop runs until ctrl-c. After a `pip install -U` under it, the next rescore exits 1 and says to restart it. |
1394
+ | `help [TOPIC]` | The help git, npm and docker answer to. With no TOPIC it prints the command list; with one it prints that subcommand's own help, the same page as `crapkit TOPIC --help`. A TOPIC that names no subcommand exits 3. |
1395
+ | `mcp` | A stdio MCP server with no extra dependency, exposing twelve read-side tools named `verb_noun`, each with a title and output schema. Tools call the CLI to inspect current scores, source and edited-file gates. They take no claims and run no verification; calls can write caches or store metadata. See [the MCP contract](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#mcp-server), and [Wiring crapkit into your agent](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/harnesses.md) for the block each agent's config file takes. |
1396
+
1397
+ ## Reading the output
1398
+
1399
+ Every line crapkit writes from its own words is ASCII, so `$x = crapkit doctor` or
1400
+ `$x = crapkit worklist 2>&1` in Windows PowerShell 5.1 captures it intact under any
1401
+ console code page. A path or function name crapkit quotes keeps its own characters and
1402
+ goes out as UTF-8; to capture one of those, set
1403
+ `[Console]::OutputEncoding = [Text.Encoding]::UTF8` first.
1404
+
1405
+ ### Flags: why a coverage number is missing
1406
+
1407
+ | Flag | Meaning | Scored |
1408
+ |---|---|---|
1409
+ | `measured` | A lane artifact spoke about this function. | Real `cov`. |
1410
+ | `untested` | A lane covers the scope, but its artifact is silent on this function, which normally means no test imports the file. | `cov = 0`. A testing gap, and `uncovered_lines` comes back `null` because no artifact can name lines it never saw. |
1411
+ | `excluded` | A lane's artifact measured the file and was told to leave this function out: `# pragma: no cover`, an `exclude_lines` pattern that takes every statement in it, or from coverage.py 7.10.1 a stub whose body is `...`, under coverage.py; `/* istanbul ignore next */` or `/* v8 ignore next */` under istanbul. istanbul drops such a function from the file's `fnMap`, so a function missing from an instrumented file's `fnMap` (one written beside its `statementMap`) that lists others reads `excluded` too. | `crap = ccn`, and `remedy` can only be `ok` or `decompose`: no test can move its number. `uncovered_lines` comes back `[]`. To have it measured, remove the exclusion. |
1412
+ | `no-lane` | No lane's `scopes` list names this function's scope. | `cov = 0`. A tooling gap, not a testing gap. `next-item` never hands one out and counts them in `skipped_no_lane`; `worklist` ranks them and marks the row `no-lane`, because a wiring gap is a risk you have to see. |
1413
+ | `cc-only` | The scope sets `coverage_optional = true`, so no coverage number can exist. | `crap = ccn`, and `remedy` can only be `ok` or `decompose`. `uncovered_lines` comes back `null` with a note naming that setting. |
1414
+
1415
+ The coverage summary counts all five as `measured` / `untested` / `excluded` / `no_lane` /
1416
+ `cc_only`.
1417
+
1418
+ ### Remedy: what to do about it
1419
+
1420
+ | Remedy | Condition | Action |
1421
+ |---|---|---|
1422
+ | `decompose` | `ccn > ceiling` | Split it. No amount of coverage clears this. |
1423
+ | `split-lines` | `ccn <= ceiling`, `crap > ceiling`, and another function shares its source lines, or a Python def's body starts on the line its signature ends | 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. A one-line Python def shares its line with its `def` statement, which runs at import, so coverage.py cannot show a call. So does a body on the last line of a signature that spans several lines, or one that goes on from the colon's line inside brackets or after a backslash: move the body to its own line after the signature. |
1424
+ | `add-tests` | `ccn <= ceiling` and `crap > ceiling` | Cover the branches. |
1425
+ | `ok` | `crap <= ceiling` | Nothing. |
1426
+
1427
+ The comparison is made on the exact CRAP, not on the float that computes it. CRAP(18, 2/3)
1428
+ is exactly 30, and its float is 30.000000000000004; at `target = 30` it reads `ok`. The
1429
+ over-target count, the grade, `ratchet seed` and `verify`'s gate judge it the same way.
1430
+
1431
+ ### Grade and CRAP load
1432
+
1433
+ The grade is the share of functions over their ceiling: `A+` at exactly zero, `A` under
1434
+ 2%, `B` under 5%, `C` under 10%, `D` under 20%, `F` at 20% or more. `crap_load` beside it
1435
+ is the sum of every function's CRAP score, so it moves when a function gets better even if
1436
+ the letter does not. It is added exactly (`math.fsum`) and rounded once to 2 dp, so the
1437
+ order the rows come in and the Python or SQLite version never move it. Both are taken over the scopes the run measured: a scope
1438
+ whose lane failed or was left out by `--lane` scores at the cov-0 stand-in and reports
1439
+ its load under `by_scope` only. `trend`, `coverage`, the digest
1440
+ and `brief`'s file totals print the same load for the same rows.
1441
+
1442
+ ### Risk: what ranks the worklist
1443
+
1444
+ `risk = ccn * churn weight`. The weight is a time-weighted sum over the file's commits in
1445
+ the churn window: each commit contributes a logistic weight rising to 0.5 for the newest
1446
+ commit in the log and falling to near zero for the oldest, so five edits last month
1447
+ outrank fifty from two years ago. The window reaches `churn_window_months` back from
1448
+ HEAD's commit date, never from the wall clock, so a fixed tree ranks identically forever.
1449
+
1450
+ Age is not the input, position in the log is. A log whose commits all share one timestamp
1451
+ has no range to weight against, so each commit counts once: a one-commit repo weighs every
1452
+ file 1.0, ranks by ccn, and promotes nothing under the floor, because a top 10% of equal
1453
+ weights would be every file. Commits minutes apart already rank. This repo was eight
1454
+ commits old, all made the same day:
1455
+
1456
+ ```
1457
+ $ crapkit worklist --scope util
1458
+ worklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) - 3 of 3 active (worklist_top 50), 0 dormant
1459
+ risk 5.4 ccn 5 crap 30.0 cov 0% 5c/1a util/stats.py:1 bucket( value , low , high )
1460
+ risk 4.5 ccn 9 crap 90.0 cov 0% 1c/1a util/curve.py:1 curve( scores , mode , floor , ceiling , skip_none )
1461
+ risk 4.3 ccn 4 crap 4.2 cov 75% 5c/1a util/stats.py:13 spread( values , cap ) ok
1462
+ -> next: crapkit next-item
1463
+ ```
1464
+
1465
+ `bucket` at ccn 5 outranks `curve` at ccn 9 because five commits touched it and one
1466
+ touched `curve`. That is the whole point of weighting by churn. `spread` carries the `ok`
1467
+ marker: already at or under its ceiling, listed anyway, and `next-item` would not hand it
1468
+ out.
1469
+
1470
+ The list splits in two: **active** (files with commits in the window) and **dormant**
1471
+ (zero churn, kept out of the queue but counted). Two rules reach under the
1472
+ `worklist_floor`. A file whose churn weight sits in the top 10% is promoted down to ccn 3,
1473
+ which is why `spread` appears above at ccn 4. And a function over its ceiling is admitted
1474
+ whatever its ccn, so the floor can never hold back debt.
1475
+
1476
+ ### The trusted baseline
1477
+
1478
+ Every `verify` measures the working tree against one earlier run, the **trusted
1479
+ baseline**. `crapkit runs list` marks which one that is today.
1480
+
1481
+ **Which runs qualify.** A `coverage` run, or a `verify` that passed. A failed `verify`
1482
+ never qualifies, and neither does a `partial` run (a lane failed, so some scope fell back
1483
+ to `no-lane`) nor a `hook` override record, which carries no scored rows at all. In `runs
1484
+ list`, `verdict=-` marks a run that produces no verdict rather than one that failed: only
1485
+ `verify` renders a verdict. Four readers ask this one question and get this one answer: the
1486
+ baseline pick here, `ratchet seed`, `prune`, and the tighten damping that compares a mark
1487
+ against the same commit's previous run. A mark can no longer be signed off a run `verify`
1488
+ refused.
1489
+
1490
+ **What advances it.** Any qualifying run whose commit is at or behind HEAD. `coverage`
1491
+ writes one wherever HEAD is, so a dashboard cron advances the baseline exactly as CI does.
1492
+ A passing `verify` advances it and tightens the ratchet on the way.
1493
+
1494
+ **A run on another branch never serves.** The store keeps every branch's runs, and after
1495
+ `git checkout main` the newest qualifying run can be a feature branch's. `verify`, `ratchet
1496
+ seed`, `ratchet prune` and `runs list` all skip it and read the newest qualifying run in
1497
+ main's own history. When no qualifying run sits behind HEAD, `verify` exits 4 and says why
1498
+ the newest one does not: it was made on a branch HEAD does not contain (run `coverage` on
1499
+ this branch), or a rebase or an amend rewrote its commit.
1500
+
1501
+ **The taint rule.** A failed `verify` recorded findings against a tree. Until some
1502
+ `verify` passes, runs made after that failure do not become the baseline: choosing one
1503
+ would move the comparison point past the findings, the flagged function would stop
1504
+ counting as touched, and nothing would look at it again. `verify` says which run it
1505
+ refused and falls back to the newest run in front of the failure.
1506
+
1507
+ ```
1508
+ $ crapkit runs list
1509
+ run 1 @ 88012a148f6 2026-08-23T09:27:46Z coverage verdict=- lanes=py baseline
1510
+ run 2 @ 803bdde8556 2026-08-23T09:27:53Z verify verdict=FAILED lanes=py
1511
+ run 3 @ 803bdde8556 2026-08-23T09:28:02Z coverage verdict=- lanes=py
1512
+
1513
+ $ crapkit verify
1514
+ warning: run 3 is not the baseline: verify run 2 FAILED with 1 finding(s) and no passing verify has cleared it since - measuring against run 1 @ 88012a148f6 instead, so those findings stay visible. Fix them, or pass `--baseline 3` to accept the newer run deliberately.
1515
+ verify FAILED @ d89068de7f3 vs baseline 88012a148f6 (2 changed files)
1516
+ changed files: calc/legacy.py, tests/test_legacy.py
1517
+ GATE crap 72.0 ccn 8 cov 0% calc/legacy.py:7 legacy_router( a , b , c , d , e ) -> decompose
1518
+ findings: 1 committed / 0 dirty (uncommitted edits and untracked files)
1519
+ ```
1520
+
1521
+ Run 3 is a `coverage` run somebody took on the tree run 2 refused, and it scores the same
1522
+ ccn-8 function. Without the rule it would have become the baseline, `legacy_router` would
1523
+ have stopped being a touched function, and that gate line would never print again.
1524
+
1525
+ **A baseline with no test results for a lane.** A trusted run can hold a lane with no
1526
+ test count and no failure list: the lane declared no `results_artifact` then,
1527
+ `coverage --reuse-artifacts` found its junit gone or unreadable, or a crapkit older than
1528
+ 0.8.1 stored a `verify --reuse-artifacts` run over such a junit. verify does not read that as a
1529
+ suite of zero tests that failed nothing. It compares that lane with the newest trusted run
1530
+ at or behind the baseline's commit that recorded it, and the line names that run:
1531
+
1532
+ ```
1533
+ warning: lane 'py': baseline run 2 recorded no test results, so its failures are compared with run 1's
1534
+ warning: lane 'py' runs 8 fewer tests than run 1 (baseline run 2 recorded no test count for it)
1535
+ ```
1536
+
1537
+ When no run recorded a failure list for the lane, each of its failures still counts as
1538
+ new, exit 8, and a line says it may predate the change; `verify --json` lists the lane
1539
+ under `lanes_without_baseline_results`.
1540
+
1541
+ **A junit this verify cannot read.** `verify --reuse-artifacts` over a lane that declares
1542
+ a `results_artifact` it finds missing or unreadable exits 5 and stores no run, the exit a
1543
+ real run of that lane gets for the same file. The refusal names the lane and the file and
1544
+ ends `run verify without --reuse-artifacts so the lane writes it again`. Before 0.8.1 that
1545
+ verify passed at exit 0 with no test checked and stored a passing run, which became the
1546
+ next baseline. The Action never reaches this case: its `coverage` step runs the lanes and
1547
+ refuses the same junit first. `coverage --reuse-artifacts` over that junit still warns and
1548
+ scores on. A lane that declares no `results_artifact` passes with a stderr line, and
1549
+ `verify --json` lists it under `lanes_without_results`.
1550
+
1551
+ **The escape, twice.** Fix the findings and let a `verify` pass, which clears the taint
1552
+ for good. Or accept the newer run on purpose with `verify --baseline 3`: an explicit id
1553
+ bypasses the rule, and the run history records which run the verdict used. Nothing here
1554
+ touches a repo that has never run `verify`: with no failure to protect, `coverage` alone
1555
+ always advances the baseline.
1556
+
1557
+ `ratchet seed --baseline ID` and `ratchet prune --baseline ID` take the same name as
1558
+ `verify --baseline ID`, admitted by the same rule. When a failed verify pins seed to a run
1559
+ it cannot read or sign, that is the way out
1560
+ ([docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md#naming-the-run-to-seed-from)).
1561
+
1562
+ **When the id you pass cannot serve.** A `--baseline ID` naming a real run that is not a
1563
+ candidate says which run it is, why, and which ones can:
1564
+
1565
+ ```
1566
+ $ crapkit verify --baseline 3
1567
+ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve as a baseline; trusted runs: 1, 2; pass `--baseline 2` for the newest
1568
+ ```
1569
+
1570
+ ## Exit codes
1571
+
1572
+ | Code | Meaning |
1573
+ |---|---|
1574
+ | 0 | OK. For `verify` and `hook-precommit`: the gate passed. |
1575
+ | 1 | **Overloaded.** Three unrelated things, listed below the table. |
1576
+ | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
1577
+ | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch ([Upgrading](#upgrading)), a `test-scoped` file under no scope or under a scope with no template, a scoped file whose name is not UTF-8 or a path argument naming a file whose name is not UTF-8 (both end `rename it (git mv) to a UTF-8 name`), a root `package.json` that `init` cannot read as one UTF-8 JSON object (`init wrote no file: ...`, before it writes any file), a root on a Windows network share (the line gives the `net use` command that maps it to a drive letter). |
1578
+ | 4 | Git error: not a repository, a repository with no commit yet, one git refuses to open (the refusal quotes git's own fix, such as a `safe.directory` exception), a baseline commit rewritten out of the history, made on a branch HEAD does not contain or missing from this clone, a baseline commit or fork point a shallow clone does not hold, `ratchet report --enforce` with a debt key set in a shallow clone (mark ages and repayments need the whole history), a `ratchet prune` that cannot tell whether a marked file was renamed because this clone lacks the commit its renames start from. The shallow refusals end with `set fetch-depth: 0 on the checkout or run git fetch --unshallow`. |
1579
+ | 5 | Tool error: lizard not importable, a lane that produced no artifact, one that measured a different tree, one that measured this tree and reported it in absolute paths (the join is root-relative, so those match nothing either; the refusal names the runner's own switch, `relative_files = true` under `[tool.coverage.run]` for a coveragepy lane, the reporter's `cwd`/`root` option for an istanbul one), a lane that timed out past its retries, `verify --reuse-artifacts` over a lane whose declared `results_artifact` is missing or unreadable (it stores no run), an override alert command that failed, a process with no home directory (`USERPROFILE` on Windows or `HOME` on POSIX unset, and the operating system names none; the message names the variable to set). A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
1580
+ | 6 | Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is pardoned. Also `rescore --gate`, which applies the same rule, and `hook-precommit`, which pardons on the mark's existence instead. All three also refuse a changed file no reader could read (`UNREAD` lines), since they judged none of its functions. |
1581
+ | 7 | Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6. |
1582
+ | 8 | New test failures against the baseline run. Failures the baseline already had do not count, against a `--baseline-tsv` file too: its stamp line lists each lane's failures ([portable records](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/portable-records.md#the-portable-baselines-stamp-line)). |
1583
+ | 9 | Diff-coverage ceiling breached: `diff_uncovered_max` is set and more changed lines than that never ran. A changed file no lane artifact mentions counts every line of its functions. |
1584
+
1585
+ ### Exit 1 means one of three things
1586
+
1587
+ CI cannot tell a crash from a clean policy verdict on the code alone. Which one you got
1588
+ depends on the command:
1589
+
1590
+ | Command | What exit 1 means |
1591
+ |---|---|
1592
+ | `doctor` | A **`FAIL` finding**. This is a verdict, not a crash. A `WARN` (an unmeasured directory, or a lane writing its artifact at the repo root) and a `note` (a file over `max_file_bytes`, or no lanes declared) both exit 0. |
1593
+ | `ratchet report --enforce` | The **debt policy was breached**. Also a verdict. In a shallow clone, with a debt key set, it judges nothing and exits 4. |
1594
+ | anything else | An unexpected error: "no snapshot yet, run `crapkit coverage` first", a `brief` name that matches no function, a `test-scoped` runner that exited non-zero. |
1595
+
1596
+ `verify` reports the **first** of 6, 7, 8, 9 that fires, in that order. A gate violation
1597
+ and a ratchet regression together report 6. A run that takes any of them fails, so it
1598
+ neither advances the baseline nor tightens the ratchet, exit 9 included.
1599
+
1600
+ ## Quickstart: Python
1601
+
1602
+ A repo with `calc/grade.py`, `tests/test_grade.py`, and a `pyproject.toml`. Commit first;
1603
+ crapkit reads `git ls-files`. Install the coverage plugin first, because the lane `init`
1604
+ writes runs `pytest --cov` and those flags come from `pytest-cov`:
1605
+
1606
+ ```
1607
+ pip install pytest-cov "coverage>=7.13.1"
1608
+ ```
1609
+
1610
+ Quote the coverage requirement, or the shell reads `>` as a redirect. Without it, pip
1611
+ keeps an older coverage.py the venv already holds, since pytest-cov accepts it, and the
1612
+ lane fails at exit 5 on its report.
1613
+
1614
+ (`pip install "crapkit[py]"` pulls both at once when crapkit shares the suite's venv.)
1615
+
1616
+ If your suite drives its own CLI through `subprocess.run`, add `[tool.coverage.run]
1617
+ patch = ["subprocess"]` to `pyproject.toml` and keep `coverage>=7.13.1`: pytest-cov 7.0.0
1618
+ dropped subprocess measurement, so without that key every entry point scores 0% and nothing
1619
+ warns. 7.13.1 is also the oldest coverage whose report crapkit reads; an older one fails the
1620
+ lane at exit 5. [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md) has the whole rule.
1621
+
1622
+ ### 1. Scaffold the config
1623
+
1624
+ ```
1625
+ $ crapkit init
1626
+ wrote crapkit.toml with 1 scope(s): calc
1627
+ detected 1 lane(s) from this repo's own files: py - next: run `crapkit coverage`
1628
+ added to .gitignore: .crapkit/, .coverage, __pycache__/
1629
+ ```
1630
+
1631
+ `init` sniffs tracked source into one scope per top-level source directory, and detects a
1632
+ coverage lane from what the repo already has: a pytest marker file (`pyproject.toml`,
1633
+ `pytest.ini`, `setup.cfg`) writes a live `[[lane]]`, and so does a `test` script or
1634
+ `vitest`/`jest` in `package.json`. A lockfile beside them names the environment: `uv.lock`,
1635
+ `poetry.lock`, `pdm.lock` or `Pipfile.lock` makes the lane `uv run python -m pytest …` (and
1636
+ the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
1637
+ has active rather than the one the repo pins; see
1638
+ [The interpreter a lane binds to](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
1639
+ it detects, it also leaves commented templates for the runners it did not find, and those
1640
+ carry the same launcher, so uncommenting one cannot hand the bare `python` back. Every lane
1641
+ it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
1642
+ [Where artifacts live](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md#where-artifacts-live).
1643
+ With no lockfile the lane names its interpreter with a launcher token, so one committed
1644
+ line runs on every OS: `{python}` here, which crapkit reads as `python` on Windows and
1645
+ `python3` on Linux and macOS, or `{python:.venv}` when a `.venv` in the repo holds pytest.
1646
+ [The launcher token](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md#the-launcher-token)
1647
+ lists each form.
1648
+
1649
+ `init` reads each `package.json` past a UTF-8 byte-order mark, as npm does. A root
1650
+ `package.json` that is not one UTF-8 JSON object stops `init` at exit 3 before it writes any
1651
+ file, naming the file and the fix, since a lane read off such a file would be a guess: one in
1652
+ UTF-16 (what PowerShell 5.1's `Out-File` writes), one holding a byte that is not UTF-8, one
1653
+ that does not parse and one that holds an array or another value, as in
1654
+ `init wrote no file: package.json is not UTF-8 (byte e9 at offset 36); save it as UTF-8`. A
1655
+ nested one, a test fixture say, is skipped with one warning line naming it.
1656
+
1657
+ `init` writes `.gitignore` before `crapkit.toml`, and appends in that file's own line ending
1658
+ without touching a byte already there. It reads the lines past a UTF-8 byte-order mark, as git
1659
+ does, so an entry already there is not added twice. A UTF-16 `.gitignore`, which git cannot
1660
+ read either, is named on stderr with the fix and left as it was. Run `init` again over an existing
1661
+ `crapkit.toml` and it adds the `.gitignore` entries its lanes need, says what it finished,
1662
+ exits 0 and leaves `crapkit.toml` byte for byte. Over a UTF-16 `.gitignore` it exits 3 with
1663
+ the line that names that file and the entries to add; with nothing missing it refuses with
1664
+ `already exists`.
1665
+
1666
+ ```toml
1667
+ [crapkit]
1668
+ target = 6
1669
+
1670
+ [[scope]]
1671
+ name = "calc"
1672
+ paths = ["calc"]
1673
+ languages = ["python"]
1674
+
1675
+ [exclude]
1676
+ # A leading **/ matches zero or more directories, so each glob below reaches the
1677
+ # repo root and every nested copy. Test directories leave the corpus on their own.
1678
+ globs = [
1679
+ "**/node_modules/**",
1680
+ "**/dist/**",
1681
+ "**/build/**",
1682
+ "**/vendor/**",
1683
+ "**/generated/**",
1684
+ "**/__generated__/**",
1685
+ "**/*.generated.*",
1686
+ "**/*.test.*",
1687
+ "**/*.spec.*",
1688
+ "**/test_*.py",
1689
+ "**/*_test.py",
1690
+ "**/conftest.py",
1691
+ "**/*_test.go",
1692
+ "**/*.config.ts",
1693
+ "**/*.config.js",
1694
+ "**/*.config.mts",
1695
+ ]
1696
+
1697
+ [[lane]]
1698
+ name = "py"
1699
+ command = "{python} -m pytest --cov --cov-branch --cov-report=json:.crapkit/cov/py.json --junitxml=.crapkit/cov/junit-py.xml --continue-on-collection-errors"
1700
+ artifact = ".crapkit/cov/py.json"
1701
+ results_artifact = ".crapkit/cov/junit-py.xml"
1702
+ parser = "coveragepy"
1703
+ scopes = ["calc"]
1704
+
1705
+ # Declare one [[lane]] per coverage command, then run `crapkit coverage`.
1706
+ # [[lane]]
1707
+ # name = "js"
1708
+ # command = "npx vitest run --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml"
1709
+ # artifact = ".crapkit/cov/js/coverage-final.json"
1710
+ # results_artifact = ".crapkit/cov/js/junit.xml"
1711
+ # parser = "istanbul"
1712
+ # scopes = ["<your-scope>"]
1713
+
1714
+ # `crapkit test-scoped FILES` runs one command per scope, with {files}
1715
+ # replaced by that scope's files, each quoted; a template with no {files}
1716
+ # runs as written, which is how a scope whose tests live elsewhere runs them.
1717
+ [crapkit.scoped_tests]
1718
+ # calc: no test file under calc/, so the whole suite runs, from tests/
1719
+ calc = "{python} -m pytest tests -q -p no:cacheprovider"
1720
+
1721
+ ```
1722
+
1723
+ The last block is the one an agent loop needs. `crapkit test-scoped` exits 3 for a file
1724
+ whose scope declares no template, and [AGENTS.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/AGENTS.md#4-run-the-owning-scopes-tests)
1725
+ makes it step 4 of the burn-down loop. Every key is in
1726
+ [docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md).
1727
+
1728
+ ### 2. Check the config against the repo
1729
+
1730
+ ```
1731
+ $ crapkit doctor
1732
+ resources: up to 8 analysis worker(s) per pool, 8 shared slot(s); lane log limit 16777216 bytes per file
1733
+ ok config keys all recognized
1734
+ ok scope 'calc': 1 file
1735
+ ok every tracked source file belongs to a scope
1736
+ ok 1 lane(s) declared
1737
+ ok lane 'py': python3 -> /home/you/.venvs/ledger/bin/python3 (pytest 8.3.3, pytest-cov 7.1.0, coverage 7.13.1)
1738
+ ok lizard 1.24.0
1739
+ doctor: no problems found
1740
+ ```
1741
+
1742
+ `doctor` prints one line per check and exits 1 only on a `FAIL`. `WARN` and `note` report
1743
+ and exit 0, and the closing line counts the WARNs above it (`doctor: no problems found, 1
1744
+ warning above`).
1745
+
1746
+ ### 3. Score the repo, and read the queue
1747
+
1748
+ ```
1749
+ $ crapkit coverage
1750
+ run 1 @ fae4db93108: 2 functions scored: 2 measured, 1 over ceiling 6, CRAP load 41.0, grade F
1751
+ -> next: crapkit worklist
1752
+
1753
+ $ crapkit worklist
1754
+ worklist @ fae4db93108 (run 1, floor ccn>=5, churn 12mo) - 1 of 1 active (worklist_top 50), 0 dormant
1755
+ risk 14.0 ccn 14 crap 38.5 cov 50% 1c/1a calc/grade.py:7 classify( score , attempts , late , bonus )
1756
+ no crapkit-ratchet.tsv yet: seed marks each function over its ceiling at today's score, and from then on a mark may only fall
1757
+ -> next: crapkit ratchet seed
1758
+ ```
1759
+
1760
+ Columns: `risk`, `ccn`, the function's `crap` and `cov` off the ranked run (both `-` on an
1761
+ inventory-only run, and `cov` `-` on a `no-lane` or `cc-only` row, which no lane
1762
+ measured), `<commits>c/<authors>a` in the churn window, `path:line`, the function's long
1763
+ name, then a marker on rows the burn-down queue will not hand out (`ok`, `no-lane`). The
1764
+ header counts the active rows against their total, so `50 of 3980 active (worklist_top 50)`
1765
+ says what the cap hid, and reads `(--top N)` when the flag set the cap.
1766
+ `--json` also carries `ccn_std`, `weight` and `ratchet_mark`.
1767
+
1768
+ The last line names the step after this one. It is `ratchet seed` while the repo has no
1769
+ marks file, `next-item` once it has one, and `coverage` when the ranked run cannot serve
1770
+ as a baseline: an inventory run, a partial run or a failed verify. This walk looks at the
1771
+ top item first, in step 4, and seeds in step 5.
1772
+
1773
+ **`worklist` is the risk map, not a to-do list.** It ranks finished rows too, so it does
1774
+ not empty when the burn-down does. `next-item` is the other view of that run: it drops the
1775
+ `no-lane` rows, ranks by `crap`, and its `empty: true` is the stop condition.
1776
+
1777
+ ### 4. Take the top item
1778
+
1779
+ ```
1780
+ $ crapkit next-item
1781
+ {"commands": {"refresh": "crapkit coverage --reuse-unchanged"}, "commit": "fae4db93108b4841a00959f9117430679e7250ca", "empty": false, "item": {"authors": 1, "ccn": 14, "ccn_std": 14, "cognitive": 13, "commits": 1, "cov": 0.5, "crap": 38.5, "end": 28, "est_splits": 3, "est_uncovered_paths": 7, "flag": "measured", "function": "classify( score , attempts , late , bonus )", "handle": "classify", "nesting": 3, "nloc": 22, "occurrence": 1, "path": "calc/grade.py", "remedy": "decompose", "scope": "calc", "start": 7, "target": 6, "uncovered_lines": [9, 11, 15, 17, 19, 24, 25, 26, 27, 28], "unmeasured": false}, "run_id": 1, "schema": 1, "scored_changes": 0, "shallow": false, "skipped_no_lane": 0, "stale": false}
1782
+ ```
1783
+
1784
+ `remedy: "decompose"`, `est_splits: 3` (this needs roughly three pieces to fit under 6),
1785
+ and `uncovered_lines` naming the ten lines no test walks. `handle` is the name form to
1786
+ pass back. `scored_changes: 0` says no file the run scored holds other content now, so
1787
+ these numbers describe the files on disk; anything but `0`, `null` included, means run
1788
+ `commands.refresh` before acting on them. `stale: false` says HEAD is still the run's
1789
+ commit, a question about the commit and not the files. Every field is in
1790
+ [docs/agent-json.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md).
1791
+
1792
+ ### 5. Seed the ratchet
1793
+
1794
+ Arm the debt gate before fixing anything. `ratchet seed` records every over-target
1795
+ function at its current score, and from then on nothing may get worse.
1796
+
1797
+ ```
1798
+ $ crapkit ratchet seed
1799
+ crapkit-ratchet.tsv: added 1, tightened 0 - 1 mark(s) vs run 1 (fae4db93108)
1800
+ -> next: commit crapkit-ratchet.tsv, then run `crapkit verify`
1801
+
1802
+ $ git add crapkit.toml crapkit-ratchet.tsv .gitignore && git commit -m "adopt crapkit"
1803
+ ```
1804
+
1805
+ A `verify` here would pass and give the repo its first passing verdict. This walk runs it
1806
+ in step 6, after the fix.
1807
+
1808
+ ### 6. Fix it and verify
1809
+
1810
+ Extract until every piece sits at or under the ceiling. Here `classify` became
1811
+ `_validate`, `_adjusted`, `_band` and a `classify` that only sequences them, with the
1812
+ table of cases pushed into parametrized tests. Commit the fix, then:
1813
+
1814
+ ```
1815
+ $ crapkit verify
1816
+ verify OK @ 8d10c13303d vs baseline fae4db93108 (5 changed files) ratchet: 1 dropped, 0 tightened -> git add crapkit-ratchet.tsv
1817
+ changed files: calc/grade.py, .gitignore, crapkit-ratchet.tsv and 2 more
1818
+
1819
+ $ crapkit coverage
1820
+ run 3 @ 8d10c13303d: 5 functions scored: 5 measured, 0 over ceiling 6, CRAP load 19.0, grade A+
1821
+ -> next: crapkit worklist
1822
+ ```
1823
+
1824
+ CRAP load 41.0 to 19.0, grade F to A+. `verify` reruns the lanes and checks three things
1825
+ against the trusted baseline: every function the diff touched sits at or under its
1826
+ ceiling, no marked function got worse, and no test that passed in the baseline fails now.
1827
+ Exit 0 advances the baseline and tightens `crapkit-ratchet.tsv` in place, so the repaid
1828
+ mark leaves the file: follow up with `git commit -am "ratchet: classify repaid"`. The full
1829
+ mark lifecycle is in [docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md).
1830
+
1831
+ `crapkit next-item` now comes back `empty: true` with a `reasons` object saying which
1832
+ ending you got. That is most of the stop condition, not all of it:
1833
+ [AGENTS.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/AGENTS.md#the-termination-rule) states the whole rule and reads the rest of
1834
+ `reasons`.
1835
+
1836
+ ## Quickstart: TypeScript
1837
+
1838
+ A vitest repo with `src/grade.ts` and `test/grade.test.ts`.
1839
+
1840
+ ### 1. Scaffold the config
1841
+
1842
+ ```
1843
+ $ crapkit init
1844
+ wrote crapkit.toml with 1 scope(s): src
1845
+ detected 1 lane(s) from this repo's own files: js - next: run `crapkit coverage`
1846
+ added to .gitignore: .crapkit/
1847
+ ```
1848
+
1849
+ The lane `init` wrote is
1850
+ `npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml`.
1851
+ It reads vitest's `json` reporter from `.crapkit/cov/js/coverage-final.json`; the
1852
+ `reportsDirectory` flag is what keeps that report out of your root. The junit half is the
1853
+ lane's `results_artifact`, which the crashed-worker and no-new-failures checks read; both
1854
+ reporters are named because `--reporter=junit` alone would replace the console output you
1855
+ watch the suite through. Anything that produces
1856
+ an istanbul `coverage-final.json` works; see [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md) for the
1857
+ [jest](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md#jest) and [pytest](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md#pytest) recipes, a package
1858
+ [one directory down](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md#running-from-a-subdirectory), and a
1859
+ [crapkit root below the repo top](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md#a-crapkit-root-below-the-repo-top).
1860
+
1861
+ `init` writes the vitest line under `[crapkit.scoped_tests]` commented out, because it
1862
+ cannot see which vitest config a file-scoped run needs. Until you uncomment that line or
1863
+ write your own, `crapkit doctor` WARNs `scope 'src' has a lane but no
1864
+ [crapkit.scoped_tests] template`. The WARN leaves doctor's exit at 0; it means `crapkit
1865
+ test-scoped` exits 3 on files under `src/`.
1866
+
1867
+ ### 2. Install a coverage provider
1868
+
1869
+ **This is the step that stops most TypeScript users.** vitest ships no coverage provider
1870
+ by default. Without one, `init` and `doctor` are both happy and `coverage` dies with
1871
+ exit 5:
1872
+
1873
+ ```
1874
+ $ crapkit coverage
1875
+ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); lane log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
1876
+
1877
+ > app@1.0.0 test
1878
+ > vitest run --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
1879
+
1880
+ MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
1881
+
1882
+ (exit 1)
1883
+ crapkit: every lane failed (1 of 1); the errors are above
1884
+ ```
1885
+
1886
+ The two `>` lines are npm's banner: `> <name>@<version> test` from your package.json (`> test`
1887
+ when it has no name or version), then the command the `test` script runs.
1888
+
1889
+ That failure **writes no run**. Every lane failed, so `coverage` exits before it opens a
1890
+ store: there is no `.crapkit/crap.sqlite` yet and the run ids below still start at 1.
1891
+
1892
+ Install the provider, and pin the major yourself. Unpinned, npm resolves the newest
1893
+ provider against your older vitest and refuses the tree:
1894
+
1895
+ ```
1896
+ npm i -D "@vitest/coverage-v8@<your vitest major>"
1897
+ ```
1898
+
1899
+ | Question | Answer |
1900
+ |---|---|
1901
+ | Which provider? | Either works. `@vitest/coverage-v8` is vitest's default and needs no config. `@vitest/coverage-istanbul` also works and needs `coverage.provider = "istanbul"` in your vitest config. |
1902
+ | Which crapkit parser? | Both feed `parser = "istanbul"`. The provider name and the parser name are unrelated: v8 output is remapped to the istanbul JSON schema before it is written. |
1903
+ | Which version? | The provider's major has to match vitest's. Read your vitest major with `npm ls vitest`, then install the provider at that major: on vitest 5, `npm i -D "@vitest/coverage-v8@5"`. Drop the pin and npm answers `ERESOLVE unable to resolve dependency tree`, naming the peer it could not satisfy. |
1904
+
1905
+ The artifact crapkit wants is `coverage-final.json`, written by vitest's `json` coverage
1906
+ reporter, which is on by default. If your vitest config sets `coverage.reporter`
1907
+ explicitly, keep `"json"` in the list.
1908
+
1909
+ vitest writes **no coverage report at all when the run fails**. The lane `init` wrote
1910
+ already carries `--coverage.reportOnFailure`, so a red test still produces the artifact.
1911
+ If you write the lane by hand, or you would rather keep the switch beside your other
1912
+ coverage settings, `coverage.reportOnFailure = true` in the vitest config does the same
1913
+ job; either one is enough. The full block is in
1914
+ [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md#reportonfailure).
1915
+
1916
+ ### 3. Score the repo
1917
+
1918
+ ```
1919
+ $ crapkit coverage
1920
+ run 1 @ 8bfbe613fcd: 2 functions scored: 2 measured, 1 over ceiling 6, CRAP load 56.68, grade F
1921
+ -> next: crapkit worklist
1922
+
1923
+ $ crapkit worklist
1924
+ worklist @ 8bfbe613fcd (run 1, floor ccn>=5, churn 12mo) - 1 of 1 active (worklist_top 50), 0 dormant
1925
+ risk 15.0 ccn 15 crap 52.4 cov 45% 1c/1a src/grade.ts:8 classify ( row Row )
1926
+ no crapkit-ratchet.tsv yet: seed marks each function over its ceiling at today's score, and from then on a mark may only fall
1927
+ -> next: crapkit ratchet seed
1928
+ ```
1929
+
1930
+ `classify` is ccn 15 against a ceiling of 6: one function holding the late-and-retry
1931
+ penalty, the letter bands, the demotion rule and the null case.
1932
+
1933
+ ### 4. Seed the ratchet and commit
1934
+
1935
+ `ratchet seed` records every over-target function at the score it has today, so nothing
1936
+ can get worse while you burn this one down.
1937
+
1938
+ ```
1939
+ $ crapkit ratchet seed
1940
+ crapkit-ratchet.tsv: added 1, tightened 0 - 1 mark(s) vs run 1 (8bfbe613fcd)
1941
+ -> next: commit crapkit-ratchet.tsv, then run `crapkit verify`
1942
+
1943
+ $ git add crapkit.toml crapkit-ratchet.tsv .gitignore && git commit -m "adopt crapkit"
1944
+ ```
1945
+
1946
+ ### 5. Fix it
1947
+
1948
+ Above the ceiling, coverage cannot help, so `classify` gets split rather than tested.
1949
+ `penalty`, `band` and `demote` come out as their own exported functions, and `classify`
1950
+ keeps the null case and the bonus:
1951
+
1952
+ ```ts
1953
+ export function classify(row: Row): string {
1954
+ if (row.score === null) {
1955
+ return "N/A";
1956
+ }
1957
+ let score = row.score - penalty(row.attempts, row.late);
1958
+ if (row.bonus && score < 90) {
1959
+ score += 3;
1960
+ }
1961
+ return demote(band(score), row);
1962
+ }
1963
+ ```
1964
+
1965
+ `rescore --gate` judges that edit on complexity alone, before the slow step:
1966
+
1967
+ ```
1968
+ $ crapkit rescore src/grade.ts --gate
1969
+ rescore vs run 1 @ 8bfbe613fcd (coverage STALE, complexity fresh)
1970
+ ccn cov crap remedy function
1971
+ 5 - 30.0 add-tests src/grade.ts:22 band ( score ) (coverage not measured)
1972
+ 5 - 30.0 add-tests src/grade.ts:38 demote ( letter , row Row ) (coverage not measured)
1973
+ 4 - 20.0 add-tests src/grade.ts:8 penalty ( attempts , late ) (coverage not measured)
1974
+ 4 45% 6.7 add-tests src/grade.ts:48 classify ( row Row )
1975
+ 4 75% 4.2 ok src/grade.ts:59 average ( scores Array )
1976
+ gate: 4 changed function(s) judged, 0 over ceiling 6
1977
+ ```
1978
+
1979
+ Exit 0 and the `gate:` line: every changed piece is at or under 6. The `crap` column is loud because its coverage half
1980
+ is still run 1's, from before three of those functions existed. Run 1 has no row for
1981
+ `band`, `demote` or `penalty`, so each prints `-` for `cov`, ends `(coverage not
1982
+ measured)` and scores as if no test ran it. `add-tests` is the literal instruction for
1983
+ step 6.
1984
+
1985
+ ### 6. Cover the new pieces
1986
+
1987
+ `rescore --gate` passed on complexity, not on coverage. `penalty`, `band` and `demote` are
1988
+ three functions no test has ever called, so each gets a table test:
1989
+
1990
+ ```ts
1991
+ describe("band", () => {
1992
+ it.each([[95, "A"], [85, "B"], [75, "C"], [65, "D"], [10, "F"]])(
1993
+ "scores %i as %s", (score, expected) => expect(band(score)).toBe(expected));
1994
+ });
1995
+ ```
1996
+
1997
+ Run the suite once before the slow step:
1998
+
1999
+ ```
2000
+ $ npx vitest run
2001
+ Test Files 1 passed (1)
2002
+ Tests 21 passed (21)
2003
+ ```
2004
+
2005
+ Skip this step and step 7 fails rather than passes. Run on a copy of this repo with steps
2006
+ 1 to 5 as written, step 5 left uncommitted and step 6 left out, `verify` reruns the lanes
2007
+ against the real tree and three functions the old suite never called come back over the
2008
+ ceiling. Each GATE line ends `[dirty]` because the edit is not committed, and the last line
2009
+ counts the findings that way. The block leaves out the two warnings verify prints above its
2010
+ verdict, one for changed lines no test covers and one for debt no mark signs:
2011
+
2012
+ ```
2013
+ $ crapkit verify
2014
+ verify FAILED @ 0296156ff21 vs baseline 8bfbe613fcd (6 changed files)
2015
+ changed files: src/grade.ts, .gitignore, crapkit-ratchet.tsv and 3 more
2016
+ GATE crap 17.8 ccn 5 cov 20% src/grade.ts:38 demote ( letter , row Row ) -> add-tests [dirty]
2017
+ GATE crap 12.4 ccn 5 cov 33% src/grade.ts:22 band ( score ) -> add-tests [dirty]
2018
+ GATE crap 10.8 ccn 4 cov 25% src/grade.ts:8 penalty ( attempts , late ) -> add-tests [dirty]
2019
+ findings: 0 committed / 3 dirty (uncommitted edits and untracked files)
2020
+ ```
2021
+
2022
+ ### 7. Verify
2023
+
2024
+ ```
2025
+ $ crapkit verify
2026
+ verify OK @ 2af3433d979 vs baseline 8bfbe613fcd (7 changed files) ratchet: 1 dropped, 0 tightened -> git add crapkit-ratchet.tsv
2027
+ changed files: src/grade.ts, .gitignore, crapkit-ratchet.tsv and 4 more
2028
+
2029
+ $ crapkit coverage
2030
+ run 3 @ 2af3433d979: 5 functions scored: 5 measured, 0 over ceiling 6, CRAP load 22.0, grade A+
2031
+ -> next: crapkit worklist
2032
+ ```
2033
+
2034
+ CRAP load 56.68 to 22.0, grade F to A+, and the mark seeded in step 4 is gone: `verify`
2035
+ dropped it once `classify` scored under the ceiling, rewriting the tracked
2036
+ `crapkit-ratchet.tsv` in place. Commit it with your change. Marks only ever fall.
2037
+
2038
+ The seven changed files are everything since run 1's commit: the three files step 4
2039
+ committed, the `package.json` and `package-lock.json` that step 2's install changed, and
2040
+ the two files steps 5 and 6 edited.
2041
+
2042
+ A verify may also print `warning: N changed line(s) have no coverage` above its verdict;
2043
+ that block is advisory unless `diff_uncovered_max` is set
2044
+ ([docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md)).
2045
+ It prints `warning: N function(s) over the ceiling carry no ratchet mark` and names the
2046
+ first three when the tree holds debt `ratchet seed` never signed: the gate judges touched functions only and the
2047
+ ratchet check compares marks only, so coverage loss on such a function would pass unseen.
2048
+ The count is `unmarked_over_target` in `--json`, fires no exit code, and is zero on a repo
2049
+ with no debt.
2050
+
2051
+ ## Documentation
2052
+
2053
+ | Page | Covers |
2054
+ |---|---|
2055
+ | [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. The same page ships in the repository as `docs/handbook.html`, self-contained, so it opens straight from a clone. |
2056
+ | [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. |
2057
+ | [docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md) | Every `crapkit.toml` key: type, default, and what it does. |
2058
+ | [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. |
2059
+ | [docs/resources.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md) | Worker budgets, command cleanup, log rotation and safe cleanup. |
2060
+ | [docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md) | Seeding, pruning, the git merge driver, metric stamps, debt policy, overrides. |
2061
+ | [docs/upgrading.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md) | Existing installations: analysis and key versions, saved state, plugin alignment and Windows upgrades. |
2062
+ | [docs/portable-records.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/portable-records.md) | Lossless exports, portable baselines and ratchets, including filenames with delimiters. |
2063
+ | [docs/commands.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/commands.md) | What `doctor`, `mutate` and `claude-hook` check, print and refuse, and which lines `duplication` compares, past what a Subcommands row holds. |
2064
+ | [docs/agent-json.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md) | The machine surface: `schema`, every payload field, real captured examples. |
2065
+ | [docs/harnesses.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/harnesses.md) | Wiring crapkit into 27 agents: each one's MCP config, whether it runs the advisory hook, and how it restarts after an upgrade. |
2066
+ | [docs/comparison.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/comparison.md) | Where crapkit sits next to radon, xenon, wily, coverage.py and SonarQube, and how they run together. |
2067
+ | [docs/accuracy.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/accuracy.md) | How crapkit checks its own numbers: each calculation against outside tools, hand tables and models, the tiers that run the checks, and every place crapkit reads a construct differently from an oracle on purpose. |
2068
+ | [AGENTS.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/AGENTS.md) | The burn-down loop an agent runs, and the rules for changing crapkit itself. |
2069
+ | [CHANGELOG.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/CHANGELOG.md) | What each release changed and how to upgrade to it. |
2070
+ | [plugin/](https://github.com/JeanFrancoisGagne/crapkit/tree/main/plugin) | Three skills and the MCP server for Claude Code and Codex, and the advisory PostToolUse hook that Claude Code, Cursor, Copilot CLI and VS Code run. |
2071
+
2072
+ [crapkit.schema.json](https://github.com/JeanFrancoisGagne/crapkit/blob/main/crapkit.schema.json) is the authority on the config file shape.
2073
+
2074
+ ## Development
2075
+
2076
+ ```
2077
+ pip install -e ".[dev,accuracy-push]"
2078
+ git config core.hooksPath git-hooks
2079
+ python tools/testing/run.py
2080
+ ```
2081
+
2082
+ The dev extra includes pytest, pytest-cov, pytest-xdist and coverage.py; the
2083
+ accuracy-push extra holds the pinned oracles of the
2084
+ [calculation-accuracy suite](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/accuracy.md).
2085
+ The shared runner owns the unit and E2E schedule; use `--unit-workers 1` for serial unit
2086
+ reproduction or `--coverage` for combined branch coverage and JUnit. The `git config`
2087
+ line arms the complexity gate on commits and change control on pushes. See
2088
+ [CONTRIBUTING.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/CONTRIBUTING.md)
2089
+ for development, [docs/accuracy.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/accuracy.md)
2090
+ for how crapkit checks its own numbers, and
2091
+ [tools/deploy/README.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/tools/deploy/README.md)
2092
+ for the install tests each release passes.
2093
+
2094
+ ## Maintainer and project background
2095
+
2096
+ crapkit is created and maintained by [Jean-François Gagné](https://www.jfgagne.com/).
2097
+ Read the [project background](https://www.jfgagne.com/projects/crapkit/) for the
2098
+ problem it addresses and how it fits into his work on software and AI.
2099
+
2100
+ ## License
2101
+
2102
+ MIT. See [LICENSE](https://github.com/JeanFrancoisGagne/crapkit/blob/main/LICENSE).