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