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
crapkit/cli/admin.py ADDED
@@ -0,0 +1,650 @@
1
+ """The setup and upkeep commands: `init` (sniff the repo, write a starter
2
+ crapkit.toml and extend .gitignore), `doctor` (does crapkit.toml still describe
3
+ this repo: keys, scopes, lanes, tools, unmeasured directories, plus the --json
4
+ report and the --tune knob advice) and `watch` (poll tracked files and rescore
5
+ what moved)."""
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import sys
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from .. import __version__
14
+ from ..config import load_config_text
15
+ from ..doctor import Finding
16
+ from ..errors import ConfigError, ToolError
17
+ from ..gitio import ls_files
18
+ from ..store import SnapshotStore
19
+ from ..universe import assign_files, scan_files
20
+ from ._shared import _file_sizer, _load_repo_config, _print_json
21
+
22
+
23
+ def _interpreter() -> str:
24
+ """The interpreter name a committed config can call. sys.executable is this
25
+ machine's absolute path and would not survive the repo reaching anyone else."""
26
+ import shutil
27
+
28
+ return "python" if shutil.which("python") else "python3"
29
+
30
+
31
+ def _present_markers(root: Path) -> frozenset[str]:
32
+ from ..scaffold import PYTEST_MARKERS
33
+
34
+ return frozenset(name for name in PYTEST_MARKERS if (root / name).is_file())
35
+
36
+
37
+ def _package_json(root: Path) -> str:
38
+ path = root / "package.json"
39
+ return path.read_text(encoding="utf-8") if path.is_file() else ""
40
+
41
+
42
+ def _print_init_summary(scopes: dict, lanes: tuple) -> None:
43
+ print(f"wrote crapkit.toml with {len(scopes)} scope(s): {', '.join(scopes)}")
44
+ if lanes:
45
+ print(f"detected {len(lanes)} lane(s) from this repo's own files: "
46
+ f"{', '.join(lane.name for lane in lanes)} — next: run `crapkit coverage`")
47
+ return
48
+ print("next: declare a [[lane]] per coverage command (see the commented template), "
49
+ "then run `crapkit coverage`")
50
+
51
+
52
+ def _no_scopes_reason(root: Path) -> str:
53
+ """Why init found nothing to scope.
54
+
55
+ crapkit reads `git ls-files`, so source nobody added is source it cannot see
56
+ — and that is the common case on the first command a new user runs. Blaming
57
+ the directory sends them to look in the one place that is already right.
58
+ """
59
+ from ..gitio import untracked_files
60
+ from ..scaffold import source_candidates
61
+
62
+ untracked = source_candidates(untracked_files(root))
63
+ if not untracked:
64
+ return "no source files found to scope — is this the repo root?"
65
+ return ("no tracked source files to scope — crapkit scores git-tracked files only; "
66
+ f"run `git add` first ({len(untracked)} untracked source file(s) found)")
67
+
68
+
69
+ def _extend_gitignore(root: Path, lanes: tuple) -> None:
70
+ """Ignore what adopting crapkit will write: the store, and each lane's
71
+ artifact. Without this the consumer's next `git status` is a wall of
72
+ untracked coverage output nobody asked for."""
73
+ from ..scaffold import gitignore_update
74
+
75
+ path = root / ".gitignore"
76
+ current = path.read_text(encoding="utf-8") if path.is_file() else ""
77
+ text, added = gitignore_update(current, lanes)
78
+ if not added:
79
+ return
80
+ path.write_text(text, encoding="utf-8", newline="\n")
81
+ print(f"added to .gitignore: {', '.join(added)}")
82
+
83
+
84
+ def cmd_init(args: argparse.Namespace) -> int:
85
+ from ..scaffold import detect_lanes, live_lanes, sniff_scopes, starter_toml
86
+
87
+ root = Path(args.repo).resolve()
88
+ toml_path = root / "crapkit.toml"
89
+ if toml_path.is_file():
90
+ raise ConfigError(f"crapkit.toml already exists in {root} — edit it instead")
91
+ scopes = sniff_scopes(ls_files(root))
92
+ if not scopes:
93
+ raise ConfigError(_no_scopes_reason(root))
94
+ # A config whose lanes are all commented out scores every function no-lane,
95
+ # so a fresh repo cannot rank anything until somebody hand-writes a lane.
96
+ lanes = detect_lanes(_present_markers(root), _package_json(root),
97
+ interpreter=_interpreter())
98
+ text = starter_toml(scopes, lanes)
99
+ load_config_text(text) # self-check: never write a config crapkit cannot read back
100
+ toml_path.write_text(text, encoding="utf-8", newline="\n")
101
+ _print_init_summary(scopes, lanes)
102
+ _extend_gitignore(root, live_lanes(lanes, scopes))
103
+ return 0
104
+
105
+
106
+ def _unknown_key_text(unknown) -> str:
107
+ """Name the key AND the spellings its table accepts. A bare rejection makes
108
+ the reader hunt for a key list the tool never printed anywhere."""
109
+ from ..doctor import table_label, valid_keys
110
+
111
+ noun = "keys" if unknown.table else "tables"
112
+ return (f"unknown key {unknown.path} — crapkit ignores it (typo?); "
113
+ f"{table_label(unknown.table)} accepts these {noun}: "
114
+ f"{', '.join(valid_keys(unknown.table))}")
115
+
116
+
117
+ def _doctor_keys(raw: dict) -> list[Finding]:
118
+ from ..doctor import unknown_key_findings
119
+
120
+ problems = [Finding("FAIL", _unknown_key_text(u)) for u in unknown_key_findings(raw)]
121
+ return problems or [Finding("ok", "config keys all recognized")]
122
+
123
+
124
+ def _listed_files(files: list[str], show: bool) -> list[Finding]:
125
+ return [Finding("", f" {f}") for f in files] if show else []
126
+
127
+
128
+ def _doctor_scope_files(files_by_scope: dict, cfg, show_files: bool) -> list[Finding]:
129
+ out: list[Finding] = []
130
+ for scope in cfg.scopes:
131
+ files = files_by_scope.get(scope.name, [])
132
+ out.append(Finding("ok" if files else "FAIL",
133
+ f"scope {scope.name!r}: {len(files)} files"))
134
+ out += _listed_files(files, show_files)
135
+ return out
136
+
137
+
138
+ def _doctor_unclaimed(unclaimed: tuple[str, ...]) -> list[Finding]:
139
+ """Tracked source in a declared language that no scope path owns. It is
140
+ analyzed by nothing and gated by nothing, and it says so nowhere else.
141
+
142
+ The paths ride the finding itself rather than trailing it as loose lines, so
143
+ the machine report names them too.
144
+ """
145
+ if not unclaimed:
146
+ return [Finding("ok", "every tracked source file belongs to a scope")]
147
+ return [Finding("FAIL", f"{len(unclaimed)} tracked file(s) match a scope language but "
148
+ f"no scope path: {', '.join(unclaimed)} — add a [[scope]] "
149
+ "claiming them, or an [exclude] glob (docs/configuration.md)")]
150
+
151
+
152
+ def _covered_scope_names(cfg) -> set[str]:
153
+ return {s for lane in cfg.lanes for s in lane.scopes} | cfg.coverage_optional_scopes
154
+
155
+
156
+ def _uncovered_scopes(cfg) -> list[str]:
157
+ # A repo with no lanes at all is the inventory-only case the lane summary
158
+ # already notes; coverage_optional scopes never need one.
159
+ if not cfg.lanes:
160
+ return []
161
+ covered = _covered_scope_names(cfg)
162
+ return [s.name for s in cfg.scopes if s.name not in covered]
163
+
164
+
165
+ def _doctor_uncovered(cfg) -> list[Finding]:
166
+ return [Finding("FAIL", f"scope {name!r} is in no lane's scopes list — its functions "
167
+ "can only score no-lane (declare a lane, or "
168
+ "coverage_optional = true)")
169
+ for name in _uncovered_scopes(cfg)]
170
+
171
+
172
+ def _doctor_oversized(oversized: tuple[tuple[str, int], ...]) -> list[Finding]:
173
+ """Reported, never a failure: skipping the blob is what max_file_bytes asked for."""
174
+ return [Finding("note", f"{path} ({size} bytes) skipped: over max_file_bytes")
175
+ for path, size in oversized]
176
+
177
+
178
+ def _doctor_scopes(root: Path, cfg, files: list[str], show_files: bool) -> list[Finding]:
179
+ universe = scan_files(files, cfg, size_of=_file_sizer(root))
180
+ return (_doctor_scope_files(universe.by_scope, cfg, show_files)
181
+ + _doctor_unclaimed(universe.unclaimed)
182
+ + _doctor_uncovered(cfg)
183
+ + _doctor_oversized(universe.oversized))
184
+
185
+
186
+ def _lane_problem(root: Path, lane) -> str | None:
187
+ if lane.cwd and not (root / lane.cwd).is_dir():
188
+ return f"lane {lane.name!r}: cwd {lane.cwd!r} does not exist"
189
+ return None
190
+
191
+
192
+ _SCRIPT_SUFFIXES = (".py", ".mjs", ".js", ".ts", ".ps1", ".sh")
193
+
194
+
195
+ def _missing_named_script(cwd: Path, tok: str) -> bool:
196
+ if not tok.endswith(_SCRIPT_SUFFIXES) or tok.startswith("-") or "=" in tok:
197
+ return False
198
+ return not (cwd / tok).is_file()
199
+
200
+
201
+ def _lane_command_problems(root: Path, lane) -> list[str]:
202
+ """Config rot a lane would only reveal 40 minutes in: a runner that no
203
+ longer resolves, a named script that left the repo. Nothing is executed."""
204
+ import shutil
205
+
206
+ problems = []
207
+ tokens = lane.command.split()
208
+ if tokens and shutil.which(tokens[0]) is None:
209
+ problems.append(f"lane {lane.name!r}: executable {tokens[0]!r} does not resolve on PATH")
210
+ cwd = root / lane.cwd if lane.cwd else root
211
+ problems += [f"lane {lane.name!r}: command names {tok!r}, which does not exist"
212
+ for tok in tokens[1:] if _missing_named_script(cwd, tok)]
213
+ return problems
214
+
215
+
216
+ def _doctor_lane_summary(cfg) -> Finding:
217
+ if cfg.lanes:
218
+ return Finding("ok", f"{len(cfg.lanes)} lane(s) declared")
219
+ return Finding("note", "no [[lane]] declared — inventory works; coverage needs one")
220
+
221
+
222
+ def _lane_problems(root: Path, cfg) -> list[str]:
223
+ return [p for lane in cfg.lanes
224
+ for p in (_lane_problem(root, lane), *_lane_command_problems(root, lane)) if p]
225
+
226
+
227
+ def _doctor_lanes(root: Path, cfg) -> list[Finding]:
228
+ return ([Finding("FAIL", p) for p in _lane_problems(root, cfg)]
229
+ or [_doctor_lane_summary(cfg)])
230
+
231
+
232
+ def _doctor_artifact_litter(cfg) -> list[Finding]:
233
+ """WARN, never FAIL: a lane writing at the repo root still measures what it
234
+ always did. Failing here would break every consumer that adopted crapkit
235
+ before its lanes wrote under .crapkit/, over tree hygiene."""
236
+ from ..doctor import artifact_litter, scope_top_dirs
237
+
238
+ return [Finding("WARN", f"lane {item.lane!r} writes {item.path} at the repo root — "
239
+ f"point it under .crapkit/ (for example .crapkit/cov/{item.lane}/) "
240
+ "to keep the tree clean")
241
+ for item in artifact_litter(cfg.lanes, scope_top_dirs(cfg.scopes))]
242
+
243
+
244
+ def _lizard_version() -> str | None:
245
+ try:
246
+ import lizard
247
+ except ImportError:
248
+ return None
249
+ return getattr(lizard, "version", "?")
250
+
251
+
252
+ def _doctor_tools() -> list[Finding]:
253
+ version = _lizard_version()
254
+ if version is None:
255
+ return [Finding("FAIL", "lizard is not importable — pip install lizard")]
256
+ return [Finding("ok", f"lizard {version}")]
257
+
258
+
259
+ def _store_path(root: Path) -> Path:
260
+ return root / ".crapkit" / "crap.sqlite"
261
+
262
+
263
+ def _store_if_any(root: Path) -> SnapshotStore | None:
264
+ """The store, or None when this repo has never run inventory. Doctor is the
265
+ one command that must describe a repo with nothing recorded yet."""
266
+ path = _store_path(root)
267
+ return SnapshotStore(path) if path.is_file() else None
268
+
269
+
270
+ def _newest_coverage_run(store: SnapshotStore) -> dict | None:
271
+ runs = [r for r in store.list_runs() if r["kind"] == "coverage"]
272
+ return runs[-1] if runs else None
273
+
274
+
275
+ @dataclass
276
+ class _DirCount:
277
+ """One directory's share of a run: how many functions it holds, how many of
278
+ them carry a verdict other than untested, and the file stems to match on."""
279
+ functions: int = 0
280
+ others: int = 0
281
+ stems: set = field(default_factory=set)
282
+
283
+
284
+ def _dirs_from_counts(counts: list[tuple]) -> dict[str, _DirCount]:
285
+ from ..doctor import _dir_of, _stem_of
286
+
287
+ dirs: dict[str, _DirCount] = {}
288
+ for path, functions, others in counts:
289
+ entry = dirs.setdefault(_dir_of(path), _DirCount())
290
+ entry.functions += functions
291
+ entry.others += others
292
+ entry.stems.add(_stem_of(path))
293
+ return dirs
294
+
295
+
296
+ def _unmeasured_gaps(counts: list[tuple], tracked: list[str]) -> tuple:
297
+ """doctor.unmeasured_directories, fed per-path counts instead of per-row rows.
298
+
299
+ Same rule, same order, same findings: a directory qualifies when nothing in
300
+ it carries a verdict other than untested and a tracked test file names its
301
+ code. The matching itself stays doctor's, so the mirror rule has one copy.
302
+ """
303
+ from ..doctor import UnmeasuredDir, _matching_test, _test_files
304
+
305
+ test_files = _test_files(tracked)
306
+ found = []
307
+ for directory, stats in sorted(_dirs_from_counts(counts).items()):
308
+ example = _matching_test(directory, stats.stems, test_files) if not stats.others else None
309
+ if example:
310
+ found.append(UnmeasuredDir(directory, stats.functions, example))
311
+ return tuple(found)
312
+
313
+
314
+ def _doctor_unmeasured(root: Path, cfg, files: list[str]) -> list[Finding]:
315
+ """WARN, never FAIL: a directory whose functions are all untested while its
316
+ tests exist is a lane that runs without measuring the code it covers.
317
+
318
+ The store does the grouping. This used to build a hundred thousand
319
+ sixteen-field rows to read three fields off each of them, then filter the
320
+ coverage_optional scopes back out after reading them.
321
+ """
322
+ store = _store_if_any(root)
323
+ run = _newest_coverage_run(store) if store else None
324
+ if run is None:
325
+ return []
326
+ counts = store.count_by_path(run["id"], flag="untested",
327
+ skip_scopes=cfg.coverage_optional_scopes)
328
+ return [Finding("WARN", f"{g.directory}: {g.functions} function(s) all flagged untested "
329
+ f"while {g.example_test} exists — tests exist but no lane "
330
+ "measures them")
331
+ for g in _unmeasured_gaps(counts, files)]
332
+
333
+
334
+ def _hook_modes(root: Path) -> dict[str, str]:
335
+ """Index modes of the files the repo's `core.hooksPath` points at.
336
+
337
+ Empty when no hooks path is configured, when it points outside the worktree
338
+ (an absolute path is a legitimate setup, and `git ls-files` refuses it with
339
+ exit 128), and when nothing under it is tracked — the local `.git/hooks`
340
+ route commits no files, so there is no bit to be wrong.
341
+ """
342
+ from ..errors import GitError
343
+ from ..gitio import config_value, index_modes
344
+
345
+ hooks_path = config_value(root, "core.hooksPath")
346
+ if not hooks_path:
347
+ return {}
348
+ try:
349
+ return index_modes(root, hooks_path)
350
+ except GitError:
351
+ return {}
352
+
353
+
354
+ def _doctor_hook_modes(root: Path) -> list[Finding]:
355
+ """WARN, never FAIL: on Windows the bit is unreadable from the filesystem and
356
+ the hook still runs, so a Windows author must not be blocked by it. On Linux
357
+ and macOS git skips a 100644 hook without a word, which is how crapkit's own
358
+ contributor gate armed nothing."""
359
+ from ..doctor import non_executable_hooks
360
+
361
+ return [Finding("WARN", f"{path} is not executable in the index — core.hooksPath "
362
+ "is set, so Unix clones silently skip it; fix with "
363
+ f"`git update-index --chmod=+x {path}` and commit")
364
+ for path in non_executable_hooks(_hook_modes(root))]
365
+
366
+
367
+ _CG_SIGNATURE = b"CGPH"
368
+ _BLOOM_CHUNK = b"BIDX" # the changed-path Bloom filter index
369
+
370
+
371
+ def _git_dir(root: Path) -> Path:
372
+ """This repo's git directory. `.git` is a FILE in a linked worktree and in a
373
+ submodule, and its one line names the directory it stands for."""
374
+ dot = root / ".git"
375
+ if not dot.is_file():
376
+ return dot
377
+ named = dot.read_text(encoding="utf-8").partition("gitdir:")[2].strip()
378
+ return (root / named).resolve()
379
+
380
+
381
+ def _object_info_dir(root: Path) -> Path:
382
+ """Where the commit-graph lives. A linked worktree keeps its own git
383
+ directory and shares the main one's objects, which `commondir` names."""
384
+ gitdir = _git_dir(root)
385
+ common = gitdir / "commondir"
386
+ if common.is_file():
387
+ gitdir = (gitdir / common.read_text(encoding="utf-8").strip()).resolve()
388
+ return gitdir / "objects" / "info"
389
+
390
+
391
+ def _graph_files(info: Path) -> list[Path]:
392
+ """Every commit-graph layer this repo has: the single file, or the layers a
393
+ chain file names. `git maintenance` writes the chain, `gc` writes the file."""
394
+ chain = info / "commit-graphs" / "commit-graph-chain"
395
+ if not chain.is_file():
396
+ single = info / "commit-graph"
397
+ return [single] if single.is_file() else []
398
+ return [info / "commit-graphs" / f"graph-{line}.graph"
399
+ for line in chain.read_text(encoding="utf-8").split()]
400
+
401
+
402
+ def _graph_chunks(path: Path) -> frozenset | None:
403
+ """The chunk ids one commit-graph declares, or None when it declares none we
404
+ can trust. The header is signature, version, hash version, chunk count, base
405
+ count; then a 12-byte table entry per chunk plus a terminator. The chunk
406
+ bodies are never read, so this is one short read per layer.
407
+ """
408
+ try:
409
+ with path.open("rb") as fh:
410
+ head = fh.read(8)
411
+ if len(head) < 8 or head[:4] != _CG_SIGNATURE:
412
+ return None
413
+ toc = fh.read((head[6] + 1) * 12)
414
+ except OSError:
415
+ return None
416
+ return frozenset(toc[i:i + 4] for i in range(0, len(toc), 12))
417
+
418
+
419
+ def _bloomless_graphs(root: Path) -> list[Path]:
420
+ """Commit-graph layers written without changed-path Bloom filters."""
421
+ layers = [(path, _graph_chunks(path)) for path in _graph_files(_object_info_dir(root))]
422
+ return [path for path, chunks in layers if chunks and _BLOOM_CHUNK not in chunks]
423
+
424
+
425
+ def _doctor_commit_graph(root: Path) -> list[Finding]:
426
+ """WARN, never FAIL: a commit-graph carrying no changed-path Bloom filters.
427
+
428
+ Every per-file history walk crapkit makes — churn, `brief`,
429
+ `explain --history` — asks git which commits touched one path, and without
430
+ the filters git opens every tree along the way: 1,147 ms against 194 ms on
431
+ the flagship consumer's 72,470 commits. A repo with NO commit-graph is left
432
+ alone; there is no shape to fix, and git decides when a history wants one.
433
+ """
434
+ if not _bloomless_graphs(root):
435
+ return []
436
+ return [Finding("WARN", "the commit-graph carries no changed-path Bloom filters, so every "
437
+ "per-file history walk (churn, brief, explain --history) opens "
438
+ "every tree it passes — fix with `git commit-graph write "
439
+ "--reachable --changed-paths`")]
440
+
441
+
442
+ def _doctor_findings(root: Path, cfg, raw: dict, files: list[str],
443
+ show_files: bool) -> list[Finding]:
444
+ return (_doctor_keys(raw)
445
+ + _doctor_scopes(root, cfg, files, show_files)
446
+ + _doctor_lanes(root, cfg)
447
+ + _doctor_artifact_litter(cfg)
448
+ + _doctor_hook_modes(root)
449
+ + _doctor_commit_graph(root)
450
+ + _doctor_tools()
451
+ + _doctor_scoped_tests(cfg)
452
+ + _doctor_unmeasured(root, cfg, files))
453
+
454
+
455
+ def _doctor_scoped_tests(cfg) -> list[Finding]:
456
+ from ..doctor import scoped_test_gaps
457
+ return list(scoped_test_gaps(cfg.lanes, cfg.scoped_tests))
458
+
459
+
460
+ def _at_level(findings: list[Finding], level: str) -> list[str]:
461
+ return [f.text for f in findings if f.level == level]
462
+
463
+
464
+ def _version_report() -> dict:
465
+ import platform
466
+
467
+ return {"crapkit": __version__, "lizard": _lizard_version(),
468
+ "python": platform.python_version()}
469
+
470
+
471
+ def _store_report(root: Path) -> dict:
472
+ path = _store_path(root)
473
+ present = path.is_file()
474
+ return {"path": ".crapkit/crap.sqlite", "present": present,
475
+ "size_bytes": path.stat().st_size if present else 0}
476
+
477
+
478
+ def _newest_run_report(store: SnapshotStore | None) -> dict | None:
479
+ runs = store.list_runs() if store else []
480
+ if not runs:
481
+ return None
482
+ return {"id": runs[-1]["id"], "kind": runs[-1]["kind"],
483
+ "verdict_ok": runs[-1]["verdict_ok"]}
484
+
485
+
486
+ def _lane_report(root: Path, lane, stamp: dict) -> dict:
487
+ return {"artifact": lane.artifact,
488
+ "artifact_present": (root / lane.artifact).is_file(),
489
+ "commit": stamp.get("commit"),
490
+ "name": lane.name,
491
+ "seconds": stamp.get("seconds")}
492
+
493
+
494
+ def _lane_reports(root: Path, cfg) -> list[dict]:
495
+ from ..lanes import read_stamps
496
+
497
+ stamps = read_stamps(root)
498
+ return [_lane_report(root, lane, stamps.get(lane.artifact, {})) for lane in cfg.lanes]
499
+
500
+
501
+ def _doctor_report(root: Path, cfg, findings: list[Finding]) -> dict:
502
+ """Everything a wrapper needs to tell lane rot from a stale artifact without
503
+ parsing prose: versions, store, newest run, per-lane stamps, findings."""
504
+ from ..analyze import ANALYSIS_VERSION
505
+
506
+ return {"analysis_version": ANALYSIS_VERSION,
507
+ "lanes": _lane_reports(root, cfg),
508
+ "newest_run": _newest_run_report(_store_if_any(root)),
509
+ "problems": _at_level(findings, "FAIL"),
510
+ "store": _store_report(root),
511
+ "versions": _version_report(),
512
+ "warnings": _at_level(findings, "WARN")}
513
+
514
+
515
+ def _print_findings(findings: list[Finding]) -> None:
516
+ for f in findings:
517
+ print(f"{f.level:<4} {f.text}" if f.level else f.text)
518
+ problems = _at_level(findings, "FAIL")
519
+ print("doctor: no problems found" if not problems else f"doctor: {len(problems)} problem(s)")
520
+
521
+
522
+ def _emit_doctor(root: Path, cfg, findings: list[Finding], as_json: bool) -> None:
523
+ if as_json:
524
+ _print_json(_doctor_report(root, cfg, findings))
525
+ return
526
+ _print_findings(findings)
527
+
528
+
529
+ def _junit_seconds(path: Path) -> float | None:
530
+ from ..junitparse import suite_seconds
531
+
532
+ if not path.is_file():
533
+ return None
534
+ try:
535
+ return suite_seconds(path.read_text(encoding="utf-8"))
536
+ except ToolError:
537
+ return None
538
+
539
+
540
+ def _lane_seconds(root: Path, lane, stamps: dict) -> float | None:
541
+ """What this lane costs, best signal first: the duration its own run
542
+ recorded, else the wall time its junit report claims. None means this lane
543
+ has never left a cost signal on disk — which is not the same as costing 0."""
544
+ recorded = stamps.get(lane.artifact, {}).get("seconds")
545
+ if isinstance(recorded, (int, float)):
546
+ return float(recorded)
547
+ return _junit_seconds(root / lane.results_artifact) if lane.results_artifact else None
548
+
549
+
550
+ def _lane_durations(root: Path, cfg) -> tuple[float, ...]:
551
+ from ..lanes import read_stamps
552
+
553
+ stamps = read_stamps(root)
554
+ measured = [_lane_seconds(root, lane, stamps) for lane in cfg.lanes]
555
+ return tuple(s for s in measured if s is not None)
556
+
557
+
558
+ def _doctor_tune(root: Path, cfg) -> int:
559
+ """Advisory only: knob lines from this machine's cpu count and whatever lane
560
+ durations are already on disk. Nothing is written and nothing is executed."""
561
+ import os
562
+
563
+ from ..doctor import suggest_knobs, tune_lines
564
+
565
+ cpus = os.cpu_count() or 1
566
+ knobs = suggest_knobs(cpus=cpus, lanes=len(cfg.lanes))
567
+ for line in tune_lines(cpus=cpus, knobs=knobs, durations=_lane_durations(root, cfg)):
568
+ print(line)
569
+ return 0
570
+
571
+
572
+ def cmd_doctor(args: argparse.Namespace) -> int:
573
+ import tomllib
574
+
575
+ root = Path(args.repo).resolve()
576
+ cfg = _load_repo_config(root) # a config that does not parse already exits 3 here
577
+ if args.tune:
578
+ return _doctor_tune(root, cfg)
579
+ raw = tomllib.loads((root / "crapkit.toml").read_text(encoding="utf-8"))
580
+ findings = _doctor_findings(root, cfg, raw, ls_files(root), args.show_files)
581
+ _emit_doctor(root, cfg, findings, args.json)
582
+ return 1 if _at_level(findings, "FAIL") else 0
583
+
584
+
585
+ def _watch_rescore(root: Path, moved: list[str]) -> None:
586
+ import subprocess
587
+
588
+ present = [f for f in moved if (root / f).is_file()]
589
+ if not present:
590
+ return
591
+ # flush: watch output exists to be tailed live; a block-buffered pipe sits silent
592
+ print(f"--- changed: {', '.join(moved)}", flush=True)
593
+ # a subprocess so a half-saved syntax error can never kill the watcher
594
+ subprocess.run([sys.executable, "-m", "crapkit", "rescore", *present, "--repo", str(root)])
595
+
596
+
597
+ def _watched_files(root: Path, cfg) -> list[str]:
598
+ """Every tracked file a scope claims, flat — the whole subject of one poll."""
599
+ by_scope = assign_files(ls_files(root), cfg, size_of=_file_sizer(root))
600
+ return [f for files in by_scope.values() for f in files]
601
+
602
+
603
+ def _watch_cycles(cycles: int | None):
604
+ """The poll counter: `cycles` polls, or an endless one when nothing bounds it.
605
+
606
+ Unbounded is the default, because a watcher an operator starts is meant to
607
+ outlive the shell it was typed into. A bound is what lets the loop be driven
608
+ to a known end — by a test, or by a caller that wants one sweep and its exit
609
+ code rather than a process to kill.
610
+ """
611
+ from itertools import count
612
+
613
+ return count() if cycles is None else range(cycles)
614
+
615
+
616
+ def _watch_banner(watched: int, interval: float, cycles: int | None) -> str:
617
+ """The first line, naming how this run ends. Telling an operator to press
618
+ ctrl-c on a `--cycles 3` run describes a loop that is not the one running."""
619
+ stop = "ctrl-c to stop" if cycles is None else f"{cycles} poll(s) then stop"
620
+ return f"watching {watched} tracked files every {interval}s — {stop}"
621
+
622
+
623
+ def _watch_cycle(root: Path, files: list[str], prev: dict[str, float],
624
+ interval: float) -> dict[str, float]:
625
+ """One poll: wait, re-stat, rescore whatever moved; the new snapshot out."""
626
+ import time
627
+
628
+ from ..watch import changed_paths, snapshot_mtimes
629
+
630
+ time.sleep(interval)
631
+ cur = snapshot_mtimes(root, files)
632
+ moved = changed_paths(prev, cur)
633
+ if moved:
634
+ _watch_rescore(root, moved)
635
+ return cur
636
+
637
+
638
+ def cmd_watch(args: argparse.Namespace) -> int:
639
+ from ..watch import snapshot_mtimes
640
+
641
+ root = Path(args.repo).resolve()
642
+ files = _watched_files(root, _load_repo_config(root))
643
+ prev = snapshot_mtimes(root, files)
644
+ print(_watch_banner(len(prev), args.interval, args.cycles), flush=True)
645
+ try:
646
+ for _ in _watch_cycles(args.cycles):
647
+ prev = _watch_cycle(root, files, prev, args.interval)
648
+ except KeyboardInterrupt:
649
+ pass
650
+ return 0