crapkit 0.7.0__tar.gz → 0.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. {crapkit-0.7.0/src/crapkit.egg-info → crapkit-0.7.2}/PKG-INFO +13 -7
  2. {crapkit-0.7.0 → crapkit-0.7.2}/README.md +11 -5
  3. {crapkit-0.7.0 → crapkit-0.7.2}/pyproject.toml +3 -2
  4. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/__init__.py +1 -1
  5. crapkit-0.7.2/src/crapkit/_analysis_pool.py +190 -0
  6. crapkit-0.7.2/src/crapkit/_mcp_stdio.py +208 -0
  7. crapkit-0.7.2/src/crapkit/_process_family.py +83 -0
  8. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/_process_owner.py +36 -11
  9. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/analyze.py +91 -55
  10. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cache.py +12 -2
  11. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/admin.py +21 -7
  12. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/analyses.py +1 -1
  13. crapkit-0.7.2/src/crapkit/cli/maintenance.py +39 -0
  14. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/parser.py +7 -1
  15. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/scoring.py +9 -8
  16. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/verifying.py +8 -8
  17. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/config.py +14 -4
  18. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/config_contract.py +16 -3
  19. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/coverage_istanbul.py +42 -4
  20. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/covstream.py +31 -0
  21. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/gitio.py +25 -11
  22. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/hook.py +4 -3
  23. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lanes.py +19 -14
  24. crapkit-0.7.2/src/crapkit/logs.py +130 -0
  25. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/mcp_server.py +39 -26
  26. crapkit-0.7.2/src/crapkit/merge.py +35 -0
  27. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/mutate_pool.py +170 -95
  28. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/procs.py +198 -25
  29. crapkit-0.7.2/src/crapkit/resources.py +141 -0
  30. crapkit-0.7.2/src/crapkit/retention.py +186 -0
  31. {crapkit-0.7.0 → crapkit-0.7.2/src/crapkit.egg-info}/PKG-INFO +13 -7
  32. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/SOURCES.txt +7 -0
  33. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/requires.txt +1 -1
  34. crapkit-0.7.0/src/crapkit/merge.py +0 -19
  35. {crapkit-0.7.0 → crapkit-0.7.2}/LICENSE +0 -0
  36. {crapkit-0.7.0 → crapkit-0.7.2}/setup.cfg +0 -0
  37. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/__main__.py +0 -0
  38. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/_pygdefer.py +0 -0
  39. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/_windows_job.py +0 -0
  40. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/churn.py +0 -0
  41. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/churn_cache.py +0 -0
  42. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/churn_log.py +0 -0
  43. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/__init__.py +0 -0
  44. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/_shared.py +0 -0
  45. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/claude_hook.py +0 -0
  46. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/queue.py +0 -0
  47. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/ratchet_cmds.py +0 -0
  48. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/cli/reports.py +0 -0
  49. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/coupling.py +0 -0
  50. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/coupling_cache.py +0 -0
  51. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/coverage_py.py +0 -0
  52. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/diffparse.py +0 -0
  53. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/digest.py +0 -0
  54. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/doctor.py +0 -0
  55. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/dup.py +0 -0
  56. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/errors.py +0 -0
  57. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/gitpaths.py +0 -0
  58. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/invocation.py +0 -0
  59. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/junitparse.py +0 -0
  60. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/keys.py +0 -0
  61. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardcognitive.py +0 -0
  62. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardpowershell.py +0 -0
  63. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardrust.py +0 -0
  64. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardshell.py +0 -0
  65. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/lizardtypescript.py +0 -0
  66. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/locks.py +0 -0
  67. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/mutate.py +0 -0
  68. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/override.py +0 -0
  69. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/packet.py +0 -0
  70. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/ratchet.py +0 -0
  71. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/ratchet_report.py +0 -0
  72. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/ratchetfile.py +0 -0
  73. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/records.py +0 -0
  74. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/report.py +0 -0
  75. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/repotext.py +0 -0
  76. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/rootfind.py +0 -0
  77. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/sarif.py +0 -0
  78. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/sarifio.py +0 -0
  79. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/scaffold.py +0 -0
  80. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/score.py +0 -0
  81. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/snapshot.py +0 -0
  82. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/store.py +0 -0
  83. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/uncovered.py +0 -0
  84. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/universe.py +0 -0
  85. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/verify.py +0 -0
  86. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/watch.py +0 -0
  87. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit/worklist.py +0 -0
  88. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/dependency_links.txt +0 -0
  89. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/entry_points.txt +0 -0
  90. {crapkit-0.7.0 → crapkit-0.7.2}/src/crapkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crapkit
