Repository navigation
Expand file tree
/
Copy pathsanity
More file actions
executable file
·533 lines (504 loc) · 19.3 KB
/
Copy pathsanity
File metadata and controls
executable file
·533 lines (504 loc) · 19.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
#!/usr/bin/env zsh
# sanity — system status check for the zshell-setup environment
# Lives in the repo; symlinked as ~/sanity and ~/.local/bin/sanity.
#
# sanity all local checks (incl. runtime tests)
# sanity --quick skip slow runtime tests (ROOT macro, PyROOT, G4 datasets, brew outdated)
# sanity --net also test SSH connectivity (github / daq / nersc)
emulate -L zsh
setopt pipefail nonomatch
# ╭───────────────────────────────╮
# │ ⚙️ Options │
# ╰───────────────────────────────╯
typeset -i quick=0 net=0
typeset arg
for arg in "$@"; do
case $arg in
-q|--quick) quick=1 ;;
-n|--net) net=1 ;;
-h|--help)
cat <<'EOF'
sanity — system status check for the zshell-setup environment
usage: sanity [-q|--quick] [-n|--net]
-q, --quick skip slow runtime tests (ROOT macro, PyROOT, Geant4 datasets, brew outdated)
-n, --net also test SSH connectivity (github, daq, nersc)
EOF
exit 0 ;;
*) print -u2 "sanity: unknown option '$arg' (try --help)"; exit 2 ;;
esac
done
# ╭───────────────────────────────╮
# │ 🧮 Tally & helpers │
# ╰───────────────────────────────╯
typeset -i n_pass=0 n_warn=0 n_fail=0
typeset -a problems
pass() { print -P " %F{green}✅%f $1"; (( ++n_pass )); }
warn() { print -P " %F{yellow}⚠️%f $1"; (( ++n_warn )); problems+=("⚠️ $1"); }
fail() { print -P " %F{red}❌%f $1"; (( ++n_fail )); problems+=("❌ $1"); }
info() { print -P " %F{cyan}·%f $1"; }
section() { print -P "\n%F{blue}%B── $1%b%f"; }
check_var() { # check_var <NAME> [warn]
typeset name=$1 sev=${2:-fail} val
val=${(P)name}
if [[ -n $val ]]; then
pass "$name = $val"
elif [[ $sev == warn ]]; then
warn "$name unset"
else
fail "$name unset (zshrc not sourced?)"
fi
}
check_dir() { # check_dir <label> <path> [warn]
typeset label=$1 d=$2 sev=${3:-fail}
if [[ -z $d ]]; then
[[ $sev == warn ]] && warn "$label: unset" || fail "$label: unset (zshrc not sourced?)"
elif [[ -d $d ]]; then
pass "$label: ${d/#$HOME/~}"
else
[[ $sev == warn ]] && warn "$label missing: $d" || fail "$label missing: $d"
fi
}
check_cmd() { # check_cmd <cmd> [warn]
typeset c=$1 sev=${2:-fail} p
p=$(command -v $c)
if [[ -n $p ]]; then
pass "$c → $p"
elif [[ $sev == warn ]]; then
warn "$c not found"
else
fail "$c not found"
fi
}
check_link() { # check_link <link> <expected target>
typeset link=$1 target=$2
if [[ ! -e $link ]]; then
fail "${link/#$HOME/~} missing"
elif [[ ${link:A} == ${target:A} ]]; then
pass "${link/#$HOME/~} → ${target/#$HOME/~}"
else
fail "${link/#$HOME/~} → ${${link:A}/#$HOME/~} (expected ${target/#$HOME/~})"
fi
}
# ╭───────────────────────────────╮
# │ 📜 Expectations from repo │
# ╰───────────────────────────────╯
typeset repo=${0:A:h}
typeset zshrc=$repo/.zshrc sshconf=$repo/config
typeset hb_python='' want_pyver=''
if [[ -f $zshrc ]]; then
hb_python=$(grep -m1 '^HOMEBREW_PYTHON=' $zshrc | cut -d= -f2)
want_pyver=${hb_python##*@}
fi
print -P "%B🧠 sanity%b — $(date '+%Y-%m-%d %H:%M') repo: ${repo/#$HOME/~}"
# ╭───────────────────────────────╮
# │ Checks │
# ╰───────────────────────────────╯
section "🖥️ Shell & flavor"
typeset arch=$(uname -m)
info "zsh $ZSH_VERSION · $arch · macOS $(sw_vers -productVersion 2>/dev/null)"
if [[ -z ${ENV_FLAVOR:-} ]]; then
fail "ENV_FLAVOR unset — .zshrc env not loaded in this shell"
elif [[ $arch == arm64 && $ENV_FLAVOR == 💻 ]] || [[ $arch == x86_64 && $ENV_FLAVOR == 🖥️ ]]; then
pass "flavor $ENV_FLAVOR matches arch $arch"
else
warn "flavor $ENV_FLAVOR vs arch $arch — check arm64()/amd64()"
fi
# free disk on the boot volume — cheap general-health signal (builds eat space)
typeset -i diskuse=$(df -Pk / | awk 'NR==2{gsub(/%/,"",$5); print $5}')
typeset diskfree=$(df -Ph / | awk 'NR==2{print $4}')
if (( diskuse >= 90 )); then
warn "disk ${diskuse}%% used (${diskfree} free)" # %% -> literal % under print -P
else
pass "disk ${diskuse}%% used (${diskfree} free)"
fi
section "🔗 Dotfile symlinks"
check_link ~/.zshrc $repo/.zshrc
check_link ~/.ssh/config $repo/config
check_link ~/.gitconfig $repo/gitconfig
check_link ~/sanity $repo/sanity
check_link ~/.local/bin/sanity $repo/sanity
[[ -x $repo/sanity ]] && pass "sanity is executable" || fail "sanity not executable (chmod +x)"
[[ -n $(git -C $repo status --porcelain 2>/dev/null) ]] && info "repo has uncommitted changes"
section "📂 PATH health"
typeset -a entries missing dups
typeset -A seen
typeset p
entries=(${(s/:/)PATH})
for p in $entries; do
[[ -z $p ]] && continue
if (( ${seen[$p]:-0} )); then dups+=($p); fi
seen[$p]=1
case $p in
/var/run/com.apple.security.cryptexd/*|/opt/pmk/*|/pkg/*) continue ;;
*/Claude/local-agent-mode-sessions/*) continue ;;
esac
[[ -d $p ]] || missing+=($p)
done
info "${#entries} entries"
if (( ${#missing} )); then
warn "missing dirs on PATH: ${(j:, :)missing}"
else
pass "all PATH entries exist"
fi
if (( ${#dups} )); then
warn "duplicate PATH entries: ${(j:, :)${(u)dups}}"
else
pass "no duplicate PATH entries"
fi
typeset -a want_on_path
want_on_path=("$HOME/.local/bin")
[[ -n $hb_python ]] && want_on_path+=("$hb_python/libexec/bin")
[[ -n ${BOBJ:-} ]] && want_on_path+=("$BOBJ")
[[ -n ${COMPILED:-} ]] && want_on_path+=("$COMPILED")
for p in $want_on_path; do
if (( ${entries[(Ie)$p]} )); then
pass "on PATH: ${p/#$HOME/~}"
else
warn "not on PATH: ${p/#$HOME/~}"
fi
done
section "🍺 Homebrew"
if ! command -v brew >/dev/null; then
fail "brew not found"
else
typeset bprefix=$(brew --prefix)
pass "$(brew --version | head -1) @ $bprefix"
if [[ ${ENV_FLAVOR:-} == 💻 && $bprefix != /opt/homebrew ]]; then
warn "flavor 💻 but brew prefix is $bprefix"
elif [[ ${ENV_FLAVOR:-} == 🖥️ && $bprefix != /usr/local ]]; then
warn "flavor 🖥️ but brew prefix is $bprefix"
fi
if (( ! quick )); then
typeset -i outdated=$(HOMEBREW_NO_AUTO_UPDATE=1 brew outdated --quiet 2>/dev/null | wc -l)
if (( outdated )); then
info "$outdated outdated formula(e) — alias: werb"
else
pass "no outdated formulae"
fi
fi
fi
section "🐍 Python (expect $want_pyver)"
check_dir "HOMEBREW_PYTHON" "$hb_python"
typeset py3=$(command -v python3)
if [[ -z $py3 ]]; then
fail "python3 not found"
else
typeset pyver=$(python3 -V 2>&1)
typeset pyexe=$(python3 -c 'import sys; print(sys.executable)' 2>/dev/null)
info "python3 → $py3"
info "sys.executable → $pyexe"
if [[ -z $want_pyver ]]; then
warn "could not parse HOMEBREW_PYTHON from .zshrc — skipping version match"
elif [[ $pyver == "Python $want_pyver."* ]]; then
pass "$pyver matches python@$want_pyver"
else
fail "$pyver ≠ expected python@$want_pyver"
fi
if [[ -n $hb_python && ${py3:A} == ${hb_python:A}/* ]]; then
pass "python3 resolves under HOMEBREW_PYTHON"
else
warn "python3 not under HOMEBREW_PYTHON — PATH shadowing?"
fi
if [[ ${Python3_EXECUTABLE:-} == $hb_python/libexec/bin/python3 && -x ${Python3_EXECUTABLE:-} ]]; then
pass "Python3_EXECUTABLE wired for cmake"
else
warn "Python3_EXECUTABLE = ${Python3_EXECUTABLE:-unset}"
fi
fi
typeset pipver=$(python3 -m pip --version 2>/dev/null | awk '{print $2}')
if [[ -n $pipver ]]; then
pass "pip OK ($pipver)"
else
fail "python3 -m pip broken"
fi
[[ -f ~/venvs/v/bin/activate ]] && pass "venv 'v' present (alias venv)" || warn "~/venvs/v missing (alias venv will fail)"
# Jupyter lives in ~/venvs/v (not pipx, not brew) so widgets resolve — see README
if [[ -x ~/venvs/v/bin/jupyter ]]; then
# query the venv's python directly: `jupyter lab --version` and
# `jupyter labextension list` each spin up ~1.5s, imports are ~0.05s.
# For JupyterLab 4 prebuilt extensions, an importable package == enabled.
pass "jupyter in venv ($(~/venvs/v/bin/python -c 'import jupyterlab as j; print(j.__version__)' 2>/dev/null))"
if ~/venvs/v/bin/python -c 'import jupyterlab_widgets' 2>/dev/null; then
pass "ipywidgets frontend enabled"
else
warn "jupyterlab_widgets missing — widgets will render blank"
fi
else
warn "no jupyter in ~/venvs/v"
fi
# these must NOT exist: a brew/pipx jupyter shadows the venv copy
if command -v jupyter-lab >/dev/null 2>&1; then
warn "jupyter-lab on PATH ($(command -v jupyter-lab)) shadows the venv copy"
else
pass "no competing jupyter-lab on PATH"
fi
section "🧲 torrent"
# Homebrew builds libtorrent's bindings for its default python3, not for HOMEBREW_PYTHON
typeset brew_py3=${HOMEBREW_PREFIX:-/opt/homebrew}/bin/python3
if [[ ! -f ~/Documents/T/torrent_dl.py ]]; then
warn "~/Documents/T/torrent_dl.py missing"
elif $brew_py3 -c 'import libtorrent' >/dev/null 2>&1; then
pass "libtorrent imports in Homebrew's python3 ($($brew_py3 -V 2>&1 | cut -d' ' -f2))"
else
fail "libtorrent not importable in $brew_py3 — torrent won't run"
fi
section "📦 pipx"
if ! command -v pipx >/dev/null; then
warn "pipx not found"
else
pass "pipx → $(command -v pipx)"
if [[ -z ${PIPX_DEFAULT_PYTHON:-} ]]; then
warn "PIPX_DEFAULT_PYTHON unset"
elif [[ ! -x $PIPX_DEFAULT_PYTHON ]]; then
fail "PIPX_DEFAULT_PYTHON missing: $PIPX_DEFAULT_PYTHON"
else
pass "PIPX_DEFAULT_PYTHON = $PIPX_DEFAULT_PYTHON"
fi
typeset -a app_pys
app_pys=(${(f)"$(pipx list 2>/dev/null | grep -Eo 'Python 3[0-9.]*' | sort -u)"})
if (( ${#app_pys} )); then
typeset v bad=0
for v in $app_pys; do
[[ -n $want_pyver && $v == "Python $want_pyver."* ]] || bad=1
done
if (( bad )); then
warn "pipx apps on other python: ${(j:, :)app_pys} — pipx reinstall-all"
else
pass "pipx apps use Python $want_pyver"
fi
else
info "no pipx apps installed"
fi
fi
section "⚛️ ROOT"
check_var ROOTSYS
if command -v root-config >/dev/null; then
pass "ROOT $(root-config --version)"
else
fail "root-config not found"
fi
check_dir "ROOT_DIR (cmake)" "${ROOT_DIR:-}" warn
if (( ! quick )) && command -v root >/dev/null; then
if root -l -b -q -e 'gSystem->Exit(0);' >/dev/null 2>&1; then
pass "root runs (batch macro)"
else
fail "root failed to run a batch macro"
fi
# Homebrew builds ROOT (and libtorrent) for its default python3, not for HOMEBREW_PYTHON
typeset brew_py=${HOMEBREW_PREFIX:-/opt/homebrew}/bin/python3
if $brew_py -c 'import ROOT' >/dev/null 2>&1; then
pass "PyROOT imports in Homebrew's python3 ($($brew_py -V 2>&1 | cut -d' ' -f2))"
else
warn "PyROOT not importable in $brew_py (ROOT built for Python $(root-config --python-version 2>/dev/null))"
fi
fi
section "📦 pixi physics envs"
if ! command -v pixi >/dev/null; then
fail "pixi not found (brew install pixi)"
else
pass "$(pixi --version)"
typeset ws
for ws in remage geant4; do
if [[ -f $repo/pixi/$ws/pixi.toml && -f $repo/pixi/$ws/pixi.lock ]]; then
pass "workspace pixi/$ws (manifest + lock)"
else
fail "workspace pixi/$ws is missing pixi.toml or pixi.lock"
fi
done
fi
section "🔮 remage (pixi/remage)"
check_link ~/.local/bin/remage $repo/bin/remage
check_link ~/.local/bin/remage-cpp $repo/bin/remage-cpp
if [[ $(command -v remage) == $HOME/.local/bin/remage ]]; then
pass "remage on PATH is the pixi wrapper"
else
fail "remage resolves to ${$(command -v remage):-nothing} (expected ~/.local/bin/remage)"
fi
if (( ! quick )); then
typeset rich=$(remage-cpp --version-rich 2>/dev/null)
typeset rmg_ver=${${(M)${(f)rich}:#version:*}#version: }
typeset rmg_g4=${${${(M)${(f)rich}:#*Geant4 version:*}##*geant4-}%% *}
[[ -n $rmg_ver ]] && pass "remage $rmg_ver (Geant4 $rmg_g4)" || fail "remage-cpp --version-rich failed"
[[ $rich == *"HDF5 support: yes"* ]] && pass "HDF5 / LH5 output supported" || fail "remage has no HDF5 support (no LH5 output)"
fi
check_dir "LEGEND_METADATA" "${LEGEND_METADATA:-}" warn
section "☢️ Geant4 for your own apps (pixi/geant4)"
check_link ~/.local/bin/g4 $repo/bin/g4
check_link ~/.local/bin/g4build $repo/bin/g4build
if (( ! quick )); then
typeset g4v=$(g4 geant4-config --version 2>/dev/null)
[[ -n $g4v ]] && pass "Geant4 $g4v" || fail "g4 geant4-config failed"
[[ $(g4 geant4-config --has-feature qt 2>/dev/null) == yes ]] && pass "Qt UI + OpenGL vis built in" \
|| warn "pixi Geant4 has no Qt (headless noqt_* build?)"
typeset g4data=$(g4 geant4-config --check-datasets 2>/dev/null)
typeset -i g4miss=$(print -r -- "$g4data" | grep -ciE 'notfound|not found|missing')
if [[ -z $g4data ]]; then
warn "could not query Geant4 datasets"
elif (( g4miss )); then
fail "$g4miss Geant4 dataset(s) missing"
else
pass "all Geant4 datasets installed"
fi
typeset g4root=$(g4 root-config --version 2>/dev/null)
[[ -n $g4root ]] && pass "ROOT $g4root alongside it" || warn "no ROOT in pixi/geant4"
fi
[[ -n ${G4VIS_DEFAULT_DRIVER:-} ]] && pass "G4VIS_DEFAULT_DRIVER = $G4VIS_DEFAULT_DRIVER" || warn "G4VIS_DEFAULT_DRIVER unset"
section "🥓 bacon2Data"
check_dir "BACONHOME" "${BACONHOME:-}" warn
check_dir "BOBJ" "${BOBJ:-}" warn
check_dir "COMPILED" "${COMPILED:-}" warn
section "🔧 Build toolchain"
check_cmd cmake
check_cmd pkg-config
check_cmd git
if xcode-select -p >/dev/null 2>&1; then
pass "Xcode CLT @ $(xcode-select -p)"
else
fail "Xcode Command Line Tools missing (xcode-select --install)"
fi
typeset var val entry
typeset -a parts bad
for var in CMAKE_PREFIX_PATH; do
val=${(P)var}
if [[ -z $val ]]; then
warn "$var unset"
continue
fi
if [[ $var == CMAKE_PREFIX_PATH ]]; then parts=(${(s/;/)val}); else parts=(${(s/:/)val}); fi
bad=()
for entry in $parts; do
[[ -z $entry || -d $entry ]] || bad+=($entry)
done
if (( ${#bad} )); then
warn "$var has missing dirs: ${(j:, :)bad}"
else
pass "$var ok (${#parts} entries)"
fi
done
section "🔐 SSH"
typeset perms=$(stat -f '%Lp' ~/.ssh 2>/dev/null)
if [[ $perms == 700 ]]; then
pass "~/.ssh perms 700"
else
warn "~/.ssh perms $perms (want 700)"
fi
# do the Host * global defaults actually apply?
typeset eff=$(ssh -G github.com 2>/dev/null)
if [[ $eff == *"controlmaster auto"* ]]; then
pass "global Host * defaults apply (ControlMaster auto)"
else
fail "global Host * defaults NOT applying — check 'Host *' line in config"
fi
typeset -a idfiles
idfiles=(${(f)"$(awk '/^[[:space:]]*IdentityFile/ {print $2}' $sshconf 2>/dev/null)"})
typeset f real
for f in ${(u)idfiles}; do
real=${f/#\~/$HOME}
if [[ ! -f $real ]]; then
fail "key missing: $f"
continue
fi
perms=$(stat -f '%Lp' $real)
if [[ $perms == 600 ]]; then
pass "key $f (600)"
else
warn "key $f perms $perms (want 600)"
fi
done
if ssh-add -l >/dev/null 2>&1; then
info "ssh-agent: $(ssh-add -l 2>/dev/null | wc -l | tr -d ' ') key(s) loaded"
else
info "ssh-agent: no keys loaded (Keychain loads on demand)"
fi
typeset -a socks
socks=(~/.ssh/cm-*(N=))
(( ${#socks} )) && info "${#socks} live ControlMaster socket(s)"
if (( net )); then
section "🌐 SSH connectivity (--net)"
typeset gh_out=$(ssh -T -o BatchMode=yes -o ConnectTimeout=8 git@github.com 2>&1)
if [[ $gh_out == *"successfully authenticated"* ]]; then
pass "github.com auth OK"
else
fail "github.com auth failed: ${gh_out%%$'\n'*}"
fi
if ssh -o BatchMode=yes -o ConnectTimeout=8 daq true 2>/dev/null; then
pass "daq reachable"
else
fail "daq unreachable or auth failed"
fi
if ssh -o BatchMode=yes -o ConnectTimeout=8 nersc true 2>/dev/null; then
pass "nersc reachable"
else
warn "nersc unreachable (sshproxy/MFA may be needed)"
fi
fi
# ╭───────────────────────────────╮
# │ ♻️ Reproducible from repo │
# ╰───────────────────────────────╯
section "♻️ Reproducible from repo"
if command -v brew >/dev/null; then
# A cask counts as satisfied if its app is already in /Applications (installed by
# hand before brew managed it) — new Macs still get it from the Brewfile.
typeset -a unmet hand real
typeset bout cask app; typeset -i brc
bout=$(brew bundle check --file $repo/Brewfile --verbose --no-upgrade 2>&1); brc=$? # unmet lines go to stderr
unmet=(${(f)"$(print -r -- $bout | sed -n 's/^→ //p')"})
for u in $unmet; do
if [[ $u =~ '^Cask ([^ ]+) needs to be installed' ]]; then
cask=$match[1]
app=$(brew info --cask --json=v2 $cask 2>/dev/null \
| jq -r '.casks[0].artifacts[] | objects | .app // empty | .[] | strings' 2>/dev/null | head -1)
if [[ -n $app && ( -d /Applications/$app || -d ~/Applications/$app ) ]]; then
hand+=($cask); continue
fi
fi
real+=("$u")
done
if (( brc && ! ${#unmet} )); then # failed, but output format unrecognised
warn "Brewfile not satisfied — run: brew bundle check --file $repo/Brewfile --verbose"
elif (( ${#real} )); then
warn "Brewfile not satisfied: ${(j:; :)real}"
else
pass "everything in Brewfile is installed"
fi
(( ${#hand} )) && info "installed by hand, not via brew (fine): ${(j:, :)hand}"
if (( ! quick )); then
typeset -a extra=(${(f)"$(brew bundle cleanup --file $repo/Brewfile 2>/dev/null | grep -vE '^(Would|Run )')"})
(( ${#extra} )) && warn "${#extra} installed package(s) not in Brewfile — brew bundle cleanup --file $repo/Brewfile" \
|| pass "no installs missing from Brewfile"
fi
fi
check_lock() { # check_lock <venv> <lockfile>
typeset venv=$1 lock=$2
[[ -x $venv/bin/python ]] || { warn "${venv/#$HOME/~} missing"; return; }
[[ -f $lock ]] || { warn "lockfile ${lock/#$HOME/~} missing"; return; }
typeset -a have want extra missing
have=(${(f)"$($venv/bin/python -m pip freeze 2>/dev/null)"})
want=(${(f)"$(grep -v '^#' $lock)"})
extra=(${have:|want}); missing=(${want:|have}) # count arrays, not joined strings
typeset -i drift=$(( ${#extra} + ${#missing} ))
(( drift )) && warn "${venv/#$HOME/~} drifted from ${lock:t} ($drift line(s)) — refresh with pip freeze" \
|| pass "${venv/#$HOME/~} matches venvs/${lock:t}"
}
check_lock ~/venvs/v $repo/venvs/v.txt
# ╭───────────────────────────────╮
# │ 🧾 Summary │
# ╰───────────────────────────────╯
section "🧾 Summary"
print -P " %F{green}✅ $n_pass passed%f %F{yellow}⚠️ $n_warn warnings%f %F{red}❌ $n_fail failures%f"
if (( ${#problems} )); then
print
typeset prob
for prob in $problems; do
print -r -- " $prob"
done
fi
print
if (( n_fail )); then
print -P "%F{red}%B❌ not sane — $n_fail failure(s).%b%f"
exit 1
elif (( n_warn )); then
print -P "%F{yellow}%B⚠️ mostly sane — $n_warn warning(s).%b%f"
else
print -P "%F{green}%B✅ all systems go — sane.%b%f"
fi