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.
- {crapkit-0.4.5/src/crapkit.egg-info → crapkit-0.4.6}/PKG-INFO +19 -8
- {crapkit-0.4.5 → crapkit-0.4.6}/README.md +16 -7
- {crapkit-0.4.5 → crapkit-0.4.6}/pyproject.toml +20 -3
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/__init__.py +1 -1
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/__init__.py +3 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/admin.py +82 -11
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/covstream.py +10 -3
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lanes.py +229 -9
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/scaffold.py +72 -13
- {crapkit-0.4.5 → crapkit-0.4.6/src/crapkit.egg-info}/PKG-INFO +19 -8
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit.egg-info/requires.txt +2 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/LICENSE +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/setup.cfg +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/__main__.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/_pygdefer.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/analyze.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cache.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/churn.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/churn_cache.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/churn_log.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/_shared.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/analyses.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/claude_hook.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/parser.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/queue.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/ratchet_cmds.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/reports.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/scoring.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/cli/verifying.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/config.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/coupling.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/coupling_cache.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/coverage_istanbul.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/coverage_py.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/diffparse.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/digest.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/discover.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/doctor.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/dup.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/errors.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/gitio.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/hook.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/junitparse.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/keys.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lizardcognitive.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lizardpowershell.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lizardrust.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/lizardshell.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/mcp_server.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/merge.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/mutate.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/mutate_pool.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/override.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/packet.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/procs.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/ratchet.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/ratchet_report.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/report.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/sarif.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/sarifio.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/score.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/snapshot.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/store.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/uncovered.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/universe.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/verify.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/watch.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit/worklist.py +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit.egg-info/SOURCES.txt +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit.egg-info/dependency_links.txt +0 -0
- {crapkit-0.4.5 → crapkit-0.4.6}/src/crapkit.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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`.
|
|
663
|
-
|
|
664
|
-
|
|
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.
|
|
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.
|
|
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`.
|
|
626
|
-
|
|
627
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
148
|
-
if not
|
|
180
|
+
word = _first_word(command)
|
|
181
|
+
if not word or shutil.which(word) is None:
|
|
149
182
|
return None
|
|
150
|
-
code = _start_probe(
|
|
151
|
-
return (
|
|
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
|
|
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.
|
|
262
|
-
sentence:
|
|
263
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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()
|
|
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}
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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()
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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`.
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
|
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
|
|
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
|