diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 index d3b7348..28af2cc 100644 --- a/scripts/start-local.ps1 +++ b/scripts/start-local.ps1 @@ -65,6 +65,11 @@ $ErrorActionPreference = 'Stop' $RepoRoot = Split-Path -Parent $PSScriptRoot $StateDir = Join-Path $RepoRoot '.local' $PidFile = Join-Path $StateDir 'pids.json' + +# Whether this run is starting from an empty database. Set below, and read by +# Start-Backend, which must refuse to reuse a backend that is still serving the +# previous one. See #257. +$script:DatabaseIsNew = $false # Which database this run uses. Everything downstream reads these four, so the # choice is made once here rather than branched at each use. if ($E2eDb) { @@ -184,14 +189,22 @@ function Start-Database { Write-Step 'Removing the existing database (-Fresh)' docker rm -f $Container *>$null Write-Good 'removed' + $script:DatabaseIsNew = $true } + # The e2e container stores its data on tmpfs, so it comes up empty whether it + # is created or merely restarted. There is no case where it carries anything + # over, which is the point of it (#186) — and it means a backend from a + # previous run is always stale against it. + if ($E2eDb) { $script:DatabaseIsNew = $true } + $existing = (docker ps -a --filter "name=^/$Container$" --format '{{.Names}}') if ($existing -eq $Container) { Write-Step "Reusing the database container" docker start $Container *>$null } else { + $script:DatabaseIsNew = $true Write-Step "Creating the database container on port $DbPort" # tmpfs for the e2e database only: its contents are worthless the # moment the run ends, and storing nothing is what makes it start empty @@ -269,6 +282,41 @@ function Start-Backend { finally { Pop-Location } if (Test-Listening -Port $ApiPort) { + # Leaving a stranger's backend alone is only safe when the database has + # not just changed underneath it. When it has, that process is serving a + # database it no longer owns, and its in-memory state describes rows that + # no longer exist. + # + # That is not theoretical. The rate limiter keys on customer id and keeps + # its buckets in memory for an hour; a recreated database restarts ids at + # 1, so a new customer inherits a previous run's spent allowance. It + # reproduced exactly the intermittent resend-verification failure in #257 + # — three refusal toasts where one was expected — and the control (same + # id restart, restarted backend) passed. Whatever else is stale, a + # process serving the wrong database gives wrong answers confidently, + # which is the worst kind. + # + # So this refuses rather than continuing, and says what to do. A run that + # stops loudly is recoverable; one that quietly tests the wrong thing is + # what cost two wrong measurements in this project already. + $tracked = Get-TrackedProcesses + $ours = $tracked.ContainsKey('backend') -and + (Get-Process -Id $tracked['backend'] -ErrorAction SilentlyContinue) + + if ($script:DatabaseIsNew -and -not $ours) { + throw @" +Something is already listening on $ApiPort, and this run just created a new database. + +That process is serving the previous database. Its in-memory state — rate-limit +buckets keyed on customer id, most obviously — describes rows that no longer +exist, and recycled ids will inherit them (#257). + +Stop it and run this again: + + .\scripts\start-local.ps1 -Stop +"@ + } + Write-Note "something is already listening on $ApiPort; leaving it alone" return } diff --git a/scripts/summarize-sonar.js b/scripts/summarize-sonar.js index ac3faae..e7bc99f 100644 --- a/scripts/summarize-sonar.js +++ b/scripts/summarize-sonar.js @@ -1,5 +1,6 @@ #!/usr/bin/env node -// Prints the project's SonarQube measures into the job log. +// Prints the project's SonarQube measures, its failing quality gate conditions, +// its open issues and its unreviewed security hotspots into the job log. // // This exists because the numbers are otherwise unreachable. The server is // SonarQube 9.9 Community: it has no Bearer auth, so the official MCP cannot @@ -183,12 +184,96 @@ async function main() { const gate = await get( `${base}/api/qualitygates/project_status?projectKey=${encodeURIComponent(PROJECT)}` ); - const gateStatus = parse(gate.body)?.projectStatus?.status; - lines.push('', `Quality gate ${gateStatus ?? 'unknown'}`, ''); + const projectStatus = parse(gate.body)?.projectStatus; + lines.push('', `Quality gate ${projectStatus?.status ?? 'unknown'}`); + + // Which conditions failed, not just that the gate did. "ERROR" on its own + // sends a reader to the dashboard, which is the thing this script exists to + // avoid needing. + const failed = (projectStatus?.conditions ?? []).filter( + (c) => c.status !== 'OK' && c.status !== 'NO_VALUE' + ); + if (failed.length > 0) { + lines.push('', 'Failing gate conditions:'); + for (const c of failed) { + lines.push( + ` ${c.metricKey}: ${c.actualValue} (${c.comparator} threshold ${c.errorThreshold})` + ); + } + } + + lines.push(...(await describeIssues(base))); + lines.push(...(await describeHotspots(base))); + lines.push(''); console.log(lines.join('\n')); } +/** + * The open maintainability issues, named. + * + * A count is enough to notice debt and useless for clearing it. #181 assumed + * the five smells were the five eslint-plugin-sonarjs warnings, because the + * counts matched — they were fixed, and the count stayed five, so they were a + * different five. Nothing short of the list settles that. + * + * Capped rather than paged: past a couple of dozen the answer is not "read the + * list" anyway, and an unbounded fetch on every CI run is a cost with no reader. + */ +async function describeIssues(base) { + const res = await get( + `${base}/api/issues/search?componentKeys=${encodeURIComponent(PROJECT)}` + + `&resolved=false&types=CODE_SMELL,BUG,VULNERABILITY&ps=25&s=SEVERITY&asc=false` + ); + if (res.status !== 200) return ['', `Open issues: could not fetch (status ${res.status}).`]; + + const json = parse(res.body); + const issues = json?.issues; + if (!Array.isArray(issues)) return ['', 'Open issues: unexpected response shape.']; + if (issues.length === 0) return ['', 'Open issues: none.']; + + const total = json.total ?? issues.length; + const out = ['', `Open issues (${issues.length} of ${total}):`]; + for (const issue of issues) { + // The component is "projectKey:path"; the key is noise in a log line. + const where = String(issue.component ?? '').split(':').slice(1).join(':') || issue.component; + const at = issue.line ? `:${issue.line}` : ''; + out.push(` [${issue.severity}] ${where}${at}`); + out.push(` ${issue.rule} — ${issue.message}`); + out.push(` effort ${issue.effort ?? issue.debt ?? 'n/a'}`); + } + return out; +} + +/** + * Security hotspots awaiting review. + * + * A hotspot is not a defect — it is a place the scanner wants a human to say + * whether the surrounding code is safe. That review happens in the dashboard + * and cannot be done from here, so the point of listing them is to say what is + * waiting and where, rather than to fix anything. + */ +async function describeHotspots(base) { + const res = await get( + `${base}/api/hotspots/search?projectKey=${encodeURIComponent(PROJECT)}` + + `&status=TO_REVIEW&ps=25` + ); + if (res.status !== 200) return ['', `Security hotspots: could not fetch (status ${res.status}).`]; + + const hotspots = parse(res.body)?.hotspots; + if (!Array.isArray(hotspots)) return ['', 'Security hotspots: unexpected response shape.']; + if (hotspots.length === 0) return ['', 'Security hotspots awaiting review: none.']; + + const out = ['', `Security hotspots awaiting review (${hotspots.length}):`]; + for (const h of hotspots) { + const where = String(h.component ?? '').split(':').slice(1).join(':') || h.component; + const at = h.line ? `:${h.line}` : ''; + out.push(` ${where}${at}`); + out.push(` ${h.ruleKey ?? h.securityCategory} — ${h.message}`); + } + return out; +} + main().catch((err) => { // Reported rather than thrown, for the reason in the header: this step must // not be able to fail the job.