@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.
- package/.claude-plugin/plugin.json +11 -0
- package/.claude-plugin/skills/day-wakeup/SKILL.md +194 -0
- package/.claude-plugin/skills/wakeup/SKILL.md +115 -0
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/bin/.gitkeep +0 -0
- package/bin/clast +172 -0
- package/examples/config/config.toml.sample +32 -0
- package/examples/cron/clast-snapshot.service +10 -0
- package/examples/cron/clast-snapshot.timer +15 -0
- package/examples/cron/crontab.sample +16 -0
- package/examples/workflows/morning-briefing.md +137 -0
- package/hooks/hooks.json +8 -0
- package/hooks/snapshot.sh +15 -0
- package/lib/clast/.gitkeep +0 -0
- package/lib/clast/clast-decode-lib.bash +184 -0
- package/lib/clast/clast-lib.bash +233 -0
- package/lib/clast/clast-manifest-lib.bash +232 -0
- package/lib/clast/clast-registry-lib.bash +252 -0
- package/lib/clast/clast-subcommands/.gitkeep +0 -0
- package/lib/clast/clast-subcommands/breadcrumb.bash +454 -0
- package/lib/clast/clast-subcommands/doctor.bash +607 -0
- package/lib/clast/clast-subcommands/entries.bash +697 -0
- package/lib/clast/clast-subcommands/projects.bash +309 -0
- package/lib/clast/clast-subcommands/registry.bash +207 -0
- package/lib/clast/clast-subcommands/sessions.bash +275 -0
- package/lib/clast/clast-subcommands/show.bash +283 -0
- package/lib/clast/clast-subcommands/snapshot.bash +294 -0
- package/lib/clast/clast-subcommands/stats.bash +354 -0
- package/lib/clast/clast-subcommands/whereami.bash +108 -0
- package/package.json +39 -0
|
@@ -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
|
+
}
|