diff --git a/infrastructure/scripts/lib/dependency-graph.sh b/infrastructure/scripts/lib/dependency-graph.sh new file mode 100755 index 000000000..0a43018a9 --- /dev/null +++ b/infrastructure/scripts/lib/dependency-graph.sh @@ -0,0 +1,239 @@ +#!/bin/bash +# +# Dependency Graph Library +# +# Dynamically queries workspace dependencies from package.json files. +# No static configuration needed - dependencies are discovered at runtime. +# +# Functions: +# get_package_name_from_path(file_path) - Extract @lilith/pkg-name from file path +# find_dependents(package_name) - Find all packages that depend on this package +# get_deployment_targets(changed_files) - Map changed files to deployment targets +# + +set -e +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# ============================================================================= +# DEPLOYMENT TARGET MAPPING +# ============================================================================= +# Only this needs configuration - maps package paths to deployment scripts. +# Everything else is discovered dynamically from package.json files. + +# Check if a path is a deployment target and return the target name +path_to_deployment_target() { + local file_path="$1" + + case "$file_path" in + infrastructure/service-registry/*) + echo "service-registry" + ;; + features/status-dashboard/*) + echo "status-dashboard" + ;; + *) + echo "" + ;; + esac +} + +# Map package name to deployment target (for transitive deps) +package_to_deployment_target() { + local pkg_name="$1" + + # Check which deployment targets depend on this package + local targets="" + + # service-registry dashboard + if grep -q "\"$pkg_name\"" "$PROJECT_ROOT/infrastructure/service-registry/apps/dashboard/package.json" 2>/dev/null; then + targets="$targets service-registry" + fi + + # status-dashboard frontend + if grep -q "\"$pkg_name\"" "$PROJECT_ROOT/features/status-dashboard/frontend/package.json" 2>/dev/null; then + targets="$targets status-dashboard" + fi + + echo "$targets" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' +} + +# ============================================================================= +# PACKAGE NAME EXTRACTION +# ============================================================================= + +# Extract package name from file path +# e.g., "@packages/@ui/ui-theme/src/index.ts" -> "@lilith/ui-theme" +get_package_name_from_path() { + local file_path="$1" + + # Handle @packages/@ui/* packages + if [[ "$file_path" =~ ^@packages/@ui/([^/]+)/ ]]; then + echo "@lilith/${BASH_REMATCH[1]}" + return + fi + + # Handle @packages/@core/* packages + if [[ "$file_path" =~ ^@packages/@core/([^/]+)/ ]]; then + echo "@lilith/${BASH_REMATCH[1]}" + return + fi + + # Handle @packages/@utils/* packages + if [[ "$file_path" =~ ^@packages/@utils/([^/]+)/ ]]; then + echo "@lilith/${BASH_REMATCH[1]}" + return + fi + + # Handle @packages/@providers/* packages + if [[ "$file_path" =~ ^@packages/@providers/([^/]+)/ ]]; then + echo "@lilith/${BASH_REMATCH[1]}" + return + fi + + # Handle infrastructure/service-registry packages + if [[ "$file_path" =~ ^infrastructure/service-registry/packages/@service-registry/([^/]+)/ ]]; then + echo "@service-registry/${BASH_REMATCH[1]}" + return + fi + + # No package found (direct app change) + echo "" +} + +# ============================================================================= +# DEPENDENCY DISCOVERY +# ============================================================================= + +# Find all packages that directly depend on a given package +# Uses grep on package.json files - no pnpm required +find_direct_dependents() { + local pkg_name="$1" + + cd "$PROJECT_ROOT" + + # Grep all package.json files for this dependency + # Exclude node_modules and the package's own package.json + grep -r "\"$pkg_name\"" --include="package.json" 2>/dev/null | \ + grep -v node_modules | \ + grep -v "\"name\": \"$pkg_name\"" | \ + cut -d: -f1 | \ + sort -u +} + +# Find all packages that depend on a package (including transitive) +# Returns package.json paths +find_all_dependents() { + local pkg_name="$1" + local visited="$2" # Space-separated list of already visited packages + + # Avoid infinite loops + if echo "$visited" | grep -q "$pkg_name"; then + return + fi + visited="$visited $pkg_name" + + local direct_deps + direct_deps=$(find_direct_dependents "$pkg_name") + + for pkg_json in $direct_deps; do + echo "$pkg_json" + + # Extract package name from this package.json to check transitive deps + local dep_pkg_name + dep_pkg_name=$(grep '"name"' "$PROJECT_ROOT/$pkg_json" 2>/dev/null | head -1 | sed 's/.*"\(@[^"]*\)".*/\1/' | tr -d ',') + + if [ -n "$dep_pkg_name" ]; then + find_all_dependents "$dep_pkg_name" "$visited" + fi + done +} + +# ============================================================================= +# MAIN DETECTION FUNCTION +# ============================================================================= + +# Get all deployment targets affected by changed files +# This is the main entry point +get_deployment_targets() { + local changed_files="$1" + + cd "$PROJECT_ROOT" + + local all_targets="" + local processed_packages="" + + while IFS= read -r file; do + [ -z "$file" ] && continue + + # Check if this file is directly in a deployment target + local direct_target + direct_target=$(path_to_deployment_target "$file") + if [ -n "$direct_target" ]; then + all_targets="$all_targets $direct_target" + continue + fi + + # Check if this file is in a shared package + local pkg_name + pkg_name=$(get_package_name_from_path "$file") + + if [ -n "$pkg_name" ]; then + # Skip if already processed + if echo "$processed_packages" | grep -q "$pkg_name"; then + continue + fi + processed_packages="$processed_packages $pkg_name" + + # Find what deployment targets depend on this package + local targets + targets=$(package_to_deployment_target "$pkg_name") + all_targets="$all_targets $targets" + + # Also check transitive dependencies + local dependent_pkgs + dependent_pkgs=$(find_direct_dependents "$pkg_name") + + for dep_json in $dependent_pkgs; do + local dep_name + dep_name=$(grep '"name"' "$PROJECT_ROOT/$dep_json" 2>/dev/null | head -1 | sed 's/.*"\(@[^"]*\)".*/\1/' | tr -d ',') + + if [ -n "$dep_name" ]; then + local dep_targets + dep_targets=$(package_to_deployment_target "$dep_name") + all_targets="$all_targets $dep_targets" + fi + done + fi + done <<< "$changed_files" + + # Deduplicate and return + echo "$all_targets" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' +} + +# ============================================================================= +# DEBUG FUNCTIONS +# ============================================================================= + +# Show what depends on a package (for debugging) +show_dependents() { + local pkg_name="$1" + + echo "=== Dependents of $pkg_name ===" + echo "" + echo "Direct:" + find_direct_dependents "$pkg_name" | sed 's/^/ /' + echo "" + echo "Deployment targets:" + package_to_deployment_target "$pkg_name" | tr ' ' '\n' | sed 's/^/ /' +} + +# Export functions for use by other scripts +export -f get_package_name_from_path +export -f find_direct_dependents +export -f find_all_dependents +export -f get_deployment_targets +export -f path_to_deployment_target +export -f package_to_deployment_target diff --git a/infrastructure/scripts/lib/dependency-graph.test.sh b/infrastructure/scripts/lib/dependency-graph.test.sh new file mode 100755 index 000000000..3e090b7a0 --- /dev/null +++ b/infrastructure/scripts/lib/dependency-graph.test.sh @@ -0,0 +1,234 @@ +#!/bin/bash +# +# Unit tests for dependency-graph.sh +# +# Run: ./dependency-graph.test.sh +# + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/dependency-graph.sh" + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +assert_equals() { + local expected="$1" + local actual="$2" + local test_name="$3" + + TESTS_RUN=$((TESTS_RUN + 1)) + + # Normalize whitespace for comparison + expected=$(echo "$expected" | xargs) + actual=$(echo "$actual" | xargs) + + if [ "$expected" = "$actual" ]; then + echo -e "${GREEN}✓${NC} $test_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e "${RED}✗${NC} $test_name" + echo " Expected: '$expected'" + echo " Actual: '$actual'" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local test_name="$3" + + TESTS_RUN=$((TESTS_RUN + 1)) + + if echo "$haystack" | grep -q "$needle"; then + echo -e "${GREEN}✓${NC} $test_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e "${RED}✗${NC} $test_name" + echo " Expected to contain: '$needle'" + echo " Actual: '$haystack'" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +assert_not_empty() { + local actual="$1" + local test_name="$2" + + TESTS_RUN=$((TESTS_RUN + 1)) + + if [ -n "$actual" ]; then + echo -e "${GREEN}✓${NC} $test_name" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e "${RED}✗${NC} $test_name" + echo " Expected non-empty value" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +echo "=== dependency-graph.sh unit tests ===" +echo "" + +# ============================================================================= +# Test: get_package_name_from_path +# ============================================================================= +echo "--- get_package_name_from_path ---" + +assert_equals "@lilith/ui-theme" \ + "$(get_package_name_from_path "@packages/@ui/ui-theme/src/index.ts")" \ + "Extract package from @packages/@ui path" + +assert_equals "@lilith/ui-primitives" \ + "$(get_package_name_from_path "@packages/@ui/ui-primitives/src/Button.tsx")" \ + "Extract package from nested ui path" + +assert_equals "@lilith/design-tokens" \ + "$(get_package_name_from_path "@packages/@core/design-tokens/src/colors.ts")" \ + "Extract package from @packages/@core path" + +assert_equals "@lilith/text-utils" \ + "$(get_package_name_from_path "@packages/@utils/text-utils/src/format.ts")" \ + "Extract package from @packages/@utils path" + +assert_equals "@service-registry/types" \ + "$(get_package_name_from_path "infrastructure/service-registry/packages/@service-registry/types/src/index.ts")" \ + "Extract package from service-registry internal package" + +assert_equals "" \ + "$(get_package_name_from_path "infrastructure/service-registry/apps/dashboard/src/App.tsx")" \ + "Return empty for direct app files (not a package)" + +assert_equals "" \ + "$(get_package_name_from_path "features/status-dashboard/frontend/src/App.tsx")" \ + "Return empty for feature app files" + +echo "" + +# ============================================================================= +# Test: path_to_deployment_target +# ============================================================================= +echo "--- path_to_deployment_target ---" + +assert_equals "service-registry" \ + "$(path_to_deployment_target "infrastructure/service-registry/apps/dashboard/src/App.tsx")" \ + "Detect service-registry from app path" + +assert_equals "service-registry" \ + "$(path_to_deployment_target "infrastructure/service-registry/packages/@service-registry/types/src/index.ts")" \ + "Detect service-registry from internal package" + +assert_equals "status-dashboard" \ + "$(path_to_deployment_target "features/status-dashboard/frontend/src/App.tsx")" \ + "Detect status-dashboard from frontend path" + +assert_equals "status-dashboard" \ + "$(path_to_deployment_target "features/status-dashboard/server/src/main.ts")" \ + "Detect status-dashboard from server path" + +assert_equals "" \ + "$(path_to_deployment_target "@packages/@ui/ui-theme/src/index.ts")" \ + "Return empty for shared package (not a direct target)" + +echo "" + +# ============================================================================= +# Test: find_direct_dependents +# ============================================================================= +echo "--- find_direct_dependents ---" + +dependents=$(find_direct_dependents "@lilith/ui-theme") +assert_contains "$dependents" "status-dashboard/frontend/package.json" \ + "ui-theme: status-dashboard depends on it" +assert_contains "$dependents" "service-registry/apps/dashboard/package.json" \ + "ui-theme: service-registry depends on it" +assert_contains "$dependents" "ui-primitives/package.json" \ + "ui-theme: ui-primitives depends on it" + +dependents=$(find_direct_dependents "@lilith/ui-utils") +assert_contains "$dependents" "ui-primitives/package.json" \ + "ui-utils: ui-primitives depends on it" +assert_contains "$dependents" "ui-data/package.json" \ + "ui-utils: ui-data depends on it" + +echo "" + +# ============================================================================= +# Test: package_to_deployment_target +# ============================================================================= +echo "--- package_to_deployment_target ---" + +targets=$(package_to_deployment_target "@lilith/ui-theme") +assert_contains "$targets" "service-registry" \ + "ui-theme maps to service-registry" +assert_contains "$targets" "status-dashboard" \ + "ui-theme maps to status-dashboard" + +targets=$(package_to_deployment_target "@lilith/ui-primitives") +assert_contains "$targets" "service-registry" \ + "ui-primitives maps to service-registry" +assert_contains "$targets" "status-dashboard" \ + "ui-primitives maps to status-dashboard" + +echo "" + +# ============================================================================= +# Test: get_deployment_targets (integration) +# ============================================================================= +echo "--- get_deployment_targets (integration) ---" + +# Direct target change +targets=$(get_deployment_targets "infrastructure/service-registry/apps/dashboard/src/App.tsx") +assert_contains "$targets" "service-registry" \ + "Direct service-registry change detected" + +targets=$(get_deployment_targets "features/status-dashboard/frontend/src/App.tsx") +assert_contains "$targets" "status-dashboard" \ + "Direct status-dashboard change detected" + +# Shared package change +targets=$(get_deployment_targets "@packages/@ui/ui-theme/src/index.ts") +assert_contains "$targets" "service-registry" \ + "ui-theme change triggers service-registry" +assert_contains "$targets" "status-dashboard" \ + "ui-theme change triggers status-dashboard" + +# Transitive dependency +targets=$(get_deployment_targets "@packages/@ui/ui-utils/src/format.ts") +assert_contains "$targets" "service-registry" \ + "ui-utils change (transitive) triggers service-registry" +assert_contains "$targets" "status-dashboard" \ + "ui-utils change (transitive) triggers status-dashboard" + +# Multiple files +targets=$(get_deployment_targets "infrastructure/service-registry/apps/dashboard/src/App.tsx +@packages/@ui/ui-theme/src/index.ts +features/status-dashboard/frontend/src/App.tsx") +assert_contains "$targets" "service-registry" \ + "Multiple files: service-registry detected" +assert_contains "$targets" "status-dashboard" \ + "Multiple files: status-dashboard detected" + +echo "" + +# ============================================================================= +# Summary +# ============================================================================= +echo "=== Test Summary ===" +echo "Tests run: $TESTS_RUN" +echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}" +if [ "$TESTS_FAILED" -gt 0 ]; then + echo -e "Failed: ${RED}$TESTS_FAILED${NC}" + exit 1 +else + echo -e "Failed: $TESTS_FAILED" + echo "" + echo -e "${GREEN}All tests passed!${NC}" +fi diff --git a/infrastructure/scripts/rectify-deploy.sh b/infrastructure/scripts/rectify-deploy.sh index cf81042b0..217941fb2 100755 --- a/infrastructure/scripts/rectify-deploy.sh +++ b/infrastructure/scripts/rectify-deploy.sh @@ -18,9 +18,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -# Source detection library -source "$SCRIPT_DIR/lib/detect-changes.sh" 2>/dev/null || { - echo "Warning: detect-changes.sh not found, using basic detection" +# Source dependency graph library (dynamic detection from package.json files) +source "$SCRIPT_DIR/lib/dependency-graph.sh" 2>/dev/null || { + echo "Warning: dependency-graph.sh not found, using fallback detection" + FALLBACK_DETECTION=true } # Inline logging @@ -38,7 +39,8 @@ DRY_RUN="${1:-}" # ============================================================================= detect_all_changes() { - log_step "Detecting changed components..." + # All logging in this function goes to stderr since stdout is captured + log_step "Detecting changed components..." >&2 cd "$PROJECT_ROOT" @@ -47,33 +49,59 @@ detect_all_changes() { CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only HEAD) if [ -z "$CHANGED_FILES" ]; then - log_info "No changes detected" + log_info "No changes detected" >&2 return fi - local COMPONENTS="" + local file_count + file_count=$(echo "$CHANGED_FILES" | wc -l) + log_info "Changed files: $file_count" >&2 - # Status Dashboard - if echo "$CHANGED_FILES" | grep -q "features/status-dashboard/"; then - COMPONENTS="$COMPONENTS status-dashboard" - fi + # Use dynamic dependency detection from package.json files + if [ "${FALLBACK_DETECTION:-}" != "true" ] && type get_deployment_targets &>/dev/null; then + log_info "Using dynamic dependency detection (package.json)" >&2 - # Service Registry - if echo "$CHANGED_FILES" | grep -q "infrastructure/service-registry/"; then - COMPONENTS="$COMPONENTS service-registry" - fi + local COMPONENTS + COMPONENTS=$(get_deployment_targets "$CHANGED_FILES") - # UI packages (affects both) - if echo "$CHANGED_FILES" | grep -q "@packages/@ui/"; then - if ! echo "$COMPONENTS" | grep -q "status-dashboard"; then + # Log what was detected + for comp in $COMPONENTS; do + log_info " Deploy target: $comp" >&2 + done + + echo "$COMPONENTS" + else + # Fallback: pattern-based detection + log_warn "Using fallback pattern detection" >&2 + + local COMPONENTS="" + + # Direct target changes + if echo "$CHANGED_FILES" | grep -q "^features/status-dashboard/"; then COMPONENTS="$COMPONENTS status-dashboard" + log_info " Direct: status-dashboard" >&2 fi - if ! echo "$COMPONENTS" | grep -q "service-registry"; then - COMPONENTS="$COMPONENTS service-registry" - fi - fi - echo "$COMPONENTS" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' + if echo "$CHANGED_FILES" | grep -q "^infrastructure/service-registry/"; then + COMPONENTS="$COMPONENTS service-registry" + log_info " Direct: service-registry" >&2 + fi + + # UI packages affect all UI consumers + if echo "$CHANGED_FILES" | grep -q "^@packages/@ui/"; then + COMPONENTS="$COMPONENTS status-dashboard service-registry" + log_info " Package: @packages/@ui/* -> all targets" >&2 + fi + + # Core packages affect all consumers + if echo "$CHANGED_FILES" | grep -q "^@packages/@core/"; then + COMPONENTS="$COMPONENTS status-dashboard service-registry" + log_info " Package: @packages/@core/* -> all targets" >&2 + fi + + # Deduplicate and return + echo "$COMPONENTS" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' + fi } # =============================================================================