tts-voice-creator-clone-and.../scripts/sonar_triage.mjs
mARTin-B78 98a87077d9 feat(qa): sonar triage loop (dry-run default) and multica agent hookup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 02:47:21 +02:00

181 lines
11 KiB
JavaScript

#!/usr/bin/env node
// Local SonarQube triage loop. Dry-run by default. See architecture/sonar-triage.md.
import { readFileSync, writeFileSync, mkdtempSync, existsSync } 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 function groupIssues(items) {
const g = new Map();
for (const i of items) { const k = `${i.rule}\u0000${i.path}`; (g.get(k) || g.set(k, []).get(k)).push(i); }
return [...g.values()].map((is) => {
const keys = is.map((i) => i.key).sort();
return { rule: is[0].rule, path: is[0].path, issues: is, keys, effort: is.reduce((s, i) => s + parseEffort(i.effort), 0),
title: `[sonar] ${is[0].rule}: ${(is[0].message || '').slice(0, 60).replace(/\s+/g, ' ')} in ${is[0].path}`.slice(0, 200) };
});
}
export function issueBody(g) {
return [
`<!-- sonar-keys: ${g.keys.join(',')} -->`,
`Rule: [${g.rule}](${HOST}/coding_rules?open=${encodeURIComponent(g.rule)}&rule_key=${encodeURIComponent(g.rule)})`,
`File: \`${g.path}\` | Estimated effort: ${g.effort} min`,
'', '| Sonar key | Line | Message |', '|---|---|---|',
...g.issues.map((i) => `| ${i.key} | ${i.line ?? '-'} | ${(i.message || '').replace(/\|/g, '\\|')} |`),
'', '## Suggested approach',
'Read the rule description linked above, fix each listed line 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 = /<!-- sonar-keys: ([^>]*?) -->/.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 });
function ghIssues(groups, apply, cap, log) {
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']);
const bodies = list.status === 0 ? JSON.parse(list.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', 'agent-fix']) 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', 'agent-fix', '--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: a multica issue (assigned to the agent) that points at the GitHub issue.
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;
}
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} ${g.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): <summary>\` ending with the line "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>". 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];
else throw new Error(`unknown flag ${f}`);
}
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 groups = groupIssues([...res.medium, ...res.large]);
log(`\nMedium+ groups (rule, file): ${groups.length}`);
ghIssues(groups, a.apply, a.cap, log);
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); });
}