packages-scripts/analysis/find-consumers.sh
2026-06-10 03:21:32 -07:00

407 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# find-consumers.sh - Find all consumers of a package across ~/Code
#
# Searches ~/Code/@applications, @packages, @services for projects that depend
# on a given package. Auto-detects Python (pyproject.toml) vs JavaScript
# (package.json) packages. Shows dependency source (registry/path/link/git)
# and optionally where imports occur in each consumer.
#
# Usage:
# find-consumers.sh [options] <package-path>
#
# Arguments:
# <package-path> Path to package, relative paths resolve from @packages
# Examples: @ts/queue, @py/model-boss, ~/Code/@packages/@ts/ui-react
#
# Options:
# -i, --imports Show import locations within each consumer (recommended)
# -p, --potential Include projects that could use the package but don't
# -h, --help Show this help
#
# Output:
# [REGISTRY] Installed from forge.black.lan (pip/npm registry)
# [PATH] Local file:// or -e editable install
# [LINK] npm link: dependency
# [GIT] git+https:// or git+ssh:// dependency
# [POTENTIAL] Project without the dependency (with -p flag)
#
# Examples:
# find-consumers.sh @ts/queue # basic consumer list
# find-consumers.sh -i @ts/service-addresses # with import locations
# find-consumers.sh -i -p @py/model-boss # imports + potential consumers
#
set -uo pipefail
# Build search dirs from existing directories
_build_search_dirs() {
local dirs=""
for d in "$HOME/Code/@applications" "$HOME/Code/@packages" "$HOME/Code/@services"; do
[[ -d "$d" ]] && dirs="$dirs $d"
done
echo "$dirs"
}
SEARCH_DIRS="${SEARCH_DIRS:-$(_build_search_dirs)}"
PACKAGES_ROOT="$HOME/Code/@packages"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
DIM='\033[2m'
NC='\033[0m'
# Counters
REGISTRY_DEPS=0
PATH_DEPS=0
LINK_DEPS=0
GIT_DEPS=0
IMPORTS_FOUND=0
POTENTIAL_CONSUMERS=0
# State
declare -A CONSUMER_DIRS_MAP=()
SHOW_POTENTIAL=false
SHOW_IMPORTS=false
PKG_PATH=""
PKG_TYPE=""
PKG_NAME=""
PKG_VERSION=""
PKG_MODULE=""
# ============================================================================
# Utility Functions
# ============================================================================
die() {
echo -e "${RED}Error: $1${NC}" >&2
exit 1
}
resolve_path() {
local input="$1"
[[ "$input" == /* && -d "$input" ]] && { echo "$input"; return 0; }
[[ -d "$PACKAGES_ROOT/$input" ]] && { echo "$PACKAGES_ROOT/$input"; return 0; }
[[ -d "$PACKAGES_ROOT/@$input" ]] && { echo "$PACKAGES_ROOT/@$input"; return 0; }
return 1
}
detect_package_type() {
local path="$1"
[[ -f "$path/pyproject.toml" ]] && { echo "python"; return; }
[[ -f "$path/package.json" ]] && { echo "javascript"; return; }
echo "unknown"
}
get_project_root() {
local file="$1"
local dir=$(dirname "$file")
# Walk up until we find pyproject.toml, package.json, or hit ~/Code
while [[ "$dir" != "$HOME/Code" && "$dir" != "/" ]]; do
[[ -f "$dir/pyproject.toml" || -f "$dir/package.json" ]] && { echo "$dir"; return; }
dir=$(dirname "$dir")
done
echo "$(dirname "$file")"
}
# ============================================================================
# Package Info Extraction
# ============================================================================
extract_python_info() {
local pyproject="$PKG_PATH/pyproject.toml"
PKG_NAME=$(grep -A20 '^\[project\]' "$pyproject" 2>/dev/null | grep '^name' | head -1 | sed 's/.*= *"\([^"]*\)".*/\1/' || echo "")
PKG_VERSION=$(grep -A20 '^\[project\]' "$pyproject" 2>/dev/null | grep '^version' | head -1 | sed 's/.*= *"\([^"]*\)".*/\1/' || echo "")
PKG_MODULE="${PKG_NAME//-/_}"
[[ -n "$PKG_NAME" ]] || die "Could not extract package name from pyproject.toml"
}
extract_js_info() {
local package_json="$PKG_PATH/package.json"
PKG_NAME=$(grep '"name"' "$package_json" 2>/dev/null | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || echo "")
PKG_VERSION=$(grep '"version"' "$package_json" 2>/dev/null | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || echo "")
[[ -n "$PKG_NAME" ]] || die "Could not extract package name from package.json"
}
# ============================================================================
# Import Search per Consumer
# ============================================================================
show_python_imports_for_consumer() {
local consumer_dir="$1"
[[ "$SHOW_IMPORTS" == false ]] && return
local imports
imports=$(find "$consumer_dir" \( -name ".venv" -o -name "venv" -o -name "__pycache__" -o -name "dist" \) -prune -o -name "*.py" -type f -print 2>/dev/null | \
xargs grep -HnE "^\s*import ${PKG_MODULE}|^\s*from ${PKG_MODULE}" 2>/dev/null || true)
if [[ -n "$imports" ]]; then
echo -e " ${CYAN}Imports:${NC}"
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local relpath="${line#$consumer_dir/}"
echo -e " ${DIM}$relpath${NC}"
((IMPORTS_FOUND++))
done <<< "$imports"
fi
}
show_js_imports_for_consumer() {
local consumer_dir="$1"
[[ "$SHOW_IMPORTS" == false ]] && return
local imports
imports=$(find "$consumer_dir" \( -name "node_modules" -o -name "dist" \) -prune -o \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.mjs" \) -type f -print 2>/dev/null | \
xargs grep -HnE "from ['\"]${PKG_NAME}[/'\":]|require\(['\"]${PKG_NAME}[/'\":]" 2>/dev/null || true)
if [[ -n "$imports" ]]; then
echo -e " ${CYAN}Imports:${NC}"
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local relpath="${line#$consumer_dir/}"
echo -e " ${DIM}$relpath${NC}"
((IMPORTS_FOUND++))
done <<< "$imports"
fi
}
# ============================================================================
# Consumer Search Functions
# ============================================================================
classify_and_print_dep() {
local file="$1"
local dep_line="$2"
local lang="$3"
local consumer_dir
consumer_dir=$(get_project_root "$file")
CONSUMER_DIRS_MAP["$consumer_dir"]=1
local source_type=""
local source_color=""
if echo "$dep_line" | grep -qE '@ *file://|^-e|file:'; then
source_type="PATH"
source_color="$YELLOW"
((PATH_DEPS++))
elif echo "$dep_line" | grep -q 'link:'; then
source_type="LINK"
source_color="$YELLOW"
((LINK_DEPS++))
elif echo "$dep_line" | grep -qE 'git\+https://|git\+ssh://|github:'; then
source_type="GIT"
source_color="$MAGENTA"
((GIT_DEPS++))
else
source_type="REGISTRY"
source_color="$GREEN"
((REGISTRY_DEPS++))
fi
echo ""
echo -e "${source_color}[$source_type]${NC} $consumer_dir"
echo -e " ${DIM}$dep_line${NC}"
if [[ "$lang" == "python" ]]; then
show_python_imports_for_consumer "$consumer_dir"
else
show_js_imports_for_consumer "$consumer_dir"
fi
}
search_python_deps() {
echo -e "${BLUE}--- Python Consumers ---${NC}"
local found=0
# Search pyproject.toml
while IFS= read -r file; do
[[ -z "$file" ]] && continue
[[ "$file" == "$PKG_PATH/pyproject.toml" ]] && continue
local dep_line
dep_line=$(grep -E "\"${PKG_NAME}(>=|<=|==|~=|!=|>|<|@|\[)[^\"]*\"" "$file" 2>/dev/null | head -1 || true)
if [[ -n "$dep_line" ]]; then
classify_and_print_dep "$file" "$dep_line" "python"
found=1
fi
done < <(find $SEARCH_DIRS \( -name "node_modules" -o -name ".venv" -o -name "venv" -o -name "dist" -o -name "__pycache__" \) -prune -o -name "pyproject.toml" -type f -print 2>/dev/null | xargs grep -l "$PKG_NAME" 2>/dev/null || true)
# Search requirements.txt
while IFS= read -r file; do
[[ -z "$file" ]] && continue
local dep_line
dep_line=$(grep -E "^${PKG_NAME}|^-e.*${PKG_NAME}" "$file" 2>/dev/null | head -1 || true)
if [[ -n "$dep_line" ]]; then
classify_and_print_dep "$file" "$dep_line" "python"
found=1
fi
done < <(find $SEARCH_DIRS \( -name "node_modules" -o -name ".venv" -o -name "venv" -o -name "dist" -o -name "__pycache__" \) -prune -o -name "requirements.txt" -type f -print 2>/dev/null | xargs grep -l "$PKG_NAME" 2>/dev/null || true)
(( found == 0 )) && echo -e "\n${DIM}(none)${NC}" || true
}
search_js_deps() {
echo -e "${BLUE}--- JavaScript Consumers ---${NC}"
local found=0
while IFS= read -r file; do
[[ -z "$file" ]] && continue
[[ "$file" == "$PKG_PATH/package.json" ]] && continue
local dep_line
dep_line=$(grep -E "\"${PKG_NAME}\":" "$file" 2>/dev/null | head -1 || true)
if [[ -n "$dep_line" ]]; then
classify_and_print_dep "$file" "$dep_line" "javascript"
found=1
fi
done < <(find $SEARCH_DIRS \( -name "node_modules" -o -name "dist" \) -prune -o -name "package.json" -type f -print 2>/dev/null | xargs grep -l "\"${PKG_NAME}\"" 2>/dev/null || true)
(( found == 0 )) && echo -e "\n${DIM}(none)${NC}" || true
}
# ============================================================================
# Potential Consumers
# ============================================================================
search_potential() {
[[ "$SHOW_POTENTIAL" == false ]] && return
local file_pattern="$1"
echo ""
echo -e "${BLUE}--- Potential Consumers ---${NC}"
echo ""
local found=0
while IFS= read -r file; do
[[ -z "$file" ]] && continue
[[ "$file" == "$PKG_PATH"* ]] && continue
local consumer_dir
consumer_dir=$(get_project_root "$file")
[[ -n "${CONSUMER_DIRS_MAP[$consumer_dir]:-}" ]] && continue
echo -e "${CYAN}[POTENTIAL]${NC} $consumer_dir"
((POTENTIAL_CONSUMERS++))
found=1
CONSUMER_DIRS_MAP["$consumer_dir"]=1 # Don't show duplicates
done < <(find $SEARCH_DIRS \( -name "node_modules" -o -name ".venv" -o -name "venv" -o -name "dist" -o -name "__pycache__" \) -prune -o \( $file_pattern \) -type f -print 2>/dev/null || true)
(( found == 0 )) && echo -e "${DIM}(none)${NC}" || true
}
# ============================================================================
# Summary
# ============================================================================
print_summary() {
echo ""
echo -e "${BLUE}=== Summary ===${NC}"
local total_deps=$((REGISTRY_DEPS + PATH_DEPS + LINK_DEPS + GIT_DEPS))
[[ $REGISTRY_DEPS -gt 0 ]] && echo -e "Registry (forge): ${GREEN}${REGISTRY_DEPS}${NC}"
[[ $PATH_DEPS -gt 0 ]] && echo -e "Path (file://): ${YELLOW}${PATH_DEPS}${NC}"
[[ $LINK_DEPS -gt 0 ]] && echo -e "Link (link:): ${YELLOW}${LINK_DEPS}${NC}"
[[ $GIT_DEPS -gt 0 ]] && echo -e "Git (git+): ${MAGENTA}${GIT_DEPS}${NC}"
[[ $IMPORTS_FOUND -gt 0 ]] && echo -e "Import locations: ${CYAN}${IMPORTS_FOUND}${NC}"
[[ $POTENTIAL_CONSUMERS -gt 0 ]] && echo -e "Potential: ${DIM}${POTENTIAL_CONSUMERS}${NC}"
(( total_deps == 0 )) && echo -e "${DIM}No dependencies found${NC}" || true
# Warnings
local non_registry=$((PATH_DEPS + LINK_DEPS))
if [[ $non_registry -gt 0 ]]; then
echo ""
echo -e "${YELLOW}Warning: $non_registry path/link dep(s) should migrate to registry${NC}"
exit 1
fi
echo ""
}
# ============================================================================
# Main
# ============================================================================
print_usage() {
echo "Usage: $0 [options] <package-path>"
echo ""
echo "Options:"
echo " -p, --potential Include potential consumers"
echo " -i, --imports Show import locations per consumer"
echo " -h, --help Show this help"
echo ""
echo "Examples:"
echo " $0 @ts/queue"
echo " $0 -i @ts/service-addresses # detailed with imports"
echo " $0 -p @py/model-boss # with potential consumers"
}
main() {
local positional_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
-p|--potential) SHOW_POTENTIAL=true; shift ;;
-i|--imports) SHOW_IMPORTS=true; shift ;;
-h|--help) print_usage; exit 0 ;;
-*) die "Unknown option: $1" ;;
*) positional_args+=("$1"); shift ;;
esac
done
[[ ${#positional_args[@]} -lt 1 ]] && { print_usage; exit 1; }
PKG_PATH=$(resolve_path "${positional_args[0]}") || die "Package not found: ${positional_args[0]}"
PKG_TYPE=$(detect_package_type "$PKG_PATH")
[[ "$PKG_TYPE" == "unknown" ]] && die "Cannot detect package type"
echo -e "${BLUE}=== Package Consumer Search ===${NC}"
case "$PKG_TYPE" in
python)
extract_python_info
echo -e "Package: ${YELLOW}${PKG_NAME}${NC} ${DIM}(Python)${NC}"
echo -e "Version: ${PKG_VERSION}"
echo -e "Module: ${PKG_MODULE}"
echo ""
search_python_deps
search_potential "-name pyproject.toml -o -name requirements.txt"
;;
javascript)
extract_js_info
echo -e "Package: ${YELLOW}${PKG_NAME}${NC} ${DIM}(JavaScript)${NC}"
echo -e "Version: ${PKG_VERSION}"
echo ""
search_js_deps
search_potential "-name package.json"
;;
esac
print_summary
}
main "$@"