crapkit 0.7.3__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.3/src/crapkit.egg-info → crapkit-0.7.4}/PKG-INFO +5 -5
  2. {crapkit-0.7.3 → crapkit-0.7.4}/README.md +4 -4
  3. {crapkit-0.7.3 → crapkit-0.7.4}/pyproject.toml +1 -1
  4. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/analyze.py +109 -12
  6. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/admin.py +9 -5
  7. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/doctor.py +67 -16
  8. crapkit-0.7.4/src/crapkit/lizardpython.py +262 -0
  9. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/procs.py +44 -1
  10. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/retention.py +20 -2
  11. {crapkit-0.7.3 → crapkit-0.7.4/src/crapkit.egg-info}/PKG-INFO +5 -5
  12. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit.egg-info/SOURCES.txt +1 -0
  13. {crapkit-0.7.3 → crapkit-0.7.4}/LICENSE +0 -0
  14. {crapkit-0.7.3 → crapkit-0.7.4}/setup.cfg +0 -0
  15. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/__main__.py +0 -0
  16. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/_analysis_pool.py +0 -0
  17. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/_mcp_stdio.py +0 -0
  18. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/_process_family.py +0 -0
  19. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/_process_owner.py +0 -0
  20. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/_pygdefer.py +0 -0
  21. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/_windows_job.py +0 -0
  22. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cache.py +0 -0
  23. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/churn.py +0 -0
  24. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/churn_cache.py +0 -0
  25. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/churn_log.py +0 -0
  26. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/__init__.py +0 -0
  27. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/_shared.py +0 -0
  28. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/analyses.py +0 -0
  29. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/claude_hook.py +0 -0
  30. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/maintenance.py +0 -0
  31. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/parser.py +0 -0
  32. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/queue.py +0 -0
  33. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/ratchet_cmds.py +0 -0
  34. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/reports.py +0 -0
  35. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/scoring.py +0 -0
  36. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/cli/verifying.py +0 -0
  37. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/config.py +0 -0
  38. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/config_contract.py +0 -0
  39. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/coupling.py +0 -0
  40. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/coupling_cache.py +0 -0
  41. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/coverage_istanbul.py +0 -0
  42. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/coverage_py.py +0 -0
  43. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/covstream.py +0 -0
  44. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/diffparse.py +0 -0
  45. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/digest.py +0 -0
  46. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/dup.py +0 -0
  47. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/errors.py +0 -0
  48. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/gitio.py +0 -0
  49. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/gitpaths.py +0 -0
  50. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/hook.py +0 -0
  51. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/invocation.py +0 -0
  52. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/junitparse.py +0 -0
  53. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/keys.py +0 -0
  54. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/lanes.py +0 -0
  55. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/lizardcognitive.py +0 -0
  56. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/lizardpowershell.py +0 -0
  57. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/lizardrust.py +0 -0
  58. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/lizardshell.py +0 -0
  59. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/lizardtypescript.py +0 -0
  60. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/locks.py +0 -0
  61. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/logs.py +0 -0
  62. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/mcp_server.py +0 -0
  63. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/merge.py +0 -0
  64. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/mutate.py +0 -0
  65. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/mutate_pool.py +0 -0
  66. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/override.py +0 -0
  67. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/packet.py +0 -0
  68. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/ratchet.py +0 -0
  69. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/ratchet_report.py +0 -0
  70. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/ratchetfile.py +0 -0
  71. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/records.py +0 -0
  72. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/report.py +0 -0
  73. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/repotext.py +0 -0
  74. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/resources.py +0 -0
  75. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/rootfind.py +0 -0
  76. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/sarif.py +0 -0
  77. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/sarifio.py +0 -0
  78. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/scaffold.py +0 -0
  79. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/score.py +0 -0
  80. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/snapshot.py +0 -0
  81. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/store.py +0 -0
  82. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/uncovered.py +0 -0
  83. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/universe.py +0 -0
  84. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/verify.py +0 -0
  85. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/watch.py +0 -0
  86. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit/worklist.py +0 -0
  87. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit.egg-info/dependency_links.txt +0 -0
  88. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit.egg-info/entry_points.txt +0 -0
  89. {crapkit-0.7.3 → crapkit-0.7.4}/src/crapkit.egg-info/requires.txt +0 -0
  90. {crapkit-0.7.3 → 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
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.3
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.3
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.3
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.3
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.3
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.3
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.3
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.3
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.3"
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.3"
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)
@@ -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))
@@ -0,0 +1,262 @@
1
+ """A Python reader that reads a def's signature to its body colon. Upstream defect: crapkit #72.
2
+
3
+ lizard 1.24.0's PythonReader ends a def inside its own signature in two shapes,
4
+ and the def then reads as two lines at ccn 1 whatever its body holds, so it
5
+ passes the complexity ceiling, the commit hook, `rescore --gate` and `verify`:
6
+
7
+ def f(value: int) -> tuple[ def make(cls_name, *, bases=(),
8
+ int, int slots=False):
9
+ ]: if slots: ...
10
+ if value > 0: ...
11
+
12
+ The left one is a return annotation opened on the def line. The right one is a
13
+ line break after a parameter default that holds brackets, which is what black
14
+ and ruff write for a long signature. Measured before the fix: 39 such defs in
15
+ the 3,742 stdlib and site-packages files of crapkit's own Python 3.11
16
+ environment, and 20 in 3,130 openclaw files.
17
+
18
+ Three mechanics combine, all in lizard_languages/python.py:
19
+
20
+ 1. `PythonStates._dec` takes the first `)` token for the end of the parameter
21
+ list, so the `)` of a default like `()` ends it early.
22
+ 2. `PythonStates._state_colon` follows `:` into the body and sends anything
23
+ else, `->` included, to `_state_global`: the reader never follows a return
24
+ annotation to its colon.
25
+ 3. `PythonReader.preprocess` sets nesting from each line's indent once the
26
+ def's long name ends with `)`. A signature line indented deeper than the
27
+ body pushes the def's nesting level there, the body's first line pops it,
28
+ and the pop ends the def before its body.
29
+
30
+ The fix, and what it keeps
31
+ --------------------------
32
+ `PythonSignatureStates` counts bracket depth from the def's `(` and leaves the
33
+ signature only at a `:` at depth 0, after the parameter list and after any
34
+ return annotation. `PythonSignatureReader.preprocess` sets no nesting while the
35
+ states are inside a signature, so the body's first line is the one that pushes
36
+ the def. A body on the colon line (`) -> None: ...`) is the one exception,
37
+ kept as lizard reads it: see `_SignatureIndents`.
38
+
39
+ Every token still reaches the long name and the parameter list exactly as
40
+ lizard routes it. lizard stops both at the signature's first `)` whatever its
41
+ depth, and a def of that shape that lizard already read whole carries that
42
+ spelling as its ratchet key, so this reader keeps it: `make( cls_name , * ,
43
+ bases = ( )` above. The states also end where lizard ends for the same
44
+ signature written on one line: `_state_first_line` after `):`, `_state_global`
45
+ after a return annotation or after a signature lizard had closed early.
46
+
47
+ What moves, only around a def lizard cut off: its end line, ccn, nloc, token
48
+ count and cognitive score now cover its body, while its long name stays; an
49
+ enclosing def gives back the conditions lizard had charged to it from the
50
+ nested body; a def nested inside the cut-off def gains its parent's name as a
51
+ prefix, as it has under a parent read whole.
52
+
53
+ The class name
54
+ --------------
55
+ lizardcognitive picks its Python rules by `type(reader).__name__` starting with
56
+ "python". A name like `CorrectedPythonReader` would score every Python file
57
+ with the brace rules.
58
+
59
+ Registration
60
+ ------------
61
+ The same mechanism as crapkit.lizardrust, whose docstring explains it:
62
+ `register()` rebinds `lizard_languages.PythonReader`, which
63
+ `lizard_languages.languages()` reads on every call, verifies the rebind through
64
+ `lizard.get_reader_for`, and raises if it did not take. analyze.py calls it at
65
+ module scope beside the other readers' registrations, so pool workers register
66
+ too.
67
+
68
+ Retirement
69
+ ----------
70
+ tests/unit/test_lizardpython.py::test_stock_reader_still_cuts_the_issue_def_off
71
+ pins the stock reader's wrong answer. It fails on the lizard release that reads
72
+ these signatures. Delete this module then, with the `register()` call.
73
+ """
74
+ from __future__ import annotations
75
+
76
+ from ._pygdefer import deferred_pygments
77
+
78
+ with deferred_pygments(): # lizard's Erlang reader would load pygments here
79
+ import lizard
80
+ import lizard_languages
81
+ from lizard_languages.python import PythonIndents, PythonStates, count_spaces
82
+ from lizard_languages.python import PythonReader as _StockPythonReader
83
+
84
+ _OPENERS = frozenset("([{")
85
+ _CLOSERS = frozenset(")]}")
86
+
87
+ # The states between the `def` keyword and the body colon. `_state_colon` is
88
+ # the one lizard enters after the parameter list's `)`.
89
+ _SIGNATURE_STATES = frozenset({
90
+ "_function", "_dec", "_state_parameterized_type_annotation", "_state_colon", "_rest_of_signature",
91
+ })
92
+
93
+ # Any filename picks the reader; the file is never opened.
94
+ _PROBE = "crapkit_registration_probe.py"
95
+
96
+
97
+ def _is_continuation(token: str) -> bool:
98
+ """A backslash line continuation, which lizard's tokenizer keeps as one token."""
99
+ return token.startswith("\\") and not token[1:].strip()
100
+
101
+
102
+ def _depth_change(token: str) -> int:
103
+ if token in _OPENERS:
104
+ return 1
105
+ if token in _CLOSERS:
106
+ return -1
107
+ return 0
108
+
109
+
110
+ class PythonSignatureStates(PythonStates):
111
+ """lizard's PythonStates, reading a signature to the colon at bracket depth 0.
112
+
113
+ `depth` counts the brackets open since the def's own `(`. The inherited
114
+ states keep every decision about the long name and the parameter list;
115
+ these overrides only choose where a state goes next.
116
+ """
117
+
118
+ def __init__(self, context, reader):
119
+ super().__init__(context, reader)
120
+ self.depth = 0
121
+
122
+ @property
123
+ def in_signature(self) -> bool:
124
+ """True from the `def` keyword until the body colon has been read."""
125
+ return self._state.__name__ in _SIGNATURE_STATES
126
+
127
+ def _function(self, token):
128
+ if token == "(":
129
+ self.depth = 1
130
+ super()._function(token)
131
+
132
+ def _dec(self, token):
133
+ if token == ")" and self.depth > 1:
134
+ # lizard's parameter list ends here too, spelled the same way; the
135
+ # signature does not.
136
+ self.depth -= 1
137
+ self.context.add_to_long_function_name(" )")
138
+ self._state = self._rest_of_signature
139
+ return
140
+ self.depth += _depth_change(token)
141
+ super()._dec(token)
142
+
143
+ def _state_parameterized_type_annotation(self, token):
144
+ self.depth += _depth_change(token)
145
+ super()._state_parameterized_type_annotation(token)
146
+
147
+ def _state_colon(self, token):
148
+ if token == "->":
149
+ self._state = self._rest_of_signature
150
+ return
151
+ if _is_continuation(token): # `) \` then `-> ...:` on the next line
152
+ return
153
+ super()._state_colon(token)
154
+
155
+ def _rest_of_signature(self, token):
156
+ """The tokens lizard sent to `_state_global` before the body: counted, not named."""
157
+ if token == ":" and self.depth == 0:
158
+ self._state = self._state_global
159
+ return
160
+ self.depth += _depth_change(token)
161
+
162
+
163
+ class _SignatureIndents(PythonIndents):
164
+ """lizard's per-line indent bookkeeping, holding back what a signature line sets.
165
+
166
+ lizard sets nesting from every line's first code token once the def's long
167
+ name ends with `)`. Inside a signature that is the defect: see mechanic 3.
168
+ The first level lizard would push from inside the signature is held, and
169
+ what happens to it depends on where the body starts:
170
+
171
+ * on the next line: dropped, and the body's first line pushes the def
172
+ * on the colon line (`) -> None: ...`): pushed at the body's first token,
173
+ so the def ends where lizard ends it, at the next line indented no
174
+ deeper. lizard lists a def of that shape only when its signature pushed
175
+ a level, and lists no def written `def f(x): return x` at all; this
176
+ keeps both answers.
177
+ """
178
+
179
+ def __init__(self, context, states):
180
+ super().__init__(context)
181
+ self.states = states
182
+ self.spaces = 0
183
+ self.leading = True
184
+ self.held = None
185
+
186
+ def see(self, token: str) -> None:
187
+ if token == "\n":
188
+ self._line_ends()
189
+ elif self.leading:
190
+ self._leading(token)
191
+ elif self.held is not None:
192
+ self._after_signature(token)
193
+
194
+ def _line_ends(self) -> None:
195
+ self.spaces, self.leading = 0, True
196
+ if not self.states.in_signature:
197
+ self.held = None
198
+
199
+ def _leading(self, token: str) -> None:
200
+ if token.isspace():
201
+ self.spaces += count_spaces(token)
202
+ return
203
+ self.leading = False
204
+ if token.startswith("#"):
205
+ return
206
+ if self.states.in_signature:
207
+ self._hold()
208
+ elif self._lizard_sets_nesting():
209
+ self.set_nesting(self.spaces, token)
210
+
211
+ def _hold(self) -> None:
212
+ if self.held is None and self._lizard_sets_nesting() and self.spaces > self.indents[-1]:
213
+ self.held = self.spaces
214
+
215
+ def _after_signature(self, token: str) -> None:
216
+ if self.states.in_signature or token.isspace() or token.startswith("#"):
217
+ return
218
+ self.set_nesting(self.held, token)
219
+ self.held = None
220
+
221
+ def _lizard_sets_nesting(self) -> bool:
222
+ function = self.context.current_function
223
+ return function.name == "*global*" or function.long_name.endswith(")")
224
+
225
+
226
+ class PythonSignatureReader(_StockPythonReader):
227
+ """lizard's PythonReader with a def's signature read to its body colon (#72)."""
228
+
229
+ def __init__(self, context):
230
+ super().__init__(context)
231
+ self.parallel_states = [PythonSignatureStates(context, self)]
232
+
233
+ def preprocess(self, tokens):
234
+ """lizard's preprocess, with the indent rules of `_SignatureIndents`."""
235
+ indents = _SignatureIndents(self.context, self.parallel_states[0])
236
+ for token in self._soft_keyword_lookahead(tokens):
237
+ indents.see(token)
238
+ if not token.isspace() or token == "\n":
239
+ yield token
240
+ indents.reset()
241
+
242
+
243
+ def register() -> None:
244
+ """Make lizard resolve `.py` to PythonSignatureReader. Idempotent.
245
+
246
+ Raises RuntimeError when the rebind does not reach lizard's own resolution.
247
+ A Python file measured with the defective reader is worse than a crash: the
248
+ score is wrong and looks fine.
249
+ """
250
+ lizard_languages.PythonReader = PythonSignatureReader
251
+ resolved = lizard.get_reader_for(_PROBE)
252
+ if resolved is not PythonSignatureReader:
253
+ raise RuntimeError(_unregistered(resolved))
254
+
255
+
256
+ def _unregistered(resolved: type | None) -> str:
257
+ name = getattr(resolved, "__name__", "no reader")
258
+ return (f"crapkit.lizardpython.register() did not take: lizard resolves '.py' to "
259
+ f"{name}, not PythonSignatureReader. lizard {lizard.version} picks readers "
260
+ f"some other way than lizard_languages.languages(); rewrite register() "
261
+ f"against the new mechanism, or drop this module if lizard reads these "
262
+ f"signatures itself.")
@@ -22,6 +22,8 @@ import tempfile
22
22
  import time
