crapkit 0.4.5__tar.gz → 0.4.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {crapkit-0.4.5/src/crapkit.egg-info → crapkit-0.4.6}/PKG-INFO +19 -8
  2. {crapkit-0.4.5 → crapkit-0.4.6}/README.md +16 -7
  3. {crapkit-0.4.5 → crapkit-0.4.6}/pyproject.toml +20 -3
  4. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/__init__.py +3 -0
  6. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/admin.py +82 -11
  7. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/covstream.py +10 -3
  8. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lanes.py +229 -9
  9. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/scaffold.py +72 -13
  10. {crapkit-0.4.5 → crapkit-0.4.6/src/crapkit.egg-info}/PKG-INFO +19 -8
  11. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit.egg-info/requires.txt +2 -0
  12. {crapkit-0.4.5 → crapkit-0.4.6}/LICENSE +0 -0
  13. {crapkit-0.4.5 → crapkit-0.4.6}/setup.cfg +0 -0
  14. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/__main__.py +0 -0
  15. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/_pygdefer.py +0 -0
  16. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/analyze.py +0 -0
  17. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cache.py +0 -0
  18. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/churn.py +0 -0
  19. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/churn_cache.py +0 -0
  20. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/churn_log.py +0 -0
  21. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/_shared.py +0 -0
  22. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/analyses.py +0 -0
  23. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/claude_hook.py +0 -0
  24. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/parser.py +0 -0
  25. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/queue.py +0 -0
  26. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/ratchet_cmds.py +0 -0
  27. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/reports.py +0 -0
  28. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/scoring.py +0 -0
  29. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/verifying.py +0 -0
  30. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/config.py +0 -0
  31. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/coupling.py +0 -0
  32. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/coupling_cache.py +0 -0
  33. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/coverage_istanbul.py +0 -0
  34. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/coverage_py.py +0 -0
  35. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/diffparse.py +0 -0
  36. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/digest.py +0 -0
  37. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/discover.py +0 -0
  38. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/doctor.py +0 -0
  39. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/dup.py +0 -0
  40. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/errors.py +0 -0
  41. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/gitio.py +0 -0
  42. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/hook.py +0 -0
  43. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/junitparse.py +0 -0
  44. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/keys.py +0 -0
  45. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lizardcognitive.py +0 -0
  46. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lizardpowershell.py +0 -0
  47. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lizardrust.py +0 -0
  48. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lizardshell.py +0 -0
  49. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/mcp_server.py +0 -0
  50. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/merge.py +0 -0
  51. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/mutate.py +0 -0
  52. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/mutate_pool.py +0 -0
  53. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/override.py +0 -0
  54. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/packet.py +0 -0
  55. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/procs.py +0 -0
  56. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/ratchet.py +0 -0
  57. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/ratchet_report.py +0 -0
  58. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/report.py +0 -0
  59. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/sarif.py +0 -0
  60. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/sarifio.py +0 -0
  61. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/score.py +0 -0
  62. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/snapshot.py +0 -0
  63. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/store.py +0 -0
  64. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/uncovered.py +0 -0
  65. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/universe.py +0 -0
  66. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/verify.py +0 -0
  67. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/watch.py +0 -0
  68. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/worklist.py +0 -0
  69. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit.egg-info/SOURCES.txt +0 -0
  70. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit.egg-info/dependency_links.txt +0 -0
  71. {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit.egg-info/entry_points.txt +0 -0
  72. {crapkit-0.4.5 → crapkit-0.4.6}/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.4.5
3
+ Version: 0.4.6
4
4
  Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
5
5
  Author: Jean-Francois Gagne
6
6
  License: MIT
@@ -31,8 +31,10 @@ Provides-Extra: dev
31
31
  Requires-Dist: pytest>=8; extra == "dev"
32
32
  Requires-Dist: pytest-cov>=5; extra == "dev"
33
33
  Requires-Dist: pytest-xdist>=3; extra == "dev"
34
+ Requires-Dist: coverage>=7.10.6; extra == "dev"
34
35
  Provides-Extra: py
35
36
  Requires-Dist: pytest-cov>=5; extra == "py"
37
+ Requires-Dist: coverage>=7.10.6; extra == "py"
36
38
  Dynamic: license-file
37
39
 
38
40
  # crapkit
@@ -143,7 +145,7 @@ changing crapkit.
143
145
 
144
146
  ```
145
147
  $ crapkit --version
146
- crapkit 0.4.5
148
+ crapkit 0.4.6
147
149
  ```
148
150
 
149
151
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -344,7 +346,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
344
346
  repos:
345
347
  - repo: https://github.com/JeanFrancoisGagne/crapkit
346
348
  # crapkit's release step rewrites this line to the tag it just cut
347
- rev: v0.4.5
349
+ rev: v0.4.6
348
350
  hooks:
349
351
  - id: crapkit-gate
350
352
  ```
@@ -614,7 +616,7 @@ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve a
614
616
  | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
615
617
  | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch ([Upgrading from 0.4.4](#upgrading-from-044)), a `test-scoped` file under no scope or under a scope with no template. |
616
618
  | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
617
- | 5 | Tool error: lizard not importable, a lane produced no artifact, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
619
+ | 5 | Tool error: lizard not importable, a lane produced no artifact or one that measured a different tree, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
618
620
  | 6 | Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is exempt. Also `rescore --gate`, which applies the same rule, and `hook-precommit`, which exempts on the mark's existence instead. |
619
621
  | 7 | Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6. |
620
622
  | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
@@ -647,6 +649,11 @@ pip install pytest-cov
647
649
 
648
650
  (`pip install "crapkit[py]"` pulls both at once when crapkit shares the suite's venv.)
649
651
 
652
+ If your suite drives its own CLI through `subprocess.run`, add `[tool.coverage.run]
653
+ patch = ["subprocess"]` to `pyproject.toml` and keep `coverage>=7.10.6`: pytest-cov 7.0.0
654
+ dropped subprocess measurement, so without that key every entry point scores 0% and nothing
655
+ warns. [docs/lanes.md](docs/lanes.md) has the whole rule.
656
+
650
657
  ### 1. Scaffold the config
651
658
 
652
659
  ```
@@ -659,9 +666,13 @@ added to .gitignore: .crapkit/, .coverage, __pycache__/
659
666
  `init` sniffs tracked source into one scope per top-level source directory, and detects a
660
667
  coverage lane from what the repo already has: a pytest marker file (`pyproject.toml`,
661
668
  `pytest.ini`, `setup.cfg`) writes a live `[[lane]]`, and so does a `test` script or
662
- `vitest`/`jest` in `package.json`. Whatever it detects, it also leaves commented templates
663
- for the runners it did not find. Every lane it writes reports into `.crapkit/cov/`, which
664
- is why the `.gitignore` list is so short: see
669
+ `vitest`/`jest` in `package.json`. A lockfile beside them names the environment: `uv.lock`,
670
+ `poetry.lock`, `pdm.lock` or `Pipfile.lock` makes the lane `uv run python -m pytest …` (and
671
+ the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
672
+ has active rather than the one the repo pins — see
673
+ [The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
674
+ it detects, it also leaves commented templates for the runners it did not find. Every lane
675
+ it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
665
676
  [Where artifacts live](docs/lanes.md#where-artifacts-live).
666
677
 
667
678
  ```toml
@@ -821,7 +832,7 @@ exit 5:
821
832
 
822
833
  ```
823
834
  $ crapkit coverage
824
- crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
835
+ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); full log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
825
836
 
826
837
  MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
827
838
 
@@ -106,7 +106,7 @@ changing crapkit.
106
106
 
107
107
  ```
108
108
  $ crapkit --version
109
- crapkit 0.4.5
109
+ crapkit 0.4.6
110
110
  ```
111
111
 
112
112
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -307,7 +307,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
307
307
  repos:
308
308
  - repo: https://github.com/JeanFrancoisGagne/crapkit
309
309
  # crapkit's release step rewrites this line to the tag it just cut
310
- rev: v0.4.5
310
+ rev: v0.4.6
311
311
  hooks:
312
312
  - id: crapkit-gate
313
313
  ```
@@ -577,7 +577,7 @@ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve a
577
577
  | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
578
578
  | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch ([Upgrading from 0.4.4](#upgrading-from-044)), a `test-scoped` file under no scope or under a scope with no template. |
579
579
  | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
580
- | 5 | Tool error: lizard not importable, a lane produced no artifact, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
580
+ | 5 | Tool error: lizard not importable, a lane produced no artifact or one that measured a different tree, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
581
581
  | 6 | Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is exempt. Also `rescore --gate`, which applies the same rule, and `hook-precommit`, which exempts on the mark's existence instead. |
582
582
  | 7 | Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6. |
583
583
  | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
@@ -610,6 +610,11 @@ pip install pytest-cov
610
610
 
611
611
  (`pip install "crapkit[py]"` pulls both at once when crapkit shares the suite's venv.)
612
612
 
613
+ If your suite drives its own CLI through `subprocess.run`, add `[tool.coverage.run]
614
+ patch = ["subprocess"]` to `pyproject.toml` and keep `coverage>=7.10.6`: pytest-cov 7.0.0
615
+ dropped subprocess measurement, so without that key every entry point scores 0% and nothing
616
+ warns. [docs/lanes.md](docs/lanes.md) has the whole rule.
617
+
613
618
  ### 1. Scaffold the config
614
619
 
615
620
  ```
@@ -622,9 +627,13 @@ added to .gitignore: .crapkit/, .coverage, __pycache__/
622
627
  `init` sniffs tracked source into one scope per top-level source directory, and detects a
623
628
  coverage lane from what the repo already has: a pytest marker file (`pyproject.toml`,
624
629
  `pytest.ini`, `setup.cfg`) writes a live `[[lane]]`, and so does a `test` script or
625
- `vitest`/`jest` in `package.json`. Whatever it detects, it also leaves commented templates
626
- for the runners it did not find. Every lane it writes reports into `.crapkit/cov/`, which
627
- is why the `.gitignore` list is so short: see
630
+ `vitest`/`jest` in `package.json`. A lockfile beside them names the environment: `uv.lock`,
631
+ `poetry.lock`, `pdm.lock` or `Pipfile.lock` makes the lane `uv run python -m pytest …` (and
632
+ the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
633
+ has active rather than the one the repo pins — see
634
+ [The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
635
+ it detects, it also leaves commented templates for the runners it did not find. Every lane
636
+ it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
628
637
  [Where artifacts live](docs/lanes.md#where-artifacts-live).
629
638
 
630
639
  ```toml
@@ -784,7 +793,7 @@ exit 5:
784
793
 
785
794
  ```
786
795
  $ crapkit coverage
787
- crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
796
+ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); full log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
788
797
 
789
798
  MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
790
799
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crapkit"
7
- version = "0.4.5"
7
+ version = "0.4.6"
8
8
  description = "Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { text = "MIT" }
@@ -63,14 +63,19 @@ Source = "https://github.com/JeanFrancoisGagne/crapkit"
63
63
  # about 8 minutes serial against about 1m30 parallel on the same tree. Nothing
64
64
  # here needs pytest-timeout: no test takes a `--timeout` and none carries the
65
65
  # marker.
66
- dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3"]
66
+ dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3", "coverage>=7.10.6"]
67
+ # coverage>=7.10.6 in both extras is the floor `[tool.coverage.run] patch` needs,
68
+ # and it is not decoration: coverage 7.9 answers an unrecognized `[run] patch=`
69
+ # with a CoverageWarning that pytest-cov 6.2.0 and later file under
70
+ # `once::CoverageWarning`, so an older coverage takes the key, says so once in a
71
+ # warnings block nobody reads, and measures no subprocess at all.
67
72
  # The py lane's runtime half, for installs that share the suite's venv: the
68
73
  # lane init writes runs `pytest --cov`, and those flags come from pytest-cov.
69
74
  # An install elsewhere (pipx, uv tool) still needs pytest-cov beside the SUITE;
70
75
  # init probes the python that runs pytest, not its own, and names
71
76
  # `pip install "crapkit[py]"` when those are the same environment. Double
72
77
  # quotes: single ones do not survive cmd.exe.
73
- py = ["pytest-cov>=5"]
78
+ py = ["pytest-cov>=5", "coverage>=7.10.6"]
74
79
 
75
80
  [project.scripts]
76
81
  crapkit = "crapkit.cli:main"
@@ -85,3 +90,15 @@ where = ["src"]
85
90
  [tool.pytest.ini_options]
86
91
  testpaths = ["tests"]
87
92
  addopts = "-q --tb=short -p no:cacheprovider"
93
+
94
+ # Every CLI entry point crapkit ships is exercised through `subprocess.run` by
95
+ # tests/e2e and by nothing else, so without a subprocess measurement `cmd_init`
96
+ # and its siblings read 0% and the gate fires on any `cmd_*` a diff touches.
97
+ # pytest-cov 7.0.0 dropped the mechanism that used to do this (its own
98
+ # `pytest-cov.pth` reading COV_CORE_*) and pointed at coverage's own patch
99
+ # system, added in coverage 7.10. Measured on tests/e2e/test_init_doctor_e2e.py:
100
+ # src/crapkit/cli/admin.py scores 0/498 statements under pytest-cov 7.1.0 with
101
+ # this key absent, and 317/498 with it present, on 7.1.0 and 6.3.0 alike.
102
+ # `parallel = true` follows automatically; .coverage* is already gitignored.
103
+ [tool.coverage.run]
104
+ patch = ["subprocess"]
@@ -1,2 +1,2 @@
1
1
  """crapkit: deterministic CRAP-score framework."""
2
- __version__ = "0.4.5"
2
+ __version__ = "0.4.6"
@@ -107,6 +107,7 @@ _OWNER = {
107
107
  "_extend_gitignore": "admin",
108
108
  "_fewer_tests_line": "verifying",
109
109
  "_file_sizer": "_shared",
110
+ "_first_word": "admin",
110
111
  "_flag_counts": "scoring",
111
112
  "_flake_retry": "verifying",
112
113
  "_gate_candidates": "scoring",
@@ -190,6 +191,7 @@ _OWNER = {
190
191
  "_plugin_json": "admin",
191
192
  "_plugins_dir": "admin",
192
193
  "_policy_findings": "ratchet_cmds",
194
+ "_present_lockfiles": "admin",
193
195
  "_present_markers": "admin",
194
196
  "_present_on_disk": "scoring",
195
197
  "_prior_crap": "verifying",
@@ -218,6 +220,7 @@ _OWNER = {
218
220
  "_pruned": "ratchet_cmds",
219
221
  "_pushdown_floor": "queue",
220
222
  "_pytest_cov_probe": "admin",
223
+ "_python_name": "admin",
221
224
  "_range_lines": "analyses",
222
225
  "_rankable": "queue",
223
226
  "_ratchet_entries": "_shared",
@@ -23,7 +23,13 @@ from ..universe import assign_files, scan_files
23
23
  from ._shared import _file_sizer, _load_repo_config, _print_json
24
24
 
25
25
 
26
- def _interpreter() -> str:
26
+ def _present_lockfiles(root: Path) -> frozenset[str]:
27
+ from ..scaffold import LOCKFILE_RUNNERS
28
+
29
+ return frozenset(name for name, _ in LOCKFILE_RUNNERS if (root / name).is_file())
30
+
31
+
32
+ def _python_name() -> str:
27
33
  """The interpreter name a committed config can call. sys.executable is this
28
34
  machine's absolute path and would not survive the repo reaching anyone else.
29
35
 
@@ -41,6 +47,25 @@ def _interpreter() -> str:
41
47
  return "python3"
42
48
 
43
49
 
50
+ def _interpreter(root: Path) -> str:
51
+ """The python invocation a committed config can call.
52
+
53
+ A lockfile at the root wins: `uv run python` and its siblings resolve to the
54
+ environment the repo pins, while a bare `python` resolves to whichever venv
55
+ the shell happens to have active — which in two worktrees of one branch is
56
+ how a lane measures the OTHER checkout and scores this one untested.
57
+
58
+ The manager only PREFIXES the name; which name it prefixes is still
59
+ `_python_name`'s answer, so a Windows PATH carrying no `python` gets
60
+ `uv run py` rather than a word the shell cannot start.
61
+ """
62
+ from ..scaffold import lockfile_runner
63
+
64
+ runner = lockfile_runner(_present_lockfiles(root))
65
+ name = _python_name()
66
+ return f"{runner} {name}" if runner else name
67
+
68
+
44
69
  def _present_markers(root: Path) -> frozenset[str]:
45
70
  from ..scaffold import PYTEST_MARKERS
46
71
 
@@ -137,6 +162,14 @@ def _start_probe(word: str) -> int | None:
137
162
  return None
138
163
 
139
164
 
165
+ def _first_word(command: str) -> str:
166
+ """The word the shell will try to start, read the way that shell reads the
167
+ line: a quoted interpreter path stays one word, where a whitespace split
168
+ would break it at its space. "" when the line holds no word at all."""
169
+ words = shell_words(command)
170
+ return words[0] if words else ""
171
+
172
+
140
173
  def _dead_first_word(command: str) -> tuple[str, int] | None:
141
174
  """The command's first word and the shell's verdict, when the shell cannot
142
175
  start it. None when it starts, and None when the word does not resolve on
@@ -144,11 +177,11 @@ def _dead_first_word(command: str) -> tuple[str, int] | None:
144
177
  proves nothing."""
145
178
  import shutil
146
179
 
147
- words = shell_words(command)
148
- if not words or shutil.which(words[0]) is None:
180
+ word = _first_word(command)
181
+ if not word or shutil.which(word) is None:
149
182
  return None
150
- code = _start_probe(words[0])
151
- return (words[0], code) if _could_not_run_it(code) else None
183
+ code = _start_probe(word)
184
+ return (word, code) if _could_not_run_it(code) else None
152
185
 
153
186
 
154
187
  def _probe_answered_no(returncode: int) -> bool:
@@ -189,7 +222,14 @@ def _probe_interpreter(command: str) -> str | None:
189
222
  """The python this lane runs pytest with, or None when no python runs it.
190
223
  `coverage run -m pytest` names no interpreter at all: the probe asked
191
224
  `coverage` to import pytest_cov, read its argument error as a missing
192
- package, and printed the pip note on a machine where pytest_cov imports."""
225
+ package, and printed the pip note on a machine where pytest_cov imports.
226
+
227
+ An environment manager heads its segment for the same reason: `uv run` and
228
+ its siblings CREATE or sync the project environment before running anything,
229
+ so init has no business provisioning one to ask a question about it, and the
230
+ head word is not a python — `uv -c "import pytest_cov"` is not the probe it
231
+ looks like, and would have warned about the wrong gap on every uv repo.
232
+ """
193
233
  segment = _pytest_segment(command)
194
234
  if not segment or not _is_python(segment[0]):
195
235
  return None
@@ -202,7 +242,8 @@ def _pytest_cov_probe(command: str) -> bool:
202
242
  the lane will get (a .bat shim included), not the one CreateProcess finds.
203
243
  True too when the probe cannot run — only a clean "no" earns the warning,
204
244
  and a missing interpreter is doctor's finding, not this one's. True as well
205
- when no python runs the suite: nothing here can be asked."""
245
+ when no python runs the suite, `uv run python -m pytest` included: nothing
246
+ here can be asked."""
206
247
  import shutil
207
248
 
208
249
  from ..procs import run_bounded
@@ -256,11 +297,41 @@ def _missing_pytest_cov_note(name: str) -> str:
256
297
  "then `crapkit coverage`")
257
298
 
258
299
 
300
+ def _absent_manager(command: str) -> str | None:
301
+ """The environment manager heading this lane, when this machine's PATH
302
+ carries no such word. None when it resolves, and None when nothing manages
303
+ the lane at all.
304
+
305
+ A lockfile is a property of the REPO, so `init` writes `uv run python` off
306
+ its presence alone — right for the repo, and unrunnable on a checkout whose
307
+ owner installed the dependencies with pip. Nothing else catches it: the
308
+ start check skips a word that does not resolve, and the pytest-cov probe
309
+ refuses to provision an environment to ask a question about it.
310
+ """
311
+ import shutil
312
+
313
+ from ..scaffold import LOCKFILE_RUNNERS
314
+
315
+ managers = {runner.split()[0] for _, runner in LOCKFILE_RUNNERS}
316
+ head = _first_word(command)
317
+ return head if head in managers and shutil.which(head) is None else None
318
+
319
+
320
+ def _missing_manager_note(name: str, manager: str) -> str:
321
+ return (f"note: lane {name!r} runs through `{manager}`, which this machine's PATH does "
322
+ f"not carry — install {manager}, or point the lane's command in crapkit.toml at "
323
+ f"an interpreter that resolves here, then `crapkit coverage`")
324
+
325
+
259
326
  def _lane_first_run_note(lane) -> str | None:
260
327
  """What init owes this lane before the first `crapkit coverage`, or None
261
- when the lane will run. Two different gaps, and they are not the same
262
- sentence: an interpreter that never started answered nothing about
263
- pytest_cov, and `pip install pytest-cov` fixes none of it."""
328
+ when the lane will run. Three different gaps, and they are not the same
329
+ sentence: a manager that is not installed never gets as far as a python, and
330
+ an interpreter that never started answered nothing about pytest_cov, so
331
+ `pip install pytest-cov` fixes neither."""
332
+ manager = _absent_manager(lane.command)
333
+ if manager:
334
+ return _missing_manager_note(lane.name, manager)
264
335
  dead = _dead_first_word(lane.command)
265
336
  if dead:
266
337
  return _dead_interpreter_note(lane.name, *dead)
@@ -316,7 +387,7 @@ def cmd_init(args: argparse.Namespace) -> int:
316
387
  # A config whose lanes are all commented out scores every function no-lane,
317
388
  # so a fresh repo cannot rank anything until somebody hand-writes a lane.
318
389
  lanes = detect_lanes(_present_markers(root), _package_json(root),
319
- interpreter=_interpreter())
390
+ interpreter=_interpreter(root))
320
391
  text = starter_toml(scopes, lanes)
321
392
  load_config_text(text) # self-check: never write a config crapkit cannot read back
322
393
  toml_path.write_text(text, encoding="utf-8", newline="\n")
@@ -281,7 +281,14 @@ def parse_istanbul_missing_file(path: Path | str, *, repo_root: str,
281
281
  return _guarded(lambda: _istanbul_map(w, repo_root, _dead_lines), _BAD_ISTANBUL)
282
282
 
283
283
 
284
- def _prefix(path_prefix: str) -> str:
284
+ def lane_prefix(path_prefix: str) -> str:
285
+ """The lane's `path_prefix` as it is actually glued onto a measured path.
286
+
287
+ Public because the lane layer has to take it back OFF: it judges whether a
288
+ measured path escaped the checkout, and that question is about the path the
289
+ runner wrote, not about the key crapkit built out of it. Spelling the join
290
+ twice let `backend/` turn `/other/checkout/a.py` into something that reads
291
+ relative, and the refusal went silent on every lane that sets the knob."""
285
292
  return (path_prefix.rstrip("/") + "/") if path_prefix else ""
286
293
 
287
294
 
@@ -334,7 +341,7 @@ def parse_coveragepy_file(path: Path | str, *, path_prefix: str, chunk: int = CH
334
341
  """Per-file function coverage plus the sha256 of the report's own bytes."""
335
342
  w, handle = _window(path, chunk)
336
343
  with handle:
337
- per_file = _guarded(lambda: _coveragepy_functions(w, _prefix(path_prefix)),
344
+ per_file = _guarded(lambda: _coveragepy_functions(w, lane_prefix(path_prefix)),
338
345
  _BAD_REPORT)
339
346
  return per_file, w.hasher.hexdigest()
340
347
 
@@ -352,4 +359,4 @@ def parse_coveragepy_missing_file(path: Path | str, *, path_prefix: str,
352
359
  """Per measured file, the lines coverage.py reports as never run."""
353
360
  w, handle = _window(path, chunk)
354
361
  with handle:
355
- return _guarded(lambda: _coveragepy_missing(w, _prefix(path_prefix)), _BAD_REPORT)
362
+ return _guarded(lambda: _coveragepy_missing(w, lane_prefix(path_prefix)), _BAD_REPORT)
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import json
15
15
  import os
16
+ import re
16
17
  import sys
17
18
  import time
18
19
  from pathlib import Path
@@ -20,12 +21,12 @@ from typing import IO, NamedTuple
20
21
 
21
22
  from .config import Lane
22
23
  from .coverage_istanbul import FnCoverage
23
- from .covstream import parse_coveragepy_file, parse_istanbul_both_file
24
+ from .covstream import lane_prefix, parse_coveragepy_file, parse_istanbul_both_file
24
25
  from .errors import GitError, ToolError
25
26
  from .gitio import GitFacts
26
27
  from .procs import run_bounded
27
28
  from .uncovered import fold_dead_lines
28
- from .universe import owning_scope, path_matchers
29
+ from .universe import ScopeMatch, owning_scope, path_matchers
29
30
 
30
31
 
31
32
  def _in_container() -> bool:
@@ -45,10 +46,72 @@ def _lane_log_path(root: Path, lane: Lane) -> Path:
45
46
  return log_path
46
47
 
47
48
 
48
- def _log_tail(log_path: Path) -> str:
49
+ _TAIL_BUDGET = 500
50
+ _CAUSE_LINES = 3
51
+ _CAUSE_WIDTH = 200
52
+
53
+ # Lines that name a cause rather than restate a count: pytest's `E ` gutter, a
54
+ # traceback header, a bare exception line. A run that died in collection ends
55
+ # with a summary block of `ERROR path` lines saying WHICH files broke and never
56
+ # why, so a plain tail spends its whole budget on filenames while the reason
57
+ # scrolls off above it.
58
+ _DIAGNOSTIC = re.compile(r"\s*(E\s|Traceback \(most recent call last\)|\w*(Error|Exception): )")
59
+
60
+
61
+ def _log_lines(log_path: Path) -> list[str]:
49
62
  if not log_path.is_file():
50
- return ""
51
- return log_path.read_text(encoding="utf-8", errors="replace").strip()[-500:]
63
+ return []
64
+ return log_path.read_text(encoding="utf-8", errors="replace").strip().splitlines()
65
+
66
+
67
+ def _tail_lines(lines: list[str], budget: int) -> list[str]:
68
+ """The last WHOLE lines that fit the budget. A tail cut on a byte count
69
+ starts mid-line, and the reader cannot tell that fragment from the real
70
+ start of the message: the report that prompted this opened on
71
+ "short test summary info ====" and read as the first thing the run said.
72
+ One line over budget on its own keeps its end behind an ellipsis, which at
73
+ least says so."""
74
+ kept: list[str] = []
75
+ left = budget
76
+ for line in reversed(lines):
77
+ left -= len(line) + 1
78
+ if left < 0:
79
+ break
80
+ kept.append(line)
81
+ if kept:
82
+ kept.reverse()
83
+ return kept
84
+ return ["..." + lines[-1][-budget:]] if lines else []
85
+
86
+
87
+ def _cut_cause(line: str) -> str:
88
+ """One cause line inside the budget, cut from the LEFT and marked.
89
+
90
+ What identifies an ImportError is the path at the end of it — the OTHER
91
+ checkout's, in the report this came from — so a cut taken from the right
92
+ drops the half worth hoisting. `_tail_lines` marks its own unavoidable cut
93
+ for the same reason: an unmarked one reads as the whole message."""
94
+ return line if len(line) <= _CAUSE_WIDTH else "..." + line[-(_CAUSE_WIDTH - 3):]
95
+
96
+
97
+ def _cause_lines(lines: list[str], tail: list[str]) -> list[str]:
98
+ """The last lines anywhere in the log that name a failure, when the tail
99
+ carries none. Nothing when the tail already says why — repeating it would
100
+ spend the message on the same words twice."""
101
+ if any(_DIAGNOSTIC.match(line) for line in tail):
102
+ return []
103
+ named = [_cut_cause(line.strip()) for line in lines if _DIAGNOSTIC.match(line)]
104
+ return named[-_CAUSE_LINES:]
105
+
106
+
107
+ def _log_tail(log_path: Path) -> str:
108
+ """What the log says about its own failure: the reason lines first when the
109
+ end of the log does not carry one, then the last whole lines of output. The
110
+ ellipsis between them marks the output they skipped over."""
111
+ lines = _log_lines(log_path)
112
+ tail = _tail_lines(lines, _TAIL_BUDGET)
113
+ cause = _cause_lines(lines, tail)
114
+ return "\n".join([*cause, "...", *tail] if cause else tail)
52
115
 
53
116
 
54
117
  def _popen_kwargs(root: Path, lane: Lane) -> dict:
@@ -105,11 +168,15 @@ def _missing_plugin_hint(tail: str) -> str:
105
168
 
106
169
 
107
170
  def _raise_no_artifact(lane: Lane, log_path: Path, exit_code: int | None) -> None:
171
+ """The log PATH before the log's words. A tail is 500 characters of a file
172
+ that holds the whole story, and a reader who is not told where that file is
173
+ has to go looking for it — one reporter had to ask another agent to find
174
+ .crapkit/lane-py.log while ten collection tracebacks sat inside it."""
108
175
  detail = f" (command exit {exit_code})" if exit_code is not None else ""
109
176
  tail = _log_tail(log_path)
110
177
  hint = f"; last output: {tail}" if tail else ""
111
- raise ToolError(f"lane {lane.name!r} produced no artifact at {lane.artifact}{detail}{hint}"
112
- f"{_missing_plugin_hint(tail)}")
178
+ raise ToolError(f"lane {lane.name!r} produced no artifact at {lane.artifact}{detail}"
179
+ f"; full log: {log_path}{hint}{_missing_plugin_hint(tail)}")
113
180
 
114
181
 
115
182
  def _run_attempts(root: Path, lane: Lane) -> int | None:
@@ -216,6 +283,18 @@ def lane_order(root: Path, lanes: list[Lane]) -> list[Lane]:
216
283
  return sorted(lanes, key=lambda lane: -_stamp_seconds(stamps, lane))
217
284
 
218
285
 
286
+ def _lane_matchers(lane: Lane, scope_paths: dict) -> tuple[ScopeMatch, ...]:
287
+ """Ownership over the scopes THIS lane names, read by universe's one predicate.
288
+
289
+ Both questions a lane asks about paths go through here: which of its scopes'
290
+ files moved since the artifact was stamped, and whether the artifact reached
291
+ any of them at all. A second hand-rolled prefix test would answer the exact
292
+ arm — a scope that declares a FILE rather than a directory — differently
293
+ from the rule that assigns files, and the two would drift.
294
+ """
295
+ return path_matchers({name: scope_paths.get(name, ()) for name in lane.scopes})
296
+
297
+
219
298
  def _scope_changes(git: GitFacts, lane: Lane, scope_paths: dict, since_commit: str) -> list[str]:
220
299
  """Committed or working-tree changes under this lane's scope paths since a commit.
221
300
 
@@ -225,7 +304,7 @@ def _scope_changes(git: GitFacts, lane: Lane, scope_paths: dict, since_commit: s
225
304
  shape crapkit's own tests/e2e/test_parallel_lanes_e2e.py writes — and
226
305
  editing that file read as no change at all.
227
306
  """
228
- matchers = path_matchers({name: scope_paths.get(name, ()) for name in lane.scopes})
307
+ matchers = _lane_matchers(lane, scope_paths)
229
308
  if not matchers:
230
309
  return []
231
310
  changed = set(git.diff_names_since(since_commit)) | set(git.status_names())
@@ -292,6 +371,138 @@ def _read_and_parse(lane: Lane, root: Path,
292
371
  raise ToolError(f"lane {lane.name!r}: parser {lane.parser!r} not implemented yet")
293
372
 
294
373
 
374
+ _SAMPLE_PATHS = 3
375
+
376
+ # A path the runner could not write relative to this checkout: absolute, drive
377
+ # lettered, or climbing out of the tree. Both parsers rebase a file INSIDE the
378
+ # repo to a repo-relative path, so one that is not is a file somewhere else.
379
+ _DRIVE = re.compile(r"[A-Za-z]:[\\/]")
380
+
381
+
382
+ def _escapes_repo(path: str) -> bool:
383
+ return path.startswith(("/", "../")) or _DRIVE.match(path) is not None
384
+
385
+
386
+ def _as_reported(lane: Lane, path: str) -> str:
387
+ """One coverage key with this lane's own `path_prefix` taken back off, which
388
+ is the path the runner actually wrote.
389
+
390
+ The coveragepy reader prepends the prefix to EVERY key, an absolute one
391
+ included, so `backend/` + `/other/checkout/a.py` starts with neither `/` nor
392
+ a drive letter. Asked of that key, `_escapes_repo` answers no on every lane
393
+ that declares the knob — the monorepo shape the check was written for."""
394
+ prefix = lane_prefix(lane.path_prefix)
395
+ return path[len(prefix):] if prefix and path.startswith(prefix) else path
396
+
397
+
398
+ def _unreached_paths(lane: Lane, coverage: dict, scope_paths: dict) -> tuple[str, ...]:
399
+ """The paths this lane's scopes declare when NOTHING the artifact measured
400
+ reaches any of them, else (). Empty too when the lane's scopes declare no
401
+ path at all: nothing to compare against is not evidence.
402
+
403
+ `owning_scope` is the reach test, so "could a scope claim this path?" can
404
+ never drift from the rule that assigns files to scopes.
405
+
406
+ The DECLARED path, not the matcher's directory prefix: a scope may name an
407
+ individual file, and the prefix half of that is `src/faro/core.py/`, a path
408
+ that exists neither in the config the reader is about to open nor on disk.
409
+ """
410
+ matchers = _lane_matchers(lane, scope_paths)
411
+ if not matchers or any(owning_scope(path, matchers) for path in coverage):
412
+ return ()
413
+ return tuple(dict.fromkeys(m.path for m in matchers))
414
+
415
+
416
+ def _outside_paths(lane: Lane, coverage: dict) -> list[str]:
417
+ """The measured files that are not in this checkout, spelled the way the
418
+ artifact spells them."""
419
+ reported = (_as_reported(lane, path) for path in coverage)
420
+ return sorted(path for path in reported if _escapes_repo(path))
421
+
422
+
423
+ def _sample(paths) -> str:
424
+ """A few of them and a count of the rest. A lane scoped to forty declared
425
+ paths listed all forty, which pushed the sentence saying what to do off the
426
+ end of a line nobody reads that far into."""
427
+ ordered = sorted(paths)
428
+ shown = ", ".join(ordered[:_SAMPLE_PATHS])
429
+ rest = len(ordered) - _SAMPLE_PATHS
430
+ return f"{shown} and {rest} more" if rest > 0 else shown
431
+
432
+
433
+ # What to do about it, which is not the same sentence for both readers. The
434
+ # coveragepy reader takes path_prefix and takes no repo root, so its refusal is
435
+ # about the environment the lane binds to and the prefix is a real knob. The
436
+ # istanbul reader takes the root, rebases every path under it and never reads
437
+ # path_prefix at all, so a path that stayed absolute came from another tree and
438
+ # no key on the lane can rebase it.
439
+ _COVERAGEPY_FIX = ("Point the lane at this checkout's own environment (a bare "
440
+ "`python -m pytest` binds to whichever venv the shell has active — run "
441
+ "it through the project's manager, `uv run python -m pytest ...`), or "
442
+ "set path_prefix when the runner reports paths relative to a subdirectory")
443
+ _ISTANBUL_FIX = ("The reader rebases every path under this checkout's root, so these were "
444
+ "written against another one: rerun the suite here rather than reusing an "
445
+ "artifact copied in or restored from a CI cache")
446
+
447
+ _COVERAGEPY_MISS = "or the runner reports paths this lane needs path_prefix to rebase"
448
+ _ISTANBUL_MISS = "or the suite measured a part of the tree these scopes do not name"
449
+
450
+
451
+ def _wrong_tree_fix(lane: Lane) -> str:
452
+ return _COVERAGEPY_FIX if lane.parser == "coveragepy" else _ISTANBUL_FIX
453
+
454
+
455
+ def _unmeasured_reading(lane: Lane) -> str:
456
+ return _COVERAGEPY_MISS if lane.parser == "coveragepy" else _ISTANBUL_MISS
457
+
458
+
459
+ def _zero_overlap(lane: Lane, coverage: dict, declared) -> str:
460
+ """The finding both verdicts open on, written once: what the artifact
461
+ measured, and that none of it is in scope. The two messages part company
462
+ after it, and a sentence kept in two places is a sentence that drifts."""
463
+ return (f"lane {lane.name!r} measured {len(coverage)} file(s), none of them under the "
464
+ f"paths its scopes declare ({_sample(declared)})")
465
+
466
+
467
+ def _wrong_tree_message(lane: Lane, coverage: dict, declared, outside: list[str]) -> str:
468
+ return (f"{_zero_overlap(lane, coverage, declared)}, and {len(outside)} of them "
469
+ f"outside this checkout entirely — {lane.artifact} describes a different tree, "
470
+ f"so joining it would score every function in those scopes untested; it reports "
471
+ f"paths like {_sample(outside)}. {_wrong_tree_fix(lane)}")
472
+
473
+
474
+ def _unmeasured_message(lane: Lane, coverage: dict, declared) -> str:
475
+ reports = f"; it measured {_sample(coverage)}" if coverage else ""
476
+ return (f"{_zero_overlap(lane, coverage, declared)}, so every function in those "
477
+ f"scopes will score untested{reports} — either nothing in them is exercised yet, "
478
+ f"{_unmeasured_reading(lane)}")
479
+
480
+
481
+ def _judge_artifact_scope(lane: Lane, coverage: dict, scope_paths: dict | None) -> None:
482
+ """Say something when a lane's artifact reaches none of the scopes it claims.
483
+
484
+ Coverage joins on path and nothing else, so such an artifact contributes
485
+ exactly nothing and every function in those scopes reads `untested` — a
486
+ confident "N untested, grade F" assembled out of a tooling mistake, which
487
+ is worse than the exit 5 a missing artifact earns because it looks like an
488
+ answer. Two worktrees of one branch reach it quietly: a venv whose editable
489
+ install points at the other checkout makes coverage.py measure that tree.
490
+
491
+ Two verdicts, because zero overlap has two readings and the paths tell them
492
+ apart. Measured files OUTSIDE this checkout can only be another tree, and
493
+ that fails the lane. In-tree paths that simply miss the scopes are the
494
+ greenfield shape as well — a suite that imports none of the scoped source
495
+ yet, which SHOULD score untested — so that one warns and scores on.
496
+ """
497
+ declared = _unreached_paths(lane, coverage, scope_paths or {})
498
+ if not declared:
499
+ return
500
+ outside = _outside_paths(lane, coverage)
501
+ if outside:
502
+ raise ToolError(_wrong_tree_message(lane, coverage, declared, outside))
503
+ print(f"crapkit: {_unmeasured_message(lane, coverage, declared)}", file=sys.stderr)
504
+
505
+
295
506
  def _results_provenance(root: Path, lane: Lane) -> dict:
296
507
  from .junitparse import suite_summary
297
508
 
@@ -399,9 +610,17 @@ def _run_or_reuse(root: Path, lane: Lane, git: GitFacts, scope_paths: dict | Non
399
610
 
400
611
 
401
612
  def _artifact_path(root: Path, lane: Lane) -> Path:
613
+ """The artifact, or the same refusal a run that wrote none raises.
614
+
615
+ Reuse is what reaches here — the run path refuses inside _run_attempts — and
616
+ it was the one lane refusal that named no log, on the reading that a reused
617
+ artifact had no run behind it. The previous run's log is usually sitting
618
+ there with the story, and a reader told to look for `full log:` on every
619
+ refusal has nowhere to go when one omits it.
620
+ """
402
621
  path = root / lane.artifact
403
622
  if not path.is_file():
404
- raise ToolError(f"lane {lane.name!r} produced no artifact at {lane.artifact}")
623
+ _raise_no_artifact(lane, _lane_log_path(root, lane), None)
405
624
  return path
406
625
 
407
626
 
@@ -411,6 +630,7 @@ def run_lane(root: Path, lane: Lane, *, reuse_artifact: bool = False,
411
630
  _refuse_container_python(lane)
412
631
  exit_code, seconds = _run_or_reuse(root, lane, facts, scope_paths, reuse_artifact)
413
632
  coverage, digest = _read_and_parse(lane, root, _artifact_path(root, lane))
633
+ _judge_artifact_scope(lane, coverage, scope_paths)
414
634
  provenance = {
415
635
  "artifact_sha256": digest,
416
636
  "exit_code": exit_code,
@@ -97,6 +97,29 @@ class LaneSpec(NamedTuple):
97
97
 
98
98
  PYTEST_MARKERS = ("pyproject.toml", "pytest.ini", "setup.cfg")
99
99
 
100
+ # A lockfile is the repo saying it pins its own environment through a manager,
101
+ # and only that manager's `run` binds a command to it. A bare `python` binds to
102
+ # whatever venv the shell has active instead: the report this came from ran a
103
+ # scaffolded lane in one worktree while the shell held another worktree's venv,
104
+ # whose editable install pointed at the other checkout's sources, and pytest
105
+ # collected ten ImportErrors. Detection is the same file-presence signal that
106
+ # picks the lane itself, so nothing is executed to learn this.
107
+ #
108
+ # First match wins, so a repo carrying two lockfiles resolves the same way every
109
+ # time rather than by dict order.
110
+ LOCKFILE_RUNNERS = (("uv.lock", "uv run"), ("poetry.lock", "poetry run"),
111
+ ("pdm.lock", "pdm run"), ("Pipfile.lock", "pipenv run"))
112
+
113
+
114
+ def lockfile_runner(present: frozenset[str]) -> str:
115
+ """The manager prefix that pins a python invocation to THIS project's
116
+ environment, or "" when no lockfile in the repo names one."""
117
+ for name, runner in LOCKFILE_RUNNERS:
118
+ if name in present:
119
+ return runner
120
+ return ""
121
+
122
+
100
123
  _PY_LANGUAGES = ("python",)
101
124
  _JS_LANGUAGES = ("javascript", "tsx", "typescript")
102
125
  _JS_RUNNER_COMMAND = {"jest": "npx jest --coverage", "vitest": "npx vitest run --coverage"}
@@ -300,18 +323,49 @@ _TEMPLATES = {
300
323
  # is the runner that scope's language usually uses, and the whole block stays
301
324
  # commented because only the repo knows whether that command is the right one.
302
325
  _SCOPED_TEST_COMMANDS = {
303
- "python": "python -m pytest {files} -q -p no:cacheprovider",
326
+ "python": "{python} -m pytest {files} -q -p no:cacheprovider",
304
327
  "javascript": "npx vitest run {files}",
305
328
  "tsx": "npx vitest run {files}",
306
329
  "typescript": "npx vitest run {files}",
307
330
  }
308
331
  _SCOPED_TEST_PLACEHOLDER = "<your test command> {files}"
332
+ _DEFAULT_PYTHON = "python"
333
+
334
+
335
+ # The shape that marks a detected lane as the pytest lane, read in one place:
336
+ # the launcher and the confirmed-runner check spelled it two ways once, so a
337
+ # command ending at `-m pytest` was the pytest lane to one and not the other,
338
+ # and init wrote a commented template carrying a `uv run python` the same file
339
+ # had just refused to confirm.
340
+ _PYTEST_INVOCATION = " -m pytest "
341
+
342
+
343
+ def _pytest_lane_launcher(lanes: tuple[LaneSpec, ...]) -> str | None:
344
+ """The python the lane that runs pytest runs it with, or None when no
345
+ detected lane runs pytest at all."""
346
+ for lane in lanes:
347
+ head, sep, _ = lane.command.partition(_PYTEST_INVOCATION)
348
+ if sep:
349
+ return head
350
+ return None
351
+
352
+
353
+ def python_launcher(lanes: tuple[LaneSpec, ...]) -> str:
354
+ """The python invocation the detected pytest lane already settled on.
355
+
356
+ Read back off the lane rather than passed in beside it, so the scoped-tests
357
+ entry runs the suite the way the coverage lane runs it and the two cannot
358
+ drift: a `uv run python` lane with a bare `python` step 4 would measure one
359
+ environment and test another.
360
+ """
361
+ launcher = _pytest_lane_launcher(lanes)
362
+ return _DEFAULT_PYTHON if launcher is None else launcher
309
363
 
310
364
 
311
- def _scoped_test_command(languages: tuple[str, ...]) -> str:
365
+ def _scoped_test_command(languages: tuple[str, ...], launcher: str) -> str:
312
366
  for language in languages:
313
367
  if language in _SCOPED_TEST_COMMANDS:
314
- return _SCOPED_TEST_COMMANDS[language]
368
+ return _SCOPED_TEST_COMMANDS[language].replace("{python}", launcher)
315
369
  return _SCOPED_TEST_PLACEHOLDER
316
370
 
317
371
 
@@ -326,17 +380,19 @@ def _confirmed_languages(lanes: tuple[LaneSpec, ...]) -> frozenset[str]:
326
380
  `python -m pytest {files}` known-good. The js runners stay unconfirmed on
327
381
  purpose — which vitest or jest config a file-scoped run needs is exactly
328
382
  what presence detection cannot see."""
329
- return frozenset({"python"} if any(" -m pytest " in ln.command for ln in lanes) else ())
383
+ return frozenset({"python"} if _pytest_lane_launcher(lanes) is not None else ())
330
384
 
331
385
 
332
- def _scoped_entry_lines(scopes: dict[str, tuple[str, ...]], live: bool) -> list[str]:
386
+ def _scoped_entry_lines(scopes: dict[str, tuple[str, ...]], live: bool,
387
+ launcher: str) -> list[str]:
333
388
  prefix = "" if live else "# "
334
- return [f'{prefix}{name} = "{_scoped_test_command(languages)}"'
389
+ return [f'{prefix}{name} = "{_scoped_test_command(languages, launcher)}"'
335
390
  for name, languages in scopes.items()]
336
391
 
337
392
 
338
393
  def _scoped_tests_stub(scopes: dict[str, tuple[str, ...]],
339
- confirmed: frozenset[str] = frozenset()) -> list[str]:
394
+ confirmed: frozenset[str] = frozenset(),
395
+ launcher: str = _DEFAULT_PYTHON) -> list[str]:
340
396
  """The [crapkit.scoped_tests] block: live entries for scopes whose runner a
341
397
  detected lane proves, commented templates for the rest.
342
398
 
@@ -349,20 +405,22 @@ def _scoped_tests_stub(scopes: dict[str, tuple[str, ...]],
349
405
  rest = {n: l for n, l in scopes.items() if n not in live}
350
406
  intro = ["# `crapkit test-scoped FILES` runs one command per scope, with {files}",
351
407
  "# replaced by that scope's files, each quoted."]
352
- return intro + _live_block(live) + _commented_block(rest, bool(live)) + [""]
408
+ return (intro + _live_block(live, launcher)
409
+ + _commented_block(rest, bool(live), launcher) + [""])
353
410
 
354
411
 
355
- def _live_block(live: dict[str, tuple[str, ...]]) -> list[str]:
412
+ def _live_block(live: dict[str, tuple[str, ...]], launcher: str) -> list[str]:
356
413
  if not live:
357
414
  return []
358
- return ["[crapkit.scoped_tests]"] + _scoped_entry_lines(live, True)
415
+ return ["[crapkit.scoped_tests]"] + _scoped_entry_lines(live, True, launcher)
359
416
 
360
417
 
361
- def _commented_block(rest: dict[str, tuple[str, ...]], has_live: bool) -> list[str]:
418
+ def _commented_block(rest: dict[str, tuple[str, ...]], has_live: bool,
419
+ launcher: str) -> list[str]:
362
420
  if not rest:
363
421
  return []
364
422
  header = ["# Uncomment what fits:"] + ([] if has_live else ["# [crapkit.scoped_tests]"])
365
- return header + _scoped_entry_lines(rest, False)
423
+ return header + _scoped_entry_lines(rest, False, launcher)
366
424
 
367
425
 
368
426
  def _template_lines(covered: set[str], scopes: dict[str, tuple[str, ...]]) -> list[str]:
@@ -392,7 +450,8 @@ def starter_toml(scopes: dict[str, tuple[str, ...]], lanes: tuple[LaneSpec, ...]
392
450
  lines += _exclude_stanza()
393
451
  live, covered = _live_lanes(lanes, scopes)
394
452
  return "\n".join(lines + live + _template_lines(covered, scopes)
395
- + _scoped_tests_stub(scopes, _confirmed_languages(lanes)))
453
+ + _scoped_tests_stub(scopes, _confirmed_languages(lanes),
454
+ python_launcher(lanes)))
396
455
 
397
456
 
398
457
  _STORE_IGNORE = ".crapkit/"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.4.5
3
+ Version: 0.4.6
4
4
  Summary: Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more.
5
5
  Author: Jean-Francois Gagne
6
6
  License: MIT
@@ -31,8 +31,10 @@ Provides-Extra: dev
31
31
  Requires-Dist: pytest>=8; extra == "dev"
32
32
  Requires-Dist: pytest-cov>=5; extra == "dev"
33
33
  Requires-Dist: pytest-xdist>=3; extra == "dev"
34
+ Requires-Dist: coverage>=7.10.6; extra == "dev"
34
35
  Provides-Extra: py
35
36
  Requires-Dist: pytest-cov>=5; extra == "py"
37
+ Requires-Dist: coverage>=7.10.6; extra == "py"
36
38
  Dynamic: license-file
37
39
 
38
40
  # crapkit
@@ -143,7 +145,7 @@ changing crapkit.
143
145
 
144
146
  ```
145
147
  $ crapkit --version
146
- crapkit 0.4.5
148
+ crapkit 0.4.6
147
149
  ```
148
150
 
149
151
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -344,7 +346,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
344
346
  repos:
345
347
  - repo: https://github.com/JeanFrancoisGagne/crapkit
346
348
  # crapkit's release step rewrites this line to the tag it just cut
347
- rev: v0.4.5
349
+ rev: v0.4.6
348
350
  hooks:
349
351
  - id: crapkit-gate
350
352
  ```
@@ -614,7 +616,7 @@ crapkit: run 3 is an inventory run (no coverage was measured) and cannot serve a
614
616
  | 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |
615
617
  | 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metric-stamp mismatch ([Upgrading from 0.4.4](#upgrading-from-044)), a `test-scoped` file under no scope or under a scope with no template. |
616
618
  | 4 | Git error: not a repository, a baseline commit rewritten out of the history. |
617
- | 5 | Tool error: lizard not importable, a lane produced no artifact, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
619
+ | 5 | Tool error: lizard not importable, a lane produced no artifact or one that measured a different tree, a lane timed out past its retries, an override alert command failed. A `timeout_seconds` kills the whole process tree, so no orphan suite keeps running behind the failure. |
618
620
  | 6 | Gate violation. A function the diff touched is over its ceiling and past any ratchet mark it carries: an edit that leaves a marked function at or under its mark is the debt the repo signed for and is exempt. Also `rescore --gate`, which applies the same rule, and `hook-precommit`, which exempts on the mark's existence instead. |
619
621
  | 7 | Ratchet regression the diff never touched. A marked function scores worse than its recorded high-water mark; a touched one past its mark reports 6. |
620
622
  | 8 | New test failures against the baseline run. Failures the baseline already had do not count. |
@@ -647,6 +649,11 @@ pip install pytest-cov
647
649
 
648
650
  (`pip install "crapkit[py]"` pulls both at once when crapkit shares the suite's venv.)
649
651
 
652
+ If your suite drives its own CLI through `subprocess.run`, add `[tool.coverage.run]
653
+ patch = ["subprocess"]` to `pyproject.toml` and keep `coverage>=7.10.6`: pytest-cov 7.0.0
654
+ dropped subprocess measurement, so without that key every entry point scores 0% and nothing
655
+ warns. [docs/lanes.md](docs/lanes.md) has the whole rule.
656
+
650
657
  ### 1. Scaffold the config
651
658
 
652
659
  ```
@@ -659,9 +666,13 @@ added to .gitignore: .crapkit/, .coverage, __pycache__/
659
666
  `init` sniffs tracked source into one scope per top-level source directory, and detects a
660
667
  coverage lane from what the repo already has: a pytest marker file (`pyproject.toml`,
661
668
  `pytest.ini`, `setup.cfg`) writes a live `[[lane]]`, and so does a `test` script or
662
- `vitest`/`jest` in `package.json`. Whatever it detects, it also leaves commented templates
663
- for the runners it did not find. Every lane it writes reports into `.crapkit/cov/`, which
664
- is why the `.gitignore` list is so short: see
669
+ `vitest`/`jest` in `package.json`. A lockfile beside them names the environment: `uv.lock`,
670
+ `poetry.lock`, `pdm.lock` or `Pipfile.lock` makes the lane `uv run python -m pytest …` (and
671
+ the matching `run` for the rest), because a bare `python` binds to whichever venv the shell
672
+ has active rather than the one the repo pins — see
673
+ [The interpreter a lane binds to](docs/lanes.md#the-interpreter-a-lane-binds-to). Whatever
674
+ it detects, it also leaves commented templates for the runners it did not find. Every lane
675
+ it writes reports into `.crapkit/cov/`, which is why the `.gitignore` list is so short: see
665
676
  [Where artifacts live](docs/lanes.md#where-artifacts-live).
666
677
 
667
678
  ```toml
@@ -821,7 +832,7 @@ exit 5:
821
832
 
822
833
  ```
823
834
  $ crapkit coverage
824
- crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
835
+ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); full log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
825
836
 
826
837
  MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
827
838
 
@@ -4,6 +4,8 @@ lizard>=1.24.0
4
4
  pytest>=8
5
5
  pytest-cov>=5
6
6
  pytest-xdist>=3
7
+ coverage>=7.10.6
7
8
 
8
9
  [py]
9
10
  pytest-cov>=5
11
+ coverage>=7.10.6
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