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.
Files changed (90) hide show
  1. {crapkit-0.7.2/src/crapkit.egg-info → crapkit-0.7.4}/PKG-INFO +5 -5
  2. {crapkit-0.7.2 → crapkit-0.7.4}/README.md +4 -4
  3. {crapkit-0.7.2 → crapkit-0.7.4}/pyproject.toml +1 -1
  4. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/analyze.py +109 -12
  6. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/admin.py +9 -5
  7. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/scoring.py +13 -0
  8. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/verifying.py +17 -1
  9. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/doctor.py +67 -16
  10. crapkit-0.7.4/src/crapkit/lizardpython.py +262 -0
  11. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/mcp_server.py +22 -18
  12. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/procs.py +44 -1
  13. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/retention.py +20 -2
  14. {crapkit-0.7.2 → crapkit-0.7.4/src/crapkit.egg-info}/PKG-INFO +5 -5
  15. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit.egg-info/SOURCES.txt +1 -0
  16. {crapkit-0.7.2 → crapkit-0.7.4}/LICENSE +0 -0
  17. {crapkit-0.7.2 → crapkit-0.7.4}/setup.cfg +0 -0
  18. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/__main__.py +0 -0
  19. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_analysis_pool.py +0 -0
  20. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_mcp_stdio.py +0 -0
  21. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_process_family.py +0 -0
  22. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_process_owner.py +0 -0
  23. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_pygdefer.py +0 -0
  24. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/_windows_job.py +0 -0
  25. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cache.py +0 -0
  26. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/churn.py +0 -0
  27. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/churn_cache.py +0 -0
  28. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/churn_log.py +0 -0
  29. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/__init__.py +0 -0
  30. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/_shared.py +0 -0
  31. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/analyses.py +0 -0
  32. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/claude_hook.py +0 -0
  33. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/maintenance.py +0 -0
  34. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/parser.py +0 -0
  35. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/queue.py +0 -0
  36. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/ratchet_cmds.py +0 -0
  37. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/cli/reports.py +0 -0
  38. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/config.py +0 -0
  39. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/config_contract.py +0 -0
  40. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/coupling.py +0 -0
  41. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/coupling_cache.py +0 -0
  42. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/coverage_istanbul.py +0 -0
  43. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/coverage_py.py +0 -0
  44. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/covstream.py +0 -0
  45. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/diffparse.py +0 -0
  46. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/digest.py +0 -0
  47. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/dup.py +0 -0
  48. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/errors.py +0 -0
  49. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/gitio.py +0 -0
  50. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/gitpaths.py +0 -0
  51. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/hook.py +0 -0
  52. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/invocation.py +0 -0
  53. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/junitparse.py +0 -0
  54. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/keys.py +0 -0
  55. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lanes.py +0 -0
  56. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardcognitive.py +0 -0
  57. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardpowershell.py +0 -0
  58. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardrust.py +0 -0
  59. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardshell.py +0 -0
  60. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/lizardtypescript.py +0 -0
  61. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/locks.py +0 -0
  62. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/logs.py +0 -0
  63. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/merge.py +0 -0
  64. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/mutate.py +0 -0
  65. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/mutate_pool.py +0 -0
  66. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/override.py +0 -0
  67. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/packet.py +0 -0
  68. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/ratchet.py +0 -0
  69. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/ratchet_report.py +0 -0
  70. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/ratchetfile.py +0 -0
  71. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/records.py +0 -0
  72. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/report.py +0 -0
  73. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/repotext.py +0 -0
  74. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/resources.py +0 -0
  75. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/rootfind.py +0 -0
  76. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/sarif.py +0 -0
  77. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/sarifio.py +0 -0
  78. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/scaffold.py +0 -0
  79. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/score.py +0 -0
  80. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/snapshot.py +0 -0
  81. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/store.py +0 -0
  82. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/uncovered.py +0 -0
  83. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/universe.py +0 -0
  84. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/verify.py +0 -0
  85. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/watch.py +0 -0
  86. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit/worklist.py +0 -0
  87. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit.egg-info/dependency_links.txt +0 -0
  88. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit.egg-info/entry_points.txt +0 -0
  89. {crapkit-0.7.2 → crapkit-0.7.4}/src/crapkit.egg-info/requires.txt +0 -0
  90. {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.2
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.2
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.2
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.2
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.2
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.2
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.2
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.2
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.2
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.2"
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"
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
- # and `.sh` and `.ps1` resolve to nothing at all, which lizard answers with
38
- # CLikeReader rather than a failure. All three belong HERE, at the module scope
39
- # of the module a ProcessPoolExecutor child imports, or spawned workers measure
40
- # with the readers lizard shipped and report plausible wrong numbers.
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 does not (rebinding a name in another package's
44
- # namespace is not something an import should do quietly). Calling all three
45
- # keeps the wiring readable in one place and costs nothing: each is idempotent.
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, _file_records(rel_path, analysis.function_list)
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 = _file_records(rel_path, analysis.function_list)
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, _file_records(relative, analysis.function_list)
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 to match on."""
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, _test_files
1027
+ from ..doctor import UnmeasuredDir, _matching_test, _tests_by_family
1025
1028
 
1026
- test_files = _test_files(tracked)
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, test_files) if not stats.others else None
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
- return parts == _path_parts(source_dir)[-len(parts):] if parts else False
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 _test_files(tracked: list[str]) -> list[str]:
290
- return sorted(p for p in tracked if _subject_stem(p))
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 _matching_test(directory: str, stems: set, test_files: list[str]) -> str | None:
294
- """The first tracked test file that names this directory's code: a same-stem
295
- test anywhere in the repo (tests/test_parser.py for core/parser.py),
296
- a sibling, or a tests/ mirror of the directory."""
297
- for path in test_files:
298
- if _subject_stem(path) in stems or _mirrors(_dir_of(path), directory):
299
- return path
300
- return None
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 = _test_files(tracked)
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))