Files
music_library/scripts/dev/summary
T
2026-04-20 10:11:34 +01:00

302 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
#MISE description="Summarise codebase shape"
set -euo pipefail
source "$(git rev-parse --show-toplevel)/scripts/_helpers.sh"
ensure_working_directory!
# Thin contexts have no sub-directory but are contexts per docs/architecture.md.
THIN_CONTEXTS=(
"lib/music_library/collection.ex"
"lib/music_library/wishlist.ex"
"lib/music_library/search.ex"
"lib/music_library/scrobble_activity.ex"
"lib/music_library/maintenance.ex"
)
# Namespace modules whose matching sibling dir is structural, not a context.
CONTEXT_EXCLUSIONS=(
"lib/music_library.ex"
"lib/music_library_web.ex"
)
BAR_WIDTH=33
INNER_WIDTH=62 # dashes between box corners
BOX_WIDTH=$((INNER_WIDTH + 2))
if [[ -n "${NO_COLOR:-}" ]] || ! command -v tput >/dev/null 2>&1; then
COLOR=0
BLOCK_FILL="#"
BLOCK_EMPTY="-"
BOX_TL="+"
BOX_TR="+"
BOX_BL="+"
BOX_BR="+"
BOX_H="-"
BOX_V="|"
RULE="-"
reset=""
dim=""
bold=""
declare -a PALETTE=("" "" "" "" "" "")
else
COLOR=1
BLOCK_FILL="█"
BLOCK_EMPTY="░"
BOX_TL="╭"
BOX_TR="╮"
BOX_BL="╰"
BOX_BR="╯"
BOX_H="─"
BOX_V="│"
RULE="─"
reset="$(tput sgr0)"
dim="$(tput setaf 8 2>/dev/null || tput setaf 0)"
bold="$(tput bold)"
declare -a PALETTE=(
"$(tput setaf 6)" # cyan
"$(tput setaf 5)" # magenta
"$(tput setaf 3)" # yellow
"$(tput setaf 2)" # green
"$(tput setaf 4)" # blue
"$(tput setaf 7)" # white
)
fi
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
# Build "all lib .ex files" (sorted, unique) as the candidate pool.
all_lib="$workdir/all_lib"
fd --type file --extension ex . lib | sort -u > "$all_lib"
# Helper: subtract a classified list from the candidate pool in place.
function consume {
local classified="$1"
local remaining="$workdir/remaining"
comm -23 "$all_lib" <(sort -u "$classified") > "$remaining"
mv "$remaining" "$all_lib"
}
# 1. Schemas — any .ex with `use Ecto.Schema`.
schemas="$workdir/schemas"
grep -rlE '^\s*use\s+Ecto\.Schema' lib | sort -u > "$schemas" || true
# Intersect with the candidate pool so we only count pool members.
comm -12 "$all_lib" "$schemas" > "$workdir/schemas.scoped"
mv "$workdir/schemas.scoped" "$schemas"
consume "$schemas"
# 2. Controllers.
controllers="$workdir/controllers"
fd --type file --extension ex . lib/music_library_web/controllers | sort -u > "$controllers"
comm -12 "$all_lib" "$controllers" > "$workdir/controllers.scoped"
mv "$workdir/controllers.scoped" "$controllers"
consume "$controllers"
# 3. Components.
components="$workdir/components"
fd --type file --extension ex . lib/music_library_web/components | sort -u > "$components"
comm -12 "$all_lib" "$components" > "$workdir/components.scoped"
mv "$workdir/components.scoped" "$components"
consume "$components"
# 4. Live Views.
liveviews="$workdir/liveviews"
fd --type file --extension ex . lib/music_library_web/live | sort -u > "$liveviews"
comm -12 "$all_lib" "$liveviews" > "$workdir/liveviews.scoped"
mv "$workdir/liveviews.scoped" "$liveviews"
consume "$liveviews"
# 5. Contexts — depth-1 .ex with sibling directory OR in THIN_CONTEXTS allowlist.
contexts="$workdir/contexts"
: > "$contexts"
for f in lib/*.ex lib/music_library/*.ex; do
[[ -f "$f" ]] || continue
dir="${f%.ex}"
if [[ -d "$dir" ]]; then
echo "$f"
fi
done >> "$contexts"
for f in "${THIN_CONTEXTS[@]}"; do
[[ -f "$f" ]] && echo "$f" >> "$contexts"
done
sort -u "$contexts" -o "$contexts"
excluded="$workdir/context_exclusions"
printf "%s\n" "${CONTEXT_EXCLUSIONS[@]}" | sort -u > "$excluded"
comm -23 "$contexts" "$excluded" > "$workdir/contexts.filtered"
mv "$workdir/contexts.filtered" "$contexts"
comm -12 "$all_lib" "$contexts" > "$workdir/contexts.scoped"
mv "$workdir/contexts.scoped" "$contexts"
consume "$contexts"
# 6. Logic — everything else.
logic="$workdir/logic"
cp "$all_lib" "$logic"
lib_liveviews=$(wc -l < "$liveviews" | tr -d ' ')
lib_components=$(wc -l < "$components" | tr -d ' ')
lib_controllers=$(wc -l < "$controllers" | tr -d ' ')
lib_contexts=$(wc -l < "$contexts" | tr -d ' ')
lib_schemas=$(wc -l < "$schemas" | tr -d ' ')
lib_logic=$(wc -l < "$logic" | tr -d ' ')
lib_total=$((lib_liveviews + lib_components + lib_controllers + lib_contexts + lib_schemas + lib_logic))
# test/ counts.
test_files=$(fd --type file --full-path '_test\.exs$' test | wc -l | tr -d ' ')
test_support=$(
fd --type file --extension ex --extension exs . test \
| grep -cvE '(_test\.exs|/test_helper\.exs)$'
)
test_hurl=$(fd --type file --extension hurl . test | wc -l | tr -d ' ')
test_total=$((test_files + test_support + test_hurl))
# Stack versions and prod-dep count.
elixir_ver=$(sed -nE 's/^elixir[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' mise.toml)
elixir_ver="${elixir_ver%-otp-*}"
otp_ver=$(sed -nE 's/^erlang[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' mise.toml)
phoenix_ver=$(sed -nE 's/.*"phoenix":[[:space:]]*\{:hex, :phoenix, "([^"]+)".*/\1/p' mix.lock)
liveview_ver=$(sed -nE 's/.*"phoenix_live_view":[[:space:]]*\{:hex, :phoenix_live_view, "([^"]+)".*/\1/p' mix.lock)
# Prod deps: lines starting with `{:name` in mix.exs minus those tagged only: :dev/:test.
total_deps=$(grep -cE '^[[:space:]]*\{:[a-z_]+' mix.exs)
nonprod_deps=$(grep -cE 'only:[[:space:]]*(:dev|:test|\[:dev|\[:test)' mix.exs)
prod_deps=$((total_deps - nonprod_deps))
function max_of {
local max=0
for v in "$@"; do
(( v > max )) && max=$v
done
echo "$max"
}
function repeat {
local s="$1" n="$2"
local out=""
for ((i = 0; i < n; i++)); do out+="$s"; done
printf "%s" "$out"
}
function render_bar {
local count="$1" max="$2" color="$3"
local filled=0
if (( max > 0 )); then
filled=$(( count * BAR_WIDTH / max ))
fi
local empty=$(( BAR_WIDTH - filled ))
if (( COLOR )); then
printf "%s%s%s%s%s%s" \
"$color" "$(repeat "$BLOCK_FILL" "$filled")" "$reset" \
"$dim" "$(repeat "$BLOCK_EMPTY" "$empty")" "$reset"
else
printf "%s%s" \
"$(repeat "$BLOCK_FILL" "$filled")" \
"$(repeat "$BLOCK_EMPTY" "$empty")"
fi
}
function render_row {
local label="$1" count="$2" max="$3" total="$4" color="$5"
local pct="0.0"
if (( total > 0 )); then
pct=$(awk -v c="$count" -v t="$total" 'BEGIN { printf "%.1f", (c*100)/t }')
fi
printf "%-15s " "$label"
render_bar "$count" "$max" "$color"
printf " %4d (%5s%%)\n" "$count" "$pct"
}
function render_fact {
local label="$1" value="$2" color="$3"
if (( COLOR )); then
printf "%-15s %s%s%s\n" "$label" "$color" "$value" "$reset"
else
printf "%-15s %s\n" "$label" "$value"
fi
}
function render_header {
local title="$1"
local pad=$(( INNER_WIDTH - ${#title} - 2 ))
(( pad < 0 )) && pad=0
printf "%s%s%s\n" "$BOX_TL" "$(repeat "$BOX_H" "$INNER_WIDTH")" "$BOX_TR"
if (( COLOR )); then
printf "%s %s%s%s%s%s\n" "$BOX_V" "$bold" "$title" "$reset" "$(repeat " " "$pad")" "$BOX_V"
else
printf "%s %s%s%s\n" "$BOX_V" "$title" "$(repeat " " "$pad")" "$BOX_V"
fi
printf "%s%s%s\n" "$BOX_BL" "$(repeat "$BOX_H" "$INNER_WIDTH")" "$BOX_BR"
}
function render_section_header {
local label="$1" total="${2:-}" unit="${3:-}"
local rule
rule="$(repeat "$RULE" "$BOX_WIDTH")"
if (( COLOR )); then
if [[ -n "$total" ]]; then
printf "\n%s%s%s %s%s%s %s\n%s%s%s\n" \
"$bold" "$label" "$reset" \
"$bold" "$total" "$reset" "$unit" \
"$dim" "$rule" "$reset"
else
printf "\n%s%s%s\n%s%s%s\n" \
"$bold" "$label" "$reset" \
"$dim" "$rule" "$reset"
fi
else
if [[ -n "$total" ]]; then
printf "\n%s %s %s\n%s\n" "$label" "$total" "$unit" "$rule"
else
printf "\n%s\n%s\n" "$label" "$rule"
fi
fi
}
render_header "music_library · codebase summary"
render_section_header "stack" "" ""
render_fact "Elixir" "$elixir_ver" "${PALETTE[0]}"
render_fact "OTP" "$otp_ver" "${PALETTE[1]}"
render_fact "Phoenix" "$phoenix_ver" "${PALETTE[2]}"
render_fact "LiveView" "$liveview_ver" "${PALETTE[3]}"
render_fact "Prod deps" "$prod_deps" "${PALETTE[4]}"
lib_max=$(max_of "$lib_liveviews" "$lib_components" "$lib_controllers" "$lib_contexts" "$lib_schemas" "$lib_logic")
render_section_header "lib/" "$lib_total" "modules"
render_row "Live Views" "$lib_liveviews" "$lib_max" "$lib_total" "${PALETTE[0]}"
render_row "Components" "$lib_components" "$lib_max" "$lib_total" "${PALETTE[1]}"
render_row "Controllers" "$lib_controllers" "$lib_max" "$lib_total" "${PALETTE[2]}"
render_row "Contexts" "$lib_contexts" "$lib_max" "$lib_total" "${PALETTE[3]}"
render_row "Schemas" "$lib_schemas" "$lib_max" "$lib_total" "${PALETTE[4]}"
render_row "Logic" "$lib_logic" "$lib_max" "$lib_total" "${PALETTE[5]}"
test_max=$(max_of "$test_files" "$test_support" "$test_hurl")
render_section_header "test/" "$test_total" "files"
render_row "Test files" "$test_files" "$test_max" "$test_total" "${PALETTE[0]}"
render_row "Support" "$test_support" "$test_max" "$test_total" "${PALETTE[1]}"
render_row "HTTP (.hurl)" "$test_hurl" "$test_max" "$test_total" "${PALETTE[2]}"
if command -v backlog >/dev/null 2>&1; then
backlog_raw=$(backlog task list --plain 2>/dev/null)
backlog_todo=$(printf "%s\n" "$backlog_raw" | awk 'BEGIN{p=0} /^To Do:/{p=1;next} /^[A-Z][A-Za-z ]*:$/{p=0} p && /^ \[/{c++} END{print c+0}')
backlog_in_progress=$(printf "%s\n" "$backlog_raw" | awk 'BEGIN{p=0} /^In Progress:/{p=1;next} /^[A-Z][A-Za-z ]*:$/{p=0} p && /^ \[/{c++} END{print c+0}')
backlog_done=$(printf "%s\n" "$backlog_raw" | awk 'BEGIN{p=0} /^Done:/{p=1;next} /^[A-Z][A-Za-z ]*:$/{p=0} p && /^ \[/{c++} END{print c+0}')
backlog_total=$((backlog_todo + backlog_in_progress + backlog_done))
backlog_max=$(max_of "$backlog_todo" "$backlog_in_progress" "$backlog_done")
render_section_header "backlog/" "$backlog_total" "tasks"
render_row "To Do" "$backlog_todo" "$backlog_max" "$backlog_total" "${PALETTE[2]}"
render_row "In Progress" "$backlog_in_progress" "$backlog_max" "$backlog_total" "${PALETTE[0]}"
render_row "Done" "$backlog_done" "$backlog_max" "$backlog_total" "${PALETTE[3]}"
fi
if (( COLOR )); then
printf "\n%sgenerated %s · source: fd + grep + backlog%s\n" \
"$dim" "$(date +%Y-%m-%d)" "$reset"
else
printf "\ngenerated %s · source: fd + grep + backlog\n" "$(date +%Y-%m-%d)"
fi