3
- Version: 0.7.0
3
+ Version: 0.7.2
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
@@ -30,7 +30,7 @@ Requires-Dist: lizard>=1.24.0
30
30
  Provides-Extra: dev
31
31
  Requires-Dist: pytest>=8; extra == "dev"
32
32
  Requires-Dist: pytest-cov>=5; extra == "dev"
33
- Requires-Dist: pytest-xdist>=3; extra == "dev"
33
+ Requires-Dist: pytest-xdist>=3.7; extra == "dev"
34
34
  Requires-Dist: coverage>=7.10.6; extra == "dev"
35
35
  Requires-Dist: PyYAML>=6; extra == "dev"
36
36
  Requires-Dist: Pillow; extra == "dev"
@@ -199,7 +199,7 @@ files. Review those commands before running Crapkit in a repository you do not t
199
199
 
200
200
  ```
201
201
  $ crapkit --version
202
- crapkit 0.7.0
202
+ crapkit 0.7.2
203
203
  ```
204
204
 
205
205
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -455,7 +455,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
455
455
  repos:
456
456
  - repo: https://github.com/JeanFrancoisGagne/crapkit
457
457
  # crapkit's release step rewrites this line to the tag it just cut
458
- rev: v0.7.0
458
+ rev: v0.7.2
459
459
  hooks:
460
460
  - id: crapkit-gate
461
461
  ```
@@ -563,7 +563,7 @@ to a workflow, and every input has a default:
563
563
  - uses: actions/checkout@v4
564
564
  with:
565
565
  fetch-depth: 0
566
- - uses: JeanFrancoisGagne/crapkit@v0.7.0
566
+ - uses: JeanFrancoisGagne/crapkit@v0.7.2
567
567
  ```
568
568
 
569
569
  The whole job those four lines sit in:
@@ -583,7 +583,7 @@ jobs:
583
583
  with:
584
584
  python-version: "3.12" # the interpreter the install below lands in
585
585
  - run: pip install -e ".[dev]" # whatever your lanes need to run
586
- - uses: JeanFrancoisGagne/crapkit@v0.7.0
586
+ - uses: JeanFrancoisGagne/crapkit@v0.7.2
587
587
  with:
588
588
  gate: "false"
589
589
  ```
@@ -754,6 +754,10 @@ on PATH fails that step. Both leave the rendered text in the job log.
754
754
 
755
755
  ## Subcommands
756
756
 
757
+ `crapkit clean --dry-run --json` previews policy-based cleanup. See
758
+ [resource policies](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md)
759
+ for shared analysis workers, process lifetime, bounded logs and retained evidence.
760
+
757
761
  Every subcommand takes `--repo PATH`, and the flag goes **after** the subcommand. Without
758
762
  it the root is the nearest `crapkit.toml` at or above the current directory
759
763
  ([ADR 0002](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adr/0002-configuration-is-found-upward-nearest-wins.md)): from a
@@ -780,6 +784,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
780
784
 
781
785
  | Command | What it does |
782
786
  |---|---|
787
+ | `clean [--dry-run] [--json]` | Applies configured retention to recognized idle default test evidence and recovers abandoned temporary mutation worktrees. Preserves active runs, caller-managed output and intentional mutation pools. `--dry-run` reports planned removals. |
783
788
  | `init` | Sniffs tracked source into per-directory scopes, writes a self-validated starter `crapkit.toml` whose lanes report into `.crapkit/cov/`, and appends `.crapkit/` plus each runner's own droppings to `.gitignore`. Writes a live `[[lane]]` when it can detect the test runner, otherwise a commented template. Refuses to clobber an existing config. |
784
789
  | `doctor [--show-files] [--json] [--tune] [--plugin-root [PATH]]` | Checks the config still describes the repo: unknown keys (with the accepted spellings), zero-file scopes, tracked source no scope claims, scopes no lane covers, lane cwds and commands that no longer resolve, lizard importable, oversized files. It reads each lane command with the shell that will run it, so a quoted interpreter path is one word and a runner after `&&` is checked too, and it FAILs a lane whose runner does not resolve or that the shell cannot start, naming the word to change; a bare name is looked for on PATH and a runner spelled as a path is looked for under the directory the lane runs in, so `.venv/bin/python` answers the same from any directory you run `doctor` in; each distinct runner is probed once, not once per lane. It WARNs on a lane writing its artifact at the repo root, a `coveragepy` or `istanbul` lane with no `results_artifact` (the crashed-worker and no-new-failures checks are off for it, whichever runner the lane spells), a committed hook under `core.hooksPath` that is not executable in the index, a directory whose functions are all `untested` while its tests exist, and a scope a lane measures with no `[crapkit.scoped_tests]` template behind it, which is the loop's step 4 with nothing to run. `--tune` prints suggested parallelism knobs and writes nothing. `--plugin-root PATH` reads no repo at all: it checks an installed [plugin](https://github.com/JeanFrancoisGagne/crapkit/tree/main/plugin) against the `crapkit` on PATH (the bare name its hooks and MCP server spawn) on both version and hook `--protocol`, and FAILs when PATH carries no `crapkit` at all, one line per disagreement and silence when they agree; PATH is the plugin root or any directory above it, `~/.claude` included (only manifests named `crapkit` count, and the newest install wins), and with no PATH it looks in Claude Code's plugin cache. A root it found rather than one you typed is named first, as `crapkit doctor: checking PATH`. See [docs/agent-json.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#doctor---json). |
785
790
  | `inventory [--db PATH] [--export PATH] [--json]` | One lizard pass over every in-scope file into a SQLite snapshot run, cached by content hash. `--db` is the only way to point crapkit at a store outside `.crapkit/`, and only this command accepts it. |
@@ -1177,7 +1182,7 @@ exit 5:
1177
1182
 
1178
1183
  ```
