crapkit 0.2.0__tar.gz → 0.4.0__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 (69) hide show
  1. {crapkit-0.2.0/src/crapkit.egg-info → crapkit-0.4.0}/PKG-INFO +24 -13
  2. {crapkit-0.2.0 → crapkit-0.4.0}/README.md +23 -12
  3. {crapkit-0.2.0 → crapkit-0.4.0}/pyproject.toml +1 -1
  4. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/__init__.py +1 -1
  5. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/_pygdefer.py +6 -3
  6. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/analyze.py +199 -12
  7. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/__init__.py +38 -0
  8. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/admin.py +70 -0
  9. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/analyses.py +21 -8
  10. crapkit-0.4.0/src/crapkit/cli/claude_hook.py +307 -0
  11. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/parser.py +54 -5
  12. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/reports.py +89 -7
  13. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/scoring.py +19 -3
  14. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/verifying.py +54 -5
  15. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/config.py +11 -2
  16. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/discover.py +24 -5
  17. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/doctor.py +55 -0
  18. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/hook.py +3 -26
  19. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/lizardcognitive.py +71 -10
  20. crapkit-0.4.0/src/crapkit/lizardpowershell.py +288 -0
  21. crapkit-0.4.0/src/crapkit/lizardrust.py +137 -0
  22. crapkit-0.4.0/src/crapkit/lizardshell.py +389 -0
  23. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/mutate.py +64 -3
  24. crapkit-0.4.0/src/crapkit/report.py +414 -0
  25. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/sarif.py +15 -0
  26. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/scaffold.py +9 -0
  27. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/uncovered.py +40 -12
  28. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/universe.py +15 -0
  29. {crapkit-0.2.0 → crapkit-0.4.0/src/crapkit.egg-info}/PKG-INFO +24 -13
  30. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit.egg-info/SOURCES.txt +5 -0
  31. {crapkit-0.2.0 → crapkit-0.4.0}/LICENSE +0 -0
  32. {crapkit-0.2.0 → crapkit-0.4.0}/setup.cfg +0 -0
  33. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/__main__.py +0 -0
  34. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cache.py +0 -0
  35. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/churn.py +0 -0
  36. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/churn_cache.py +0 -0
  37. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/churn_log.py +0 -0
  38. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/_shared.py +0 -0
  39. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/queue.py +0 -0
  40. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/cli/ratchet_cmds.py +0 -0
  41. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/coupling.py +0 -0
  42. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/coverage_istanbul.py +0 -0
  43. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/coverage_py.py +0 -0
  44. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/covstream.py +0 -0
  45. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/diffparse.py +0 -0
  46. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/digest.py +0 -0
  47. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/dup.py +0 -0
  48. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/errors.py +0 -0
  49. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/gitio.py +0 -0
  50. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/junitparse.py +0 -0
  51. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/lanes.py +0 -0
  52. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/mcp_server.py +0 -0
  53. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/merge.py +0 -0
  54. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/mutate_pool.py +0 -0
  55. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/override.py +0 -0
  56. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/packet.py +0 -0
  57. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/ratchet.py +0 -0
  58. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/ratchet_report.py +0 -0
  59. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/sarifio.py +0 -0
  60. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/score.py +0 -0
  61. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/snapshot.py +0 -0
  62. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/store.py +0 -0
  63. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/verify.py +0 -0
  64. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/watch.py +0 -0
  65. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit/worklist.py +0 -0
  66. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit.egg-info/dependency_links.txt +0 -0
  67. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit.egg-info/entry_points.txt +0 -0
  68. {crapkit-0.2.0 → crapkit-0.4.0}/src/crapkit.egg-info/requires.txt +0 -0
  69. {crapkit-0.2.0 → crapkit-0.4.0}/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.2.0
3
+ Version: 0.4.0
4
4
  Summary: Deterministic CRAP-score framework: per-function complexity x coverage risk, worklists, ratchets, refactor verification
5
5
  Author: Jean-Francois Gagne
6
6
  License: MIT
@@ -28,10 +28,14 @@ Dynamic: license-file
28
28
 
29
29
  crapkit scores every function in your repo on complexity times uncovered risk, ranks the
30
30
  worst ones by how often the file changes, and blocks commits that add more. It reads
