crapkit 0.4.2__tar.gz → 0.4.3__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 (70) hide show
  1. {crapkit-0.4.2/src/crapkit.egg-info → crapkit-0.4.3}/PKG-INFO +25 -7
  2. {crapkit-0.4.2 → crapkit-0.4.3}/README.md +24 -6
  3. {crapkit-0.4.2 → crapkit-0.4.3}/pyproject.toml +1 -1
  4. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/analyze.py +20 -16
  6. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/__init__.py +4 -0
  7. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/claude_hook.py +14 -4
  8. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/parser.py +2 -0
  9. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/queue.py +45 -13
  10. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/ratchet_cmds.py +37 -8
  11. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/reports.py +16 -3
  12. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/scoring.py +37 -5
  13. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/verifying.py +70 -9
  14. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/config.py +85 -10
  15. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/doctor.py +1 -1
  16. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/dup.py +16 -3
  17. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/hook.py +19 -4
  18. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/junitparse.py +53 -3
  19. crapkit-0.4.3/src/crapkit/keys.py +86 -0
  20. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/lanes.py +30 -0
  21. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/override.py +5 -3
  22. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/ratchet.py +76 -18
  23. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/store.py +25 -1
  24. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/verify.py +19 -6
  25. {crapkit-0.4.2 → crapkit-0.4.3/src/crapkit.egg-info}/PKG-INFO +25 -7
  26. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit.egg-info/SOURCES.txt +1 -0
  27. {crapkit-0.4.2 → crapkit-0.4.3}/LICENSE +0 -0
  28. {crapkit-0.4.2 → crapkit-0.4.3}/setup.cfg +0 -0
  29. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/__main__.py +0 -0
  30. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/_pygdefer.py +0 -0
  31. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cache.py +0 -0
  32. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/churn.py +0 -0
  33. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/churn_cache.py +0 -0
  34. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/churn_log.py +0 -0
  35. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/_shared.py +0 -0
  36. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/admin.py +0 -0
  37. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/cli/analyses.py +0 -0
  38. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/coupling.py +0 -0
  39. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/coverage_istanbul.py +0 -0
  40. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/coverage_py.py +0 -0
  41. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/covstream.py +0 -0
  42. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/diffparse.py +0 -0
  43. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/digest.py +0 -0
  44. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/discover.py +0 -0
  45. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/errors.py +0 -0
  46. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/gitio.py +0 -0
  47. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/lizardcognitive.py +0 -0
  48. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/lizardpowershell.py +0 -0
  49. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/lizardrust.py +0 -0
  50. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/lizardshell.py +0 -0
  51. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/mcp_server.py +0 -0
  52. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/merge.py +0 -0
  53. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/mutate.py +0 -0
  54. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/mutate_pool.py +0 -0
  55. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/packet.py +0 -0
  56. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/ratchet_report.py +0 -0
  57. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/report.py +0 -0
  58. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/sarif.py +0 -0
  59. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/sarifio.py +0 -0
  60. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/scaffold.py +0 -0
  61. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/score.py +0 -0
  62. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/snapshot.py +0 -0
  63. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/uncovered.py +0 -0
  64. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/universe.py +0 -0
  65. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/watch.py +0 -0
  66. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit/worklist.py +0 -0
  67. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit.egg-info/dependency_links.txt +0 -0
  68. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit.egg-info/entry_points.txt +0 -0
  69. {crapkit-0.4.2 → crapkit-0.4.3}/src/crapkit.egg-info/requires.txt +0 -0
  70. {crapkit-0.4.2 → crapkit-0.4.3}/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.2
3
+ Version: 0.4.3
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
@@ -116,7 +116,7 @@ changing crapkit.
116
116
 
117
117
  ```
118
118
  $ crapkit --version
119
- crapkit 0.4.2
119
+ crapkit 0.4.3
120
120
  ```
121
121
 
