#!/usr/bin/env bash set -euo pipefail VERSION="1.0.0" SCRIPT_NAME="$(basename "$0")" usage() { cat <&2; usage >&2; exit 2 ;; *) TARGET="$1"; shift ;; esac done TARGET="${TARGET:-.}" json_escape() { local s="$1" s="${s//\\/\\\\}" s="${s//\"/\\\"}" s="${s//$'\t'/\\t}" s="${s//$'\r'/}" s="${s//$'\n'/\\n}" printf '%s' "$s" } find_go_files() { local t="$1" if [[ -f "$t" ]]; then echo "$t" elif [[ -d "$t" ]]; then find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null else local dir="${t%%/...}" dir="${dir:-.}" if [[ -d "$dir" ]]; then find "$dir" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null else echo "error: path not found: $t" >&2 exit 2 fi fi } FINDINGS=() add_finding() { local file="$1" line="$2" rule="$3" message="$4" FINDINGS+=("${file}:${line}|${rule}|${message}") } # Rule 1: err.Error() in string comparison check_string_error_comparison() { local file="$1" local line_num=0 while IFS= read -r line; do line_num=$((line_num + 1)) # Pattern: err.Error() == "..." or err.Error() != "..." pat='\.Error\(\)[[:space:]]*(==|!=)[[:space:]]*\"' if [[ "$line" =~ $pat ]]; then add_finding "$file" "$line_num" "string-error-compare" \ "comparing err.Error() to string; use errors.Is() or errors.As() instead" fi # Pattern: strings.Contains(err.Error(), "...") pat_contains='strings\.Contains\(.*\.Error\(\)' if [[ "$line" =~ $pat_contains ]]; then add_finding "$file" "$line_num" "string-error-compare" \ "using strings.Contains on err.Error(); use errors.Is() or errors.As() instead" fi # Pattern: "..." == err.Error() pat='\"[^\"]*\"[[:space:]]*(==|!=)[[:space:]]*[a-zA-Z_][a-zA-Z0-9_]*\.Error\(\)' if [[ "$line" =~ $pat ]]; then add_finding "$file" "$line_num" "string-error-compare" \ "comparing string to err.Error(); use errors.Is() or errors.As() instead" fi done < "$file" } # Rule 2: Bare return err (no wrapping) check_bare_return_err() { local file="$1" local line_num=0 local in_error_block=false while IFS= read -r line; do line_num=$((line_num + 1)) # Detect if err != nil { block pat='if[[:space:]]+(.*err[[:space:]]*(!=|==)[[:space:]]*nil|err[[:space:]]*:=)' if [[ "$line" =~ $pat ]]; then in_error_block=true fi # Check for bare "return err" that is not wrapped pat='^[[:space:]]*return[[:space:]]+(.*,)?[[:space:]]*err[[:space:]]*$' if $in_error_block && [[ "$line" =~ $pat ]]; then # Exclude single-line functions and main error handlers # Only flag if the return is just "err" (not fmt.Errorf wrapped) local trimmed trimmed=$(echo "$line" | sed 's/^[[:space:]]*//') if [[ "$trimmed" == "return err" ]]; then add_finding "$file" "$line_num" "bare-return-err" \ "bare 'return err' without wrapping context; consider fmt.Errorf('...: %w', err)" fi fi # Reset error block tracking on closing brace at same indentation pat_close='^[[:space:]]*\}[[:space:]]*$' if $in_error_block && [[ "$line" =~ $pat_close ]]; then in_error_block=false fi done < "$file" } # Rule 3: Log-and-return (handle errors once) check_log_and_return() { local file="$1" local line_num=0 local prev_lines=() while IFS= read -r line; do line_num=$((line_num + 1)) prev_lines+=("$line") # Keep a small window to detect log followed by return err if [[ ${#prev_lines[@]} -gt 5 ]]; then prev_lines=("${prev_lines[@]:1}") fi # Check if current line is 'return ... err' and a recent line logged the error pat='^[[:space:]]*return[[:space:]]+(.*,)?[[:space:]]*err' if [[ "$line" =~ $pat ]]; then local window_size=${#prev_lines[@]} for ((i=0; i