Files
redefined-designs/scripts/summarize-sonar.js
T
bermudalambandClaude Opus 5 fe28c97e0f
Linting / lint (pull_request) Successful in 2m7s
SonarQube Analysis / sonarqube (pull_request) Successful in 25m21s
chore(sonar): remove the rejected Tinqer spike, clear the lint debt, and report measures in CI (#261)
The standing cleanup, three features behind. Four changes.

Report the measures in CI. This is the one that matters, because the rest was only findable by reading the tree. SonarQube here is 9.9 Community: no Bearer auth, so the official MCP cannot connect, and the host is a CI secret, so hotspots, duplication, debt and coverage existed only on a dashboard — which made "reduce the debt" an instruction nobody could act on without a browser open beside them. scripts/summarize-sonar.js queries the measures API with the secrets the workflow already holds and prints the result into the job log. The scanner masks the URL and token; measures are not secret.

It polls the compute task before reading. The workflow does not set sonar.qualitygate.wait, so the scan step returns once the report is uploaded and the server computes measures afterwards — reading immediately would return the previous analysis, indistinguishable from this one and quietly wrong. When it cannot confirm, it says so in the output rather than presenting stale numbers as current. It is deliberately not guarded with continue-on-error: it exits 0 on every path, and guarding it would oblige it to appear in the final gate, whose job is to fail the build.

Remove the Tinqer spike. #216 evaluated Drizzle against Tinqer and rejected Tinqer, and its closing comment said the throwaway src/db-tinqer/ probe must not reach main. The whole spike commit was merged, so it did. The probe is 71 lines imported by nothing, and @tinqerjs/tinqer, @tinqerjs/pg-promise-adapter and pg-promise were dependencies for a library nobody chose. The condition_note column that warning also named did not reach main.

Clear the lint debt, both projects now at zero warnings from six and two. One of these was a real defect rather than tidiness: the third catch block in shippingAddresses.ts rolled back and returned 500 while discarding the error, so a failed default-address change left nothing behind to say why — the two catch blocks above it in the same file already logged, and this one had simply been missed. The Express namespace augmentation is a false positive and is disabled with the reason written beside it, because an interface that must merge into one Express declares inside a namespace has no ES module spelling.

Dedupe the extension map. backfillImageReencode.ts kept its own .jpg/.png/.webp table whose comment named uploadTypes.ts as the source of truth, directly above duplicating it. That file rewrites stored images, so the two disagreeing would silently skip files it should re-encode.

src/db-drizzle/ deliberately stays. #217 is open to promote exactly those files properly, with tablesFilter and the sql.param() array rule; deleting them here would be doing #217 badly in the wrong issue. Only their unused-symbol warnings are fixed, and if drizzle-kit pull regenerates schema.ts the table warning returns — worth #217 knowing.

Hotspots and coverage are untouched because both numbers are still invisible. They are the next pass, once the step above has printed them once.

Closes #261

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 13:30:29 -05:00

197 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
// Prints the project's SonarQube measures 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
// connect, and the host URL is a CI secret, so there is no way to query it from
// a developer's machine either. The measures lived only on a dashboard, which
// made "reduce the debt" an instruction nobody could act on without a browser
// open beside them. See #261.
//
// The scanner masks SONAR_HOST_URL and SONAR_TOKEN in the log. Measures are not
// secret, so they print fine — only the URL they came from is redacted.
//
// Authentication is HTTP Basic with the token as the username and an empty
// password. That is the documented scheme for 9.9; the `Authorization: Bearer`
// form was added later and returns 401 here.
//
// Runs with `if: always()` and deliberately WITHOUT `continue-on-error`. A
// cleanup report that can break a build is worse than no report, so instead of
// being guarded this script simply never fails: every path exits 0 and problems
// are described rather than thrown. Guarding it would also oblige it to be named
// in the workflow's final gate — workflowGate.test.ts asserts that pairing — and
// that gate exists to fail the job, which is the opposite of what is wanted for
// a report. Same shape as the summarize-jest.js steps beside it.
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const HOST = process.env.SONAR_HOST_URL;
const TOKEN = process.env.SONAR_TOKEN;
const PROJECT = process.argv[2] || 'redefined-designs';
// Ordered so the four things the cleanup issue asks about come first, rather
// than alphabetically. Each row is [metric key, label, formatter].
const METRICS = [
['security_hotspots', 'Security hotspots', (v) => v],
['vulnerabilities', 'Vulnerabilities', (v) => v],
['bugs', 'Bugs', (v) => v],
['duplicated_lines_density', 'Duplicated lines', (v) => `${v}%`],
['duplicated_blocks', 'Duplicated blocks', (v) => v],
['sqale_index', 'Technical debt', formatDebt],
['code_smells', 'Code smells', (v) => v],
['coverage', 'Coverage', (v) => `${v}%`],
['line_coverage', 'Line coverage', (v) => `${v}%`],
['branch_coverage', 'Branch coverage', (v) => `${v}%`],
['uncovered_lines', 'Uncovered lines', (v) => v],
['ncloc', 'Lines of code', (v) => v],
['new_coverage', 'Coverage on new code', (v) => `${v}%`],
['new_duplicated_lines_density', 'Duplication on new code', (v) => `${v}%`],
['new_code_smells', 'Code smells on new code', (v) => v]
];
/** sqale_index is minutes. Days here are Sonar's 8-hour working days. */
function formatDebt(minutes) {
const total = Number(minutes);
if (!Number.isFinite(total)) return String(minutes);
const days = Math.floor(total / (8 * 60));
const hours = Math.floor((total % (8 * 60)) / 60);
const mins = total % 60;
return `${total} min (${days}d ${hours}h ${mins}m)`;
}
function get(url) {
return new Promise((resolve) => {
const client = url.startsWith('https:') ? https : http;
const request = client.get(
url,
// The empty password is deliberate: 9.9 expects `token:` rather than a
// password, and omitting the colon sends the token as a username with no
// password field at all, which it rejects.
{ auth: `${TOKEN}:`, timeout: 30000 },
(res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => resolve({ status: res.statusCode, body }));
}
);
request.on('timeout', () => {
request.destroy();
resolve({ status: 0, body: 'timed out after 30s' });
});
request.on('error', (err) => resolve({ status: 0, body: err.message }));
});
}
function parse(body) {
try {
return JSON.parse(body);
} catch {
return null;
}
}
/**
* Waits for the server to finish processing this run's analysis.
*
* The workflow does not set `sonar.qualitygate.wait`, so the scan step returns
* as soon as the report is uploaded and the server computes measures in the
* background. Querying immediately therefore returns the *previous* analysis,
* which would be indistinguishable from the current one and quietly wrong — the
* worst kind of number to publish into a cleanup report.
*
* The scanner writes the task id to .scannerwork/report-task.txt. Polling it
* costs a few seconds and makes the difference between "these are this commit's
* numbers" and "these are probably this commit's numbers".
*/
async function waitForAnalysis(base) {
const reportPath = path.join(process.cwd(), '.scannerwork', 'report-task.txt');
if (!fs.existsSync(reportPath)) {
return 'no report-task.txt — reporting the last completed analysis, which may predate this commit';
}
const taskId = /^ceTaskId=(.+)$/m.exec(fs.readFileSync(reportPath, 'utf8'))?.[1]?.trim();
if (!taskId) {
return 'no ceTaskId in report-task.txt — reporting the last completed analysis';
}
// Twenty attempts at three seconds. A minute is far longer than this project
// takes to process and short enough not to stretch the job noticeably.
for (let attempt = 0; attempt < 20; attempt++) {
const res = await get(`${base}/api/ce/task?id=${encodeURIComponent(taskId)}`);
const status = parse(res.body)?.task?.status;
if (status === 'SUCCESS') return null;
if (status === 'FAILED' || status === 'CANCELED') {
return `analysis ${status} on the server — reporting the last completed analysis instead`;
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
return 'analysis still processing after 60s — reporting the last completed analysis';
}
async function main() {
if (!HOST || !TOKEN) {
// Not an error. A fork or a run without the secrets configured reaches this
// legitimately, and saying so is more useful than a stack trace.
console.log('SonarQube measures: SONAR_HOST_URL or SONAR_TOKEN not set, skipping.');
return;
}
const base = HOST.replace(/\/+$/, '');
const staleness = await waitForAnalysis(base);
const keys = METRICS.map(([key]) => key).join(',');
const measuresUrl = `${base}/api/measures/component?component=${encodeURIComponent(PROJECT)}&metricKeys=${keys}`;
const res = await get(measuresUrl);
if (res.status !== 200) {
console.log(`SonarQube measures: request failed (status ${res.status}). ${res.body.slice(0, 300)}`);
return;
}
const json = parse(res.body);
const measures = json?.component?.measures;
if (!Array.isArray(measures)) {
console.log('SonarQube measures: unexpected response shape.');
console.log(res.body.slice(0, 300));
return;
}
// Keyed by metric so the display order above is what prints, rather than
// whatever order the API happened to return.
const byKey = new Map(measures.map((m) => [m.metric, m]));
const lines = ['', '=== SonarQube measures ===', ''];
// Said plainly rather than omitted. A reader acting on these numbers needs to
// know whether they describe this commit.
if (staleness) lines.push(`NOTE: ${staleness}`, '');
for (const [key, label, format] of METRICS) {
const measure = byKey.get(key);
// A metric can be legitimately absent — new-code metrics do not exist until
// a second analysis, and branch_coverage is missing when nothing branches.
if (!measure) {
lines.push(`${label.padEnd(26)} —`);
continue;
}
const value = measure.period?.value ?? measure.value;
lines.push(`${label.padEnd(26)} ${format(value)}`);
}
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'}`, '');
console.log(lines.join('\n'));
}
main().catch((err) => {
// Reported rather than thrown, for the reason in the header: this step must
// not be able to fail the job.
console.log(`SonarQube measures: ${err instanceof Error ? err.message : String(err)}`);
});