diff --git a/scripts/check-deps.sh b/scripts/check-deps.sh index 8e058dd..1d2999c 100755 --- a/scripts/check-deps.sh +++ b/scripts/check-deps.sh @@ -3,6 +3,7 @@ # Exit 1 if any unapproved import is found. # # The allowlist is parsed from CONVENTIONS.md to maintain a single source of truth. +# Also enforces Scope column: "test only" packages cannot appear in non-test code. set -euo pipefail @@ -13,22 +14,53 @@ if [ ! -f "$CONVENTIONS_FILE" ]; then exit 1 fi -# Parse approved packages from CONVENTIONS.md table -# Looks for lines like: | `gopkg.in/yaml.v3` | ... -ALLOWED=() -while IFS= read -r line; do - # Extract package from markdown table cell: | `package` | - pkg=$(echo "$line" | grep -oP '\| `\K[^`]+' | head -1 || true) - if [ -n "$pkg" ] && [[ "$pkg" != "Package" ]]; then - ALLOWED+=("$pkg") - fi -done < <(grep -E '^\| `[a-zA-Z]' "$CONVENTIONS_FILE" || true) +# Parse approved packages from CONVENTIONS.md table using awk (POSIX-compatible) +# Format: | `package` | use case | scope | +# Output: package:scope (e.g., "gopkg.in/yaml.v3:production") +declare -A ALLOWED_PROD=() +declare -A ALLOWED_TEST=() -if [ ${#ALLOWED[@]} -eq 0 ]; then +while IFS= read -r line; do + # Use awk to extract package and scope from table row + # Split on | and extract backtick-wrapped package from column 1, scope from column 3 + pkg=$(echo "$line" | awk -F'|' '{gsub(/^[[:space:]]*`|`[[:space:]]*$/, "", $2); print $2}') + scope=$(echo "$line" | awk -F'|' '{gsub(/^[[:space:]]+|[[:space:]]+$/, "", $4); print tolower($4)}') + + if [ -n "$pkg" ] && [ "$pkg" != "Package" ] && [[ "$pkg" =~ ^[a-zA-Z] ]]; then + if [[ "$scope" == *"test"* ]]; then + ALLOWED_TEST["$pkg"]=1 + else + ALLOWED_PROD["$pkg"]=1 + fi + fi +done < <(grep '| `' "$CONVENTIONS_FILE" 2>/dev/null || true) + +ALL_ALLOWED=("${!ALLOWED_PROD[@]}" "${!ALLOWED_TEST[@]}") + +if [ ${#ALL_ALLOWED[@]} -eq 0 ]; then echo "⚠️ No approved packages found in $CONVENTIONS_FILE" echo " (This is fine if you want stdlib-only)" fi +# Helper: check if import matches any package in an associative array (literal prefix, no glob) +matches_allowlist() { + local import="$1" + shift + local -n allowlist=$1 + + for allowed in "${!allowlist[@]}"; do + # Exact match + if [ "$import" = "$allowed" ]; then + return 0 + fi + # Literal prefix match for subpackages (no glob interpretation) + if [ "${import#"$allowed/"}" != "$import" ]; then + return 0 + fi + done + return 1 +} + # Get DIRECT dependencies only (exclude indirect/transitive) # Fail closed: if go list fails, we exit non-zero IMPORTS=$(go list -m -f '{{if not .Indirect}}{{.Path}}{{end}}' all 2>&1) || { @@ -44,21 +76,13 @@ if [ -z "$IMPORTS" ]; then exit 0 fi +# Check all direct dependencies are in the allowlist VIOLATIONS="" while IFS= read -r import; do [ -z "$import" ] && continue - # Check if import matches any allowed package (prefix match for subpackages) - MATCHED=false - for allowed in "${ALLOWED[@]}"; do - if [[ "$import" == "$allowed" ]] || [[ "$import" == "$allowed/"* ]]; then - MATCHED=true - break - fi - done - - if [ "$MATCHED" = false ]; then - VIOLATIONS="${VIOLATIONS} - ${import}"$'\n' + if ! matches_allowlist "$import" ALLOWED_PROD && ! matches_allowlist "$import" ALLOWED_TEST; then + VIOLATIONS="${VIOLATIONS} - ${import} (not in allowlist)"$'\n' fi done <<< "$IMPORTS" @@ -68,17 +92,31 @@ if [ -n "$VIOLATIONS" ]; then echo "The following imports are not in the allowlist:" printf "%s" "$VIOLATIONS" echo "" - echo "Approved packages (from CONVENTIONS.md):" - for pkg in "${ALLOWED[@]}"; do - echo " - $pkg" - done + echo "To add a dependency, update CONVENTIONS.md (requires Aaron's approval)" + exit 1 +fi + +# Enforce Scope: test-only packages must not appear in non-test code +# Get imports used by non-test code only +PROD_IMPORTS=$(go list -deps -f '{{if not .Standard}}{{.ImportPath}}{{end}}' ./... 2>/dev/null | grep -v '_test' || true) + +TEST_ONLY_IN_PROD="" +for test_pkg in "${!ALLOWED_TEST[@]}"; do + if echo "$PROD_IMPORTS" | grep -q "^${test_pkg}"; then + TEST_ONLY_IN_PROD="${TEST_ONLY_IN_PROD} - ${test_pkg} (marked 'test only' but used in production code)"$'\n' + fi +done + +if [ -n "$TEST_ONLY_IN_PROD" ]; then + echo "❌ TEST-ONLY DEPENDENCIES IN PRODUCTION CODE" echo "" - echo "To add a dependency:" - echo " 1. Open a PR that ONLY updates CONVENTIONS.md" - echo " 2. Get explicit approval from Aaron" - echo " 3. After merge, use the package in a separate PR" + printf "%s" "$TEST_ONLY_IN_PROD" + echo "" + echo "These packages are marked 'test only' in CONVENTIONS.md" + echo "and must only be imported from *_test.go files." exit 1 fi echo "✅ All dependencies are approved" echo " Direct deps: $(echo "$IMPORTS" | wc -l | tr -d ' ')" +echo " Production: ${#ALLOWED_PROD[@]}, Test-only: ${#ALLOWED_TEST[@]}"