23
23
  from typing import IO
24
24
 
25
+ from .errors import ToolError
26
+
25
27
  _OWN_GROUP = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
26
28
  if os.name == "nt" else {"start_new_session": True})
27
29
 
@@ -177,7 +179,10 @@ class _ProcessOwner:
177
179
 
178
180
  def start(self, process, family=None):
179
181
  """Register and release one launcher atomically against cancellation."""
180
- self.register_then(process.pid, lambda: _release_launcher(process), family=family)
182
+ try:
183
+ self.register_then(process.pid, lambda: _release_launcher(process), family=family)
184
+ except OSError as error:
185
+ raise _dead_launcher(process, error) from error
181
186
 
182
187
  def register_then(self, pid, release, *, family=None):
183
188
  """Register a gated process, then release it before cancellation can enter."""
@@ -271,11 +276,49 @@ def _close_input(process) -> None:
271
276
  pass
272
277
 
273
278
 
279
+ # How long a launcher whose input pipe is already dead gets to settle its exit
280
+ # code before the message has to say it is still running.
281
+ _LAUNCHER_SETTLE = 2.0
282
+
283
+ # The code a Windows process exits with when a DLL's initialisation fails,
284
+ # 0xC0000142. Seen at spawn on a loaded machine, before Python reads stdin.
285
+ _DLL_INIT_FAILED = 3221225794
286
+
287
+
288
+ class _LauncherDied(ToolError, OSError):
289
+ """A ToolError to the lane layer, which fails and retries that one lane.
290
+ Still an OSError to the doctor and init probes, which read an OSError from
291
+ run_bounded as a question that could not be put and answer with a finding."""
292
+
293
+
274
294
  def _release_launcher(process):
