From 30f451d80ca27dd68522784dc4636d1729758de0 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 19 May 2026 17:17:41 -0500 Subject: [PATCH] feat(sonar): Add SonarQube static analysis and code coverage setup Establishes tooling to systematically analyze and address technical debt. This includes: - `scan-sonar.ps1`: An orchestration script for local SonarQube scans with coverage. - `Directory.Build.props`: Integrates SonarAnalyzer.CSharp for static analysis during build. - `coverlet.runsettings`: Configures code coverage collection using Coverlet. - `.claude/settings.local.json`: Adds permissions for AI to query SonarQube and local dev status. --- .claude/settings.local.json | 19 ++++++++++++++ Directory.Build.props | 10 +++++++ coverlet.runsettings | 14 ++++++++++ scan-sonar.ps1 | 52 +++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 Directory.Build.props create mode 100644 coverlet.runsettings create mode 100644 scan-sonar.ps1 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3970ede --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "PowerShell($url = 'https://snrqbe.bermudalamb.synology.me'; $tok = 'squ_49ed6268ca76f64072ff5d6a98ab21bdc352a3ef'; $pair = \"${tok}:\"; $b64 = [Convert]::ToBase64String\\([Text.Encoding]::ASCII.GetBytes\\($pair\\)\\); $h = @{ Authorization = \"Basic $b64\" }; $v = Invoke-WebRequest -Uri \"$url/api/server/version\" -Headers $h -SkipHttpErrorCheck; \"Version: $\\($v.Content\\)\"; $s = Invoke-WebRequest -Uri \"$url/api/system/status\" -Headers $h -SkipHttpErrorCheck; \"Status: $\\($s.Content\\)\")", + "PowerShell($url = 'https://snrqbe.bermudalamb.synology.me'; $tok = 'squ_49ed6268ca76f64072ff5d6a98ab21bdc352a3ef'; $b64 = [Convert]::ToBase64String\\([Text.Encoding]::ASCII.GetBytes\\(\"${tok}:\"\\)\\); $h = @{ Authorization = \"Basic $b64\" }; $p = Invoke-RestMethod -Uri \"$url/api/projects/search?ps=100\" -Headers $h; \"Projects \\($\\($p.paging.total\\)\\):\"; $p.components | ForEach-Object { \" - $\\($_.key\\) [$\\($_.name\\)] visibility=$\\($_.visibility\\) lastAnalysis=$\\($_.lastAnalysisDate\\)\" })", + "PowerShell($url='https://snrqbe.bermudalamb.synology.me'; $tok='squ_49ed6268ca76f64072ff5d6a98ab21bdc352a3ef'; $b64=[Convert]::ToBase64String\\([Text.Encoding]::ASCII.GetBytes\\(\"${tok}:\"\\)\\); $h=@{Authorization=\"Basic $b64\"}; $p='sql-utilities'; $iv=Invoke-RestMethod -Uri \"$url/api/issues/search?componentKeys=$p&resolved=false&ps=1&facets=severities,types,rules,files\" -Headers $h; \"=== Severity ===\"; \\($iv.facets | ? property -eq severities\\).values | Format-Table val, count -AutoSize; \"=== Type ===\"; \\($iv.facets | ? property -eq types\\).values | Format-Table val, count -AutoSize; \"=== Top 15 rules ===\"; \\($iv.facets | ? property -eq rules\\).values | Select-Object -First 15 | Format-Table val, count -AutoSize; \"=== Top 15 files ===\"; \\($iv.facets | ? property -eq files\\).values | Select-Object -First 15 | Format-Table val, count -AutoSize; \"Total open issues: $\\($iv.total\\)\")", + "PowerShell($url='https://snrqbe.bermudalamb.synology.me'; $tok='squ_49ed6268ca76f64072ff5d6a98ab21bdc352a3ef'; $b64=[Convert]::ToBase64String\\([Text.Encoding]::ASCII.GetBytes\\(\"${tok}:\"\\)\\); $h=@{Authorization=\"Basic $b64\"}; $p='sql-utilities'; \"=== BLOCKER + CRITICAL \\(showing rule + file\\) ===\"; $iv=Invoke-RestMethod -Uri \"$url/api/issues/search?componentKeys=$p&resolved=false&severities=BLOCKER,CRITICAL&ps=50\" -Headers $h; $iv.issues | Group-Object rule | Sort-Object Count -Descending | ForEach-Object { \"$\\($_.Count\\) x $\\($_.Name\\)\" }; \"\"; \"=== S3776 \\(cognitive complexity\\) hotspots ===\"; $s=Invoke-RestMethod -Uri \"$url/api/issues/search?componentKeys=$p&resolved=false&rules=csharpsquid:S3776&ps=20\" -Headers $h; $s.issues | ForEach-Object { \"$\\($_.component\\) line $\\($_.line\\) - $\\($_.message\\)\" })", + "Bash(dotnet test *)", + "PowerShell($url='https://snrqbe.bermudalamb.synology.me'; $tok='squ_49ed6268ca76f64072ff5d6a98ab21bdc352a3ef'; $b64=[Convert]::ToBase64String\\([Text.Encoding]::ASCII.GetBytes\\(\"${tok}:\"\\)\\); $h=@{Authorization=\"Basic $b64\"}; $p='sql-utilities'; $iv=Invoke-RestMethod -Uri \"$url/api/issues/search?componentKeys=$p&resolved=false&severities=BLOCKER,CRITICAL&rules=csharpsquid:S2365,csharpsquid:S927,csharpsquid:S2696,csharpsquid:S3875,csharpsquid:S4487&ps=50\" -Headers $h; \"Total: $\\($iv.total\\)\"; \"\"; $iv.issues | Sort-Object rule, component | ForEach-Object { $f = $_.component -replace '^sql-utilities:',''; \"[$\\($_.severity\\)] $\\($_.rule\\)`n $\\($f\\):$\\($_.line\\)`n $\\($_.message\\)`n\" })", + "Bash(git -C C:/gitea/sql-utilities status --short)", + "Bash(git -C C:/gitea/sql-utilities log --oneline -3)", + "Bash(git *)", + "PowerShell(git *)", + "Bash(dotnet build *)", + "Bash(command -v gh)", + "Bash(command -v tea)" + ] + } +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6bfd7b9 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..3df384b --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,14 @@ + + + + + + + opencover + [*.Tests]*,[*.Tests.*]* + **/bin/**/*.cs,**/obj/**/*.cs + + + + + diff --git a/scan-sonar.ps1 b/scan-sonar.ps1 new file mode 100644 index 0000000..5c425bb --- /dev/null +++ b/scan-sonar.ps1 @@ -0,0 +1,52 @@ +#!/usr/bin/env pwsh +# Runs a local SonarQube scan with coverage. +# Requires: $env:SONAR_TOKEN (and optionally $env:SONAR_HOST_URL). + +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +if (-not $env:SONAR_TOKEN) { + throw "Set `$env:SONAR_TOKEN before running (generate at /account/security)." +} + +$sonarHost = if ($env:SONAR_HOST_URL) { $env:SONAR_HOST_URL } else { 'https://snrqbe.bermudalamb.synology.me' } +$projectKey = 'sql-utilities' +$solution = 'Strata.SqlTools.QueryBreakdown.sln' + +if (-not (Get-Command dotnet-sonarscanner -ErrorAction SilentlyContinue)) { + Write-Host "Installing dotnet-sonarscanner..." -ForegroundColor Cyan + dotnet tool install --global dotnet-sonarscanner +} + +Write-Host "Cleaning previous coverage artifacts..." -ForegroundColor Cyan +Get-ChildItem -Path tests -Directory -Filter TestResults -Recurse -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + +Write-Host "sonarscanner begin..." -ForegroundColor Cyan +dotnet sonarscanner begin ` + /k:$projectKey ` + /d:sonar.host.url=$sonarHost ` + /d:sonar.token=$env:SONAR_TOKEN ` + /d:sonar.cs.opencover.reportsPaths="tests/**/TestResults/**/coverage.opencover.xml" ` + /d:sonar.exclusions="**/bin/**,**/obj/**" ` + /d:sonar.coverage.exclusions="tests/**,**/*.Tests/**" ` + /d:sonar.scanner.scanAll=false +if ($LASTEXITCODE -ne 0) { throw "sonarscanner begin failed ($LASTEXITCODE)" } + +Write-Host "dotnet build..." -ForegroundColor Cyan +dotnet build $solution --configuration Release +if ($LASTEXITCODE -ne 0) { throw "build failed ($LASTEXITCODE)" } + +Write-Host "dotnet test (with coverage)..." -ForegroundColor Cyan +dotnet test $solution ` + --configuration Release --no-build ` + --settings coverlet.runsettings ` + --collect "XPlat Code Coverage" +# don't throw on test failures — we still want the analysis to upload +if ($LASTEXITCODE -ne 0) { Write-Warning "some tests failed (exit $LASTEXITCODE); continuing so issues still upload" } + +Write-Host "sonarscanner end..." -ForegroundColor Cyan +dotnet sonarscanner end /d:sonar.token=$env:SONAR_TOKEN +if ($LASTEXITCODE -ne 0) { throw "sonarscanner end failed ($LASTEXITCODE)" } + +Write-Host "Done. Open $sonarHost/dashboard?id=$projectKey" -ForegroundColor Green