crapkit 0.7.2__tar.gz → 0.7.4__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.
- {crapkit-0.7.2/src/crapkit.egg-info → crapkit-0.7.4}/PKG-INFO +5 -5
- {crapkit-0.7.2 → crapkit-0.7.4}/README.md +4 -4
- {crapkit-0.7.2 → crapkit-0.7.4}/pyproject.toml +1 -1
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/__init__.py +1 -1
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/analyze.py +109 -12
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/admin.py +9 -5
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/scoring.py +13 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/verifying.py +17 -1
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/doctor.py +67 -16
- crapkit-0.7.4/src/crapkit/lizardpython.py +262 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/mcp_server.py +22 -18
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/procs.py +44 -1
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/retention.py +20 -2
- {crapkit-0.7.2 → crapkit-0.7.4/src/crapkit.egg-info}/PKG-INFO +5 -5
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit.egg-info/SOURCES.txt +1 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/LICENSE +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/setup.cfg +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/__main__.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_analysis_pool.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_mcp_stdio.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_process_family.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_process_owner.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_pygdefer.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_windows_job.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cache.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/churn.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/churn_cache.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/churn_log.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/__init__.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/_shared.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/analyses.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/claude_hook.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/maintenance.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/parser.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/queue.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/ratchet_cmds.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/reports.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/config.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/config_contract.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/coupling.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/coupling_cache.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/coverage_istanbul.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/coverage_py.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/covstream.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/diffparse.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/digest.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/dup.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/errors.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/gitio.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/gitpaths.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/hook.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/invocation.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/junitparse.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/keys.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lanes.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardcognitive.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardpowershell.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardrust.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardshell.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardtypescript.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/locks.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/logs.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/merge.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/mutate.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/mutate_pool.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/override.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/packet.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/ratchet.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/ratchet_report.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/ratchetfile.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/records.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/report.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/repotext.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/resources.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/rootfind.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/sarif.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/sarifio.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/scaffold.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/score.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/snapshot.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/store.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/uncovered.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/universe.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/verify.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/watch.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/worklist.py +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit.egg-info/dependency_links.txt +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit.egg-info/entry_points.txt +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit.egg-info/requires.txt +0 -0
- {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crapkit
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.4
|
|
4
4
|
Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
|
|
5
5
|
Author: Jean-Francois Gagne
|
|
6
6
|
License: MIT
|
|
@@ -199,7 +199,7 @@ files. Review those commands before running Crapkit in a repository you do not t
|
|
|
199
199
|
|
|
200
200
|
```
|
|
201
201
|
$ crapkit --version
|
|
202
|
-
crapkit 0.7.
|
|
202
|
+
crapkit 0.7.4
|
|
203
203
|
```
|
|
204
204
|
|
|
205
205
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -455,7 +455,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
455
455
|
repos:
|
|
456
456
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
457
457
|
# crapkit's release step rewrites this line to the tag it just cut
|
|
458
|
-
rev: v0.7.
|
|
458
|
+
rev: v0.7.4
|
|
459
459
|
hooks:
|
|
460
460
|
- id: crapkit-gate
|
|
461
461
|
```
|
|
@@ -563,7 +563,7 @@ to a workflow, and every input has a default:
|
|
|
563
563
|
- uses: actions/checkout@v4
|
|
564
564
|
with:
|
|
565
565
|
fetch-depth: 0
|
|
566
|
-
- uses: JeanFrancoisGagne/crapkit@v0.7.
|
|
566
|
+
- uses: JeanFrancoisGagne/crapkit@v0.7.4
|
|
567
567
|
```
|
|
568
568
|
|
|
569
569
|
The whole job those four lines sit in:
|
|
@@ -583,7 +583,7 @@ jobs:
|
|
|
583
583
|
with:
|
|
584
584
|
python-version: "3.12" # the interpreter the install below lands in
|
|
585
585
|
- run: pip install -e ".[dev]" # whatever your lanes need to run
|
|
586
|
-
- uses: JeanFrancoisGagne/crapkit@v0.7.
|
|
586
|
+
- uses: JeanFrancoisGagne/crapkit@v0.7.4
|
|
587
587
|
with:
|
|
588
588
|
gate: "false"
|
|
589
589
|
```
|
|
@@ -158,7 +158,7 @@ files. Review those commands before running Crapkit in a repository you do not t
|
|
|
158
158
|
|
|
159
159
|
```
|
|
160
160
|
$ crapkit --version
|
|
161
|
-
crapkit 0.7.
|
|
161
|
+
crapkit 0.7.4
|
|
162
162
|
```
|
|
163
163
|
|
|
164
164
|
`python -m crapkit` works identically to the console script and is what to use from a
|
|
@@ -414,7 +414,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
|
|
|
414
414
|
repos:
|
|
415
415
|
- repo: https://github.com/JeanFrancoisGagne/crapkit
|
|
416
416
|
# crapkit's release step rewrites this line to the tag it just cut
|
|
417
|
-
rev: v0.7.
|
|
417
|
+
rev: v0.7.4
|
|
418
418
|
hooks:
|
|
419
419
|
- id: crapkit-gate
|
|
420
420
|
```
|
|
@@ -522,7 +522,7 @@ to a workflow, and every input has a default:
|
|
|
522
522
|
- uses: actions/checkout@v4
|
|
523
523
|
with:
|
|
524
524
|
fetch-depth: 0
|
|
525
|
-
- uses: JeanFrancoisGagne/crapkit@v0.7.
|
|
525
|
+
- uses: JeanFrancoisGagne/crapkit@v0.7.4
|
|
526
526
|
```
|
|
527
527
|
|
|
528
528
|
The whole job those four lines sit in:
|
|
@@ -542,7 +542,7 @@ jobs:
|
|
|
542
542
|
with:
|
|
543
543
|
python-version: "3.12" # the interpreter the install below lands in
|
|
544
544
|
- run: pip install -e ".[dev]" # whatever your lanes need to run
|
|
545
|
-
- uses: JeanFrancoisGagne/crapkit@v0.7.
|
|
545
|
+
- uses: JeanFrancoisGagne/crapkit@v0.7.4
|
|
546
546
|
with:
|
|
547
547
|
gate: "false"
|
|
548
548
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "crapkit"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.4"
|
|
8
8
|
description = "Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more."
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crapkit: deterministic CRAP-score framework."""
|
|
2
|
-
__version__ = "0.7.
|
|
2
|
+
__version__ = "0.7.4"
|
|
@@ -19,8 +19,10 @@ from ._pygdefer import deferred_pygments
|
|
|
19
19
|
with deferred_pygments(): # lizard's Erlang reader would load pygments here
|
|
20
20
|
import lizard
|
|
21
21
|
from lizard_languages import get_reader_for as _lizard_reader_for
|
|
22
|
+
from lizard_languages.python import PythonReader as _PythonReader
|
|
22
23
|
|
|
23
24
|
from .lizardpowershell import register as _register_powershell
|
|
25
|
+
from .lizardpython import register as _register_python
|
|
24
26
|
from .lizardrust import register as _register_rust
|
|
25
27
|
from .lizardshell import register as _register_shell
|
|
26
28
|
from .lizardtypescript import LizardExtension as _TypeScriptExpressions
|
|
@@ -34,18 +36,22 @@ from .packet import bare_name
|
|
|
34
36
|
|
|
35
37
|
# lizard picks a reader by extension off a hardcoded list, and none of these is
|
|
36
38
|
# on it: `.rs` resolves to a reader that counts no `match` arm (lizard #494),
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
39
|
+
# `.py` to one that ends a def inside a signature that runs past its first `)`
|
|
40
|
+
# (crapkit #72), and `.sh` and `.ps1` resolve to nothing at all, which lizard
|
|
41
|
+
# answers with CLikeReader rather than a failure. All four belong HERE, at the
|
|
42
|
+
# module scope of the module a ProcessPoolExecutor child imports, or spawned
|
|
43
|
+
# workers measure with the readers lizard shipped and report plausible wrong
|
|
44
|
+
# numbers.
|
|
41
45
|
#
|
|
42
|
-
# lizardshell and lizardpowershell already register themselves on import and
|
|
43
|
-
# lizardrust deliberately
|
|
44
|
-
# namespace is not something an import should do quietly). Calling
|
|
45
|
-
# keeps the wiring readable in one place and costs nothing: each is
|
|
46
|
+
# lizardshell and lizardpowershell already register themselves on import, and
|
|
47
|
+
# lizardrust and lizardpython deliberately do not (rebinding a name in another
|
|
48
|
+
# package's namespace is not something an import should do quietly). Calling
|
|
49
|
+
# all four keeps the wiring readable in one place and costs nothing: each is
|
|
50
|
+
# idempotent.
|
|
46
51
|
_register_rust()
|
|
47
52
|
_register_shell()
|
|
48
53
|
_register_powershell()
|
|
54
|
+
_register_python()
|
|
49
55
|
|
|
50
56
|
_POOL_THRESHOLD = 16
|
|
51
57
|
|
|
@@ -145,6 +151,67 @@ class _CreationOrder:
|
|
|
145
151
|
context.try_new_function = original
|
|
146
152
|
|
|
147
153
|
|
|
154
|
+
# --- a Python def no reader finished (#72) -----------------------------------------
|
|
155
|
+
#
|
|
156
|
+
# lizard 1.24.0's PythonReader ended a def inside its own signature when the
|
|
157
|
+
# signature ran past its first `)`: a return annotation opened on the def line,
|
|
158
|
+
# or a line break after a default such as `()`. The def read as two lines at ccn
|
|
159
|
+
# 1 whatever its body held and passed every gate on that reading.
|
|
160
|
+
# crapkit.lizardpython reads those signatures to the body's colon. Measured on
|
|
161
|
+
# 6,866 stdlib, site-packages and openclaw files: all 59 defs lizard cut off read
|
|
162
|
+
# their full span, and of the defs lizard read whole only the 20 nested inside a
|
|
163
|
+
# cut-off def changed, each gaining its parent's name as a prefix (one of them
|
|
164
|
+
# also scores 1 lower on cognitive).
|
|
165
|
+
#
|
|
166
|
+
# This check stays as the net under that reader. A def read to its body has a
|
|
167
|
+
# `:` at bracket depth 0 with a token after it; one cut off in its signature has
|
|
168
|
+
# not. A def the reader still cannot finish is refused, never scored at ccn 1.
|
|
169
|
+
_OPENERS = frozenset("([{")
|
|
170
|
+
_CLOSERS = frozenset(")]}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _step_body(fn, token: str) -> None:
|
|
174
|
+
"""Advance one function's signature reading by one of its own tokens.
|
|
175
|
+
|
|
176
|
+
`crapkit_body` is False from the name token on and True from the first
|
|
177
|
+
token after the body colon. A function some other reader produced never
|
|
178
|
+
gets the attribute, which is what keeps `_unread_defs` to Python.
|
|
179
|
+
"""
|
|
180
|
+
if getattr(fn, "crapkit_colon", False):
|
|
181
|
+
fn.crapkit_body = True
|
|
182
|
+
return
|
|
183
|
+
fn.crapkit_body = False
|
|
184
|
+
depth = getattr(fn, "crapkit_depth", 0)
|
|
185
|
+
if token in _OPENERS:
|
|
186
|
+
fn.crapkit_depth = depth + 1
|
|
187
|
+
elif token in _CLOSERS:
|
|
188
|
+
fn.crapkit_depth = depth - 1
|
|
189
|
+
elif token == ":" and depth == 0:
|
|
190
|
+
fn.crapkit_colon = True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _PythonBodies:
|
|
194
|
+
"""Mark every Python function whose body lizard reached.
|
|
195
|
+
|
|
196
|
+
Reads a token's owner after lizard has, the way the cognitive pass does:
|
|
197
|
+
the name token is what creates the function, and the line that ends one
|
|
198
|
+
was charged to its parent by `preprocess`, upstream of here, before the
|
|
199
|
+
token arrived. Sits behind `line_counter`, so no whitespace or newline
|
|
200
|
+
token reaches it and any token after the body colon is body.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __call__(self, tokens, reader):
|
|
204
|
+
if not isinstance(reader, _PythonReader):
|
|
205
|
+
yield from tokens
|
|
206
|
+
return
|
|
207
|
+
context = reader.context
|
|
208
|
+
for token in tokens:
|
|
209
|
+
yield token
|
|
210
|
+
fn = context.current_function
|
|
211
|
+
if fn is not context.global_pseudo_function:
|
|
212
|
+
_step_body(fn, token)
|
|
213
|
+
|
|
214
|
+
|
|
148
215
|
def _chain(cognitive_index: int) -> list:
|
|
149
216
|
"""lizard's standard extensions with cognitive spliced in at one index.
|
|
150
217
|
|
|
@@ -155,7 +222,7 @@ def _chain(cognitive_index: int) -> list:
|
|
|
155
222
|
"""
|
|
156
223
|
extensions = lizard.get_extensions(["ND"])
|
|
157
224
|
extensions.insert(cognitive_index, _Cognitive())
|
|
158
|
-
return [_TypeScriptExpressions(), *extensions, _ModifiedDelta(), _CreationOrder()]
|
|
225
|
+
return [_TypeScriptExpressions(), *extensions, _ModifiedDelta(), _PythonBodies(), _CreationOrder()]
|
|
159
226
|
|
|
160
227
|
|
|
161
228
|
# Two chains, built once per process each, not once per file: 14k files paid 14k
|
|
@@ -279,6 +346,35 @@ def _file_records(rel_path: str, functions) -> list[FunctionRecord]:
|
|
|
279
346
|
return [_record(rel_path, fn, occurrences[id(fn)]) for fn in functions]
|
|
280
347
|
|
|
281
348
|
|
|
349
|
+
def _unread_defs(functions) -> list:
|
|
350
|
+
"""The Python functions `_PythonBodies` never saw a body token for."""
|
|
351
|
+
return [fn for fn in functions if getattr(fn, "crapkit_body", True) is False]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _unread_reason(rel_path: str, unread: list) -> str:
|
|
355
|
+
named = ", ".join(f"{rel_path}:{fn.start_line} {fn.long_name}" for fn in unread)
|
|
356
|
+
return (f"{rel_path}: the Python reader reached no body for {len(unread)} def(s): {named}; "
|
|
357
|
+
f"a def read no further than its signature would score ccn 1 whatever its body "
|
|
358
|
+
f"holds, so the file is not scored. Check that the file parses (a signature cut "
|
|
359
|
+
f"off at the end of the file reads this way); if it does, report the signature at "
|
|
360
|
+
f"https://github.com/JeanFrancoisGagne/crapkit/issues")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _trusted_records(rel_path: str, functions) -> list[FunctionRecord]:
|
|
364
|
+
"""Records for a file every function of which was read to its body.
|
|
365
|
+
|
|
366
|
+
A file with a def the reader never finished takes the unanalyzable road,
|
|
367
|
+
named on every run and scored as zero functions, not a ccn-1 reading of
|
|
368
|
+
that def: scoring it would pass the gate on a number that means nothing,
|
|
369
|
+
and ending the run over one file is what 0.7.1 stopped (_note_unanalyzable).
|
|
370
|
+
Every such def in the file is named at once.
|
|
371
|
+
"""
|
|
372
|
+
unread = _unread_defs(functions)
|
|
373
|
+
if unread:
|
|
374
|
+
return UnanalyzableFile(_unread_reason(rel_path, unread))
|
|
375
|
+
return _file_records(rel_path, functions)
|
|
376
|
+
|
|
377
|
+
|
|
282
378
|
# --- how a source file's bytes become text -------------------------------------
|
|
283
379
|
#
|
|
284
380
|
# lizard opens a source file with `io.open(path, 'r')` and no encoding, so the
|
|
@@ -374,7 +470,7 @@ def analyze_one(args: tuple[str, str]) -> tuple[str, list[FunctionRecord]]:
|
|
|
374
470
|
abs_path, rel_path = args
|
|
375
471
|
try:
|
|
376
472
|
analysis = lizard.FileAnalyzer(_extensions_for(rel_path))(abs_path)
|
|
377
|
-
return rel_path,
|
|
473
|
+
return rel_path, _trusted_records(rel_path, analysis.function_list)
|
|
378
474
|
except Exception as exc: # loud and counted, never fatal: see _note_unanalyzable
|
|
379
475
|
return rel_path, UnanalyzableFile(f"lizard failed on {rel_path}: {exc}")
|
|
380
476
|
|
|
@@ -390,9 +486,10 @@ def analyze_source(rel_path: str, code: str) -> list[FunctionRecord]:
|
|
|
390
486
|
try:
|
|
391
487
|
analyzer = lizard.FileAnalyzer(_extensions_for(rel_path))
|
|
392
488
|
analysis = analyzer.analyze_source_code(rel_path, code)
|
|
393
|
-
records =
|
|
489
|
+
records = _trusted_records(rel_path, analysis.function_list)
|
|
394
490
|
except Exception as exc: # per-file, exactly as in analyze_one; the hook keeps going
|
|
395
491
|
records = UnanalyzableFile(f"lizard failed on {rel_path}: {exc}")
|
|
492
|
+
if isinstance(records, UnanalyzableFile):
|
|
396
493
|
_note_unanalyzable({rel_path: records})
|
|
397
494
|
return records
|
|
398
495
|
_note_twin_keys(rel_path, records)
|
|
@@ -640,7 +737,7 @@ def _analyze_verified(job: tuple[str, str, str]) -> tuple[str, list[FunctionReco
|
|
|
640
737
|
try:
|
|
641
738
|
analyzer = lizard.FileAnalyzer(_extensions_for(relative))
|
|
642
739
|
analysis = analyzer.analyze_source_code(relative, decode_source(raw))
|
|
643
|
-
return relative,
|
|
740
|
+
return relative, _trusted_records(relative, analysis.function_list)
|
|
644
741
|
except Exception as exc: # a parse refusal, unlike the read and hash above, is per-file
|
|
645
742
|
return relative, UnanalyzableFile(f"lizard failed on {relative}: {exc}")
|
|
646
743
|
|
|
@@ -996,14 +996,16 @@ def _newest_coverage_run(store: SnapshotStore) -> dict | None:
|
|
|
996
996
|
@dataclass
|
|
997
997
|
class _DirCount:
|
|
998
998
|
"""One directory's share of a run: how many functions it holds, how many of
|
|
999
|
-
them carry a verdict other than untested, and the file stems
|
|
999
|
+
them carry a verdict other than untested, and the file stems and language
|
|
1000
|
+
families to match on."""
|
|
1000
1001
|
functions: int = 0
|
|
1001
1002
|
others: int = 0
|
|
1002
1003
|
stems: set = field(default_factory=set)
|
|
1004
|
+
families: set = field(default_factory=set)
|
|
1003
1005
|
|
|
1004
1006
|
|
|
1005
1007
|
def _dirs_from_counts(counts: list[tuple]) -> dict[str, _DirCount]:
|
|
1006
|
-
from ..doctor import _dir_of, _stem_of
|
|
1008
|
+
from ..doctor import _dir_of, _family_of, _stem_of
|
|
1007
1009
|
|
|
1008
1010
|
dirs: dict[str, _DirCount] = {}
|
|
1009
1011
|
for path, functions, others in counts:
|
|
@@ -1011,6 +1013,7 @@ def _dirs_from_counts(counts: list[tuple]) -> dict[str, _DirCount]:
|
|
|
1011
1013
|
entry.functions += functions
|
|
1012
1014
|
entry.others += others
|
|
1013
1015
|
entry.stems.add(_stem_of(path))
|
|
1016
|
+
entry.families.add(_family_of(path))
|
|
1014
1017
|
return dirs
|
|
1015
1018
|
|
|
1016
1019
|
|
|
@@ -1021,12 +1024,13 @@ def _unmeasured_gaps(counts: list[tuple], tracked: list[str]) -> tuple:
|
|
|
1021
1024
|
it carries a verdict other than untested and a tracked test file names its
|
|
1022
1025
|
code. The matching itself stays doctor's, so the mirror rule has one copy.
|
|
1023
1026
|
"""
|
|
1024
|
-
from ..doctor import UnmeasuredDir, _matching_test,
|
|
1027
|
+
from ..doctor import UnmeasuredDir, _matching_test, _tests_by_family
|
|
1025
1028
|
|
|
1026
|
-
test_files =
|
|
1029
|
+
test_files = _tests_by_family(tracked)
|
|
1027
1030
|
found = []
|
|
1028
1031
|
for directory, stats in sorted(_dirs_from_counts(counts).items()):
|
|
1029
|
-
example = _matching_test(directory, stats.stems,
|
|
1032
|
+
example = _matching_test(directory, stats.stems, stats.families, test_files) \
|
|
1033
|
+
if not stats.others else None
|
|
1030
1034
|
if example:
|
|
1031
1035
|
found.append(UnmeasuredDir(directory, stats.functions, example))
|
|
1032
1036
|
return tuple(found)
|
|
@@ -540,12 +540,25 @@ def _rescored_records(root: Path, cache_path: Path, flat: list,
|
|
|
540
540
|
return records_by_path
|
|
541
541
|
|
|
542
542
|
|
|
543
|
+
def _refuse_missing(root: Path, rel_paths: list) -> None:
|
|
544
|
+
"""A path crapkit cannot open is refused the way one it cannot place is.
|
|
545
|
+
|
|
546
|
+
Unchecked, a typo reached the analyzer and came back as a FileNotFoundError
|
|
547
|
+
traceback with exit 1; through the MCP server that traceback was the whole
|
|
548
|
+
answer. A config error says which path, and exits 3 like every other
|
|
549
|
+
argument the command cannot act on."""
|
|
550
|
+
missing = [rel for rel in rel_paths if not (root / rel).exists()]
|
|
551
|
+
if missing:
|
|
552
|
+
raise ConfigError(f"{', '.join(missing)} does not exist under {root}")
|
|
553
|
+
|
|
554
|
+
|
|
543
555
|
def _rescore_analyze(root: Path, cfg, files, cwd: Path | None = None) -> tuple[list, list, dict]:
|
|
544
556
|
"""Fresh complexity for the named files, said from `cwd` where the user
|
|
545
557
|
stands; the shared cache is merged, never truncated."""
|
|
546
558
|
from ..hook import file_ceilings
|
|
547
559
|
|
|
548
560
|
rel_paths = sorted({_repo_relative(p, root, cwd) for p in files})
|
|
561
|
+
_refuse_missing(root, rel_paths)
|
|
549
562
|
files_by_scope = assign_files(rel_paths, cfg, size_of=_file_sizer(root))
|
|
550
563
|
flat = sorted(set().union(*files_by_scope.values())) if files_by_scope else []
|
|
551
564
|
records_by_path = _rescored_records(root, root / ".crapkit" / "cache.json", flat,
|
|
@@ -558,6 +558,20 @@ def _ratchet_suffix(changes: dict | None, overridden: list, ratchet_file: str) -
|
|
|
558
558
|
return f" ratchet: {_marks_moved(changes)} -> git add {ratchet_file}"
|
|
559
559
|
|
|
560
560
|
|
|
561
|
+
def _forgiven_suffix(out: dict) -> str:
|
|
562
|
+
"""Failures this verdict forgives because the baseline carries them too.
|
|
563
|
+
|
|
564
|
+
A regression verdict is about change, so an unchanged failure is not a
|
|
565
|
+
regression. Saying nothing about it made `verify OK` read as a clean suite
|
|
566
|
+
beside three failing tests, and the release guard downstream, which does not
|
|
567
|
+
forgive them, then refused evidence the operator had just watched pass."""
|
|
568
|
+
forgiven = out.get("forgiven_failures") or ()
|
|
569
|
+
if not forgiven:
|
|
570
|
+
return ""
|
|
571
|
+
plural = "" if len(forgiven) == 1 else "s"
|
|
572
|
+
return f" ({len(forgiven)} unchanged failure{plural} forgiven, first {forgiven[0]})"
|
|
573
|
+
|
|
574
|
+
|
|
561
575
|
def _report_verify(as_json: bool, out: dict, verdict, overridden, ratchet_file: str) -> None:
|
|
562
576
|
if as_json:
|
|
563
577
|
_print_json(out)
|
|
@@ -565,6 +579,7 @@ def _report_verify(as_json: bool, out: dict, verdict, overridden, ratchet_file:
|
|
|
565
579
|
state = "OK" if verdict.ok else "FAILED"
|
|
566
580
|
print(f"verify {state} @ {out['commit'][:11]} vs baseline {out['baseline_commit'][:11]} "
|
|
567
581
|
f"({out['changed_files']} changed files)"
|
|
582
|
+
f"{_forgiven_suffix(out)}"
|
|
568
583
|
f"{_ratchet_suffix(out['ratchet_changes'], overridden, ratchet_file)}")
|
|
569
584
|
_print_verify_findings(verdict, overridden)
|
|
570
585
|
_print_finding_split(verdict)
|
|
@@ -649,7 +664,8 @@ def cmd_verify(args: argparse.Namespace) -> int:
|
|
|
649
664
|
_report_verify(args.json,
|
|
650
665
|
{**_verify_result(verdict, overridden, run_id, baseline, commit, ranges,
|
|
651
666
|
uncovered, cfg.diff_uncovered_max, len(unmarked)),
|
|
652
|
-
**_receipt(tool_versions, saved.sha256, changes)
|
|
667
|
+
**_receipt(tool_versions, saved.sha256, changes),
|
|
668
|
+
"forgiven_failures": sorted(set(fresh_failures) - set(verdict.new_failures))},
|
|
653
669
|
verdict, overridden, cfg.ratchet_file)
|
|
654
670
|
_refuse_override(verdict, args.override)
|
|
655
671
|
return _verify_exit_code(verdict)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
from typing import NamedTuple
|
|
6
6
|
|
|
7
|
-
from .universe import scopes_with_tests
|
|
7
|
+
from .universe import LANGUAGE_EXTENSIONS, scopes_with_tests
|
|
8
8
|
|
|
9
9
|
from .config_contract import known_keys
|
|
10
10
|
|
|
@@ -242,6 +242,7 @@ class _DirStats:
|
|
|
242
242
|
functions: int = 0
|
|
243
243
|
flags: set = field(default_factory=set)
|
|
244
244
|
stems: set = field(default_factory=set)
|
|
245
|
+
families: set = field(default_factory=set)
|
|
245
246
|
|
|
246
247
|
|
|
247
248
|
_TEST_DIR_PARTS = frozenset({"test", "tests", "__tests__", "spec", "specs"})
|
|
@@ -255,12 +256,31 @@ def _stem_of(path: str) -> str:
|
|
|
255
256
|
return path.rsplit("/", 1)[-1].split(".")[0]
|
|
256
257
|
|
|
257
258
|
|
|
259
|
+
# One family per coverage parser, not one per lizard reader: a vitest run
|
|
260
|
+
# measures .ts, .tsx, .js and the .vue components beside them as one suite, and
|
|
261
|
+
# its tests are .spec.ts whatever the component is. Every other language is its
|
|
262
|
+
# own family.
|
|
263
|
+
_JS_FAMILY = frozenset({"javascript", "typescript", "tsx", "vue"})
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _family_of(path: str) -> str | None:
|
|
267
|
+
"""The language family a path's extension puts it in, or None for a file
|
|
268
|
+
no lizard reader parses (a .md, a .json, a .txt)."""
|
|
269
|
+
for language, extensions in LANGUAGE_EXTENSIONS.items():
|
|
270
|
+
if path.endswith(extensions):
|
|
271
|
+
return "javascript" if language in _JS_FAMILY else language
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
258
275
|
def _subject_stem(path: str) -> str | None:
|
|
259
276
|
"""The source stem a test file names, or None when the name is not a test.
|
|
260
277
|
Four conventions cover every runner crapkit parses: foo.test.ts, foo.spec.ts,
|
|
261
|
-
test_foo.py, foo_test.py.
|
|
278
|
+
test_foo.py, foo_test.py. The extension has to be one a reader parses:
|
|
279
|
+
docs/_mermaid_test.md is named like a test and is not one."""
|
|
262
280
|
name = path.rsplit("/", 1)[-1]
|
|
263
281
|
stem = _stem_of(path)
|
|
282
|
+
if _family_of(path) is None:
|
|
283
|
+
return None
|
|
264
284
|
if ".test." in name or ".spec." in name:
|
|
265
285
|
return stem
|
|
266
286
|
if stem.startswith("test_"):
|
|
@@ -281,23 +301,53 @@ def _mirrored_parts(test_dir: str) -> tuple[str, ...]:
|
|
|
281
301
|
def _mirrors(test_dir: str, source_dir: str) -> bool:
|
|
282
302
|
"""A tests/ mirror: the test directory, with its test-named components
|
|
283
303
|
dropped, is a path suffix of the source directory. tests/api mirrors src/api;
|
|
284
|
-
a flat tests/ mirrors nothing, or it would claim the whole repo
|
|
304
|
+
a flat tests/ mirrors nothing, or it would claim the whole repo, and a
|
|
305
|
+
directory with no test-named component (a root src/) is no tests/ tree."""
|
|
285
306
|
parts = _mirrored_parts(test_dir)
|
|
286
|
-
|
|
307
|
+
if not parts or len(parts) == len(_path_parts(test_dir)):
|
|
308
|
+
return False
|
|
309
|
+
return parts == _path_parts(source_dir)[-len(parts):]
|
|
287
310
|
|
|
288
311
|
|
|
289
|
-
def
|
|
290
|
-
|
|
312
|
+
def _nearest_below(directory: str, candidates: tuple[str, ...]) -> str | None:
|
|
313
|
+
"""The test below the directory with the fewest path components, then the
|
|
314
|
+
first alphabetically. The repo root has nothing below it: "below the root"
|
|
315
|
+
would be the whole repo."""
|
|
316
|
+
below = (p for p in candidates if _dir_of(p).startswith(directory + "/"))
|
|
317
|
+
return min(below, key=lambda p: (len(_path_parts(p)), p), default=None)
|
|
291
318
|
|
|
292
319
|
|
|
293
|
-
def
|
|
294
|
-
"""The
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
320
|
+
def _tests_by_family(tracked: list[str]) -> dict[str, tuple[str, ...]]:
|
|
321
|
+
"""The tracked test files, sorted, under the language family each is in."""
|
|
322
|
+
by_family: dict[str, list[str]] = {}
|
|
323
|
+
for path in sorted(p for p in tracked if _subject_stem(p)):
|
|
324
|
+
by_family.setdefault(_family_of(path), []).append(path)
|
|
325
|
+
return {family: tuple(paths) for family, paths in by_family.items()}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _first(paths: tuple[str, ...], qualifies) -> str | None:
|
|
329
|
+
return next((p for p in paths if qualifies(p)), None)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _matching_test(directory: str, stems: set, families: set,
|
|
333
|
+
tests: dict[str, tuple[str, ...]]) -> str | None:
|
|
334
|
+
"""The tracked test file that names this directory's code, the nearest first:
|
|
335
|
+
a test in the directory, then the nearest one below it, then a tests/ mirror
|
|
336
|
+
of the directory, then a same-stem test anywhere (tests/test_parser.py for
|
|
337
|
+
core/parser.py).
|
|
338
|
+
|
|
339
|
+
Every tier only looks at tests in `families`, the language families of
|
|
340
|
+
the files the store scored here. The directory's other files do not count: a Python
|
|
341
|
+
directory with a static/ folder of .js below it is still Python, and on a
|
|
342
|
+
repo with twenty handler.test.ts files a stem alone paired a Python
|
|
343
|
+
directory with a TypeScript test.
|
|
344
|
+
"""
|
|
345
|
+
candidates = tuple(sorted(p for family in families for p in tests.get(family, ())))
|
|
346
|
+
tiers = (lambda: _first(candidates, lambda p: _dir_of(p) == directory),
|
|
347
|
+
lambda: _nearest_below(directory, candidates),
|
|
348
|
+
lambda: _first(candidates, lambda p: _mirrors(_dir_of(p), directory)),
|
|
349
|
+
lambda: _first(candidates, lambda p: _subject_stem(p) in stems))
|
|
350
|
+
return next(filter(None, (tier() for tier in tiers)), None)
|
|
301
351
|
|
|
302
352
|
|
|
303
353
|
def _group_dirs(rows, skip_scopes: frozenset[str]) -> dict[str, _DirStats]:
|
|
@@ -309,6 +359,7 @@ def _group_dirs(rows, skip_scopes: frozenset[str]) -> dict[str, _DirStats]:
|
|
|
309
359
|
entry.functions += 1
|
|
310
360
|
entry.flags.add(row.flag)
|
|
311
361
|
entry.stems.add(_stem_of(row.path))
|
|
362
|
+
entry.families.add(_family_of(row.path))
|
|
312
363
|
return stats
|
|
313
364
|
|
|
314
365
|
|
|
@@ -321,10 +372,10 @@ def unmeasured_directories(rows, tracked: list[str], *,
|
|
|
321
372
|
tests pass, and the lane's own include list never looks at this code. One
|
|
322
373
|
measured function anywhere in the directory clears it.
|
|
323
374
|
"""
|
|
324
|
-
test_files =
|
|
375
|
+
test_files = _tests_by_family(tracked)
|
|
325
376
|
found = []
|
|
326
377
|
for directory, stats in sorted(_group_dirs(rows, skip_scopes).items()):
|
|
327
|
-
example = _matching_test(directory, stats.stems, test_files) \
|
|
378
|
+
example = _matching_test(directory, stats.stems, stats.families, test_files) \
|
|
328
379
|
if stats.flags == {"untested"} else None
|
|
329
380
|
if example:
|
|
330
381
|
found.append(UnmeasuredDir(directory, stats.functions, example))
|