275
295
  process.stdin.write(b"go\n")
276
296
  process.stdin.flush()
277
297
 
278
298
 
299
+ def _dead_launcher(process, error: OSError):
300
+ """The launcher exited before its start gate: registration or the start
301
+ line failed at the OS level. Windows Job assignment refuses an exited
302
+ process with access denied; the start line hits EINVAL there, EPIPE on
303
+ POSIX. Either is one lane's failure, which the lane layer retries, not a
304
+ crash of the whole run.
305
+ """
306
+ try:
307
+ code = process.wait(timeout=_LAUNCHER_SETTLE)
308
+ except subprocess.TimeoutExpired:
309
+ code = None
310
+ return _LauncherDied(f"command launcher {_launcher_fate(code, error)}, so the command never ran")
311
+
312
+
313
+ def _launcher_fate(code, error: OSError) -> str:
314
+ if code is None:
315
+ return (f"was still running {_LAUNCHER_SETTLE:g}s after its start gate failed "
316
+ f"({error})")
317
+ if code == _DLL_INIT_FAILED:
318
+ return f"exited with code {code} (0xC0000142, STATUS_DLL_INIT_FAILED) before its start gate"
319
+ return f"exited with code {code} before its start gate"
320
+
321
+
279
322
  _OWNED_LAUNCH = """import json, os, sys
280
323
  if sys.stdin.buffer.readline() != b'go\\n':
281
324
  raise SystemExit(1)
@@ -3,6 +3,7 @@ from contextlib import contextmanager
3
3
  import hashlib
4
4
  import json
5
5
  import math
6
+ import os
6
7
  from pathlib import Path
7
8
  import shutil
8
9
  import tempfile
@@ -18,9 +19,26 @@ DEFAULT_TEST_RETENTION_COUNT = 10
18
19
  _RECEIPT = ".crapkit-test-run.json"
19
20
 
20
21
 
22
+ def _same_place(path: Path, resolved: Path) -> bool:
23
+ r"""Whether `resolve()` named the directory `path` already names.
24
+
25
+ On Windows it names the same directory two other ways while a sibling
26
+ process is creating or deleting it: the extended-length form with the
27
+ `\\?\` prefix, and the NTFS tombstone under `$Extend\$Deleted` for a
28
+ directory whose last handle is still open. Two direct runners sharing a
29
+ repository hit both, and the guard read each as a redirect and refused.
30
+ A symlink or junction elsewhere still resolves elsewhere, and is still
31
+ refused.
32
+ """
33
+ text = str(resolved)
34
+ if text.startswith("\\\\?\\"):
35
+ text = text[4:]
36
+ return os.path.normcase(text) == os.path.normcase(str(path)) or "$Extend" in text
37
+
38
+
21
39
  def _parent(root: Path) -> Path:
22
40
  path = root / ".crapkit" / "test-runs"
23
- if path.resolve() != path:
41
+ if not _same_place(path, path.resolve()):
24
42
  raise ToolError("test evidence retention refuses a redirected .crapkit/test-runs path")
25
43
  return path
26
44
 
@@ -30,7 +48,7 @@ def _lease(parent: Path, name: str) -> Path:
30
48
 
31
49
 
32
50
  def _safe_run(path: Path, parent: Path) -> bool:
33
- return path.parent == parent and path.name.startswith("run-") and path.resolve() == path
51
+ return path.parent == parent and path.name.startswith("run-") and _same_place(path, path.resolve())
34
52
 
35
53
 
36
54
  def _valid_receipt(value: dict, root: Path, path: Path) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.7.3
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.3
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.3
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.3
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.3
586
+ - uses: JeanFrancoisGagne/crapkit@v0.7.4
587
587
  with:
588
588
  gate: "false"
589
589
  ```
@@ -35,6 +35,7 @@ src/crapkit/keys.py
35
35
  src/crapkit/lanes.py
36
36
  src/crapkit/lizardcognitive.py
37
37
  src/crapkit/lizardpowershell.py
38
+ src/crapkit/lizardpython.py
38
39
  src/crapkit/lizardrust.py
39
40
  src/crapkit/lizardshell.py
40
41
  src/crapkit/lizardtypescript.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes