karajan-code 1.49.0 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Reviewer stage logic + fetchReviewDiff.
3
+ * Extracted from iteration-stages.js for maintainability.
4
+ */
5
+
6
+ import { addCheckpoint, markSessionStatus, saveSession } from "../../session-store.js";
7
+ import { generateDiff } from "../../review/diff-generator.js";
8
+ import { validateReviewResult } from "../../review/schema.js";
9
+ import { filterReviewScope, buildDeferredContext } from "../../review/scope-filter.js";
10
+ import { emitProgress, makeEvent } from "../../utils/events.js";
11
+ import { runReviewerWithFallback } from "../reviewer-fallback.js";
12
+ import { invokeSolomon } from "../solomon-escalation.js";
13
+ import { detectRateLimit } from "../../utils/rate-limit-detector.js";
14
+ import { createStallDetector } from "../../utils/stall-detector.js";
15
+
16
+ function categorizeIssues(issues) {
17
+ const categories = { security: 0, correctness: 0, tests: 0, style: 0, other: 0 };
18
+ const securityKw = /inject|xss|csrf|secret|credential|auth|vulnerab|exploit/i;
19
+ const styleKw = /naming|name|rename|style|format|indent|spacing|convention|cosmetic|readability|comment|jsdoc|whitespace/i;
20
+ const testKw = /test|coverage|assert|spec|mock/i;
21
+
22
+ for (const issue of issues || []) {
23
+ const desc = issue.description || "";
24
+ const sev = (issue.severity || "").toLowerCase();
25
+ if (sev === "critical" || securityKw.test(desc)) categories.security++;
26
+ else if (testKw.test(desc)) categories.tests++;
27
+ else if (sev === "low" || sev === "minor" || styleKw.test(desc)) categories.style++;
28
+ else categories.correctness++;
29
+ }
30
+ return categories;
31
+ }
32
+
33
+ function buildReviewHistory(session) {
34
+ return (session.checkpoints || [])
35
+ .filter(cp => cp.stage === "reviewer")
36
+ .map(cp => ({ iteration: cp.iteration, note: cp.note || "" }));
37
+ }
38
+
39
+ async function handleReviewerStalledSolomon({ review, repeatCounts, repeatState, config, logger, emitter, eventBase, session, iteration, task, askQuestion, budgetSummary, repeatDetector }) {
40
+ const logPrefix = repeatState.stalled
41
+ ? `Reviewer stalled (${repeatCounts.reviewer} repeats)`
42
+ : `Reviewer rejected (first rejection)`;
43
+ logger.warn(`${logPrefix}. Invoking Solomon mediation.`);
44
+ emitProgress(
45
+ emitter,
46
+ makeEvent("solomon:escalate", { ...eventBase, stage: "reviewer" }, {
47
+ message: `${logPrefix} — Solomon mediating`,
48
+ detail: { repeats: repeatCounts.reviewer || 1, reason: repeatState.reason || "first_rejection" }
49
+ })
50
+ );
51
+
52
+ const solomonResult = await invokeSolomon({
53
+ config, logger, emitter, eventBase, stage: "reviewer", askQuestion, session, iteration,
54
+ conflict: {
55
+ stage: "reviewer",
56
+ task,
57
+ iterationCount: repeatCounts.reviewer || 1,
58
+ maxIterations: config.session?.fail_fast_repeats ?? 2,
59
+ isFirstRejection: !repeatState.stalled,
60
+ isRepeat: repeatState.stalled,
61
+ stalledReason: repeatState.reason || "first_rejection",
62
+ blockingIssues: review.blocking_issues,
63
+ issueCategories: categorizeIssues(review.blocking_issues),
64
+ history: [
65
+ ...buildReviewHistory(session),
66
+ { agent: "reviewer", feedback: review.blocking_issues.map(x => x.description).join("; ") }
67
+ ]
68
+ }
69
+ });
70
+
71
+ if (solomonResult.action === "approve") {
72
+ logger.info("Solomon overrode reviewer — approving code");
73
+ emitProgress(emitter, makeEvent("solomon:override", { ...eventBase, stage: "solomon" }, {
74
+ message: "Solomon overrode reviewer rejection — code approved",
75
+ detail: { ruling: solomonResult.ruling?.result?.ruling }
76
+ }));
77
+ return { review: { ...review, approved: true, solomon_override: true }, solomonApproved: true };
78
+ }
79
+ if (solomonResult.action === "pause") {
80
+ await markSessionStatus(session, "stalled");
81
+ return { review, stalled: true, stalledResult: { paused: true, sessionId: session.id, question: solomonResult.question, context: "reviewer_stalled" } };
82
+ }
83
+ if (solomonResult.action === "continue") {
84
+ repeatDetector.reviewer = { lastHash: null, repeatCount: 0 };
85
+ if (solomonResult.humanGuidance) {
86
+ session.last_reviewer_feedback = `Solomon/user guidance: ${solomonResult.humanGuidance}`;
87
+ await saveSession(session);
88
+ }
89
+ return { review };
90
+ }
91
+ if (solomonResult.action === "subtask") {
92
+ return { review, stalled: true, stalledResult: { paused: true, sessionId: session.id, subtask: solomonResult.subtask, context: "reviewer_subtask" } };
93
+ }
94
+
95
+ // Fallback
96
+ const message = `Manual intervention required: reviewer issues repeated ${repeatCounts.reviewer || 1} times.`;
97
+ await markSessionStatus(session, "stalled");
98
+ emitProgress(
99
+ emitter,
100
+ makeEvent("session:end", { ...eventBase, stage: "reviewer" }, {
101
+ status: "stalled",
102
+ message,
103
+ detail: { reason: repeatState.reason, repeats: repeatCounts.reviewer, budget: budgetSummary() }
104
+ })
105
+ );
106
+ return { review, stalled: true, stalledResult: { approved: false, sessionId: session.id, reason: "stalled" } };
107
+ }
108
+
109
+ async function handleReviewerRejection({ review, repeatDetector, config, logger, emitter, eventBase, session, iteration, task, askQuestion, budgetSummary }) {
110
+ repeatDetector.addIteration([], review.blocking_issues);
111
+ const repeatState = repeatDetector.isStalled();
112
+
113
+ const solomonEnabled = Boolean(config.pipeline?.solomon?.enabled);
114
+
115
+ // When Solomon is disabled, only act on stalls (legacy behavior)
116
+ if (!solomonEnabled) {
117
+ if (!repeatState.stalled) return null;
118
+ const repeatCounts = repeatDetector.getRepeatCounts();
119
+ return handleReviewerStalledSolomon({
120
+ review, repeatCounts, repeatState, config, logger, emitter,
121
+ eventBase, session, iteration, task, askQuestion,
122
+ budgetSummary, repeatDetector
123
+ });
124
+ }
125
+
126
+ // Solomon evaluates EVERY rejection
127
+ const repeatCounts = repeatDetector.getRepeatCounts();
128
+ logger.info(`Reviewer rejected — Solomon evaluating ${review.blocking_issues.length} blocking issue(s)`);
129
+ emitProgress(emitter, makeEvent("solomon:evaluate", { ...eventBase, stage: "solomon" }, {
130
+ message: `Solomon evaluating reviewer rejection`,
131
+ detail: { blockingCount: review.blocking_issues.length, isRepeat: repeatState.stalled }
132
+ }));
133
+
134
+ return handleReviewerStalledSolomon({
135
+ review, repeatCounts, repeatState, config, logger, emitter,
136
+ eventBase, session, iteration, task, askQuestion,
137
+ budgetSummary, repeatDetector
138
+ });
139
+ }
140
+
141
+ export async function fetchReviewDiff(session, logger) {
142
+ if (session.becaria_pr_number) {
143
+ const { getPrDiff } = await import("../../becaria/pr-diff.js");
144
+ const diff = await getPrDiff(session.becaria_pr_number);
145
+ logger.info(`Reviewer reading PR diff #${session.becaria_pr_number}`);
146
+ return diff;
147
+ }
148
+ return generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true });
149
+ }
150
+
151
+ export async function runReviewerStage({ reviewerRole, config, logger, emitter, eventBase, session, trackBudget, iteration, reviewRules, task, repeatDetector, budgetSummary, askQuestion }) {
152
+ logger.setContext({ iteration, stage: "reviewer" });
153
+ emitProgress(
154
+ emitter,
155
+ makeEvent("reviewer:start", { ...eventBase, stage: "reviewer" }, {
156
+ message: `Reviewer (${reviewerRole.provider}) running`,
157
+ detail: { reviewer: reviewerRole.provider, provider: reviewerRole.provider, executorType: "agent" }
158
+ })
159
+ );
160
+
161
+ let diff;
162
+ try {
163
+ diff = await fetchReviewDiff(session, logger);
164
+ } catch (err) {
165
+ logger.warn(`Review diff generation failed: ${err.message}`);
166
+ return { approved: false, blocking_issues: [{ description: `Diff generation failed: ${err.message}` }], non_blocking_suggestions: [], summary: `Reviewer failed: cannot generate diff — ${err.message}`, confidence: 0 };
167
+ }
168
+
169
+ // Injection guard: scan diff before sending to AI reviewer
170
+ const { scanDiff } = await import("../../utils/injection-guard.js");
171
+ const guardResult = scanDiff(diff);
172
+ if (!guardResult.clean) {
173
+ logger.warn(`Injection guard: ${guardResult.summary}`);
174
+ emitProgress(emitter, makeEvent("guard:injection", { ...eventBase, stage: "reviewer" }, {
175
+ message: `Injection guard blocked review: ${guardResult.summary}`,
176
+ detail: { findings: guardResult.findings, summary: guardResult.summary }
177
+ }));
178
+ return {
179
+ approved: false,
180
+ blocking_issues: guardResult.findings.map((f) => ({
181
+ id: `INJECTION_${f.type.toUpperCase()}`,
182
+ severity: "critical",
183
+ description: `Potential prompt injection (${f.type}): ${f.snippet}`,
184
+ line: f.line,
185
+ })),
186
+ non_blocking_suggestions: [],
187
+ summary: `Review blocked by injection guard: ${guardResult.summary}`,
188
+ confidence: 1,
189
+ };
190
+ }
191
+
192
+ const reviewerOnOutput = ({ stream, line }) => {
193
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "reviewer" }, {
194
+ message: line,
195
+ detail: { stream, agent: reviewerRole.provider }
196
+ }));
197
+ };
198
+ const reviewerStall = createStallDetector({
199
+ onOutput: reviewerOnOutput, emitter, eventBase, stage: "reviewer", provider: reviewerRole.provider
200
+ });
201
+ const reviewerStart = Date.now();
202
+ let reviewerExec;
203
+ try {
204
+ reviewerExec = await runReviewerWithFallback({
205
+ reviewerName: reviewerRole.provider,
206
+ config,
207
+ logger,
208
+ emitter,
209
+ reviewInput: { task, diff, reviewRules, onOutput: reviewerStall.onOutput },
210
+ session,
211
+ iteration,
212
+ onAttemptResult: ({ reviewer, result }) => {
213
+ trackBudget({ role: "reviewer", provider: reviewer, model: reviewerRole.model, result, duration_ms: Date.now() - reviewerStart });
214
+ }
215
+ });
216
+ } catch (err) {
217
+ logger.warn(`Reviewer threw: ${err.message}`);
218
+ reviewerExec = { execResult: { ok: false, error: err.message }, attempts: [{ reviewer: reviewerRole.provider, result: { ok: false, error: err.message } }] };
219
+ } finally {
220
+ reviewerStall.stop();
221
+ }
222
+
223
+ if (!reviewerExec.execResult?.ok) {
224
+ const lastAttempt = reviewerExec.attempts.at(-1);
225
+ const details =
226
+ lastAttempt?.result?.error ||
227
+ lastAttempt?.execResult?.summary ||
228
+ `reviewer=${lastAttempt?.reviewer || "unknown"}`;
229
+
230
+ const rateLimitCheck = detectRateLimit({
231
+ stderr: lastAttempt?.result?.error || "",
232
+ stdout: lastAttempt?.result?.output || ""
233
+ });
234
+
235
+ if (rateLimitCheck.isRateLimit) {
236
+ // Enter standby instead of pausing
237
+ return {
238
+ action: "standby",
239
+ standbyInfo: {
240
+ agent: reviewerRole.provider,
241
+ cooldownMs: rateLimitCheck.cooldownMs || (rateLimitCheck.isProviderOutage ? 30000 : null),
242
+ cooldownUntil: rateLimitCheck.cooldownUntil,
243
+ message: rateLimitCheck.message,
244
+ isProviderOutage: rateLimitCheck.isProviderOutage || false
245
+ }
246
+ };
247
+ }
248
+
249
+ await markSessionStatus(session, "failed");
250
+ emitProgress(
251
+ emitter,
252
+ makeEvent("reviewer:end", { ...eventBase, stage: "reviewer" }, {
253
+ status: "fail",
254
+ message: `Reviewer failed: ${details}`,
255
+ detail: { provider: reviewerRole.provider, executorType: "agent" }
256
+ })
257
+ );
258
+ throw new Error(`Reviewer failed: ${details}`);
259
+ }
260
+
261
+ const reviewResult = reviewerExec.execResult.result;
262
+ let review;
263
+ try {
264
+ review = validateReviewResult({
265
+ approved: reviewResult.approved,
266
+ blocking_issues: reviewResult.blocking_issues || [],
267
+ non_blocking_suggestions: reviewResult.non_blocking_suggestions || [],
268
+ summary: reviewResult.raw_summary || "",
269
+ confidence: reviewResult.confidence ?? 0
270
+ });
271
+ } catch (error_) {
272
+ logger.warn(`Reviewer output validation failed: ${error_.message}`);
273
+ review = {
274
+ approved: false,
275
+ blocking_issues: [{
276
+ id: "PARSE_ERROR",
277
+ severity: "high",
278
+ description: `Reviewer output could not be parsed: ${error_.message}`
279
+ }],
280
+ non_blocking_suggestions: [],
281
+ summary: `Parse error: ${error_.message}`,
282
+ confidence: 0
283
+ };
284
+ }
285
+ // --- Scope filter: auto-defer out-of-scope blocking issues ---
286
+ const { review: filteredReview, demoted, deferred, allDemoted } = filterReviewScope(review, diff);
287
+ review = filteredReview;
288
+
289
+ if (demoted.length > 0) {
290
+ logger.info(`Scope filter: deferred ${demoted.length} out-of-scope issue(s)${allDemoted ? " — auto-approved" : ""}`);
291
+
292
+ // Accumulate deferred issues in session for tracking
293
+ if (!session.deferred_issues) session.deferred_issues = [];
294
+ session.deferred_issues.push(...deferred);
295
+ await saveSession(session);
296
+
297
+ emitProgress(
298
+ emitter,
299
+ makeEvent("reviewer:scope_filter", { ...eventBase, stage: "reviewer" }, {
300
+ message: `Scope filter deferred ${demoted.length} out-of-scope issue(s)`,
301
+ detail: {
302
+ demotedCount: demoted.length,
303
+ autoApproved: allDemoted,
304
+ totalDeferred: session.deferred_issues.length,
305
+ deferred: deferred.map(d => ({ file: d.file, id: d.id, description: d.description }))
306
+ }
307
+ })
308
+ );
309
+ await addCheckpoint(session, {
310
+ stage: "reviewer-scope-filter",
311
+ iteration,
312
+ demoted_count: demoted.length,
313
+ auto_approved: allDemoted,
314
+ total_deferred: session.deferred_issues.length
315
+ });
316
+ }
317
+
318
+ await addCheckpoint(session, {
319
+ stage: "reviewer",
320
+ iteration,
321
+ approved: review.approved,
322
+ blocking_issues: review.blocking_issues.length,
323
+ provider: reviewerRole.provider,
324
+ model: reviewerRole.model || null
325
+ });
326
+
327
+ emitProgress(
328
+ emitter,
329
+ makeEvent("reviewer:end", { ...eventBase, stage: "reviewer" }, {
330
+ status: review.approved ? "ok" : "fail",
331
+ message: review.approved ? "Review approved" : `Review rejected (${review.blocking_issues.length} blocking)`,
332
+ detail: {
333
+ approved: review.approved,
334
+ blockingCount: review.blocking_issues.length,
335
+ issues: review.blocking_issues.map(
336
+ (x) => `${x.id || "ISSUE"}: ${x.description || "Missing description"}`
337
+ ),
338
+ provider: reviewerRole.provider,
339
+ executorType: "agent"
340
+ }
341
+ })
342
+ );
343
+
344
+ if (!review.approved) {
345
+ const rejectionResult = await handleReviewerRejection({ review, repeatDetector, config, logger, emitter, eventBase, session, iteration, task, askQuestion, budgetSummary });
346
+ if (rejectionResult) return rejectionResult;
347
+ }
348
+
349
+ return { review };
350
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Sonar stage logic + sonar retry logic.
3
+ * Extracted from iteration-stages.js for maintainability.
4
+ */
5
+
6
+ import { SonarRole } from "../../roles/sonar-role.js";
7
+ import { addCheckpoint, markSessionStatus, saveSession } from "../../session-store.js";
8
+ import { emitProgress, makeEvent } from "../../utils/events.js";
9
+ import { invokeSolomon } from "../solomon-escalation.js";
10
+ import { sonarUp, isSonarReachable } from "../../sonar/manager.js";
11
+
12
+ async function handleSonarStalled({ repeatDetector, logger, emitter, eventBase, session, budgetSummary }) {
13
+ const repeatCounts = repeatDetector.getRepeatCounts();
14
+ const message = `No progress: SonarQube issues repeated ${repeatCounts.sonar} times.`;
15
+ logger.warn(message);
16
+ await markSessionStatus(session, "stalled");
17
+ const repeatState = repeatDetector.isStalled();
18
+ emitProgress(
19
+ emitter,
20
+ makeEvent("session:end", { ...eventBase, stage: "sonar" }, {
21
+ status: "stalled",
22
+ message,
23
+ detail: { reason: repeatState.reason, repeats: repeatCounts.sonar, budget: budgetSummary() }
24
+ })
25
+ );
26
+ return { action: "stalled", result: { approved: false, sessionId: session.id, reason: "stalled" } };
27
+ }
28
+
29
+ async function handleSonarRetryLimit({ config, logger, emitter, eventBase, session, iteration, askQuestion, task, maxSonarRetries, sonarResult }) {
30
+ emitProgress(
31
+ emitter,
32
+ makeEvent("solomon:escalate", { ...eventBase, stage: "sonar" }, {
33
+ message: `Sonar sub-loop limit reached (${session.sonar_retry_count}/${maxSonarRetries})`,
34
+ detail: { subloop: "sonar", retryCount: session.sonar_retry_count, limit: maxSonarRetries, gateStatus: sonarResult.gateStatus }
35
+ })
36
+ );
37
+
38
+ const solomonResult = await invokeSolomon({
39
+ config, logger, emitter, eventBase, stage: "sonar", askQuestion, session, iteration,
40
+ conflict: {
41
+ stage: "sonar",
42
+ task,
43
+ iterationCount: session.sonar_retry_count,
44
+ maxIterations: maxSonarRetries,
45
+ history: [{ agent: "sonar", feedback: session.last_sonar_summary }]
46
+ }
47
+ });
48
+
49
+ if (solomonResult.action === "pause") {
50
+ return { action: "pause", result: { paused: true, sessionId: session.id, question: solomonResult.question, context: "sonar_fail_fast" } };
51
+ }
52
+ if (solomonResult.action === "subtask") {
53
+ return { action: "pause", result: { paused: true, sessionId: session.id, subtask: solomonResult.subtask, context: "sonar_subtask" } };
54
+ }
55
+ if (solomonResult.action === "continue") {
56
+ if (solomonResult.humanGuidance) {
57
+ session.last_reviewer_feedback += `\nUser guidance: ${solomonResult.humanGuidance}`;
58
+ }
59
+ session.sonar_retry_count = 0;
60
+ await saveSession(session);
61
+ return { action: "continue" };
62
+ }
63
+
64
+ return null;
65
+ }
66
+
67
+ async function handleSonarBlocking({ sonarResult, config, logger, emitter, eventBase, session, iteration, repeatDetector, budgetSummary, askQuestion, task }) {
68
+ // If the ONLY quality gate failure is coverage, treat as non-blocking warning
69
+ if (sonarResult.conditions) {
70
+ const failedConditions = sonarResult.conditions.filter(c => c.status === "ERROR");
71
+ const onlyCoverage = failedConditions.length > 0 && failedConditions.every(c =>
72
+ c.metricKey === "new_coverage" || c.metricKey === "coverage"
73
+ );
74
+ if (onlyCoverage) {
75
+ logger.warn("Quality gate failed on coverage only — treating as advisory (code quality is clean)");
76
+ emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
77
+ status: "warn",
78
+ message: "Quality gate: coverage below threshold (advisory — code quality is clean)"
79
+ }));
80
+ session.last_reviewer_feedback = null;
81
+ return { action: "ok", stageResult: { gateStatus: "WARN_COVERAGE", advisory: true } };
82
+ }
83
+ }
84
+
85
+ repeatDetector.addIteration(sonarResult.issues, []);
86
+ const repeatState = repeatDetector.isStalled();
87
+ if (repeatState.stalled) {
88
+ return handleSonarStalled({ repeatDetector, logger, emitter, eventBase, session, budgetSummary });
89
+ }
90
+
91
+ session.last_reviewer_feedback = `Sonar gate blocking (${sonarResult.gateStatus}). Resolve critical findings first.`;
92
+ session.sonar_retry_count = (session.sonar_retry_count || 0) + 1;
93
+ await saveSession(session);
94
+ const maxSonarRetries = config.session.max_sonar_retries ?? config.session.fail_fast_repeats;
95
+
96
+ if (session.sonar_retry_count >= maxSonarRetries) {
97
+ const result = await handleSonarRetryLimit({ config, logger, emitter, eventBase, session, iteration, askQuestion, task, maxSonarRetries, sonarResult });
98
+ if (result) return result;
99
+ }
100
+
101
+ return { action: "continue" };
102
+ }
103
+
104
+ export async function runSonarStage({ config, logger, emitter, eventBase, session, trackBudget, iteration, repeatDetector, budgetSummary, sonarState, askQuestion, task }) {
105
+ logger.setContext({ iteration, stage: "sonar" });
106
+ emitProgress(
107
+ emitter,
108
+ makeEvent("sonar:start", { ...eventBase, stage: "sonar" }, {
109
+ message: "SonarQube scanning",
110
+ detail: { provider: "sonarqube", executorType: "local" }
111
+ })
112
+ );
113
+
114
+ // Auto-manage SonarQube: ensure it is reachable before scanning
115
+ const sonarHost = config.sonarqube?.host || "http://localhost:9000";
116
+
117
+ if (!await isSonarReachable(sonarHost)) {
118
+ logger.info("SonarQube not reachable, attempting to start...");
119
+ emitProgress(emitter, makeEvent("sonar:start", { ...eventBase, stage: "sonar" }, { message: "Starting SonarQube Docker..." }));
120
+
121
+ const upResult = await sonarUp(sonarHost);
122
+ if (upResult.exitCode !== 0) {
123
+ logger.warn(`SonarQube could not be started: ${upResult.stderr || upResult.stdout}`);
124
+ emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
125
+ status: "skip",
126
+ message: "SonarQube not available — install Docker and run 'kj sonar start' to enable static analysis"
127
+ }));
128
+ return { action: "ok", stageResult: { gateStatus: "SKIPPED", reason: "SonarQube not available" } };
129
+ }
130
+
131
+ let ready = false;
132
+ for (let attempt = 0; attempt < 12; attempt++) {
133
+ await new Promise(r => setTimeout(r, 5000));
134
+ if (await isSonarReachable(sonarHost)) { ready = true; break; }
135
+ }
136
+ if (!ready) {
137
+ logger.warn("SonarQube started but not ready after 60s");
138
+ emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
139
+ status: "skip", message: "SonarQube started but not ready — will retry next iteration"
140
+ }));
141
+ return { action: "ok", stageResult: { gateStatus: "PENDING", reason: "SonarQube starting up" } };
142
+ }
143
+ logger.info("SonarQube is ready");
144
+ }
145
+
146
+ const sonarRole = new SonarRole({ config, logger, emitter });
147
+ await sonarRole.init({ iteration });
148
+ const sonarStart = Date.now();
149
+ let sonarOutput;
150
+ try {
151
+ sonarOutput = await sonarRole.run();
152
+ } catch (err) {
153
+ logger.warn(`Sonar threw: ${err.message}`);
154
+ sonarOutput = { ok: false, result: { error: err.message }, summary: `Sonar error: ${err.message}` };
155
+ }
156
+ trackBudget({ role: "sonar", provider: "sonar", result: sonarOutput, duration_ms: Date.now() - sonarStart });
157
+ const sonarResult = sonarOutput.result;
158
+
159
+ if (!sonarResult.gateStatus && sonarResult.error) {
160
+ const isTokenError = /unable to resolve sonar token/i.test(sonarResult.error);
161
+ const errorMessage = isTokenError
162
+ ? "SonarQube is running but no authentication token is configured. Fix: run 'kj init' to configure it, or set KJ_SONAR_TOKEN env var, or add sonarqube.token to ~/.karajan/kj.config.yml."
163
+ : `Sonar scan failed: ${sonarResult.error}`;
164
+
165
+ await markSessionStatus(session, "failed");
166
+ emitProgress(
167
+ emitter,
168
+ makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
169
+ status: "fail",
170
+ message: errorMessage
171
+ })
172
+ );
173
+ throw new Error(errorMessage);
174
+ }
175
+
176
+ session.last_sonar_summary = sonarOutput.summary;
177
+ if (typeof sonarResult.openIssuesTotal === "number") {
178
+ if (sonarState.issuesInitial === null) {
179
+ sonarState.issuesInitial = sonarResult.openIssuesTotal;
180
+ }
181
+ sonarState.issuesFinal = sonarResult.openIssuesTotal;
182
+ }
183
+ await addCheckpoint(session, {
184
+ stage: "sonar",
185
+ iteration,
186
+ project_key: sonarResult.projectKey,
187
+ quality_gate: sonarResult.gateStatus,
188
+ open_issues: sonarResult.openIssuesTotal,
189
+ provider: "sonar",
190
+ model: null
191
+ });
192
+
193
+ emitProgress(
194
+ emitter,
195
+ makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
196
+ status: sonarResult.blocking ? "fail" : "ok",
197
+ message: `Quality gate: ${sonarResult.gateStatus}`,
198
+ detail: { projectKey: sonarResult.projectKey, gateStatus: sonarResult.gateStatus, openIssues: sonarResult.openIssuesTotal, provider: "sonarqube", executorType: "local" }
199
+ })
200
+ );
201
+
202
+ if (sonarResult.blocking) {
203
+ return handleSonarBlocking({ sonarResult, config, logger, emitter, eventBase, session, iteration, repeatDetector, budgetSummary, askQuestion, task });
204
+ }
205
+
206
+ // Sonar passed — reset retry counter
207
+ session.sonar_retry_count = 0;
208
+ const issuesInitial = sonarState.issuesInitial ?? sonarResult.openIssuesTotal ?? 0;
209
+ const issuesFinal = sonarState.issuesFinal ?? sonarResult.openIssuesTotal ?? 0;
210
+ const stageResult = {
211
+ gateStatus: sonarResult.gateStatus,
212
+ openIssues: sonarResult.openIssuesTotal,
213
+ issuesInitial,
214
+ issuesFinal,
215
+ issuesResolved: Math.max(issuesInitial - issuesFinal, 0)
216
+ };
217
+
218
+ return { action: "ok", stageResult };
219
+ }
220
+
221
+ export async function runSonarCloudStage({ config, logger, emitter, eventBase, session, trackBudget, iteration }) {
222
+ logger.setContext({ iteration, stage: "sonarcloud" });
223
+ emitProgress(
224
+ emitter,
225
+ makeEvent("sonarcloud:start", { ...eventBase, stage: "sonarcloud" }, {
226
+ message: "SonarCloud scanning",
227
+ detail: { provider: "sonarcloud", executorType: "local" }
228
+ })
229
+ );
230
+
231
+ const { runSonarCloudScan } = await import("../../sonar/cloud-scanner.js");
232
+ const scanStart = Date.now();
233
+ let result;
234
+ try {
235
+ result = await runSonarCloudScan(config);
236
+ } catch (err) {
237
+ logger.warn(`SonarCloud threw: ${err.message}`);
238
+ result = { ok: false, error: err.message };
239
+ }
240
+ trackBudget({ role: "sonarcloud", provider: "sonarcloud", result: { ok: result.ok }, duration_ms: Date.now() - scanStart });
241
+
242
+ await addCheckpoint(session, {
243
+ stage: "sonarcloud",
244
+ iteration,
245
+ project_key: result.projectKey,
246
+ exitCode: result.exitCode,
247
+ provider: "sonarcloud",
248
+ model: null
249
+ });
250
+
251
+ const status = result.ok ? "ok" : "warn";
252
+ const message = result.ok
253
+ ? `SonarCloud scan passed (project: ${result.projectKey})`
254
+ : `SonarCloud scan issue: ${(result.stderr || "").slice(0, 200)}`;
255
+
256
+ emitProgress(
257
+ emitter,
258
+ makeEvent("sonarcloud:end", { ...eventBase, stage: "sonarcloud" }, {
259
+ status,
260
+ message,
261
+ detail: { projectKey: result.projectKey, exitCode: result.exitCode, provider: "sonarcloud", executorType: "local" }
262
+ })
263
+ );
264
+
265
+ // SonarCloud is advisory — never blocks the pipeline
266
+ return { action: "ok", stageResult: { ok: result.ok, projectKey: result.projectKey, message } };
267
+ }