31
- TypeScript, TSX, JavaScript and Python through [lizard](https://github.com/terryyin/lizard),
31
+ TypeScript, TSX, JavaScript, Python, Swift, Go, Rust, shell, PowerShell, C and C++,
32
+ Objective-C, Vue, Java and Zig through [lizard](https://github.com/terryyin/lizard),
32
33
  and joins per-function branch coverage from istanbul or coverage.py artifacts your own test
33
- command already produces. Every read-side command speaks JSON with a pinned schema, because
34
- half the callers are coding agents.
34
+ command already produces. Those two parsers are the whole list, so Swift, Go, Rust, shell,
35
+ PowerShell, C and C++, Objective-C, Java and Zig have no coverage to join: declare those
36
+ scopes `coverage_optional` and they score on complexity alone. Vue joins istanbul coverage
37
+ when your own vitest run reports on `.vue` files. Every read-side command speaks JSON with a
38
+ pinned schema, because half the callers are coding agents.
35
39
 
36
40
  ```
37
41
  CRAP = ccn^2 * (1 - cov)^3 + ccn
@@ -76,7 +80,7 @@ Check the install:
76
80
 
77
81
  ```
78
82
  $ crapkit --version
79
- crapkit 0.2.0
83
+ crapkit 0.4.0
80
84
  ```
81
85
 
82
86
  `python -m crapkit` works identically to the `crapkit` console script, and is what to use
@@ -130,7 +134,7 @@ paths = ["calc"]
130
134
  languages = ["python"]
131
135
 
132
136
  [exclude]
133
- globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
137
+ globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "**/*_test.go", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
134
138
 
135
139
  [[lane]]
136
140
  name = "py"
@@ -472,11 +476,15 @@ without touching the repo-wide cache, and refuses the commit when a staged funct
472
476
  its scope ceiling. It needs no coverage data and no snapshot, so it costs the size of the
473
477
  commit, not the size of the repo.
474
478
 
475
- Two limits to know. The gate judges files a `[[scope]]` claims; a staged source file no
479
+ Three limits to know. The gate judges files a `[[scope]]` claims; a staged source file no
476
480
  scope claims is not gated, and the hook says so on stderr (`N staged file(s) belong to no
477
481
  scope and were not gated`) so the hole is visible the moment a new top-level directory
478
- appears. And git runs hooks outside your shell's activated venv: bare `python` must resolve
479
- to an interpreter that has crapkit installed, or use the absolute form
482
+ appears. A function the committed ratchet already carries a mark for is not gated either,
483
+ so touching signed debt does not refuse the commit; the hook reports the count on stderr
484
+ (`N staged function(s) carry a ratchet mark and were not gated`) and `crapkit verify` is
485
+ what fails a mark that rose. And git runs hooks outside your shell's activated venv: bare
486
+ `python` must resolve to an interpreter that has crapkit installed, or use the absolute
487
+ form
480
488
  (`exec /path/to/venv/Scripts/python -m crapkit hook-precommit`).
481
489
 
482
490
  ### Route 1: `.git/hooks/pre-commit` (local, not committed)
@@ -725,7 +733,8 @@ advances the baseline nor tightens the ratchet, exit 9 included.
725
733
  ## Subcommands
726
734
 
727
735
  Every subcommand takes `--repo PATH` (default `.`), and the flag goes **after** the
728
- subcommand:
736
+ subcommand. `claude-hook` is the one exception: it has no `--repo`, because it takes its
737
+ root from the file named in the hook payload it reads.
729
738
 
730
739
  ```
731
740
  $ crapkit worklist --repo /path/to/repo --scope util --top 1
@@ -746,7 +755,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
746
755
  | Command | What it does |
747
756
  |---|---|
748
757
  | `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. |
749
- | `doctor [--show-files] [--json] [--tune]` | 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, lanes writing their artifacts at the repo root instead of under `.crapkit/` (WARN), committed hooks under `core.hooksPath` that are not executable in the index (WARN), directories whose functions are all `untested` while their tests exist (WARN), and scopes a lane measures with no `[crapkit.scoped_tests]` template behind them (WARN), which is the loop's step 4 with nothing to run. `--tune` prints suggested parallelism knobs and writes nothing. See [docs/agent-json.md](docs/agent-json.md#doctor---json). |
758
+ | `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, lanes writing their artifacts at the repo root instead of under `.crapkit/` (WARN), committed hooks under `core.hooksPath` that are not executable in the index (WARN), directories whose functions are all `untested` while their tests exist (WARN), and scopes a lane measures with no `[crapkit.scoped_tests]` template behind them (WARN), 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, comparing `.claude-plugin/plugin.json`'s version against the running crapkit and every `--protocol` in `hooks/hooks.json` against the protocol `claude-hook` answers, one line per disagreement and silence when they agree. See [docs/agent-json.md](docs/agent-json.md#doctor---json). |
750
759
  | `inventory [--db PATH] [--export PATH] [--json]` | Two lizard passes 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. |
751
760
  | `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). |
752
761
  | `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. |
@@ -761,11 +770,13 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
761
770
  | `overrides [--json]` | The override audit trail: who granted what, when, and why. |
762
771
  | `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
763
772
  | `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. |
773
+ | `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, opens no network connection, and carries no per-function CRAP or coverage, because no repo-wide payload has them; each row prints the `crapkit explain` call that does. |
764
774
  | `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. |
765
775
  | `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. |
766
- | `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. |
776
+ | `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. |
767
777
  | `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. |
768
778
  | `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
779
+ | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed and nothing is blocked, and `hook-precommit` stays the enforcement point. Exit 2 with three lines on stderr is the only thing it ever says. No `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure: exit 0, silence, empty stderr. Takes no `--repo` — the root is the first `crapkit.toml` above the edited file and the upward walk stops at a `.git` entry, so a worktree never borrows its parent's config. It opens no snapshot, writes nothing, and leaves stdout empty. |
769
780
  | `watch [--interval SECONDS] [--cycles N]` | Rescores tracked files as they change (mtime polling, default 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). `--cycles N` polls exactly N times and exits 0; without it the loop runs until ctrl-c. |
770
781
  | `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing nine read-only tools. See [docs/agent-json.md](docs/agent-json.md#mcp-server). |
771
782
 
@@ -779,7 +790,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
779
790
  | [docs/ratchet.md](docs/ratchet.md) | Seeding, pruning, the git merge driver, metric stamps, debt policy, overrides. |
780
791
  | [docs/agent-json.md](docs/agent-json.md) | The machine surface: `schema`, every payload field, real captured examples. |
781
792
  | [docs/adoption.md](docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
782
- | [skills/](skills/) | Agent skills shipped with the repo (`crapkit`, `crapkit-recover`, `crapkit-onboard`) — install by copying to your agent runtime's skills directory. |
793
+ | [plugin/](plugin/) | The Claude Code plugin: three skills (`crapkit`, `crapkit-recover`, `crapkit-onboard`), the read-side MCP server, and the advisory PostToolUse hook. Install with `claude plugin marketplace add JeanFrancoisGagne/crapkit` then `claude plugin install crapkit@crapkit`; other runtimes copy `plugin/skills/*` into their skills directory. |
783
794
 
784
795
  [crapkit.schema.json](crapkit.schema.json) is the authority on the config file shape.
785
796
 
@@ -2,10 +2,14 @@
2
2
 
3
3
  crapkit scores every function in your repo on complexity times uncovered risk, ranks the
4
4
  worst ones by how often the file changes, and blocks commits that add more. It reads
5
- TypeScript, TSX, JavaScript and Python through [lizard](https://github.com/terryyin/lizard),
5
+ TypeScript, TSX, JavaScript, Python, Swift, Go, Rust, shell, PowerShell, C and C++,
6
+ Objective-C, Vue, Java and Zig through [lizard](https://github.com/terryyin/lizard),
6
7
  and joins per-function branch coverage from istanbul or coverage.py artifacts your own test
7
- command already produces. Every read-side command speaks JSON with a pinned schema, because
8
- half the callers are coding agents.
8
+ command already produces. Those two parsers are the whole list, so Swift, Go, Rust, shell,
9
+ PowerShell, C and C++, Objective-C, Java and Zig have no coverage to join: declare those
10
+ scopes `coverage_optional` and they score on complexity alone. Vue joins istanbul coverage
11
+ when your own vitest run reports on `.vue` files. Every read-side command speaks JSON with a
12
+ pinned schema, because half the callers are coding agents.
9
13
 
10
14
  ```
11
15
  CRAP = ccn^2 * (1 - cov)^3 + ccn
@@ -50,7 +54,7 @@ Check the install:
50
54
 
51
55
  ```
52
56
  $ crapkit --version
53
- crapkit 0.2.0
57
+ crapkit 0.4.0
54
58
  ```
55
59
 
56
60
  `python -m crapkit` works identically to the `crapkit` console script, and is what to use
@@ -104,7 +108,7 @@ paths = ["calc"]
104
108
  languages = ["python"]
105
109
 
106
110
  [exclude]
107
- globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
111
+ globs = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.test.*", "**/*.spec.*", "**/test_*.py", "**/*_test.py", "**/conftest.py", "**/*_test.go", "*.config.ts", "*.config.js", "*.config.mts", "**/*.config.ts", "**/*.config.js", "**/*.config.mts"]
108
112
 
109
113
  [[lane]]
110
114
  name = "py"
@@ -446,11 +450,15 @@ without touching the repo-wide cache, and refuses the commit when a staged funct
446
450
  its scope ceiling. It needs no coverage data and no snapshot, so it costs the size of the
447
451
  commit, not the size of the repo.
448
452
 
449
- Two limits to know. The gate judges files a `[[scope]]` claims; a staged source file no
453
+ Three limits to know. The gate judges files a `[[scope]]` claims; a staged source file no
450
454
  scope claims is not gated, and the hook says so on stderr (`N staged file(s) belong to no
451
455
  scope and were not gated`) so the hole is visible the moment a new top-level directory
452
- appears. And git runs hooks outside your shell's activated venv: bare `python` must resolve
453
- to an interpreter that has crapkit installed, or use the absolute form
456
+ appears. A function the committed ratchet already carries a mark for is not gated either,
457
+ so touching signed debt does not refuse the commit; the hook reports the count on stderr
458
+ (`N staged function(s) carry a ratchet mark and were not gated`) and `crapkit verify` is
459
+ what fails a mark that rose. And git runs hooks outside your shell's activated venv: bare
460
+ `python` must resolve to an interpreter that has crapkit installed, or use the absolute
461
+ form
454
462
  (`exec /path/to/venv/Scripts/python -m crapkit hook-precommit`).
455
463
 
456
464
  ### Route 1: `.git/hooks/pre-commit` (local, not committed)
@@ -699,7 +707,8 @@ advances the baseline nor tightens the ratchet, exit 9 included.
699
707
  ## Subcommands
700
708
 
701
709
  Every subcommand takes `--repo PATH` (default `.`), and the flag goes **after** the
702
- subcommand:
710
+ subcommand. `claude-hook` is the one exception: it has no `--repo`, because it takes its
711
+ root from the file named in the hook payload it reads.
703
712
 
704
713
  ```
705
714
  $ crapkit worklist --repo /path/to/repo --scope util --top 1
@@ -720,7 +729,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
720
729
  | Command | What it does |
721
730
  |---|---|
722
731
  | `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. |
723
- | `doctor [--show-files] [--json] [--tune]` | 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, lanes writing their artifacts at the repo root instead of under `.crapkit/` (WARN), committed hooks under `core.hooksPath` that are not executable in the index (WARN), directories whose functions are all `untested` while their tests exist (WARN), and scopes a lane measures with no `[crapkit.scoped_tests]` template behind them (WARN), which is the loop's step 4 with nothing to run. `--tune` prints suggested parallelism knobs and writes nothing. See [docs/agent-json.md](docs/agent-json.md#doctor---json). |
732
+ | `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, lanes writing their artifacts at the repo root instead of under `.crapkit/` (WARN), committed hooks under `core.hooksPath` that are not executable in the index (WARN), directories whose functions are all `untested` while their tests exist (WARN), and scopes a lane measures with no `[crapkit.scoped_tests]` template behind them (WARN), 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, comparing `.claude-plugin/plugin.json`'s version against the running crapkit and every `--protocol` in `hooks/hooks.json` against the protocol `claude-hook` answers, one line per disagreement and silence when they agree. See [docs/agent-json.md](docs/agent-json.md#doctor---json). |
724
733
  | `inventory [--db PATH] [--export PATH] [--json]` | Two lizard passes 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. |
725
734
  | `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). |
726
735
  | `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. |
@@ -735,11 +744,13 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
735
744
  | `overrides [--json]` | The override audit trail: who granted what, when, and why. |
736
745
  | `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. |
737
746
  | `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. |
747
+ | `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, opens no network connection, and carries no per-function CRAP or coverage, because no repo-wide payload has them; each row prints the `crapkit explain` call that does. |
738
748
  | `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. |
739
749
  | `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. |
740
- | `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. |
750
+ | `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. |
741
751
  | `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. |
742
752
  | `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |
753
+ | `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed and nothing is blocked, and `hook-precommit` stays the enforcement point. Exit 2 with three lines on stderr is the only thing it ever says. No `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure: exit 0, silence, empty stderr. Takes no `--repo` — the root is the first `crapkit.toml` above the edited file and the upward walk stops at a `.git` entry, so a worktree never borrows its parent's config. It opens no snapshot, writes nothing, and leaves stdout empty. |
743
754
  | `watch [--interval SECONDS] [--cycles N]` | Rescores tracked files as they change (mtime polling, default 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). `--cycles N` polls exactly N times and exits 0; without it the loop runs until ctrl-c. |
744
755
  | `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing nine read-only tools. See [docs/agent-json.md](docs/agent-json.md#mcp-server). |
745
756
 
@@ -753,7 +764,7 @@ crapkit: error: argument command: invalid choice: '/path/to/repo' (choose from '
753
764
  | [docs/ratchet.md](docs/ratchet.md) | Seeding, pruning, the git merge driver, metric stamps, debt policy, overrides. |
754
765
  | [docs/agent-json.md](docs/agent-json.md) | The machine surface: `schema`, every payload field, real captured examples. |
755
766
  | [docs/adoption.md](docs/adoption.md) | The judgment layer over the quickstarts: scope granularity, exclude vs lane, scoped_tests wiring, the first-verify taint hazard. |
756
- | [skills/](skills/) | Agent skills shipped with the repo (`crapkit`, `crapkit-recover`, `crapkit-onboard`) — install by copying to your agent runtime's skills directory. |
767
+ | [plugin/](plugin/) | The Claude Code plugin: three skills (`crapkit`, `crapkit-recover`, `crapkit-onboard`), the read-side MCP server, and the advisory PostToolUse hook. Install with `claude plugin marketplace add JeanFrancoisGagne/crapkit` then `claude plugin install crapkit@crapkit`; other runtimes copy `plugin/skills/*` into their skills directory. |
757
768
 
758
769
  [crapkit.schema.json](crapkit.schema.json) is the authority on the config file shape.
759
770
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crapkit"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Deterministic CRAP-score framework: per-function complexity x coverage risk, worklists, ratchets, refactor verification"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,2 +1,2 @@
1
1
  """crapkit: deterministic CRAP-score framework."""
2
- __version__ = "0.2.0"
2
+ __version__ = "0.4.0"
@@ -6,9 +6,12 @@ importlib.metadata, email, zipfile and socket. Measured on this box: 42ms with
6
6
  pygments, 16ms without, paid by every process that touches the analysis stack,
7
7
  the pre-commit hook included.
8
8
 
9
- crapkit analyzes five languages (typescript, tsx, javascript, python, swift).
10
- Erlang is not one of them and no scope can name it. The readers that need
11
- pygments are still SHIPPED, not removed: `deferred_pygments()` puts proxies in
9
+ crapkit analyzes fourteen languages (typescript, tsx, javascript, python, swift,
10
+ go, rust, shell, powershell, cpp, objectivec, vue, java, zig). Erlang is not one
11
+ of them and no scope can name it, which is the whole argument for stubbing its
12
+ reader, so a test pins this list to `config.SUPPORTED_LANGUAGES`. The readers
13
+ that need pygments are still SHIPPED, not removed: `deferred_pygments()` puts
14
+ proxies in
12
15
  sys.modules for the duration of the lizard import, so the readers bind stand-ins
13
16
  and the real package loads the first time anything reads or calls one. An .erl
14
17
  file analyzed through lizard directly gets the same answer; it just pays the
@@ -5,9 +5,11 @@ nested node_modules (measured hang on the first consumer repo).
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import codecs
8
9
  import hashlib
9
10
  import json
10
11
  import os
12
+ import sys
11
13
  import time
12
14
  from concurrent.futures import ProcessPoolExecutor
13
15
  from pathlib import Path
@@ -17,17 +19,40 @@ from ._pygdefer import deferred_pygments
17
19
  with deferred_pygments(): # lizard's Erlang reader would load pygments here
18
20
  import lizard
19
21
 
22
+ from .lizardpowershell import register as _register_powershell
23
+ from .lizardrust import register as _register_rust
24
+ from .lizardshell import register as _register_shell
25
+
20
26
  from .cache import partition_by_cache, updated_cache
21
27
  from .errors import ToolError
22
28
  from .lizardcognitive import LizardExtension as _Cognitive
23
29
  from .merge import FunctionRecord
30
+ from .packet import bare_name
31
+
32
+ # lizard picks a reader by extension off a hardcoded list, and none of these is
33
+ # on it: `.rs` resolves to a reader that counts no `match` arm (lizard #494),
34
+ # and `.sh` and `.ps1` resolve to nothing at all, which lizard answers with
35
+ # CLikeReader rather than a failure. All three belong HERE, at the module scope
36
+ # of the module a ProcessPoolExecutor child imports, or spawned workers measure
37
+ # with the readers lizard shipped and report plausible wrong numbers.
38
+ #
39
+ # lizardshell and lizardpowershell already register themselves on import and
40
+ # lizardrust deliberately does not (rebinding a name in another package's
41
+ # namespace is not something an import should do quietly). Calling all three
42
+ # keeps the wiring readable in one place and costs nothing: each is idempotent.
43
+ _register_rust()
44
+ _register_shell()
45
+ _register_powershell()
24
46
 
25
47
  _POOL_THRESHOLD = 16
26
48
 
27
49
  # Bump whenever analysis semantics change (merge rules, extension set, record
28
50
  # extraction): the fingerprint must invalidate cached records produced by older
29
51
  # logic even when file content and tool versions are identical.
30
- ANALYSIS_VERSION = 3 # 3: cognitive complexity column joined the standard pass
52
+ ANALYSIS_VERSION = 6 # 6: five more C-family extension sets and .ps1/.psm1 are
53
+ # admitted, a C++ rvalue reference is no longer a
54
+ # cognitive condition, and source bytes decode utf-8
55
+ # then cp1252 instead of by machine locale
31
56
 
32
57
  # The three tokens lizard's modified rule reacts to. Membership is checked before
33
58
  # anything else runs, so the common token pays one frozenset lookup.
@@ -69,12 +94,39 @@ class _ModifiedDelta:
69
94
  yield token
70
95
 
71
96
 
72
- # Built once per process, not once per file: 14k files paid 14k chain builds.
73
- # Every extension in it keeps its state in the generator frame __call__ opens,
74
- # so one chain serves every file. Cognitive comes FIRST because lizard's own
75
- # preprocessors strip the whitespace tokens its python rules read; the delta
76
- # comes last, where the modified pass used to sit.
77
- _EXTENSIONS = [_Cognitive()] + lizard.get_extensions(["ND"]) + [_ModifiedDelta()]
97
+ def _chain(cognitive_index: int) -> list:
98
+ """lizard's standard extensions with cognitive spliced in at one index.
99
+
100
+ Index 0 puts cognitive ahead of lizard's own `preprocessing`, which is where
101
+ it has to sit for Python: `preprocessing` strips the whitespace tokens the
102
+ python indent rules read, and behind it a 6-branch function scores 6 instead
103
+ of 10. The delta comes last either way, where the modified pass used to sit.
104
+ """
105
+ extensions = lizard.get_extensions(["ND"])
106
+ extensions.insert(cognitive_index, _Cognitive())
107
+ return extensions + [_ModifiedDelta()]
108
+
109
+
110
+ # Two chains, built once per process each, not once per file: 14k files paid 14k
111
+ # chain builds. Every extension keeps its state in the generator frame __call__
112
+ # opens, so one chain serves every file of its kind.
113
+ _EXTENSIONS = _chain(0)
114
+ _PREPROCESSED_EXTENSIONS = _chain(1)
115
+
116
+ # lizard's SwiftReplaceLabel.preprocess RETURNS a list where the other seven
117
+ # preprocessors YIELD: it runs list() over its input, so every extension AHEAD of
118
+ # `preprocessing` is drained to exhaustion before lizard has split the file into
119
+ # functions. An extension at index 0 counts the whole file against one
120
+ # placeholder FunctionInfo, and every real function comes out at 0. SwiftReader
121
+ # and KotlinReader are the only two of lizard's 27 readers that inherit that
122
+ # preprocessor, so only these suffixes take the second chain.
123
+ _DRAINED_READER_SUFFIXES = (".swift", ".kt", ".kts")
124
+
125
+
126
+ def _extensions_for(rel_path: str) -> list:
127
+ if rel_path.lower().endswith(_DRAINED_READER_SUFFIXES):
128
+ return _PREPROCESSED_EXTENSIONS
129
+ return _EXTENSIONS
78
130
 
79
131
 
80
132
  def _record(rel_path: str, fn) -> FunctionRecord:
@@ -95,11 +147,145 @@ def _record(rel_path: str, fn) -> FunctionRecord:
95
147
  )
96
148
 
97
149
 
150
+ # How many colliding names one warning prints before it stops. A generated file
151
+ # can hold hundreds; the point is that the file needs looking at, not the list.
152
+ _NAMES_SHOWN = 5
153
+
154
+
155
+ def _colliding_names(records: list[FunctionRecord]) -> list[str]:
156
+ """Names this file gives to more than one function, in first-seen order.
157
+
158
+ Anonymous functions are exempt. lizard calls every one of them
159
+ `(anonymous)`, so a file with two arrow callbacks collides by construction
160
+ and a warning would name nothing anyone could act on; `packet.handles`
161
+ answers that collision with the `(anonymous)#N` ordinal instead.
162
+ """
163
+ seen: set[str] = set()
164
+ colliding: dict[str, None] = {}
165
+ for record in records:
166
+ if bare_name(record.long_name) and record.long_name in seen:
167
+ colliding[record.long_name] = None
168
+ seen.add(record.long_name)
169
+ return list(colliding)
170
+
171
+
172
+ def _warn_on_collisions(rel_path: str, records: list[FunctionRecord]) -> None:
173
+ """One stderr line for a file whose records cannot all reach the ratchet.
174
+
175
+ A mark is keyed on (path, long_name), and so is the row a run writes, so the
176
+ last function under a colliding name is the only one marked and the only one
177
+ gated. C makes this ordinary: both arms of an `#ifdef` fork are textually
178
+ present, so a platform shim defines the same function twice in one file.
179
+ Python makes it ordinary too, because a method's long_name carries no class.
180
+ Neither is fixable here — the ratchet cannot key on a span, which drifts with
181
+ every edit — so the loss is announced rather than silent.
182
+ """
183
+ names = _colliding_names(records)
184
+ if not names:
185
+ return
186
+ print(f"crapkit: {rel_path} defines {_listed(names)} more than once; the ratchet keys "
187
+ f"on (path, long_name) and keeps the last, so the earlier ones are neither "
188
+ f"marked nor gated", file=sys.stderr)
189
+
190
+
191
+ def _listed(names: list[str]) -> str:
192
+ if len(names) <= _NAMES_SHOWN:
193
+ return ", ".join(names)
194
+ return f"{', '.join(names[:_NAMES_SHOWN])} and {len(names) - _NAMES_SHOWN} more name(s)"
195
+
196
+
197
+ def _file_records(rel_path: str, functions) -> list[FunctionRecord]:
198
+ records = [_record(rel_path, fn) for fn in functions]
199
+ _warn_on_collisions(rel_path, records)
200
+ return records
201
+
202
+
203
+ # --- how a source file's bytes become text -------------------------------------
204
+ #
205
+ # lizard opens a source file with `io.open(path, 'r')` and no encoding, so the
206
+ # MACHINE'S LOCALE decides what a repo scores. Measured on one function,
207
+ # `function Write-Café`, written once as UTF-8 and once as cp1252, read on a
208
+ # cp1252 interpreter and on a UTF-8 one:
209
+ #
210
+ # source cp1252 reader utf-8 reader
211
+ # utf-8 no function at all Write-Café
212
+ # cp1252 Write-Café Write-Caf
213
+ #
214
+ # The empty cell is not a rounding error: `é` arrives as `Ã` plus `©`, the `©`
215
+ # is no word character, and the declaration stops being one. The ratchet keys on
216
+ # path::long_name, so those are three different rows for one commit, and a
217
+ # Windows developer and a Linux CI cannot see each other's baseline.
218
+ #
219
+ # utf-8 first, cp1252 second, replacement for the five bytes cp1252 leaves
220
+ # undefined. That is the whole rule and it is fixed rather than environmental:
221
+ # all four cells above read `Write-Café`. The fallback is cp1252 rather than
222
+ # latin-1 because Windows PowerShell 5.1 writes cp1252, and because latin-1
223
+ # decodes every byte and so can never say it was wrong.
224
+ #
225
+ # UTF-16 is NOT handled. `Out-File` and the ISE write it, and such a file
226
+ # decodes here as NUL-separated cp1252 text that reports no function; it
227
+ # reported none before this change either, so nothing regressed and the narrow
228
+ # rule stays narrow.
229
+
230
+
231
+ def _characters(raw: bytes) -> str:
232
+ if raw.startswith(codecs.BOM_UTF8):
233
+ raw = raw[len(codecs.BOM_UTF8):]
234
+ try:
235
+ return raw.decode("utf-8")
236
+ except UnicodeDecodeError:
237
+ return raw.decode("cp1252", "replace")
238
+
239
+
240
+ def decode_source(raw: bytes) -> str:
241
+ """Source bytes as text, decoded by content rather than by machine locale.
242
+
243
+ The single rule for every path into lizard: a file on disk, and a staged
244
+ blob the pre-commit gate never writes down. Those two must agree or the
245
+ gate judges different content than the inventory scores.
246
+
247
+ Line endings are normalized the way `io.open(path, 'r')` normalized them,
248
+ because that is what lizard did and every recorded line number and NLOC in
249
+ every cache depends on it: a lone `\\r` left in the stream is one more
250
+ whitespace token, not one more line.
251
+ """
252
+ return _characters(raw).replace("\r\n", "\n").replace("\r", "\n")
253
+
254
+
255
+ def read_source(path: str) -> str:
256
+ return decode_source(Path(path).read_bytes())
257
+
258
+
259
+ def _install_decoder() -> None:
260
+ """Point lizard's own file read at `read_source`. Idempotent.
261
+
262
+ A rebind rather than a replacement for `FileAnalyzer.__call__`, so lizard
263
+ keeps the IOError branch that answers a file deleted mid-run with an empty
264
+ result instead of a traceback, and keeps reading each file exactly once.
265
+ `lizard.py` binds `auto_read` into its own module namespace at import, and
266
+ `FileAnalyzer.__call__` resolves it there on every call.
267
+
268
+ Raises when that name is gone, which is what a lizard release that reads
269
+ source some other way would look like. Loud beats an attribute nobody
270
+ reads and a decode that silently went back to the locale's.
271
+ """
272
+ if not hasattr(lizard, "auto_read"):
273
+ raise RuntimeError(
274
+ f"crapkit.analyze._install_decoder() found no lizard.auto_read to "
275
+ f"rebind: lizard {lizard.version} reads source some other way. "
276
+ f"Rewrite this against the new mechanism; leaving it undone makes "
277
+ f"every non-ASCII file score by the machine's locale.")
278
+ lizard.auto_read = read_source
279
+
280
+
281
+ _install_decoder()
282
+
283
+
98
284
  def analyze_one(args: tuple[str, str]) -> tuple[str, list[FunctionRecord]]:
99
285
  abs_path, rel_path = args
100
286
  try:
101
- analysis = lizard.FileAnalyzer(_EXTENSIONS)(abs_path)
102
- return rel_path, [_record(rel_path, fn) for fn in analysis.function_list]
287
+ analysis = lizard.FileAnalyzer(_extensions_for(rel_path))(abs_path)
288
+ return rel_path, _file_records(rel_path, analysis.function_list)
103
289
  except Exception as exc: # loud, with the file named
104
290
  raise ToolError(f"lizard failed on {rel_path}: {exc}") from exc
105
291
 
@@ -110,11 +296,12 @@ def analyze_source(rel_path: str, code: str) -> list[FunctionRecord]:
110
296
  analyze_source_code is what FileAnalyzer.__call__ runs once it has read the
111
297
  file, so nothing about the analysis depends on whether the source arrived
112
298
  from the disk or from a git blob the caller already holds; rel_path picks
113
- the language exactly as the path on disk did.
299
+ the language, and with it the extension chain, exactly as the path on disk did.
114
300
  """
115
301
  try:
116
- analysis = lizard.FileAnalyzer(_EXTENSIONS).analyze_source_code(rel_path, code)
117
- return [_record(rel_path, fn) for fn in analysis.function_list]
302
+ analyzer = lizard.FileAnalyzer(_extensions_for(rel_path))
303
+ analysis = analyzer.analyze_source_code(rel_path, code)
304
+ return _file_records(rel_path, analysis.function_list)
118
305
  except Exception as exc: # loud, with the file named
119
306
  raise ToolError(f"lizard failed on {rel_path}: {exc}") from exc
120
307