#!/usr/bin/env node // Local SonarQube triage loop. Dry-run by default. See architecture/sonar-triage.md. import { readFileSync, writeFileSync, mkdtempSync, existsSync, rmSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const HOST = process.env.SONAR_HOST_URL || 'https://sq.martin-bierschenk.de'; const PROJECT = process.env.SONAR_PROJECT_KEY || 'mARTin-B78_tts-voice-creator-clone-and-design-2_2b78a834-b2ae-4292-9363-d41c1254cb7a'; const MULTICA_AGENT = process.env.MULTICA_AGENT || 'sonar-fixer'; const MULTICA_PROJECT = process.env.MULTICA_PROJECT || 'fe4b0ad0-111c-4801-a8af-2301b37f3ee6'; const SMALL_DIRS = ['core/', 'routes/', 'static/js/', 'scripts/']; const RISKY_PATH = /auth|crypt|async|thread|sql|secret|token|password/i; const RISKY_MSG = /\b(await|async|thread|lock|sql|crypt\w*|hash\w*|password|token|secret)\b/i; export function parseEffort(s) { if (!s) return 0; const m = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)min)?$/.exec(s); return m ? (+m[1] || 0) * 480 + (+m[2] || 0) * 60 + (+m[3] || 0) : 0; } export const pathOf = (component) => component.slice(component.indexOf(':') + 1); export function isSkipped(issue, path, skip) { return skip.find((s) => (!s.rule || s.rule === issue.rule) && (!s.file || new RegExp(s.file).test(path)) && (!s.message || new RegExp(s.message, 'i').test(issue.message || ''))); } // -> {cls: small|medium|large|skipped, why} export function classify(issue, { allow, skip }) { const path = pathOf(issue.component); const sk = isSkipped(issue, path, skip); if (sk) return { cls: 'skipped', why: sk.reason || 'skip-list' }; const effort = parseEffort(issue.effort); const big = effort > 60 || ['BLOCKER', 'CRITICAL'].includes(issue.severity); const never = issue.type === 'VULNERABILITY' ? 'vulnerability' : !SMALL_DIRS.some((d) => path.startsWith(d)) ? 'outside core/routes/static/js/scripts' : RISKY_PATH.test(path) || RISKY_MSG.test(issue.message || '') ? 'touches auth/crypto/async/threading/SQL' : null; const sevOk = ['INFO', 'MINOR'].includes(issue.severity) || (issue.severity === 'MAJOR' && effort <= 5); if (!never && sevOk && allow.has(issue.rule)) return { cls: 'small', why: 'allowlisted mechanical rule' }; return { cls: big ? 'large' : 'medium', why: never || (allow.has(issue.rule) ? 'severity/effort above small' : 'rule not allowlisted') }; } export function loadConfig(dir = __dirname_()) { const rd = (f) => JSON.parse(readFileSync(join(dir, f), 'utf8')); return { allow: new Set(Object.keys(rd('sonar_triage.rules.json').rules)), skip: rd('sonar_triage.skip.json').skip }; } function __dirname_() { return dirname(fileURLToPath(import.meta.url)); } export async function fetchIssues({ fetchFn = fetch, token, newCode = true, max = Infinity }) { const auth = 'Basic ' + Buffer.from(`${token}:`).toString('base64'); // token as username, empty password const out = []; for (let p = 1; out.length < max; p++) { const q = new URLSearchParams({ componentKeys: PROJECT, statuses: 'OPEN,CONFIRMED,REOPENED', types: 'BUG,VULNERABILITY,CODE_SMELL', ps: '500', p: String(p) }); if (newCode) q.set('inNewCodePeriod', 'true'); const r = await fetchFn(`${HOST}/api/issues/search?${q}`, { headers: { Authorization: auth }, signal: AbortSignal.timeout(30000) }); if (!r.ok) throw new Error(`SonarQube HTTP ${r.status}`); const j = await r.json(); out.push(...j.issues); if (p * 500 >= j.paging.total) break; } return out.slice(0, max); } export function triage(issues, cfg) { const res = { small: [], medium: [], large: [], skipped: [] }; for (const i of issues) { const c = classify(i, cfg); res[c.cls].push({ ...i, path: pathOf(i.component), why: c.why }); } return res; } const countBy = (xs, f) => xs.reduce((m, x) => (m[f(x)] = (m[f(x)] || 0) + 1, m), {}); export const MAX_LOCATIONS = 40; export const REFACTOR_RULES = new Set(['python:S3776', 'javascript:S3776']); // cognitive complexity: own lane and agent // One group per rule; a rule with more than `size` locations is split into numbered parts. export function groupIssues(items, size = MAX_LOCATIONS) { const byRule = new Map(); for (const i of items) (byRule.get(i.rule) || byRule.set(i.rule, []).get(i.rule)).push(i); const out = []; for (const [rule, all] of byRule) { all.sort((a, b) => a.path.localeCompare(b.path) || (a.line ?? 0) - (b.line ?? 0) || a.key.localeCompare(b.key)); const parts = Math.ceil(all.length / size); for (let p = 0; p < parts; p++) { const is = all.slice(p * size, (p + 1) * size); const files = new Set(is.map((i) => i.path)).size; const tag = parts > 1 ? ` (part ${p + 1}/${parts})` : ''; out.push({ rule, part: p + 1, parts, issues: is, files, keys: is.map((i) => i.key).sort(), effort: is.reduce((s, i) => s + parseEffort(i.effort), 0), title: (size === 1 ? `[sonar] ${rule}: complexity ${(/from (\d+)/.exec(is[0].message || '') || [])[1] || '?'} -> 15 in ${is[0].path}:${is[0].line ?? '?'}` : `[sonar] ${rule}: ${(is[0].message || '').slice(0, 60).replace(/\s+/g, ' ')} in ${files} file(s)${tag}`).slice(0, 200) }); } } // Most valuable first, so a capped run files bugs before style findings. const TYPE_RANK = { BUG: 0, VULNERABILITY: 1, CODE_SMELL: 2 }; const SEV_RANK = { BLOCKER: 0, CRITICAL: 1, MAJOR: 2, MINOR: 3, INFO: 4 }; const NOISY = new Set(['javascript:S3358', 'python:S1192', 'python:S8415', 'javascript:S5850', 'python:S5850', 'javascript:S2189']); // triage: low value or probable false positive, file last const rank = (g) => [NOISY.has(g.rule) ? 1 : 0, Math.min(...g.issues.map((i) => TYPE_RANK[i.type] ?? 3)), Math.min(...g.issues.map((i) => SEV_RANK[i.severity] ?? 5))]; out.sort((a, b) => { const x = rank(a), y = rank(b); return x[0] - y[0] || x[1] - y[1] || x[2] - y[2] || a.rule.localeCompare(b.rule) || a.part - b.part; }); return out; } export function issueBody(g) { const shown = g.issues.slice(0, MAX_LOCATIONS); const more = g.issues.length - shown.length; return [ ``, `Rule: [${g.rule}](${HOST}/coding_rules?open=${encodeURIComponent(g.rule)}&rule_key=${encodeURIComponent(g.rule)})`, `${g.parts > 1 ? `Part ${g.part}/${g.parts} | ` : ''}${g.issues.length} location(s) in ${g.files} file(s) | Estimated effort: ${g.effort} min`, '', '| File | Line | Sonar key | Message |', '|---|---|---|---|', ...shown.map((i) => `| \`${i.path}\` | ${i.line ?? '-'} | ${i.key} | ${(i.message || '').replace(/\|/g, '\\|')} |`), ...(more > 0 ? ['', `and ${more} more (keys are in the marker above).`] : []), '', '## Suggested approach', 'Read the rule description linked above, fix each listed location at the root cause, keep behaviour unchanged unless the rule demands otherwise.', '', '## Acceptance', '- A re-scan clears the listed Sonar keys.', '- Follow CLAUDE.md and PROJECT_MAP.md; `node scripts/check-frontend.mjs` and the relevant unit tests pass (tests mock the API, import from tests/_guard.js).', '- LOCAL commit only (`fix(sonar): ...`), never push; stop and comment if a design decision is needed.', ].join('\n'); } export function hasMarker(existingBodies, keys) { return existingBodies.some((b) => { const m = //.exec(b || ''); return m && keys.some((k) => m[1].split(',').includes(k)); }); } const run = (cmd, args, input) => spawnSync(cmd, args, { input, encoding: 'utf8', cwd: ROOT, timeout: 600000 }); // The webhook URL carries its own secret: read it from env/file, pass it to curl on stdin only (never argv, never logged). export function webhookUrl(env = process.env, file, lane = 'fix') { const refactor = lane === 'refactor'; file = file || join(homedir(), '.config', refactor ? 'multica-webhook-refactor' : 'multica-webhook'); const u = (env[refactor ? 'MULTICA_WEBHOOK_REFACTOR_URL' : 'MULTICA_WEBHOOK_URL'] || (existsSync(file) ? readFileSync(file, 'utf8') : '')).trim(); return /^https:\/\/[^\s"]+$/.test(u) ? u : ''; } export function postWebhook(url, payload) { const dir = mkdtempSync(join(tmpdir(), 'sonar-hook-')); const f = join(dir, 'payload.json'); writeFileSync(f, JSON.stringify(payload), { mode: 0o600 }); const cfg = `url = "${url}"\nrequest = "POST"\nheader = "Content-Type: application/json"\ndata-binary = "@${f}"\nsilent\nmax-time = 30\noutput = "/dev/null"\nwrite-out = "%{http_code}"\n`; const r = spawnSync('curl', ['-K', '-'], { input: cfg, encoding: 'utf8', timeout: 40000 }); try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir cleanup is best effort */ } const code = Number((r.stdout || '').trim()) || 0; return { ok: code >= 200 && code < 300, code }; } function ghIssues(groups, apply, cap, log, lane = 'fix', noHook = false) { const agentLabel = lane === 'refactor' ? 'agent-refactor' : 'agent-fix'; if (run('gh', ['auth', 'status']).status !== 0) { log('gh unauthenticated: skipping GitHub issues (run `gh auth login`).'); return []; } const list = run('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--limit', '300', '--json', 'number,body']); // closed fixed-local issues also suppress (Sonar keys stay open until a re-scan) const fixed = run('gh', ['issue', 'list', '--state', 'closed', '--label', 'fixed-local', '--limit', '300', '--json', 'number,body']); const bodies = [list, fixed].flatMap((l) => (l.status === 0 ? JSON.parse(l.stdout).map((x) => x.body) : [])); const todo = groups.filter((g) => !hasMarker(bodies, g.keys)); log(`GitHub: ${groups.length - todo.length} duplicate(s) skipped, ${Math.min(todo.length, cap)} of ${todo.length} to create (cap ${cap}).`); const done = []; if (apply) for (const l of ['sonar', agentLabel]) run('gh', ['label', 'create', l, '--color', '5319e7', '--description', 'SonarQube triage']); for (const g of todo.slice(0, cap)) { if (!apply) { log(` [dry-run] would create: ${g.title}`); continue; } const r = run('gh', ['issue', 'create', '--title', g.title, '--label', 'sonar', '--label', agentLabel, '--body-file', '-'], issueBody(g)); if (r.status !== 0) { log(` gh create failed for ${g.title}`); continue; } const url = r.stdout.trim().split('\n').pop(); log(` created ${url}`); // Mechanism 1: a multica autopilot webhook (URL is a secret: env MULTICA_WEBHOOK_URL or ~/.config/multica-webhook). // Mechanism 2 (fallback): the multica CLI, which refuses to run in an agent context. const hook = webhookUrl(process.env, undefined, lane); if (noHook) { log(' webhook skipped (--no-webhook): use --drain to deliver it'); } else if (hook) { const w = postWebhook(hook, { source: 'sonar-triage', rule: g.rule, title: g.title, github_issue_url: url, files: g.files, sonar_keys: g.keys, effort_min: g.effort, body: issueBody(g) }); log(w.ok ? ` multica autopilot fired (HTTP ${w.code})` : ` multica webhook failed (HTTP ${w.code || 'none'}): assign the issue manually`); } else { const m = run('multica', ['issue', 'create', '--title', g.title, '--project', MULTICA_PROJECT, '--assignee', MULTICA_AGENT, '--description-stdin'], `GitHub issue: ${url}\n\n${issueBody(g)}`); log(m.status === 0 ? ` multica issue assigned to ${MULTICA_AGENT}` : ` multica hookup failed (create it manually): ${(m.stderr || '').split('\n')[0]}`); } done.push(url); } return done; } // Work through the open agent issues ONE AT A TIME: fire the webhook, wait for the agent to answer (comment, label or close), // resend at most `resends` times when nothing arrives within `timeoutMin`, then stop and report. A webhook 200 alone proves nothing: // multica has been seen to accept a call without creating an issue. export async function drain(log, { lane = 'fix', timeoutMin = 60, resends = 1, pollSec = 60, deps = {} } = {}) { const sh = deps.run || run; const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); const hook = deps.webhookUrl ? deps.webhookUrl() : webhookUrl(process.env, undefined, lane); const post = deps.postWebhook || postWebhook; if (!hook) { log('no webhook configured for this lane'); return { done: [], stalled: null }; } const label = lane === 'refactor' ? 'agent-refactor' : 'agent-fix'; const listed = sh('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--label', label, '--limit', '100', '--json', 'number,title,body,url']); if (listed.status !== 0) { log('gh issue list failed'); return { done: [], stalled: null }; } const todo = JSON.parse(listed.stdout).sort((x, y) => x.number - y.number); const done = []; const view = (n) => JSON.parse(sh('gh', ['issue', 'view', String(n), '--json', 'state,comments,labels']).stdout || '{}'); for (const it of todo) { const base = (view(it.number).comments || []).length; let sent = 0, answered = false; while (!answered && sent <= resends) { const w = post(hook, { source: 'sonar-triage', title: it.title, github_issue_url: it.url, body: it.body }); sent++; log(`#${it.number}: webhook ${w.ok ? 'HTTP ' + w.code : 'FAILED'} (send ${sent}/${resends + 1})`); const deadline = Date.now() + timeoutMin * 60000; while (Date.now() < deadline) { await sleep(pollSec * 1000); const v = view(it.number); if (v.state === 'CLOSED' || (v.comments || []).length > base || (v.labels || []).some((l) => l.name === 'fixed-local')) { answered = true; break; } } if (!answered) log(`#${it.number}: no answer within ${timeoutMin} min`); } if (!answered) { log(`#${it.number}: stalled after ${sent} send(s); stopping so nothing else queues behind it.`); return { done, stalled: it.number }; } log(`#${it.number}: answered`); done.push(it.number); } return { done, stalled: null }; } function fixSmall(small, log) { const dirty = () => run('git', ['status', '--porcelain', '--untracked-files=no']).stdout.trim(); if (dirty()) { log('fix-small: refusing, tracked working tree is dirty.'); return; } const head = run('git', ['rev-parse', 'HEAD']).stdout.trim(); const dir = mkdtempSync(join(tmpdir(), 'sonar-small-')); const task = join(dir, 'task.md'); const lines = groupIssues(small).flatMap((g) => g.issues.map((i) => `- ${i.rule} ${i.path}:${i.line ?? '?'} (${i.key}): ${i.message}`)); writeFileSync(task, `Fix ONLY these SonarQube issues, changing behaviour nowhere. Follow CLAUDE.md.\n${lines.join('\n')}\n\nThen run \`node scripts/check-frontend.mjs\` and \`node --test tests/*.test.mjs\`. If they pass, make exactly ONE local commit \`fix(sonar): \` ending with the line "Co-Authored-By: Claude Sonnet 5.5 ". Never push. Touch no other file; if an issue needs a design decision, skip it and say so.\n`); const tools = ['Read', 'Edit', 'Grep', 'Glob', 'Bash(node scripts/check-frontend.mjs)', 'Bash(node --test:*)', 'Bash(git add:*)', 'Bash(git commit:*)', 'Bash(git status:*)', 'Bash(git diff:*)']; const r = run('claude', ['-p', '--allowedTools', ...tools], `Read the task file ${task} and do exactly what it says.`); if (r.status !== 0) { log(`fix-small: headless run failed; task file kept at ${task}. Manual: claude -p "do ${task}"`); return; } const n = run('git', ['rev-list', '--count', `${head}..HEAD`]).stdout.trim(); log(`fix-small: ${n} new local commit(s); tree ${dirty() ? 'DIRTY (review!)' : 'clean'}. Not pushed.`); } export async function main(argv, deps = {}) { const log = deps.log || console.log; const a = { apply: false, newCode: true, max: Infinity, json: null, fixSmall: false, list: false, cap: 10 }; for (let i = 0; i < argv.length; i++) { const f = argv[i]; if (f === '--apply') a.apply = true; else if (f === '--dry-run') a.apply = false; else if (f === '--all') a.newCode = false; else if (f === '--since-new-code') a.newCode = true; else if (f === '--fix-small') a.fixSmall = true; else if (f === '--list-rules') a.list = true; else if (f === '--json') a.json = argv[++i]; else if (f === '--max-issues') a.max = +argv[++i]; else if (f === '--cap') { a.cap = +argv[++i]; a.capSet = true; } else if (f === '--lane') { a.lane = argv[++i]; if (!['fix', 'refactor'].includes(a.lane)) throw new Error('--lane must be fix or refactor'); } else if (f === '--drain') a.drain = true; else if (f === '--no-webhook') a.noHook = true; else if (f === '--timeout') a.timeout = +argv[++i]; else if (f === '--resends') a.resends = +argv[++i]; else if (f === '--exclude-rules') a.exclude = (argv[++i] || '').split(',').filter(Boolean); else throw new Error(`unknown flag ${f}`); } if (a.drain) { if (!a.apply) { log('--drain needs --apply (it fires webhooks).'); return {}; } return drain(log, { lane: a.lane || 'fix', timeoutMin: a.timeout || 60, resends: a.resends ?? 1, deps: deps.drainDeps }); } const cfg = deps.cfg || loadConfig(); if (a.list) { log('Allowlisted (small) rules:'); for (const r of cfg.allow) log(` ${r}`); log('Skip-list:'); for (const s of cfg.skip) log(` ${s.rule || '*'} ${s.file || ''} ${s.message || ''} -- ${s.reason}`); return; } const tokFile = join(homedir(), '.config', 'sonar-token'); const token = deps.token || process.env.SONAR_TOKEN || (existsSync(tokFile) ? readFileSync(tokFile, 'utf8').trim() : ''); if (!token) throw new Error('no token: put it in ~/.config/sonar-token (or SONAR_TOKEN)'); const issues = await fetchIssues({ fetchFn: deps.fetchFn, token, newCode: a.newCode, max: a.max }); const res = triage(issues, cfg); log(`Mode: ${a.apply ? 'APPLY' : 'dry-run'} | scope: ${a.newCode ? 'new code' : 'all'} | issues: ${issues.length}`); for (const c of ['small', 'medium', 'large', 'skipped']) { log(`\n${c.toUpperCase()} (${res[c].length})`); for (const [r, n] of Object.entries(countBy(res[c], (i) => i.rule)).sort((x, y) => y[1] - x[1])) log(` ${String(n).padStart(4)} ${r}`); } if (a.json) writeFileSync(a.json, JSON.stringify(res, null, 2)); if (deps.noActions) return res; const lane = a.lane || 'fix'; // refactor lane: cognitive-complexity findings only, one GitHub issue per function (reviewed and reverted per function), cap 3. const all = [...res.medium, ...res.large]; const groups = (lane === 'refactor' ? groupIssues(all.filter((i) => REFACTOR_RULES.has(i.rule)), 1) : groupIssues(all)).filter((g) => !(a.exclude || []).includes(g.rule)); const cap = lane === 'refactor' && !a.capSet ? 3 : a.cap; log(`\n${lane === 'refactor' ? 'Refactor' : 'Medium+'} groups (${lane === 'refactor' ? 'one per function' : 'per rule, max 40 locations'}): ${groups.length}`); ghIssues(groups, a.apply, cap, log, lane, !!a.noHook); if (a.fixSmall) { if (a.apply) fixSmall(res.small, log); else log('fix-small needs --apply (dry-run).'); } return res; } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main(process.argv.slice(2)).catch((e) => { console.error(`sonar_triage: ${e.message}`); process.exit(1); }); }