crapkit 0.2.0__py3-none-any.whl

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 (59) hide show
  1. crapkit/__init__.py +2 -0
  2. crapkit/__main__.py +5 -0
  3. crapkit/_pygdefer.py +86 -0
  4. crapkit/analyze.py +375 -0
  5. crapkit/cache.py +58 -0
  6. crapkit/churn.py +113 -0
  7. crapkit/churn_cache.py +108 -0
  8. crapkit/churn_log.py +286 -0
  9. crapkit/cli/__init__.py +316 -0
  10. crapkit/cli/_shared.py +130 -0
  11. crapkit/cli/admin.py +650 -0
  12. crapkit/cli/analyses.py +144 -0
  13. crapkit/cli/parser.py +384 -0
  14. crapkit/cli/queue.py +926 -0
  15. crapkit/cli/ratchet_cmds.py +172 -0
  16. crapkit/cli/reports.py +459 -0
  17. crapkit/cli/scoring.py +500 -0
  18. crapkit/cli/verifying.py +580 -0
  19. crapkit/config.py +289 -0
  20. crapkit/coupling.py +89 -0
  21. crapkit/coverage_istanbul.py +225 -0
  22. crapkit/coverage_py.py +87 -0
  23. crapkit/covstream.py +320 -0
  24. crapkit/diffparse.py +98 -0
  25. crapkit/digest.py +191 -0
  26. crapkit/discover.py +365 -0
  27. crapkit/doctor.py +308 -0
  28. crapkit/dup.py +179 -0
  29. crapkit/errors.py +18 -0
  30. crapkit/gitio.py +504 -0
  31. crapkit/hook.py +167 -0
  32. crapkit/junitparse.py +87 -0
  33. crapkit/lanes.py +373 -0
  34. crapkit/lizardcognitive.py +238 -0
  35. crapkit/mcp_server.py +167 -0
  36. crapkit/merge.py +77 -0
  37. crapkit/mutate.py +96 -0
  38. crapkit/mutate_pool.py +152 -0
  39. crapkit/override.py +94 -0
  40. crapkit/packet.py +343 -0
  41. crapkit/ratchet.py +236 -0
  42. crapkit/ratchet_report.py +135 -0
  43. crapkit/sarif.py +82 -0
  44. crapkit/sarifio.py +49 -0
  45. crapkit/scaffold.py +361 -0
  46. crapkit/score.py +255 -0
  47. crapkit/snapshot.py +51 -0
  48. crapkit/store.py +1066 -0
  49. crapkit/uncovered.py +131 -0
  50. crapkit/universe.py +157 -0
  51. crapkit/verify.py +194 -0
  52. crapkit/watch.py +112 -0
  53. crapkit/worklist.py +290 -0
  54. crapkit-0.2.0.dist-info/METADATA +802 -0
  55. crapkit-0.2.0.dist-info/RECORD +59 -0
  56. crapkit-0.2.0.dist-info/WHEEL +5 -0
  57. crapkit-0.2.0.dist-info/entry_points.txt +2 -0
  58. crapkit-0.2.0.dist-info/licenses/LICENSE +21 -0
  59. crapkit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,144 @@
