From 2328852aab500bb79cc4d289d5b77395ce8e17f5 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 6 Oct 2026 04:19:27 +0200 Subject: [PATCH] feat(qa): refactor lane for cognitive-complexity findings (own webhook, one issue per function) Co-Authored-By: Claude Sonnet 5.5 --- architecture/sonar-refactorer.md | 7 +++++++ scripts/sonar_triage.mjs | 35 ++++++++++++++++++++++---------- tests/sonar_triage.test.mjs | 12 +++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/architecture/sonar-refactorer.md b/architecture/sonar-refactorer.md index 53fd473..d42d567 100644 --- a/architecture/sonar-refactorer.md +++ b/architecture/sonar-refactorer.md @@ -16,6 +16,13 @@ ## Lanes Only one agent may edit the tree at a time. The batch driver runs the `sonar-fixer` lane first; the refactor lane is started only when no `sonar` issue is open and the tracked tree is clean. Each lane's issues carry a label: `agent-fix` (fixer) or `agent-refactor` (refactorer). +## Running the lane +``` +npm run sonar:triage -- --all --lane refactor # dry-run: lists the next 3 functions +npm run sonar:triage -- --all --lane refactor --apply # files 3 issues, fires the refactor webhook +``` +`--lane refactor` selects only `python:S3776` and `javascript:S3776`, groups one issue per function, labels them `sonar` and `agent-refactor`, defaults the cap to 3 and posts to the URL in `~/.config/multica-webhook-refactor` (or `MULTICA_WEBHOOK_REFACTOR_URL`). Start it only when no `sonar` issue is open and the tracked tree is clean. + ## Issue shape for this lane One GitHub issue per function (one Sonar location), not per rule: a complexity refactor is reviewed and reverted per function. Title `[sonar] : complexity N -> 15 in :`. Cap: 3 issues per batch. diff --git a/scripts/sonar_triage.mjs b/scripts/sonar_triage.mjs index 46e43c3..f2a80d4 100644 --- a/scripts/sonar_triage.mjs +++ b/scripts/sonar_triage.mjs @@ -74,6 +74,7 @@ export function triage(issues, cfg) { 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) { @@ -88,7 +89,9 @@ export function groupIssues(items, size = MAX_LOCATIONS) { 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: `[sonar] ${rule}: ${(is[0].message || '').slice(0, 60).replace(/\s+/g, ' ')} in ${files} file(s)${tag}`.slice(0, 200) }); + 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. @@ -126,8 +129,10 @@ export function hasMarker(existingBodies, keys) { 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 = join(homedir(), '.config', 'multica-webhook')) { - const u = (env.MULTICA_WEBHOOK_URL || (existsSync(file) ? readFileSync(file, 'utf8') : '')).trim(); +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 : ''; } @@ -142,7 +147,8 @@ export function postWebhook(url, payload) { return { ok: code >= 200 && code < 300, code }; } -function ghIssues(groups, apply, cap, log) { +function ghIssues(groups, apply, cap, log, lane = 'fix') { + 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) @@ -151,16 +157,16 @@ function ghIssues(groups, apply, cap, log) { 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']); + 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', 'agent-fix', '--body-file', '-'], issueBody(g)); + 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(); + const hook = webhookUrl(process.env, undefined, lane); 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`); @@ -197,7 +203,8 @@ export async function main(argv, deps = {}) { 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 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 === '--exclude-rules') a.exclude = (argv[++i] || '').split(',').filter(Boolean); else throw new Error(`unknown flag ${f}`); } @@ -215,9 +222,15 @@ export async function main(argv, deps = {}) { } if (a.json) writeFileSync(a.json, JSON.stringify(res, null, 2)); if (deps.noActions) return res; - const groups = groupIssues([...res.medium, ...res.large]).filter((g) => !(a.exclude || []).includes(g.rule)); - log(`\nMedium+ groups (per rule, max 40 locations): ${groups.length}`); - ghIssues(groups, a.apply, a.cap, log); + 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); if (a.fixSmall) { if (a.apply) fixSmall(res.small, log); else log('fix-small needs --apply (dry-run).'); } return res; } diff --git a/tests/sonar_triage.test.mjs b/tests/sonar_triage.test.mjs index 2319f02..32d6f63 100644 --- a/tests/sonar_triage.test.mjs +++ b/tests/sonar_triage.test.mjs @@ -98,3 +98,15 @@ test('webhookUrl accepts only https URLs and never invents one', async () => { assert.equal(webhookUrl({ MULTICA_WEBHOOK_URL: 'https://a b"c' }, '/nonexistent'), ''); assert.equal(webhookUrl({}, '/nonexistent'), ''); }); + +test('refactor lane: own webhook, one group per function with location in the title', async () => { + const { webhookUrl, groupIssues, REFACTOR_RULES } = await import('../scripts/sonar_triage.mjs'); + assert.equal(webhookUrl({ MULTICA_WEBHOOK_REFACTOR_URL: 'https://example.org/r' }, '/nonexistent', 'refactor'), 'https://example.org/r'); + assert.equal(webhookUrl({ MULTICA_WEBHOOK_URL: 'https://example.org/f' }, '/nonexistent', 'refactor'), ''); + assert.equal(webhookUrl({ MULTICA_WEBHOOK_URL: 'https://example.org/f' }, '/nonexistent'), 'https://example.org/f'); + assert.ok(REFACTOR_RULES.has('python:S3776') && REFACTOR_RULES.has('javascript:S3776')); + const mkc = (path, line) => ({ key: path + line, rule: 'python:S3776', severity: 'CRITICAL', type: 'CODE_SMELL', effort: '10min', path, line, message: 'Refactor this function to reduce its Cognitive Complexity from 23 to the 15 allowed.' }); + const g = groupIssues([mkc('core/a.py', 10), mkc('core/a.py', 99)], 1); + assert.equal(g.length, 2); + assert.equal(g[0].title, '[sonar] python:S3776: complexity 23 -> 15 in core/a.py:10'); +});