@procrastivity/clast 0.0.1

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.
@@ -0,0 +1,607 @@
1
+ # clast-subcommands/doctor.bash — `clast doctor`.
2
+ #
3
+ # Run six sanity checks against the journal and report findings. With
4
+ # `--fix`, perform the two safe repairs (manifest rebuild from disk,
5
+ # orphan-snapshot removal). See docs/cli-contract.md#clast-doctor.
6
+ # shellcheck shell=bash
7
+ # shellcheck source=lib/clast/clast-lib.bash
8
+ # shellcheck source=lib/clast/clast-manifest-lib.bash
9
+ # shellcheck source=lib/clast/clast-registry-lib.bash
10
+ # shellcheck source=lib/clast/clast-decode-lib.bash
11
+
12
+ _clast_doctor_usage() {
13
+ cat <<'EOF'
14
+ Usage: clast doctor [--fix] [--yes]
15
+
16
+ Sanity-check the journal: manifest validity, registry validity, orphan
17
+ snapshots, missing snapshots, day-bucket consistency, day-cutoff sanity.
18
+
19
+ Flags:
20
+ --fix Apply safe repairs (rebuild manifest, remove orphans).
21
+ --yes, -y Skip the interactive confirmation prompt for orphan removal.
22
+ -h, --help Print this usage and exit.
23
+
24
+ Exit codes: 0 ok, 1 warnings, 2 usage error, 4 critical corruption.
25
+ EOF
26
+ }
27
+
28
+ _clast_doctor_err() {
29
+ local msg="$1" code="${2:-2}"
30
+ if [[ -n "${CLAST_JSON:-}" ]]; then
31
+ jq -cn --arg m "$msg" --argjson c "$code" '{error:$m, code:$c}'
32
+ else
33
+ clast_log_error "doctor: $msg"
34
+ fi
35
+ }
36
+
37
+ # Shared accumulator. Reset at the top of every check pass.
38
+ _CLAST_DOCTOR_FINDINGS=()
39
+
40
+ # _clast_doctor_emit <check> <severity> <message> [items...]
41
+ _clast_doctor_emit() {
42
+ local check="$1" severity="$2" message="$3"
43
+ shift 3
44
+ local items_json='[]'
45
+ if (( $# > 0 )); then
46
+ items_json="$(printf '%s\n' "$@" | jq -R . | jq -cs .)"
47
+ fi
48
+ local finding
49
+ finding="$(jq -cn \
50
+ --arg check "$check" \
51
+ --arg severity "$severity" \
52
+ --arg message "$message" \
53
+ --argjson items "$items_json" \
54
+ '{check:$check, severity:$severity, message:$message, items:$items}')"
55
+ _CLAST_DOCTOR_FINDINGS+=("$finding")
56
+ }
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Checks
60
+ # ---------------------------------------------------------------------------
61
+
62
+ # 9a — manifest_validity
63
+ _clast_doctor_check_manifest_validity() {
64
+ local path
65
+ path="$(clast_manifest_path)"
66
+ if [[ ! -f "$path" ]]; then
67
+ _clast_doctor_emit "manifest_validity" "ok" "no manifest yet (0 entries)"
68
+ return 0
69
+ fi
70
+
71
+ local lineno=0 total=0 valid=0
72
+ local -a bad_lines=()
73
+ local critical=0
74
+ local line parsed ok
75
+ while IFS= read -r line || [[ -n "$line" ]]; do
76
+ lineno=$((lineno + 1))
77
+ [[ -z "$line" ]] && continue
78
+ total=$((total + 1))
79
+ if ! parsed="$(jq -c . <<<"$line" 2>/dev/null)" || [[ -z "$parsed" ]]; then
80
+ bad_lines+=("line $lineno: unparseable JSON")
81
+ critical=1
82
+ continue
83
+ fi
84
+ ok="$(jq -r '
85
+ (has("session_id") and has("source") and has("snapshot")
86
+ and has("captured_at") and has("source_mtime")
87
+ and has("source_size") and has("day_bucket"))
88
+ ' <<<"$parsed" 2>/dev/null)"
89
+ if [[ "$ok" == "true" ]]; then
90
+ valid=$((valid + 1))
91
+ else
92
+ bad_lines+=("line $lineno: missing required field(s)")
93
+ fi
94
+ done <"$path"
95
+
96
+ if (( critical == 1 )); then
97
+ _clast_doctor_emit "manifest_validity" "critical" \
98
+ "$total line(s), unparseable line(s) detected" "${bad_lines[@]}"
99
+ return 0
100
+ fi
101
+ if (( ${#bad_lines[@]} > 0 )); then
102
+ _clast_doctor_emit "manifest_validity" "warn" \
103
+ "$total line(s), $valid valid, ${#bad_lines[@]} with missing fields" \
104
+ "${bad_lines[@]}"
105
+ return 0
106
+ fi
107
+ _clast_doctor_emit "manifest_validity" "ok" \
108
+ "$total entries, all valid"
109
+ }
110
+
111
+ # 9b — registry_validity
112
+ _clast_doctor_check_registry_validity() {
113
+ local path
114
+ path="$(clast_registry_path)"
115
+ if [[ ! -f "$path" ]]; then
116
+ _clast_doctor_emit "registry_validity" "ok" "no registry yet (0 entries)"
117
+ return 0
118
+ fi
119
+
120
+ local lineno=0 total=0
121
+ local -a entries=()
122
+ local -a issues=()
123
+ local critical=0
124
+ local line parsed ok
125
+ while IFS= read -r line || [[ -n "$line" ]]; do
126
+ lineno=$((lineno + 1))
127
+ [[ -z "$line" ]] && continue
128
+ total=$((total + 1))
129
+ if ! parsed="$(jq -c . <<<"$line" 2>/dev/null)" || [[ -z "$parsed" ]]; then
130
+ issues+=("line $lineno: unparseable JSON")
131
+ critical=1
132
+ continue
133
+ fi
134
+ ok="$(jq -r '
135
+ (has("path") and has("slug") and has("first_seen") and has("aliases"))
136
+ ' <<<"$parsed" 2>/dev/null)"
137
+ if [[ "$ok" != "true" ]]; then
138
+ issues+=("line $lineno: missing required field(s)")
139
+ continue
140
+ fi
141
+ entries+=("$parsed")
142
+ done <"$path"
143
+
144
+ if (( critical == 1 )); then
145
+ _clast_doctor_emit "registry_validity" "critical" \
146
+ "$total line(s), unparseable line(s) detected" "${issues[@]}"
147
+ return 0
148
+ fi
149
+
150
+ # Duplicate slug + alias collisions, computed across the parseable subset.
151
+ local entries_json='[]'
152
+ if (( ${#entries[@]} > 0 )); then
153
+ entries_json="$(printf '%s\n' "${entries[@]}" | jq -cs .)"
154
+ fi
155
+
156
+ local dup_lines
157
+ dup_lines="$(jq -r '
158
+ [.[] | .slug] | group_by(.)
159
+ | map(select(length > 1) | .[0]) | .[]
160
+ ' <<<"$entries_json")"
161
+ local dup_slug
162
+ while IFS= read -r dup_slug; do
163
+ [[ -z "$dup_slug" ]] && continue
164
+ issues+=("duplicate slug: $dup_slug")
165
+ done <<<"$dup_lines"
166
+
167
+ local collisions
168
+ collisions="$(jq -r '
169
+ . as $arr
170
+ | [
171
+ # alias collides with another entry'"'"'s slug
172
+ ( range(0; length) as $i
173
+ | range(0; length) as $j
174
+ | select($i != $j)
175
+ | ($arr[$i].aliases // []) as $aliases
176
+ | select($aliases | index($arr[$j].slug) != null)
177
+ | "alias collision: " + $arr[$i].slug + " aliases slug " + $arr[$j].slug
178
+ ),
179
+ # alias collides with another entry'"'"'s alias (shared alias across two slugs)
180
+ ( range(0; length) as $i
181
+ | range(0; length) as $j
182
+ | select($i < $j)
183
+ | ($arr[$i].aliases // []) as $ai
184
+ | ($arr[$j].aliases // []) as $aj
185
+ | ($ai | map(select(. as $x | $aj | index($x) != null))) as $shared
186
+ | select($shared | length > 0)
187
+ | "alias collision: " + $arr[$i].slug + " and " + $arr[$j].slug + " share alias " + ($shared[0])
188
+ )
189
+ ]
190
+ | unique[]?
191
+ ' <<<"$entries_json")"
192
+ while IFS= read -r line; do
193
+ [[ -z "$line" ]] && continue
194
+ issues+=("$line")
195
+ done <<<"$collisions"
196
+
197
+ local valid_count="${#entries[@]}"
198
+ if (( ${#issues[@]} > 0 )); then
199
+ _clast_doctor_emit "registry_validity" "warn" \
200
+ "$total line(s), $valid_count valid, ${#issues[@]} issue(s)" \
201
+ "${issues[@]}"
202
+ return 0
203
+ fi
204
+ _clast_doctor_emit "registry_validity" "ok" \
205
+ "$valid_count projects, no duplicates"
206
+ }
207
+
208
+ # Compute the deduped (most-recent per session_id) manifest rows.
209
+ # Writes JSON array to stdout; empty manifest → '[]'.
210
+ _clast_doctor_dedup_manifest() {
211
+ local path
212
+ path="$(clast_manifest_path)"
213
+ if [[ ! -f "$path" ]]; then
214
+ printf '[]\n'
215
+ return 0
216
+ fi
217
+ jq -cR 'fromjson?' "$path" \
218
+ | jq -cs 'group_by(.session_id) | map(max_by(.captured_at))'
219
+ }
220
+
221
+ # 9c — orphan_snapshots
222
+ _clast_doctor_check_orphan_snapshots() {
223
+ local journal_dir transcripts
224
+ journal_dir="$(clast_journal_dir)"
225
+ transcripts="$journal_dir/transcripts"
226
+ if [[ ! -d "$transcripts" ]]; then
227
+ _clast_doctor_emit "orphan_snapshots" "ok" "none (no transcripts directory)"
228
+ return 0
229
+ fi
230
+
231
+ # Collect every known session_id from the manifest (full set, not deduped —
232
+ # orphan check only cares whether the sid appears anywhere). Tolerate a
233
+ # missing manifest file: a fresh-or-partially-synced journal with stray
234
+ # transcripts on disk should still report orphans, not abort.
235
+ local manifest_path known='[]'
236
+ manifest_path="$(clast_manifest_path)"
237
+ if [[ -f "$manifest_path" ]]; then
238
+ known="$(jq -cR 'fromjson? | .session_id' "$manifest_path" 2>/dev/null \
239
+ | jq -Rs 'split("\n") | map(select(length > 0) | fromjson? // .)
240
+ | map(select(type == "string")) | unique')"
241
+ [[ -z "$known" ]] && known='[]'
242
+ fi
243
+
244
+ local -a orphans=()
245
+ local snapshot sid rel found
246
+ while IFS= read -r snapshot; do
247
+ [[ -z "$snapshot" ]] && continue
248
+ sid="$(basename "$snapshot" .jsonl)"
249
+ found="$(jq -r --arg s "$sid" 'index($s) // empty' <<<"$known")"
250
+ if [[ -z "$found" ]]; then
251
+ rel="${snapshot#"$journal_dir/"}"
252
+ orphans+=("$rel")
253
+ fi
254
+ done < <(find "$transcripts" -mindepth 3 -maxdepth 3 -type f -name '*.jsonl' 2>/dev/null | sort)
255
+
256
+ if (( ${#orphans[@]} > 0 )); then
257
+ _clast_doctor_emit "orphan_snapshots" "warn" \
258
+ "${#orphans[@]}" "${orphans[@]}"
259
+ return 0
260
+ fi
261
+ _clast_doctor_emit "orphan_snapshots" "ok" "none"
262
+ }
263
+
264
+ # 9d — missing_snapshots
265
+ _clast_doctor_check_missing_snapshots() {
266
+ local deduped journal_dir
267
+ deduped="$(_clast_doctor_dedup_manifest)"
268
+ journal_dir="$(clast_journal_dir)"
269
+
270
+ local n
271
+ n="$(jq 'length' <<<"$deduped")"
272
+
273
+ local -a missing=()
274
+ local i snapshot abs
275
+ for (( i = 0; i < n; i++ )); do
276
+ snapshot="$(jq -r ".[$i].snapshot // empty" <<<"$deduped")"
277
+ [[ -z "$snapshot" ]] && continue
278
+ abs="$journal_dir/$snapshot"
279
+ if [[ ! -e "$abs" ]]; then
280
+ missing+=("$snapshot")
281
+ fi
282
+ done
283
+
284
+ if (( ${#missing[@]} > 0 )); then
285
+ _clast_doctor_emit "missing_snapshots" "warn" \
286
+ "${#missing[@]}" "${missing[@]}"
287
+ return 0
288
+ fi
289
+ _clast_doctor_emit "missing_snapshots" "ok" "none"
290
+ }
291
+
292
+ # 9e — day_bucket_consistency
293
+ _clast_doctor_check_day_bucket_consistency() {
294
+ local deduped
295
+ deduped="$(_clast_doctor_dedup_manifest)"
296
+ local n
297
+ n="$(jq 'length' <<<"$deduped")"
298
+
299
+ local -a mismatches=()
300
+ local i snapshot day_bucket sday
301
+ for (( i = 0; i < n; i++ )); do
302
+ snapshot="$(jq -r ".[$i].snapshot // empty" <<<"$deduped")"
303
+ day_bucket="$(jq -r ".[$i].day_bucket // empty" <<<"$deduped")"
304
+ [[ -z "$snapshot" || -z "$day_bucket" ]] && continue
305
+ case "$snapshot" in
306
+ transcripts/*) ;;
307
+ *) continue ;;
308
+ esac
309
+ sday="$(awk -F/ 'NR==1{print $2}' <<<"$snapshot")"
310
+ if [[ -n "$sday" && "$sday" != "$day_bucket" ]]; then
311
+ mismatches+=("$snapshot vs day_bucket=$day_bucket")
312
+ fi
313
+ done
314
+
315
+ if (( ${#mismatches[@]} > 0 )); then
316
+ _clast_doctor_emit "day_bucket_consistency" "warn" \
317
+ "${#mismatches[@]} mismatch(es)" "${mismatches[@]}"
318
+ return 0
319
+ fi
320
+ _clast_doctor_emit "day_bucket_consistency" "ok" "ok"
321
+ }
322
+
323
+ # 9f — day_cutoff_sanity
324
+ _clast_doctor_check_day_cutoff_sanity() {
325
+ local path
326
+ path="$(clast_manifest_path)"
327
+ if [[ ! -f "$path" ]]; then
328
+ _clast_doctor_emit "day_cutoff_sanity" "ok" "ok (empty manifest)"
329
+ return 0
330
+ fi
331
+
332
+ local cutoff="${CLAST_DAY_CUTOFF:-04:00}"
333
+ local ch cm cutoff_secs
334
+ ch="${cutoff%%:*}"
335
+ cm="${cutoff##*:}"
336
+ ch=$((10#$ch))
337
+ cm=$((10#$cm))
338
+ cutoff_secs=$((ch * 3600 + cm * 60))
339
+
340
+ local total=0 near=0
341
+ local ts secs diff a b
342
+ while IFS= read -r ts; do
343
+ [[ -z "$ts" ]] && continue
344
+ if ! secs="$(date -d "$ts" +%H:%M:%S 2>/dev/null)" || [[ -z "$secs" ]]; then
345
+ continue
346
+ fi
347
+ local hh mm ss
348
+ hh="${secs%%:*}"; secs="${secs#*:}"
349
+ mm="${secs%%:*}"; ss="${secs#*:}"
350
+ hh=$((10#$hh)); mm=$((10#$mm)); ss=$((10#$ss))
351
+ local cap_secs=$((hh * 3600 + mm * 60 + ss))
352
+ a=$((cap_secs - cutoff_secs))
353
+ if (( a < 0 )); then a=$((-a)); fi
354
+ b=$((86400 - a))
355
+ diff=$a
356
+ if (( b < diff )); then diff=$b; fi
357
+ total=$((total + 1))
358
+ if (( diff <= 1800 )); then
359
+ near=$((near + 1))
360
+ fi
361
+ done < <(jq -rR 'fromjson? | .captured_at // empty' "$path")
362
+
363
+ if (( total == 0 )); then
364
+ _clast_doctor_emit "day_cutoff_sanity" "ok" "ok (empty manifest)"
365
+ return 0
366
+ fi
367
+ # >5% within ±30min of cutoff hour → warn.
368
+ if (( near * 20 > total )); then
369
+ local pct=$(( near * 100 / total ))
370
+ _clast_doctor_emit "day_cutoff_sanity" "warn" \
371
+ "$near/$total ($pct%) captures within ±30min of $cutoff; consider tuning ~/.config/clast/config.toml day_cutoff"
372
+ return 0
373
+ fi
374
+ _clast_doctor_emit "day_cutoff_sanity" "ok" "ok"
375
+ }
376
+
377
+ # Run all six checks (resets the accumulator).
378
+ _clast_doctor_run_all() {
379
+ _CLAST_DOCTOR_FINDINGS=()
380
+ _clast_doctor_check_manifest_validity
381
+ _clast_doctor_check_registry_validity
382
+ _clast_doctor_check_orphan_snapshots
383
+ _clast_doctor_check_missing_snapshots
384
+ _clast_doctor_check_day_bucket_consistency
385
+ _clast_doctor_check_day_cutoff_sanity
386
+ }
387
+
388
+ # Aggregate the current findings into a severity bucket: critical / warn / ok.
389
+ _clast_doctor_overall_severity() {
390
+ local f sev has_critical=0 has_warn=0
391
+ for f in "${_CLAST_DOCTOR_FINDINGS[@]+"${_CLAST_DOCTOR_FINDINGS[@]}"}"; do
392
+ sev="$(jq -r '.severity' <<<"$f")"
393
+ case "$sev" in
394
+ critical) has_critical=1 ;;
395
+ warn) has_warn=1 ;;
396
+ esac
397
+ done
398
+ if (( has_critical == 1 )); then printf 'critical\n'
399
+ elif (( has_warn == 1 )); then printf 'warn\n'
400
+ else printf 'ok\n'
401
+ fi
402
+ }
403
+
404
+ # Look up a finding by check id; print its JSON or empty.
405
+ _clast_doctor_finding_for() {
406
+ local check="$1" f
407
+ for f in "${_CLAST_DOCTOR_FINDINGS[@]+"${_CLAST_DOCTOR_FINDINGS[@]}"}"; do
408
+ if [[ "$(jq -r '.check' <<<"$f")" == "$check" ]]; then
409
+ printf '%s\n' "$f"
410
+ return 0
411
+ fi
412
+ done
413
+ return 1
414
+ }
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Entry
418
+ # ---------------------------------------------------------------------------
419
+
420
+ clast_cmd_doctor() {
421
+ local fix_mode=0 assume_yes=0
422
+
423
+ while [[ $# -gt 0 ]]; do
424
+ case "$1" in
425
+ --fix) fix_mode=1; shift ;;
426
+ --yes|-y) assume_yes=1; shift ;;
427
+ -h|--help) _clast_doctor_usage; return 0 ;;
428
+ --) shift; break ;;
429
+ -*) _clast_doctor_err "unknown flag '$1'"; return 2 ;;
430
+ *) _clast_doctor_err "unexpected positional '$1'"; return 2 ;;
431
+ esac
432
+ done
433
+
434
+ local -a fixed=()
435
+ _clast_doctor_run_all
436
+ local overall
437
+ overall="$(_clast_doctor_overall_severity)"
438
+
439
+ if (( fix_mode == 1 )); then
440
+ # Manifest critical → one rebuild attempt, then re-run all checks.
441
+ if [[ "$overall" == "critical" ]]; then
442
+ local mf
443
+ mf="$(_clast_doctor_finding_for manifest_validity || true)"
444
+ if [[ -n "$mf" && "$(jq -r '.severity' <<<"$mf")" == "critical" ]]; then
445
+ if clast_manifest_rebuild_from_disk; then
446
+ fixed+=("rebuilt manifest from disk")
447
+ _clast_doctor_run_all
448
+ overall="$(_clast_doctor_overall_severity)"
449
+ fi
450
+ fi
451
+ fi
452
+
453
+ # Orphan removal — gather post-rebuild orphan list, if any. Skip when a
454
+ # non-manifest critical finding remains (e.g. unparseable projects.json):
455
+ # destructive cleanup should not run while unresolved corruption may
456
+ # affect the analysis.
457
+ local of orphan_items orphan_count=0
458
+ if [[ "$overall" != "critical" ]]; then
459
+ of="$(_clast_doctor_finding_for orphan_snapshots || true)"
460
+ if [[ -n "$of" && "$(jq -r '.severity' <<<"$of")" == "warn" ]]; then
461
+ orphan_items="$(jq -r '.items[]?' <<<"$of")"
462
+ orphan_count="$(jq '.items | length' <<<"$of")"
463
+ fi
464
+ fi
465
+ if (( orphan_count > 0 )); then
466
+ local proceed=0
467
+ if (( assume_yes == 1 )); then
468
+ proceed=1
469
+ else
470
+ # Interactive prompts would corrupt --json output. Force --yes when
471
+ # JSON mode is requested.
472
+ if [[ -n "${CLAST_JSON:-}" ]]; then
473
+ _clast_doctor_err "--fix needs --yes when --json is set"
474
+ return 2
475
+ fi
476
+ if [[ ! -t 0 ]] || ! { exec 3</dev/tty; } 2>/dev/null; then
477
+ _clast_doctor_err "--fix needs --yes when stdin is not a TTY"
478
+ return 2
479
+ fi
480
+ printf 'Found %d orphan snapshot(s):\n' "$orphan_count"
481
+ while IFS= read -r _orphan_item; do
482
+ [[ -z "$_orphan_item" ]] && continue
483
+ printf ' %s\n' "$_orphan_item"
484
+ done <<<"$orphan_items"
485
+ printf 'Remove these %d file(s)? [y/N] ' "$orphan_count"
486
+ local ans=""
487
+ IFS= read -r ans <&3 || true
488
+ exec 3<&-
489
+ case "$ans" in y|Y) proceed=1 ;; esac
490
+ fi
491
+ if (( proceed == 1 )); then
492
+ local journal_dir rel abs removed=0
493
+ journal_dir="$(clast_journal_dir)"
494
+ while IFS= read -r rel; do
495
+ [[ -z "$rel" ]] && continue
496
+ abs="$journal_dir/$rel"
497
+ if rm -f "$abs" 2>/dev/null; then
498
+ removed=$((removed + 1))
499
+ fi
500
+ done <<<"$orphan_items"
501
+ fixed+=("removed $removed orphan snapshot(s)")
502
+ clast_log_info "removed $removed orphan snapshot(s)"
503
+ _clast_doctor_run_all
504
+ overall="$(_clast_doctor_overall_severity)"
505
+ fi
506
+ fi
507
+ fi
508
+
509
+ # Compute exit code from final overall severity.
510
+ local exit_code
511
+ case "$overall" in
512
+ critical) exit_code=4 ;;
513
+ warn) exit_code=1 ;;
514
+ *) exit_code=0 ;;
515
+ esac
516
+
517
+ # Determine if there are any auto-fixable findings still pending (for hint).
518
+ local hint_fixable=0
519
+ local of2
520
+ of2="$(_clast_doctor_finding_for orphan_snapshots || true)"
521
+ if [[ -n "$of2" && "$(jq -r '.severity' <<<"$of2")" == "warn" ]]; then
522
+ hint_fixable=1
523
+ fi
524
+ local mf2
525
+ mf2="$(_clast_doctor_finding_for manifest_validity || true)"
526
+ if [[ -n "$mf2" && "$(jq -r '.severity' <<<"$mf2")" == "critical" ]]; then
527
+ hint_fixable=1
528
+ fi
529
+
530
+ if [[ -n "${CLAST_JSON:-}" ]]; then
531
+ local findings_json fixed_json
532
+ if (( ${#_CLAST_DOCTOR_FINDINGS[@]} > 0 )); then
533
+ findings_json="$(printf '%s\n' "${_CLAST_DOCTOR_FINDINGS[@]}" | jq -cs .)"
534
+ else
535
+ findings_json='[]'
536
+ fi
537
+ if (( ${#fixed[@]} > 0 )); then
538
+ fixed_json="$(printf '%s\n' "${fixed[@]}" | jq -R . | jq -cs .)"
539
+ else
540
+ fixed_json='[]'
541
+ fi
542
+ # Re-order findings to canonical sequence.
543
+ findings_json="$(jq -c '
544
+ def by_check(c): map(select(.check == c)) | .[0];
545
+ [
546
+ by_check("manifest_validity"),
547
+ by_check("registry_validity"),
548
+ by_check("orphan_snapshots"),
549
+ by_check("missing_snapshots"),
550
+ by_check("day_bucket_consistency"),
551
+ by_check("day_cutoff_sanity")
552
+ ] | map(select(. != null))
553
+ ' <<<"$findings_json")"
554
+ jq -cn \
555
+ --argjson findings "$findings_json" \
556
+ --argjson exit_code "$exit_code" \
557
+ --argjson fixed "$fixed_json" \
558
+ '{findings:$findings, exit_code:$exit_code, fixed:$fixed}'
559
+ return "$exit_code"
560
+ fi
561
+
562
+ if [[ -z "${CLAST_QUIET:-}" ]]; then
563
+ local order=(manifest_validity registry_validity orphan_snapshots \
564
+ missing_snapshots day_bucket_consistency day_cutoff_sanity)
565
+ local label sev msg prefix items item check
566
+ for check in "${order[@]}"; do
567
+ local f
568
+ f="$(_clast_doctor_finding_for "$check" || true)"
569
+ [[ -z "$f" ]] && continue
570
+ sev="$(jq -r '.severity' <<<"$f")"
571
+ msg="$(jq -r '.message' <<<"$f")"
572
+ case "$sev" in
573
+ ok) prefix='✓' ;;
574
+ warn) prefix='!' ;;
575
+ critical) prefix='✗' ;;
576
+ *) prefix='?' ;;
577
+ esac
578
+ case "$check" in
579
+ manifest_validity) label="Manifest" ;;
580
+ registry_validity) label="Registry" ;;
581
+ orphan_snapshots) label="Orphan snapshots" ;;
582
+ missing_snapshots) label="Missing snapshots" ;;
583
+ day_bucket_consistency) label="Day-bucket consistency" ;;
584
+ day_cutoff_sanity) label="Day-cutoff sanity" ;;
585
+ esac
586
+ printf '%s %s: %s\n' "$prefix" "$label" "$msg"
587
+ if [[ "$sev" == "warn" || "$sev" == "critical" ]]; then
588
+ items="$(jq -r '.items[]?' <<<"$f")"
589
+ if [[ -n "$items" ]]; then
590
+ while IFS= read -r item; do
591
+ [[ -z "$item" ]] && continue
592
+ printf ' %s\n' "$item"
593
+ done <<<"$items"
594
+ fi
595
+ fi
596
+ done
597
+
598
+ if (( ${#fixed[@]} > 0 )); then
599
+ printf '\nFixed: %s\n' "$(IFS='; '; printf '%s' "${fixed[*]}")"
600
+ elif (( hint_fixable == 1 && fix_mode == 0 )); then
601
+ # shellcheck disable=SC2016
602
+ printf '\n%s\n' 'Run `clast doctor --fix` to clean up auto-fixable findings.'
603
+ fi
604
+ fi
605
+
606
+ return "$exit_code"
607
+ }