cctally 1.6.3 → 1.7.0
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.
- package/CHANGELOG.md +15 -0
- package/bin/_lib_doctor.py +903 -0
- package/bin/_lib_share.py +350 -32
- package/bin/_lib_share_templates.py +233 -44
- package/bin/cctally +835 -52
- package/dashboard/static/assets/index-BgpoazlS.js +18 -0
- package/dashboard/static/assets/index-nJdUaGys.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Z6V0XgqK.js +0 -18
- package/dashboard/static/assets/index-ZPC0pk-h.css +0 -1
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
"""Pure-function kernel for `cctally doctor`.
|
|
2
|
+
|
|
3
|
+
Module boundary: bin/cctally imports _lib_doctor — never the reverse.
|
|
4
|
+
Per the spec (docs/superpowers/specs/2026-05-13-doctor-design.md §7.1),
|
|
5
|
+
all I/O happens in `doctor_gather_state()` in bin/cctally; this
|
|
6
|
+
module operates on already-gathered DoctorState dataclasses and is
|
|
7
|
+
deterministic given its input.
|
|
8
|
+
|
|
9
|
+
User-facing reference: docs/commands/doctor.md.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import pathlib
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass
|
|
24
|
+
class DoctorState:
|
|
25
|
+
"""All on-disk / in-DB inputs needed to run checks. Built by
|
|
26
|
+
`doctor_gather_state()` in bin/cctally. Each field is independently
|
|
27
|
+
optional so a failed read in one corner degrades the dependent
|
|
28
|
+
check(s) without killing the rest of the report."""
|
|
29
|
+
# Install
|
|
30
|
+
symlink_state: Optional[list[tuple[str, str]]]
|
|
31
|
+
path_includes_local_bin: Optional[bool]
|
|
32
|
+
legacy_snippet: Optional[tuple[pathlib.Path, list[int]]]
|
|
33
|
+
legacy_bespoke: Optional[dict]
|
|
34
|
+
# Hooks
|
|
35
|
+
# claude_settings is populated by doctor_gather_state() (Task 13) via
|
|
36
|
+
# `_load_claude_settings()`; spec §7.1 includes it in the contract.
|
|
37
|
+
# The kernel does not currently consume it — it feeds verbose render
|
|
38
|
+
# and a future evaluator that walks the parsed settings tree.
|
|
39
|
+
claude_settings: Optional[dict]
|
|
40
|
+
hook_counts: Optional[dict[str, int]]
|
|
41
|
+
log_activity_24h: Optional[dict]
|
|
42
|
+
# Auth
|
|
43
|
+
oauth_token_present: Optional[bool]
|
|
44
|
+
# DB
|
|
45
|
+
stats_db_status: Optional[dict]
|
|
46
|
+
cache_db_status: Optional[dict]
|
|
47
|
+
# Data
|
|
48
|
+
latest_snapshot_at: Optional[dt.datetime]
|
|
49
|
+
cache_entries_count: Optional[int]
|
|
50
|
+
cache_last_entry_at: Optional[dt.datetime]
|
|
51
|
+
claude_jsonl_present: bool
|
|
52
|
+
codex_entries_count: Optional[int]
|
|
53
|
+
codex_last_entry_at: Optional[dt.datetime]
|
|
54
|
+
codex_jsonl_present: bool
|
|
55
|
+
# Safety
|
|
56
|
+
dashboard_bind_stored: str
|
|
57
|
+
runtime_bind: Optional[str]
|
|
58
|
+
config_json_error: Optional[str]
|
|
59
|
+
update_state: Optional[dict]
|
|
60
|
+
update_state_error: Optional[str]
|
|
61
|
+
update_suppress: Optional[dict]
|
|
62
|
+
update_suppress_error: Optional[str]
|
|
63
|
+
# Precomputed by `doctor_gather_state` via the same predicate that
|
|
64
|
+
# gates the update banner (`_compute_effective_update_available`).
|
|
65
|
+
# Keeps the kernel free of release-version knowledge while staying
|
|
66
|
+
# in lockstep with the banner: doctor must never warn about an
|
|
67
|
+
# update the user has skipped or deferred.
|
|
68
|
+
effective_update_available: Optional[bool]
|
|
69
|
+
effective_update_reason: Optional[str]
|
|
70
|
+
# Meta
|
|
71
|
+
now_utc: dt.datetime
|
|
72
|
+
cctally_version: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclasses.dataclass(frozen=True)
|
|
76
|
+
class CheckResult:
|
|
77
|
+
id: str
|
|
78
|
+
title: str
|
|
79
|
+
severity: str # "ok" | "warn" | "fail"
|
|
80
|
+
summary: str
|
|
81
|
+
remediation: Optional[str]
|
|
82
|
+
details: dict
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclasses.dataclass(frozen=True)
|
|
86
|
+
class CategoryResult:
|
|
87
|
+
id: str
|
|
88
|
+
title: str
|
|
89
|
+
severity: str
|
|
90
|
+
checks: tuple[CheckResult, ...]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclasses.dataclass(frozen=True)
|
|
94
|
+
class DoctorReport:
|
|
95
|
+
schema_version: int
|
|
96
|
+
generated_at: dt.datetime
|
|
97
|
+
cctally_version: str
|
|
98
|
+
overall_severity: str
|
|
99
|
+
counts: dict[str, int]
|
|
100
|
+
categories: tuple[CategoryResult, ...]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
SCHEMA_VERSION = 1
|
|
104
|
+
SEVERITY_ORDER = ("ok", "warn", "fail")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _max_severity(severities: list[str]) -> str:
|
|
108
|
+
"""Return the highest severity in the list per SEVERITY_ORDER ordering."""
|
|
109
|
+
if not severities:
|
|
110
|
+
return "ok"
|
|
111
|
+
return max(severities, key=SEVERITY_ORDER.index)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _check_install_symlinks(s: DoctorState) -> CheckResult:
|
|
115
|
+
if s.symlink_state is None:
|
|
116
|
+
return CheckResult(
|
|
117
|
+
id="install.symlinks", title="Symlinks",
|
|
118
|
+
severity="fail", summary="state unavailable",
|
|
119
|
+
remediation="See logs", details={"reason": "gather returned None"},
|
|
120
|
+
)
|
|
121
|
+
total = len(s.symlink_state)
|
|
122
|
+
missing = [n for n, st in s.symlink_state if st != "ok"]
|
|
123
|
+
ok_count = total - len(missing)
|
|
124
|
+
if not missing:
|
|
125
|
+
return CheckResult(
|
|
126
|
+
id="install.symlinks", title="Symlinks",
|
|
127
|
+
severity="ok", summary=f"{ok_count}/{total} present",
|
|
128
|
+
remediation=None,
|
|
129
|
+
details={"present": ok_count, "total": total, "missing": []},
|
|
130
|
+
)
|
|
131
|
+
return CheckResult(
|
|
132
|
+
id="install.symlinks", title="Symlinks",
|
|
133
|
+
severity="warn",
|
|
134
|
+
summary=f"{ok_count}/{total} present; missing {', '.join(missing)}",
|
|
135
|
+
remediation="Run `cctally setup`",
|
|
136
|
+
details={"present": ok_count, "total": total, "missing": missing},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_install_path(s: DoctorState) -> CheckResult:
|
|
141
|
+
if s.path_includes_local_bin:
|
|
142
|
+
return CheckResult(
|
|
143
|
+
id="install.path", title="PATH",
|
|
144
|
+
severity="ok", summary="~/.local/bin on $PATH",
|
|
145
|
+
remediation=None, details={},
|
|
146
|
+
)
|
|
147
|
+
return CheckResult(
|
|
148
|
+
id="install.path", title="PATH",
|
|
149
|
+
severity="warn", summary="~/.local/bin not on $PATH",
|
|
150
|
+
remediation="Append `export PATH=\"$HOME/.local/bin:$PATH\"` to your shell rc",
|
|
151
|
+
details={},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _check_install_legacy_snippet(s: DoctorState) -> CheckResult:
|
|
156
|
+
if s.legacy_snippet is None:
|
|
157
|
+
return CheckResult(
|
|
158
|
+
id="install.legacy_snippet", title="Legacy status-line snippet",
|
|
159
|
+
severity="ok", summary="not detected",
|
|
160
|
+
remediation=None, details={},
|
|
161
|
+
)
|
|
162
|
+
path, lines = s.legacy_snippet
|
|
163
|
+
location = f"{path}:{lines[0]}" if lines else str(path)
|
|
164
|
+
return CheckResult(
|
|
165
|
+
id="install.legacy_snippet", title="Legacy status-line snippet",
|
|
166
|
+
severity="warn", summary=f"detected at {location}",
|
|
167
|
+
remediation=f"Edit {path} to remove the cctally status-line snippet",
|
|
168
|
+
details={"path": str(path), "line_numbers": list(lines)},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_install_legacy_bespoke(s: DoctorState) -> CheckResult:
|
|
173
|
+
info = s.legacy_bespoke or {"detected": False, "settings_entries": [], "files": []}
|
|
174
|
+
if not info.get("detected"):
|
|
175
|
+
return CheckResult(
|
|
176
|
+
id="install.legacy_bespoke_hooks", title="Legacy bespoke hooks",
|
|
177
|
+
severity="ok", summary="not detected",
|
|
178
|
+
remediation=None, details={},
|
|
179
|
+
)
|
|
180
|
+
n_entries = len(info.get("settings_entries") or [])
|
|
181
|
+
n_files = len(info.get("files") or [])
|
|
182
|
+
return CheckResult(
|
|
183
|
+
id="install.legacy_bespoke_hooks", title="Legacy bespoke hooks",
|
|
184
|
+
severity="warn",
|
|
185
|
+
summary=f"detected ({n_entries} entries, {n_files} files)",
|
|
186
|
+
remediation="Run `cctally setup --migrate-legacy-hooks`",
|
|
187
|
+
details={"entries": n_entries, "files": n_files},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
_REQUIRED_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_hooks_installed(s: DoctorState) -> CheckResult:
|
|
195
|
+
counts = s.hook_counts or {}
|
|
196
|
+
missing = [ev for ev in _REQUIRED_HOOK_EVENTS if counts.get(ev, 0) < 1]
|
|
197
|
+
if not missing:
|
|
198
|
+
return CheckResult(
|
|
199
|
+
id="hooks.installed", title="Hook entries installed",
|
|
200
|
+
severity="ok",
|
|
201
|
+
summary=", ".join(_REQUIRED_HOOK_EVENTS),
|
|
202
|
+
remediation=None,
|
|
203
|
+
details={"counts": {ev: counts.get(ev, 0) for ev in _REQUIRED_HOOK_EVENTS}},
|
|
204
|
+
)
|
|
205
|
+
return CheckResult(
|
|
206
|
+
id="hooks.installed", title="Hook entries installed",
|
|
207
|
+
severity="warn",
|
|
208
|
+
summary=f"missing {', '.join(missing)}",
|
|
209
|
+
remediation="Run `cctally setup`",
|
|
210
|
+
details={
|
|
211
|
+
"counts": {ev: counts.get(ev, 0) for ev in _REQUIRED_HOOK_EVENTS},
|
|
212
|
+
"missing": missing,
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _check_hooks_recent_activity_24h(s: DoctorState) -> CheckResult:
|
|
218
|
+
act = s.log_activity_24h or {"fires": 0, "errors": 0,
|
|
219
|
+
"by_event": {}, "last_fire_ago_s": None,
|
|
220
|
+
"oauth_ok": 0, "throttled": 0}
|
|
221
|
+
fires = act.get("fires") or 0
|
|
222
|
+
errors = act.get("errors") or 0
|
|
223
|
+
if fires == 0:
|
|
224
|
+
return CheckResult(
|
|
225
|
+
id="hooks.recent_activity_24h", title="Recent activity (24h)",
|
|
226
|
+
severity="warn", summary="0 fires",
|
|
227
|
+
remediation="No hook fired in last 24h. Restart Claude Code, or run `cctally setup`.",
|
|
228
|
+
details={"fires": 0, "errors": errors, "by_event": act.get("by_event") or {},
|
|
229
|
+
"last_fire_age_s": act.get("last_fire_ago_s")},
|
|
230
|
+
)
|
|
231
|
+
ratio = errors / fires if fires else 0.0
|
|
232
|
+
if ratio >= 0.5:
|
|
233
|
+
return CheckResult(
|
|
234
|
+
id="hooks.recent_activity_24h", title="Recent activity (24h)",
|
|
235
|
+
severity="warn",
|
|
236
|
+
summary=f"high error ratio ({errors}/{fires})",
|
|
237
|
+
remediation="Check ~/.local/share/cctally/logs/hook-tick.log",
|
|
238
|
+
details={"fires": fires, "errors": errors, "ratio": ratio,
|
|
239
|
+
"by_event": act.get("by_event") or {}},
|
|
240
|
+
)
|
|
241
|
+
return CheckResult(
|
|
242
|
+
id="hooks.recent_activity_24h", title="Recent activity (24h)",
|
|
243
|
+
severity="ok",
|
|
244
|
+
summary=f"{fires} fires, {errors} errors",
|
|
245
|
+
remediation=None,
|
|
246
|
+
details={"fires": fires, "errors": errors,
|
|
247
|
+
"by_event": act.get("by_event") or {},
|
|
248
|
+
"last_fire_age_s": act.get("last_fire_ago_s")},
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _check_hooks_last_fire_age(s: DoctorState) -> CheckResult:
|
|
253
|
+
act = s.log_activity_24h or {}
|
|
254
|
+
age = act.get("last_fire_ago_s")
|
|
255
|
+
if age is None:
|
|
256
|
+
return CheckResult(
|
|
257
|
+
id="hooks.last_fire_age", title="Last hook fire",
|
|
258
|
+
severity="warn", summary="never",
|
|
259
|
+
remediation="No hook has fired yet. Restart Claude Code.",
|
|
260
|
+
details={"last_fire_age_s": None},
|
|
261
|
+
)
|
|
262
|
+
if age > 3600:
|
|
263
|
+
return CheckResult(
|
|
264
|
+
id="hooks.last_fire_age", title="Last hook fire",
|
|
265
|
+
severity="warn", summary=f"{int(age)}s ago",
|
|
266
|
+
remediation="No hook fired in >1h. Claude Code may not be running.",
|
|
267
|
+
details={"last_fire_age_s": int(age)},
|
|
268
|
+
)
|
|
269
|
+
return CheckResult(
|
|
270
|
+
id="hooks.last_fire_age", title="Last hook fire",
|
|
271
|
+
severity="ok", summary=f"{int(age)}s ago",
|
|
272
|
+
remediation=None,
|
|
273
|
+
details={"last_fire_age_s": int(age)},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _check_oauth_token_present(s: DoctorState) -> CheckResult:
|
|
278
|
+
if s.oauth_token_present:
|
|
279
|
+
return CheckResult(
|
|
280
|
+
id="oauth.token_present", title="OAuth token",
|
|
281
|
+
severity="ok", summary="present",
|
|
282
|
+
remediation=None, details={},
|
|
283
|
+
)
|
|
284
|
+
return CheckResult(
|
|
285
|
+
id="oauth.token_present", title="OAuth token",
|
|
286
|
+
severity="fail", summary="missing",
|
|
287
|
+
remediation="Log into Claude Code to populate the OAuth token",
|
|
288
|
+
details={},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _db_file_check(label_id: str, label_title: str, status: Optional[dict],
|
|
293
|
+
rebuild_hint: str) -> CheckResult:
|
|
294
|
+
if status is None:
|
|
295
|
+
return CheckResult(
|
|
296
|
+
id=label_id, title=label_title,
|
|
297
|
+
severity="fail", summary="state unavailable",
|
|
298
|
+
remediation="Re-run; see stderr",
|
|
299
|
+
details={"reason": "gather returned None"},
|
|
300
|
+
)
|
|
301
|
+
if status.get("_open_error"):
|
|
302
|
+
return CheckResult(
|
|
303
|
+
id=label_id, title=label_title,
|
|
304
|
+
severity="fail", summary=f"could not open: {status['_open_error']}",
|
|
305
|
+
remediation=rebuild_hint,
|
|
306
|
+
details={"exception": status["_open_error"], "path": status["path"]},
|
|
307
|
+
)
|
|
308
|
+
if status.get("_file_exists") is False:
|
|
309
|
+
return CheckResult(
|
|
310
|
+
id=label_id, title=label_title,
|
|
311
|
+
severity="warn", summary="absent (fresh install)",
|
|
312
|
+
remediation=None,
|
|
313
|
+
details={"path": status["path"]},
|
|
314
|
+
)
|
|
315
|
+
return CheckResult(
|
|
316
|
+
id=label_id, title=label_title,
|
|
317
|
+
severity="ok",
|
|
318
|
+
summary=f"version {status['user_version']} / {status['registry_size']} known",
|
|
319
|
+
remediation=None,
|
|
320
|
+
details={"path": status["path"],
|
|
321
|
+
"user_version": status["user_version"],
|
|
322
|
+
"registry_size": status["registry_size"]},
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _check_db_stats_file(s: DoctorState) -> CheckResult:
|
|
327
|
+
return _db_file_check("db.stats.file", "stats.db", s.stats_db_status,
|
|
328
|
+
"Restore from backup, or `cctally setup --uninstall --purge` + re-record")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _check_db_cache_file(s: DoctorState) -> CheckResult:
|
|
332
|
+
return _db_file_check("db.cache.file", "cache.db", s.cache_db_status,
|
|
333
|
+
"Run `cctally cache-sync --rebuild`")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _migrations_by_status(status: Optional[dict]) -> dict[str, list[str]]:
|
|
337
|
+
if not status:
|
|
338
|
+
return {"applied": [], "skipped": [], "pending": [], "failed": []}
|
|
339
|
+
out = {"applied": [], "skipped": [], "pending": [], "failed": []}
|
|
340
|
+
for m in status.get("migrations") or []:
|
|
341
|
+
out.setdefault(m["status"], []).append(m["name"])
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _check_db_migrations_applied(s: DoctorState) -> CheckResult:
|
|
346
|
+
both = {}
|
|
347
|
+
for db_label, st in (("stats.db", s.stats_db_status), ("cache.db", s.cache_db_status)):
|
|
348
|
+
both[db_label] = _migrations_by_status(st)
|
|
349
|
+
any_failed = any(both[d]["failed"] for d in both)
|
|
350
|
+
any_skipped = any(both[d]["skipped"] for d in both)
|
|
351
|
+
if any_failed:
|
|
352
|
+
failed = [(d, n) for d, info in both.items() for n in info["failed"]]
|
|
353
|
+
return CheckResult(
|
|
354
|
+
id="db.migrations.applied", title="Migrations",
|
|
355
|
+
severity="fail",
|
|
356
|
+
summary=f"{len(failed)} failed",
|
|
357
|
+
remediation="Run `cctally db status`; see ~/.local/share/cctally/logs/migration-errors.log",
|
|
358
|
+
details={"failed": failed, "by_db": both},
|
|
359
|
+
)
|
|
360
|
+
if any_skipped:
|
|
361
|
+
skipped = [(d, n) for d, info in both.items() for n in info["skipped"]]
|
|
362
|
+
return CheckResult(
|
|
363
|
+
id="db.migrations.applied", title="Migrations",
|
|
364
|
+
severity="warn",
|
|
365
|
+
summary=f"{len(skipped)} skipped",
|
|
366
|
+
remediation="Run `cctally db unskip <name>` if you want to retry",
|
|
367
|
+
details={"skipped": skipped, "by_db": both},
|
|
368
|
+
)
|
|
369
|
+
total_applied = sum(len(both[d]["applied"]) for d in both)
|
|
370
|
+
total_registered = ((s.stats_db_status or {}).get("registry_size", 0)
|
|
371
|
+
+ (s.cache_db_status or {}).get("registry_size", 0))
|
|
372
|
+
return CheckResult(
|
|
373
|
+
id="db.migrations.applied", title="Migrations",
|
|
374
|
+
severity="ok",
|
|
375
|
+
summary=f"{total_applied}/{total_registered} applied",
|
|
376
|
+
remediation=None,
|
|
377
|
+
details={"by_db": both},
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _check_db_migrations_pending(s: DoctorState) -> CheckResult:
|
|
382
|
+
both = {db: _migrations_by_status(st)
|
|
383
|
+
for db, st in (("stats.db", s.stats_db_status),
|
|
384
|
+
("cache.db", s.cache_db_status))}
|
|
385
|
+
pending = [(d, n) for d, info in both.items() for n in info["pending"]]
|
|
386
|
+
if not pending:
|
|
387
|
+
return CheckResult(
|
|
388
|
+
id="db.migrations.pending", title="Pending migrations",
|
|
389
|
+
severity="ok", summary="none pending",
|
|
390
|
+
remediation=None, details={},
|
|
391
|
+
)
|
|
392
|
+
return CheckResult(
|
|
393
|
+
id="db.migrations.pending", title="Pending migrations",
|
|
394
|
+
severity="warn",
|
|
395
|
+
summary=f"{len(pending)} pending",
|
|
396
|
+
remediation="Run any cctally command — opens the DB and applies pending migrations",
|
|
397
|
+
details={"pending": pending},
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _check_data_latest_snapshot_age(s: DoctorState) -> CheckResult:
|
|
402
|
+
if s.latest_snapshot_at is None:
|
|
403
|
+
return CheckResult(
|
|
404
|
+
id="data.latest_snapshot_age", title="Latest snapshot",
|
|
405
|
+
severity="fail", summary="never",
|
|
406
|
+
remediation="Check hooks are installed and Claude Code is running",
|
|
407
|
+
details={"latest_snapshot_at": None},
|
|
408
|
+
)
|
|
409
|
+
age_s = int((s.now_utc - s.latest_snapshot_at).total_seconds())
|
|
410
|
+
if age_s <= 300:
|
|
411
|
+
sev, rem = "ok", None
|
|
412
|
+
elif age_s <= 3600:
|
|
413
|
+
sev = "warn"
|
|
414
|
+
rem = "Recent but not current. Check Claude Code session is active."
|
|
415
|
+
else:
|
|
416
|
+
sev = "fail"
|
|
417
|
+
rem = "No snapshot in >1h. Hooks may be broken — check `cctally setup --status`."
|
|
418
|
+
return CheckResult(
|
|
419
|
+
id="data.latest_snapshot_age", title="Latest snapshot",
|
|
420
|
+
severity=sev, summary=f"{age_s}s ago",
|
|
421
|
+
remediation=rem,
|
|
422
|
+
details={"latest_snapshot_at": s.latest_snapshot_at.isoformat(),
|
|
423
|
+
"latest_snapshot_age_s": age_s},
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _check_data_cache_sync_state(s: DoctorState) -> CheckResult:
|
|
428
|
+
count = s.cache_entries_count or 0
|
|
429
|
+
if count == 0:
|
|
430
|
+
if s.claude_jsonl_present:
|
|
431
|
+
return CheckResult(
|
|
432
|
+
id="data.cache_sync_state", title="Claude cache",
|
|
433
|
+
severity="warn",
|
|
434
|
+
summary="0 entries despite JSONL files present",
|
|
435
|
+
remediation="Run `cctally cache-sync --rebuild`",
|
|
436
|
+
details={"entries": 0, "claude_jsonl_present": True},
|
|
437
|
+
)
|
|
438
|
+
return CheckResult(
|
|
439
|
+
id="data.cache_sync_state", title="Claude cache",
|
|
440
|
+
severity="ok", summary="0 entries (no JSONL corpus)",
|
|
441
|
+
remediation=None,
|
|
442
|
+
details={"entries": 0, "claude_jsonl_present": False},
|
|
443
|
+
)
|
|
444
|
+
if s.cache_last_entry_at is None:
|
|
445
|
+
age_s = None
|
|
446
|
+
else:
|
|
447
|
+
age_s = int((s.now_utc - s.cache_last_entry_at).total_seconds())
|
|
448
|
+
if age_s is not None and age_s > 24 * 3600:
|
|
449
|
+
return CheckResult(
|
|
450
|
+
id="data.cache_sync_state", title="Claude cache",
|
|
451
|
+
severity="warn",
|
|
452
|
+
summary=f"{count:,} entries; last sync {age_s}s ago (>24h)",
|
|
453
|
+
remediation="Run `cctally cache-sync --rebuild`",
|
|
454
|
+
details={"entries": count, "cache_last_entry_age_s": age_s},
|
|
455
|
+
)
|
|
456
|
+
return CheckResult(
|
|
457
|
+
id="data.cache_sync_state", title="Claude cache",
|
|
458
|
+
severity="ok",
|
|
459
|
+
summary=f"{count:,} entries; last sync {age_s}s ago" if age_s is not None
|
|
460
|
+
else f"{count:,} entries",
|
|
461
|
+
remediation=None,
|
|
462
|
+
details={"entries": count, "cache_last_entry_age_s": age_s},
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _check_data_codex_cache(s: DoctorState) -> CheckResult:
|
|
467
|
+
count = s.codex_entries_count or 0
|
|
468
|
+
if count == 0 and not s.codex_jsonl_present:
|
|
469
|
+
return CheckResult(
|
|
470
|
+
id="data.codex_cache", title="Codex cache",
|
|
471
|
+
severity="ok", summary="none (no ~/.codex/sessions/)",
|
|
472
|
+
remediation=None,
|
|
473
|
+
details={"entries": 0, "codex_jsonl_present": False},
|
|
474
|
+
)
|
|
475
|
+
if count == 0 and s.codex_jsonl_present:
|
|
476
|
+
return CheckResult(
|
|
477
|
+
id="data.codex_cache", title="Codex cache",
|
|
478
|
+
severity="warn",
|
|
479
|
+
summary="0 entries despite Codex JSONL files present",
|
|
480
|
+
remediation="Run `cctally cache-sync --source codex --rebuild`",
|
|
481
|
+
details={"entries": 0, "codex_jsonl_present": True},
|
|
482
|
+
)
|
|
483
|
+
if s.codex_last_entry_at is None:
|
|
484
|
+
age_s = None
|
|
485
|
+
else:
|
|
486
|
+
age_s = int((s.now_utc - s.codex_last_entry_at).total_seconds())
|
|
487
|
+
if age_s is not None and age_s > 24 * 3600:
|
|
488
|
+
return CheckResult(
|
|
489
|
+
id="data.codex_cache", title="Codex cache",
|
|
490
|
+
severity="warn",
|
|
491
|
+
summary=f"{count:,} entries; last sync {age_s}s ago (>24h)",
|
|
492
|
+
remediation="Run `cctally cache-sync --source codex --rebuild`",
|
|
493
|
+
details={"entries": count, "codex_last_entry_age_s": age_s},
|
|
494
|
+
)
|
|
495
|
+
return CheckResult(
|
|
496
|
+
id="data.codex_cache", title="Codex cache",
|
|
497
|
+
severity="ok",
|
|
498
|
+
summary=f"{count:,} entries; last sync {age_s}s ago" if age_s is not None
|
|
499
|
+
else f"{count:,} entries",
|
|
500
|
+
remediation=None,
|
|
501
|
+
details={"entries": count, "codex_last_entry_age_s": age_s},
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
_LOOPBACK_HOSTS = frozenset({"loopback", "127.0.0.1", "::1", "localhost"})
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _check_safety_dashboard_bind(s: DoctorState) -> CheckResult:
|
|
509
|
+
stored_ok = s.dashboard_bind_stored in _LOOPBACK_HOSTS
|
|
510
|
+
runtime_ok = (s.runtime_bind is None) or (s.runtime_bind in _LOOPBACK_HOSTS)
|
|
511
|
+
if stored_ok and runtime_ok:
|
|
512
|
+
suffix = f"; running: {s.runtime_bind}" if s.runtime_bind else ""
|
|
513
|
+
return CheckResult(
|
|
514
|
+
id="safety.dashboard_bind", title="Dashboard bind",
|
|
515
|
+
severity="ok",
|
|
516
|
+
summary=f"config: {s.dashboard_bind_stored}{suffix}",
|
|
517
|
+
remediation=None,
|
|
518
|
+
details={"config": s.dashboard_bind_stored,
|
|
519
|
+
"runtime_bind": s.runtime_bind},
|
|
520
|
+
)
|
|
521
|
+
notes = []
|
|
522
|
+
if not stored_ok:
|
|
523
|
+
notes.append(f"config: {s.dashboard_bind_stored}")
|
|
524
|
+
if not runtime_ok:
|
|
525
|
+
notes.append(f"running: {s.runtime_bind}")
|
|
526
|
+
rem = "Run `cctally config set dashboard.bind loopback`"
|
|
527
|
+
if not runtime_ok:
|
|
528
|
+
rem += "; restart the dashboard process if it was launched with `--host`"
|
|
529
|
+
rem += "."
|
|
530
|
+
note = ("A separate running dashboard process may have overridden via --host; "
|
|
531
|
+
"the CLI sees config only.") if s.runtime_bind is None else None
|
|
532
|
+
return CheckResult(
|
|
533
|
+
id="safety.dashboard_bind", title="Dashboard bind",
|
|
534
|
+
severity="warn", summary="; ".join(notes),
|
|
535
|
+
remediation=rem,
|
|
536
|
+
details={"config": s.dashboard_bind_stored,
|
|
537
|
+
"runtime_bind": s.runtime_bind,
|
|
538
|
+
**({"note": note} if note else {})},
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _check_safety_config_json_valid(s: DoctorState) -> CheckResult:
|
|
543
|
+
if s.config_json_error is None:
|
|
544
|
+
return CheckResult(
|
|
545
|
+
id="safety.config_json_valid", title="config.json",
|
|
546
|
+
severity="ok", summary="absent or parses cleanly",
|
|
547
|
+
remediation=None, details={},
|
|
548
|
+
)
|
|
549
|
+
return CheckResult(
|
|
550
|
+
id="safety.config_json_valid", title="config.json",
|
|
551
|
+
severity="fail",
|
|
552
|
+
summary=f"unreadable: {s.config_json_error}",
|
|
553
|
+
remediation="Fix or remove ~/.local/share/cctally/config.json",
|
|
554
|
+
details={"exception": s.config_json_error},
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# Required keys per spec §3.6 + the producer code at bin/cctally:9663-9695
|
|
559
|
+
# (_load_update_state). `_schema` is set on every write; `current_version` and
|
|
560
|
+
# `latest_version` are the two semantically-meaningful fields the banner predicate
|
|
561
|
+
# and the doctor summary line consume.
|
|
562
|
+
_UPDATE_STATE_REQUIRED_KEYS = ("current_version", "latest_version")
|
|
563
|
+
|
|
564
|
+
# Required shape per spec §3.6 + bin/cctally:9725-9753 (_load_update_suppress)
|
|
565
|
+
# default record: {"_schema": 1, "skipped_versions": [], "remind_after": None}.
|
|
566
|
+
# `remind_after` is allowed to be None per the default — only its presence and
|
|
567
|
+
# the type when non-None are validated.
|
|
568
|
+
_UPDATE_SUPPRESS_REQUIRED_KEYS = ("skipped_versions", "remind_after")
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _check_safety_update_state(s: DoctorState) -> CheckResult:
|
|
572
|
+
if s.update_state_error is not None:
|
|
573
|
+
return CheckResult(
|
|
574
|
+
id="safety.update_state", title="update-state.json",
|
|
575
|
+
severity="fail", summary=f"unreadable: {s.update_state_error}",
|
|
576
|
+
remediation="`rm ~/.local/share/cctally/update-state.json` (will be regenerated)",
|
|
577
|
+
details={"exception": s.update_state_error},
|
|
578
|
+
)
|
|
579
|
+
if s.update_state is None:
|
|
580
|
+
return CheckResult(
|
|
581
|
+
id="safety.update_state", title="update-state.json",
|
|
582
|
+
severity="warn", summary="absent (first run)",
|
|
583
|
+
remediation="Run `cctally update --check` to populate",
|
|
584
|
+
details={},
|
|
585
|
+
)
|
|
586
|
+
# Spec §3.6: WARN when known fields are missing. Both keys are needed
|
|
587
|
+
# for the version-comparison banner predicate; without them the file
|
|
588
|
+
# exists but is semantically unusable.
|
|
589
|
+
missing = [k for k in _UPDATE_STATE_REQUIRED_KEYS if k not in s.update_state]
|
|
590
|
+
if missing:
|
|
591
|
+
return CheckResult(
|
|
592
|
+
id="safety.update_state", title="update-state.json",
|
|
593
|
+
severity="warn",
|
|
594
|
+
summary=f"missing fields: {', '.join(missing)}",
|
|
595
|
+
remediation="Run `cctally update --check` to refresh",
|
|
596
|
+
details={"missing_keys": missing,
|
|
597
|
+
"current_version": s.update_state.get("current_version"),
|
|
598
|
+
"latest_version": s.update_state.get("latest_version")},
|
|
599
|
+
)
|
|
600
|
+
return CheckResult(
|
|
601
|
+
id="safety.update_state", title="update-state.json",
|
|
602
|
+
severity="ok",
|
|
603
|
+
summary=f"v{s.update_state.get('current_version', '?')}",
|
|
604
|
+
remediation=None,
|
|
605
|
+
details={"current_version": s.update_state.get("current_version"),
|
|
606
|
+
"latest_version": s.update_state.get("latest_version")},
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _check_safety_update_suppress(s: DoctorState) -> CheckResult:
|
|
611
|
+
if s.update_suppress_error is not None:
|
|
612
|
+
return CheckResult(
|
|
613
|
+
id="safety.update_suppress", title="update-suppress.json",
|
|
614
|
+
severity="fail", summary=f"unreadable: {s.update_suppress_error}",
|
|
615
|
+
remediation="`rm ~/.local/share/cctally/update-suppress.json`",
|
|
616
|
+
details={"exception": s.update_suppress_error},
|
|
617
|
+
)
|
|
618
|
+
if s.update_suppress is None:
|
|
619
|
+
return CheckResult(
|
|
620
|
+
id="safety.update_suppress", title="update-suppress.json",
|
|
621
|
+
severity="ok", summary="absent (no deferrals)",
|
|
622
|
+
remediation=None, details={},
|
|
623
|
+
)
|
|
624
|
+
# Spec §3.6: WARN on "known fields missing or unexpected types". The
|
|
625
|
+
# producer's default record (bin/cctally:9731) defines the canonical
|
|
626
|
+
# shape: {"skipped_versions": [], "remind_after": None}. Anything else
|
|
627
|
+
# — a partial dict, wrong types — means a hand-edit or older binary
|
|
628
|
+
# corrupted the file.
|
|
629
|
+
missing = [k for k in _UPDATE_SUPPRESS_REQUIRED_KEYS if k not in s.update_suppress]
|
|
630
|
+
bad_types: list[str] = []
|
|
631
|
+
if "skipped_versions" in s.update_suppress:
|
|
632
|
+
if not isinstance(s.update_suppress["skipped_versions"], list):
|
|
633
|
+
bad_types.append("skipped_versions")
|
|
634
|
+
if "remind_after" in s.update_suppress:
|
|
635
|
+
v = s.update_suppress["remind_after"]
|
|
636
|
+
# The producer (bin/cctally `_do_update_remind_later`) writes
|
|
637
|
+
# `remind_after` as a dict `{"version", "until_utc"}`; the
|
|
638
|
+
# banner predicate consumes that shape. Accept it here so a
|
|
639
|
+
# legitimate deferral doesn't render as "bad types: remind_after".
|
|
640
|
+
# `None` (default record) and the legacy scalar form (older
|
|
641
|
+
# binaries persisted a bare until-string) both stay valid.
|
|
642
|
+
if v is not None and not isinstance(v, (str, int, float, dict)):
|
|
643
|
+
bad_types.append("remind_after")
|
|
644
|
+
if missing or bad_types:
|
|
645
|
+
bits = []
|
|
646
|
+
if missing:
|
|
647
|
+
bits.append(f"missing: {', '.join(missing)}")
|
|
648
|
+
if bad_types:
|
|
649
|
+
bits.append(f"bad types: {', '.join(bad_types)}")
|
|
650
|
+
return CheckResult(
|
|
651
|
+
id="safety.update_suppress", title="update-suppress.json",
|
|
652
|
+
severity="warn",
|
|
653
|
+
summary="; ".join(bits),
|
|
654
|
+
remediation="`rm ~/.local/share/cctally/update-suppress.json` (will be regenerated)",
|
|
655
|
+
details={"missing_keys": missing, "bad_types": bad_types},
|
|
656
|
+
)
|
|
657
|
+
return CheckResult(
|
|
658
|
+
id="safety.update_suppress", title="update-suppress.json",
|
|
659
|
+
severity="ok", summary="parses cleanly",
|
|
660
|
+
remediation=None,
|
|
661
|
+
details={"skipped_versions": s.update_suppress.get("skipped_versions") or [],
|
|
662
|
+
"remind_after": s.update_suppress.get("remind_after")},
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _check_safety_update_available(s: DoctorState) -> CheckResult:
|
|
667
|
+
st = s.update_state or {}
|
|
668
|
+
cur = st.get("current_version")
|
|
669
|
+
lat = st.get("latest_version")
|
|
670
|
+
# `effective_update_available` is precomputed by the I/O layer via
|
|
671
|
+
# the same predicate the update banner uses (semver + skipped +
|
|
672
|
+
# remind_after). If the user has skipped or deferred a newer
|
|
673
|
+
# version, the banner stays silent — doctor must do the same.
|
|
674
|
+
if not s.effective_update_available:
|
|
675
|
+
details = {"current_version": cur, "latest_version": lat}
|
|
676
|
+
reason = s.effective_update_reason
|
|
677
|
+
# Surface the suppression reason only when it matters — i.e.
|
|
678
|
+
# there *is* a newer version, but the user has opted out.
|
|
679
|
+
# Preserves the byte-stable details shape for the common case
|
|
680
|
+
# (no probe yet / no newer version) while informing verbose
|
|
681
|
+
# readers when a real update is being held back.
|
|
682
|
+
if reason in ("skipped", "reminded"):
|
|
683
|
+
details["suppressed"] = True
|
|
684
|
+
details["suppression_reason"] = reason
|
|
685
|
+
return CheckResult(
|
|
686
|
+
id="safety.update_available", title="Update available",
|
|
687
|
+
severity="ok", summary="no",
|
|
688
|
+
remediation=None,
|
|
689
|
+
details=details,
|
|
690
|
+
)
|
|
691
|
+
return CheckResult(
|
|
692
|
+
id="safety.update_available", title="Update available",
|
|
693
|
+
severity="warn",
|
|
694
|
+
summary=f"v{lat} (you are on v{cur})",
|
|
695
|
+
remediation="Run `cctally update`",
|
|
696
|
+
details={"current_version": cur, "latest_version": lat},
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
# Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
|
|
701
|
+
# The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
|
|
702
|
+
# fingerprint identity-slice key (spec §5.5). When an evaluator raises,
|
|
703
|
+
# `_evaluate_one` uses this id — not the function name — so the synthesized
|
|
704
|
+
# FAIL CheckResult retains the contract id and fingerprint stays stable across
|
|
705
|
+
# success-vs-raise transitions.
|
|
706
|
+
_CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
|
|
707
|
+
("install", "Install", (
|
|
708
|
+
("install.symlinks", "_check_install_symlinks"),
|
|
709
|
+
("install.path", "_check_install_path"),
|
|
710
|
+
("install.legacy_snippet", "_check_install_legacy_snippet"),
|
|
711
|
+
("install.legacy_bespoke_hooks", "_check_install_legacy_bespoke"),
|
|
712
|
+
)),
|
|
713
|
+
("hooks", "Hooks", (
|
|
714
|
+
("hooks.installed", "_check_hooks_installed"),
|
|
715
|
+
("hooks.recent_activity_24h", "_check_hooks_recent_activity_24h"),
|
|
716
|
+
("hooks.last_fire_age", "_check_hooks_last_fire_age"),
|
|
717
|
+
)),
|
|
718
|
+
("auth", "Auth", (
|
|
719
|
+
("oauth.token_present", "_check_oauth_token_present"),
|
|
720
|
+
)),
|
|
721
|
+
("db", "Database", (
|
|
722
|
+
("db.stats.file", "_check_db_stats_file"),
|
|
723
|
+
("db.cache.file", "_check_db_cache_file"),
|
|
724
|
+
("db.migrations.applied", "_check_db_migrations_applied"),
|
|
725
|
+
("db.migrations.pending", "_check_db_migrations_pending"),
|
|
726
|
+
)),
|
|
727
|
+
("data", "Data", (
|
|
728
|
+
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|
|
729
|
+
("data.cache_sync_state", "_check_data_cache_sync_state"),
|
|
730
|
+
("data.codex_cache", "_check_data_codex_cache"),
|
|
731
|
+
)),
|
|
732
|
+
("safety", "Safety", (
|
|
733
|
+
("safety.dashboard_bind", "_check_safety_dashboard_bind"),
|
|
734
|
+
("safety.config_json_valid", "_check_safety_config_json_valid"),
|
|
735
|
+
("safety.update_state", "_check_safety_update_state"),
|
|
736
|
+
("safety.update_suppress", "_check_safety_update_suppress"),
|
|
737
|
+
("safety.update_available", "_check_safety_update_available"),
|
|
738
|
+
)),
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _evaluate_one(check_id: str, check_fn_name: str,
|
|
743
|
+
state: DoctorState) -> CheckResult:
|
|
744
|
+
"""Invoke a single check evaluator by name, catching any exception so
|
|
745
|
+
one bad check does not crash the whole report. Spec §7.1, §5.4.
|
|
746
|
+
|
|
747
|
+
On exception, the synthesized FAIL CheckResult uses the canonical
|
|
748
|
+
dotted ``check_id`` (NOT the function name) so the JSON contract
|
|
749
|
+
(spec §5.2) and fingerprint identity slice (spec §5.5) stay stable
|
|
750
|
+
across success-vs-raise transitions.
|
|
751
|
+
"""
|
|
752
|
+
mod = sys.modules[__name__]
|
|
753
|
+
fn = getattr(mod, check_fn_name, None)
|
|
754
|
+
if fn is None:
|
|
755
|
+
return CheckResult(
|
|
756
|
+
id=check_id, title=check_id,
|
|
757
|
+
severity="fail",
|
|
758
|
+
summary=f"evaluator not found: {check_fn_name}",
|
|
759
|
+
remediation="Internal error; see bin/_lib_doctor.py",
|
|
760
|
+
details={"exception": f"NameError: {check_fn_name}"},
|
|
761
|
+
)
|
|
762
|
+
try:
|
|
763
|
+
return fn(state)
|
|
764
|
+
except Exception as exc: # noqa: BLE001 — deliberate broad catch per spec §7.1
|
|
765
|
+
return CheckResult(
|
|
766
|
+
id=check_id, title=check_id,
|
|
767
|
+
severity="fail",
|
|
768
|
+
summary=f"{type(exc).__name__}: {exc}",
|
|
769
|
+
remediation="See details.exception",
|
|
770
|
+
details={"exception": f"{type(exc).__name__}: {exc}"},
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def run_checks(state: DoctorState) -> DoctorReport:
|
|
775
|
+
categories: list[CategoryResult] = []
|
|
776
|
+
counts = {"ok": 0, "warn": 0, "fail": 0}
|
|
777
|
+
for cat_id, cat_title, check_specs in _CATEGORY_DEFINITIONS:
|
|
778
|
+
results: list[CheckResult] = []
|
|
779
|
+
for check_id, fn_name in check_specs:
|
|
780
|
+
r = _evaluate_one(check_id, fn_name, state)
|
|
781
|
+
results.append(r)
|
|
782
|
+
counts[r.severity] = counts.get(r.severity, 0) + 1
|
|
783
|
+
cat_sev = _max_severity([r.severity for r in results])
|
|
784
|
+
categories.append(CategoryResult(
|
|
785
|
+
id=cat_id, title=cat_title,
|
|
786
|
+
severity=cat_sev,
|
|
787
|
+
checks=tuple(results),
|
|
788
|
+
))
|
|
789
|
+
overall = _max_severity([c.severity for c in categories])
|
|
790
|
+
return DoctorReport(
|
|
791
|
+
schema_version=SCHEMA_VERSION,
|
|
792
|
+
generated_at=state.now_utc,
|
|
793
|
+
cctally_version=state.cctally_version,
|
|
794
|
+
overall_severity=overall,
|
|
795
|
+
counts=counts,
|
|
796
|
+
categories=tuple(categories),
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _iso_z(d: dt.datetime) -> str:
|
|
801
|
+
"""Render a UTC datetime as ISO 8601 with trailing 'Z' (share-v2 convention)."""
|
|
802
|
+
if d.tzinfo is None:
|
|
803
|
+
d = d.replace(tzinfo=dt.timezone.utc)
|
|
804
|
+
s = d.astimezone(dt.timezone.utc).isoformat()
|
|
805
|
+
return s.replace("+00:00", "Z")
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _serialize_check(c: CheckResult) -> dict:
|
|
809
|
+
out = {
|
|
810
|
+
"id": c.id,
|
|
811
|
+
"title": c.title,
|
|
812
|
+
"severity": c.severity,
|
|
813
|
+
"summary": c.summary,
|
|
814
|
+
"details": c.details,
|
|
815
|
+
}
|
|
816
|
+
if c.severity != "ok" and c.remediation:
|
|
817
|
+
out["remediation"] = c.remediation
|
|
818
|
+
return out
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def serialize_json(report: DoctorReport) -> dict:
|
|
822
|
+
"""Produce the stable JSON payload per spec §5.1-§5.2.
|
|
823
|
+
|
|
824
|
+
Top-level fields are contract; the per-check `details` block is
|
|
825
|
+
unstable (consumers MUST tolerate unknown keys). schema_version
|
|
826
|
+
bumps only on a breaking change to the stable fields.
|
|
827
|
+
"""
|
|
828
|
+
return {
|
|
829
|
+
"schema_version": report.schema_version,
|
|
830
|
+
"generated_at": _iso_z(report.generated_at),
|
|
831
|
+
"cctally_version": report.cctally_version,
|
|
832
|
+
"overall": {
|
|
833
|
+
"severity": report.overall_severity,
|
|
834
|
+
"counts": dict(report.counts),
|
|
835
|
+
},
|
|
836
|
+
"categories": [
|
|
837
|
+
{
|
|
838
|
+
"id": cat.id,
|
|
839
|
+
"title": cat.title,
|
|
840
|
+
"severity": cat.severity,
|
|
841
|
+
"checks": [_serialize_check(c) for c in cat.checks],
|
|
842
|
+
}
|
|
843
|
+
for cat in report.categories
|
|
844
|
+
],
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _identity_slice(report: DoctorReport) -> dict:
|
|
849
|
+
"""The fields the fingerprint hashes over. Excludes generated_at,
|
|
850
|
+
cctally_version, summary text, remediation, and the entire details
|
|
851
|
+
block — those carry volatile values that change tick-to-tick even
|
|
852
|
+
when severity doesn't flip. See spec §5.5."""
|
|
853
|
+
return {
|
|
854
|
+
"schema_version": report.schema_version,
|
|
855
|
+
"overall_severity": report.overall_severity,
|
|
856
|
+
"counts": dict(report.counts),
|
|
857
|
+
"checks": [
|
|
858
|
+
[c.id, c.severity]
|
|
859
|
+
for cat in report.categories
|
|
860
|
+
for c in cat.checks
|
|
861
|
+
],
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def fingerprint(report: DoctorReport) -> str:
|
|
866
|
+
"""Stable SHA1 over the identity slice. Same identity slice → same
|
|
867
|
+
fingerprint, even when ages and rendered summaries change."""
|
|
868
|
+
payload = json.dumps(_identity_slice(report), sort_keys=True, separators=(",", ":"))
|
|
869
|
+
h = hashlib.sha1(payload.encode("utf-8")).hexdigest()
|
|
870
|
+
return f"sha1:{h}"
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
_GLYPH = {"ok": "✓", "warn": "⚠", "fail": "✗"}
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def render_text(report: DoctorReport, *, quiet: bool = False, verbose: bool = False) -> str:
|
|
877
|
+
if quiet and verbose:
|
|
878
|
+
raise ValueError("render_text: --quiet and --verbose are mutually exclusive")
|
|
879
|
+
lines: list[str] = []
|
|
880
|
+
ts = _iso_z(report.generated_at).replace("T", " ").replace("Z", " UTC")
|
|
881
|
+
lines.append(f"cctally doctor — {ts}")
|
|
882
|
+
lines.append("")
|
|
883
|
+
for cat in report.categories:
|
|
884
|
+
lines.append(cat.title)
|
|
885
|
+
for c in cat.checks:
|
|
886
|
+
if quiet and c.severity == "ok":
|
|
887
|
+
continue
|
|
888
|
+
glyph = _GLYPH.get(c.severity, "?")
|
|
889
|
+
lines.append(f" {glyph} {c.title:<24s} {c.summary}")
|
|
890
|
+
if c.remediation:
|
|
891
|
+
lines.append(f" → {c.remediation}")
|
|
892
|
+
if verbose and c.details:
|
|
893
|
+
lines.append(" details:")
|
|
894
|
+
for k, v in c.details.items():
|
|
895
|
+
lines.append(f" {k}: {v}")
|
|
896
|
+
lines.append("")
|
|
897
|
+
counts = report.counts
|
|
898
|
+
lines.append(
|
|
899
|
+
f"Summary: {counts.get('ok', 0)} OK · "
|
|
900
|
+
f"{counts.get('warn', 0)} WARN · "
|
|
901
|
+
f"{counts.get('fail', 0)} FAIL"
|
|
902
|
+
)
|
|
903
|
+
return "\n".join(lines) + "\n"
|