122
122
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -124,6 +124,22 @@ source checkout. Every subcommand accepts `--repo PATH` (default: the current di
124
124
  so you never have to `cd` into the repo you are scoring. The flag goes after the
125
125
  subcommand; [Subcommands](#subcommands) shows both orders.
126
126
 
127
+ ### Upgrading on Windows
128
+
129
+ `uv tool upgrade crapkit`, and `pip install -U` into a tool venv, fail with `os error 32`
130
+ ("The process cannot access the file because it is being used by another process") while a
131
+ crapkit MCP server is live: an agent session spawns `crapkit.exe mcp`, which holds the
132
+ launcher, and Windows will not overwrite a running executable. The venv upgrades before
133
+ that copy fails, so `crapkit --version` already reports the new version and the launcher is
134
+ the only stale piece. Quit the agent session and rerun the upgrade, or rename the locked
135
+ exe aside (Windows allows renaming a running one) and copy the new one in; the `.old` file
136
+ goes at the next reboot.
137
+
138
+ ```
139
+ mv ~/.local/bin/crapkit.exe ~/.local/bin/crapkit.exe.old
140
+ cp %APPDATA%/uv/tools/crapkit/Scripts/crapkit.exe ~/.local/bin/crapkit.exe
141
+ ```
142
+
127
143
  ## The Claude Code plugin
128
144
 
129
145
  ```
@@ -247,7 +263,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
247
263
  repos:
248
264
  - repo: https://github.com/JeanFrancoisGagne/crapkit
249
265
  # crapkit's release step rewrites this line to the tag it just cut
250
- rev: v0.4.2
266
+ rev: v0.4.3
251
267
  hooks:
252
268
  - id: crapkit-gate
253
269
  ```
@@ -321,11 +337,11 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
321
337
  | `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 WARNs on a lane writing its artifact at the repo root, 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](plugin/) against this CLI on both version and hook `--protocol`, one line per disagreement and silence when they agree. See [docs/agent-json.md](docs/agent-json.md#doctor---json). |
322
338
  | `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. |
323
339
  | `coverage [--lane NAME] [--reuse-artifacts] [--reuse-unchanged] [--export PATH] [--sarif PATH] [--github] [--json]` | Runs the lanes, joins branch coverage onto a fresh inventory, writes a scored run. A failed lane is recorded, not fatal: its scopes fall back to `no-lane` and the run is typed `partial`, so it can never serve as a baseline. See [docs/lanes.md](docs/lanes.md). |
324
- | `verify [--baseline ID \| --base REF \| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--sarif PATH] [--github] [--json]` | The full verdict against the trusted baseline: gate on touched functions, ratchet, no new test failures, optional diff-coverage ceiling. The three baseline selectors are mutually exclusive; `--baseline ID` also bypasses the taint rule ([The trusted baseline](#the-trusted-baseline)), and `--baseline-tsv` reads a commit-stamped file so a fresh clone verifies with no store. Findings a dirty tree produced are tagged `dirty` and counted apart. |
340
+ | `verify [--baseline ID \| --base REF \| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--no-tighten] [--sarif PATH] [--github] [--json]` | The full verdict against the trusted baseline: gate on touched functions, ratchet, no new test failures, optional diff-coverage ceiling. The three baseline selectors are mutually exclusive; `--baseline ID` also bypasses the taint rule ([The trusted baseline](#the-trusted-baseline)), and `--baseline-tsv` reads a commit-stamped file so a fresh clone verifies with no store. `--no-tighten` passes the verdict without rewriting the ratchet. Findings a dirty tree produced are tagged `dirty` and counted apart. |
325
341
  | `worklist [--top N] [--scope NAME] [--batches N] [--json]` | The risk map: every admitted function ranked by `ccn * churn weight`, floored by `worklist_floor`, with hot simple code and anything over its ceiling admitted past that floor. It ranks finished rows and `no-lane` rows too, marked `ok` and `no-lane`, so it never empties; `next-item` carries the stop condition. `--scope NAME` (repeatable) is exact, not a substring. `--batches N` **adds** a `batches[]` view cutting the active list into at most N file-disjoint batches with co-changing files kept together; the normal keys stay. |
326
342
  | `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG; `--scope NAME` (repeatable) is exact, not a substring. `--claim` holds what it hands out so a second session skips it. `stale` is true when the ranked run's commit is not HEAD, the same field `worklist` carries. Every item carries a `handle`: the bare identifier, or `(anonymous)#N` for a function with no name, which is the name form that survives the edit the item asks for. |
327
343
  | `claims [list \| release PATH NAME \| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. |
328
- | `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, or `(anonymous)#N` for a function printed `(anonymous)`, counting the file's anonymous functions from the top. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
344
+ | `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, `(anonymous)#N` for a function printed `(anonymous)` counting the file's anonymous functions from the top, or `NAME#2` for the second of several functions a file gives one name to. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
329
345
  | `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
330
346
  | `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions a ratchet mark already covers, and exits 6. |
331
347
  | `ratchet seed \| prune \| merge \| move \| report [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. See [docs/ratchet.md](docs/ratchet.md). |
@@ -334,7 +350,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
334
350
  | `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
335
351
  | `digest [--alert]` | The delta between the two newest runs with identical lane sets. Silent when nothing changed. `--alert` pipes the body to `alert_command` on stdin. Plain lines, never JSON. |
336
352
  | `report [--out PATH]` | One self-contained HTML page written to `.crapkit/report.html` (or `--out PATH`, repo-relative), with the path printed on stdout. It renders what `worklist --json` and `trend --json` already answer at their defaults: the ranked worklist capped at `worklist_top`, the per-scope grades off the newest run, the trend series, and a banner naming every stale lane. It measures nothing and opens no network connection. Per-function CRAP and coverage are absent because no repo-wide payload carries them; each row prints the `crapkit explain` call that does. |
337
- | `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. |
353
+ | `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. A function and a function nested inside it never pair: their spans nest, they score 1.0 by construction, and nobody can deduplicate a factory from its own closure. |
338
354
  | `coupling [--min-support N] [--min-confidence F] [--top N] [--json]` | File pairs that keep landing in the same commits. Defaults: `--min-support 5` shared commits, `--min-confidence 0.5` max-direction ratio, `--top 50`. Bulk commits never couple pairs, and a young repo returns nothing at the default support. |
339
355
  | `mutate [--files F ...] [--max-mutants N] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. Shell and PowerShell files are refused by name on stderr rather than mutated: `<` and `>` are redirections there, not comparisons. |
340
356
  | `test-scoped FILE ...` | Runs each owning scope's `[crapkit.scoped_tests]` template on the files (quoted, longest-prefix scope wins). A template with no `{files}` runs as written, which is how a scope whose tests live outside its own paths runs its whole suite. Exit code only; a nonzero runner exits 1. |
@@ -412,7 +428,9 @@ baseline**. `crapkit runs list` marks which one that is today.
412
428
  never qualifies, and neither does a `partial` run (a lane failed, so some scope fell back
413
429
  to `no-lane`) nor a `hook` override record, which carries no scored rows at all. In `runs
414
430
  list`, `verdict=-` marks a run that produces no verdict rather than one that failed: only
415
- `verify` renders a verdict.
431
+ `verify` renders a verdict. Three readers ask this one question and get this one answer:
432
+ the baseline pick here, `ratchet seed` and `prune`, and the tighten damping that compares a
433
+ mark against the same commit's previous run.
416
434
 
417
435
  **What advances it.** Any qualifying run. `coverage` writes one wherever HEAD is, so a
418
436
  dashboard cron advances the baseline exactly as CI does. A passing `verify` advances it
@@ -83,7 +83,7 @@ changing crapkit.
83
83
 
84
84
  ```
85
85
  $ crapkit --version
86
- crapkit 0.4.2
86
+ crapkit 0.4.3
87
87
  ```
88
88
 
89
89
  `python -m crapkit` works identically to the console script and is what to use from a
@@ -91,6 +91,22 @@ source checkout. Every subcommand accepts `--repo PATH` (default: the current di
91
91
  so you never have to `cd` into the repo you are scoring. The flag goes after the
92
92
  subcommand; [Subcommands](#subcommands) shows both orders.
93
93
 
94
+ ### Upgrading on Windows
95
+
96
+ `uv tool upgrade crapkit`, and `pip install -U` into a tool venv, fail with `os error 32`
97
+ ("The process cannot access the file because it is being used by another process") while a
98
+ crapkit MCP server is live: an agent session spawns `crapkit.exe mcp`, which holds the
99
+ launcher, and Windows will not overwrite a running executable. The venv upgrades before
100
+ that copy fails, so `crapkit --version` already reports the new version and the launcher is
101
+ the only stale piece. Quit the agent session and rerun the upgrade, or rename the locked
102
+ exe aside (Windows allows renaming a running one) and copy the new one in; the `.old` file
103
+ goes at the next reboot.
104
+
105
+ ```
106
+ mv ~/.local/bin/crapkit.exe ~/.local/bin/crapkit.exe.old
107
+ cp %APPDATA%/uv/tools/crapkit/Scripts/crapkit.exe ~/.local/bin/crapkit.exe
108
+ ```
109
+
94
110
  ## The Claude Code plugin
95
111
 
96
112
  ```
@@ -214,7 +230,7 @@ crapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your
214
230
  repos:
215
231
  - repo: https://github.com/JeanFrancoisGagne/crapkit
216
232
  # crapkit's release step rewrites this line to the tag it just cut
217
- rev: v0.4.2
233
+ rev: v0.4.3
218
234
  hooks:
219
235
  - id: crapkit-gate
220
236
  ```
@@ -288,11 +304,11 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
288
304
  | `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 WARNs on a lane writing its artifact at the repo root, 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](plugin/) against this CLI on both version and hook `--protocol`, one line per disagreement and silence when they agree. See [docs/agent-json.md](docs/agent-json.md#doctor---json). |
289
305
  | `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. |
290
306
  | `coverage [--lane NAME] [--reuse-artifacts] [--reuse-unchanged] [--export PATH] [--sarif PATH] [--github] [--json]` | Runs the lanes, joins branch coverage onto a fresh inventory, writes a scored run. A failed lane is recorded, not fatal: its scopes fall back to `no-lane` and the run is typed `partial`, so it can never serve as a baseline. See [docs/lanes.md](docs/lanes.md). |
291
- | `verify [--baseline ID \| --base REF \| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--sarif PATH] [--github] [--json]` | The full verdict against the trusted baseline: gate on touched functions, ratchet, no new test failures, optional diff-coverage ceiling. The three baseline selectors are mutually exclusive; `--baseline ID` also bypasses the taint rule ([The trusted baseline](#the-trusted-baseline)), and `--baseline-tsv` reads a commit-stamped file so a fresh clone verifies with no store. Findings a dirty tree produced are tagged `dirty` and counted apart. |
307
+ | `verify [--baseline ID \| --base REF \| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--no-tighten] [--sarif PATH] [--github] [--json]` | The full verdict against the trusted baseline: gate on touched functions, ratchet, no new test failures, optional diff-coverage ceiling. The three baseline selectors are mutually exclusive; `--baseline ID` also bypasses the taint rule ([The trusted baseline](#the-trusted-baseline)), and `--baseline-tsv` reads a commit-stamped file so a fresh clone verifies with no store. `--no-tighten` passes the verdict without rewriting the ratchet. Findings a dirty tree produced are tagged `dirty` and counted apart. |
292
308
  | `worklist [--top N] [--scope NAME] [--batches N] [--json]` | The risk map: every admitted function ranked by `ccn * churn weight`, floored by `worklist_floor`, with hot simple code and anything over its ceiling admitted past that floor. It ranks finished rows and `no-lane` rows too, marked `ok` and `no-lane`, so it never empties; `next-item` carries the stop condition. `--scope NAME` (repeatable) is exact, not a substring. `--batches N` **adds** a `batches[]` view cutting the active list into at most N file-disjoint batches with co-changing files kept together; the normal keys stay. |
293
309
  | `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG; `--scope NAME` (repeatable) is exact, not a substring. `--claim` holds what it hands out so a second session skips it. `stale` is true when the ranked run's commit is not HEAD, the same field `worklist` carries. Every item carries a `handle`: the bare identifier, or `(anonymous)#N` for a function with no name, which is the name form that survives the edit the item asks for. |
294
310
  | `claims [list \| release PATH NAME \| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. |
295
- | `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, or `(anonymous)#N` for a function printed `(anonymous)`, counting the file's anonymous functions from the top. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
311
+ | `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, `(anonymous)#N` for a function printed `(anonymous)` counting the file's anonymous functions from the top, or `NAME#2` for the second of several functions a file gives one name to. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store. |
296
312
  | `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |
297
313
  | `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions a ratchet mark already covers, and exits 6. |
298
314
  | `ratchet seed \| prune \| merge \| move \| report [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. See [docs/ratchet.md](docs/ratchet.md). |
@@ -301,7 +317,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
301
317
  | `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
302
318
  | `digest [--alert]` | The delta between the two newest runs with identical lane sets. Silent when nothing changed. `--alert` pipes the body to `alert_command` on stdin. Plain lines, never JSON. |
303
319
  | `report [--out PATH]` | One self-contained HTML page written to `.crapkit/report.html` (or `--out PATH`, repo-relative), with the path printed on stdout. It renders what `worklist --json` and `trend --json` already answer at their defaults: the ranked worklist capped at `worklist_top`, the per-scope grades off the newest run, the trend series, and a banner naming every stale lane. It measures nothing and opens no network connection. Per-function CRAP and coverage are absent because no repo-wide payload carries them; each row prints the `crapkit explain` call that does. |
304
- | `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. |
320
+ | `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. A function and a function nested inside it never pair: their spans nest, they score 1.0 by construction, and nobody can deduplicate a factory from its own closure. |
305
321
  | `coupling [--min-support N] [--min-confidence F] [--top N] [--json]` | File pairs that keep landing in the same commits. Defaults: `--min-support 5` shared commits, `--min-confidence 0.5` max-direction ratio, `--top 50`. Bulk commits never couple pairs, and a young repo returns nothing at the default support. |
306
322
  | `mutate [--files F ...] [--max-mutants N] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. Shell and PowerShell files are refused by name on stderr rather than mutated: `<` and `>` are redirections there, not comparisons. |
307
323
  | `test-scoped FILE ...` | Runs each owning scope's `[crapkit.scoped_tests]` template on the files (quoted, longest-prefix scope wins). A template with no `{files}` runs as written, which is how a scope whose tests live outside its own paths runs its whole suite. Exit code only; a nonzero runner exits 1. |
@@ -379,7 +395,9 @@ baseline**. `crapkit runs list` marks which one that is today.
379
395
  never qualifies, and neither does a `partial` run (a lane failed, so some scope fell back
380
396
  to `no-lane`) nor a `hook` override record, which carries no scored rows at all. In `runs
381
397
  list`, `verdict=-` marks a run that produces no verdict rather than one that failed: only
382
- `verify` renders a verdict.
398
+ `verify` renders a verdict. Three readers ask this one question and get this one answer:
399
+ the baseline pick here, `ratchet seed` and `prune`, and the tighten damping that compares a
400
+ mark against the same commit's previous run.
383
401
 
384
402
  **What advances it.** Any qualifying run. `coverage` writes one wherever HEAD is, so a
385
403
  dashboard cron advances the baseline exactly as CI does. A passing `verify` advances it
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crapkit"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Scores every function on complexity times uncovered risk, ranks the worst, and blocks commits that add more."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { text = "MIT" }
@@ -1,2 +1,2 @@
1
1
  """crapkit: deterministic CRAP-score framework."""
2
- __version__ = "0.4.2"
2
+ __version__ = "0.4.3"
@@ -160,10 +160,11 @@ _NAMES_SHOWN = 5
160
160
  def _colliding_names(records: list[FunctionRecord]) -> list[str]:
161
161
  """Names this file gives to more than one function, in first-seen order.
162
162
 
163
- Anonymous functions are exempt. lizard calls every one of them
163
+ Anonymous functions are exempt from the line. lizard calls every one of them
164
164
  `(anonymous)`, so a file with two arrow callbacks collides by construction
165
- and a warning would name nothing anyone could act on; `packet.handles`
166
- answers that collision with the `(anonymous)#N` ordinal instead.
165
+ and the line would name nothing anyone could act on. They take the same
166
+ ordinal keys as any other twin; `packet.handles` already addresses them as
167
+ `(anonymous)#N`.
167
168
  """
168
169
  seen: set[str] = set()
169
170
  colliding: dict[str, None] = {}
@@ -174,23 +175,26 @@ def _colliding_names(records: list[FunctionRecord]) -> list[str]:
174
175
  return list(colliding)
175
176
 
176
177
 
177
- def _warn_on_collisions(rel_path: str, records: list[FunctionRecord]) -> None:
178
- """One stderr line for a file whose records cannot all reach the ratchet.
178
+ def _note_twin_keys(rel_path: str, records: list[FunctionRecord]) -> None:
179
+ """One stderr line for a file that gives one name to more than one function.
179
180
 
180
- A mark is keyed on (path, long_name), and so is the row a run writes, so the
181
- last function under a colliding name is the only one marked and the only one
182
- gated. C makes this ordinary: both arms of an `#ifdef` fork are textually
183
- present, so a platform shim defines the same function twice in one file.
184
- Python makes it ordinary too, because a method's long_name carries no class.
185
- Neither is fixable here — the ratchet cannot key on a span, which drifts with
186
- every edit so the loss is announced rather than silent.
181
+ Information, not a warning. Until 0.4.2 it was the second: a mark keyed on
182
+ (path, long_name) meant one twin owned the key and the rest were neither
183
+ marked nor gated, which is the loss this announced. `keys` ends that by
184
+ giving each twin its own ordinal, so the line now says what a reader will
185
+ see in `crapkit-ratchet.tsv` and nothing is lost.
186
+
187
+ Still printed, because a `#2` appearing in a committed marks file is
188
+ otherwise unexplained. C makes the shape ordinary — both arms of an `#ifdef`
189
+ fork are textually present — and so does Python, whose method long_names
190
+ carry no class.
187
191
  """
188
192
  names = _colliding_names(records)
189
193
  if not names:
190
194
  return
191
- print(f"crapkit: {rel_path} defines {_listed(names)} more than once; the ratchet keys "
192
- f"on (path, long_name) and keeps the last, so the earlier ones are neither "
193
- f"marked nor gated", file=sys.stderr)
195
+ print(f"crapkit: {rel_path} defines {_listed(names)} more than once; each one takes "
196
+ f"its own ratchet key the first as written, later ones suffixed #2, #3 in "
197
+ f"file order", file=sys.stderr)
194
198
 
195
199
 
196
200
  def _listed(names: list[str]) -> str:
@@ -201,7 +205,7 @@ def _listed(names: list[str]) -> str:
201
205
 
202
206
  def _file_records(rel_path: str, functions) -> list[FunctionRecord]:
203
207
  records = [_record(rel_path, fn) for fn in functions]
204
- _warn_on_collisions(rel_path, records)
208
+ _note_twin_keys(rel_path, records)
205
209
  return records
206
210
 
207
211
 
@@ -109,6 +109,7 @@ _OWNER = {
109
109
  "_group_files_by_scope": "verifying",
110
110
  "_guard_ratchet_stamp": "verifying",
111
111
  "_handle": "queue",
112
+ "_held_marks": "verifying",
112
113
  "_hook_handlers": "admin",
113
114
  "_hook_modes": "admin",
114
115
  "_hook_protocols": "admin",
@@ -160,6 +161,8 @@ _OWNER = {
160
161
  "_no_line_message": "queue",
161
162
  "_no_match_message": "queue",
162
163
  "_no_scopes_reason": "admin",
164
+ "_no_tighten_line": "verifying",
165
+ "_no_twin_message": "queue",
163
166
  "_note_marked_staged": "verifying",
164
167
  "_note_stale_staged": "verifying",
165
168
  "_open_store": "_shared",
@@ -176,6 +179,7 @@ _OWNER = {
176
179
  "_policy_findings": "ratchet_cmds",
177
180
  "_present_markers": "admin",
178
181
  "_present_on_disk": "scoring",
182
+ "_prior_crap": "verifying",
179
183
  "_print_batches": "queue",
180
184
  "_print_brief": "queue",
181
185
  "_print_brief_context": "queue",
@@ -154,7 +154,7 @@ def _judge(root: Path, rel: str) -> int:
154
154
  records = _records(root, rel)
155
155
  ranges = _changed(root, rel, diff.communicate()[0])
156
156
  breaches, ceiling = _verdict(cfg, in_scope, rel, records, ranges)
157
- return _report(root, cfg, rel, breaches, ceiling)
157
+ return _report(root, cfg, rel, breaches, ceiling, _keys(records))
158
158
 
159
159
 
160
160
  def _config(root: Path):
@@ -260,11 +260,21 @@ def _answerable(over: list, ranges) -> list:
260
260
  return [rec for rec in over if _touches(rec, ranges)]
261
261
 
262
262
 
263
- def _report(root: Path, cfg, rel: str, breaches: list, ceiling: int) -> int:
263
+ def _keys(records: list) -> dict:
264
+ """The file's ratchet keys, built from every record rather than the breaching
265
+ ones: the ordinal counts same-named functions in file order."""
266
+ from ..keys import key_names
267
+
268
+ return key_names(records)
269
+
270
+
271
+ def _report(root: Path, cfg, rel: str, breaches: list, ceiling: int, keys: dict) -> int:
264
272
  """Rung 9. stdout stays empty whatever happens: protocol 1 reserves it for a
265
273
  future JSON channel, and Claude Code parses stdout JSON on exit 0."""
274
+ from ..keys import key_of
275
+
266
276
  marked = _marks_for(root / cfg.ratchet_file, rel)
267
- unmarked = [rec for rec in breaches if rec.long_name not in marked]
277
+ unmarked = [rec for rec in breaches if key_of(keys, rec)[1] not in marked]
268
278
  if not unmarked:
269
279
  return 0
270
280
  for line in _advisory_lines(rel, unmarked, ceiling):
@@ -273,7 +283,7 @@ def _report(root: Path, cfg, rel: str, breaches: list, ceiling: int) -> int:
273
283
 
274
284
 
275
285
  def _marks_for(marks_path: Path, rel: str) -> set[str]:
276
- """The function names one file carries ratchet marks for.
286
+ """The ratchet KEY names one file carries marks for, `#N` ordinals included.
277
287
 
278
288
  Existence, not the numeric high-water rule `verify` applies: crap needs
279
289
  coverage, coverage needs the store, and the store stays closed. A mark is a
@@ -286,6 +286,8 @@ def build_parser() -> argparse.ArgumentParser:
286
286
  help="rerun only lanes whose scope files changed since their artifact; reuse the rest")
287
287
  ver.add_argument("--override", default=None, metavar="REASON",
288
288
  help="audited exemption for gate violations: alert + ratchet debt + snapshot record")
289
+ ver.add_argument("--no-tighten", action="store_true",
290
+ help="pass the verdict without rewriting the ratchet; marks stay where they are")
289
291
  ver.add_argument("--sarif", default=None, metavar="PATH",
290
292
  help="write gate/ratchet findings as SARIF 2.1.0, relative to the repo")
291
293
  ver.add_argument("--github", action="store_true",
@@ -13,6 +13,7 @@ from .. import packet
13
13
  from ..churn_cache import load_churn
14
14
  from ..errors import ConfigError, CrapkitError
15
15
  from ..gitio import head_commit
16
+ from ..keys import key_names, key_of, split_ordinal
16
17
  from ..store import SnapshotStore
17
18
  from ..uncovered import load_uncovered
18
19
  from ..worklist import admission, build_worklist, sql_floor
@@ -443,31 +444,50 @@ def _no_handle_message(path: str, name: str, rows: list) -> str:
443
444
 
444
445
 
445
446
  def _pick_function(path: str, rows: list, name: str):
446
- """The one row `name` names, or an error listing what the file does hold.
447
-
448
- Twins (one long_name at two spans) are ONE candidate, not an ambiguity: the
449
- worst-scoring twin is what a burn-down item means, the same rule the ratchet
450
- and the verdict use. The -start term keeps verify.worst_twins' tie-break —
451
- equal-scoring twins resolve to the one that appears first in the file.
452
- """
447
+ """The one row `name` names, or an error listing what the file does hold."""
453
448
  at_line = _row_at_line(path, rows, name)
454
449
  if at_line is not None:
455
450
  return at_line
456
451
  by_handle = _row_by_handle(path, rows, name)
457
452
  if by_handle is not None:
458
453
  return by_handle
459
- matched = _matching_rows(rows, name)
454
+ wanted, ordinal = split_ordinal(name)
455
+ matched = _matching_rows(rows, wanted)
460
456
  candidates = sorted({r.long_name for r in matched})
461
457
  if len(candidates) != 1:
462
458
  raise CrapkitError(_no_match_message(path, name, rows, candidates))
463
- return max(matched, key=lambda r: (r.crap, -r.start))
459
+ return _one_of(path, matched, name, wanted, ordinal)
460
+
461
+
462
+ def _one_of(path: str, matched: list, name: str, wanted: str, ordinal: int):
463
+ """Which of the same-named rows NAME meant.
464
+
465
+ A bare name means the burn-down item, and that is the worst twin — the rule
466
+ the ratchet, the verdict and `next-item` all run. The -start term is the
467
+ tie-break: equal-scoring twins resolve to the one that appears first.
468
+
469
+ `name#2` means the second of them in file order, which is the function its
470
+ own ratchet key `name#2` is about. That is how a session addresses the twin
471
+ a bare name does not pick.
472
+ """
473
+ if wanted == name:
474
+ return max(matched, key=lambda r: (r.crap, -r.start))
475
+ ordered = sorted(matched, key=lambda r: r.start)
476
+ if ordinal > len(ordered):
477
+ raise CrapkitError(_no_twin_message(path, name, wanted, len(ordered)))
478
+ return ordered[ordinal - 1]
479
+
480
+
481
+ def _no_twin_message(path: str, name: str, wanted: str, held: int) -> str:
482
+ return (f"no {name} in {path} in the latest scored run"
483
+ f" — it holds {held} function(s) named {wanted!r}")
464
484
 
465
485
 
466
- def _brief_mark(entries: list | None, row) -> float | None:
486
+ def _brief_mark(entries: list | None, key: tuple[str, str]) -> float | None:
467
487
  """The committed mark on this function, or None when the repo carries none."""
468
488
  from ..ratchet import mark_for
469
489
 
470
- return None if entries is None else mark_for(entries, row.path, row.long_name)
490
+ return None if entries is None else mark_for(entries, *key)
471
491
 
472
492
 
473
493
  def _brief_churn(churn: dict, path: str) -> dict | None:
@@ -505,6 +525,7 @@ class _BriefLoader:
505
525
  self.latest = latest
506
526
  self._whole_repo: dict = {}
507
527
  self._scored_files: dict = {}
528
+ self._file_keys: dict = {}
508
529
  self._attempts: dict = {}
509
530
 
510
531
  def _once(self, key: str, build):
@@ -555,9 +576,20 @@ class _BriefLoader:
555
576
  self._scored_files[path] = self.store.read_scored_file(self.latest["id"], path)
556
577
  return self._scored_files[path]
557
578
 
579
+ def key(self, row) -> tuple[str, str]:
580
+ """The row's ratchet key, counted over the whole file it lives in.
581
+
582
+ Cached per path beside the rows it is built from: a batch of packets
583
+ about one file would otherwise recount its ordinals per packet.
584
+ """
585
+ if row.path not in self._file_keys:
586
+ self._file_keys[row.path] = key_names(self.scored_file(row.path))
587
+ return key_of(self._file_keys[row.path], row)
588
+
558
589
  def mark(self, row) -> float | None:
559
590
  return _brief_mark(self._once("marks",
560
- lambda: _ratchet_entries(self.root, self.cfg)), row)
591
+ lambda: _ratchet_entries(self.root, self.cfg)),
592
+ self.key(row))
561
593
 
562
594
  def mark_age(self, row, mark: float | None) -> int | None:
563
595
  """How long the mark has stood. No mark, no history read: reading the
@@ -565,7 +597,7 @@ class _BriefLoader:
565
597
  if mark is None:
566
598
  return None
567
599
  return packet.mark_age_days(self._once("mark_events", self._read_mark_events),
568
- (row.path, row.long_name))
600
+ self.key(row))
569
601
 
570
602
  def _read_mark_events(self) -> list:
571
603
  from ..gitio import file_log_patches
@@ -12,11 +12,40 @@ from ..store import SnapshotStore
12
12
  from ._shared import _load_ratchet_or_die, _load_repo_config, _open_store, _print_json
13
13
 
14
14
 
15
- def _latest_full_run(store: SnapshotStore) -> dict:
16
- runs = [r for r in store.list_runs() if r["kind"] in ("coverage", "verify")]
17
- if not runs:
18
- raise CrapkitError("no full coverage run to work from — run `crapkit coverage` first")
19
- return runs[-1]
15
+ def _is_failed_verify(run: dict) -> bool:
16
+ return run["kind"] == "verify" and run["verdict_ok"] is False
17
+
18
+
19
+ def _skipped_failed_verifies(runs: list[dict], chosen_id: int) -> list[dict]:
20
+ """The failed verifies newer than the run this seed or prune settled on."""
21
+ return [r for r in runs if r["id"] > chosen_id and _is_failed_verify(r)]
22
+
23
+
24
+ def _latest_full_run(store: SnapshotStore) -> tuple[dict, list[dict]]:
25
+ """The newest TRUSTED run to work from, and the failed verifies passed over.
26
+
27
+ `verify`'s own rule, shared: a failed verify never serves as a baseline
28
+ because it can carry the scores of a red tree, and the failure is visible in
29
+ the same run. Seeding from one signs debt at values verify itself will not
30
+ accept as a comparison point. Before this, seed took the newest run whose
31
+ kind was coverage or verify and never read the verdict (#16).
32
+ """
33
+ from ..store import is_trusted
34
+
35
+ runs = store.list_runs()
36
+ trusted = [r for r in runs if is_trusted(r)]
37
+ if not trusted:
38
+ raise CrapkitError("no trusted full run to work from — run `crapkit coverage` first "
39
+ "(failed verifies and hook runs never serve as baselines)")
40
+ return trusted[-1], _skipped_failed_verifies(runs, trusted[-1]["id"])
41
+
42
+
43
+ def _skip_note(skipped: list[dict]) -> str:
44
+ """Why the line names an older run than the newest one in the store."""
45
+ if not skipped:
46
+ return ""
47
+ ids = ", ".join(str(r["id"]) for r in skipped)
48
+ return f", skipped failed verify {'runs' if len(skipped) > 1 else 'run'} {ids}"
20
49
 
21
50
 
22
51
  def _merge_stamp(texts: list[str]) -> str:
@@ -108,7 +137,7 @@ def _print_ratchet_report(report: dict, violations: list, ratchet_file: str) ->
108
137
 
109
138
 
110
139
  def _working_marks(root: Path, ratchet_file: str) -> dict:
111
- """The marks on disk, keyed (path, long_name) -> crap. Ages come from the
140
+ """The marks on disk, keyed (path, key name) -> crap. Ages come from the
112
141
  file's git history, but which marks are OPEN is a question about now, and a
113
142
  seed prints "added 1" long before anybody commits the TSV."""
114
143
  entries = _load_ratchet_or_die(root / ratchet_file, ratchet_file)
@@ -156,7 +185,7 @@ def cmd_ratchet(args: argparse.Namespace) -> int:
156
185
  if args.action == "move": # a hand-declared rename needs no run to follow
157
186
  return _ratchet_move(root, cfg, args.files)
158
187
  store = _open_store(root)
159
- latest = _latest_full_run(store)
188
+ latest, skipped = _latest_full_run(store)
160
189
  fresh = store.read_scored(latest["id"])
161
190
  ratchet_path = root / cfg.ratchet_file
162
191
  prior = _load_ratchet_or_die(ratchet_path, cfg.ratchet_file)
@@ -168,5 +197,5 @@ def cmd_ratchet(args: argparse.Namespace) -> int:
168
197
  entries, note = _pruned(root, store, prior, fresh)
169
198
  ratchet_path.write_text(dump_ratchet(entries), encoding="utf-8", newline="\n")
170
199
  print(f"{cfg.ratchet_file}: {note} — {len(entries)} mark(s) vs run {latest['id']} "
171
- f"({latest['commit'][:11]})")
200
+ f"({latest['commit'][:11]}){_skip_note(skipped)}")
172
201
  return 0
@@ -318,6 +318,10 @@ class _ExplainCtx(NamedTuple):
318
318
  uncovered: MissingLines
319
319
  ratchet: list | None
320
320
  contexts: dict
321
+ # The twin NAME selected: 1 for a bare name, 2 for `f#2`. It is the ordinal
322
+ # in the ratchet key, and `(anonymous)#2` reads the same way — that handle
323
+ # and that twin's key are the same string.
324
+ ordinal: int = 1
321
325
 
322
326
 
323
327
  def cmd_explain(args: argparse.Namespace) -> int:
@@ -338,19 +342,24 @@ def cmd_explain(args: argparse.Namespace) -> int:
338
342
 
339
343
 
340
344
  def _explain_ctx(root: Path, cfg, store: SnapshotStore, args) -> _ExplainCtx:
345
+ from ..keys import split_ordinal
346
+
341
347
  runs = [r for r in store.list_runs() if r["kind"] != "hook"]
342
348
  return _ExplainCtx(root, args.path, runs[-1]["id"] if runs else None,
343
349
  load_uncovered(root, cfg), _ratchet_entries(root, cfg),
344
- _contexts_for_path(root, cfg, args.path) if args.tests else {})
350
+ _contexts_for_path(root, cfg, args.path) if args.tests else {},
351
+ split_ordinal(args.name)[1])
345
352
 
346
353
 
347
354
  def _explain_payload(ctx: _ExplainCtx, store: SnapshotStore, args, long_name: str) -> dict:
348
355
  """One function's whole packet. The span is looked up once and passed down:
349
356
  dark lines, --history and --tests all want the same line range."""
357
+ from ..keys import key_name
358
+
350
359
  span = _latest_span(store, ctx.run_id, ctx.path, long_name)
351
360
  out = {"long_name": long_name,
352
361
  "history": store.function_history(ctx.path, long_name),
353
- **_mark_fields(ctx.ratchet, ctx.path, long_name),
362
+ **_mark_fields(ctx.ratchet, ctx.path, key_name(long_name, ctx.ordinal)),
354
363
  **_dark_fields(ctx.uncovered, ctx.path, span)}
355
364
  if args.history:
356
365
  out.update(_commits_fields(ctx.root, ctx.path, span))
@@ -384,7 +393,11 @@ def _latest_span(store: SnapshotStore, run_id: int | None, path: str, long_name:
384
393
 
385
394
  def _mark_fields(ratchet: list | None, path: str, long_name: str) -> dict:
386
395
  """null and a note when the repo carries no marks file: an unmarked function
387
- and a repo with no ratchet both read as null, and they want different moves."""
396
+ and a repo with no ratchet both read as null, and they want different moves.
397
+
398
+ `long_name` arrives as the KEY name, so `explain path f#2` reads the second
399
+ twin's mark and a bare `f` reads the first's.
400
+ """
388
401
  from ..ratchet import mark_for
389
402
 
390
403
  if ratchet is None: