Pulse/scripts/lint-fixer/run.sh
2026-03-18 16:06:30 +00:00

532 lines
18 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
# ── Overnight Lint Fixer — errcheck + dupl ─────────────────────────────
#
# Fixes golangci-lint errcheck and dupl warnings using OpenCode + MiniMax M2.5.
# OpenCode provides autonomous code editing with built-in verification.
#
# Usage:
# ./scripts/lint-fixer/run.sh
#
# Options:
# --model MODEL OpenCode model (default: opencode/minimax-m2.5-free)
# --lint LINTERS Comma-separated linters to fix (default: errcheck,dupl)
# --packages PKGS Comma-separated packages to target (default: all with warnings)
# --max-iters N Max iterations (default: 100)
# --branch NAME Branch name (default: lint-fixes/TIMESTAMP, use "current" for current branch)
# --no-worktree Work directly on current branch (DANGEROUS — no isolation)
#
# Overnight execution:
# nohup ./scripts/lint-fixer/run.sh > /tmp/lint-fixer.log 2>&1 &
#
# Graceful shutdown:
# touch scripts/lint-fixer/.stop
#
# Monitoring:
# ./scripts/lint-fixer/watch.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
WORKTREE_BASE="$REPO_DIR/../pulse-lint-fixes"
# Survive terminal disconnection
trap '' HUP
# Defaults
MODEL="opencode/minimax-m2.5-free"
LINTERS="errcheck,dupl"
PACKAGES=""
MAX_ITERS=100
BRANCH=""
USE_WORKTREE=false # Default to no worktree (simpler, avoids build issues)
AIDER_TIMEOUT_SECS="${AIDER_TIMEOUT_SECS:-900}"
while [[ $# -gt 0 ]]; do
case $1 in
--model) MODEL="$2"
# Normalize model name to include openrouter/ prefix.
if [[ "$MODEL" == minimax/* ]] || [[ "$MODEL" == deepseek/* ]]; then
MODEL="openrouter/$MODEL"
fi
shift 2 ;;
--lint) LINTERS="$2"; shift 2 ;;
--packages) PACKAGES="$2"; shift 2 ;;
--max-iters) MAX_ITERS="$2"; shift 2 ;;
--branch) BRANCH="$2"; shift 2 ;;
--worktree) USE_WORKTREE=true; shift ;;
--no-worktree) USE_WORKTREE=false; shift ;;
--engine) ENGINE="$2"; shift 2 ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
# Validate environment
if ! command -v opencode >/dev/null 2>&1; then
echo "Error: opencode not found"
echo " Install with: brew install opencode"
exit 1
fi
if ! command -v golangci-lint >/dev/null 2>&1; then
GOLANGCI_LINT="$HOME/go/bin/golangci-lint"
if [ ! -f "$GOLANGCI_LINT" ]; then
echo "Error: golangci-lint not found in PATH or ~/go/bin/"
exit 1
fi
else
GOLANGCI_LINT="golangci-lint"
fi
cd "$REPO_DIR"
# Generate branch name if not provided
if [ -z "$BRANCH" ]; then
BRANCH="lint-fixes/$(date +%Y%m%d-%H%M%S)"
fi
# Special handling: if branch is "current", use the current branch
if [ "$BRANCH" = "current" ]; then
BRANCH=$(git rev-parse --abbrev-ref HEAD)
fi
STOP_FILE="$SCRIPT_DIR/.stop"
PROGRESS_FILE="$SCRIPT_DIR/PROGRESS.md"
LOG_FILE="$SCRIPT_DIR/run.log"
rm -f "$STOP_FILE"
mkdir -p "$(dirname "$LOG_FILE")"
should_stop() {
[ -f "$STOP_FILE" ]
}
# Run a command with a hard timeout (bash 3 compatible).
run_with_timeout() {
local timeout_secs="$1"
shift
"$@" &
local cmd_pid=$!
local start_time
start_time=$(date +%s)
while kill -0 "$cmd_pid" 2>/dev/null; do
if [ $(( $(date +%s) - start_time )) -ge "$timeout_secs" ]; then
kill "$cmd_pid" 2>/dev/null || true
sleep 2
kill -9 "$cmd_pid" 2>/dev/null || true
wait "$cmd_pid" 2>/dev/null || true
return 124
fi
sleep 2
done
wait "$cmd_pid"
}
STARTED_AT=$(date +%s)
echo "╔══════════════════════════════════════════════════════╗"
echo "║ Pulse Overnight Lint Fixer ║"
echo "╠══════════════════════════════════════════════════════╣"
echo "║ Model: $MODEL"
echo "║ Linters: $LINTERS"
echo "║ Branch: $BRANCH"
echo "║ Max iters: $MAX_ITERS"
echo "║ Started: $(date)"
echo "╚══════════════════════════════════════════════════════╝"
echo ""
# Set up worktree or work in-place
if [ "$USE_WORKTREE" = true ]; then
WORK_DIR="$WORKTREE_BASE"
if [ -d "$WORK_DIR" ]; then
git worktree remove --force "$WORK_DIR" 2>/dev/null || rm -rf "$WORK_DIR"
fi
BASE_SHA=$(git rev-parse HEAD)
git worktree add -b "$BRANCH" "$WORK_DIR" "$BASE_SHA" 2>/dev/null
echo "Working in isolated worktree: $WORK_DIR"
echo "Base commit: $BASE_SHA"
echo ""
cd "$WORK_DIR"
# Copy frontend dist if it exists (required for embed directives)
if [ -d "$REPO_DIR/frontend-modern/dist" ]; then
echo "Copying frontend-modern/dist for Go build..."
cp -r "$REPO_DIR/frontend-modern/dist" frontend-modern/
fi
# Build internal/ packages to populate build cache
echo "Building internal/ packages..."
if go build ./internal/... >> "$LOG_FILE" 2>&1; then
echo "✓ Build successful"
else
echo "Warning: go build failed, some linters may not work correctly"
fi
echo ""
else
WORK_DIR="$REPO_DIR"
BASE_SHA=$(git rev-parse HEAD)
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" != "$BRANCH" ]; then
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
echo "Working directly in repo on branch: $BRANCH"
else
echo "Working directly in repo on current branch: $BRANCH"
fi
echo "Base commit: $BASE_SHA"
echo ""
fi
# Initialize progress file
cat > "$PROGRESS_FILE" << 'PROGRESS_EOF'
# Lint Fixer Progress
## Status
- **Started**: (timestamp)
- **Packages completed**: 0
- **Commits**: 0
- **Warnings fixed**: 0
## Package Log
(will be populated as we go)
PROGRESS_EOF
sed -i.bak "s/(timestamp)/$(date)/" "$PROGRESS_FILE" && rm -f "$PROGRESS_FILE.bak"
# ── Get list of packages with warnings ────────────────────────────────
echo "Scanning for lint warnings..."
# Parse LINTERS into array
IFS=',' read -ra LINT_ARRAY <<< "$LINTERS"
# Parse requested packages (optional)
REQUESTED_PACKAGES=()
if [ -n "$PACKAGES" ]; then
IFS=',' read -ra RAW_PACKAGES <<< "$PACKAGES"
for raw_pkg in "${RAW_PACKAGES[@]}"; do
raw_pkg="$(echo "$raw_pkg" | xargs)"
[ -n "$raw_pkg" ] || continue
raw_pkg="${raw_pkg#./}"
raw_pkg="${raw_pkg#internal/}"
raw_pkg="${raw_pkg%/...}"
raw_pkg="${raw_pkg%/}"
[ -n "$raw_pkg" ] || continue
REQUESTED_PACKAGES+=("$raw_pkg")
done
fi
SCAN_TARGETS=()
if [ ${#REQUESTED_PACKAGES[@]} -gt 0 ]; then
for pkg in "${REQUESTED_PACKAGES[@]}"; do
SCAN_TARGETS+=("./internal/$pkg/...")
done
else
SCAN_TARGETS+=("./internal/...")
fi
# Build package list (bash 3 compatible: avoid associative arrays)
PKG_WARNING_TMP=$(mktemp -t lint-fixer-pkg-warnings.XXXXXX)
for linter in "${LINT_ARRAY[@]}"; do
for target in "${SCAN_TARGETS[@]}"; do
while IFS= read -r line; do
if [[ "$line" =~ ^internal/([^/]+)/ ]]; then
pkg="${BASH_REMATCH[1]}"
echo "$pkg" >> "$PKG_WARNING_TMP"
fi
done < <($GOLANGCI_LINT run --enable "$linter" --disable-all "$target" 2>&1 | grep "$linter" || true)
done
done
# Convert to sorted array (by warning count ascending for quick wins first)
PACKAGES_TO_FIX=()
PACKAGE_WARNING_COUNTS=()
if [ -s "$PKG_WARNING_TMP" ]; then
while read -r count pkg; do
[ -n "$pkg" ] || continue
PACKAGES_TO_FIX+=("$pkg")
PACKAGE_WARNING_COUNTS+=("$count")
done < <(sort "$PKG_WARNING_TMP" | uniq -c | awk '{print $1 " " $2}' | sort -n)
fi
rm -f "$PKG_WARNING_TMP"
if [ ${#PACKAGES_TO_FIX[@]} -eq 0 ]; then
echo "No packages with warnings found!"
exit 0
fi
echo "Found ${#PACKAGES_TO_FIX[@]} packages with warnings:"
for idx in "${!PACKAGES_TO_FIX[@]}"; do
pkg="${PACKAGES_TO_FIX[$idx]}"
pkg_warning_count="${PACKAGE_WARNING_COUNTS[$idx]}"
echo " internal/$pkg: ${pkg_warning_count} warnings"
done
echo ""
# ── Main loop: fix packages one by one ────────────────────────────────
TOTAL_COMMITS=0
TOTAL_WARNINGS_FIXED=0
PACKAGES_COMPLETED=0
for idx in "${!PACKAGES_TO_FIX[@]}"; do
pkg="${PACKAGES_TO_FIX[$idx]}"
pkg_warning_count="${PACKAGE_WARNING_COUNTS[$idx]}"
if should_stop; then
echo "Stop signal received — shutting down gracefully"
break
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Package: internal/$pkg (${pkg_warning_count} warnings)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
PKG_START=$(date +%s)
BEFORE_SHA=$(git rev-parse HEAD)
for linter in "${LINT_ARRAY[@]}"; do
if should_stop; then break; fi
echo "→ Running $linter on internal/$pkg..."
# Get warnings for this package + linter
WARNINGS=$($GOLANGCI_LINT run --enable "$linter" --disable-all "./internal/$pkg/..." 2>&1 | grep "$linter" || true)
if [ -z "$WARNINGS" ]; then
echo " ✓ No $linter warnings"
continue
fi
WARNING_COUNT=$(echo "$WARNINGS" | wc -l | tr -d ' ')
echo " Found $WARNING_COUNT $linter warnings"
# Extract affected files and process one file at a time to avoid model context overflow.
FILES=$(echo "$WARNINGS" | awk -F: '/^[^:]+\.go:/{print $1}' | sort -u || true)
if [ -z "$FILES" ]; then
echo " ⚠ Could not extract file list from warnings"
continue
fi
FILE_COUNT=$(echo "$FILES" | wc -l | tr -d ' ')
echo " Affected files: $FILE_COUNT"
while IFS= read -r file; do
if should_stop; then break; fi
[ -n "$file" ] || continue
if [ ! -f "$file" ]; then
echo " ⚠ Skipping missing file: $file"
continue
fi
FILE_WARNINGS=$(echo "$WARNINGS" | awk -v f="$file" 'index($0, f ":") == 1')
if [ -z "$FILE_WARNINGS" ]; then
continue
fi
FILE_WARNING_COUNT=$(echo "$FILE_WARNINGS" | wc -l | tr -d ' ')
echo " → File: $file ($FILE_WARNING_COUNT warnings)"
# Build MiniMax M2.5 optimized prompt (purpose-driven, explain "why")
case "$linter" in
errcheck)
PROMPT="## Task: Fix errcheck warnings in $file (internal/$pkg)
**Why this matters:** Unchecked errors can cause silent failures, data corruption, or security issues in production. We need proper error handling to make Pulse reliable for users monitoring critical infrastructure.
**Context:** This is production code for Pulse, an infrastructure monitoring platform. Users depend on accurate metrics and reliable operations. Every unchecked error is a potential production incident.
**Warnings to fix (this file only):**
\`\`\`
$FILE_WARNINGS
\`\`\`
**How to fix them:**
For **test files** (\`*_test.go\`):
- If the error is truly irrelevant (e.g., \`defer file.Close()\` in test setup), use: \`_ = expr\`
- Example: \`_ = os.Remove(tmpFile)\` when cleanup failure doesn't affect test validity
For **production code**:
- If function returns \`error\`: propagate it
- Good: \`return fmt.Errorf(\"failed to save config: %w\", err)\`
- Bad: ignoring the error
- If function doesn't return error but should handle it: log and continue gracefully
- Example: \`if err := conn.Close(); err != nil { log.Warn(\"connection close failed: %v\", err) }\`
- For critical operations (database writes, API calls, file I/O): ALWAYS handle errors
**Constraints:**
- Do NOT change function signatures
- Do NOT add new imports unless absolutely necessary
- Keep changes minimal — fix only the error handling for this file
- Preserve all existing behavior
- Ensure all tests still pass
**Expected output:** Clean, production-ready code with all errors properly handled."
;;
dupl)
PROMPT="## Task: Eliminate code duplication tied to warnings in $file (internal/$pkg)
**Why this matters:** Duplicated code creates maintenance burden. When we fix a bug or add a feature, we have to find and update multiple copies, which leads to inconsistencies and missed updates. DRY code is easier to maintain and less error-prone.
**Context:** This is production code for Pulse. We're refactoring to improve long-term maintainability without changing any behavior.
**Duplication warnings (anchored to this file):**
\`\`\`
$FILE_WARNINGS
\`\`\`
**How to fix them:**
1. **Identify** the duplicated logic blocks
2. **Extract** into well-named helper functions:
- Use descriptive names that explain what they do (e.g., \`buildProxmoxMetricPayload\`, \`formatTimestampISO8601\`)
- Keep helpers private (lowercase) unless they need to be exported
- Place helpers near their call sites (same file if only used there, separate file if shared across package)
- Add brief comment explaining purpose if not obvious from name
3. **Replace** duplicated code with calls to the helper
**Constraints:**
- Do NOT over-abstract — only extract what's actually duplicated
- Preserve exact behavior (no logic changes)
- Ensure all tests still pass
- Keep changes minimal
**Expected output:** DRY code with shared logic extracted into clear, well-named helper functions."
;;
*)
PROMPT="## Task: Fix $linter warnings in $file (internal/$pkg)
**Warnings (this file only):**
\`\`\`
$FILE_WARNINGS
\`\`\`
**Instructions:**
Keep changes minimal and focused. Do not change function signatures or add unnecessary imports. Fix only what the linter flagged."
;;
esac
echo " Calling AI model..."
# Run OpenCode with MiniMax M2.5
if run_with_timeout "$AIDER_TIMEOUT_SECS" opencode run \
--model "$MODEL" \
"$PROMPT" >> "$LOG_FILE" 2>&1; then
echo " ✓ AI completed successfully"
else
AI_EXIT_CODE=$?
if [ "$AI_EXIT_CODE" -eq 124 ]; then
echo " ✗ AI call timed out after ${AIDER_TIMEOUT_SECS}s"
else
echo " ✗ AI call failed (exit $AI_EXIT_CODE)"
fi
continue
fi
# Check if changes were ACTUALLY made
# OpenCode should only modify code files, not metadata
if ! git diff --quiet HEAD -- './internal'; then
echo " Testing changes..."
# Run package tests
if go test "./internal/$pkg/..." >> "$LOG_FILE" 2>&1; then
echo " ✓ Tests passed"
FILE_BASENAME="$(basename "$file")"
COMMIT_MSG="fix($linter): $FILE_BASENAME in internal/$pkg
Fixed $FILE_WARNING_COUNT $linter warnings in $file.
Model: $MODEL
Context: Pulse infrastructure monitoring platform"
# Stage only internal/ files (exclude PROGRESS.md, session files, etc.)
git add internal/
if ! git diff --cached --quiet; then
git commit -m "$COMMIT_MSG" >> "$LOG_FILE" 2>&1
TOTAL_COMMITS=$((TOTAL_COMMITS + 1))
TOTAL_WARNINGS_FIXED=$((TOTAL_WARNINGS_FIXED + FILE_WARNING_COUNT))
echo " ✓ Committed ($TOTAL_COMMITS total)"
else
echo " ⚠ No changes to commit (AI may have only added comments)"
git reset HEAD internal/ >/dev/null 2>&1
fi
else
echo " ✗ Tests failed — reverting changes"
git checkout HEAD -- internal/ >> "$LOG_FILE" 2>&1
git clean -fd >> "$LOG_FILE" 2>&1
fi
else
echo " ⚠ No code changes made by AI"
fi
echo ""
done <<< "$FILES"
if should_stop; then break; fi
done
PACKAGES_COMPLETED=$((PACKAGES_COMPLETED + 1))
PKG_ELAPSED=$(( $(date +%s) - PKG_START ))
# Update progress file
cat >> "$PROGRESS_FILE" << PKG_EOF
### internal/$pkg
- Warnings: ${pkg_warning_count}
- Time: ${PKG_ELAPSED}s
- Commits: $(git log --oneline "$BEFORE_SHA..HEAD" 2>/dev/null | wc -l | tr -d ' ')
PKG_EOF
# Update status
sed -i.bak "s/Packages completed: [0-9]*/Packages completed: $PACKAGES_COMPLETED/" "$PROGRESS_FILE" && rm -f "$PROGRESS_FILE.bak"
sed -i.bak "s/Commits: [0-9]*/Commits: $TOTAL_COMMITS/" "$PROGRESS_FILE" && rm -f "$PROGRESS_FILE.bak"
sed -i.bak "s/Warnings fixed: [0-9]*/Warnings fixed: $TOTAL_WARNINGS_FIXED/" "$PROGRESS_FILE" && rm -f "$PROGRESS_FILE.bak"
echo ""
done
# ── Summary ────────────────────────────────────────────────────────────
ELAPSED=$(( $(date +%s) - STARTED_AT ))
HOURS=$(( ELAPSED / 3600 ))
MINS=$(( (ELAPSED % 3600) / 60 ))
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo "║ Lint Fixer Complete ║"
echo "╠══════════════════════════════════════════════════════╣"
echo "║ Packages: $PACKAGES_COMPLETED / ${#PACKAGES_TO_FIX[@]}"
echo "║ Commits: $TOTAL_COMMITS"
echo "║ Warnings: $TOTAL_WARNINGS_FIXED fixed"
echo "║ Duration: ${HOURS}h ${MINS}m"
echo "╚══════════════════════════════════════════════════════╝"
echo ""
if [ "$USE_WORKTREE" = true ]; then
echo "Review changes:"
echo " cd $WORK_DIR"
echo " git log --oneline"
echo ""
echo "Merge to main branch:"
echo " git checkout $(git rev-parse --abbrev-ref HEAD@{-1})"
echo " git merge $BRANCH"
fi
echo ""
echo "Progress saved to: $PROGRESS_FILE"
echo "Full log: $LOG_FILE"