Changed in this deploy: local-quality.sh
setup.sh recommended
local-quality.sh Updated
flutter-lint.sh
portable-local-sonar.sh
quality-check.sh
generate-report.py
template.html
README.md
sonar-project.properties
#!/bin/bash
# First-run setup for portable local quality tooling.
# No admin, no Homebrew, no Docker. Downloads SonarQube + sonar-scanner zips
# and installs a valid cache/vendor/mirror-provided sonar-flutter plugin so
# local-quality.sh can run portable local SonarQube by default.
set -euo pipefail
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log_info() { echo -e "${CYAN}▸ $*${NC}"; }
log_ok() { echo -e "${GREEN}✓ $*${NC}"; }
log_warn() { echo -e "${YELLOW}⚠ $*${NC}"; }
log_error() { echo -e "${RED}✗ $*${NC}" >&2; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
CACHE_DIR="${TOP_FLUTTER_QUALITY_CACHE:-$HOME/.cache/top-flutter-quality}"
SONARQUBE_PORTABLE_VERSION="${SONARQUBE_PORTABLE_VERSION:-10.7.0.96327}"
SONAR_SCANNER_PORTABLE_VERSION="${SONAR_SCANNER_PORTABLE_VERSION:-6.2.1.4610}"
SONARQUBE_ZIP_URL="${SONARQUBE_ZIP_URL:-https://binaries.sonarsource.com/Distribution/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}.zip}"
SONAR_SCANNER_ZIP_URL="${SONAR_SCANNER_ZIP_URL:-https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_PORTABLE_VERSION}.zip}"
SONAR_FLUTTER_PLUGIN_VERSION="${SONAR_FLUTTER_PLUGIN_VERSION:-0.5.2}"
SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL="${SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL:-}"
SONAR_FLUTTER_PLUGIN_VENDOR_JAR="${SONAR_FLUTTER_PLUGIN_VENDOR_JAR:-$SCRIPT_DIR/vendor/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN="${SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN:-1}"
SONAR_FLUTTER_PLUGIN_JAR="${SONAR_FLUTTER_PLUGIN_JAR:-$CACHE_DIR/plugins/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
show_help() {
echo ""
echo "Usage: bash scripts/quality/setup.sh"
echo ""
echo "First-run setup for Top Flutter local quality checks."
echo "Downloads portable SonarQube and sonar-scanner, then installs a cache/vendor/mirror-provided sonar-flutter plugin."
echo "No admin, Homebrew, Docker, or sudo required."
echo ""
echo "Requirements checked before download:"
echo " curl, unzip, python3"
echo " java (JDK 17) for bundled SonarQube 10.7; on macOS /usr/libexec/java_home -v 17 is preferred"
echo ""
echo "Cache:"
echo " TOP_FLUTTER_QUALITY_CACHE Cache dir (default: ~/.cache/top-flutter-quality)"
echo ""
echo "Version/URL overrides:"
echo " SONARQUBE_PORTABLE_VERSION default: $SONARQUBE_PORTABLE_VERSION"
echo " SONAR_SCANNER_PORTABLE_VERSION default: $SONAR_SCANNER_PORTABLE_VERSION"
echo " SONARQUBE_ZIP_URL default SonarQube zip URL"
echo " SONAR_SCANNER_ZIP_URL default sonar-scanner zip URL"
echo " SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN default: 1 (set 0 to skip)"
echo " SONAR_FLUTTER_PLUGIN_VERSION default: $SONAR_FLUTTER_PLUGIN_VERSION"
echo " SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL optional explicit mirror URL for sonar-flutter plugin (no default)"
echo " SONAR_FLUTTER_PLUGIN_VENDOR_JAR default: scripts/quality/vendor/sonar-flutter-plugin-<version>.jar"
echo " SONAR_FLUTTER_PLUGIN_JAR default: cache/plugins/sonar-flutter-plugin-<version>.jar"
echo ""
echo "Options:"
echo " --clean Remove existing extracted SonarQube/scanner/plugin before installing"
echo " (cached zip files are kept to avoid re-downloading)"
echo ""
echo "After setup run:"
echo " bash scripts/quality/local-quality.sh"
echo ""
}
CLEAN_INSTALL=0
for arg in "$@"; do
case "$arg" in
-h|--help) show_help; exit 0 ;;
--clean) CLEAN_INSTALL=1 ;;
*) log_error "Unknown option: $arg"; echo "Run with --help for usage."; exit 2 ;;
esac
done
resolve_java() {
java_line_for_home() {
"$1/bin/java" -version 2>&1 | python3 -c 'import sys; print(sys.stdin.read().split("\n")[0].strip())' 2>/dev/null || true
}
java_major_for_home() {
"$1/bin/java" -version 2>&1 | python3 -c 'import re,sys
text=sys.stdin.read()
m=re.search(r"version \"([^\"]+)\"", text)
if not m:
sys.exit(0)
v=m.group(1)
print(v.split(".")[1] if v.startswith("1.") else v.split(".")[0])' 2>/dev/null || true
}
use_java_home() {
export JAVA_HOME="$1"
export PATH="$JAVA_HOME/bin:$PATH"
}
require_java17_home() {
local candidate="$1"
local label="$2"
local major=""
local line=""
if [[ -z "$candidate" || ! -x "$candidate/bin/java" ]]; then
log_error "$label does not point to an executable JDK: $candidate"
exit 1
fi
major="$(java_major_for_home "$candidate")"
line="$(java_line_for_home "$candidate")"
if [[ "$major" != "17" ]]; then
log_error "SonarQube ${SONARQUBE_PORTABLE_VERSION} requires Java 17; $label is ${line:-unknown}."
echo " Set PORTABLE_JAVA_HOME to a JDK 17 archive extracted under your home directory."
exit 1
fi
use_java_home "$candidate"
}
local mac_java17=""
local path_java=""
local path_home=""
local path_major=""
local path_line=""
if [[ -n "${PORTABLE_JAVA_HOME:-}" ]]; then
require_java17_home "$PORTABLE_JAVA_HOME" "PORTABLE_JAVA_HOME"
elif [[ -n "${JAVA_HOME:-}" && -x "$JAVA_HOME/bin/java" && "$(java_major_for_home "$JAVA_HOME")" == "17" ]]; then
use_java_home "$JAVA_HOME"
elif [[ "$(uname -s)" == "Darwin" && -x /usr/libexec/java_home ]] && mac_java17="$(/usr/libexec/java_home -v 17 2>/dev/null || true)" && [[ -n "$mac_java17" && -x "$mac_java17/bin/java" ]]; then
use_java_home "$mac_java17"
elif command -v java >/dev/null 2>&1; then
path_java="$(command -v java)"
path_home="$(cd "$(dirname "$path_java")/.." && pwd)"
path_major="$(java_major_for_home "$path_home")"
path_line="$(java_line_for_home "$path_home")"
if [[ "$path_major" == "17" ]]; then
use_java_home "$path_home"
else
log_error "SonarQube ${SONARQUBE_PORTABLE_VERSION} requires Java 17; PATH java is ${path_line:-unknown}."
echo " Install/extract JDK 17 and set PORTABLE_JAVA_HOME, or on macOS install a JDK 17 visible to /usr/libexec/java_home -v 17."
exit 1
fi
else
log_error "Java 17 not found."
echo ""
echo " To fix without admin/Homebrew:"
echo " 1. Download a JDK 17 archive from https://adoptium.net/temurin/releases/"
echo " 2. Extract it under your home directory, e.g. ~/.cache/jdk/"
echo " 3. Re-run with: PORTABLE_JAVA_HOME=~/.cache/jdk/<extracted-dir> bash scripts/quality/setup.sh"
echo ""
exit 1
fi
log_ok "java: $(java_line_for_home "$JAVA_HOME")"
}
require_tool() {
local tool="$1"
if command -v "$tool" >/dev/null 2>&1; then
log_ok "$tool: $(command -v "$tool")"
else
log_error "$tool is required but not found on PATH."
exit 1
fi
}
download_if_missing() {
local url="$1"
local output="$2"
local label="$3"
if [[ -f "$output" ]]; then
log_ok "$label zip already cached: $output"
else
log_info "Downloading $label..."
log_info "URL: $url"
curl -fL --progress-bar "$url" -o "$output"
log_ok "$label downloaded: $output"
fi
}
download_file_if_missing() {
local url="$1"
local output="$2"
local label="$3"
if [[ -f "$output" ]]; then
log_ok "$label already cached: $output"
else
mkdir -p "$(dirname "$output")"
log_info "Downloading $label..."
log_info "URL: $url"
curl -fL --progress-bar "$url" -o "$output"
log_ok "$label downloaded: $output"
fi
}
validate_flutter_plugin_jar() {
local jar_path="$1"
[[ -f "$jar_path" ]] || return 1
unzip -t "$jar_path" >/dev/null 2>&1
}
fail_missing_flutter_plugin() {
log_error "Missing valid sonar-flutter plugin jar."
echo ""
echo " GitHub fallback downloads are disabled. Provide the plugin explicitly by"
echo " placing a valid sonar-flutter plugin jar at:"
echo " $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
echo " Or prefill the cache with a valid jar at:"
echo " $SONAR_FLUTTER_PLUGIN_JAR"
echo " Or set SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted non-default mirror."
echo ""
exit 1
}
fail_invalid_flutter_plugin_vendor() {
log_error "Repo-local sonar-flutter plugin vendor jar is not valid: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
echo ""
echo " The vendor jar exists but failed validation. Replace it with a valid"
echo " sonar-flutter plugin jar."
echo ""
exit 1
}
fail_invalid_flutter_plugin_download() {
log_error "Mirror-provided sonar-flutter plugin is not a valid jar: $SONAR_FLUTTER_PLUGIN_JAR"
echo ""
echo " Replace it with a valid cache/vendor jar or set"
echo " SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted mirror that serves a jar."
echo ""
exit 1
}
fail_flutter_plugin_download_failed() {
log_error "Could not download sonar-flutter plugin from explicit mirror: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL"
echo ""
echo " Place a valid plugin jar at:"
echo " $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
echo " Or prefill the cache with a valid jar at:"
echo " $SONAR_FLUTTER_PLUGIN_JAR"
echo ""
exit 1
}
extract_if_missing() {
local zip="$1"
local dest_parent="$2"
local dest_dir="$3"
local glob_pattern="$4"
local label="$5"
if [[ -d "$dest_dir" ]]; then
log_ok "$label already extracted: $dest_dir"
else
log_info "Extracting $label..."
unzip -q "$zip" -d "$dest_parent"
if [[ ! -d "$dest_dir" ]]; then
extracted="$(find "$dest_parent" -maxdepth 1 -name "$glob_pattern" -type d | head -1)"
if [[ -z "$extracted" ]]; then
log_error "Could not find extracted $label directory under $dest_parent."
exit 1
fi
mv "$extracted" "$dest_dir"
fi
log_ok "$label extracted: $dest_dir"
fi
}
verify_layout() {
local sq_dir="$1"
local ss_dir="$2"
local sq_script=""
if [[ "$(uname -s)" == "Darwin" ]]; then
sq_script="$sq_dir/bin/macosx-universal-64/sonar.sh"
else
sq_script="$sq_dir/bin/linux-x86-64/sonar.sh"
fi
if [[ ! -f "$sq_script" ]]; then
sq_script="$(find "$sq_dir/bin" -name "sonar.sh" | head -1 || true)"
fi
if [[ -z "$sq_script" || ! -f "$sq_script" ]]; then
log_error "Cannot find sonar.sh under $sq_dir/bin"
exit 1
fi
chmod +x "$sq_script" 2>/dev/null || true
log_ok "SonarQube launcher found: $sq_script"
if [[ ! -x "$ss_dir/bin/sonar-scanner" ]]; then
chmod +x "$ss_dir/bin/sonar-scanner" 2>/dev/null || true
fi
if [[ ! -x "$ss_dir/bin/sonar-scanner" ]]; then
log_error "Cannot find executable sonar-scanner at $ss_dir/bin/sonar-scanner"
exit 1
fi
log_ok "sonar-scanner found: $ss_dir/bin/sonar-scanner"
}
install_flutter_plugin() {
local sq_dir="$1"
local plugin_dest_dir="$sq_dir/extensions/plugins"
local plugin_dest="$plugin_dest_dir/$(basename "$SONAR_FLUTTER_PLUGIN_JAR")"
if [[ "$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN" != "1" ]]; then
log_warn "Skipping sonar-flutter plugin install (SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN)"
return 0
fi
if [[ -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
if validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
else
log_warn "Removing invalid cached sonar-flutter plugin jar: $SONAR_FLUTTER_PLUGIN_JAR"
rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
fi
fi
if [[ ! -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
mkdir -p "$(dirname "$SONAR_FLUTTER_PLUGIN_JAR")"
if [[ -f "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" ]]; then
log_info "Using repo-local sonar-flutter plugin vendor jar: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR"; then
fail_invalid_flutter_plugin_vendor
fi
cp "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" "$SONAR_FLUTTER_PLUGIN_JAR"
if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
log_error "Copied sonar-flutter plugin cache jar is invalid: $SONAR_FLUTTER_PLUGIN_JAR"
exit 1
fi
log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
elif [[ -n "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" ]]; then
log_info "Fetching sonar-flutter plugin ${SONAR_FLUTTER_PLUGIN_VERSION} from explicit mirror..."
log_info "URL: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL"
if ! curl -fL --progress-bar "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" -o "$SONAR_FLUTTER_PLUGIN_JAR"; then
rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
fail_flutter_plugin_download_failed
fi
if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
fail_invalid_flutter_plugin_download
fi
log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
else
fail_missing_flutter_plugin
fi
fi
mkdir -p "$plugin_dest_dir"
cp "$SONAR_FLUTTER_PLUGIN_JAR" "$plugin_dest"
if [[ ! -f "$plugin_dest" ]]; then
log_error "sonar-flutter plugin was not installed at $plugin_dest"
exit 1
fi
if ! validate_flutter_plugin_jar "$plugin_dest"; then
log_error "sonar-flutter plugin installed jar is invalid: $plugin_dest"
exit 1
fi
log_ok "sonar-flutter plugin installed: $plugin_dest"
}
echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN} Top Flutter portable quality setup${NC}"
echo -e "${CYAN} No Homebrew · No Docker · No admin${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""
require_tool curl
require_tool unzip
require_tool python3
resolve_java
mkdir -p "$CACHE_DIR/sonarqube" "$CACHE_DIR/sonar-scanner" "$CACHE_DIR/runtime" "$CACHE_DIR/plugins"
log_ok "Cache directories ready: $CACHE_DIR"
SQ_ZIP="$CACHE_DIR/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}.zip"
SQ_DIR="$CACHE_DIR/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}"
SS_ZIP="$CACHE_DIR/sonar-scanner/sonar-scanner-cli-${SONAR_SCANNER_PORTABLE_VERSION}.zip"
SS_DIR="$CACHE_DIR/sonar-scanner/sonar-scanner-${SONAR_SCANNER_PORTABLE_VERSION}"
if [[ "$CLEAN_INSTALL" == "1" ]]; then
echo ""
log_info "Cleaning previous installation..."
[[ -d "$SQ_DIR" ]] && { rm -rf "$SQ_DIR"; log_ok "Removed: $SQ_DIR"; }
[[ -d "$SS_DIR" ]] && { rm -rf "$SS_DIR"; log_ok "Removed: $SS_DIR"; }
[[ -f "$SONAR_FLUTTER_PLUGIN_JAR" ]] && { rm -f "$SONAR_FLUTTER_PLUGIN_JAR"; log_ok "Removed: $SONAR_FLUTTER_PLUGIN_JAR"; }
log_ok "Clean done (cached zips kept)"
fi
download_if_missing "$SONARQUBE_ZIP_URL" "$SQ_ZIP" "SonarQube ${SONARQUBE_PORTABLE_VERSION}"
extract_if_missing "$SQ_ZIP" "$CACHE_DIR/sonarqube" "$SQ_DIR" "sonarqube-*" "SonarQube"
download_if_missing "$SONAR_SCANNER_ZIP_URL" "$SS_ZIP" "sonar-scanner ${SONAR_SCANNER_PORTABLE_VERSION}"
extract_if_missing "$SS_ZIP" "$CACHE_DIR/sonar-scanner" "$SS_DIR" "sonar-scanner-*" "sonar-scanner"
verify_layout "$SQ_DIR" "$SS_DIR"
install_flutter_plugin "$SQ_DIR"
# Install very_good_cli for test coverage
if command -v very_good >/dev/null 2>&1; then
log_ok "very_good_cli: $(very_good --version 2>/dev/null || echo 'installed')"
elif command -v dart >/dev/null 2>&1; then
log_info "Installing very_good_cli via dart pub global..."
dart pub global activate very_good_cli
log_ok "very_good_cli installed"
else
log_warn "dart not found — skipping very_good_cli (flutter test will be used instead)"
fi
echo ""
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Portable quality tooling is ready.${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo ""
echo "Next command:"
echo " bash scripts/quality/local-quality.sh"
echo ""
#!/bin/bash
# One-command local quality entry point.
# Default path is portable local SonarQube: no Homebrew, no Docker, no admin/sudo.
# Usage: bash scripts/quality/local-quality.sh [quality-check.sh flags/args]
# Legacy Docker/Homebrew path: bash scripts/quality/local-quality.sh --legacy-local [flags/args]
set -euo pipefail
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log_ok() { echo -e "${GREEN}✓ $*${NC}"; }
log_warn() { echo -e "${YELLOW}⚠ $*${NC}"; }
log_error() { echo -e "${RED}✗ $*${NC}" >&2; }
show_help() {
echo ""
echo "Usage: bash scripts/quality/local-quality.sh [OPTIONS]"
echo ""
echo "Default mode:"
echo " Portable local SonarQube from ~/.cache/top-flutter-quality."
echo " Default URL: http://localhost:9000 (override with SONAR_LOCAL_PORT)."
echo " Requires Java 17; macOS prefers /usr/libexec/java_home -v 17."
echo " No Homebrew, Docker, admin, or sudo required."
echo ""
echo "First run:"
echo " bash scripts/quality/setup.sh"
echo ""
echo "Options:"
echo " --keep-sonar-local Keep local SonarQube running after scan"
echo " --keep-server Alias for --keep-sonar-local"
echo " -d, --dup-threshold Max duplication % (default: 3)"
echo " -c, --cov-threshold Min coverage % (default: 80)"
echo " --focus AREAS Focus on: coverage,duplication,smell"
echo " --minimal-focus Shorthand for --focus coverage,duplication,smell"
echo " --focus-minimal Alias for --minimal-focus"
echo " --smell-threshold N Max code smells in focus mode (default: 0)"
echo " --legacy-local Use old Homebrew + Colima/Docker local mode"
echo " --portable-local Backward-compatible alias for the default mode"
echo " -h, --help Show this help (no download)"
echo ""
echo "Examples:"
echo " bash scripts/quality/setup.sh"
echo " bash scripts/quality/local-quality.sh"
echo " bash scripts/quality/local-quality.sh --keep-sonar-local"
echo ""
}
# Help is an early exit with no brew/download.
for arg in "$@"; do
if [[ "$arg" == "-h" || "$arg" == "--help" ]]; then
show_help
exit 0
fi
done
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
QUALITY_CHECK="$SCRIPT_DIR/quality-check.sh"
REPORT_HTML="$PROJECT_ROOT/reports/quality/quality-report.html"
LEGACY_MODE=false
LINT_MODE=""
REMAINING_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--legacy-local)
LEGACY_MODE=true
shift
;;
--portable-local)
shift
;;
--dev|--sit)
LINT_MODE="${1#--}"
shift
;;
*)
REMAINING_ARGS+=("$1")
shift
;;
esac
done
if [[ "$LEGACY_MODE" != true ]]; then
echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN} local-quality.sh → portable-local-sonar.sh${NC}"
echo -e "${CYAN} Mode: Portable (no Homebrew/Docker/admin)${NC}"
[[ -n "$LINT_MODE" ]] && echo -e "${CYAN} Lint mode: $LINT_MODE${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""
# Clean previous reports
echo -e "${CYAN}▸ [0/3] Cleaning reports...${NC}"
rm -rf "$PROJECT_ROOT/reports/quality" "$PROJECT_ROOT/reports/coverage" "$PROJECT_ROOT/reports/lint"
mkdir -p "$PROJECT_ROOT/reports/quality" "$PROJECT_ROOT/reports/coverage" "$PROJECT_ROOT/reports/lint"
echo -e "${CYAN} ✓ reports/ cleaned${NC}"
echo ""
# Step 1 — Lint
echo -e "${CYAN}▸ [1/3] Lint...${NC}"
LINT_SCRIPT="$SCRIPT_DIR/flutter-lint.sh"
if [[ -f "$LINT_SCRIPT" ]]; then
if [[ -n "$LINT_MODE" ]]; then
bash "$LINT_SCRIPT" "--$LINT_MODE" || true
else
bash "$LINT_SCRIPT" || true
fi
else
echo -e "${YELLOW}⚠ flutter-lint.sh not found — skipping lint${NC}"
fi
# Step 2 — Test coverage (run tests fresh, then copy generated lcov)
echo ""
echo -e "${CYAN}▸ [2/3] Test coverage...${NC}"
if command -v very_good >/dev/null 2>&1; then
TEST_CMD="very_good test --coverage"
echo -e "${CYAN} Runner: very_good${NC}"
else
TEST_CMD="flutter test --coverage"
echo -e "${CYAN} Runner: flutter${NC}"
fi
COVERAGE_FOUND=false
while IFS= read -r pubspec_dir; do
pkg_rel="${pubspec_dir#$PROJECT_ROOT/}"
if (cd "$pubspec_dir" && $TEST_CMD 2>&1); then
LCOV="$pubspec_dir/coverage/lcov.info"
if [[ -f "$LCOV" ]]; then
mkdir -p "$PROJECT_ROOT/reports/coverage"
cp "$LCOV" "$PROJECT_ROOT/reports/coverage/lcov.info"
echo -e "${CYAN} ✓ $pkg_rel${NC}"
COVERAGE_FOUND=true
fi
else
echo -e "${YELLOW} ⚠ $pkg_rel — tests failed or no tests${NC}"
fi
done < <(find "$PROJECT_ROOT" -name "pubspec.yaml" \
-not -path "*/.dart_tool/*" -not -path "*/build/*" -not -path "*/.git/*" \
-not -path "$PROJECT_ROOT/pubspec.yaml" \
-not -path "$PROJECT_ROOT/app/*" \
-exec dirname {} \; | sort)
if [[ "$COVERAGE_FOUND" == false ]]; then
echo -e "${YELLOW} ⚠ No coverage data generated${NC}"
fi
# Step 3 — Sonar scan
echo ""
echo -e "${CYAN}▸ [3/3] Sonar scan...${NC}"
if [[ ${#REMAINING_ARGS[@]} -gt 0 ]]; then
exec bash "$SCRIPT_DIR/portable-local-sonar.sh" "${REMAINING_ARGS[@]}"
else
exec bash "$SCRIPT_DIR/portable-local-sonar.sh"
fi
fi
# ── Legacy Homebrew/Docker path (explicit --legacy-local only) ────────────────
log_warn "Using legacy Homebrew + Colima/Docker mode (--legacy-local)."
# PATH: prefer Homebrew CLIs on Apple Silicon / Intel / user-local.
[[ -d "$HOME/.homebrew/bin" ]] && export PATH="$HOME/.homebrew/bin:$PATH"
[[ -d "$HOME/homebrew/bin" ]] && export PATH="$HOME/homebrew/bin:$PATH"
[[ -d /opt/homebrew/bin ]] && export PATH="/opt/homebrew/bin:$PATH"
[[ -d /usr/local/bin ]] && export PATH="/usr/local/bin:$PATH"
if ! command -v brew >/dev/null 2>&1; then
log_error "Homebrew is not installed."
echo " Recommended path: bash scripts/quality/setup.sh && bash scripts/quality/local-quality.sh"
echo " Legacy path requires Homebrew: https://brew.sh"
exit 1
fi
log_ok "Homebrew found: $(brew --prefix)"
ensure_brew_tool() {
local cmd="$1"
local formula="$2"
if command -v "$cmd" >/dev/null 2>&1; then
log_ok "$cmd found"
else
log_warn "$cmd not found — installing via brew install $formula ..."
brew install "$formula"
log_ok "$cmd installed"
fi
}
ensure_brew_tool colima colima
ensure_brew_tool docker docker
ensure_brew_tool sonar-scanner sonar-scanner
ensure_brew_tool jq jq
ensure_brew_tool python3 python
if [[ ! -x "$QUALITY_CHECK" ]]; then
chmod +x "$QUALITY_CHECK" 2>/dev/null || true
fi
echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN} local-quality.sh → quality-check.sh${NC}"
echo -e "${CYAN} Legacy passing: --sonar-local ${REMAINING_ARGS[*]:-}${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""
quality_exit=0
if [[ ${#REMAINING_ARGS[@]} -gt 0 ]]; then
bash "$QUALITY_CHECK" --sonar-local "${REMAINING_ARGS[@]}" || quality_exit=$?
else
bash "$QUALITY_CHECK" --sonar-local || quality_exit=$?
fi
echo ""
if [[ -f "$REPORT_HTML" ]]; then
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Report ready: ${REPORT_HTML}${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
fi
exit $quality_exit
#!/bin/bash
# Lint runner for the Flutter monorepo.
# Uses melos analyze (per-package, ordered) if melos is bootstrapped,
# otherwise falls back to flutter analyze at project root.
#
# Usage: bash scripts/quality/flutter-lint.sh [mode]
# mode: dev | sit → only errors cause failure (warnings/info ignored)
# mode: (empty) → strict, fail on any issue
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
MODE=""
show_help() {
echo ""
echo "Usage: bash scripts/quality/flutter-lint.sh [MODE]"
echo ""
echo " --dev | dev — only errors cause failure (warnings and info ignored)"
echo " --sit | sit — same as dev"
echo " (empty) — strict, fail on any issue"
echo ""
}
for arg in "$@"; do
case "$arg" in
-h|--help|help) show_help; exit 0 ;;
--dev|dev) MODE="dev" ;;
--sit|sit) MODE="sit" ;;
*) echo -e "${RED}✗ Unknown option: $arg${NC}"; show_help; exit 2 ;;
esac
done
cd "$PROJECT_ROOT"
# Discover packages: all dirs with pubspec.yaml, excluding root
PACKAGES=()
while IFS= read -r pubspec; do
dir="$(dirname "$pubspec")"
[[ "$dir" == "." || "$dir" == "$PROJECT_ROOT" ]] && continue
PACKAGES+=("$dir")
done < <(find "$PROJECT_ROOT" -name "pubspec.yaml" \
-not -path "*/.dart_tool/*" \
-not -path "*/build/*" \
-not -path "*/.git/*" \
-not -path "$PROJECT_ROOT/app/*" \
| sort)
echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN} Flutter Lint — flutter analyze --no-pub${NC}"
if [[ -n "$MODE" ]]; then
echo -e "${CYAN} Mode: $MODE (only errors cause failure)${NC}"
else
echo -e "${CYAN} Mode: strict${NC}"
fi
echo -e "${CYAN} Packages: ${#PACKAGES[@]}${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""
START_TIME=$(date +%s)
RESULT=""
for pkg in "${PACKAGES[@]}"; do
pkg_rel="${pkg#$PROJECT_ROOT/}"
echo -e "${CYAN}▸ $pkg_rel${NC}"
pkg_result=$(cd "$pkg" && flutter analyze --no-pub 2>&1)
echo "$pkg_result" \
| sed 's/^[[:space:]]*error[[:space:]]*•/ 🔴 error •/g' \
| sed 's/^[[:space:]]*warning[[:space:]]*•/ 🟡 warning •/g' \
| sed 's/^[[:space:]]*info[[:space:]]*•/ 🔵 info •/g'
RESULT="$RESULT"$'\n'"$pkg_result"
done
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
ERROR_COUNT=$(echo "$RESULT" | grep -cE "^\s*error\s*•" || true)
WARNING_COUNT=$(echo "$RESULT" | grep -cE "^\s*warning\s*•" || true)
INFO_COUNT=$(echo "$RESULT" | grep -cE "^\s*info\s*•" || true)
write_lint_json() {
local passed_bool
[[ "$1" == "true" ]] && passed_bool="true" || passed_bool="false"
mkdir -p "$PROJECT_ROOT/reports/lint"
local tmp_result="$PROJECT_ROOT/reports/lint/.result.tmp"
printf '%s' "$RESULT" > "$tmp_result"
python3 - "$tmp_result" "$PROJECT_ROOT" "${MODE:-strict}" "$passed_bool" \
"$ERROR_COUNT" "$WARNING_COUNT" "$INFO_COUNT" "$ELAPSED" \
> "$PROJECT_ROOT/reports/lint/lint-results.json" <<'PYEOF'
import json, re, sys
result_file, project_root, mode, passed_s, errors_s, warnings_s, info_s, elapsed_s = sys.argv[1:]
with open(result_file, encoding='utf-8', errors='replace') as f:
result = f.read()
files = {}
SEV_KEY = {'error': 'errors', 'warning': 'warnings', 'info': 'info'}
pat = re.compile(r'^\s*(error|warning|info)\s+•\s+(.*?)\s+•\s+(.+?):(\d+):\d+\s+•\s+(.+)$')
for line in result.splitlines():
m = pat.match(line)
if not m:
continue
sev, msg, path, lineno, rule = m.group(1), m.group(2).strip(), m.group(3).strip(), m.group(4), m.group(5).strip()
if path.startswith(project_root + '/'):
path = path[len(project_root)+1:]
if path not in files:
files[path] = {'errors': 0, 'warnings': 0, 'info': 0, 'issues': []}
files[path][SEV_KEY.get(sev, 'info')] += 1
files[path]['issues'].append({'line': int(lineno), 'message': msg, 'severity': sev, 'rule': rule})
files_list = sorted(files.items(), key=lambda x: (-x[1].get('errors',0), -x[1].get('warnings',0)))[:500]
print(json.dumps({
'errors': int(errors_s), 'warnings': int(warnings_s), 'info': int(info_s),
'mode': mode, 'passed': passed_s == 'true', 'elapsed': int(elapsed_s),
'files': [{'path': p, **v} for p, v in files_list]
}))
PYEOF
rm -f "$tmp_result"
}
echo ""
if [[ "$MODE" == "dev" || "$MODE" == "sit" ]]; then
if [[ "$ERROR_COUNT" -gt 0 ]]; then
write_lint_json "false"
echo -e "${RED}══════════════════════════════════════════════════${NC}"
echo -e "${RED} ❌ LINT FAILED — $ERROR_COUNT error(s) in $MODE mode (took ${ELAPSED}s)${NC}"
echo -e "${RED}══════════════════════════════════════════════════${NC}"
[[ "$WARNING_COUNT" -gt 0 ]] && echo -e " ${YELLOW}⚠ $WARNING_COUNT warning(s) — ignored in $MODE mode${NC}"
[[ "$INFO_COUNT" -gt 0 ]] && echo -e " ${CYAN}ℹ $INFO_COUNT info — ignored in $MODE mode${NC}"
echo ""
exit 1
fi
write_lint_json "true"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${GREEN} ✅ LINT PASSED — no errors in $MODE mode (took ${ELAPSED}s)${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
[[ "$WARNING_COUNT" -gt 0 ]] && echo -e " ${YELLOW}⚠ $WARNING_COUNT warning(s) — ignored${NC}"
[[ "$INFO_COUNT" -gt 0 ]] && echo -e " ${CYAN}ℹ $INFO_COUNT info — ignored${NC}"
echo ""
exit 0
fi
# Strict mode
TOTAL=$((ERROR_COUNT + WARNING_COUNT + INFO_COUNT))
if [[ "$TOTAL" -gt 0 ]]; then
write_lint_json "false"
echo -e "${RED}══════════════════════════════════════════════════${NC}"
echo -e "${RED} ❌ LINT FAILED — $TOTAL issue(s) found (took ${ELAPSED}s)${NC}"
echo -e "${RED}══════════════════════════════════════════════════${NC}"
[[ "$ERROR_COUNT" -gt 0 ]] && echo -e " 🔴 $ERROR_COUNT error(s)"
[[ "$WARNING_COUNT" -gt 0 ]] && echo -e " ${YELLOW}🟡 $WARNING_COUNT warning(s)${NC}"
[[ "$INFO_COUNT" -gt 0 ]] && echo -e " ${CYAN}🔵 $INFO_COUNT info${NC}"
echo ""
exit 1
fi
write_lint_json "true"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${GREEN} ✅ LINT PASSED — no issues found (took ${ELAPSED}s)${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo ""
#!/bin/bash
# Portable local SonarQube mode — no Homebrew, no Docker, no admin/sudo.
# Downloads SonarQube + sonar-scanner zips to a user cache; starts SonarQube
# via its own bundled script; delegates to quality-check.sh in remote mode.
#
# Usage: bash scripts/quality/portable-local-sonar.sh [OPTIONS]
# (called automatically by: bash scripts/quality/local-quality.sh)
set -euo pipefail
# ── Colors ────────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log_info() { echo -e "${CYAN}▸ $*${NC}"; }
log_ok() { echo -e "${GREEN}✓ $*${NC}"; }
log_warn() { echo -e "${YELLOW}⚠ $*${NC}"; }
log_error() { echo -e "${RED}✗ $*${NC}" >&2; }
# ── Resolve stable script path before env defaults/help ───────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
# ── Defaults (all overridable via env) ────────────────────────────────────────
CACHE_DIR="${TOP_FLUTTER_QUALITY_CACHE:-$HOME/.cache/top-flutter-quality}"
SONAR_LOCAL_PORT="${SONAR_LOCAL_PORT:-19102}"
SONARQUBE_PORTABLE_VERSION="${SONARQUBE_PORTABLE_VERSION:-10.7.0.96327}"
SONAR_SCANNER_PORTABLE_VERSION="${SONAR_SCANNER_PORTABLE_VERSION:-6.2.1.4610}"
# Override with SONAR_LOCAL_ADMIN_PASSWORD env var if default admin password has been changed
# URL env overrides let users point to a mirror or new release without editing this file.
SONARQUBE_ZIP_URL="${SONARQUBE_ZIP_URL:-https://binaries.sonarsource.com/Distribution/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}.zip}"
SONAR_SCANNER_ZIP_URL="${SONAR_SCANNER_ZIP_URL:-https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_PORTABLE_VERSION}.zip}"
SONAR_FLUTTER_PLUGIN_VERSION="${SONAR_FLUTTER_PLUGIN_VERSION:-0.5.2}"
SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL="${SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL:-}"
SONAR_FLUTTER_PLUGIN_VENDOR_JAR="${SONAR_FLUTTER_PLUGIN_VENDOR_JAR:-$SCRIPT_DIR/vendor/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN="${SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN:-1}"
SONAR_FLUTTER_PLUGIN_JAR="${SONAR_FLUTTER_PLUGIN_JAR:-$CACHE_DIR/plugins/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
SONAR_LOCAL_PROJECT_KEY="${SONAR_LOCAL_PROJECT_KEY:-top-flutter-local}"
SONAR_LOCAL_TOKEN_DIR="${SONAR_LOCAL_TOKEN_DIR:-$HOME/.sonarqube-local}"
SONAR_LOCAL_TOKEN_FILE="${SONAR_LOCAL_TOKEN_FILE:-$SONAR_LOCAL_TOKEN_DIR/top-flutter-portable-${SONAR_LOCAL_PORT}.token}"
SONAR_LOCAL_READY_TIMEOUT="${SONAR_LOCAL_READY_TIMEOUT:-360}"
KEEP_SERVER=0
SONAR_PID=""
# ── Help ──────────────────────────────────────────────────────────────────────
show_help() {
echo ""
echo "Usage: bash scripts/quality/portable-local-sonar.sh [OPTIONS]"
echo " bash scripts/quality/local-quality.sh [OPTIONS]"
echo ""
echo "Starts a portable local SonarQube (zip, no Docker/Homebrew/admin),"
echo "then runs the quality gate and generates reports."
echo "Run first-time setup with: bash scripts/quality/setup.sh"
echo ""
echo "Requirements:"
echo " curl, unzip, python3 (usually pre-installed on macOS/Linux)"
echo " java (JDK 17) SonarQube 10.7 requires JDK 17; set JAVA_HOME / PORTABLE_JAVA_HOME"
echo ""
echo "Options:"
echo " --keep-sonar-local Keep SonarQube process running after scan"
echo " --keep-server Alias for --keep-sonar-local"
echo " -d, --dup-threshold Max duplication % (default: 3)"
echo " -c, --cov-threshold Min coverage % (default: 80)"
echo " --focus AREAS Focus on: coverage,duplication,smell"
echo " --minimal-focus Shorthand for --focus coverage,duplication,smell"
echo " --focus-minimal Alias for --minimal-focus"
echo " --smell-threshold N Max code smells in focus mode (default: 0)"
echo " -h, --help Show this help (no download)"
echo ""
echo "Environment overrides:"
echo " TOP_FLUTTER_QUALITY_CACHE Cache dir (default: ~/.cache/top-flutter-quality)"
echo " SONAR_LOCAL_PORT SonarQube port (default: 19102)"
echo " SONARQUBE_PORTABLE_VERSION SonarQube version (default: $SONARQUBE_PORTABLE_VERSION)"
echo " SONAR_SCANNER_PORTABLE_VERSION sonar-scanner version (default: $SONAR_SCANNER_PORTABLE_VERSION)"
echo " SONARQUBE_ZIP_URL Override full SonarQube download URL"
echo " SONAR_SCANNER_ZIP_URL Override full sonar-scanner download URL"
echo " SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN default: 1 (set 0 to skip)"
echo " SONAR_FLUTTER_PLUGIN_VERSION sonar-flutter plugin version (default: $SONAR_FLUTTER_PLUGIN_VERSION)"
echo " SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL Optional explicit mirror URL for sonar-flutter plugin (no default)"
echo " SONAR_FLUTTER_PLUGIN_VENDOR_JAR Repo-local vendor jar path (default: scripts/quality/vendor/sonar-flutter-plugin-<version>.jar)"
echo " SONAR_FLUTTER_PLUGIN_JAR Cached plugin jar path (default: cache/plugins/sonar-flutter-plugin-<version>.jar)"
echo " PORTABLE_JAVA_HOME Explicit JDK path (skips PATH/JAVA_HOME search)"
echo " SONAR_TOKEN Skip token auto-creation; use this token"
echo " SONAR_LOCAL_TOKEN_FILE Token cache file (default: ~/.sonarqube-local/top-flutter-portable-<port>.token)"
echo ""
echo "Examples:"
echo " bash scripts/quality/setup.sh"
echo " bash scripts/quality/local-quality.sh"
echo " bash scripts/quality/local-quality.sh --keep-sonar-local"
echo ""
exit 0
}
# ── Arg parsing ───────────────────────────────────────────────────────────────
PASSTHROUGH_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) show_help ;;
--keep-sonar-local|--keep-server) KEEP_SERVER=1; shift ;;
--portable-local) shift ;; # backward-compatible no-op; portable is the default entrypoint path
*) PASSTHROUGH_ARGS+=("$1"); shift ;;
esac
done
# ── Resolve paths ─────────────────────────────────────────────────────────────
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
QUALITY_CHECK="$SCRIPT_DIR/quality-check.sh"
REPORT_HTML="$PROJECT_ROOT/reports/quality/quality-report.html"
RUNTIME_DIR="$CACHE_DIR/runtime"
# ── Prerequisite checks ───────────────────────────────────────────────────────
for tool in curl unzip python3; do
if ! command -v "$tool" >/dev/null 2>&1; then
log_error "$tool is required but not found. Install it and retry."
exit 1
fi
done
# ── Java resolution ───────────────────────────────────────────────────────────
java_line_for_home() {
"$1/bin/java" -version 2>&1 | python3 -c 'import sys; print(sys.stdin.readline().strip())' 2>/dev/null || true
}
java_major_for_home() {
"$1/bin/java" -version 2>&1 | python3 -c 'import re,sys
text=sys.stdin.read()
m=re.search(r"version \"([^\"]+)\"", text)
if not m:
sys.exit(0)
v=m.group(1)
print(v.split(".")[1] if v.startswith("1.") else v.split(".")[0])' 2>/dev/null || true
}
use_java_home() {
export JAVA_HOME="$1"
export PATH="$JAVA_HOME/bin:$PATH"
}
require_java17_home() {
local candidate="$1"
local label="$2"
local major=""
local line=""
if [[ -z "$candidate" || ! -x "$candidate/bin/java" ]]; then
log_error "$label does not point to an executable JDK: $candidate"
exit 1
fi
major="$(java_major_for_home "$candidate")"
line="$(java_line_for_home "$candidate")"
if [[ "$major" != "17" ]]; then
log_error "SonarQube ${SONARQUBE_PORTABLE_VERSION} requires Java 17; $label is ${line:-unknown}."
echo " Set PORTABLE_JAVA_HOME to a JDK 17 archive extracted under your home directory."
exit 1
fi
use_java_home "$candidate"
}
resolve_java() {
local mac_java17=""
local path_java=""
local path_home=""
local path_major=""
local path_line=""
if [[ -n "${PORTABLE_JAVA_HOME:-}" ]]; then
require_java17_home "$PORTABLE_JAVA_HOME" "PORTABLE_JAVA_HOME"
return 0
fi
if [[ -n "${JAVA_HOME:-}" && -x "$JAVA_HOME/bin/java" ]]; then
if [[ "$(java_major_for_home "$JAVA_HOME")" == "17" ]]; then
use_java_home "$JAVA_HOME"
return 0
fi
fi
if [[ "$(uname -s)" == "Darwin" ]] && [[ -x /usr/libexec/java_home ]]; then
mac_java17="$(/usr/libexec/java_home -v 17 2>/dev/null || true)"
if [[ -n "$mac_java17" && -x "$mac_java17/bin/java" ]]; then
use_java_home "$mac_java17"
return 0
fi
fi
if command -v java >/dev/null 2>&1; then
path_java="$(command -v java)"
path_home="$(cd "$(dirname "$path_java")/.." && pwd)"
if [[ -x "$path_home/bin/java" ]]; then
path_major="$(java_major_for_home "$path_home")"
path_line="$(java_line_for_home "$path_home")"
if [[ "$path_major" == "17" ]]; then
use_java_home "$path_home"
return 0
fi
log_error "SonarQube ${SONARQUBE_PORTABLE_VERSION} requires Java 17; PATH java is ${path_line:-unknown}."
echo " Install/extract JDK 17 and set PORTABLE_JAVA_HOME, or on macOS install a JDK 17 visible to /usr/libexec/java_home -v 17."
exit 1
fi
fi
log_error "Java 17 not found."
echo ""
echo " To fix without admin/Homebrew:"
echo " 1. Download a JDK archive from https://adoptium.net/temurin/releases/"
echo " (choose macOS/Linux, .tar.gz, JDK 17)"
echo " 2. Extract it, e.g.: tar -xzf OpenJDK17*.tar.gz -C ~/.cache/jdk/"
echo " 3. Re-run with: PORTABLE_JAVA_HOME=~/.cache/jdk/<extracted-dir> bash scripts/quality/local-quality.sh"
echo ""
exit 1
}
resolve_java
log_ok "java: $(java_line_for_home "$JAVA_HOME")"
# ── Cache directories ─────────────────────────────────────────────────────────
mkdir -p "$CACHE_DIR/sonarqube" "$CACHE_DIR/sonar-scanner" "$RUNTIME_DIR" "$CACHE_DIR/plugins"
validate_flutter_plugin_jar() {
local jar_path="$1"
[[ -f "$jar_path" ]] || return 1
unzip -t "$jar_path" >/dev/null 2>&1
}
fail_invalid_flutter_plugin_download() {
log_error "Mirror-provided sonar-flutter plugin is not a valid jar: $SONAR_FLUTTER_PLUGIN_JAR"
echo ""
echo " The explicit mirror download may have been partial, corrupted, or replaced"
echo " by non-jar content from a proxy/corporate network."
echo ""
echo " To fix:"
echo " 1. Copy a valid sonar-flutter plugin jar to the repo-local vendor path:"
echo " $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
echo " 2. Or prefill the cache with a valid jar at:"
echo " $SONAR_FLUTTER_PLUGIN_JAR"
echo " 3. Or set SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted mirror."
echo ""
exit 1
}
fail_missing_flutter_plugin() {
log_error "Missing valid sonar-flutter plugin jar."
echo ""
echo " GitHub fallback downloads are disabled. Provide the plugin explicitly by"
echo " placing a valid sonar-flutter plugin jar at:"
echo " $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
echo " Or prefill the cache with a valid jar at:"
echo " $SONAR_FLUTTER_PLUGIN_JAR"
echo " Or set SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted non-default mirror."
echo ""
exit 1
}
fail_flutter_plugin_download_failed() {
log_error "Could not download sonar-flutter plugin from explicit mirror: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL"
echo ""
echo " Place a valid plugin jar at:"
echo " $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
echo " Or prefill the cache with a valid jar at:"
echo " $SONAR_FLUTTER_PLUGIN_JAR"
echo ""
exit 1
}
fail_invalid_flutter_plugin_vendor() {
log_error "Repo-local sonar-flutter plugin vendor jar is not valid: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
echo ""
echo " The vendor jar exists but failed validation, so this repository/package copy"
echo " is considered broken. Replace it with a valid sonar-flutter plugin jar."
echo ""
exit 1
}
ensure_flutter_plugin() {
local plugin_dest_dir="$SQ_DIR/extensions/plugins"
local plugin_dest="$plugin_dest_dir/$(basename "$SONAR_FLUTTER_PLUGIN_JAR")"
if [[ "$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN" != "1" ]]; then
log_warn "Skipping sonar-flutter plugin install (SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN)"
return 0
fi
if [[ -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
if validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
else
log_warn "Removing invalid cached sonar-flutter plugin jar: $SONAR_FLUTTER_PLUGIN_JAR"
rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
fi
fi
if [[ ! -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
mkdir -p "$(dirname "$SONAR_FLUTTER_PLUGIN_JAR")"
if [[ -f "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" ]]; then
log_info "Using repo-local sonar-flutter plugin vendor jar: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR"; then
fail_invalid_flutter_plugin_vendor
fi
cp "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" "$SONAR_FLUTTER_PLUGIN_JAR"
if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
log_error "Copied sonar-flutter plugin cache jar is invalid: $SONAR_FLUTTER_PLUGIN_JAR"
exit 1
fi
log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
elif [[ -n "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" ]]; then
log_info "Fetching sonar-flutter plugin ${SONAR_FLUTTER_PLUGIN_VERSION} from explicit mirror..."
log_info "URL: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL"
if ! curl -fL --progress-bar "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" -o "$SONAR_FLUTTER_PLUGIN_JAR"; then
rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
fail_flutter_plugin_download_failed
fi
if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
fail_invalid_flutter_plugin_download
fi
log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
else
fail_missing_flutter_plugin
fi
fi
mkdir -p "$plugin_dest_dir"
if [[ -f "$plugin_dest" ]] && ! validate_flutter_plugin_jar "$plugin_dest"; then
log_warn "Replacing invalid installed sonar-flutter plugin jar: $plugin_dest"
fi
if [[ "$SONAR_FLUTTER_PLUGIN_JAR" != "$plugin_dest" ]]; then
cp "$SONAR_FLUTTER_PLUGIN_JAR" "$plugin_dest"
fi
if [[ ! -f "$plugin_dest" ]]; then
log_error "sonar-flutter plugin was not installed at $plugin_dest"
exit 1
fi
if ! validate_flutter_plugin_jar "$plugin_dest"; then
log_error "sonar-flutter plugin installed jar is invalid: $plugin_dest"
exit 1
fi
log_ok "sonar-flutter plugin installed: $plugin_dest"
}
# ── Download/extract SonarQube ────────────────────────────────────────────────
SQ_ZIP="$CACHE_DIR/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}.zip"
SQ_DIR="$CACHE_DIR/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}"
if [[ ! -d "$SQ_DIR" ]]; then
if [[ ! -f "$SQ_ZIP" ]]; then
log_info "Downloading SonarQube ${SONARQUBE_PORTABLE_VERSION}..."
log_info "URL: $SONARQUBE_ZIP_URL"
curl -fL --progress-bar "$SONARQUBE_ZIP_URL" -o "$SQ_ZIP"
fi
log_info "Extracting SonarQube..."
unzip -q "$SQ_ZIP" -d "$CACHE_DIR/sonarqube"
# Normalize extracted dir name (zip may unpack as sonarqube-X.Y.Z.BUILD)
if [[ ! -d "$SQ_DIR" ]]; then
extracted="$(find "$CACHE_DIR/sonarqube" -maxdepth 1 -name "sonarqube-*" -type d | head -1)"
if [[ -z "$extracted" ]]; then
log_error "Could not find extracted SonarQube directory."
exit 1
fi
mv "$extracted" "$SQ_DIR"
fi
fi
log_ok "SonarQube cache: $SQ_DIR"
ensure_flutter_plugin
# ── Download/extract sonar-scanner ───────────────────────────────────────────
SS_ZIP="$CACHE_DIR/sonar-scanner/sonar-scanner-cli-${SONAR_SCANNER_PORTABLE_VERSION}.zip"
SS_DIR="$CACHE_DIR/sonar-scanner/sonar-scanner-${SONAR_SCANNER_PORTABLE_VERSION}"
if [[ ! -d "$SS_DIR" ]]; then
if [[ ! -f "$SS_ZIP" ]]; then
log_info "Downloading sonar-scanner ${SONAR_SCANNER_PORTABLE_VERSION}..."
log_info "URL: $SONAR_SCANNER_ZIP_URL"
curl -fL --progress-bar "$SONAR_SCANNER_ZIP_URL" -o "$SS_ZIP"
fi
log_info "Extracting sonar-scanner..."
unzip -q "$SS_ZIP" -d "$CACHE_DIR/sonar-scanner"
if [[ ! -d "$SS_DIR" ]]; then
extracted="$(find "$CACHE_DIR/sonar-scanner" -maxdepth 1 -name "sonar-scanner-*" -type d | head -1)"
if [[ -z "$extracted" ]]; then
log_error "Could not find extracted sonar-scanner directory."
exit 1
fi
mv "$extracted" "$SS_DIR"
fi
fi
log_ok "sonar-scanner cache: $SS_DIR"
# Prepend portable sonar-scanner to PATH
export PATH="$SS_DIR/bin:$PATH"
export TOP_FLUTTER_PORTABLE_SCANNER=1
# ── Start SonarQube ───────────────────────────────────────────────────────────
SONAR_HOST_URL="http://localhost:${SONAR_LOCAL_PORT}"
SQ_LOG="$RUNTIME_DIR/sonarqube.log"
SQ_PID_FILE="$RUNTIME_DIR/sonarqube.pid"
# Detect OS for the correct sonar.sh wrapper
if [[ "$(uname -s)" == "Darwin" ]]; then
SQ_OS="macosx-universal-64"
else
SQ_OS="linux-x86-64"
fi
SQ_SCRIPT="$SQ_DIR/bin/$SQ_OS/sonar.sh"
if [[ ! -f "$SQ_SCRIPT" ]]; then
# Fall back: find any sonar.sh in the bin tree
SQ_SCRIPT="$(find "$SQ_DIR/bin" -name "sonar.sh" | head -1 || true)"
if [[ -z "$SQ_SCRIPT" ]]; then
log_error "Cannot find sonar.sh in $SQ_DIR/bin"
exit 1
fi
fi
chmod +x "$SQ_SCRIPT" 2>/dev/null || true
configure_sonar_properties() {
local props="$SQ_DIR/conf/sonar.properties"
local tmp
tmp="$(mktemp "${props}.tmp.XXXXXX")"
trap 'rm -f "$tmp"' RETURN
if [[ ! -f "$props" ]]; then
log_error "Missing SonarQube config file: $props"
exit 1
fi
python3 - "$props" "$tmp" "$SONAR_LOCAL_PORT" <<'PY'
import sys
path, tmp, port = sys.argv[1:]
updates = {"sonar.web.port": port, "sonar.web.host": "127.0.0.1"}
seen = set()
out = []
with open(path, encoding="utf-8") as fh:
for raw in fh:
stripped = raw.lstrip()
active = not stripped.startswith("#") and "=" in raw
key = raw.split("=", 1)[0].strip() if active else ""
if key in updates:
out.append(f"{key}={updates[key]}\n")
seen.add(key)
else:
out.append(raw)
for key, value in updates.items():
if key not in seen:
out.append(f"{key}={value}\n")
with open(tmp, "w", encoding="utf-8") as fh:
fh.writelines(out)
PY
mv "$tmp" "$props"
log_ok "Configured SonarQube web bind: 127.0.0.1:${SONAR_LOCAL_PORT}"
}
configure_sonar_properties
# Check if already up
sonar_status() {
curl -fsS "$SONAR_HOST_URL/api/system/status" 2>/dev/null \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("status","UNKNOWN"))' 2>/dev/null || true
}
sonar_version() {
curl -fsS "$SONAR_HOST_URL/api/server/version" 2>/dev/null || true
}
port_diagnostic() {
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"$SONAR_LOCAL_PORT" -sTCP:LISTEN 2>/dev/null || true
fi
}
SONAR_STARTED_BY_SCRIPT=0
existing_status="$(sonar_status)"
existing_version="$(sonar_version)"
if [[ "$existing_status" == "UP" ]]; then
log_info "Existing SonarQube status at $SONAR_HOST_URL: $existing_status version=${existing_version:-unknown}"
if [[ -n "$existing_version" && "$existing_version" != "$SONARQUBE_PORTABLE_VERSION" ]]; then
log_error "Port $SONAR_LOCAL_PORT is serving SonarQube $existing_version, not expected portable $SONARQUBE_PORTABLE_VERSION."
echo " Choose a free port with SONAR_LOCAL_PORT=9103 or stop the other server."
port_diagnostic
exit 1
fi
log_ok "SonarQube already UP at $SONAR_HOST_URL"
else
if port_diagnostic | grep -q .; then
log_error "Port $SONAR_LOCAL_PORT is already occupied, but $SONAR_HOST_URL is not an UP SonarQube instance."
port_diagnostic
echo " Choose a free port with SONAR_LOCAL_PORT=9103 or stop the conflicting process."
exit 1
fi
log_info "Starting portable SonarQube on port $SONAR_LOCAL_PORT..."
JAVA_HOME="${JAVA_HOME:-}" bash "$SQ_SCRIPT" console >> "$SQ_LOG" 2>&1 &
SONAR_PID=$!
SONAR_STARTED_BY_SCRIPT=1
echo "$SONAR_PID" > "$SQ_PID_FILE"
log_info "SonarQube PID $SONAR_PID — log: $SQ_LOG"
fi
# ── Cleanup trap ──────────────────────────────────────────────────────────────
cleanup_sonar() {
if [[ "$SONAR_STARTED_BY_SCRIPT" == "1" && "$KEEP_SERVER" == "0" && -n "$SONAR_PID" ]]; then
log_warn "Stopping portable SonarQube (PID $SONAR_PID)..."
kill "$SONAR_PID" 2>/dev/null || true
rm -f "$SQ_PID_FILE"
fi
}
trap cleanup_sonar EXIT
# ── Wait for UP ───────────────────────────────────────────────────────────────
log_info "Waiting for SonarQube to be UP at $SONAR_HOST_URL ..."
deadline=$((SECONDS + SONAR_LOCAL_READY_TIMEOUT))
reached_up=0
while (( SECONDS < deadline )); do
if [[ "$(sonar_status)" == "UP" ]]; then
reached_up=1; break
fi
printf "."
sleep 5
done
if [[ "$reached_up" == "0" ]]; then
echo ""
log_error "Timed out waiting for SonarQube. Check: $SQ_LOG"
exit 1
fi
log_ok "SonarQube is UP"
# ── Token management ──────────────────────────────────────────────────────────
validate_token() {
local token="$1"
local valid=""
if [[ -z "$token" ]]; then
return 1
fi
valid="$(curl -fsS -H "Authorization: Bearer ${token}" \
"$SONAR_HOST_URL/api/authentication/validate" 2>/dev/null \
| python3 -c 'import json,sys; print("true" if json.load(sys.stdin).get("valid") is True else "false")' 2>/dev/null || true)"
[[ "$valid" == "true" ]]
}
ensure_token() {
if [[ -n "${SONAR_TOKEN:-}" ]]; then
return 0
fi
mkdir -p "$SONAR_LOCAL_TOKEN_DIR"
chmod 700 "$SONAR_LOCAL_TOKEN_DIR" 2>/dev/null || true
if [[ -f "$SONAR_LOCAL_TOKEN_FILE" ]]; then
SONAR_TOKEN="$(tr -d '[:space:]' < "$SONAR_LOCAL_TOKEN_FILE")"
if validate_token "$SONAR_TOKEN"; then
export SONAR_TOKEN
log_ok "Using cached SonarQube token: $SONAR_LOCAL_TOKEN_FILE"
return 0
fi
log_warn "Ignoring stale/invalid cached SonarQube token for $SONAR_HOST_URL: $SONAR_LOCAL_TOKEN_FILE"
rm -f "$SONAR_LOCAL_TOKEN_FILE"
SONAR_TOKEN=""
fi
log_info "Creating local SonarQube token (admin:${SONAR_LOCAL_ADMIN_PASSWORD:-admin})..."
local token_name="top-flutter-portable-$(date +%Y%m%d%H%M%S)"
SONAR_TOKEN="$(curl -fsS -u "admin:${SONAR_LOCAL_ADMIN_PASSWORD:-admin}" -X POST \
--data-urlencode "name=${token_name}" \
"$SONAR_HOST_URL/api/user_tokens/generate" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("token",""))' 2>/dev/null || true)"
if [[ -z "$SONAR_TOKEN" ]]; then
log_error "Could not auto-create token."
echo " Open $SONAR_HOST_URL, log in, create a token, then rerun with:"
echo " SONAR_TOKEN=<your-token> bash scripts/quality/local-quality.sh"
exit 1
fi
if ! validate_token "$SONAR_TOKEN"; then
log_error "Auto-created SonarQube token was not accepted by $SONAR_HOST_URL."
echo " Open $SONAR_HOST_URL, log in, create a token, then rerun with:"
echo " SONAR_TOKEN=<your-token> bash scripts/quality/local-quality.sh"
SONAR_TOKEN=""
exit 1
fi
printf '%s\n' "$SONAR_TOKEN" > "$SONAR_LOCAL_TOKEN_FILE"
chmod 600 "$SONAR_LOCAL_TOKEN_FILE" 2>/dev/null || true
export SONAR_TOKEN
log_ok "Cached SonarQube token: $SONAR_LOCAL_TOKEN_FILE"
}
ensure_token
# ── Delegate to quality-check.sh (remote mode — no --sonar-local) ─────────────
echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN} portable-local-sonar.sh → quality-check.sh${NC}"
echo -e "${CYAN} URL: $SONAR_HOST_URL${NC}"
echo -e "${CYAN} Project: $SONAR_LOCAL_PROJECT_KEY${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""
if [[ ! -x "$QUALITY_CHECK" ]]; then
chmod +x "$QUALITY_CHECK" 2>/dev/null || true
fi
quality_exit=0
if [[ ${#PASSTHROUGH_ARGS[@]} -gt 0 ]]; then
SONAR_HOST_URL="$SONAR_HOST_URL" \
SONAR_TOKEN="$SONAR_TOKEN" \
SONAR_PROJECT_KEY="$SONAR_LOCAL_PROJECT_KEY" \
TOP_FLUTTER_PORTABLE_SCANNER=1 \
bash "$QUALITY_CHECK" "${PASSTHROUGH_ARGS[@]}" || quality_exit=$?
else
SONAR_HOST_URL="$SONAR_HOST_URL" \
SONAR_TOKEN="$SONAR_TOKEN" \
SONAR_PROJECT_KEY="$SONAR_LOCAL_PROJECT_KEY" \
TOP_FLUTTER_PORTABLE_SCANNER=1 \
bash "$QUALITY_CHECK" || quality_exit=$?
fi
# ── Final report hint ─────────────────────────────────────────────────────────
echo ""
if [[ -f "$REPORT_HTML" ]]; then
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Report ready: ${REPORT_HTML}${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
fi
exit $quality_exit
#!/bin/bash
##############################################
# Code Quality Check (SonarQube + Coverage)
# Duplication Threshold: 3% | Coverage Threshold: 80%
##############################################
set -euo pipefail
# Prefer Homebrew CLIs on Apple Silicon for the legacy Colima/Docker path, but do
# not let Homebrew's sonar-scanner override the portable scanner selected by
# portable-local-sonar.sh.
if [[ "${TOP_FLUTTER_PORTABLE_SCANNER:-0}" != "1" && -d /opt/homebrew/bin ]]; then
export PATH="/opt/homebrew/bin:$PATH"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
REPORT_DIR="$PROJECT_ROOT/reports/quality"
SUMMARY_JSON="$REPORT_DIR/summary.json"
SONAR_PROPERTIES="$PROJECT_ROOT/sonar-project.properties"
DUP_THRESHOLD=3
COV_THRESHOLD=90
FOCUS_MODE=""
SMELL_THRESHOLD=0
SONAR_LOCAL=false
SONAR_LOCAL_KEEP_RUNNING="${SONAR_LOCAL_KEEP_RUNNING:-0}"
SONAR_LOCAL_CONTAINER="${SONAR_LOCAL_CONTAINER:-sonarqube-local}"
SONAR_LOCAL_IMAGE="${SONAR_LOCAL_IMAGE:-sonarqube:community}"
SONAR_LOCAL_PORT="${SONAR_LOCAL_PORT:-9000}"
SONAR_LOCAL_URL="http://localhost:${SONAR_LOCAL_PORT}"
SONAR_LOCAL_PROJECT_KEY="${SONAR_LOCAL_PROJECT_KEY:-top-flutter-local}"
SONAR_LOCAL_TOKEN_DIR="${SONAR_LOCAL_TOKEN_DIR:-$HOME/.sonarqube-local}"
SONAR_LOCAL_TOKEN_FILE="${SONAR_LOCAL_TOKEN_FILE:-$SONAR_LOCAL_TOKEN_DIR/top-flutter.token}"
SONAR_FLUTTER_PLUGIN_VERSION="${SONAR_FLUTTER_PLUGIN_VERSION:-0.5.2}"
SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL="${SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL:-}"
SONAR_FLUTTER_PLUGIN_VENDOR_JAR="${SONAR_FLUTTER_PLUGIN_VENDOR_JAR:-$SCRIPT_DIR/vendor/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
SONAR_FLUTTER_PLUGIN_JAR="${SONAR_FLUTTER_PLUGIN_JAR:-$SONAR_LOCAL_TOKEN_DIR/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN="${SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN:-1}"
COLIMA_CPU="${COLIMA_CPU:-2}"
COLIMA_MEMORY="${COLIMA_MEMORY:-4}"
COLIMA_DISK="${COLIMA_DISK:-20}"
COLIMA_STARTED_BY_SCRIPT=0
SONAR_CONTAINER_STARTED_BY_SCRIPT=0
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
show_help() {
echo ""
echo "Usage: $(basename "$0") [OPTIONS]"
echo ""
echo "Run the local quality gate with SonarScanner/SonarQube, then generate"
echo "reports/quality/summary.json and reports/quality/quality-report.html."
echo ""
echo "Options:"
echo " --sonar-local Start local SonarQube on demand via Colima/Docker,"
echo " scan against localhost only, then stop what this script started"
echo " --keep-sonar-local Keep the local SonarQube container/Colima running after scan"
echo " --keep-server Alias for --keep-sonar-local"
echo " -h, --help Show this help message"
echo " -d, --dup-threshold N Max duplication % fallback threshold (default: 3)"
echo " -c, --cov-threshold N Min coverage % fallback threshold (default: 90)"
echo " --focus AREAS Focus report on specific quality areas: coverage,duplication,smell."
echo " Gate is derived locally from these three thresholds; BUG/VULN/HOTSPOT"
echo " issues are hidden in the report."
echo " Example: --focus coverage,duplication,smell"
echo " --minimal-focus Shorthand for --focus coverage,duplication,smell"
echo " --focus-minimal Alias for --minimal-focus"
echo " --smell-threshold N Max code smells allowed in focus gate (default: 0)"
echo ""
echo "Environment:"
echo " SONAR_HOST_URL Required unless sonar.host.url is configured elsewhere"
echo " SONAR_TOKEN Required for authenticated scanner/API access"
echo " SONAR_PROJECT_KEY Required unless sonar.projectKey is in sonar-project.properties"
echo ""
echo "Local SonarQube harness environment:"
echo " SONAR_LOCAL_PORT Local SonarQube port (default: 9000)"
echo " SONAR_LOCAL_IMAGE SonarQube image (default: sonarqube:community)"
echo " SONAR_LOCAL_CONTAINER Container name (default: sonarqube-local)"
echo " SONAR_LOCAL_PROJECT_KEY Project key for local mode (default: top-flutter-local)"
echo " SONAR_LOCAL_KEEP_RUNNING=1 Keep local services running after scan"
echo " SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=0 Skip installing sonar-flutter plugin"
echo " SONAR_FLUTTER_PLUGIN_VERSION Flutter/Dart plugin version (default: 0.5.2)"
echo " SONAR_FLUTTER_PLUGIN_VENDOR_JAR Repo-local vendor jar path"
echo " SONAR_FLUTTER_PLUGIN_JAR Cached plugin jar path"
echo " SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL Optional explicit mirror URL (no default)"
echo " COLIMA_CPU=2 COLIMA_MEMORY=4 COLIMA_DISK=20 Resource limits when Colima is started"
echo ""
echo "Examples:"
echo ' SONAR_HOST_URL=https://sonar.example.com SONAR_TOKEN=*** SONAR_PROJECT_KEY=top-flutter \'
echo " $(basename "$0")"
echo " $(basename "$0") --sonar-local"
echo " $(basename "$0") -d 5 -c 70"
echo ""
echo "Recommended portable local mode (no Homebrew/Docker/admin):"
echo " bash scripts/quality/setup.sh"
echo " bash scripts/quality/local-quality.sh"
echo " bash scripts/quality/local-quality.sh --keep-sonar-local"
echo ""
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) show_help ;;
--sonar-local)
SONAR_LOCAL=true
shift
;;
--keep-sonar-local|--keep-server)
SONAR_LOCAL_KEEP_RUNNING=1
shift
;;
-d|--dup-threshold)
if [[ $# -lt 2 ]]; then
echo -e "${RED}✗ Missing value for $1${NC}"
exit 1
fi
DUP_THRESHOLD="$2"
shift 2
;;
-c|--cov-threshold)
if [[ $# -lt 2 ]]; then
echo -e "${RED}✗ Missing value for $1${NC}"
exit 1
fi
COV_THRESHOLD="$2"
shift 2
;;
--focus)
if [[ $# -lt 2 ]]; then
echo -e "${RED}✗ Missing value for $1${NC}"
exit 1
fi
FOCUS_MODE="$2"
shift 2
;;
--minimal-focus|--focus-minimal)
FOCUS_MODE="coverage,duplication,smell"
shift
;;
--smell-threshold)
if [[ $# -lt 2 ]]; then
echo -e "${RED}✗ Missing value for $1${NC}"
exit 1
fi
SMELL_THRESHOLD="$2"
shift 2
;;
*) echo -e "${RED}✗ Unknown option: $1${NC}"; exit 1 ;;
esac
done
validate_threshold() {
local name="$1"
local value="$2"
if ! [[ "$value" =~ ^([0-9]+([.][0-9]+)?|[.][0-9]+)$ ]]; then
echo -e "${RED}✗ ${name} must be a non-negative number: ${value}${NC}"
exit 1
fi
}
property_value() {
local key="$1"
local file="$2"
if [[ -f "$file" ]]; then
python3 - "$key" "$file" <<'PY'
import sys
key, path = sys.argv[1:]
with open(path, encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
if k.strip() == key:
print(v.strip())
break
PY
fi
}
is_local_sonar_url() {
local url="$1"
[[ "$url" == http://localhost:* || "$url" == http://127.0.0.1:* ]]
}
require_local_sonar_url() {
local url="$1"
if ! is_local_sonar_url "$url"; then
echo -e "${RED}✗ Refusing to run local Sonar mode against non-local host: ${url}${NC}"
echo -e "${YELLOW} --sonar-local only allows http://localhost:* or http://127.0.0.1:*${NC}"
exit 1
fi
}
sonar_status() {
local url="$1"
curl -fsS "$url/api/system/status" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status", "UNKNOWN"))' 2>/dev/null || true
}
wait_for_local_sonar() {
local url="$1"
local timeout_seconds="${SONAR_LOCAL_READY_TIMEOUT:-360}"
local deadline=$((SECONDS + timeout_seconds))
local status="UNKNOWN"
echo -e "${YELLOW}▸ Waiting for local SonarQube to be UP at ${url}...${NC}"
local reached_up=0
while (( SECONDS < deadline )); do
if [[ "$(sonar_status "$url")" == "UP" ]]; then
reached_up=1; break
fi
printf "."
sleep 5
done
if [[ "$reached_up" == "0" ]]; then
echo ""
echo -e "${RED}✗ Timed out waiting for local SonarQube.${NC}"
echo -e "${YELLOW} Check logs with: docker logs --tail 100 ${SONAR_LOCAL_CONTAINER}${NC}"
exit 1
fi
echo -e "${GREEN}✓ Local SonarQube is UP${NC}"
}
start_colima_if_needed() {
if docker info >/dev/null 2>&1; then
return 0
fi
if ! command -v colima >/dev/null 2>&1; then
echo -e "${RED}✗ Docker is not running and Colima is not installed.${NC}"
echo -e "${YELLOW} Install lightweight runtime: brew install colima docker${NC}"
exit 1
fi
echo -e "${YELLOW}▸ Starting Colima (${COLIMA_CPU} CPU, ${COLIMA_MEMORY}GB RAM, ${COLIMA_DISK}GB disk)...${NC}"
colima start --cpu "$COLIMA_CPU" --memory "$COLIMA_MEMORY" --disk "$COLIMA_DISK"
COLIMA_STARTED_BY_SCRIPT=1
if ! docker info >/dev/null 2>&1; then
echo -e "${RED}✗ Docker CLI still cannot reach a Docker engine after starting Colima.${NC}"
exit 1
fi
}
prepare_colima_kernel_limits() {
if command -v colima >/dev/null 2>&1 && colima status >/dev/null 2>&1; then
# SonarQube embeds a search engine that commonly needs this Linux VM setting.
colima ssh -- sudo sysctl -w vm.max_map_count=262144 >/dev/null 2>&1 || true
fi
}
container_is_running() {
local name="$1"
[[ "$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null || true)" == "true" ]]
}
container_exists() {
local name="$1"
docker container inspect "$name" >/dev/null 2>&1
}
validate_flutter_plugin_jar() {
local jar_path="$1"
[[ -f "$jar_path" ]] || return 1
unzip -t "$jar_path" >/dev/null 2>&1
}
fail_missing_sonar_flutter_plugin() {
echo -e "${RED}✗ Missing valid sonar-flutter plugin jar.${NC}"
echo -e "${YELLOW} GitHub fallback downloads are disabled. Provide the plugin explicitly at:${NC}"
echo -e "${YELLOW} $SONAR_FLUTTER_PLUGIN_VENDOR_JAR${NC}"
echo -e "${YELLOW} Or prefill the cache with a valid jar at:${NC}"
echo -e "${YELLOW} $SONAR_FLUTTER_PLUGIN_JAR${NC}"
echo -e "${YELLOW} Or set SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted non-default mirror.${NC}"
exit 1
}
ensure_sonar_flutter_plugin() {
if [[ "$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN" != "1" ]]; then
return 0
fi
mkdir -p "$SONAR_LOCAL_TOKEN_DIR" "$(dirname "$SONAR_FLUTTER_PLUGIN_JAR")"
chmod 700 "$SONAR_LOCAL_TOKEN_DIR" 2>/dev/null || true
if [[ -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
if validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
echo -e "${GREEN}✓ sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR${NC}"
else
echo -e "${YELLOW}⚠ Removing invalid cached sonar-flutter plugin jar: $SONAR_FLUTTER_PLUGIN_JAR${NC}"
rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
fi
fi
if [[ ! -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
if [[ -f "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" ]]; then
echo -e "${YELLOW}▸ Using repo-local sonar-flutter plugin vendor jar: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR${NC}"
if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR"; then
echo -e "${RED}✗ Repo-local sonar-flutter plugin vendor jar is not valid: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR${NC}"
exit 1
fi
cp "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" "$SONAR_FLUTTER_PLUGIN_JAR"
elif [[ -n "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" ]]; then
echo -e "${YELLOW}▸ Fetching sonar-flutter plugin ${SONAR_FLUTTER_PLUGIN_VERSION} from explicit mirror...${NC}"
curl -fL "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" -o "$SONAR_FLUTTER_PLUGIN_JAR" || {
rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
echo -e "${RED}✗ Could not download sonar-flutter plugin from explicit mirror: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL${NC}"
exit 1
}
else
fail_missing_sonar_flutter_plugin
fi
fi
if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
echo -e "${RED}✗ sonar-flutter plugin cache jar is invalid: $SONAR_FLUTTER_PLUGIN_JAR${NC}"
exit 1
fi
echo -e "${YELLOW}▸ Installing sonar-flutter plugin into local SonarQube container...${NC}"
docker cp "$SONAR_FLUTTER_PLUGIN_JAR" \
"$SONAR_LOCAL_CONTAINER:/opt/sonarqube/extensions/plugins/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar"
}
ensure_local_sonar_container() {
start_colima_if_needed
prepare_colima_kernel_limits
if ! command -v docker >/dev/null 2>&1; then
echo -e "${RED}✗ docker CLI not found. Install with: brew install docker${NC}"
exit 1
fi
if ! container_exists "$SONAR_LOCAL_CONTAINER"; then
echo -e "${YELLOW}▸ Creating local SonarQube container: ${SONAR_LOCAL_CONTAINER}${NC}"
docker create \
--name "$SONAR_LOCAL_CONTAINER" \
-p "127.0.0.1:${SONAR_LOCAL_PORT}:9000" \
-v "${SONAR_LOCAL_CONTAINER}_data:/opt/sonarqube/data" \
-v "${SONAR_LOCAL_CONTAINER}_extensions:/opt/sonarqube/extensions" \
-v "${SONAR_LOCAL_CONTAINER}_logs:/opt/sonarqube/logs" \
"$SONAR_LOCAL_IMAGE" >/dev/null
fi
if ! container_is_running "$SONAR_LOCAL_CONTAINER"; then
echo -e "${YELLOW}▸ Starting local SonarQube container: ${SONAR_LOCAL_CONTAINER}${NC}"
docker start "$SONAR_LOCAL_CONTAINER" >/dev/null
SONAR_CONTAINER_STARTED_BY_SCRIPT=1
fi
wait_for_local_sonar "$SONAR_LOCAL_URL"
ensure_sonar_flutter_plugin
}
read_cached_local_token() {
if [[ -f "$SONAR_LOCAL_TOKEN_FILE" ]]; then
tr -d '[:space:]' < "$SONAR_LOCAL_TOKEN_FILE"
fi
}
validate_local_sonar_token() {
local token="$1"
local valid=""
if [[ -z "$token" ]]; then
return 1
fi
valid="$(curl -fsS -H "Authorization: Bearer ${token}" \
"$SONAR_LOCAL_URL/api/authentication/validate" 2>/dev/null \
| python3 -c 'import json,sys; print("true" if json.load(sys.stdin).get("valid") is True else "false")' 2>/dev/null || true)"
[[ "$valid" == "true" ]]
}
generate_local_sonar_token() {
local token_name="top-flutter-local-$(date +%Y%m%d%H%M%S)"
mkdir -p "$SONAR_LOCAL_TOKEN_DIR"
chmod 700 "$SONAR_LOCAL_TOKEN_DIR" 2>/dev/null || true
curl -fsS -u "admin:${SONAR_LOCAL_ADMIN_PASSWORD:-admin}" -X POST \
--data-urlencode "name=${token_name}" \
"$SONAR_LOCAL_URL/api/user_tokens/generate" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("token", ""))' 2>/dev/null || true
}
ensure_local_sonar_token() {
local cached_token=""
if [[ -n "${SONAR_TOKEN:-}" ]]; then
return 0
fi
cached_token="$(read_cached_local_token)"
if [[ -n "$cached_token" ]]; then
if validate_local_sonar_token "$cached_token"; then
export SONAR_TOKEN="$cached_token"
return 0
fi
echo -e "${YELLOW}⚠ Cached SonarQube token is stale/invalid; regenerating...${NC}"
rm -f "$SONAR_LOCAL_TOKEN_FILE"
cached_token=""
fi
echo -e "${YELLOW}▸ Creating local SonarQube token using default local admin credentials...${NC}"
if ! SONAR_TOKEN="$(generate_local_sonar_token)" || [[ -z "$SONAR_TOKEN" ]]; then
echo -e "${RED}✗ Could not auto-create a local SonarQube token.${NC}"
echo -e "${YELLOW} Open ${SONAR_LOCAL_URL}, login to the local instance, create a token, then rerun with:${NC}"
echo -e "${YELLOW} SONAR_TOKEN=<local-token> $(basename "$0") --sonar-local${NC}"
exit 1
fi
(umask 077 && printf '%s\n' "$SONAR_TOKEN" > "$SONAR_LOCAL_TOKEN_FILE")
export SONAR_TOKEN
}
setup_local_sonar_mode() {
SONAR_LOCAL_URL="http://localhost:${SONAR_LOCAL_PORT}"
require_local_sonar_url "$SONAR_LOCAL_URL"
echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN} Local SonarQube On-Demand Harness${NC}"
echo -e "${CYAN} Runtime: Colima/Docker CLI${NC}"
echo -e "${CYAN} URL: ${SONAR_LOCAL_URL}${NC}"
echo -e "${CYAN} Container: ${SONAR_LOCAL_CONTAINER}${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
ensure_local_sonar_container
ensure_local_sonar_token
export SONAR_HOST_URL="$SONAR_LOCAL_URL"
export SONAR_PROJECT_KEY="${SONAR_PROJECT_KEY:-$SONAR_LOCAL_PROJECT_KEY}"
}
cleanup_local_sonar() {
if [[ "$SONAR_LOCAL" != true || "$SONAR_LOCAL_KEEP_RUNNING" == "1" ]]; then
return 0
fi
if [[ "$SONAR_CONTAINER_STARTED_BY_SCRIPT" == "1" ]] && command -v docker >/dev/null 2>&1; then
echo -e "${YELLOW}▸ Stopping local SonarQube container...${NC}"
docker stop "$SONAR_LOCAL_CONTAINER" >/dev/null 2>&1 || true
fi
if [[ "$COLIMA_STARTED_BY_SCRIPT" == "1" ]] && command -v colima >/dev/null 2>&1; then
echo -e "${YELLOW}▸ Stopping Colima...${NC}"
colima stop >/dev/null 2>&1 || true
fi
}
trap cleanup_local_sonar EXIT INT TERM
validate_threshold "Duplicate threshold" "$DUP_THRESHOLD"
validate_threshold "Coverage threshold" "$COV_THRESHOLD"
SCAN_LABEL="Whole Project"
COVERAGE_FILE=""
LCOV_PATH="$PROJECT_ROOT/reports/coverage/lcov.info"
if [[ -f "$LCOV_PATH" ]]; then
COVERAGE_FILE="$LCOV_PATH"
fi
if [[ ! -f "$SONAR_PROPERTIES" ]]; then
echo -e "${RED}✗ Missing sonar-project.properties at project root${NC}"
exit 1
fi
if [[ "$SONAR_LOCAL" == true ]]; then
setup_local_sonar_mode
fi
if ! command -v sonar-scanner >/dev/null 2>&1; then
echo -e "${RED}✗ sonar-scanner not found. Install SonarScanner CLI before running this gate.${NC}"
echo -e "${YELLOW} macOS: brew install sonar-scanner${NC}"
exit 1
fi
PROJECT_KEY="${SONAR_PROJECT_KEY:-$(property_value sonar.projectKey "$SONAR_PROPERTIES")}"
PROJECT_KEY="${PROJECT_KEY//[[:space:]]/}"
HOST_URL="${SONAR_HOST_URL:-$(property_value sonar.host.url "$SONAR_PROPERTIES")}"
HOST_URL="${HOST_URL//[[:space:]]/}"
TOKEN="${SONAR_TOKEN:-${SONAR_LOGIN:-}}"
if [[ -z "$PROJECT_KEY" ]]; then
echo -e "${RED}✗ SONAR_PROJECT_KEY is required because sonar.projectKey is not set in sonar-project.properties.${NC}"
exit 1
fi
if [[ -z "$HOST_URL" ]]; then
echo -e "${RED}✗ SONAR_HOST_URL is required because sonar.host.url is not set in sonar-project.properties.${NC}"
exit 1
fi
if [[ "$SONAR_LOCAL" == true ]]; then
require_local_sonar_url "$HOST_URL"
fi
if [[ -z "$TOKEN" ]]; then
echo -e "${RED}✗ SONAR_TOKEN is required for local SonarScanner and SonarQube API access.${NC}"
exit 1
fi
mkdir -p "$REPORT_DIR"
rm -f "$SUMMARY_JSON"
SONAR_ARGS=(
"-Dproject.settings=$SONAR_PROPERTIES"
"-Dsonar.projectKey=$PROJECT_KEY"
)
if [[ -n "$HOST_URL" ]]; then
SONAR_ARGS+=("-Dsonar.host.url=$HOST_URL")
fi
if [[ -n "$COVERAGE_FILE" ]]; then
rel_coverage="${COVERAGE_FILE#$PROJECT_ROOT/}"
SONAR_ARGS+=("-Dsonar.flutter.coverage.reportPath=$rel_coverage")
fi
# Keep token out of echoed output and command-line arguments; sonar-scanner reads SONAR_TOKEN from env.
export SONAR_TOKEN="$TOKEN"
export SONAR_HOST_URL="$HOST_URL"
cd "$PROJECT_ROOT"
echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN} SonarQube Quality Check${NC}"
echo -e "${CYAN} Scope: ${SCAN_LABEL}${NC}"
echo -e "${CYAN} Host: ${HOST_URL}${NC}"
echo -e "${CYAN} Project: ${PROJECT_KEY}${NC}"
echo -e "${CYAN} Duplication: ≤${DUP_THRESHOLD}% | Coverage: ≥${COV_THRESHOLD}%${NC}"
if [[ -n "$FOCUS_MODE" ]]; then
echo -e "${CYAN} Focus: ${FOCUS_MODE} | Smell threshold: ≤${SMELL_THRESHOLD}${NC}"
fi
echo -e "${CYAN} Settings: sonar-project.properties${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""
echo -e "${YELLOW}▸ [1/2] Running sonar-scanner...${NC}"
sonar-scanner "${SONAR_ARGS[@]}"
REPORT_TASK="$PROJECT_ROOT/.scannerwork/report-task.txt"
if [[ ! -f "$REPORT_TASK" ]]; then
echo -e "${RED}✗ SonarScanner completed but .scannerwork/report-task.txt was not generated.${NC}"
exit 1
fi
echo ""
echo -e "${YELLOW}▸ [2/2] Reading SonarQube quality gate and generating local report...${NC}"
REPORT_ARGS=(
--report-task "$REPORT_TASK"
--project-key "$PROJECT_KEY"
--scope "$SCAN_LABEL"
--dup-threshold "$DUP_THRESHOLD"
--coverage-threshold "$COV_THRESHOLD"
)
if [[ -n "$COVERAGE_FILE" ]]; then
REPORT_ARGS+=(--coverage "$COVERAGE_FILE")
fi
if [[ -n "$FOCUS_MODE" ]]; then
REPORT_ARGS+=(--focus "$FOCUS_MODE")
if [[ "$FOCUS_MODE" == *"smell"* ]]; then
REPORT_ARGS+=(--smell-threshold "$SMELL_THRESHOLD")
fi
fi
if ! python3 "$SCRIPT_DIR/generate-report.py" "${REPORT_ARGS[@]}"; then
echo -e "${RED}✗ Report generation failed. Check output above.${NC}"
exit 1
fi
echo ""
echo -e "${CYAN}▸ HTML Report: ${REPORT_DIR}/quality-report.html${NC}"
echo -e "${CYAN}▸ Summary: ${SUMMARY_JSON}${NC}"
if [[ "${OSTYPE:-}" =~ ^darwin ]]; then
open "$REPORT_DIR/quality-report.html" 2>/dev/null || true
fi
GATE_STATUS="$(python3 - "$SUMMARY_JSON" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as fh:
print(json.load(fh).get("gate", "failed"))
PY
)"
if [[ "$GATE_STATUS" = "passed" ]]; then
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${GREEN} ✅ QUALITY GATE PASSED${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
exit 0
fi
echo -e "${RED}══════════════════════════════════════════════════${NC}"
echo -e "${RED} ❌ QUALITY GATE FAILED${NC}"
echo -e "${RED}══════════════════════════════════════════════════${NC}"
exit 1
#!/usr/bin/env python3
"""Generate local quality reports from SonarQube/SonarScanner results.
The scanner is the source of truth. This script reads the scanner report-task.txt,
queries the configured SonarQube/SonarCloud API for quality gate status, measures,
and file-level problem data, then writes reports/quality/summary.json and
quality-report.html.
"""
from __future__ import annotations
import argparse
import base64
import collections
import datetime
import html
import json
import os
import sys
import time
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
REPORT_DIR = PROJECT_ROOT / "reports" / "quality"
OUTPUT_HTML = REPORT_DIR / "quality-report.html"
SUMMARY_JSON = REPORT_DIR / "summary.json"
TEMPLATE_HTML = Path(__file__).parent / "template.html"
class SonarApiError(RuntimeError):
"""Raised when SonarQube API data cannot be retrieved."""
_incomplete_endpoints: set[str] = set()
def percent_threshold(value: str) -> float:
try:
parsed = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be a number") from exc
if parsed < 0:
raise argparse.ArgumentTypeError("must be non-negative")
return parsed
def read_properties(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def normalize_base_url(url: str) -> str:
return url.rstrip("/")
def api_get(base_url: str, endpoint: str, params: dict[str, str], token: str) -> dict:
query = urllib.parse.urlencode(params)
url = f"{normalize_base_url(base_url)}{endpoint}"
if query:
url = f"{url}?{query}"
request = urllib.request.Request(url)
if token:
auth = base64.b64encode(f"{token}:".encode("utf-8")).decode("ascii")
request.add_header("Authorization", f"Basic {auth}")
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except Exception as exc: # noqa: BLE001 - preserve CLI-friendly error context
raise SonarApiError(f"Failed to read {url}: {exc}") from exc
def api_get_optional(base_url: str, endpoint: str, params: dict[str, str], token: str) -> dict:
try:
return api_get(base_url, endpoint, params, token)
except SonarApiError as exc:
print(f"WARN: {exc}", file=sys.stderr)
_incomplete_endpoints.add(endpoint)
return {}
def wait_for_analysis(base_url: str, ce_task_id: str, token: str, timeout_seconds: int = 180) -> str | None:
deadline = time.time() + timeout_seconds
last_status = "UNKNOWN"
while time.time() < deadline:
task = api_get(base_url, "/api/ce/task", {"id": ce_task_id}, token).get("task", {})
last_status = task.get("status", last_status)
if last_status == "SUCCESS":
return task.get("analysisId")
if last_status in {"FAILED", "CANCELED"}:
raise SonarApiError(f"SonarQube background task {ce_task_id} ended with status {last_status}")
time.sleep(3)
raise SonarApiError(f"Timed out waiting for SonarQube background task {ce_task_id}; last status: {last_status}")
def measure_float(measures: dict[str, float | None], key: str) -> float | None:
value = measures.get(key)
if value is None:
return None
return float(value)
def status_from_threshold(value: float | None, threshold: float, direction: str) -> bool | None:
if value is None:
return None
if direction == "max":
return value <= threshold
return value >= threshold
def to_float(value: Any) -> float | None:
if value in (None, ""):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def component_path(component: dict[str, Any], project_key: str) -> str:
path = component.get("path") or component.get("name") or component.get("key", "")
if path.startswith(project_key + ":"):
path = path.split(":", 1)[1]
return str(path)
def folder_for(path: str) -> str:
parent = str(Path(path).parent)
return "." if parent == "." else parent
def sonar_component_link(base_url: str, project_key: str, path: str) -> str:
selected = f"{project_key}:{path}"
return f"{normalize_base_url(base_url)}/component_measures?id={urllib.parse.quote(project_key)}&selected={urllib.parse.quote(selected, safe='')}"
def sonar_issue_link(base_url: str, project_key: str, issue_key: str) -> str:
return f"{normalize_base_url(base_url)}/project/issues?open={urllib.parse.quote(issue_key)}&id={urllib.parse.quote(project_key)}"
def paginate_issues(base_url: str, project_key: str, token: str, max_items: int = 1000, types_filter: str | None = None) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
page = 1
page_size = 500
while len(issues) < max_items:
params: dict[str, str] = {
"componentKeys": project_key,
"resolved": "false",
"ps": str(page_size),
"p": str(page),
"s": "FILE_LINE",
"asc": "true",
}
if types_filter:
params["types"] = types_filter
payload = api_get_optional(
base_url,
"/api/issues/search",
params,
token,
)
batch = payload.get("issues", [])
if not batch:
break
issues.extend(batch)
paging = payload.get("paging", {})
total = int(paging.get("total", len(issues)))
if len(issues) >= total:
break
page += 1
return issues[:max_items]
def fetch_file_measures(base_url: str, project_key: str, token: str, max_items: int = 5000) -> list[dict[str, Any]]:
components: list[dict[str, Any]] = []
page = 1
page_size = 500
metric_keys = "coverage,lines_to_cover,uncovered_lines,duplicated_lines_density,duplicated_lines,ncloc"
while len(components) < max_items:
payload = api_get_optional(
base_url,
"/api/measures/component_tree",
{
"component": project_key,
"qualifiers": "FIL",
"metricKeys": metric_keys,
"ps": str(page_size),
"p": str(page),
},
token,
)
batch = payload.get("components", [])
if not batch:
break
components.extend(batch)
paging = payload.get("paging", {})
total = int(paging.get("total", len(components)))
if len(components) >= total:
break
page += 1
return components[:max_items]
def parse_measure_map(component: dict[str, Any]) -> dict[str, float | None]:
values: dict[str, float | None] = {}
for item in component.get("measures", []):
values[str(item.get("metric"))] = to_float(item.get("value"))
return values
def build_problem_map(
base_url: str,
project_key: str,
token: str,
coverage_threshold: float,
focus_mode: bool = False,
) -> dict[str, Any]:
issues = paginate_issues(base_url, project_key, token, types_filter="CODE_SMELL" if focus_mode else None)
file_components = fetch_file_measures(base_url, project_key, token)
files: dict[str, dict[str, Any]] = {}
def ensure_file(path: str) -> dict[str, Any]:
if path not in files:
files[path] = {
"path": path,
"folder": folder_for(path),
"issues": {"BUG": 0, "VULNERABILITY": 0, "CODE_SMELL": 0, "SECURITY_HOTSPOT": 0},
"issueList": [],
"coverage": None,
"linesToCover": None,
"uncoveredLines": None,
"duplicatedLinesDensity": None,
"duplicatedLines": None,
"ncloc": None,
"link": sonar_component_link(base_url, project_key, path),
}
return files[path]
for issue in issues:
component = str(issue.get("component", ""))
path = component.split(":", 1)[1] if component.startswith(project_key + ":") else component
if not path:
continue
record = ensure_file(path)
issue_type = str(issue.get("type") or "CODE_SMELL")
record["issues"][issue_type] = record["issues"].get(issue_type, 0) + 1
record["issueList"].append(
{
"key": issue.get("key"),
"type": issue_type,
"severity": issue.get("severity"),
"line": issue.get("line"),
"message": issue.get("message"),
"link": sonar_issue_link(base_url, project_key, str(issue.get("key", ""))),
}
)
for component in file_components:
path = component_path(component, project_key)
if not path:
continue
measures = parse_measure_map(component)
record = ensure_file(path)
record["coverage"] = measures.get("coverage")
record["linesToCover"] = measures.get("lines_to_cover")
record["uncoveredLines"] = measures.get("uncovered_lines")
record["duplicatedLinesDensity"] = measures.get("duplicated_lines_density")
record["duplicatedLines"] = measures.get("duplicated_lines")
record["ncloc"] = measures.get("ncloc")
for record in files.values():
issue_total = sum(int(v) for v in record["issues"].values())
coverage = record.get("coverage")
lines_to_cover = record.get("linesToCover") or 0
duplicated = record.get("duplicatedLines") or 0
duplicated_density = record.get("duplicatedLinesDensity") or 0
record["issueTotal"] = issue_total
record["coverageProblem"] = bool(lines_to_cover and coverage is not None and coverage < coverage_threshold)
record["duplicationProblem"] = bool(duplicated > 0 or duplicated_density > 0)
record["problemTotal"] = issue_total + (1 if record["coverageProblem"] else 0) + (1 if record["duplicationProblem"] else 0)
problem_files = [item for item in files.values() if item["problemTotal"] > 0]
problem_files.sort(
key=lambda item: (
-int(item["issueTotal"]),
-(float(item.get("duplicatedLinesDensity") or 0)),
float(item.get("coverage") if item.get("coverage") is not None else 101),
item["path"],
)
)
folders: dict[str, dict[str, Any]] = {}
for record in problem_files:
folder = record["folder"]
if folder not in folders:
folders[folder] = {
"folder": folder,
"files": [],
"issueTotal": 0,
"bugs": 0,
"vulnerabilities": 0,
"codeSmells": 0,
"securityHotspots": 0,
"coverageProblems": 0,
"duplicationProblems": 0,
}
folder_record = folders[folder]
folder_record["files"].append(record)
folder_record["issueTotal"] += int(record["issueTotal"])
folder_record["bugs"] += int(record["issues"].get("BUG", 0))
folder_record["vulnerabilities"] += int(record["issues"].get("VULNERABILITY", 0))
folder_record["codeSmells"] += int(record["issues"].get("CODE_SMELL", 0))
folder_record["securityHotspots"] += int(record["issues"].get("SECURITY_HOTSPOT", 0))
folder_record["coverageProblems"] += 1 if record["coverageProblem"] else 0
folder_record["duplicationProblems"] += 1 if record["duplicationProblem"] else 0
folder_list = sorted(
folders.values(),
key=lambda item: (
-int(item["issueTotal"]),
-int(item["duplicationProblems"]),
-int(item["coverageProblems"]),
item["folder"],
),
)
return {
"issuesTotal": len(issues),
"filesAnalyzed": len(file_components),
"problemFilesTotal": len(problem_files),
"foldersTotal": len(folder_list),
"folders": folder_list,
"topFiles": problem_files,
"limited": len(issues) >= 1000,
}
def fetch_file_issues(base_url: str, component_key: str, token: str) -> list[dict[str, Any]]:
"""Fetch issues for a specific file component from SonarQube."""
payload = api_get_optional(
base_url,
"/api/issues/search",
{"componentKeys": component_key, "resolved": "false", "ps": "500"},
token,
)
issues = []
for issue in payload.get("issues", []):
issues.append({
"line": issue.get("line"),
"message": issue.get("message", ""),
"severity": issue.get("severity", ""),
"type": issue.get("type", ""),
"rule": issue.get("rule", ""),
})
return issues
def fetch_file_duplications(base_url: str, component_key: str, token: str) -> list[dict[str, Any]]:
"""Fetch duplication blocks for a file from SonarQube /api/duplications/show."""
payload = api_get_optional(
base_url,
"/api/duplications/show",
{"key": component_key},
token,
)
files_map = payload.get("files", {})
result = []
for dup in payload.get("duplications", []):
blocks = []
for block in dup.get("blocks", []):
ref = str(block.get("_ref", ""))
file_info = files_map.get(ref, {})
blocks.append({
"from": block.get("from", 0),
"size": block.get("size", 0),
"fileKey": file_info.get("key", ""),
"fileName": file_info.get("name", ""),
})
if len(blocks) > 1:
result.append({"blocks": blocks})
return result
def parse_lcov(lcov_path: Path, project_root: Path | None = None) -> dict[str, dict[str, list[int]]]:
"""Parse lcov.info → {sf_path: {c: [covered lines], u: [uncovered lines]}}."""
if not lcov_path or not lcov_path.exists():
return {}
_root = project_root or PROJECT_ROOT
result: dict[str, dict[str, list[int]]] = {}
current: dict[str, list[int]] | None = None
for raw in lcov_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw.strip()
if line.startswith("SF:"):
sf = line[3:]
if os.path.isabs(sf):
try:
sf = str(Path(sf).relative_to(_root))
except ValueError:
pass
current = {"c": [], "u": []}
result[sf] = current
elif line.startswith("DA:") and current is not None:
parts = line[3:].split(",")
if len(parts) >= 2:
try:
lineno = int(parts[0])
hits = int(parts[1])
(current["c"] if hits > 0 else current["u"]).append(lineno)
except ValueError:
pass
elif line == "end_of_record":
current = None
return result
def read_source_file(project_root: Path, file_path: str) -> list[str]:
"""Read source file from disk, return list of lines (up to 2000)."""
full_path = project_root / file_path
try:
lines = full_path.read_text(encoding="utf-8", errors="replace").splitlines()
return lines[:2000]
except Exception:
return []
def render_issue_badges(record: dict[str, Any]) -> str:
labels = [
("BUG", "BUG", "sq-badge sq-badge-bug"),
("VULNERABILITY", "VULN", "sq-badge sq-badge-vuln"),
("CODE_SMELL", "SMELL", "sq-badge sq-badge-smell"),
("SECURITY_HOTSPOT", "HOTSPOT", "sq-badge sq-badge-hotspot"),
]
badges = []
for key, label, css in labels:
count = int(record.get("issues", {}).get(key, 0) or 0)
if count:
badges.append(f'<span class="{css}">{html.escape(label)}</span>')
if record.get("duplicationProblem"):
density = record.get("duplicatedLinesDensity")
duplicated = record.get("duplicatedLines")
text = "DUP"
if density is not None:
text += f" {float(density):.1f}%"
badges.append(f'<span class="sq-badge sq-badge-smell">{html.escape(text)}</span>')
if record.get("coverageProblem"):
coverage = record.get("coverage")
text = "COV"
if coverage is not None:
text += f" {float(coverage):.1f}%"
badges.append(f'<span class="sq-badge sq-badge-cov">{html.escape(text)}</span>')
return " ".join(badges) or '<span style="color:var(--text-dim);font-size:11px">—</span>'
def fmt_percent(value: Any) -> str:
parsed = to_float(value)
return "N/A" if parsed is None else f"{parsed:.1f}%"
def render_problem_map(problem_map: dict[str, Any]) -> str:
if not problem_map.get("problemFilesTotal"):
return """
<section class="section">
<h2>Problem Structure</h2>
<p class="muted">No file-level issues, duplication, or coverage gaps were returned by SonarQube.</p>
</section>
"""
top_rows = "".join(
"<tr>"
f"<td><a href=\"{html.escape(record['link'])}\">{html.escape(record['path'])}</a></td>"
f"<td>{render_issue_badges(record)}</td>"
f"<td>{fmt_percent(record.get('coverage'))}</td>"
f"<td>{fmt_percent(record.get('duplicatedLinesDensity'))}</td>"
"</tr>"
for record in problem_map.get("topFiles", [])
)
folder_blocks = []
for folder in problem_map.get("folders", []):
rows = "".join(
"<tr>"
f"<td class=\"path\"><a href=\"{html.escape(record['link'])}\">{html.escape(Path(record['path']).name)}</a><br><small>{html.escape(record['path'])}</small></td>"
f"<td>{render_issue_badges(record)}</td>"
f"<td>{fmt_percent(record.get('coverage'))}</td>"
f"<td>{fmt_percent(record.get('duplicatedLinesDensity'))}</td>"
"</tr>"
for record in folder.get("files", [])[:30]
)
extra = len(folder.get("files", [])) - 30
if extra > 0:
rows += f'<tr><td colspan="4" class="muted">+ {extra} more files in this folder</td></tr>'
folder_blocks.append(
f"""
<details class="folder" open>
<summary>
<strong>{html.escape(folder['folder'])}</strong>
<span class="muted">{len(folder.get('files', []))} files · {folder['issueTotal']} issues · {folder['duplicationProblems']} dup · {folder['coverageProblems']} coverage</span>
</summary>
<table>
<thead><tr><th>File</th><th>Problems</th><th>Coverage</th><th>Duplication</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</details>
"""
)
limit_note = ""
if problem_map.get("limited"):
limit_note = '<p class="warn">Issue list was capped at 1000 unresolved issues for report size.</p>'
return f"""
<section class="section">
<h2>Problem Structure / Full Quality Map</h2>
<p class="muted">File/folder map from SonarQube APIs: unresolved issues + duplicated files + files below coverage threshold.</p>
{limit_note}
<div class="cards compact">
<div class="card"><h3>Problem files</h3><div class="value small">{problem_map['problemFilesTotal']}</div></div>
<div class="card"><h3>Folders</h3><div class="value small">{problem_map['foldersTotal']}</div></div>
<div class="card"><h3>Unresolved issues</h3><div class="value small">{problem_map['issuesTotal']}</div></div>
<div class="card"><h3>Files analyzed</h3><div class="value small">{problem_map['filesAnalyzed']}</div></div>
</div>
<h3>Top problem files</h3>
<table>
<thead><tr><th>File</th><th>Problems</th><th>Coverage</th><th>Duplication</th></tr></thead>
<tbody>{top_rows}</tbody>
</table>
<h3>By folder</h3>
{''.join(folder_blocks)}
</section>
"""
def get_current_branch() -> str:
import subprocess
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=5,
cwd=str(PROJECT_ROOT),
)
branch = result.stdout.strip()
return branch if branch and branch != "HEAD" else ""
except Exception:
return ""
def display_metric_key(key: Any) -> str:
metric = str(key or "")
labels = {
"new_duplicated_lines_density": "Duplication",
"duplicated_lines_density": "Duplication",
"new_violations": "New Issues",
"coverage": "Coverage",
}
return labels.get(metric, metric.replace("_", " ").title())
def short_path(path: Any, max_len: int = 42) -> str:
text = str(path or "")
if len(text) <= max_len:
return text
parts = text.split("/")
if len(parts) >= 3:
compact = f"{parts[0]}/…/{parts[-1]}"
if len(compact) <= max_len:
return compact
return text[: max_len - 1].rstrip("/") + "…"
def describe_gate_condition(item: dict[str, Any]) -> str:
metric_key = str(item.get("metricKey", ""))
label = display_metric_key(metric_key)
actual_raw = str(item.get("actualValue", ""))
threshold_raw = str(item.get("errorThreshold", ""))
actual = html.escape(actual_raw)
threshold = html.escape(threshold_raw)
comparator = str(item.get("comparator", "")).upper()
status = str(item.get("status", "")).upper()
failed = status not in ("OK", "PASS", "PASSED")
if metric_key == "new_violations":
return f"{actual} new issue{'s' if actual_raw != '1' else ''} exceed threshold {threshold}"
if metric_key in {"coverage"}:
return f"Coverage {actual}% is {'below' if failed else 'above'} threshold {threshold}%"
if metric_key in {"new_duplicated_lines_density", "duplicated_lines_density"}:
if failed or comparator in {"GT", ">"}:
return f"Duplication {actual}% exceeds threshold {threshold}%"
return f"Duplication {actual}% is within threshold {threshold}%"
return f"{html.escape(label)} {actual} {'exceeds' if failed else 'meets'} threshold {threshold}"
def _top_file_rows(files: list[dict[str, Any]], base_url: str = "", project_key: str = "", focus_mode: bool = False) -> str:
rows = ""
for record in files:
path = record.get("path", "")
full_path = str(path)
filename = html.escape(Path(full_path).name)
filepath = html.escape(short_path(str(Path(full_path).parent), 64))
full_path_escaped = html.escape(full_path)
parent_title = html.escape(str(Path(full_path).parent))
link = record.get("link", "")
if not link and base_url and project_key:
link = sonar_component_link(base_url, project_key, full_path)
link = html.escape(link)
issues = record.get("issues", {})
bugs = int(issues.get("BUG", 0) or 0)
vulns = int(issues.get("VULNERABILITY", 0) or 0)
smells = int(issues.get("CODE_SMELL", 0) or 0)
hotspots = int(issues.get("SECURITY_HOTSPOT", 0) or 0)
is_dup = record.get("duplicationProblem", False)
is_cov = record.get("coverageProblem", False)
issue_total = int(record.get("issueTotal", 0) or 0)
dup_density = to_float(record.get("duplicatedLinesDensity"))
dup_lines = to_float(record.get("duplicatedLines"))
cov_val = to_float(record.get("coverage"))
if not is_dup and (dup_density or dup_lines):
is_dup = True
if not is_cov and cov_val is not None and cov_val == 0.0 and not issue_total and not is_dup:
is_cov = True
pills = []
if bugs and not focus_mode:
pills.append('<span class="pill pill-bug">BUG</span>')
if vulns and not focus_mode:
pills.append('<span class="pill pill-vuln">VULN</span>')
if smells:
pills.append('<span class="pill pill-smell">SMELL</span>')
if hotspots and not focus_mode:
pills.append('<span class="pill pill-hotspot">HOTSPOT</span>')
if is_dup:
pills.append('<span class="pill pill-dup">DUP</span>')
if is_cov:
pills.append('<span class="pill pill-cov">COV</span>')
type_html = " ".join(pills) if pills else '<span class="muted">—</span>'
details: list[str] = []
if issue_total:
details.append(f"{issue_total} issue{'s' if issue_total != 1 else ''}")
if is_dup and dup_density is not None:
details.append(f"{dup_density:.1f}% dup")
if dup_lines:
details.append(f"{int(dup_lines)} lines")
if is_cov and cov_val is not None:
details.append(f"{cov_val:.1f}% cov")
detail_text = html.escape(" · ".join(details)) if details else "—"
anchor_open = f'<a href="{link}" title="{full_path_escaped}">' if link else ""
anchor_close = "</a>" if link else ""
rows += (
"<tr>"
f'<td><div class="filename">{anchor_open}{filename}{anchor_close}</div>'
f'<div class="path" title="{parent_title}">{filepath}</div></td>'
f"<td>{type_html}</td>"
f"<td>{detail_text}</td>"
"</tr>\n"
)
return rows
def _reliability_grade(bugs: Any) -> str:
b = int(bugs or 0)
if b == 0: return "A"
if b == 1: return "B"
if b <= 5: return "C"
if b <= 10: return "D"
return "E"
def _security_grade(vulns: Any) -> str:
return _reliability_grade(vulns)
def _maintainability_grade(smells: Any) -> str:
s = int(smells or 0)
if s == 0: return "A"
if s <= 5: return "B"
if s <= 20: return "C"
if s <= 50: return "D"
return "E"
def render_html(
summary: dict,
measures: dict[str, float | None],
gate_conditions: list[dict],
problem_map: dict[str, Any],
branch: str = "",
focus_mode: bool = False,
code_smells_summary: dict | None = None,
file_details: dict[str, Any] | None = None,
lcov_coverage: dict | None = None,
) -> str:
gate = summary["gate"]
duplicate = summary["duplicate"]
coverage = summary["coverage"]
dashboard_url = summary.get("dashboardUrl") or ""
is_passed = gate == "passed"
project_key = html.escape(summary.get("projectKey", "top-flutter"))
branch_or_scope = html.escape(branch) if branch else html.escape(summary.get("scope", ""))
smell_cs = code_smells_summary or {}
smell_count = smell_cs.get("count")
smell_passed = smell_cs.get("passed", True)
gate_class = "pass" if is_passed else "fail"
gate_icon = "✓" if is_passed else "✗"
gate_status_text = "Passed" if is_passed else "Failed"
dup_pct = to_float(duplicate.get("percentage"))
cov_pct = to_float(coverage.get("percentage"))
dup_threshold = float(duplicate.get("threshold") or 3)
cov_threshold = float(coverage.get("threshold") or 80)
dup_passed = duplicate.get("passed")
cov_passed = coverage.get("passed")
# Lint results
lint_data = summary.get("lint") or {}
lint_errors = lint_data.get("errors")
lint_warnings = lint_data.get("warnings")
lint_mode = lint_data.get("mode", "strict")
lint_passed_val = lint_data.get("passed") # True/False/None
files_analyzed = int(problem_map.get("filesAnalyzed", 0))
issues_total = int(problem_map.get("issuesTotal", 0))
problem_files_total = int(problem_map.get("problemFilesTotal", 0))
folders_total = int(problem_map.get("foldersTotal", 0))
def fmt_pct(v: Any) -> str:
parsed = to_float(v)
return "N/A" if parsed is None else f"{parsed:.1f}%"
# Compute metric values from measures
_bugs_raw = measures.get("bugs")
bugs_count: int | None = int(_bugs_raw) if _bugs_raw is not None else None
vulns_count = int(measures.get("vulnerabilities") or 0)
_smells_raw = measures.get("code_smells")
smells_count: int | None = int(_smells_raw) if _smells_raw is not None else None
if smells_count is None:
smells_count = int(smell_count) if smell_count is not None else None
ncloc = int(measures.get("ncloc") or 0)
ncloc_display = f"{ncloc:,}" if ncloc else "N/A"
reliability_grade_val = _reliability_grade(bugs_count)
security_grade_val = _security_grade(vulns_count)
maintainability_grade_val = _maintainability_grade(smells_count)
cov_circle = "circle-pass" if cov_passed is True else ("circle-fail" if cov_passed is False else "circle-none")
dup_circle = "circle-pass" if dup_passed is True else ("circle-fail" if dup_passed is False else "circle-none")
cov_val_class = "sq-metric-value"
dup_val_class = "sq-metric-value"
# Build table rows for problem files
table_rows = ""
for idx, record in enumerate(problem_map.get("topFiles", [])):
path = str(record.get("path", ""))
filename = html.escape(Path(path).name)
path_escaped = html.escape(path)
path_js = html.escape(json.dumps(path)[1:-1])
badges = render_issue_badges(record)
issue_total = int(record.get("issueTotal") or 0)
details_parts = []
if issue_total:
details_parts.append(f'{issue_total} issue{"s" if issue_total != 1 else ""}')
cov = to_float(record.get("coverage"))
dup = to_float(record.get("duplicatedLinesDensity"))
if cov is not None:
details_parts.append(f"{cov:.1f}% cov")
if dup is not None and dup > 0:
details_parts.append(f"{dup:.1f}% dup")
detail_text = html.escape(" · ".join(details_parts)) if details_parts else "—"
table_rows += (
f'<tr class="file-row" data-path="{path_escaped}">\n'
f' <td onclick="showDetail(\'{path_js}\')">\n'
f' <div class="sq-file-link">{filename}</div>\n'
f' <div class="sq-file-path">{path_escaped}</div>\n'
f' </td>\n'
f' <td>{badges}</td>\n'
f' <td class="sq-issues-count">{detail_text}</td>\n'
f'</tr>\n'
)
if not table_rows:
table_rows = '<tr><td colspan="3" style="padding:14px;color:var(--text-dim)">No problem files found.</td></tr>'
# Gate conditions table rows
def _cstatus_html(item: dict) -> str:
s = str(item.get("status", "")).upper()
cls = "sq-cond-pass" if s in ("OK", "PASS", "PASSED") else "sq-cond-fail"
return f'<span class="{cls}">{html.escape(s)}</span>'
if focus_mode:
smell_threshold = smell_cs.get("threshold", 0)
_focus_rows_data = [
("Coverage", fmt_pct(cov_pct), f">= {cov_threshold:g}%", "OK" if cov_passed else "ERROR"),
("Duplication", fmt_pct(dup_pct), f"<= {dup_threshold:g}%", "OK" if dup_passed else "ERROR"),
("Code Smells", str(smell_count) if smell_count is not None else "N/A", f"<= {smell_threshold}", "OK" if smell_passed else "ERROR"),
]
condition_rows = "".join(
"<tr>"
f"<td>{html.escape(label)}</td>"
f"<td>{html.escape(actual)}</td>"
f"<td>{html.escape(threshold)}</td>"
f"<td>{_cstatus_html({'status': status})}</td>"
"</tr>"
for label, actual, threshold, status in _focus_rows_data
)
else:
condition_rows = "".join(
"<tr>"
f"<td>{html.escape(display_metric_key(item.get('metricKey', '')))}</td>"
f"<td>{html.escape(str(item.get('actualValue', '')))}</td>"
f"<td>{html.escape(str(item.get('comparator', '')))} {html.escape(str(item.get('errorThreshold', '')))}</td>"
f"<td>{_cstatus_html(item)}</td>"
"</tr>"
for item in gate_conditions
) or '<tr><td colspan="4" style="color:var(--text-dim)">No quality gate conditions returned.</td></tr>'
sq_open_btn = ""
limit_note = ""
if problem_map.get("limited"):
limit_note = '<div class="sq-warn">Issue list capped at 1,000 unresolved issues.</div>'
incomplete_banner = ""
if summary.get("data_incomplete"):
incomplete_banner = '<div class="sq-warn">⚠ Some SonarQube API endpoints were unavailable. Report data may be incomplete.</div>'
template = TEMPLATE_HTML.read_text(encoding="utf-8")
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
bugs_card_attr = 'onclick="showFileList(\'bugs\')" style="cursor:pointer"' if bugs_count else 'style="cursor:default;opacity:0.5"'
smells_card_attr = 'onclick="showFileList(\'smells\')" style="cursor:pointer"' if smells_count > 0 else 'style="cursor:default;opacity:0.5"'
cov_card_attr = 'onclick="showFileList(\'coverage\')" style="cursor:pointer"' if cov_pct is not None else 'style="cursor:default;opacity:0.5"'
dup_card_attr = 'onclick="showFileList(\'duplications\')" style="cursor:pointer"' if dup_pct is not None else 'style="cursor:default;opacity:0.5"'
cov_pass_sub = f' · {"pass" if cov_passed else "fail"}' if cov_pct is not None else ''
dup_pass_sub = f' · {"pass" if dup_passed else "fail"}' if dup_pct is not None else ''
lint_circle = "circle-pass" if lint_passed_val is True else ("circle-fail" if lint_passed_val is False else "circle-none")
has_lint_files = bool(lint_data and lint_data.get("files"))
lint_card_attr = 'onclick="showFileList(\'lint\')" style="cursor:pointer"' if has_lint_files else 'style="cursor:default;opacity:0.5"'
lint_errors_display = str(lint_errors) if lint_errors is not None else "N/A"
lint_warn_display = str(lint_warnings) if lint_warnings is not None else ""
lint_mode_display = lint_mode if lint_mode else "strict"
lint_pass_sub = f'mode: {lint_mode_display} · {"pass" if lint_passed_val else "fail"}' if lint_passed_val is not None else "not run"
body_html = f"""
<div class="sq-navbar">
<span class="sq-logo">⬤</span>
<span class="sq-project">{project_key}</span>
{f'<span class="sq-branch-badge"><span class="pass">✓</span> {branch_or_scope}</span>' if branch_or_scope else ''}
<div style="margin-left:auto;display:flex;align-items:center;gap:12px">
<span style="font-size:12px;color:var(--text-dim)">{ncloc_display} Lines of Code</span>
<span style="font-size:12px;color:var(--text-dim)">{now_str}</span>
</div>
</div>
<div class="sq-tabs">
<div class="sq-tab active">Overview</div>
<div style="margin-left:auto;display:flex;align-items:center">
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle colour theme">
<span class="theme-toggle-icon" id="themeIcon">☾</span>
<span id="themeLabel">Dark</span>
</button>
</div>
</div>
<div class="sq-content">
{incomplete_banner}
{limit_note}
<!-- View: Overview list -->
<div id="view-list">
<!-- Quality Gate Banner -->
<div class="sq-gate">
<div class="sq-gate-icon {gate_class}">{gate_icon}</div>
<div>
<div class="sq-gate-title">Quality Gate</div>
<div class="sq-gate-status {gate_class}">{gate_status_text}</div>
<div class="sq-gate-meta">Last analysis: {branch_or_scope or html.escape(summary.get("scope", ""))}</div>
</div>
{sq_open_btn}
<div class="sq-gate-right">
<div>{files_analyzed} files analyzed</div>
<div>{folders_total} folders</div>
</div>
</div>
<!-- Metric cards -->
<div class="sq-metrics">
<div class="sq-metric-card" {bugs_card_attr}>
<div class="sq-metric-label">Bugs</div>
<div class="sq-metric-body">
<div class="sq-metric-value">{bugs_count if bugs_count is not None else "N/A"}</div>
</div>
<div class="sq-metric-sub">{bugs_count if bugs_count is not None else "N/A"} open issue{"s" if (bugs_count or 0) != 1 else ""}</div>
</div>
<div class="sq-metric-card" {smells_card_attr}>
<div class="sq-metric-label">Code Smells</div>
<div class="sq-metric-body">
<div class="sq-metric-value">{smells_count if smells_count is not None else "N/A"}</div>
</div>
<div class="sq-metric-sub">{smells_count if smells_count is not None else "N/A"} open issue{"s" if (smells_count or 0) != 1 else ""}</div>
</div>
<div class="sq-metric-card" {cov_card_attr}>
<div class="sq-metric-label">Coverage</div>
<div class="sq-metric-body">
<div class="{cov_val_class}">{fmt_pct(cov_pct)}</div>
<div class="sq-metric-circle {cov_circle}"></div>
</div>
<div class="sq-metric-sub">threshold {cov_threshold:g}%{cov_pass_sub}</div>
</div>
<div class="sq-metric-card" {dup_card_attr}>
<div class="sq-metric-label">Duplications</div>
<div class="sq-metric-body">
<div class="{dup_val_class}">{fmt_pct(dup_pct)}</div>
<div class="sq-metric-circle {dup_circle}"></div>
</div>
<div class="sq-metric-sub">threshold {dup_threshold:g}%{dup_pass_sub}</div>
</div>
<div class="sq-metric-card" {lint_card_attr}>
<div class="sq-metric-label">Lint</div>
<div class="sq-metric-body">
<div class="sq-metric-value">{lint_errors_display}</div>
<div class="sq-metric-circle {lint_circle}"></div>
</div>
<div class="sq-metric-sub">{lint_pass_sub}{f" · {lint_warn_display} warn" if lint_warn_display else ""}</div>
</div>
</div>
</div>
<!-- View: File List (drill-down from metric card) -->
<div id="view-files" style="display:none">
<div class="detail-header">
<button class="back-btn" onclick="goBack()">← Back</button>
<div class="detail-file-info">
<span class="detail-filename" id="view-files-title"></span>
<span class="detail-filepath" id="view-files-count"></span>
</div>
<div class="view-toggle">
<button id="btn-view-list" class="view-toggle-btn active" onclick="setFilesView('list')">☰ List</button>
<button id="btn-view-tree" class="view-toggle-btn" onclick="setFilesView('tree')">▶ Tree</button>
</div>
</div>
<div id="view-files-content"></div>
</div>
<!-- View: File Detail -->
<div id="view-detail" style="display:none">
<div class="detail-header">
<button class="back-btn" onclick="goBack()">← Back</button>
<div class="detail-file-info">
<span class="detail-filename" id="detail-filename"></span>
<span class="detail-filepath" id="detail-filepath"></span>
</div>
<div class="detail-badges" id="detail-badges"></div>
<div class="detail-issue-count" id="detail-issue-count"></div>
</div>
<div id="detail-content"></div>
</div>
<div style="padding:16px 0;font-size:11px;color:var(--text-dim);text-align:right">
top-flutter · {project_key} · Source: SonarQube · {html.escape(summary.get("scope", ""))}
</div>
</div>"""
# Build FILE_DETAILS JS object (exclude scripts/quality to avoid injection marker conflicts)
file_details_data: dict[str, Any] = {}
if file_details:
for path, detail in file_details.items():
if path.startswith("scripts/"):
continue
file_details_data[path] = {
"lines": detail.get("lines", []),
"issues": detail.get("issues", []),
"duplications": detail.get("duplications", []),
}
file_details_js = json.dumps(file_details_data, ensure_ascii=True).replace("</", "<\\/")
coverage_data_js = json.dumps(lcov_coverage or {}, ensure_ascii=True).replace("</", "<\\/")
lint_files_data = []
if lint_data and lint_data.get("files"):
for f in lint_data["files"]:
lint_files_data.append({
"path": f.get("path", ""),
"errors": f.get("errors", 0),
"warnings": f.get("warnings", 0),
"info": f.get("info", 0),
"issues": f.get("issues", []),
})
lint_files_js = json.dumps(lint_files_data, ensure_ascii=True).replace("</", "<\\/")
# Serialize problem files for JS drill-down
problem_files_data = []
for rec in problem_map.get("topFiles", []):
problem_files_data.append({
"path": rec.get("path", ""),
"issueTotal": int(rec.get("issueTotal") or 0),
"coverage": rec.get("coverage"),
"duplicatedLinesDensity": rec.get("duplicatedLinesDensity"),
"coverageProblem": bool(rec.get("coverageProblem")),
"duplicationProblem": bool(rec.get("duplicationProblem")),
"issues": {
"BUG": int((rec.get("issues") or {}).get("BUG") or 0),
"VULNERABILITY": int((rec.get("issues") or {}).get("VULNERABILITY") or 0),
"CODE_SMELL": int((rec.get("issues") or {}).get("CODE_SMELL") or 0),
"SECURITY_HOTSPOT": int((rec.get("issues") or {}).get("SECURITY_HOTSPOT") or 0),
},
})
problem_files_js = json.dumps(problem_files_data, ensure_ascii=True).replace("</", "<\\/")
inject_js = f"const FILE_DETAILS = {file_details_js};\nconst PROBLEM_FILES = {problem_files_js};\nconst COVERAGE_DATA = {coverage_data_js};\nconst LINT_FILES = {lint_files_js};"
html_out = template.replace("<!-- INJECT_BODY -->", body_html)
html_out = html_out.replace("/*__REPORT_DATA__*/", inject_js)
return html_out
def generate_report() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--report-task", required=True, help="Path to .scannerwork/report-task.txt")
parser.add_argument("--project-key", required=True, help="SonarQube project key")
parser.add_argument("--scope", default="Whole Project", help="Display label for this run")
parser.add_argument("--coverage", help="Path to lcov.info used by scanner", default=None)
parser.add_argument("--coverage-threshold", type=percent_threshold, default=80)
parser.add_argument("--dup-threshold", type=percent_threshold, default=3)
parser.add_argument("--focus", default="", help="Focus areas: coverage,duplication,smell")
parser.add_argument("--smell-threshold", type=int, default=0, help="Max code smells allowed in focus gate")
args = parser.parse_args()
focus_mode = bool(args.focus)
report_task = Path(args.report_task)
task_values = read_properties(report_task)
env_url = os.environ.get("SONAR_HOST_URL")
task_url = task_values.get("serverUrl")
if env_url and task_url and env_url.rstrip("/") != task_url.rstrip("/"):
print(f"WARN: SONAR_HOST_URL ({env_url}) differs from serverUrl in report-task ({task_url}), using SONAR_HOST_URL", file=sys.stderr)
base_url = env_url or task_url or task_values.get("dashboardUrl", "").split("/dashboard", 1)[0]
ce_task_id = task_values.get("ceTaskId")
token = os.environ.get("SONAR_TOKEN") or os.environ.get("SONAR_LOGIN") or ""
if not base_url:
raise SonarApiError("Could not determine SonarQube URL from SONAR_HOST_URL or report-task.txt")
if not ce_task_id:
raise SonarApiError(f"Missing ceTaskId in {report_task}")
try:
analysis_id = wait_for_analysis(base_url, ce_task_id, token)
except SonarApiError:
# Remove stale summary so quality-check.sh cannot read a false PASSED gate
if SUMMARY_JSON.exists():
SUMMARY_JSON.unlink()
raise
gate_params = {"analysisId": analysis_id} if analysis_id else {"projectKey": args.project_key}
gate_payload = api_get(
base_url,
"/api/qualitygates/project_status",
gate_params,
token,
)
project_status = gate_payload.get("projectStatus", {})
gate_status = str(project_status.get("status", "ERROR")).upper()
gate_conditions = project_status.get("conditions", [])
metric_keys = "duplicated_lines_density,duplicated_lines,coverage,ncloc,bugs,vulnerabilities,code_smells"
measures_payload = api_get(
base_url,
"/api/measures/component",
{"component": args.project_key, "metricKeys": metric_keys},
token,
)
measures: dict[str, float | None] = {key: None for key in metric_keys.split(",")}
for item in measures_payload.get("component", {}).get("measures", []):
key = item.get("metric")
value = item.get("value")
if key in measures and value is not None:
measures[key] = to_float(value)
problem_map = build_problem_map(base_url, args.project_key, token, args.coverage_threshold, focus_mode=focus_mode)
# Parse lcov for coverage highlighting (raw SF paths, matching done in JS)
lcov_path = Path(args.coverage) if args.coverage else None
lcov_coverage = parse_lcov(lcov_path)
# Build file detail data for drill-down
def _fetch_detail(args_tuple: tuple) -> tuple:
fp, ck, bu, tk, dup = args_tuple
issues = fetch_file_issues(bu, ck, tk)
dups = fetch_file_duplications(bu, ck, tk) if dup > 0 else []
return fp, issues, dups
top_records = [r for r in problem_map.get("topFiles", [])[:500] if r.get("path")]
fetch_args_list = [
(r["path"], f"{args.project_key}:{r['path']}", base_url, token, to_float(r.get("duplicatedLines")) or 0)
for r in top_records
]
file_details: dict[str, Any] = {}
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(_fetch_detail, a): a[0] for a in fetch_args_list}
for future in as_completed(futures):
fp, issues, dups = future.result()
file_details[fp] = {
"lines": read_source_file(PROJECT_ROOT, fp),
"issues": issues,
"duplications": dups,
# TODO: add per-line coverage from /api/sources/lines?key=component when lcov absent
}
duplication_pct = measure_float(measures, "duplicated_lines_density")
coverage_pct = measure_float(measures, "coverage")
code_smells_count = measures.get("code_smells")
dup_passed = status_from_threshold(duplication_pct, args.dup_threshold, "max")
cov_passed = status_from_threshold(coverage_pct, args.coverage_threshold, "min")
smell_passed = (code_smells_count is not None and int(code_smells_count) <= args.smell_threshold) if focus_mode else True
code_smells_summary = {
"count": int(code_smells_count) if code_smells_count is not None else None,
"threshold": args.smell_threshold,
"passed": smell_passed,
}
# Read lint results if available
lint_summary: dict[str, Any] | None = None
lint_json_path = PROJECT_ROOT / "reports" / "lint" / "lint-results.json"
if lint_json_path.exists():
try:
lint_summary = json.loads(lint_json_path.read_text(encoding="utf-8"))
except Exception:
lint_summary = None
lint_passed = lint_summary.get("passed") if lint_summary else None
# Gate: SonarQube result + local thresholds (coverage, dup) + lint — all must pass
sonar_ok = gate_status == "OK"
cov_ok = cov_passed is True # None (no data) = fail gate
dup_ok = dup_passed is not False # None (no data) = don't block
lint_ok = lint_passed is not False # None (not run) = don't block
smell_ok = (code_smells_count is None or int(code_smells_count) <= args.smell_threshold) if focus_mode else True
gate_passed = sonar_ok and cov_ok and dup_ok and lint_ok and smell_ok
gate_display = "passed" if gate_passed else "failed"
gate_source = "local"
summary = {
"scope": args.scope,
"source": "sonarqube",
"projectKey": args.project_key,
"dashboardUrl": task_values.get("dashboardUrl"),
"analysisId": analysis_id,
"duplicate": {
"percentage": duplication_pct,
"threshold": args.dup_threshold,
"passed": dup_passed,
},
"coverage": {
"percentage": coverage_pct,
"threshold": args.coverage_threshold,
"passed": cov_passed,
"reportPath": args.coverage,
},
"qualityGateStatus": gate_status,
"gate": gate_display,
"gateSource": gate_source,
"focusMode": focus_mode,
"data_incomplete": len(_incomplete_endpoints) > 0,
"codeSmells": code_smells_summary,
"lint": lint_summary,
"problemMap": {
"issuesTotal": problem_map["issuesTotal"],
"filesAnalyzed": problem_map["filesAnalyzed"],
"problemFilesTotal": problem_map["problemFilesTotal"],
"foldersTotal": problem_map["foldersTotal"],
"topFiles": [
{
"path": item["path"],
"issueTotal": item["issueTotal"],
"coverage": item["coverage"],
"duplicatedLinesDensity": item["duplicatedLinesDensity"],
"duplicatedLines": item["duplicatedLines"],
}
for item in problem_map.get("topFiles", [])[:20]
],
},
}
REPORT_DIR.mkdir(parents=True, exist_ok=True)
SUMMARY_JSON.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
OUTPUT_HTML.write_text(render_html(summary, measures, gate_conditions, problem_map, branch=get_current_branch(), focus_mode=focus_mode, code_smells_summary=code_smells_summary, file_details=file_details, lcov_coverage=lcov_coverage), encoding="utf-8")
print(f"Summary generated: {SUMMARY_JSON}")
print(f"Report generated: {OUTPUT_HTML}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(generate_report())
except SonarApiError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code Quality Report</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-light">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-dark" disabled>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/dart.min.js"></script>
<style>
/* ── Design tokens ──────────────────────────────────────────────
Tokenized from the existing report styling. Legacy aliases (--bg,
--surface, etc.) remain mapped so the visual design is preserved. */
:root {
/* Color primitives: dark theme (Catppuccin Mocha) */
--color-canvas-dark: #1e1e2e;
--color-surface-dark: #181825;
--color-surface-raised-dark: #313244;
--color-border-dark: #45475a;
--color-text-dark: #cdd6f4;
--color-text-muted-dark: #6c7086;
--color-success-dark: #a6e3a1;
--color-danger-dark: #f38ba8;
--color-accent-dark: #89b4fa;
--color-warning-dark: #f9e2af;
--color-info-dark: #94e2d5;
/* Semantic colors */
--color-canvas: var(--color-canvas-dark);
--color-surface: var(--color-surface-dark);
--color-surface-raised: var(--color-surface-raised-dark);
--color-border: var(--color-border-dark);
--color-text: var(--color-text-dark);
--color-text-muted: var(--color-text-muted-dark);
--color-success: var(--color-success-dark);
--color-danger: var(--color-danger-dark);
--color-accent: var(--color-accent-dark);
--color-warning: var(--color-warning-dark);
--color-info: var(--color-info-dark);
--color-code-bg: var(--color-canvas-dark);
--color-on-badge: #1e1e2e;
--color-gate-pass-bg: rgba(166,227,161,0.15);
--color-gate-fail-bg: rgba(243,139,168,0.15);
--color-filter-bg: rgba(137,180,250,0.15);
--color-active-bg: rgba(137,180,250,0.2);
--color-overlay: rgba(17,17,27,0.7);
/* Typography, space, radius, motion, layout */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
--space-1: 4px; --space-2: 6px; --space-3: 8px; --space-4: 10px; --space-5: 12px; --space-6: 14px; --space-7: 16px; --space-8: 20px; --space-9: 24px;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 10px; --radius-pill: 20px;
--shadow-popover: 0 18px 60px rgba(0,0,0,0.35);
--shadow-card: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08);
--shadow-card-hover: 0 4px 12px rgba(0,0,0,0.20), 0 2px 4px rgba(0,0,0,0.12);
--duration-fast: 0.15s;
--duration-med: 0.2s;
--layout-sidebar-width: 320px;
/* Legacy aliases preserved for existing rules */
--bg: var(--color-canvas);
--surface: var(--color-surface);
--surface2: var(--color-surface-raised);
--border: var(--color-border);
--text: var(--color-text);
--text-dim: var(--color-text-muted);
--green: var(--color-success);
--red: var(--color-danger);
--blue: var(--color-accent);
--yellow: var(--color-warning);
--cyan: var(--color-info);
--sidebar-w: var(--layout-sidebar-width);
--code-bg: var(--color-code-bg);
--badge-text: var(--color-on-badge);
--gate-pass-bg: var(--color-gate-pass-bg);
--gate-fail-bg: var(--color-gate-fail-bg);
--filter-bg: var(--color-filter-bg);
--active-bg: var(--color-active-bg);
}
[data-theme="light"] {
/* Catppuccin Latte */
--color-canvas: #eff1f5;
--color-surface: #e6e9ef;
--color-surface-raised: #dce0e8;
--color-border: #ccd0da;
--color-text: #4c4f69;
--color-text-muted: #9ca0b0;
--color-success: #40a02b;
--color-danger: #d20f39;
--color-accent: #1e66f5;
--color-warning: #df8e1d;
--color-info: #179299;
--color-code-bg: #e6e9ef;
--color-on-badge: #eff1f5;
--color-gate-pass-bg: rgba(64,160,43,0.1);
--color-gate-fail-bg: rgba(210,15,57,0.1);
--color-filter-bg: rgba(30,102,245,0.1);
--color-active-bg: rgba(30,102,245,0.12);
--color-overlay: rgba(76,79,105,0.4);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: var(--bg); color: var(--text); margin: 0; font-size: 14px; }
/* SonarQube-style layout */
/* Top nav */
.sq-navbar { background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; height: 48px; gap: 16px; }
.sq-logo { font-weight: 700; font-size: 15px; color: var(--blue); }
.sq-project { font-weight: 600; font-size: 14px; }
.sq-branch-badge { background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 4px; }
.sq-branch-badge .pass { color: var(--green); }
/* Tab bar */
.sq-tabs { background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; }
.sq-tab { padding: 12px 16px; font-size: 13px; color: var(--text-dim); border-bottom: 2px solid transparent; cursor: pointer; }
.sq-tab.active { color: var(--blue); border-bottom-color: var(--blue); font-weight: 500; }
/* Content area */
.sq-content { max-width: 1200px; margin: 0 auto; padding: 24px 20px; }
.sq-meta { font-size: 13px; color: var(--text-dim); margin-bottom: 16px; }
.sq-meta strong { color: var(--text); }
/* Quality gate banner */
.sq-gate { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px; display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
.sq-gate-icon { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
.sq-gate-icon.pass { background: rgba(63,185,80,0.15); color: var(--green); }
.sq-gate-icon.fail { background: rgba(248,81,73,0.15); color: var(--red); }
.sq-gate-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin-bottom: 4px; }
.sq-gate-status { font-size: 26px; font-weight: 700; }
.sq-gate-status.pass { color: var(--green); }
.sq-gate-status.fail { color: var(--red); }
.sq-gate-meta { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
.sq-gate-right { margin-left: auto; font-size: 12px; color: var(--text-dim); text-align: right; }
/* Code tabs (New Code / Overall Code) */
.sq-code-tabs { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
.sq-code-tab { padding: 8px 16px; font-size: 13px; color: var(--text-dim); border-bottom: 2px solid transparent; cursor: pointer; }
.sq-code-tab.active { color: var(--text); border-bottom-color: var(--text); font-weight: 500; }
/* Metric cards grid */
.sq-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 24px; }
.sq-metric-card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 16px 18px; }
.sq-metric-label { font-size: 12px; color: var(--text-dim); margin-bottom: 10px; font-weight: 500; }
.sq-metric-body { display: flex; align-items: center; justify-content: space-between; }
.sq-metric-value { font-size: 28px; font-weight: 700; color: var(--text); }
.sq-metric-grade { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; }
.sq-metric-sub { font-size: 12px; color: var(--text-dim); margin-top: 6px; }
/* Grade colors */
.grade-A { background: rgba(63,185,80,0.2); color: var(--green); }
.grade-B { background: rgba(57,210,192,0.2); color: var(--cyan); }
.grade-C { background: rgba(210,153,22,0.2); color: var(--yellow); }
.grade-D { background: rgba(248,81,73,0.15); color: var(--red); }
.grade-E { background: rgba(248,81,73,0.2); color: var(--red); border: 1px solid var(--red); }
/* Coverage/Dup circle */
.sq-metric-circle { width: 28px; height: 28px; border-radius: 50%; border: 3px solid; flex-shrink: 0; }
.circle-pass { border-color: var(--green); }
.circle-fail { border-color: var(--red); }
.circle-warn { border-color: var(--yellow); }
.circle-none { border-color: var(--text-dim); opacity: 0.4; }
/* Problem files section */
.sq-section-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
.sq-table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.sq-table th { text-align: left; padding: 10px 16px; font-size: 12px; font-weight: 600; color: var(--text-dim); background: var(--surface2); border-bottom: 1px solid var(--border); }
.sq-table td { padding: 10px 16px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
.sq-table tr:last-child td { border-bottom: none; }
.sq-table tr:hover td { background: var(--surface2); }
.sq-file-link { color: var(--blue); font-size: 12px; }
.sq-file-path { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
.sq-badge { display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 3px; margin-right: 3px; }
.sq-badge-bug { background: rgba(248,81,73,0.15); color: var(--red); }
.sq-badge-smell { background: rgba(210,153,22,0.15); color: var(--yellow); }
.sq-badge-cov { background: rgba(88,166,255,0.15); color: var(--blue); }
.sq-badge-vuln { background: rgba(203,166,247,0.15); color: var(--cyan); }
.sq-badge-dup { background: rgba(210,153,22,0.15); color: var(--yellow); }
.sq-badge-hotspot { background: rgba(203,166,247,0.15); color: var(--cyan); }
.sq-issues-count { font-size: 12px; color: var(--text-dim); }
/* Open SonarQube button */
.sq-open-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; background: var(--blue); color: #fff; border-radius: 4px; font-size: 12px; font-weight: 500; text-decoration: none; }
.sq-open-btn:hover { opacity: 0.9; text-decoration: none; color: #fff; }
/* warn/incomplete banner */
.sq-warn { background: rgba(249,226,175,0.15); color: var(--yellow); border: 1px solid var(--yellow); border-radius: 6px; padding: 10px 14px; font-size: 12px; margin-bottom: 16px; }
[data-theme="light"] .sq-warn { background: rgba(223,142,29,0.12); }
/* Gate conditions table */
.sq-gate-table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; font-size: 13px; margin-top: 24px; }
.sq-gate-table th { text-align: left; padding: 10px 16px; font-size: 12px; font-weight: 600; color: var(--text-dim); background: var(--surface2); border-bottom: 1px solid var(--border); }
.sq-gate-table td { padding: 10px 16px; border-bottom: 1px solid var(--border); }
.sq-gate-table tr:last-child td { border-bottom: none; }
.sq-gate-table tr:hover td { background: var(--surface2); }
.sq-cond-pass { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; display: inline-block; background: var(--color-gate-pass-bg); color: var(--green); }
.sq-cond-fail { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; display: inline-block; background: var(--color-gate-fail-bg); color: var(--red); }
@media (max-width: 900px) { .sq-metrics { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) { .sq-metrics { grid-template-columns: 1fr; } }
/* Theme toggle — fixed top-right */
.theme-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
background: none;
border: none;
cursor: pointer;
font-size: 13px;
color: var(--text-dim);
transition: color 0.15s;
}
.theme-toggle:hover { color: var(--text); }
.theme-toggle:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.theme-toggle-icon { font-size: 16px; }
/* Relationship panel */
.rel-panel {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
}
.rel-panel.active { display: block; }
.rel-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.rel-header-file {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 13px;
font-weight: 600;
color: var(--blue);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rel-header-stat {
font-size: 12px;
color: var(--text-dim);
flex-shrink: 0;
}
.rel-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.rel-table th {
text-align: left;
padding: 8px 12px;
background: var(--surface2);
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rel-table td {
padding: 8px 12px;
border-top: 1px solid var(--border);
}
.rel-table tr:hover { background: var(--surface2); cursor: pointer; }
.rel-file-link {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--cyan);
font-size: 12px;
}
.rel-count { color: var(--yellow); font-weight: 600; }
.rel-lines { color: var(--text-dim); }
.result-count { color: var(--text-dim); font-size: 12px; margin-bottom: 12px; }
/* Clone cards */
.clone-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
overflow: hidden;
content-visibility: auto;
contain-intrinsic-size: auto 60px;
}
.clone-card.highlight { border-color: var(--blue); }
.clone-header {
display: flex;
align-items: center;
padding: 10px 14px;
cursor: pointer;
gap: 10px;
transition: background 0.15s;
}
.clone-header:hover { background: var(--surface2); }
.clone-title { display: flex; align-items: center; gap: 6px; min-width: 120px; }
.clone-badge {
background: var(--yellow);
color: var(--badge-text);
padding: 1px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.clone-lines { color: var(--text-dim); font-size: 11px; }
.clone-type-badge {
background: rgba(210,153,34,0.15);
color: var(--yellow);
border: 1px solid rgba(210,153,34,0.3);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.clone-files-row { flex: 1; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.file-tag {
background: var(--surface2);
padding: 2px 7px;
border-radius: 4px;
font-size: 11px;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--cyan);
}
.vs { color: var(--text-dim); font-size: 10px; }
.toggle-icon { color: var(--text-dim); font-size: 11px; transition: transform 0.2s; }
.toggle-icon.open { transform: rotate(90deg); }
.clone-list-wrap {
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
max-height: 70vh;
padding: 8px;
background: var(--bg);
}
.clone-list-wrap .clone-card:last-child { margin-bottom: 0; }
.clone-body { border-top: 1px solid var(--border); }
.code-compare {
display: grid;
grid-template-columns: 1fr 1fr;
}
.code-panel { overflow: auto; }
.code-panel + .code-panel { border-left: 1px solid var(--border); }
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
font-size: 11px;
}
.panel-file {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
color: var(--blue);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.panel-range { color: var(--text-dim); flex-shrink: 0; margin-left: 8px; }
.code-wrapper {
display: flex;
overflow-x: auto;
background: var(--code-bg);
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 12px;
line-height: 1.7;
}
.code-wrapper .line-numbers {
flex-shrink: 0;
padding: 8px 0;
text-align: right;
user-select: none;
border-right: 1px solid var(--border);
}
.code-wrapper .line-numbers > div {
padding: 0 10px 0 12px;
color: var(--text-dim);
font-size: 12px;
line-height: 1.7;
height: 1.7em;
}
.code-wrapper .line-numbers > div.hl {
background: rgba(248, 81, 73, 0.18);
color: var(--red);
font-weight: 600;
}
.code-wrapper .code-content {
flex: 1;
margin: 0;
padding: 8px 0;
overflow-x: auto;
background: transparent;
}
.code-wrapper .code-content code {
display: block;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
.code-wrapper .code-content .code-line {
display: block;
padding: 0 12px;
height: 1.7em;
line-height: 1.7;
}
.code-wrapper .code-content .code-line.hl {
background: rgba(248, 81, 73, 0.18);
border-left: 3px solid var(--red);
padding-left: 9px;
}
[data-theme="light"] .code-wrapper .code-content .code-line.hl {
background: rgba(255, 220, 220, 0.7);
}
[data-theme="light"] .code-wrapper .line-numbers > div.hl {
background: rgba(255, 220, 220, 0.7);
}
.code-panel code.hljs {
background: transparent;
padding: 0;
line-height: 1.7;
}
/* Main tabs */
.main-tabs {
display: flex;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
gap: 4px;
}
.main-tab {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
color: var(--text-dim);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
border-radius: 6px 6px 0 0;
}
.main-tab:hover { color: var(--text); background: var(--surface); }
.main-tab.active { color: var(--blue); border-bottom-color: var(--blue); background: var(--surface); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Overview gauge */
.overview-gates {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.gate-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.gate-card-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
margin-bottom: 8px;
}
.gate-card-value {
font-size: 32px;
font-weight: 700;
margin-bottom: 4px;
}
.gate-card-bar {
height: 6px;
border-radius: 3px;
background: var(--surface2);
margin-top: 8px;
overflow: hidden;
}
.gate-card-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.gate-card-detail {
font-size: 11px;
color: var(--text-dim);
margin-top: 6px;
}
/* Coverage table */
.cov-table-wrap {
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
max-height: 70vh;
margin-top: 12px;
}
.cov-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.cov-table th {
text-align: left;
padding: 10px 12px;
background: var(--surface2);
color: var(--text-dim);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
user-select: none;
position: sticky;
top: 0;
z-index: 10;
border-bottom: 2px solid var(--border);
}
.cov-table th:hover { color: var(--text); }
.cov-table td {
padding: 7px 12px;
border-top: 1px solid var(--border);
}
.cov-table tr:hover { background: var(--surface); }
.cov-row { cursor: default; }
.cov-file-action {
display: inline-flex;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.cov-file-action:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 3px; }
.cov-file {
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 11px;
color: var(--cyan);
}
.cov-bar-wrap {
width: 100%;
height: 8px;
background: var(--surface2);
border-radius: 4px;
overflow: hidden;
min-width: 80px;
}
.cov-bar {
height: 100%;
border-radius: 4px;
}
/* Coverage sidebar */
.cov-sidebar-file {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
font-size: 12px;
margin-bottom: 2px;
}
.cov-sidebar-file:hover { background: var(--surface2); }
.cov-sidebar-name {
flex: 1;
font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cov-sidebar-bar {
width: 50px;
height: 6px;
background: var(--surface2);
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
}
.cov-sidebar-pct {
font-size: 11px;
font-weight: 600;
min-width: 32px;
text-align: right;
flex-shrink: 0;
}
/* Coverage drill-down */
.coverage-table-view.hidden, .coverage-detail-view.hidden { display: none; }
.coverage-detail-view {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--surface);
}
.cov-detail-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
}
.cov-detail-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
color: var(--cyan);
font-size: 13px;
font-weight: 600;
}
.cov-detail-stats { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 14px; border-bottom: 1px solid var(--border); }
.cov-stat-pill { padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; font-size: 12px; background: var(--bg); }
.cov-legend { display: flex; gap: 10px; align-items: center; margin-left: auto; color: var(--text-dim); font-size: 11px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.legend-covered { background: rgba(63,185,80,0.35); border: 1px solid var(--green); }
.legend-uncovered { background: rgba(248,81,73,0.35); border: 1px solid var(--red); }
.legend-neutral { background: var(--surface2); border: 1px solid var(--border); }
.cov-source-wrap { max-height: 72vh; overflow: auto; background: var(--code-bg); }
.cov-source-line { display: grid; grid-template-columns: 72px 1fr; min-height: 1.65em; font-family: var(--font-mono); font-size: 12px; line-height: 1.65; }
.cov-source-line .ln { padding: 0 10px; text-align: right; color: var(--text-dim); user-select: none; border-right: 1px solid var(--border); }
.cov-source-line .src { padding: 0 12px; white-space: pre; overflow: visible; }
.cov-source-line.covered { background: rgba(63,185,80,0.10); }
.cov-source-line.covered .ln { color: var(--green); }
.cov-source-line.uncovered { background: rgba(248,81,73,0.16); }
.cov-source-line.uncovered .ln { color: var(--red); font-weight: 600; }
.cov-source-line.neutral { background: transparent; }
/* Sidebar sections */
.sidebar-section { display: none; }
.sidebar-section.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
/* Theme toggle */
.theme-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
background: none;
border: none;
cursor: pointer;
font-size: 13px;
color: var(--text-dim);
transition: color 0.15s;
}
.theme-toggle:hover { color: var(--text); }
.theme-toggle:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.theme-toggle-icon { font-size: 16px; }
/* Focus-visible for interactive elements */
button:focus-visible, [role="button"]:focus-visible,
.sidebar-tab:focus-visible, .folder-row:focus-visible, .tree-file:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
/* ── Contextual Copilot Prompt Modal ── */
.copilot-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 6px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--blue);
background: var(--filter-bg);
color: var(--blue);
cursor: pointer;
font-size: 12px;
font-weight: 600;
min-height: 32px;
white-space: nowrap;
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
}
.copilot-btn:hover { background: var(--blue); color: var(--badge-text); }
.copilot-btn.compact { padding: 4px 9px; font-size: 11px; min-height: 32px; }
.cov-list-item .copilot-btn { margin-top: 6px; width: 100%; }
.prompt-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
padding: var(--space-9);
background: var(--color-overlay);
pointer-events: none;
}
.prompt-modal-backdrop.active { display: flex; }
.prompt-modal {
width: min(860px, 96vw);
pointer-events: auto;
max-height: 86vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-popover);
}
.prompt-modal-header {
display: flex;
align-items: center;
gap: var(--space-5);
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
}
.prompt-modal-title { font-size: 15px; font-weight: 700; color: var(--text); }
.prompt-modal-file {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 12px;
color: var(--cyan);
}
.prompt-modal-close {
border: 0;
background: transparent;
color: var(--text-dim);
cursor: pointer;
font-size: 22px;
line-height: 1;
}
.prompt-modal-close:hover { color: var(--red); }
.prompt-modal-body { padding: 16px; overflow: auto; background: var(--bg); }
.prompt-modal-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.65;
}
.prompt-modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--surface);
}
.prompt-copy-btn {
padding: 8px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--blue);
background: var(--blue);
color: var(--badge-text);
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.prompt-copy-btn.copied { background: var(--green); border-color: var(--green); }
@media (max-width: 1000px) {
.sidebar { width: 260px; min-width: 260px; }
.code-compare { grid-template-columns: 1fr; }
.code-panel + .code-panel { border-left: none; border-top: 1px solid var(--border); }
.stats { grid-template-columns: repeat(2, 1fr); }
}
/* Extra tokens layered on top of template.html Catppuccin vars */
:root { --strong: var(--color-text); }
a { color: var(--blue); text-decoration: none; }
a:hover { text-decoration: underline; }
.file-row { cursor: pointer; }
.file-row:hover td { background: var(--surface2); }
/* SPA views */
#view-list, #view-detail { width: 100%; }
/* Detail header */
.detail-header { display: flex; align-items: center; gap: 16px; padding: 12px 0 16px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
.back-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; cursor: pointer; white-space: nowrap; }
.back-btn:hover { background: var(--surface2); }
.detail-file-info { flex: 1; min-width: 0; }
.detail-filename { font-size: 15px; font-weight: 600; color: var(--text); display: block; }
.detail-filepath { font-size: 11px; color: var(--text-dim); display: block; margin-top: 2px; word-break: break-all; }
.detail-issue-count { font-size: 13px; color: var(--text-dim); white-space: nowrap; }
/* Source code view */
.code-view { overflow-x: auto; border: 1px solid var(--border); border-radius: 6px; }
.code-table { width: 100%; border-collapse: collapse; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.code-table .ln { width: 52px; min-width: 52px; text-align: right; padding: 1px 12px; color: var(--text-dim); background: var(--surface); border-right: 1px solid var(--border); user-select: none; vertical-align: top; line-height: 1.6; }
.code-table .lc { padding: 1px 16px; white-space: pre; color: var(--text); line-height: 1.6; }
.code-table tr.issue-line .ln { background: rgba(248,81,73,0.12); color: var(--red); font-weight: 600; }
.code-table tr.issue-line .lc { background: rgba(248,81,73,0.05); }
.issue-inline { display: flex; align-items: flex-start; gap: 8px; padding: 4px 16px 6px 68px; background: rgba(248,81,73,0.06); border-bottom: 1px solid rgba(248,81,73,0.12); }
.issue-inline .sev { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 3px; flex-shrink: 0; margin-top: 1px; }
.issue-inline .msg { font-size: 12px; color: var(--text); line-height: 1.5; }
.sev-BLOCKER, .sev-CRITICAL { background: rgba(248,81,73,0.2); color: var(--red); }
.sev-MAJOR { background: rgba(210,153,22,0.2); color: var(--yellow); }
.sev-MINOR, .sev-INFO { background: rgba(88,166,255,0.15); color: var(--blue); }
.no-source { padding: 32px; text-align: center; color: var(--text-dim); font-size: 13px; }
.code-table tr.dup-line .ln { background: rgba(210,153,22,0.12); color: var(--yellow); }
.code-table tr.dup-line .lc { background: rgba(210,153,22,0.05); }
.code-table tr.cov-line .ln { background: rgba(63,185,80,0.15); color: #3fb950; }
.code-table tr.cov-line .lc { background: rgba(63,185,80,0.05); }
.code-table tr.uncov-line .ln { background: rgba(248,81,73,0.15); color: var(--red); }
.code-table tr.uncov-line .lc { background: rgba(248,81,73,0.05); }
.code-table tr.lint-line .ln { background: rgba(188,140,255,0.15); color: #bc8cff; }
.code-table tr.lint-line .lc { background: rgba(188,140,255,0.05); }
.lint-inline { display: flex; align-items: flex-start; gap: 8px; padding: 4px 16px 6px 68px; background: rgba(188,140,255,0.06); border-bottom: 1px solid rgba(188,140,255,0.12); }
.lint-rule { font-size: 10px; color: var(--text-dim); margin-left: auto; font-family: 'JetBrains Mono', monospace; flex-shrink: 0; }
.dup-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 8px 16px; background: rgba(210,153,22,0.07); border: 1px solid rgba(210,153,22,0.2); border-radius: 6px; margin-bottom: 8px; font-size: 12px; }
.dup-label { color: var(--yellow); font-weight: 600; white-space: nowrap; }
.dup-file { background: rgba(210,153,22,0.12); color: var(--text); border-radius: 4px; padding: 1px 8px; font-family: 'JetBrains Mono', monospace; }
.view-toggle { display: flex; gap: 4px; margin-left: auto; }
.view-toggle-btn { background: var(--surface); border: 1px solid var(--border); color: var(--text-dim); padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; }
.view-toggle-btn.active { background: var(--blue); border-color: var(--blue); color: #fff; }
.tree-folder { width: 100%; }
.tree-folder summary { display: flex; align-items: center; gap: 8px; padding: 6px 12px; cursor: pointer; background: var(--surface); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-dim); list-style: none; }
.tree-folder summary::-webkit-details-marker { display: none; }
.tree-folder[open] summary .tree-arrow { transform: rotate(90deg); }
.tree-arrow { display: inline-block; font-size: 10px; transition: transform 0.15s; flex-shrink: 0; }
.tree-folder-name { font-weight: 600; color: var(--text); font-size: 11px; }
.tree-folder-meta { color: var(--text-dim); font-size: 11px; margin-left: auto; }
.tree-file-row { display: flex; align-items: center; padding: 5px 12px 5px 28px; border-bottom: 1px solid var(--border); cursor: pointer; font-size: 12px; gap: 10px; }
.tree-file-row:hover { background: var(--surface2); }
.tree-file-name { color: var(--text); font-size: 11px; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-file-badges { display: flex; gap: 4px; flex-shrink: 0; }
.tree-file-detail { color: var(--text-dim); font-size: 11px; white-space: nowrap; flex-shrink: 0; }
</style>
</head>
<body>
<!-- INJECT_BODY -->
<script>
/*__REPORT_DATA__*/
</script>
<script>
function _renderDetail(path) {
const detail = FILE_DETAILS[path];
// Update header
const parts = path.split('/');
document.getElementById('detail-filename').textContent = parts[parts.length - 1];
document.getElementById('detail-filepath').textContent = path;
// Badges from PROBLEM_FILES
const pf = PROBLEM_FILES.find(f => f.path === path);
const badgesEl = document.getElementById('detail-badges');
badgesEl.innerHTML = pf ? renderFileBadges(pf) : '';
const issueCount = detail ? (detail.issues || []).length : 0;
document.getElementById('detail-issue-count').textContent = issueCount + ' issue' + (issueCount !== 1 ? 's' : '');
// Build source view
const content = document.getElementById('detail-content');
if (!detail || !detail.lines || detail.lines.length === 0) {
content.innerHTML = '<div class="no-source">Source not available for this file.</div>';
} else {
const issuesByLine = {};
(detail.issues || []).forEach(issue => {
const ln = issue.line || 0;
if (!issuesByLine[ln]) issuesByLine[ln] = [];
issuesByLine[ln].push(issue);
});
const issueLines = new Set(Object.keys(issuesByLine).map(Number));
const dupBlocks = detail.duplications || [];
const dupLineSet = new Set();
dupBlocks.forEach(dup => {
dup.blocks.forEach(block => {
const _normPath = path.replace(/\\/g, '/');
const _isCurrentFile = (k) => !k || k === _normPath || k.endsWith('/' + _normPath) || k.endsWith(':' + _normPath);
if (_isCurrentFile(block.fileKey)) {
for (let i = block.from; i < block.from + block.size; i++) dupLineSet.add(i);
}
});
});
let dupHtml = '';
if (dupBlocks.length > 0) {
const _np = path.replace(/\\/g, '/');
const _isCurrent = (k) => !k || k === _np || k.endsWith('/' + _np) || k.endsWith(':' + _np);
const otherFiles = new Set();
dupBlocks.forEach(dup => {
dup.blocks.forEach(block => {
if (block.fileName && !_isCurrent(block.fileKey)) {
otherFiles.add(block.fileName);
}
});
});
if (otherFiles.size > 0) {
dupHtml = '<div class="dup-panel"><span class="dup-label">Duplicated with:</span> ';
dupHtml += [...otherFiles].map(f => '<span class="dup-file">' + escHtml(f) + '</span>').join(' ');
dupHtml += '</div>';
}
}
// Match coverage data by suffix (SF paths in lcov may be shorter than project paths)
let covData = null;
if (COVERAGE_DATA) {
// Try progressively shorter suffixes of the file path
const parts = path.split('/');
for (let i = 0; i < parts.length && !covData; i++) {
const suffix = parts.slice(i).join('/');
if (COVERAGE_DATA[suffix]) covData = COVERAGE_DATA[suffix];
}
}
const coveredSet = new Set(covData ? covData.c || [] : []);
const uncoveredSet = new Set(covData ? covData.u || [] : []);
const hasCoverage = coveredSet.size > 0 || uncoveredSet.size > 0;
// Lint issues for this file
const lintFile = (typeof LINT_FILES !== 'undefined' ? LINT_FILES : []).find(l => {
const parts = path.split('/');
for (let i = 0; i < parts.length; i++) {
if (l.path === parts.slice(i).join('/') || l.path === path) return true;
}
return false;
});
const lintIssuesByLine = {};
if (lintFile) {
(lintFile.issues || []).forEach(issue => {
if (!lintIssuesByLine[issue.line]) lintIssuesByLine[issue.line] = [];
lintIssuesByLine[issue.line].push(issue);
});
}
const lintLineSet = new Set(Object.keys(lintIssuesByLine).map(Number));
let html = dupHtml + '<div class="code-view"><table class="code-table">';
const fullSource = detail.lines.join('\n');
const hlResult = hljs.highlight(fullSource, { language: 'dart', ignoreIllegals: true });
const hlLines = hlResult.value.split('\n');
detail.lines.forEach((line, idx) => {
const ln = idx + 1;
const isIssue = issueLines.has(ln);
const isDup = dupLineSet.has(ln);
const isCovered = hasCoverage && coveredSet.has(ln);
const isUncovered = hasCoverage && uncoveredSet.has(ln);
const isLint = lintLineSet.has(ln);
const rowClass = isIssue ? 'issue-line' : (isDup ? 'dup-line' : (isCovered ? 'cov-line' : (isUncovered ? 'uncov-line' : (isLint ? 'lint-line' : ''))));
html += '<tr' + (rowClass ? ' class="' + rowClass + '"' : '') + '>';
html += '<td class="ln">' + ln + '</td>';
html += '<td class="lc">' + (hlLines[idx] || ' ') + '</td>';
html += '</tr>';
if (isIssue && issuesByLine[ln]) {
issuesByLine[ln].forEach(issue => {
html += '<tr><td colspan="2" style="padding:0"><div class="issue-inline">';
html += '<span class="sev sev-' + issue.severity + '">' + issue.severity + '</span>';
html += '<span class="msg">' + escHtml(issue.message) + '</span>';
html += '</div></td></tr>';
});
}
if (isLint && lintIssuesByLine[ln]) {
lintIssuesByLine[ln].forEach(issue => {
html += '<tr><td colspan="2" style="padding:0"><div class="issue-inline lint-inline">';
html += '<span class="sev sev-' + issue.severity.toUpperCase() + '">' + issue.severity + '</span>';
html += '<span class="msg">' + escHtml(issue.message) + '</span>';
html += '<span class="lint-rule">' + escHtml(issue.rule || '') + '</span>';
html += '</div></td></tr>';
});
}
});
html += '</table></div>';
content.innerHTML = html;
if (detail.issues && detail.issues.length > 0) {
const firstLine = Math.min(...detail.issues.map(i => i.line || 999999).filter(n => n < 999999));
if (isFinite(firstLine) && firstLine > 0) {
setTimeout(() => {
const rows = document.querySelectorAll('.code-table tr');
if (rows[firstLine - 1]) rows[firstLine - 1].scrollIntoView({block: 'center'});
}, 50);
}
}
}
}
function showDetail(path) {
_renderDetail(path);
_switchView('detail');
history.pushState({view: 'detail', path: path}, '', '#file=' + encodeURIComponent(path));
}
// Set initial history state so browser back works from the first page
history.replaceState({view: 'list'}, '', location.pathname + location.search);
function _switchView(show) {
document.getElementById('view-list').style.display = show === 'list' ? '' : 'none';
document.getElementById('view-files').style.display = show === 'files' ? '' : 'none';
document.getElementById('view-detail').style.display = show === 'detail' ? '' : 'none';
window.scrollTo(0, 0);
}
function showList() {
_switchView('list');
}
function goBack() {
history.back();
}
function _renderFileList(category) {
const _validCats = new Set(['bugs', 'vulns', 'smells', 'coverage', 'duplications', 'issues', 'lint']);
if (!_validCats.has(category)) category = 'issues';
const labels = {
bugs: 'Bugs', vulns: 'Vulnerabilities', smells: 'Code Smells',
coverage: 'Coverage Problems', duplications: 'Duplication Problems', issues: 'All Issues',
lint: 'Lint Issues'
};
const filtered = PROBLEM_FILES.filter(f => {
if (category === 'bugs') return f.issues.BUG > 0;
if (category === 'vulns') return f.issues.VULNERABILITY > 0;
if (category === 'smells') return f.issues.CODE_SMELL > 0;
if (category === 'coverage') return f.coverageProblem;
if (category === 'duplications') return f.duplicationProblem;
if (category === 'issues') return f.issueTotal > 0;
if (category === 'lint') {
const lf = (typeof LINT_FILES !== 'undefined' ? LINT_FILES : []).find(l => l.path === f.path);
return lf && (lf.errors > 0 || lf.warnings > 0);
}
return true;
});
let displayFiles;
if (category === 'lint' && typeof LINT_FILES !== 'undefined') {
displayFiles = LINT_FILES
.filter(f => f.errors > 0 || f.warnings > 0)
.map(f => ({
path: f.path,
issueTotal: f.errors + f.warnings,
coverage: null,
duplicatedLinesDensity: null,
coverageProblem: false,
duplicationProblem: false,
issues: { BUG: 0, VULNERABILITY: 0, CODE_SMELL: 0, SECURITY_HOTSPOT: 0 },
lintErrors: f.errors,
lintWarnings: f.warnings,
}));
} else {
displayFiles = filtered;
}
document.getElementById('view-files-title').textContent = labels[category] || category;
document.getElementById('view-files-count').textContent = displayFiles.length + ' file' + (displayFiles.length !== 1 ? 's' : '');
_currentFilteredFiles = displayFiles;
document.getElementById('view-files-content').innerHTML =
_filesViewMode === 'tree' ? buildTreeHtml(displayFiles) : buildListHtml(displayFiles);
}
function showFileList(category) {
_renderFileList(category);
_switchView('files');
history.pushState({view: 'files', category: category, filesViewMode: _filesViewMode}, '', location.pathname + '#' + category);
}
function renderFileBadges(f) {
// Lint file — show ERROR/WARNING badges only
if (f.lintErrors !== undefined || f.lintWarnings !== undefined) {
const badges = [];
if (f.lintErrors > 0) badges.push('<span class="sq-badge sq-badge-bug">' + f.lintErrors + ' ERROR</span>');
if (f.lintWarnings > 0) badges.push('<span class="sq-badge sq-badge-smell">' + f.lintWarnings + ' WARN</span>');
return badges.join(' ') || '<span style="color:var(--text-dim);font-size:11px">—</span>';
}
// SonarQube file
const badges = [];
const iss = f.issues || {};
[['BUG','BUG','sq-badge-bug'],['VULNERABILITY','VULN','sq-badge-vuln'],['CODE_SMELL','SMELL','sq-badge-smell'],['SECURITY_HOTSPOT','HOTSPOT','sq-badge-hotspot']].forEach(([key,label,css]) => {
const n = parseInt(iss[key] || 0);
if (n > 0) badges.push('<span class="sq-badge ' + css + '">' + n + ' ' + label + '</span>');
});
if (!badges.length && f.duplicationProblem) badges.push('<span class="sq-badge sq-badge-dup">DUP</span>');
if (!badges.length && f.coverageProblem) badges.push('<span class="sq-badge sq-badge-cov">COV</span>');
return badges.join(' ') || '<span style="color:var(--text-dim);font-size:11px">—</span>';
}
// Handle browser back/forward
window.addEventListener('popstate', function(e) {
if (e.state && e.state.view === 'detail') {
if (typeof FILE_DETAILS !== 'undefined') {
_renderDetail(e.state.path);
_switchView('detail');
}
} else if (e.state && e.state.view === 'files') {
if (typeof PROBLEM_FILES === 'undefined') return;
if (e.state.filesViewMode) {
_filesViewMode = e.state.filesViewMode;
const btnList = document.getElementById('btn-view-list');
const btnTree = document.getElementById('btn-view-tree');
if (btnList) btnList.classList.toggle('active', _filesViewMode === 'list');
if (btnTree) btnTree.classList.toggle('active', _filesViewMode === 'tree');
}
_renderFileList(e.state.category);
_switchView('files');
} else {
_switchView('list');
}
});
function escHtml(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
let _filesViewMode = 'list';
let _currentFilteredFiles = [];
function setFilesView(mode) {
_filesViewMode = mode;
document.getElementById('btn-view-list').classList.toggle('active', mode === 'list');
document.getElementById('btn-view-tree').classList.toggle('active', mode === 'tree');
document.getElementById('view-files-content').innerHTML =
mode === 'tree' ? buildTreeHtml(_currentFilteredFiles) : buildListHtml(_currentFilteredFiles);
}
function buildListHtml(files) {
if (!files.length) return '<div style="padding:32px;text-align:center;color:var(--text-dim);font-size:13px">No files found.</div>';
let html = '<table class="sq-table"><thead><tr><th>File</th><th>Type</th><th>Detail</th></tr></thead><tbody>';
files.forEach(f => {
const parts = [];
if (f.issueTotal) parts.push(f.issueTotal + ' issue' + (f.issueTotal !== 1 ? 's' : ''));
if (f.coverage != null) parts.push(f.coverage.toFixed(1) + '% cov');
if (f.duplicatedLinesDensity != null && f.duplicatedLinesDensity > 0) parts.push(f.duplicatedLinesDensity.toFixed(1) + '% dup');
const detail = parts.join(' · ') || '—';
const filename = f.path.split('/').pop();
const pathJs = f.path.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
html += '<tr class="file-row" data-path="' + escHtml(f.path) + '">';
html += '<td onclick="showDetail(\'' + pathJs + '\')">';
html += '<div class="sq-file-link">' + escHtml(filename) + '</div>';
html += '<div class="sq-file-path">' + escHtml(f.path) + '</div></td>';
html += '<td>' + renderFileBadges(f) + '</td>';
html += '<td class="sq-issues-count">' + escHtml(detail) + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
function buildTreeHtml(files) {
if (!files.length) return '<div style="padding:32px;text-align:center;color:var(--text-dim);font-size:13px">No files found.</div>';
const root = {};
files.forEach(f => {
const parts = f.path.split('/');
let node = root;
parts.forEach((part, i) => {
if (i === parts.length - 1) {
if (!node.__files) node.__files = [];
node.__files.push({name: part, file: f});
} else {
if (!node[part]) node[part] = {};
node = node[part];
}
});
});
function countFiles(n) {
let c = (n.__files || []).length;
Object.keys(n).filter(k => k !== '__files').forEach(k => { c += countFiles(n[k]); });
return c;
}
function renderNode(node, depth) {
let html = '';
const indent = depth * 16;
Object.keys(node).filter(k => k !== '__files').sort().forEach(folderName => {
const child = node[folderName];
const fc = countFiles(child);
html += '<details class="tree-folder"' + (depth === 0 ? ' open' : '') + '>';
html += '<summary style="padding-left:' + (12 + indent) + 'px">' +
'<span class="tree-arrow">▶</span>' +
'<span class="tree-folder-name">📁 ' + escHtml(folderName) + '</span>' +
'<span class="tree-folder-meta">' + fc + ' file' + (fc !== 1 ? 's' : '') + '</span>' +
'</summary>';
html += renderNode(child, depth + 1);
html += '</details>';
});
(node.__files || []).sort((a, b) => a.name.localeCompare(b.name)).forEach(({name, file}) => {
const pathJs = file.path.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const parts = [];
if (file.issueTotal) parts.push(file.issueTotal + ' issue' + (file.issueTotal !== 1 ? 's' : ''));
if (file.coverage != null) parts.push(file.coverage.toFixed(1) + '% cov');
if (file.duplicatedLinesDensity != null && file.duplicatedLinesDensity > 0) parts.push(file.duplicatedLinesDensity.toFixed(1) + '% dup');
const detail = parts.join(' · ') || '';
html += '<div class="tree-file-row" style="padding-left:' + (28 + indent) + 'px" onclick="showDetail(\'' + pathJs + '\')">';
html += '<span class="tree-file-name">📜 ' + escHtml(name) + '</span>';
html += '<span class="tree-file-badges">' + renderFileBadges(file) + '</span>';
if (detail) html += '<span class="tree-file-detail">' + escHtml(detail) + '</span>';
html += '</div>';
});
return html;
}
return '<div style="border:1px solid var(--border);border-radius:6px;overflow:hidden">' + renderNode(root, 0) + '</div>';
}
</script>
<script>
// Theme toggle
function setHljsTheme(theme) {
document.getElementById('hljs-light').disabled = (theme === 'dark');
document.getElementById('hljs-dark').disabled = (theme !== 'dark');
}
function toggleTheme() {
const html = document.documentElement;
const isLight = html.getAttribute('data-theme') === 'light';
const next = isLight ? 'dark' : 'light';
html.setAttribute('data-theme', next);
document.getElementById('themeIcon').innerHTML = isLight ? '☼' : '☾';
document.getElementById('themeLabel').textContent = isLight ? 'Light' : 'Dark';
setHljsTheme(next);
localStorage.setItem('dup-report-theme', next);
}
(function() {
const saved = localStorage.getItem('dup-report-theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
const icon = document.getElementById('themeIcon');
const label = document.getElementById('themeLabel');
if (icon && label) {
icon.innerHTML = theme === 'dark' ? '☼' : '☾';
label.textContent = theme === 'dark' ? 'Light' : 'Dark';
}
setHljsTheme(theme);
})();
// Syntax highlighting + line wrapping
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('code.language-dart').forEach(el => {
const mask = (el.dataset.hl || '').split(',');
const rawText = el.textContent;
const result = hljs.highlight(rawText, { language: 'dart', ignoreIllegals: true });
const lines = result.value.split('\n');
el.innerHTML = lines.map((line, idx) => {
const isHl = mask[idx] === '1';
return '<span class="code-line' + (isHl ? ' hl' : '') + '">' + (line || ' ') + '</span>';
}).join('');
el.classList.add('hljs');
});
});
// Tab switching
function switchMainTab(tab) {
closePromptModal();
document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
const sidebar = document.querySelector('.sidebar');
const hasSidebar = (tab === 'duplicates' || tab === 'coverage');
if (sidebar) {
sidebar.style.display = hasSidebar ? 'flex' : 'none';
document.querySelectorAll('.sidebar-section').forEach(s => s.classList.remove('active'));
const section = document.getElementById('sidebar-' + tab);
if (section) section.classList.add('active');
}
}
// Coverage sidebar tab switch
function switchCovTab(el, panel) {
const section = document.getElementById('sidebar-coverage');
section.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
section.querySelectorAll('.sidebar-panel').forEach(p => p.classList.remove('active'));
el.classList.add('active');
document.getElementById('panel-' + panel).classList.add('active');
}
// Coverage sidebar filter
function filterCovSidebar() {
const q = document.getElementById('covSidebarSearch').value.toLowerCase();
const section = document.getElementById('sidebar-coverage');
section.querySelectorAll('.tree-file').forEach(el => {
const title = (el.querySelector('.file-name').title || '').toLowerCase();
el.style.display = (!q || title.includes(q)) ? 'flex' : 'none';
});
section.querySelectorAll('.cov-list-item').forEach(el => {
const text = el.textContent.toLowerCase();
el.style.display = (!q || text.includes(q)) ? 'block' : 'none';
});
if (q) {
section.querySelectorAll('.folder-children').forEach(c => c.style.display = 'block');
section.querySelectorAll('.folder-toggle').forEach(t => t.classList.add('open'));
section.querySelectorAll('.folder-icon').forEach(i => i.innerHTML = '📂');
}
}
// Contextual Copilot prompts
let currentPromptId = null;
function openPromptModal(id) {
const prompt = PROMPTS_DATA[id];
if (!prompt) return;
currentPromptId = id;
const modal = document.getElementById('promptModal');
const title = document.getElementById('promptModalTitle');
const file = document.getElementById('promptModalFile');
const text = document.getElementById('promptModalText');
const copyBtn = document.getElementById('promptCopyBtn');
title.textContent = prompt.type === 'coverage' ? 'Coverage Copilot Prompt' : 'Duplicate Refactor Copilot Prompt';
file.textContent = prompt.file || '';
file.title = prompt.file || '';
text.textContent = prompt.text || '';
copyBtn.innerHTML = '📋 Copy for Copilot';
copyBtn.classList.remove('copied');
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
}
function closePromptModal() {
const modal = document.getElementById('promptModal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
currentPromptId = null;
}
document.addEventListener('click', function(e) {
const modal = document.getElementById('promptModal');
if (!modal || !modal.classList.contains('active')) return;
const tabAtPoint = Array.from(document.querySelectorAll('.main-tab')).find(tab => {
const rect = tab.getBoundingClientRect();
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
});
if (tabAtPoint) {
e.preventDefault();
e.stopPropagation();
closePromptModal();
tabAtPoint.click();
return;
}
if (e.target.closest('.prompt-modal')) return;
closePromptModal();
}, true);
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePromptModal();
});
function copyPrompt(id, btn) {
const text = (PROMPTS_DATA[id] || {}).text || '';
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => flashCopied(btn));
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
flashCopied(btn);
}
}
function flashCopied(btn) {
const orig = btn.innerHTML;
btn.innerHTML = '✓ Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('copied'); }, 1800);
}
function showCoverageTable(options) {
const opts = options || {};
const tableView = document.getElementById('coverageTableView');
const detailView = document.getElementById('coverageDetailView');
if (tableView) tableView.classList.remove('hidden');
if (detailView) detailView.classList.add('hidden');
document.querySelectorAll('.cov-row.active, .cov-tree-file.active, .cov-list-item.active').forEach(el => el.classList.remove('active'));
if (opts.scroll !== false && tableView) tableView.scrollIntoView({ block: 'start' });
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function openCoverageDetail(filePath) {
const detail = COVERAGE_DETAILS[filePath];
if (!detail) return;
const tableView = document.getElementById('coverageTableView');
const detailView = document.getElementById('coverageDetailView');
const sourceWrap = document.getElementById('covSourceWrap');
if (!tableView || !detailView || !sourceWrap) return;
document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const coverageTab = document.getElementById('tab-coverage');
if (coverageTab) coverageTab.classList.add('active');
const tabs = Array.from(document.querySelectorAll('.main-tab'));
const covTabButton = tabs.find(t => t.textContent.includes('Coverage'));
if (covTabButton) covTabButton.classList.add('active');
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.style.display = 'flex';
document.querySelectorAll('.sidebar-section').forEach(s => s.classList.remove('active'));
const section = document.getElementById('sidebar-coverage');
if (section) section.classList.add('active');
}
tableView.classList.add('hidden');
detailView.classList.remove('hidden');
setText('covDetailFile', detail.path || filePath);
const title = document.getElementById('covDetailFile');
if (title) title.title = detail.path || filePath;
const missed = Math.max((detail.total || 0) - (detail.hit || 0), 0);
setText('covDetailPct', 'Coverage: ' + Number(detail.pct || 0).toFixed(1) + '%');
setText('covDetailHit', (detail.hit || 0) + ' covered / ' + (detail.total || 0) + ' executable lines');
setText('covDetailMiss', missed + ' uncovered executable line' + (missed === 1 ? '' : 's'));
const promptBtn = document.getElementById('covDetailPromptBtn');
if (promptBtn) {
promptBtn.onclick = function(e) { e.stopPropagation(); openPromptModal(detail.promptId); };
}
sourceWrap.textContent = '';
const lineHits = detail.lines || {};
(detail.source || []).forEach((line, idx) => {
const lineNumber = idx + 1;
const key = String(lineNumber);
const executable = Object.prototype.hasOwnProperty.call(lineHits, key);
const hits = executable ? Number(lineHits[key]) : 0;
const row = document.createElement('div');
row.className = 'cov-source-line ' + (executable ? (hits > 0 ? 'covered' : 'uncovered') : 'neutral');
if (executable) row.title = hits + ' hit' + (hits === 1 ? '' : 's');
const gutter = document.createElement('span');
gutter.className = 'ln';
gutter.textContent = executable ? (lineNumber + ' ' + (hits > 0 ? '✓' : '✕')) : String(lineNumber);
const src = document.createElement('span');
src.className = 'src';
src.textContent = line || ' ';
row.appendChild(gutter);
row.appendChild(src);
sourceWrap.appendChild(row);
});
document.querySelectorAll('.cov-row.active, .cov-tree-file.active, .cov-list-item.active').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.cov-row').forEach(row => {
const fileCell = row.querySelector('.cov-file');
if (fileCell && fileCell.textContent === filePath) row.classList.add('active');
});
document.querySelectorAll('.cov-tree-file, .cov-list-item').forEach(el => {
const label = el.querySelector('.file-name, .list-file-name');
if (label && label.title === filePath) el.classList.add('active');
});
const main = document.querySelector('.main');
if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
}
// Coverage table filter
function filterCovTable() {
const q = document.getElementById('covSearch').value.toLowerCase();
const rows = document.querySelectorAll('#covBody tr');
let visible = 0;
rows.forEach(row => {
const text = row.textContent.toLowerCase();
const show = !q || text.includes(q);
row.style.display = show ? '' : 'none';
if (show) visible++;
});
const el = document.getElementById('covCount');
if (el) el.textContent = 'Showing ' + visible + ' files';
}
let covSortCol = -1, covSortAsc = true;
function sortCovTable(col) {
const tbody = document.getElementById('covBody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
if (covSortCol === col) { covSortAsc = !covSortAsc; } else { covSortCol = col; covSortAsc = true; }
rows.sort((a, b) => {
let va = a.cells[col].textContent.trim();
let vb = b.cells[col].textContent.trim();
const na = parseFloat(va.replace('%',''));
const nb = parseFloat(vb.replace('%',''));
if (!isNaN(na) && !isNaN(nb)) return covSortAsc ? na - nb : nb - na;
return covSortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
});
rows.forEach(r => tbody.appendChild(r));
}
function toggleClone(i) {
const body = document.getElementById('body-' + i);
const icon = document.getElementById('icon-' + i);
if (body.style.display === 'none') {
body.style.display = 'block';
icon.classList.add('open');
} else {
body.style.display = 'none';
icon.classList.remove('open');
}
}
function expandAll() {
document.querySelectorAll('.clone-card').forEach(c => {
if (c.style.display !== 'none') {
c.querySelector('.clone-body').style.display = 'block';
c.querySelector('.toggle-icon').classList.add('open');
}
});
}
function collapseAll() {
document.querySelectorAll('.clone-body').forEach(b => b.style.display = 'none');
document.querySelectorAll('.toggle-icon').forEach(i => i.classList.remove('open'));
}
function filterClones() {
const q = document.getElementById('mainSearch').value.toLowerCase();
let visible = 0;
document.querySelectorAll('.clone-card').forEach(card => {
if (card.dataset.hidden === 'true') return;
const text = card.textContent.toLowerCase();
const show = !q || text.includes(q);
card.style.display = show ? 'block' : 'none';
if (show) visible++;
});
document.getElementById('resultCount').textContent = 'Showing ' + visible + ' clones';
}
function renderRelPanel(filePath) {
const rels = FILE_RELATIONS[filePath];
const panel = document.getElementById('relPanel');
if (!rels || Object.keys(rels).length === 0) {
panel.classList.remove('active');
return;
}
const fname = filePath.split('/').pop();
const relFiles = Object.keys(rels);
const totalLines = relFiles.reduce((s, k) => s + rels[k].lines, 0);
document.getElementById('relFileName').textContent = fname;
document.getElementById('relFileName').title = filePath;
document.getElementById('relStat').textContent = relFiles.length + ' related file' + (relFiles.length > 1 ? 's' : '') + ' · ' + totalLines + ' duplicate lines';
const tbody = document.getElementById('relBody');
tbody.innerHTML = '';
relFiles.forEach(other => {
const info = rels[other];
const otherName = other.split('/').pop();
const tr = document.createElement('tr');
tr.onclick = function() {
// Filter to show only clones between these two files
document.querySelectorAll('.clone-card').forEach(card => {
const idx = parseInt(card.dataset.index);
if (info.clones.includes(idx)) {
card.style.display = 'block';
card.dataset.hidden = 'false';
card.classList.add('highlight');
} else {
card.style.display = 'none';
card.dataset.hidden = 'true';
card.classList.remove('highlight');
}
});
document.getElementById('resultCount').textContent = 'Showing ' + info.clones.length + ' of ' + TOTAL + ' clones (' + fname + ' vs ' + otherName + ')';
};
const fileTd = document.createElement('td');
const fileSpan = document.createElement('span');
fileSpan.className = 'rel-file-link';
fileSpan.title = other;
fileSpan.textContent = otherName;
fileTd.appendChild(fileSpan);
const countTd = document.createElement('td');
const countSpan = document.createElement('span');
countSpan.className = 'rel-count';
countSpan.textContent = info.clones.length;
countTd.appendChild(countSpan);
const linesTd = document.createElement('td');
const linesSpan = document.createElement('span');
linesSpan.className = 'rel-lines';
linesSpan.textContent = info.lines;
linesTd.appendChild(linesSpan);
tr.appendChild(fileTd);
tr.appendChild(countTd);
tr.appendChild(linesTd);
tbody.appendChild(tr);
});
panel.classList.add('active');
}
function filterByFile(filePath, cloneIds) {
// Clear highlights
document.querySelectorAll('.tree-file.active, .list-file.active').forEach(el => el.classList.remove('active'));
// Highlight clicked item
event.currentTarget.classList.add('active');
document.querySelectorAll('.clone-card').forEach(card => {
const idx = parseInt(card.dataset.index);
if (cloneIds.includes(idx)) {
card.style.display = 'block';
card.dataset.hidden = 'false';
card.classList.add('highlight');
} else {
card.style.display = 'none';
card.dataset.hidden = 'true';
card.classList.remove('highlight');
}
});
renderRelPanel(filePath);
const fname = filePath.split('/').pop();
document.getElementById('filterName').textContent = fname;
document.getElementById('filterName').title = filePath;
document.getElementById('filterTag').classList.add('active');
document.getElementById('resultCount').textContent = 'Showing ' + cloneIds.length + ' of ' + TOTAL + ' clones';
document.getElementById('mainSearch').value = '';
}
function showAll() {
document.querySelectorAll('.clone-card').forEach(card => {
card.style.display = 'block';
card.dataset.hidden = 'false';
card.classList.remove('highlight');
});
document.querySelectorAll('.tree-file.active, .list-file.active').forEach(el => el.classList.remove('active'));
document.getElementById('filterTag').classList.remove('active');
document.getElementById('relPanel').classList.remove('active');
document.getElementById('resultCount').textContent = 'Showing ' + TOTAL + ' clones';
document.getElementById('mainSearch').value = '';
}
function switchTab(tab) {
document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.sidebar-panel').forEach(p => p.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('panel-' + tab).classList.add('active');
}
function toggleFolder(row) {
const toggle = row.querySelector('.folder-toggle');
const icon = row.querySelector('.folder-icon');
toggle.classList.toggle('open');
const children = row.parentElement.querySelector('.folder-children');
const isOpen = children.style.display !== 'none';
children.style.display = isOpen ? 'none' : 'block';
icon.innerHTML = isOpen ? '📁' : '📂';
}
function filterSidebar() {
const q = document.getElementById('sidebarSearch').value.toLowerCase();
// Filter tree files
document.querySelectorAll('.tree-file').forEach(el => {
const name = el.querySelector('.file-name').textContent.toLowerCase();
const title = (el.querySelector('.file-name').title || '').toLowerCase();
el.style.display = (!q || name.includes(q) || title.includes(q)) ? 'flex' : 'none';
});
// Filter list files
document.querySelectorAll('.list-file').forEach(el => {
const text = el.textContent.toLowerCase();
el.style.display = (!q || text.includes(q)) ? 'block' : 'none';
});
if (q) {
document.querySelectorAll('.folder-children').forEach(c => c.style.display = 'block');
document.querySelectorAll('.folder-toggle').forEach(t => t.classList.add('open'));
document.querySelectorAll('.folder-icon').forEach(i => i.innerHTML = '📂');
}
}
let sortAsc = false;
function sortByLines() {
const list = document.getElementById('cloneList');
const cards = Array.from(list.querySelectorAll('.clone-card'));
sortAsc = !sortAsc;
cards.sort((a, b) => {
const la = parseInt(a.dataset.lines);
const lb = parseInt(b.dataset.lines);
return sortAsc ? la - lb : lb - la;
});
cards.forEach(c => list.appendChild(c));
document.getElementById('sortBtn').innerHTML = 'Sort: Lines ' + (sortAsc ? '↑' : '↓');
}
</script>
</body>
</html>
# Quality Check
Local and CI quality gate for the Flutter monorepo. **SonarScanner and SonarQube are the source of truth** for analysis, duplication, coverage metrics, and the quality gate result. The local script runs the same scanner engine used in CI, then writes a small HTML report and `summary.json` from SonarQube API results.
## Files
```text
scripts/quality/
├── setup.sh # First-run setup for portable local SonarQube/scanner (no Homebrew/Docker/admin)
├── local-quality.sh # Recommended entrypoint; defaults to portable local SonarQube
├── portable-local-sonar.sh # Portable local SonarQube runner used by local-quality.sh
├── quality-check.sh # Core engine: runs SonarScanner, then generates local reports
├── generate-report.py # Reads scanner task metadata and SonarQube API metrics
├── template.html # Self-contained HTML/CSS/JS template for quality-report.html
├── DESIGN.md # Design tokens and UI spec — start here for visual changes
└── README.md
```
Output goes to `reports/quality/`:
| File | Description |
|------|-------------|
| `quality-report.html` | Local HTML summary plus Full Quality Map by folder/file from SonarQube APIs |
| `summary.json` | Machine-readable quality gate result and top problem files for CI/local automation |
## Prerequisites
Recommended local flow uses portable zip-based tooling and does **not** require Homebrew, Docker, Colima, sudo, or admin access.
Required for setup/run:
- `curl`
- `unzip`
- `python3` — stdlib only, no extra packages needed
- Java **JDK 17** on `PATH`, `JAVA_HOME`, or `PORTABLE_JAVA_HOME` (SonarQube 10.7 is run with Java 17; on macOS the scripts prefer `/usr/libexec/java_home -v 17`)
Optional coverage file generated before running the check:
- `reports/coverage/clean_combined_lcov.info` (auto-copied from `coverage/lcov.info`)
Remote/team SonarQube mode also needs:
- **SonarQube/SonarCloud access** via `SONAR_HOST_URL` or `sonar.host.url`
- **Authentication token** in `SONAR_TOKEN`
## Recommended local flow
### 1) First-run setup
```bash
bash scripts/quality/setup.sh
```
This creates `~/.cache/top-flutter-quality/`, downloads/extracts the configured SonarQube and sonar-scanner zips, installs a valid cache/vendor/mirror-provided sonar-flutter plugin into the portable SonarQube, verifies `sonar.sh`, `sonar-scanner`, and plugin placement, and prints the next command. SonarQube/scanner downloads are cached for later runs.
Environment overrides are shared with `portable-local-sonar.sh`:
```bash
TOP_FLUTTER_QUALITY_CACHE=/tmp/top-flutter-quality bash scripts/quality/setup.sh
SONARQUBE_PORTABLE_VERSION=10.7.0.96327 bash scripts/quality/setup.sh
SONAR_SCANNER_PORTABLE_VERSION=6.2.1.4610 bash scripts/quality/setup.sh
SONAR_FLUTTER_PLUGIN_VERSION=0.5.2 bash scripts/quality/setup.sh
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=0 bash scripts/quality/setup.sh # skip plugin install
```
Use `bash scripts/quality/setup.sh --help` to see all setup overrides without downloading anything.
### 2) Daily run
```bash
bash scripts/quality/local-quality.sh
```
`local-quality.sh` now defaults to portable local SonarQube:
1. Uses cached SonarQube and sonar-scanner under `~/.cache/top-flutter-quality/`.
2. Configures the portable SonarQube bind address to `127.0.0.1:${SONAR_LOCAL_PORT:-19102}` (default port `19102`, override with `SONAR_LOCAL_PORT`).
3. Ensures a valid cache/vendor/mirror-provided sonar-flutter plugin is installed under SonarQube `extensions/plugins/`.
4. Starts SonarQube from the zip using its bundled launcher — no Docker/Colima.
5. Creates/caches and validates a portable local token in `~/.sonarqube-local/top-flutter-portable-<port>.token` when `SONAR_TOKEN` is not provided (override with `SONAR_LOCAL_TOKEN_FILE`).
6. Runs the same `quality-check.sh` engine with the portable sonar-scanner first on `PATH`.
7. Writes `reports/quality/summary.json` and `reports/quality/quality-report.html`.
8. Stops SonarQube unless you keep it running.
Common examples:
```bash
# Keep SonarQube running after the scan (inspect dashboard at http://localhost:19102)
bash scripts/quality/local-quality.sh --keep-sonar-local
# Minimal focus: coverage >= 80%, duplication <= 3%, code smells <= 0
bash scripts/quality/local-quality.sh --minimal-focus
bash scripts/quality/local-quality.sh --focus-minimal # alias
# Point to a different JDK without changing system settings
PORTABLE_JAVA_HOME=~/.cache/jdk/jdk-17 bash scripts/quality/local-quality.sh
# Override cache location
TOP_FLUTTER_QUALITY_CACHE=/tmp/sq-cache bash scripts/quality/local-quality.sh
# Override the portable SonarQube port if 19102 is already in use
SONAR_LOCAL_PORT=9103 bash scripts/quality/local-quality.sh
```
`--portable-local` is still accepted as a backward-compatible no-op, but it is no longer needed.
**Options:**
| Flag | Default | Description |
|------|---------|-------------|
| `--keep-sonar-local` | off | Keep portable local SonarQube running after the scan |
| `--keep-server` | off | Alias for `--keep-sonar-local` |
| `-d, --dup-threshold N` | `3` | Max allowed duplication percentage used in local summary fields |
| `-c, --cov-threshold N` | `80` | Min required coverage percentage used in local summary fields |
| `--focus AREAS` | — | Focus report on `coverage,duplication,smell` — gate derived locally from those three thresholds; BUG/VULN/HOTSPOT hidden |
| `--minimal-focus` | — | Shorthand for `--focus coverage,duplication,smell` |
| `--focus-minimal` | — | Alias for `--minimal-focus` |
| `--smell-threshold N` | `0` | Max code smells allowed when `--focus` is active |
| `--legacy-local` | off | Optional legacy Homebrew + Colima/Docker local mode |
| `-h, --help` | — | Show help |
Portable local SonarQube installs the Flutter/Dart analyzer plugin by default, matching legacy mode:
```bash
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=1
SONAR_FLUTTER_PLUGIN_VERSION=0.5.2
SONAR_FLUTTER_PLUGIN_VENDOR_JAR=scripts/quality/vendor/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar
SONAR_FLUTTER_PLUGIN_JAR=~/.cache/top-flutter-quality/plugins/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar
# Optional only when explicitly set to a trusted internal/non-default mirror:
SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL=
```
Plugin acquisition order is: valid cache jar, valid repo-local vendor jar, then an explicit `SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL` mirror only if you set it. There is no default plugin download URL. Place a valid plugin jar at `scripts/quality/vendor/sonar-flutter-plugin-<version>.jar` (or set `SONAR_FLUTTER_PLUGIN_VENDOR_JAR` to another path) before running setup/local quality. If the vendor jar exists but is invalid, the script fails instead of silently downloading, so a broken packaged artifact is visible.
Set `SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=0` only if you are intentionally running without Dart/Flutter-specific Sonar rules.
## Direct usage (advanced)
```bash
# Whole project against a remote/team SonarQube host
SONAR_HOST_URL=https://sonar.example.com SONAR_TOKEN=*** SONAR_PROJECT_KEY=top-flutter \
bash scripts/quality/quality-check.sh
# Custom fallback thresholds for the local summary
bash scripts/quality/quality-check.sh -d 5 -c 70
```
## Focus / Minimal mode
Use focus mode to narrow the quality gate to only three areas — **Coverage**, **Duplication**, and **Code Smell** — and hide BUG/VULN/HOTSPOT issues in the HTML report. The gate is derived locally from those three thresholds, not from SonarQube's configured gate.
```bash
# Minimal focus through the recommended local entrypoint
bash scripts/quality/local-quality.sh --minimal-focus
# Same with a looser smell threshold for a legacy package
bash scripts/quality/local-quality.sh --minimal-focus --smell-threshold 20 packages/features/home
# Direct engine usage against an already configured SonarQube host
bash scripts/quality/quality-check.sh --focus coverage,duplication,smell --smell-threshold 5
```
In focus mode the HTML report shows:
- **FOCUS MODE** badge in the header
- 3-card metrics row: Coverage · Duplication · Code Smells
- "Focus Gate Passed/Failed" banner (not the Sonar gate)
- Gate conditions table with the three local thresholds
- Only CODE_SMELL pills visible in the problem-file rows
`summary.json` gains three extra fields when focus mode is active:
```json
{
"gate": "passed",
"gateSource": "focus",
"focusMode": true,
"codeSmells": { "count": 3, "threshold": 5, "passed": true }
}
```
`gateSource` is `"focus"` (local threshold check) or `"sonar"` (Sonar's configured quality gate). The raw `qualityGateStatus` from SonarQube is still present and unchanged.
## Legacy Homebrew/Docker local mode (optional)
The previous local path is still available, but it is no longer the recommended default. Use it only if you specifically want Colima/Docker-managed SonarQube:
```bash
bash scripts/quality/local-quality.sh --legacy-local
```
Legacy mode requires Homebrew and will install missing packages (`colima`, `docker`, `sonar-scanner`, `jq`, `python`) via `brew install`. You can also call the core engine directly:
```bash
bash scripts/quality/quality-check.sh --sonar-local
```
Legacy flow:
```text
1. Start Colima if Docker is not already available
2. Create/start sonarqube-local container if needed
3. Install a valid cache/vendor/mirror-provided sonar-flutter plugin into the local container unless disabled
4. Wait until http://localhost:9000/api/system/status = UP
5. Create/cache a local token in ~/.sonarqube-local/top-flutter.token when SONAR_TOKEN is not provided
6. Run sonar-scanner against localhost only
7. Generate reports/quality/summary.json and reports/quality/quality-report.html
8. Stop the SonarQube container and Colima if this script started them
```
The legacy local mode hard-guards the host URL and refuses anything except:
```text
http://localhost:*
http://127.0.0.1:*
```
This prevents accidental scans against the team SonarQube server.
### Legacy resource knobs
Defaults are conservative for a resource-limited Mac:
```bash
COLIMA_CPU=2
COLIMA_MEMORY=4
COLIMA_DISK=20
SONAR_LOCAL_PORT=9000
SONAR_LOCAL_IMAGE=sonarqube:community
SONAR_LOCAL_CONTAINER=sonarqube-local
SONAR_LOCAL_PROJECT_KEY=top-flutter-local
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=1
SONAR_FLUTTER_PLUGIN_VERSION=0.5.2
```
Examples:
```bash
# Try a slightly smaller VM. If SonarQube fails to start, go back to 4GB.
COLIMA_MEMORY=3 COLIMA_DISK=15 bash scripts/quality/local-quality.sh --legacy-local
# Keep the Docker-backed local server open after the scan.
bash scripts/quality/local-quality.sh --legacy-local --keep-sonar-local
# Equivalent env switch.
SONAR_LOCAL_KEEP_RUNNING=1 bash scripts/quality/local-quality.sh --legacy-local
```
First legacy run will pull `sonarqube:community`, require a valid cache/vendor/mirror-provided `sonar-flutter-plugin`, and create Docker volumes. Later runs reuse the local container/volumes.
## Coverage setup
The script does not run tests. Generate coverage first when coverage should be included in the scanner analysis.
```bash
bash scripts/quality/local-quality.sh # auto-discovers coverage/lcov.info
```
## Quality gate
`summary.json` schema:
```json
{
"scope": "packages/features/create_rm",
"source": "sonarqube",
"projectKey": "top-flutter",
"dashboardUrl": "https://sonar.example.com/dashboard?id=top-flutter",
"analysisId": "...",
"duplicate": { "percentage": 4.73, "threshold": 3, "passed": false },
"coverage": { "percentage": 75.42, "threshold": 80, "passed": false, "reportPath": ".../lcov.info" },
"qualityGateStatus": "ERROR",
"gate": "failed",
"gateSource": "sonar",
"focusMode": false,
"codeSmells": { "count": 12, "threshold": 0, "passed": false }
}
```
- `gate` is `"passed"` or `"failed"`. In default mode it mirrors SonarQube's gate status. In focus mode it is computed locally from the three focus thresholds.
- `gateSource` is `"sonar"` (default) or `"focus"` (when `--focus`/`--minimal-focus` is used).
- `qualityGateStatus` is always the raw SonarQube gate status string and is never overridden.
- `duplicate` and `coverage` percentages come from SonarQube measures, not a separate local analyzer.
- `problemMap` contains a compact file/folder quality map from SonarQube APIs:
- unresolved issues from `/api/issues/search`
- duplicated files from `/api/measures/component_tree`
- coverage gaps from `/api/measures/component_tree`
- Exit code is `1` when the SonarQube quality gate fails.
## Configuration
Analysis scope, duplication behavior, coverage input, and exclusions live in the repository root `sonar-project.properties`. Keep CI and local runs aligned by changing that file rather than adding separate local quality rules.
## UI customisation
The generated `quality-report.html` is fully self-contained — all CSS and JS are inlined, no build step required.
> **Current source of truth for the generated HTML: `generate-report.py`.**
> The `render_html()` function in `generate-report.py` owns the inline CSS, JS, and HTML structure written to `quality-report.html`. `template.html` is a reference/design document and is **not** used by `quality-check.sh` at runtime. To wire `template.html` into generation, update `render_html()` to read and substitute into it.
Key points:
- **Dark/light mode** — the report ships with a toggle button (top-right, `class="theme-toggle"`). Default follows `prefers-color-scheme`; preference saved to `localStorage` key `dup-report-theme`. CSS variables for both themes live in `render_html()` `:root` (dark default) and `[data-theme="light"]` blocks.
- **CSS variables** — layout colors are tokens (`--bg`, `--surface`, `--surface2`, `--border`, `--text`, `--muted`, `--green`, `--red`, `--blue`, `--yellow`, `--cyan`, `--gate-pass-bg`, `--gate-fail-bg`). Badge `.pill` variants have `[data-theme="light"]` overrides.
- **`template.html` / `DESIGN.md`** — document design tokens and a richer sidebar/main layout that can replace the current inline HTML when `render_html()` is updated to use it.
sonar.projectKey=top-flutter
# Config file report path
sonar.flutter.coverage.reportPath=reports/coverage/lcov.info
# Encoding of the source code. Default is default system encoding.
sonar.sourceEncoding=UTF-8
# Use existing options to perform dartanalyzer analysis
sonar.dart.analyzer.options.override=false
# Set dart pattern
sonar.lang.patterns.dart=**/*.dart
# Scan dart files only
sonar.inclusions=**/*.dart
# Excluding Files
sonar.exclusions=web/**/*.js/,apps/**/**/**/*.js,packages/**/**/*.js,packages/**/**/test/**/*.mocks.dart,packages/**/test/**/*.mocks.dart,apps/tep/lib/**/*.g.dart,apps/tep/lib/**/**/*.g.dart,apps/tep/android/app/src/**/google-services.json,apps/tep/lib/**/**/**/*.g.dart,apps/tep/lib/**/*.config.dart,apps/tep_cc/lib/**/*.g.dart,apps/tep_cc/lib/**/**/*.g.dart,apps/tep_cc/android/app/src/**/google-services.json,apps/tep_cc/lib/**/**/**/*.g.dart,apps/tep_cc/lib/**/*.config.dart,packages/features/feature_template/**,packages/core/application/**,packages/core/configurations/**,packages/core/constants/**,packages/core/enum/**,packages/core/firebase/**,packages/core/localization/**,packages/core/log/**,packages/core/route/**,packages/core/ui/**,packages/core/utils/**,packages/core/widgets/**,packages/core/**,packages/**/**/lib/**/*.g.dart,packages/**/**/lib/**/**/*.g.dart,packages/**/**/lib/**/**/**/*.g.dart,packages/**/**/lib/**/*.config.dart,packages/**/**/libs/**/**/*.java,packages/**/**/java/**/**/*.java,packages/**/**/kotlin/**/**/*.java,packages/design_system/lib/**,packages/design_system/lib/**/**,packages/design_system/lib/**/**/**,**/test/**,packages/**/**/lib/**/*.freezed.dart,packages/**/**/lib/**/**/*.freezed.dart,packages/**/**/lib/**/**/**/*.freezed.dart,packages/**/**/lib/**/**/**/**/*.freezed.dart,packages/**/**/lib/widget/**,packages/**/**/lib/widgets/**,packages/**/**/lib/ui/widget/**,packages/**/**/lib/ui/widgets/**,packages/**/**/lib/ui/**/widget/**,packages/**/**/lib/ui/**/widgets/**,packages/**/**/lib/src/ui/**,packages/**/**/lib/src/widget/**,packages/**/**/lib/src/widgets/**,packages/**/**/lib/src/ui/widget/**,packages/**/**/lib/src/ui/widgets/**,packages/**/**/lib/src/ui/**/widget/**,packages/**/**/lib/src/ui/**/widgets/**,packages/**/**/lib/**/ui/**,packages/plugins/face_recognition/**
sonar.coverage.exclusions=packages/**/**/lib/constants/**,packages/**/**/lib/presentation/**/*selector.dart,packages/**/**/lib/data/model/**,packages/**/**/lib/data/model/**/**,packages/**/**/lib/data/model/**/**/**,app/assets/js/base_webview_injector.js,packages/**/**/lib/presentation/**/*listener.dart,packages/**/**/lib/src/constants/**,packages/**/**/lib/src/presentation/**/*selector.dart,packages/**/**/lib/src/data/model/**,packages/**/**/lib/src/data/model/**/**,packages/**/**/lib/src/data/model/**/**/**,app/assets/js/base_webview_injector.js,packages/**/**/lib/src/presentation/**/*listener.dart,packages/**/common_application/lib/domain/mapper/additional_mapper/**,scripts/**
sonar.cpd.dart.ignoreImports=true
sonar.html.file.suffixes=