1179
1184
  $ crapkit coverage
1180
- 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 --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
1185
+ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); lane log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
1181
1186
 
1182
1187
  MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
1183
1188
 
@@ -1339,6 +1344,7 @@ with no debt.
1339
1344
  | [docs/adoption.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
1340
1345
  | [docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md) | Every `crapkit.toml` key: type, default, and what it does. |
1341
1346
  | [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md) | The lane model, vitest and jest and pytest recipes, artifact reuse, flake retest, containers. |
1347
+ | [docs/resources.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md) | Worker budgets, command cleanup, log rotation, test evidence retention and safe cleanup. |
1342
1348
  | [docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md) | Seeding, pruning, the git merge driver, metric stamps, debt policy, overrides. |
1343
1349
  | [docs/upgrading.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md) | Existing installations: analysis and key versions, saved state, plugin alignment and Windows upgrades. |
1344
1350
  | [docs/portable-records.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/portable-records.md) | Lossless exports, portable baselines and ratchets, including filenames with delimiters. |
@@ -158,7 +158,7 @@ files. Review those commands before running Crapkit in a repository you do not t
158
158
 
159
159
  ```
160
160
  $ crapkit --version
161
- crapkit 0.7.0
161
+ crapkit 0.7.2
162
162
  ```
163
163
 
164
164
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -414,7 +414,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
414
414
  repos:
415
415
  - repo: https://github.com/JeanFrancoisGagne/crapkit
416
416
  # crapkit's release step rewrites this line to the tag it just cut
417
- rev: v0.7.0
417
+ rev: v0.7.2
418
418
  hooks:
419
419
  - id: crapkit-gate
420
420
  ```
@@ -522,7 +522,7 @@ to a workflow, and every input has a default:
522
522
  - uses: actions/checkout@v4
523
523
  with:
524
524
  fetch-depth: 0
525
- - uses: JeanFrancoisGagne/crapkit@v0.7.0
525
+ - uses: JeanFrancoisGagne/crapkit@v0.7.2
526
526
  ```
527
527
 
528
528
  The whole job those four lines sit in:
@@ -542,7 +542,7 @@ jobs:
542
542
  with:
543
543
  python-version: "3.12" # the interpreter the install below lands in
544
544
  - run: pip install -e ".[dev]" # whatever your lanes need to run
545
- - uses: JeanFrancoisGagne/crapkit@v0.7.0
545
+ - uses: JeanFrancoisGagne/crapkit@v0.7.2
546
546
  with:
547
547
  gate: "false"
548
548
  ```
@@ -713,6 +713,10 @@ on PATH fails that step. Both leave the rendered text in the job log.
713
713
 
714
714
  ## Subcommands
715
715
 
716
+ `crapkit clean --dry-run --json` previews policy-based cleanup. See
717
+ [resource policies](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md)
718
+ for shared analysis workers, process lifetime, bounded logs and retained evidence.
719
+
716
720
  Every subcommand takes `--repo PATH`, and the flag goes **after** the subcommand. Without
717
721
  it the root is the nearest `crapkit.toml` at or above the current directory
718
722
  ([ADR 0002](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adr/0002-configuration-is-found-upward-nearest-wins.md)): from a
@@ -739,6 +743,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
739
743
 
740
744
  | Command | What it does |
741
745
  |---|---|
746
+ | `clean [--dry-run] [--json]` | Applies configured retention to recognized idle default test evidence and recovers abandoned temporary mutation worktrees. Preserves active runs, caller-managed output and intentional mutation pools. `--dry-run` reports planned removals. |
742
747
  | `init` | Sniffs tracked source into per-directory scopes, writes a self-validated starter `crapkit.toml` whose lanes report into `.crapkit/cov/`, and appends `.crapkit/` plus each runner's own droppings to `.gitignore`. Writes a live `[[lane]]` when it can detect the test runner, otherwise a commented template. Refuses to clobber an existing config. |
743
748
  | `doctor [--show-files] [--json] [--tune] [--plugin-root [PATH]]` | Checks the config still describes the repo: unknown keys (with the accepted spellings), zero-file scopes, tracked source no scope claims, scopes no lane covers, lane cwds and commands that no longer resolve, lizard importable, oversized files. It reads each lane command with the shell that will run it, so a quoted interpreter path is one word and a runner after `&&` is checked too, and it FAILs a lane whose runner does not resolve or that the shell cannot start, naming the word to change; a bare name is looked for on PATH and a runner spelled as a path is looked for under the directory the lane runs in, so `.venv/bin/python` answers the same from any directory you run `doctor` in; each distinct runner is probed once, not once per lane. It WARNs on a lane writing its artifact at the repo root, a `coveragepy` or `istanbul` lane with no `results_artifact` (the crashed-worker and no-new-failures checks are off for it, whichever runner the lane spells), a committed hook under `core.hooksPath` that is not executable in the index, a directory whose functions are all `untested` while its tests exist, and a scope a lane measures with no `[crapkit.scoped_tests]` template behind it, which is the loop's step 4 with nothing to run. `--tune` prints suggested parallelism knobs and writes nothing. `--plugin-root PATH` reads no repo at all: it checks an installed [plugin](https://github.com/JeanFrancoisGagne/crapkit/tree/main/plugin) against the `crapkit` on PATH (the bare name its hooks and MCP server spawn) on both version and hook `--protocol`, and FAILs when PATH carries no `crapkit` at all, one line per disagreement and silence when they agree; PATH is the plugin root or any directory above it, `~/.claude` included (only manifests named `crapkit` count, and the newest install wins), and with no PATH it looks in Claude Code's plugin cache. A root it found rather than one you typed is named first, as `crapkit doctor: checking PATH`. See [docs/agent-json.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#doctor---json). |
744
749
  | `inventory [--db PATH] [--export PATH] [--json]` | One lizard pass over every in-scope file into a SQLite snapshot run, cached by content hash. `--db` is the only way to point crapkit at a store outside `.crapkit/`, and only this command accepts it. |
@@ -1136,7 +1141,7 @@ exit 5:
1136
1141
 
1137
1142
  ```
1138
1143
  $ crapkit coverage
1139
- 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 --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
1144
+ crapkit: lane 'js' FAILED: lane 'js' produced no artifact at .crapkit/cov/js/coverage-final.json (command exit 1); lane log: /repo/.crapkit/lane-js.log; last output: $ npm run test -- --coverage --coverage.reportsDirectory=.crapkit/cov/js --coverage.reportOnFailure --reporter=default --reporter=junit --outputFile=.crapkit/cov/js/junit.xml
1140
1145
 
1141
1146
  MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'
1142
1147
 
@@ -1298,6 +1303,7 @@ with no debt.
1298
1303
  | [docs/adoption.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
1299
1304
  | [docs/configuration.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/configuration.md) | Every `crapkit.toml` key: type, default, and what it does. |
1300
1305
  | [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md) | The lane model, vitest and jest and pytest recipes, artifact reuse, flake retest, containers. |
1306
+ | [docs/resources.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/resources.md) | Worker budgets, command cleanup, log rotation, test evidence retention and safe cleanup. |
1301
1307
  | [docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md) | Seeding, pruning, the git merge driver, metric stamps, debt policy, overrides. |
1302
1308
  | [docs/upgrading.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/upgrading.md) | Existing installations: analysis and key versions, saved state, plugin alignment and Windows upgrades. |
1303
1309
  | [docs/portable-records.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/portable-records.md) | Lossless exports, portable baselines and ratchets, including filenames with delimiters. |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crapkit"
7
- version = "0.7.0"
7
+ version = "0.7.2"
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,7 +63,8 @@ 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", "coverage>=7.10.6", "PyYAML>=6", "Pillow"]
66
+ # xdist 3.7 supports Python 3.13 and atomic reassignment for work stealing.
67
+ dev = ["pytest>=8", "pytest-cov>=5", "pytest-xdist>=3.7", "coverage>=7.10.6", "PyYAML>=6", "Pillow"]
67
68
  # coverage>=7.10.6 in both extras is the floor `[tool.coverage.run] patch` needs,
68
69
  # and it is not decoration: coverage 7.9 answers an unrecognized `[run] patch=`
69
70
  # with a CoverageWarning that pytest-cov 6.2.0 and later file under
@@ -1,2 +1,2 @@
1
1
  """crapkit: deterministic CRAP-score framework."""
2
- __version__ = "0.7.0"
2
+ __version__ = "0.7.2"
@@ -0,0 +1,190 @@
1
+ """Keep shared worker slots until every gated analysis worker has stopped."""
2
+ from __future__ import annotations
3
+
4
+ from concurrent.futures import ProcessPoolExecutor
5
+ from concurrent.futures.process import BrokenProcessPool
6
+ from contextlib import ExitStack, contextmanager
7
+ import multiprocessing
8
+ from multiprocessing.connection import wait
9
+ import os
10
+ from pathlib import Path
11
+ import threading
12
+
13
+ from .errors import ToolError
14
+ from .locks import exclusive_lock
15
+ from .procs import own_processes
16
+ from .resources import available_slots, resource_status, worker_lock
17
+
18
+
19
+ def _lock_worker_descriptor(descriptor):
20
+ if os.name == "nt":
21
+ import msvcrt
22
+ msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
23
+ else:
24
+ import fcntl
25
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
26
+
27
+
28
+ def _claim_worker_slot(path):
29
+ descriptor = os.open(worker_lock(path), os.O_CREAT | os.O_RDWR, 0o600)
30
+ try:
31
+ _lock_worker_descriptor(descriptor)
32
+ except OSError:
33
+ os.close(descriptor)
34
+ return False
35
+ # No Python file object or finalizer may release this before process exit.
36
+ return True
37
+
38
+
39
+ def _retain_worker_slot(paths):
40
+ for path in paths:
41
+ if _claim_worker_slot(path):
42
+ return
43
+ raise ToolError("no analysis worker slot remains available")
44
+
45
+
46
+ def _stop_with_parent(parent):
47
+ wait([parent.sentinel])
48
+ os._exit(1)
49
+
50
+
51
+ def _worker_gate(registrations, paths) -> None:
52
+ if os.name != "nt":
53
+ os.setsid()
54
+ parent = multiprocessing.parent_process()
55
+ directory, names = paths
56
+ _retain_worker_slot(Path(directory) / name for name in names)
57
+ receive, send = multiprocessing.Pipe(duplex=False)
58
+ registrations.put((os.getpid(), send))
59
+ try:
60
+ ready = wait([receive, parent.sentinel])
61
+ if parent.sentinel in ready or receive.recv() != "go":
62
+ os._exit(1)
63
+ finally:
64
+ receive.close()
65
+ send.close()
66
+ threading.Thread(target=_stop_with_parent, args=(parent,), daemon=True).start()
67
+
68
+
69
+ def _release_worker(owner, pid: int, gate, errors: list) -> None:
70
+ try:
71
+ owner.register_then(pid, lambda: gate.send("go"))
72
+ except Exception as error:
73
+ errors.append(str(error))
74
+ _refuse_gate(gate)
75
+ finally:
76
+ gate.close()
77
+
78
+
79
+ def _refuse_gate(gate):
80
+ try:
81
+ gate.send("refused")
82
+ except OSError:
83
+ pass # The worker can already have exited after its caller died.
84
+
85
+
86
+ def _register_workers(owner, registrations, errors: list) -> None:
87
+ for entry in iter(registrations.get, None):
88
+ _release_worker(owner, *entry, errors)
89
+
90
+
91
+ class _OwnedPool:
92
+ def __init__(self, owner, paths):
93
+ context = multiprocessing.get_context()
94
+ self.start_before_submit = context.get_start_method() != "fork"
95
+ self.registrations = context.Queue()
96
+ # Windows spawn writes this descriptor through a bounded bootstrap pipe.
97
+ packed = (str(Path(paths[0]).parent), tuple(Path(path).name for path in paths))
98
+ self.executor = ProcessPoolExecutor(max_workers=len(paths), mp_context=context,
99
+ initializer=_worker_gate,
100
+ initargs=(self.registrations, packed))
101
+ self.errors = []
102
+ self.registrar = threading.Thread(target=_register_workers,
103
+ args=(owner, self.registrations, self.errors), daemon=True)
104
+ self.started = False
105
+
106
+ def map(self, function, jobs, *, chunksize=1):
107
+ # Submit starts all fork workers before adding our registration thread.
108
+ if self.start_before_submit:
109
+ self._start_registrar()
110
+ try:
111
+ results = self.executor.map(function, jobs, chunksize=chunksize)
112
+ finally:
113
+ self._start_registrar()
114
+ try:
115
+ yield from results
116
+ except BrokenProcessPool as error:
117
+ if self.errors:
118
+ raise ToolError(self.errors[0]) from error
119
+ raise
120
+
121
+ def _start_registrar(self):
122
+ if not self.started:
123
+ self.registrar.start()
124
+ self.started = True
125
+
126
+ def close(self):
127
+ self.executor.shutdown(wait=True, cancel_futures=True)
128
+ if self.started:
129
+ self.registrations.put(None)
130
+ self.registrar.join()
131
+ self.registrations.close()
132
+ self.registrations.join_thread()
133
+
134
+
135
+ @contextmanager
136
+ def _pool_owner(paths):
137
+ with ExitStack() as stack:
138
+ owned = paths
139
+ if os.name == "nt":
140
+ if not _claim_primary_slots(stack, paths):
141
+ yield None
142
+ return
143
+ owned = ()
144
+ owner = stack.enter_context(own_processes(owned, optional=True, label="analysis workers"))
145
+ yield owner if owner.held else None
146
+
147
+
148
+ def _claim_primary_slots(stack, paths):
149
+ try:
150
+ for path in paths:
151
+ stack.enter_context(exclusive_lock(path, label="analysis workers"))
152
+ except (OSError, ToolError):
153
+ return False
154
+ return True
155
+
156
+
157
+ @contextmanager
158
+ def _held_pool(paths):
159
+ with _pool_owner(paths) as owner:
160
+ if owner is None:
161
+ yield None
162
+ return
163
+ pool = _OwnedPool(owner, paths)
164
+ try:
165
+ yield pool
166
+ except BaseException:
167
+ owner.cancel()
168
+ raise
169
+ finally:
170
+ pool.close()
171
+
172
+
173
+ @contextmanager
174
+ def analysis_pool(*, workers: int | None = None, worker_budget: int = 0):
175
+ """Yield an owned pool, or None for cheap serial work or busy shared slots.
176
+
177
+ Probing does not reserve capacity. The caller on Windows, or a guardian on
178
+ POSIX, claims primary slots before workers start. Actual workers retain
179
+ companion slots through process exit. A competing claim falls back.
180
+ """
181
+ status = resource_status(analysis_workers=workers or 0, worker_budget=worker_budget)
182
+ if status["pool_worker_limit"] == 1:
183
+ yield None
184
+ return
185
+ paths = available_slots(status)
186
+ if len(paths) < 2:
187
+ yield None
188
+ return
189
+ with _held_pool(paths) as pool:
190
+ yield pool
@@ -0,0 +1,208 @@
1
+ """A bounded stdio session with one owned tool request at a time."""
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from contextlib import ExitStack
4
+ import json
5
+ import os
6
+ import queue
7
+ import sys
8
+ import threading
9
+
10
+ from .procs import CommandCancelled, own_processes
11
+
12
+
13
+ class _OutputClosed(Exception):
14
+ """The client stopped accepting protocol output."""
15
+
16
+
17
+ def _lines(source):
18
+ try:
19
+ descriptor = source.fileno()
20
+ except (AttributeError, OSError):
21
+ yield from iter(source.readline, '')
22
+ return
23
+ pending = b''
24
+ while chunk := os.read(descriptor, 65536):
25
+ pieces = (pending + chunk).split(b'\n')
26
+ pending = pieces.pop()
27
+ yield from (piece.decode('utf-8') for piece in pieces)
28
+ if pending:
29
+ yield pending.decode('utf-8')
30
+
31
+
32
+ def _read(source, events, slots):
33
+ try:
34
+ for line in _lines(source):
35
+ slots.acquire()
36
+ events.put(('input', line))
37
+ except (OSError, ValueError):
38
+ pass
39
+ finally:
40
+ events.put(('eof', None))
41
+
42
+
43
+ class Session:
44
+ """Keep protocol input responsive while a single CLI child is running."""
45
+
46
+ def __init__(self, source, target, reply, run_cli):
47
+ self.source, self.target = source, target
48
+ self.reply, self.run_cli = reply, run_cli
49
+ self.events = queue.SimpleQueue()
50
+ self.input_slots = threading.Semaphore(32)
51
+ self.stack = ExitStack()
52
+ self.owner_context = None
53
+ self.lock = threading.Lock()
54
+ self.owner = self.pool = self.active = None
55
+ self.request_id = None
56
+
57
+ def run(self):
58
+ threading.Thread(target=_read, args=(self.source, self.events, self.input_slots),
59
+ daemon=True).start()
60
+ try:
61
+ while self._event(*self.events.get()):
62
+ pass
63
+ finally:
64
+ self._cleanup()
65
+ return 0
66
+
67
+ def _cleanup(self):
68
+ failure = sys.exception()
69
+ for cleanup in (self._cancel_for_cleanup, self.stack.close, self._retire_owner):
70
+ try:
71
+ cleanup()
72
+ except BaseException as error:
73
+ failure = failure or error
74
+ if failure is not None:
75
+ raise failure
76
+
77
+ def _cancel_for_cleanup(self):
78
+ try:
79
+ self._cancel()
80
+ except BaseException as error:
81
+ try:
82
+ self._retire_owner()
83
+ finally:
84
+ raise error
85
+
86
+ def _event(self, kind, value):
87
+ if kind == 'eof':
88
+ return False
89
+ if kind == 'done':
90
+ self._done(value)
91
+ else:
92
+ self.input_slots.release()
93
+ self._input(value)
94
+ return True
95
+
96
+ def _input(self, line):
97
+ try:
98
+ message = json.loads(line)
99
+ except ValueError:
100
+ return
101
+ if not isinstance(message, dict):
102
+ return
103
+ if message.get('method') == 'notifications/cancelled' and 'id' not in message:
104
+ self._notification(message)
105
+ return
106
+ self.request_id = message.get('id')
107
+ response = self.reply(message, self._submit)
108
+ if self._immediate(response):
109
+ self._write(response)
110
+
111
+ def _immediate(self, response):
112
+ if response is None:
113
+ return False
114
+ return self.active is None or response.get('result') is not self.active
115
+
116
+ def _notification(self, message):
117
+ params = message.get('params') or {}
118
+ if not isinstance(params, dict):
119
+ return
120
+ if self._matches(params.get('requestId')):
121
+ self._cancel()
122
+
123
+ def _matches(self, request_id):
124
+ if self.active is None:
125
+ return False
126
+ current = self.active['id']
127
+ return type(request_id) is type(current) and request_id == current
128
+
129
+ def _submit(self, tool, arguments, repo):
130
+ if self.active is not None:
131
+ return {'content': [{'type': 'text', 'text': 'another tool is running; retry after it finishes'}],
132
+ 'isError': True}
133
+ if self.pool is None:
134
+ self.pool = self.stack.enter_context(ThreadPoolExecutor(max_workers=1))
135
+ request = {'id': self.request_id, 'cancelled': False}
136
+ self.active = request
137
+ future = self.pool.submit(self._execute, request, tool, arguments, repo)
138
+ request['future'] = future
139
+ future.add_done_callback(lambda done: self.events.put(('done', request)))
140
+ return request
141
+
142
+ def _execute(self, request, tool, arguments, repo):
143
+ with self.lock:
144
+ if request['cancelled']:
145
+ raise CommandCancelled('request was cancelled before it started')
146
+ if self.owner is None:
147
+ context = own_processes(())
148
+ self.owner = context.__enter__()
149
+ self.owner_context = context
150
+ if request['cancelled']:
151
+ raise CommandCancelled('request was cancelled during owner startup')
152
+ owner = self.owner
153
+ return self.run_cli(tool, arguments, repo, owner=owner)
154
+
155
+ def _cancel(self):
156
+ request = self.active
157
+ if request is None:
158
+ return
159
+ request['cancelled'] = True
160
+ with self.lock:
161
+ if self.owner is not None:
162
+ self.owner.cancel()
163
+
164
+ def _done(self, request):
165
+ self.active = None
166
+ if request['cancelled']:
167
+ self._retire_owner()
168
+ return
169
+ self._write(self._response(request))
170
+
171
+ def _retire_owner(self):
172
+ if self.owner_context is not None:
173
+ self.owner_context.__exit__(None, None, None)
174
+ self.owner_context = None
175
+ self.owner = None
176
+
177
+ def _response(self, request):
178
+ response = {'jsonrpc': '2.0', 'id': request['id']}
179
+ try:
180
+ response['result'] = request['future'].result()
181
+ except Exception as error:
182
+ response['error'] = {'code': -32603, 'message': f'{type(error).__name__}: {error}'}
183
+ return response
184
+
185
+ def _write(self, response):
186
+ try:
187
+ self.target.write(json.dumps(response) + '\n')
188
+ self.target.flush()
189
+ except OSError as error:
190
+ _silence_output(self.target)
191
+ raise _OutputClosed from error
192
+
193
+
194
+ def _silence_output(target):
195
+ # Prevent the interpreter's final stdout flush from repeating a broken pipe.
196
+ try:
197
+ with open(os.devnull, 'w') as sink:
198
+ os.dup2(sink.fileno(), target.fileno())
199
+ except (AttributeError, OSError):
200
+ pass
201
+
202
+
203
+ def serve(source, target, reply, run_cli):
204
+ try:
205
+ return Session(source, target, reply, run_cli).run()
206
+ except _OutputClosed:
207
+ # A client can close its read pipe independently of stdin.
208
+ return 0
@@ -0,0 +1,83 @@
1
+ """POSIX nested guardians retain ancestor completion until their writers stop.
2
+
3
+ Command groups cannot kill a nested guardian before it finishes cleanup. Each
4
+ guardian runs in a separate session and holds a lease in every ancestor family.
5
+ Closing admission before killing a group makes late guardian starts refuse.
6
+ """
7
+ from contextlib import ExitStack, contextmanager
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import shutil
12
+ import tempfile
13
+ import uuid
14
+
15
+ from .errors import ToolError
16
+
17
+ _ENV = 'CRAPKIT_COMMAND_FAMILIES'
18
+
19
+
20
+ def command_environment(environment):
21
+ """Name a family before spawn; only successful registration creates it."""
22
+ family = str(Path(tempfile.gettempdir()) / ('crapkit-family-' + uuid.uuid4().hex))
23
+ parents = json.loads(os.environ.get(_ENV, '[]'))
24
+ return family, {**(os.environ if environment is None else environment),
25
+ _ENV: json.dumps([*parents, family])}
26
+
27
+
28
+ @contextmanager
29
+ def _locked(path, mode='r+b'):
30
+ import fcntl
31
+ with path.open(mode) as stream:
32
+ fcntl.flock(stream, fcntl.LOCK_EX)
33
+ yield stream
34
+
35
+
36
+ def _register(path, stack):
37
+ with _locked(path / 'admission'):
38
+ if (path / 'closed').exists():
39
+ raise ToolError('ancestor command has stopped; no new work can start')
40
+ lease = path / (uuid.uuid4().hex + '.lease')
41
+ stack.enter_context(_locked(lease, 'x+b'))
42
+
43
+
44
+ @contextmanager
45
+ def ancestor_leases():
46
+ """Keep all ancestor commands incomplete until this guardian exits."""
47
+ with ExitStack() as stack:
48
+ try:
49
+ for path in json.loads(os.environ.get(_ENV, '[]')):
50
+ _register(Path(path), stack)
51
+ except OSError as error:
52
+ raise ToolError('ancestor command has stopped; cannot retain ownership') from error
53
+ yield
54
+
55
+
56
+ class Family:
57
+ def __init__(self, path):
58
+ self.path = Path(path)
59
+ self.path.mkdir(mode=0o700)
60
+ try:
61
+ (self.path / 'admission').touch()
62
+ except OSError:
63
+ self.path.rmdir()
64
+ raise
65
+
66
+ def close_admission(self):
67
+ with _locked(self.path / 'admission'):
68
+ (self.path / 'closed').touch()
69
+ return list(self.path.glob('*.lease'))
70
+
71
+ def finish(self, leases):
72
+ for lease in leases:
73
+ with _locked(lease):
74
+ pass
75
+ shutil.rmtree(self.path)
76
+
77
+ @contextmanager
78
+ def stopping(self):
79
+ leases = self.close_admission()
80
+ try:
81
+ yield
82
+ finally:
83
+ self.finish(leases)