1
+ """The standalone analyses that read a snapshot or the working tree rather than
2
+ producing one: `coupling` (files that co-change), `mutate` (diff-scoped mutation
3
+ testing), `duplication` (near-duplicate functions) and `mcp` (the stdio server
4
+ that exposes the read-side tools)."""
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from ..churn_log import log_lines
12
+ from ..errors import ConfigError, CrapkitError
13
+ from ._shared import _load_repo_config, _load_sources, _open_store, _print_json
14
+
15
+
16
+ def cmd_coupling(args: argparse.Namespace) -> int:
17
+ from ..coupling import change_coupling_lines
18
+
19
+ root = Path(args.repo).resolve()
20
+ cfg = _load_repo_config(root)
21
+ pairs = change_coupling_lines(log_lines(root, cfg.churn_window_months),
22
+ min_support=args.min_support, min_confidence=args.min_confidence,
23
+ top=args.top)
24
+ if args.json:
25
+ _print_json({"pairs": pairs, "window_months": cfg.churn_window_months})
26
+ return 0
27
+ if not pairs:
28
+ print(f"no coupled pairs at support>={args.min_support} "
29
+ f"confidence>={args.min_confidence} in {cfg.churn_window_months}mo")
30
+ return 0
31
+ for p in pairs:
32
+ print(f" {p['support']:>4}x {p['confidence']:.0%} {p['files'][0]} <-> {p['files'][1]}")
33
+ return 0
34
+
35
+
36
+ def _range_lines(ranges: list) -> set[int]:
37
+ return {line for start, end in ranges for line in range(start, end + 1)}
38
+
39
+
40
+ def _mutation_targets(root: Path, files: list | None) -> dict:
41
+ """path -> changed line set (None = whole file). Default is diff-scoped:
42
+ only the working tree's changes vs HEAD grow mutants."""
43
+ from ..diffparse import changed_ranges
44
+ from ..gitio import diff_since
45
+
46
+ if files:
47
+ return {f.replace("\\", "/"): None for f in files}
48
+ targets = {p: _range_lines(rs) for p, rs in changed_ranges(diff_since(root, "HEAD")).items()}
49
+ if not targets:
50
+ raise CrapkitError("no changes vs HEAD to mutate — name files with --files")
51
+ return targets
52
+
53
+
54
+ def _collect_mutants(root: Path, targets: dict, max_mutants: int) -> list:
55
+ from ..mutate import file_mutants
56
+
57
+ out = []
58
+ for rel, lines in sorted(targets.items()):
59
+ p = root / rel
60
+ if not p.is_file():
61
+ continue
62
+ language = "python" if rel.endswith(".py") else "typescript"
63
+ for m in file_mutants(p.read_text(encoding="utf-8", errors="replace"), lines, language):
64
+ out.append(m._replace(path=rel))
65
+ if len(out) > max_mutants:
66
+ print(f"crapkit: capping at {max_mutants} of {len(out)} mutants "
67
+ f"(raise --max-mutants to run them all)", file=sys.stderr)
68
+ out = out[:max_mutants]
69
+ return out
70
+
71
+
72
+ def _print_mutation(as_json: bool, survivors: list, total: int) -> None:
73
+ killed = total - len(survivors)
74
+ if as_json:
75
+ _print_json({"mutants": total, "killed": killed, "survived": len(survivors),
76
+ "survivors": [m._asdict() for m in survivors]})
77
+ return
78
+ rate = f"{killed / total:.0%}" if total else "n/a"
79
+ print(f"mutation: {killed}/{total} killed ({rate})")
80
+ for m in survivors:
81
+ print(f" SURVIVED {m.path}:{m.line} [{m.op}] {m.mutated.strip()}")
82
+
83
+
84
+ def cmd_mcp(args: argparse.Namespace) -> int:
85
+ """Serve stdio from `--repo`, or from the directory the client started in.
86
+
87
+ A client registered globally opens the server in every project, most of
88
+ which have no crapkit.toml. Refusing to start there gave the client a server
89
+ that never answered `initialize`; the tools answer that per call instead.
90
+ A config that EXISTS and is broken still fails fast, before any client
91
+ connects, because every tool would fail the same way anyway.
92
+ """
93
+ from ..mcp_server import serve
94
+
95
+ root = Path(args.repo).resolve()
96
+ if (root / "crapkit.toml").is_file():
97
+ _load_repo_config(root)
98
+ return serve(root)
99
+
100
+
101
+ def cmd_mutate(args: argparse.Namespace) -> int:
102
+ from ..mutate_pool import reporter, run_mutants
103
+
104
+ root = Path(args.repo).resolve()
105
+ cfg = _load_repo_config(root)
106
+ if not cfg.mutation_command:
107
+ raise ConfigError("mutate needs [crapkit] mutation_command — the suite run once per mutant")
108
+ mutants = _collect_mutants(root, _mutation_targets(root, args.files), args.max_mutants)
109
+ verdicts = run_mutants(root, cfg, mutants, reporter(len(mutants), sys.stderr))
110
+ survivors = [m for m, killed in zip(mutants, verdicts) if not killed]
111
+ _print_mutation(args.json, survivors, len(mutants))
112
+ return 0
113
+
114
+
115
+ def _print_duplication(as_json: bool, pairs, latest: dict) -> None:
116
+ if as_json:
117
+ _print_json({"run_id": latest["id"], "pairs": pairs})
118
+ return
119
+ if not pairs:
120
+ print("no near-duplicate functions found")
121
+ return
122
+ for p in pairs:
123
+ a, b = p["functions"]
124
+ print(f" {p['similarity']:.0%} {a['path']}:{a['start']} {a['long_name']} == "
125
+ f"{b['path']}:{b['start']} {b['long_name']}")
126
+
127
+
128
+ def cmd_duplication(args: argparse.Namespace) -> int:
129
+ from ..dup import find_duplicates
130
+
131
+ root = Path(args.repo).resolve()
132
+ _load_repo_config(root) # config errors first, like every command
133
+ store = _open_store(root, first_command="inventory")
134
+ runs = [r for r in store.list_runs() if r["kind"] != "hook"]
135
+ if not runs:
136
+ raise CrapkitError(f"no snapshot in {root} — run `crapkit inventory` first")
137
+ rows = store.read_rows(runs[-1]["id"])
138
+ # A loader, never a bound dict: whoever names those texts pins every byte of
139
+ # them across the pair counting (146 MB of peak on a 104 MB repo).
140
+ pairs = find_duplicates(rows, lambda: _load_sources(root, {r.path for r in rows}),
141
+ min_lines=args.min_lines,
142
+ similarity=args.similarity, top=args.top)
143
+ _print_duplication(args.json, pairs, runs[-1])
144
+ return 0
crapkit/cli/parser.py ADDED
@@ -0,0 +1,384 @@
1
+ """The argument parser and the process entry point: every subcommand's flags in
2
+ one place, the lazy --version action, and main()'s stream reconfiguration and
3
+ CrapkitError-to-exit-code mapping."""
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+
10
+ from .. import __version__
11
+ from ..errors import CrapkitError
12
+
13
+
14
+ class _Handler:
15
+ """A subcommand's handler, named at parser-build time and imported when it runs.
16
+
17
+ argparse holds this where the function used to sit, and main() still calls
18
+ args.func(args). Naming eight families to build the parser imported all
19
+ eight, so `crapkit runs list` paid for the mutation engine and the verifier,
20
+ and `hook-precommit` paid for them at every git commit.
21
+ """
22
+
23
+ __slots__ = ("_family", "_name")
24
+
25
+ def __init__(self, family: str, name: str) -> None:
26
+ self._family = family
27
+ self._name = name
28
+
29
+ def __call__(self, args):
30
+ from importlib import import_module
31
+
32
+ return getattr(import_module(f"{__package__}.{self._family}"), self._name)(args)
33
+
34
+ def __repr__(self) -> str:
35
+ return f"<{__package__}.{self._family}.{self._name}>"
36
+
37
+
38
+ def _version_line() -> str:
39
+ """`crapkit <version>` — the program AND the number, so a pasted bug report
40
+ says what produced it.
41
+
42
+ The installed distribution answers, because pyproject.toml is where the
43
+ number is published. `__version__` is the fallback for a source tree with
44
+ nothing installed, which is where the git merge driver runs.
45
+
46
+ importlib.metadata reads that number out of the installed METADATA file,
47
+ dragging in email, csv, typing and importlib.resources to do it. Reading the
48
+ same header straight costs a directory listing, so when it produces the
49
+ number the package already carries, both sources say the same thing and
50
+ neither import is worth making. A disagreement, or a distribution this scan
51
+ cannot see, goes to importlib.metadata and takes its answer.
52
+ """
53
+ if _published_version() == __version__:
54
+ return f"crapkit {__version__}"
55
+ return f"crapkit {_metadata_version()}"
56
+
57
+
58
+ def _metadata_version() -> str:
59
+ """The authority, for what reading a header cannot settle."""
60
+ from importlib.metadata import PackageNotFoundError, version
61
+
62
+ try:
63
+ return version("crapkit")
64
+ except PackageNotFoundError:
65
+ return __version__
66
+
67
+
68
+ def _published_version() -> str | None:
69
+ """The Version: header of the first crapkit dist-info on sys.path — the same
70
+ file, found the same way, that importlib.metadata would answer from."""
71
+ for entry in sys.path:
72
+ metadata = _dist_info_metadata(entry)
73
+ if metadata:
74
+ return _version_field(metadata)
75
+ return None
76
+
77
+
78
+ def _dist_info_metadata(entry: str) -> str | None:
79
+ """<entry>/crapkit-*.dist-info/METADATA, when this path entry holds one."""
80
+ try:
81
+ names = os.listdir(entry or ".")
82
+ except OSError:
83
+ return None # a zip, a stale path entry, an unreadable directory
84
+ for name in names:
85
+ if _is_crapkit_dist_info(name):
86
+ return os.path.join(entry, name, "METADATA")
87
+ return None
88
+
89
+
90
+ def _is_crapkit_dist_info(name: str) -> bool:
91
+ low = name.lower()
92
+ return low.startswith("crapkit-") and low.endswith(".dist-info")
93
+
94
+
95
+ def _version_field(path: str) -> str | None:
96
+ """METADATA's Version: header. Headers stop at the first blank line; the
97
+ long description below it is free to contain anything."""
98
+ try:
99
+ with open(path, encoding="utf-8", errors="replace") as handle:
100
+ return _version_header(handle)
101
+ except OSError:
102
+ return None
103
+
104
+
105
+ def _version_header(lines) -> str | None:
106
+ for line in lines:
107
+ if not line.strip():
108
+ return None
109
+ if line.startswith("Version:"):
110
+ return line.partition(":")[2].strip()
111
+ return None
112
+
113
+
114
+ class _VersionAction(argparse.Action):
115
+ """`--version`, resolved when it is asked for.
116
+
117
+ argparse's own version action wants the finished string at parser-build
118
+ time, and reading the installed distribution costs ~30ms. build_parser()
119
+ runs on every invocation, `crapkit hook-precommit` included, and the hook is
120
+ measured in what a developer waits for at every `git commit`.
121
+ """
122
+
123
+ def __init__(self, option_strings, dest, **kwargs):
124
+ super().__init__(option_strings, dest, nargs=0, **kwargs)
125
+
126
+ def __call__(self, parser, namespace, values, option_string=None):
127
+ print(_version_line())
128
+ parser.exit()
129
+
130
+
131
+ def build_parser() -> argparse.ArgumentParser:
132
+ """The whole CLI surface, assembled without parsing anything. README's
133
+ Subcommands table is checked against this parser's own subcommand set."""
134
+ parser = argparse.ArgumentParser(prog="crapkit")
135
+ parser.add_argument("--version", action=_VersionAction, default=argparse.SUPPRESS,
136
+ help="print the program name and its version")
137
+ sub = parser.add_subparsers(dest="command", required=True)
138
+
139
+ inv = sub.add_parser("inventory", help="build the per-function complexity inventory snapshot")
140
+ inv.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
141
+ inv.add_argument("--db", default=None, help="snapshot database path (default: <repo>/.crapkit/crap.sqlite)")
142
+ inv.add_argument("--export", default=None, help="also write a canonical TSV export, relative to the repo")
143
+ inv.add_argument("--json", action="store_true", help="print the run summary as JSON")
144
+ inv.set_defaults(func=_Handler("scoring", "cmd_inventory"))
145
+
146
+ cov = sub.add_parser("coverage", help="run coverage lanes, join onto a fresh inventory, write a scored run")
147
+ cov.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
148
+ cov.add_argument("--lane", default=None, help="run only this lane (default: all)")
149
+ cov.add_argument("--reuse-artifacts", action="store_true", help="skip lane commands, parse existing artifacts")
150
+ cov.add_argument("--reuse-unchanged", action="store_true",
151
+ help="rerun only lanes whose scope files changed since their artifact; reuse the rest")
152
+ cov.add_argument("--export", default=None, help="write scored TSV export, relative to the repo")
153
+ cov.add_argument("--sarif", default=None, metavar="PATH",
154
+ help="write over-target findings as SARIF 2.1.0, relative to the repo")
155
+ cov.add_argument("--github", action="store_true",
156
+ help="print findings as GitHub workflow-command annotations")
157
+ cov.add_argument("--json", action="store_true", help="print the run summary as JSON")
158
+ cov.set_defaults(func=_Handler("scoring", "cmd_coverage"))
159
+
160
+ nxt = sub.add_parser("next-item", help="the actionable queue as JSON, ranked by crap "
161
+ "descending: what a refactor session takes next")
162
+ nxt.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
163
+ nxt.add_argument("--top", type=int, default=1, help="return the next N items instead of one")
164
+ nxt.add_argument("--exclude", action="append", default=[],
165
+ help="skip items whose path or function name contains this (repeatable)")
166
+ nxt.add_argument("--scope", action="append", default=[], metavar="NAME",
167
+ help="restrict to this configured scope (repeatable); exact, not substring")
168
+ nxt.add_argument("--claim", action="store_true",
169
+ help="hold the items handed out so another session skips them; "
170
+ "verify releases a claim once the function is at its ceiling, "
171
+ "and `crapkit claims release PATH NAME` hands one back by hand")
172
+ nxt.set_defaults(func=_Handler("queue", "cmd_next_item"))
173
+
174
+ clm = sub.add_parser("claims", help="the claims sessions hold, and the release that hands one back")
175
+ clm.add_argument("action", nargs="?", default="list", choices=("list", "release"),
176
+ help="list (default): every open claim; release: close one (PATH NAME) "
177
+ "or, with --all, every one")
178
+ clm.add_argument("target", nargs="*", metavar="ARG",
179
+ help="release: PATH NAME, taking either the bare identifier or the "
180
+ "long_name next-item printed")
181
+ clm.add_argument("--all", action="store_true", help="release: close every open claim")
182
+ clm.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
183
+ clm.add_argument("--json", action="store_true", help="machine output")
184
+ clm.set_defaults(func=_Handler("queue", "cmd_claims"))
185
+
186
+ runs_p = sub.add_parser("runs", help="run history, and the retention command that trims it")
187
+ runs_p.add_argument("action", nargs="?", default="list", choices=("list", "prune"),
188
+ help="list (default): id, kind, verdict, commit, lane set, and "
189
+ "`baseline` on the run verify compares against (verdict=- is "
190
+ "a run that renders no verdict); prune: delete runs outside "
191
+ "the keep-set, then VACUUM")
192
+ runs_p.add_argument("--keep", type=int, default=5,
193
+ help="prune: newest trusted runs to keep (default 5). A floor, not "
194
+ "a cap — the digest pair, passing verify baselines, runs an "
195
+ "override names and the newest non-hook run are kept too")
196
+ runs_p.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
197
+ runs_p.add_argument("--json", action="store_true", help="machine output")
198
+ runs_p.set_defaults(func=_Handler("reports", "cmd_runs"))
199
+
200
+ ovr = sub.add_parser("overrides", help="the override audit trail")
201
+ ovr.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
202
+ ovr.add_argument("--json", action="store_true", help="machine output")
203
+ ovr.set_defaults(func=_Handler("reports", "cmd_overrides"))
204
+
205
+ expl = sub.add_parser("explain", help="a function's trajectory across runs, plus its ratchet mark")
206
+ expl.add_argument("path", help="repo-relative source file")
207
+ expl.add_argument("name", help="function name or fragment")
208
+ expl.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
209
+ expl.add_argument("--history", action="store_true",
210
+ help="also list the commits that touched this function (git log -L)")
211
+ expl.add_argument("--tests", action="store_true",
212
+ help="also list the tests covering this function (coverage.py contexts)")
213
+ expl.add_argument("--json", action="store_true", help="machine output")
214
+ expl.set_defaults(func=_Handler("reports", "cmd_explain"))
215
+
216
+ brf = sub.add_parser("brief", help="one function's whole start-editing packet: score, "
217
+ "source, the rest of its file, gate rule, lane, mark, "
218
+ "dark lines, twins, churn, coupling, commands")
219
+ brf.add_argument("path", nargs="?", help="repo-relative source file")
220
+ brf.add_argument("name", nargs="?",
221
+ help="function name: the bare identifier, the whole long_name "
222
+ "next-item printed, or the line it starts on")
223
+ brf.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
224
+ brf.add_argument("--batch", type=int, default=None, metavar="N",
225
+ help="skip PATH NAME and emit a packet for each of the top N "
226
+ "queue items, assembled in one process; always JSON")
227
+ brf.add_argument("--json", action="store_true", help="machine output (default: a short summary)")
228
+ brf.set_defaults(func=_Handler("queue", "cmd_brief"))
229
+
230
+ rsc = sub.add_parser("rescore", help="fresh complexity for named files overlaid on the latest run's coverage")
231
+ rsc.add_argument("files", nargs="+", help="repo-relative source files to re-analyze")
232
+ rsc.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
233
+ rsc.add_argument("--json", action="store_true", help="machine output (default: table)")
234
+ rsc.add_argument("--gate", action="store_true",
235
+ help="exit 6 when a function this tree changed since HEAD is over its "
236
+ "scope ceiling: the pre-commit hook's ccn-only policy on the hook's "
237
+ "own selection, minus functions a ratchet mark already covers")
238
+ rsc.set_defaults(func=_Handler("scoring", "cmd_rescore"))
239
+
240
+ dig = sub.add_parser("digest", help="delta between the last two scored runs; silent when unchanged")
241
+ dig.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
242
+ dig.add_argument("--alert", action="store_true", help="pipe a non-quiet digest through alert_command")
243
+ dig.set_defaults(func=_Handler("reports", "cmd_digest"))
244
+
245
+ trd = sub.add_parser("trend", help="totals per scored run: over-target count, CRAP load, average")
246
+ trd.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
247
+ trd.add_argument("--json", action="store_true", help="print as JSON")
248
+ trd.set_defaults(func=_Handler("reports", "cmd_trend"))
249
+
250
+ tsc = sub.add_parser("test-scoped", help="run the configured isolated test command for the files' scope")
251
+ tsc.add_argument("files", nargs="+", help="repo-relative test files")
252
+ tsc.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
253
+ tsc.set_defaults(func=_Handler("verifying", "cmd_test_scoped"))
254
+
255
+ ver = sub.add_parser("verify", help="full verdict vs the baseline snapshot: gate, ratchet, new failures")
256
+ ver.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
257
+ # one baseline, named one way: two of these would leave the losing flag
258
+ # silently ignored, and which one lost would be argument order
259
+ picked = ver.add_mutually_exclusive_group()
260
+ picked.add_argument("--baseline", type=int, default=None, help="baseline run id (default: latest scored run)")
261
+ picked.add_argument("--base", default=None, metavar="REF",
262
+ help="measure the diff from merge-base(REF, HEAD); the baseline run must "
263
+ "then sit at or behind that fork point")
264
+ picked.add_argument("--baseline-tsv", default=None, metavar="PATH",
265
+ help="read the baseline from a TSV written by --emit-baseline, not the store")
266
+ ver.add_argument("--emit-baseline", default=None, metavar="PATH",
267
+ help="also write the baseline run as a portable TSV, relative to the repo")
268
+ ver.add_argument("--reuse-artifacts", action="store_true", help="skip lane commands, parse existing artifacts")
269
+ ver.add_argument("--reuse-unchanged", action="store_true",
270
+ help="rerun only lanes whose scope files changed since their artifact; reuse the rest")
271
+ ver.add_argument("--override", default=None, metavar="REASON",
272
+ help="audited exemption for gate violations: alert + ratchet debt + snapshot record")
273
+ ver.add_argument("--sarif", default=None, metavar="PATH",
274
+ help="write gate/ratchet findings as SARIF 2.1.0, relative to the repo")
275
+ ver.add_argument("--github", action="store_true",
276
+ help="print findings as GitHub workflow-command annotations")
277
+ ver.add_argument("--json", action="store_true", help="print the verdict as JSON")
278
+ ver.set_defaults(func=_Handler("verifying", "cmd_verify"))
279
+
280
+ hook = sub.add_parser("hook-precommit", help="gate staged functions at min-CCN <= target; exit 6 on violation")
281
+ hook.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
282
+ hook.set_defaults(func=_Handler("verifying", "cmd_hook_precommit"))
283
+
284
+ wl = sub.add_parser("worklist", help="ranked risk map: every admitted function, "
285
+ "finished and no-lane rows included, so it never empties")
286
+ wl.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
287
+ wl.add_argument("--top", type=int, default=None, help="cap the active list (default: config worklist_top)")
288
+ wl.add_argument("--scope", action="append", default=[], metavar="NAME",
289
+ help="restrict to this configured scope (repeatable); exact, not substring")
290
+ wl.add_argument("--batches", type=int, default=None, metavar="N",
291
+ help="split the active list into at most N batches with no shared "
292
+ "files, co-changing files kept together: one per agent session")
293
+ wl.add_argument("--json", action="store_true", help="print as JSON")
294
+ wl.set_defaults(func=_Handler("queue", "cmd_worklist"))
295
+
296
+ ini = sub.add_parser("init", help="sniff the repo and write a starter crapkit.toml")
297
+ ini.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
298
+ ini.set_defaults(func=_Handler("admin", "cmd_init"))
299
+
300
+ doc = sub.add_parser("doctor", help="check that crapkit.toml still describes this repo")
301
+ doc.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
302
+ doc.add_argument("--show-files", action="store_true", help="list every file each scope matched")
303
+ doc.add_argument("--json", action="store_true",
304
+ help="one machine-readable report instead of lines: versions, store, "
305
+ "newest run, per-lane artifact stamps, problems, warnings")
306
+ doc.add_argument("--tune", action="store_true",
307
+ help="print suggested [crapkit] parallelism knobs for this machine from "
308
+ "cpu count and recorded lane durations; writes nothing")
309
+ doc.set_defaults(func=_Handler("admin", "cmd_doctor"))
310
+
311
+ rat = sub.add_parser("ratchet", help="manage the committed marks file: seed new debt, prune gone code")
312
+ rat.add_argument("action", choices=("seed", "prune", "merge", "move", "report"),
313
+ help="seed: mark over-target functions from the latest run; "
314
+ "prune: drop marks whose functions left the codebase "
315
+ "(a mark whose file git renamed follows it instead); "
316
+ "merge: 3-way git merge driver (BASE OURS THEIRS); "
317
+ "move: re-path marks at their recorded values (OLD NEW); "
318
+ "report: burn-down from the marks file's git history")
319
+ rat.add_argument("files", nargs="*", metavar="FILE",
320
+ help="for merge: the three files git passes as %%O %%A %%B; "
321
+ "for move: OLD NEW, where a trailing '/' on OLD moves a directory")
322
+ rat.add_argument("--json", action="store_true", help="machine output (report)")
323
+ rat.add_argument("--enforce", action="store_true",
324
+ help="report: exit 1 on debt policy violations (age, repayment quota)")
325
+ rat.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
326
+ rat.set_defaults(func=_Handler("ratchet_cmds", "cmd_ratchet"))
327
+
328
+ wat = sub.add_parser("watch", help="rescore files as they change (polls tracked files from start)")
329
+ wat.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
330
+ wat.add_argument("--interval", type=float, default=2.0, help="poll seconds (default 2)")
331
+ wat.add_argument("--cycles", type=int, default=None,
332
+ help="stop after N polls (default: poll until ctrl-c)")
333
+ wat.set_defaults(func=_Handler("admin", "cmd_watch"))
334
+
335
+ srv = sub.add_parser("mcp", help="stdio MCP server exposing the read-side tools (JSON-RPC, no deps)")
336
+ srv.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
337
+ srv.set_defaults(func=_Handler("analyses", "cmd_mcp"))
338
+
339
+ mut = sub.add_parser("mutate", help="diff-scoped mutation testing: flip operators on changed lines, run the suite per mutant")
340
+ mut.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
341
+ mut.add_argument("--files", nargs="*", default=None,
342
+ help="mutate these whole files instead of the working-tree diff vs HEAD")
343
+ mut.add_argument("--max-mutants", type=int, default=100, help="hard cap per run (default 100)")
344
+ mut.add_argument("--json", action="store_true", help="machine output")
345
+ mut.set_defaults(func=_Handler("analyses", "cmd_mutate"))
346
+
347
+ dup = sub.add_parser("duplication", help="near-duplicate functions by normalized line shingles")
348
+ dup.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
349
+ dup.add_argument("--min-lines", type=int, default=8, help="smallest function considered (default 8)")
350
+ dup.add_argument("--similarity", type=float, default=0.8,
351
+ help="containment threshold, shared/smaller (default 0.8)")
352
+ dup.add_argument("--top", type=int, default=50, help="cap the pair list (default 50)")
353
+ dup.add_argument("--json", action="store_true", help="machine output")
354
+ dup.set_defaults(func=_Handler("analyses", "cmd_duplication"))
355
+
356
+ cpl = sub.add_parser("coupling", help="files that co-change in the churn window: hidden dependencies")
357
+ cpl.add_argument("--repo", default=".", help="consuming repo root (default: cwd)")
358
+ cpl.add_argument("--min-support", type=int, default=5, help="minimum shared commits (default 5)")
359
+ cpl.add_argument("--min-confidence", type=float, default=0.5,
360
+ help="minimum max-direction co-change ratio (default 0.5)")
361
+ cpl.add_argument("--top", type=int, default=50, help="cap the pair list (default 50)")
362
+ cpl.add_argument("--json", action="store_true", help="machine output")
363
+ cpl.set_defaults(func=_Handler("analyses", "cmd_coupling"))
364
+ return parser
365
+
366
+
367
+ def main(argv: list[str] | None = None) -> int:
368
+ # Piped output (git hooks, CI, agents) reads as UTF-8 everywhere modern;
369
+ # Windows hands pipes the legacy codepage instead, which renders as mojibake.
370
+ # A tty keeps its native encoding. Either way errors degrade to '?' — an
371
+ # exotic console must never turn an exit code into a traceback.
372
+ for stream in (sys.stdout, sys.stderr):
373
+ if not hasattr(stream, "reconfigure"):
374
+ continue
375
+ if stream.isatty():
376
+ stream.reconfigure(errors="replace")
377
+ else:
378
+ stream.reconfigure(encoding="utf-8", errors="replace")
379
+ args = build_parser().parse_args(argv)
380
+ try:
381
+ return args.func(args)
382
+ except CrapkitError as exc:
383
+ print(f"crapkit: {exc}", file=sys.stderr)
384
+ return exc.exit_code